SQL 7회차 : 서브쿼리(Scalar/Inline/EXISTS) — IN vs EXISTS, 상관 서브쿼리 기초, 실습 2종
한눈에 보는 요약
서브쿼리는 “쿼리 안에 들어가는 또 다른 쿼리”이며, SQL 실전에서 조건(필터), 계산(값 1개), 집계 결과 결합(테이블처럼 조인), 존재 여부 확인(있냐/없냐)을 깔끔하게 처리할 때 자주 사용합니다. 이번 회차는 서브쿼리를 세 가지 형태로 정리합니다. 첫째, 값 1개를 반환하는 Scalar Subquery(스칼라 서브쿼리). 둘째, FROM 절에서 테이블처럼 쓰는 Inline View(인라인 뷰/파생 테이블). 셋째, “존재 여부”만 확인하는 EXISTS입니다.
그리고 많은 분들이 헷갈려하는 IN vs EXISTS 차이를 개념 중심으로 정리한 뒤, 상관 서브쿼리(외부 쿼리의 값을 서브쿼리가 참조하는 형태)를 기초부터 다룹니다. 마지막으로 실습은 두 가지로 마무리합니다. “최고가 상품”을 서브쿼리로 구하고, “최근 주문한 고객”을 IN/EXISTS/상관 서브쿼리 관점으로 각각 풀어보며 감각을 굳힙니다.
목차
- 핵심 포인트
- 실습용 테이블 구조
- 서브쿼리 3종 한 장으로 정리
- 1) Scalar Subquery(스칼라)
- 2) Inline View(인라인 뷰/파생 테이블)
- 3) EXISTS(존재 확인)
- 4) IN vs EXISTS 차이(개념)
- 5) 상관 서브쿼리 기초
- 실습 1: “최고가 상품”
- 실습 2: “최근 주문한 고객”
- 따라하기
- 요약 표
- 블로그 최적화 정보
핵심 포인트
- Scalar는 “값 1개”를 SELECT/WHERE에 끼워 넣는 방식입니다.
- Inline은 “결과를 테이블처럼” 만들어 FROM에서 조인/집계하는 방식입니다.
- EXISTS는 “행이 하나라도 존재하는지”만 보고 빠르게 true/false로 필터링합니다.
- IN은 “값이 집합 안에 포함되는지”를 비교하고, EXISTS는 “관계가 존재하는지”를 확인합니다.
- 상관 서브쿼리는 서브쿼리가 외부 테이블의 값을 참조하며, “각 행마다 조건을 재평가”하는 패턴으로 이해하면 됩니다.
실습용 테이블 구조
이번 실습은 아래처럼 매우 흔한 쇼핑몰 구조(상품/고객/주문/주문상세)를 가정합니다. 실제 환경의 컬럼명이 다르더라도, “어떤 테이블을 무엇으로 묶는지”가 핵심이므로 구조만 맞춰서 읽으시면 됩니다.
테이블 예시
-- 상품
-- products(product_id, product_name, price)
-- 고객
-- customers(customer_id, customer_name)
-- 주문
-- orders(order_id, customer_id, order_date)
-- 주문상세(주문에 어떤 상품이 몇 개 포함됐는지)
-- order_items(order_id, product_id, quantity, unit_price)
서브쿼리 3종 한 장으로 정리
초보자 단계에서 가장 빠른 정리법은 “서브쿼리를 어디에 두느냐”입니다. 위치가 곧 역할을 결정하는 경우가 많기 때문입니다.
- SELECT/WHERE에 놓는 값 1개 → Scalar Subquery
- FROM에 놓는 테이블(결과 집합) → Inline View(파생 테이블)
- WHERE에서 존재 여부만 확인 → EXISTS
이 틀만 잡히면 “이 문제는 값 1개가 필요한가?”, “집계 결과를 테이블로 붙여야 하나?”, “관계가 있냐/없냐만 보면 되나?”를 기준으로 정답이 빠르게 보입니다.
1) Scalar Subquery(스칼라 서브쿼리)
스칼라 서브쿼리는 결과가 단 하나의 값(1행 1열)이어야 합니다. 그래서 MAX, MIN, COUNT, AVG 같은 집계함수와 자주 같이 씁니다. 스칼라 서브쿼리가 유용한 상황은 “비교 기준이 되는 값 1개가 필요할 때”입니다.
대표 예시: 평균 가격보다 비싼 상품만 조회
SELECT p.product_id, p.product_name, p.price
FROM products p
WHERE p.price > (SELECT AVG(price) FROM products);
여기서 (SELECT AVG(price) ...)는 “평균 가격”이라는 값 1개를 반환해야 하며, 그래서 WHERE에서 비교 기준으로 자연스럽게 들어갑니다.
초보자가 자주 하는 실수
스칼라 서브쿼리인데 결과가 여러 행이 나오면 대부분 DB에서 오류가 납니다(“단일 행을 기대했는데 여러 행이 반환됨” 같은 메시지). 해결 방법은 “집계로 1개로 만들기”, “TOP 1/LIMIT 1로 1개만 고르기”, 또는 “원래 목적이 집합 비교라면 IN으로 바꾸기”입니다.
2) Inline View(인라인 뷰/파생 테이블)
인라인 뷰는 서브쿼리 결과를 FROM에 놓고 테이블처럼 다루는 방식입니다. 집계 결과를 원본 테이블과 결합하거나, 복잡한 계산을 단계적으로 쪼개고 싶을 때 특히 유용합니다. “먼저 요약 테이블을 만들고(서브쿼리), 그 요약 결과를 다시 조인한다”라고 생각하면 쉽습니다.
대표 예시: 고객별 마지막 주문일(집계)을 만들어 고객 테이블과 조인
SELECT c.customer_id, c.customer_name, lo.last_order_date
FROM customers c
JOIN (
SELECT o.customer_id, MAX(o.order_date) AS last_order_date
FROM orders o
GROUP BY o.customer_id
) lo
ON lo.customer_id = c.customer_id
ORDER BY lo.last_order_date DESC;
orders를 고객별로 GROUP BY 해서 “마지막 주문일 테이블(lo)”을 만든 다음, customers와 JOIN하는 구조입니다. 이 방식은 읽기도 쉽고, 결과 컬럼(마지막 주문일)을 다른 조건/정렬에 재사용하기도 좋습니다.
3) EXISTS(존재 확인)
EXISTS는 “서브쿼리 결과가 1행이라도 있으면 true”입니다. 즉, 서브쿼리가 어떤 값을 반환하든 관심이 없고, 오직 “있냐/없냐”만 봅니다. 그래서 EXISTS 내부에서는 관례적으로 SELECT 1 또는 SELECT *를 사용합니다(의미상 차이는 거의 없고, 핵심은 조건입니다).
대표 예시: 주문 이력이 있는 고객만 조회
SELECT c.customer_id, c.customer_name
FROM customers c
WHERE EXISTS (
SELECT 1
FROM orders o
WHERE o.customer_id = c.customer_id
);
이 쿼리는 고객 한 명씩 보면서 “orders에 이 고객의 주문이 존재하는가?”를 확인합니다. 한 건이라도 찾으면 true가 되며, 존재만 확인하므로 내부적으로는 일찍 멈출 수도 있다는 점이 EXISTS의 대표적인 특징으로 자주 설명됩니다(다만 실제 성능은 DB 옵티마이저가 재작성할 수 있어, 항상 단정짓기보다는 개념을 먼저 잡는 것이 좋습니다).
4) IN vs EXISTS 차이(개념)
둘 다 “서브쿼리와 함께 쓰는 필터”라는 점 때문에 자주 비교되지만, 관점이 다릅니다.
IN은 “왼쪽 값이 오른쪽 집합 안에 포함되는지”를 비교합니다. 즉, 값 비교(멤버십 테스트)입니다.
-- 주문한 적이 있는 고객 ID 집합 안에 포함되면 통과
SELECT c.customer_id, c.customer_name
FROM customers c
WHERE c.customer_id IN (SELECT o.customer_id FROM orders o);
EXISTS는 “관계가 존재하는지”를 확인합니다. 즉, 외부 행(고객)과 내부 행(주문)이 “연결되는 조건”이 하나라도 성립하면 통과입니다.
-- 이 고객에 해당하는 주문 행이 하나라도 존재하면 통과
SELECT c.customer_id, c.customer_name
FROM customers c
WHERE EXISTS (
SELECT 1 FROM orders o
WHERE o.customer_id = c.customer_id
);
초보자 관점에서의 실전 선택 기준
- “값 목록에 포함되면 된다”가 자연스러우면 IN이 읽기 쉬운 경우가 많습니다.
- “외부 행마다 매칭되는 내부 행이 존재하는지 확인”이면 EXISTS가 의도 표현이 더 명확한 경우가 많습니다.
- 상관 관계(outer 컬럼 참조)가 필요하면 EXISTS가 패턴적으로 더 자주 맞습니다.
반드시 알아야 하는 함정: NOT IN과 NULL
IN 자체보다 더 많이 사고가 나는 곳은 NOT IN입니다. 서브쿼리 결과에 NULL이 섞이면, NOT IN의 판단이 “UNKNOWN”으로 흐르면서 결과가 예상과 다르게(또는 전부 제외되는 것처럼) 나올 수 있습니다. 초보자 단계에서는 “~이 없는 것”을 찾을 때 NOT EXISTS를 더 안전한 기본값으로 두는 것을 권장합니다.
-- 주문이 '없는' 고객 찾기: NOT EXISTS 패턴(안전한 기본)
SELECT c.customer_id, c.customer_name
FROM customers c
WHERE NOT EXISTS (
SELECT 1 FROM orders o
WHERE o.customer_id = c.customer_id
);
5) 상관 서브쿼리 기초
상관 서브쿼리는 서브쿼리가 외부 쿼리의 컬럼을 참조하는 형태입니다. 즉, 서브쿼리가 “독립적으로 한 번” 실행되는 것이 아니라, 외부 쿼리의 각 행에 대해 “조건이 바뀐 상태로” 평가된다고 이해하시면 됩니다.
가장 흔한 패턴은 “외부 행의 키를 서브쿼리 조건에 넣어 존재/집계를 구하는 것”입니다.
대표 예시: 고객별 마지막 주문일을 스칼라 서브쿼리로 구하기
SELECT c.customer_id, c.customer_name,
(SELECT MAX(o.order_date)
FROM orders o
WHERE o.customer_id = c.customer_id) AS last_order_date
FROM customers c;
customers의 각 행마다 orders를 다시 바라보며 MAX(order_date)를 구합니다. 같은 결과라도 인라인 뷰로 만드는 방식과 비교해보면, “표현 방법이 다를 뿐 결국 같은 문제를 푸는 도구”라는 감각이 생깁니다.
실습 1: “최고가 상품”
요구사항을 구체화하면 “전체 상품 중 가격이 가장 높은 상품(들)을 찾기”입니다. 여기서 중요한 포인트는 동점(최고가가 여러 개)이 있을 수 있다는 점입니다. 동점을 포함하려면 “최고가 값(1개)”을 구한 뒤 그 값과 같은 상품을 찾는 패턴이 가장 깔끔합니다.
풀이 1) 스칼라 서브쿼리로 최고가(값 1개)를 구해 비교
SELECT p.product_id, p.product_name, p.price
FROM products p
WHERE p.price = (SELECT MAX(price) FROM products);
MAX(price)는 값 1개를 반환하므로 스칼라 서브쿼리에 딱 맞습니다. 동점이 있어도 p.price가 그 최고값과 같으면 모두 조회되므로 요구사항에 잘 맞습니다.
풀이 2) 인라인 뷰로 “최고가 값 테이블”을 만든 뒤 조인
SELECT p.product_id, p.product_name, p.price
FROM products p
JOIN (
SELECT MAX(price) AS max_price
FROM products
) m
ON p.price = m.max_price;
결과는 풀이 1과 동일하지만, “나중에 max_price를 더 재활용하거나 확장”할 때(예: 카테고리별 최고가, 날짜별 최고가 등) 인라인 뷰 스타일이 더 자연스럽게 확장되는 경우가 많습니다.
확장 과제) 카테고리별 최고가 상품(개념 맛보기)
카테고리별 최고가를 구하려면 “카테고리별 MAX(price) 테이블”을 만들고, products와 (category_id, price)로 맞춰 조인하는 패턴이 자주 사용됩니다. 이때 인라인 뷰가 특히 빛납니다.
-- products에 category_id가 있다고 가정
SELECT p.category_id, p.product_id, p.product_name, p.price
FROM products p
JOIN (
SELECT category_id, MAX(price) AS max_price
FROM products
GROUP BY category_id
) mx
ON mx.category_id = p.category_id
AND mx.max_price = p.price
ORDER BY p.category_id, p.product_id;
실습 2: “최근 주문한 고객”
“최근 주문한 고객”은 해석이 두 가지로 갈립니다. 첫째, “최근 주문이 존재하는 고객 목록”(예: 최근 30일 내 주문). 둘째, “가장 최근 주문을 한 고객(Top 1)” 또는 “고객별 최근 주문일을 구해 정렬”. 실무에서는 첫째와 셋째를 훨씬 자주 쓰므로, 이 글에서는 최근 N일 내 주문 고객과 고객별 최근 주문일 두 가지를 모두 보여드립니다.
풀이 A) EXISTS로 “최근 N일 내 주문이 존재하는 고객” 찾기
EXISTS는 “존재 여부”에 특화되어 있으므로 “최근 주문이 하나라도 있으면 포함”이라는 조건에 잘 맞습니다. 날짜 표현은 DB마다 문법이 달라질 수 있어, 아래는 대표적인 형태로 이해하시면 됩니다.
-- 예시: 최근 30일 내 주문이 있는 고객
SELECT c.customer_id, c.customer_name
FROM customers c
WHERE EXISTS (
SELECT 1
FROM orders o
WHERE o.customer_id = c.customer_id
AND o.order_date >= CURRENT_DATE - INTERVAL '30' DAY
);
핵심은 INTERVAL 문법이 아니라, “고객(c)과 주문(o)을 customer_id로 연결하고, 날짜 조건을 서브쿼리에 넣는다”는 구조입니다. DB가 MySQL이면 DATE_SUB, SQL Server면 DATEADD, Oracle이면 SYSDATE - 30 같은 방식으로 바꿔 쓰시면 됩니다.
풀이 B) IN으로 “주문한 고객 ID 집합”에 포함 여부 판단
SELECT c.customer_id, c.customer_name
FROM customers c
WHERE c.customer_id IN (
SELECT o.customer_id
FROM orders o
WHERE o.order_date >= CURRENT_DATE - INTERVAL '30' DAY
);
읽기 쉬운 장점이 있고, “고객 ID 집합”이라는 사고방식이 자연스러울 때는 매우 깔끔합니다. 다만 “관계 존재”를 강조하고 싶거나, 상관 조건이 자연스러운 상황에서는 EXISTS가 더 목적에 가까운 표현이 되곤 합니다.
풀이 C) 고객별 최근 주문일을 구해서 정렬(인라인 뷰 방식)
SELECT c.customer_id, c.customer_name, lo.last_order_date
FROM customers c
JOIN (
SELECT o.customer_id, MAX(o.order_date) AS last_order_date
FROM orders o
GROUP BY o.customer_id
) lo
ON lo.customer_id = c.customer_id
ORDER BY lo.last_order_date DESC;
이 결과는 “최근 주문한 고객을 최신 순으로” 보기 좋게 정렬할 수 있고, last_order_date를 화면에 그대로 보여줄 수 있다는 장점이 있습니다. 보고서/관리자 화면에서 특히 자주 쓰는 패턴입니다.
풀이 D) 고객별 최근 주문일을 스칼라(상관) 서브쿼리로 구하기
SELECT c.customer_id, c.customer_name,
(SELECT MAX(o.order_date)
FROM orders o
WHERE o.customer_id = c.customer_id) AS last_order_date
FROM customers c
ORDER BY last_order_date DESC;
인라인 뷰와 결과는 비슷하지만, “각 고객 행에서 마지막 주문일을 계산해 붙인다”는 구조로 읽힙니다. 초보자 입장에서는 상관 서브쿼리 감각을 잡기에 좋은 예시입니다.
보너스) “가장 최근 주문을 한 고객 1명(또는 동점 포함)”
이 요구사항은 DB마다 TOP/LIMIT 문법이 달라서, 개념만 깔끔하게 보여드리겠습니다. 핵심은 “전체 주문 중 MAX(order_date)를 구한 뒤, 그 날짜에 해당하는 주문의 고객을 찾는 것”입니다(동점 날짜가 있으면 여러 고객이 나올 수 있습니다).
SELECT DISTINCT c.customer_id, c.customer_name
FROM customers c
JOIN orders o
ON o.customer_id = c.customer_id
WHERE o.order_date = (SELECT MAX(order_date) FROM orders);
따라하기
- 실습용 테이블(Products/Customers/Orders/Order_Items)에서 PK/FK 관계를 먼저 확인합니다. 고객과 주문은 customer_id로 연결되고, 주문과 주문상세는 order_id로 연결되며, 주문상세와 상품은 product_id로 연결되는 구조를 머릿속에 고정하시는 것이 핵심입니다.
- “최고가 상품”을 먼저 스칼라 서브쿼리로 풉니다. (1) MAX(price)로 최고가 값을 1개 만들고, (2) products.price와 비교하는 형태로 작성한 뒤, (3) 동점이 모두 나오는지 확인합니다.
- 같은 문제를 인라인 뷰로 다시 작성해봅니다. FROM에 (SELECT MAX(price) ...)를 올리고 products와 조인해, “값 1개짜리 테이블도 조인할 수 있다”는 감각을 익히면 이후 집계 조인 패턴이 매우 쉬워집니다.
- “최근 주문한 고객”을 EXISTS로 먼저 풀어봅니다. (1) customers를 외부 테이블로 두고, (2) orders에서 customer_id로 상관 조건을 걸고, (3) 날짜 조건을 서브쿼리에 넣어 “주문이 존재하면 통과” 구조를 만듭니다.
- 같은 조건을 IN으로도 작성해보고, 읽기 쉬운지/의도가 잘 드러나는지 비교합니다. 이후 “주문이 없는 고객”을 찾는 과제로 넘어갈 때는 NOT EXISTS를 기본으로 사용하는 습관을 들이시면 안정성이 올라갑니다.
- 마지막으로 고객별 마지막 주문일을 인라인 뷰(집계 후 조인) 방식으로 만든 뒤, 스칼라 상관 서브쿼리 방식으로도 만들어봅니다. 둘 다 만들 수 있어야 “문제에 맞는 표현”을 선택할 수 있습니다.
요약 표
아래 표는 서브쿼리 3종과 IN/EXISTS를 “무엇을 반환하고, 어디에 쓰며, 어떤 상황에서 읽기/확장이 좋은지” 기준으로 정리한 것입니다. 실무에서는 성능보다도 “의도 표현과 유지보수”가 더 중요해지는 경우가 많아, 우선은 이 표의 기준으로 선택하는 습관을 권장드립니다.
| 구분 | 반환 형태 | 주요 위치 | 적합한 문제 | 주의사항 |
|---|---|---|---|---|
| Scalar Subquery | 1행 1열(값 1개) | SELECT / WHERE | 평균보다 비싼 상품, 최고값과 비교, 기준값 계산 | 여러 행이 나오면 오류가 날 수 있어 집계/제한으로 1개를 보장해야 합니다. |
| Inline View | 테이블(결과 집합) | FROM | 집계 결과를 조인, 단계적 쿼리 구성, 보고서형 결과 | 별칭(alias)을 반드시 붙이고, 조인 키(예: customer_id)를 명확히 관리해야 합니다. |
| EXISTS | true/false(존재 여부) | WHERE | 주문 이력 있는 고객, 특정 조건을 만족하는 행이 “하나라도” 있는지 확인 | 서브쿼리 SELECT 목록보다 조건이 핵심이며, 상관 조건을 빠뜨리면 의도와 다른 결과가 나옵니다. |
| IN | 집합 포함 여부 | WHERE | ID 목록에 포함되면 통과, 소규모 목록/단순 멤버십 테스트 | NOT IN은 NULL에 취약할 수 있어, “없음” 조건은 NOT EXISTS가 안전한 기본값입니다. |
| 상관 서브쿼리 | 케이스에 따라 값/존재 | SELECT / WHERE | 각 행별 계산(마지막 주문일), 행별 존재 검사(주문 여부) | 외부 테이블 컬럼을 참조하므로 “각 행마다 평가”되는 구조를 먼저 떠올리면 이해가 빨라집니다. |
추가로 생각해볼 점
서브쿼리로 풀리는 문제 중 상당수는 JOIN과 GROUP BY로도 풀립니다. 초보 단계에서는 “둘 중 무엇이 더 빠르냐”보다 “내 의도를 가장 정확하고 읽기 쉽게 표현하는가”에 초점을 맞추는 것이 성장 속도가 빠릅니다. 다만 데이터가 커질수록 EXISTS/인라인 뷰/조인 재작성 등은 DB 옵티마이저가 다양한 방식으로 처리할 수 있으니, 실무에서는 실행 계획과 인덱스(특히 조인 키와 날짜 컬럼)를 함께 보는 습관을 추천드립니다.
이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

0 댓글