Subquery: 쿼리 안에 중첩된 쿼리로 단계적 집계와 조건 필터링 표현하기
서브쿼리(Subquery)는 다른 SQL 문 내부에 포함된 SELECT 문으로, 하나의 쿼리 결과를 바깥 쿼리의 값, 행 집합, 임시 테이블처럼 사용할 수 있게 한다. 스칼라, 다중 행, 인라인 뷰, 상관 서브쿼리로 나뉘며 각각 반환 형태와 평가 방식이 다르다. PostgreSQL에서는 옵티마이저가 많은 서브쿼리를 조인으로 변환하지만, 상관 서브쿼리와 NO
By 송민성
1. 개념
서브쿼리(Subquery)는 다른 SQL 문 안에 괄호로 감싸 포함된 SELECT 문이다. 바깥 쿼리(outer query)는 서브쿼리가 반환한 결과를 값, 집합, 테이블처럼 사용한다.
반환 형태와 위치에 따라 다음과 같이 분류한다.
| 종류 | 반환 형태 | 주 사용 위치 |
|---|---|---|
| 스칼라 서브쿼리(Scalar Subquery) | 1행 1열 | SELECT 목록, WHERE 비교 |
| 다중 행 서브쿼리 | N행 1열 | IN, ANY, ALL |
| 인라인 뷰(Inline View, Derived Table) | N행 N열 | FROM 절 |
| 상관 서브쿼리(Correlated Subquery) | 바깥 쿼리의 행에 의존 | WHERE, SELECT 목록 |
| 존재 검사 서브쿼리 | 행의 존재 여부 | EXISTS, NOT EXISTS |
2. 왜 사용하는가
- 단계적 사고의 표현: "평균 급여보다 많이 받는 직원"처럼, 먼저 계산한 값을 다시 조건에 쓰는 문제를 한 문장으로 쓸 수 있다.
- 집계 결과와 상세 행의 결합:
GROUP BY로 줄어든 결과를 다시 원본 행과 비교할 때 유용하다. - 존재 여부 판단: "주문이 한 건이라도 있는 고객"은
EXISTS로 의도가 분명하게 드러난다. - 가독성: 조인으로 쓰면 중복 행이 생기거나
DISTINCT가 필요한 경우, 서브쿼리가 더 직관적일 수 있다.
3. 동작 원리
비상관 서브쿼리(Uncorrelated)
바깥 쿼리와 독립적으로 평가할 수 있다. 개념적으로는 서브쿼리를 한 번 실행하고, 그 결과를 바깥 쿼리가 사용한다. PostgreSQL은 InitPlan으로 한 번만 계산해 재사용하는 계획을 세우는 경우가 많다.
상관 서브쿼리(Correlated)
서브쿼리가 바깥 쿼리의 컬럼을 참조한다. 개념적으로는 바깥 쿼리의 각 행마다 서브쿼리를 다시 평가하는 것과 같다. 다만 PostgreSQL 옵티마이저는 EXISTS, IN 형태의 서브쿼리를 세미 조인(Semi Join)이나 안티 조인(Anti Join)으로 변환할 수 있어, 실제 실행이 행마다 반복된다고 단정할 수 없다. 반복 실행 여부는 EXPLAIN의 SubPlan, Hash Semi Join 같은 노드로 확인한다.
평가 의미
- 스칼라 서브쿼리가 2행 이상을 반환하면 런타임 오류가 발생한다. 0행이면
NULL로 평가된다. EXISTS는 하나라도 행이 있으면 참이며, 서브쿼리의SELECT목록 내용은 결과에 영향을 주지 않는다.IN은 비교 결과가NULL일 수 있는 삼치 논리(three-valued logic)를 따른다.
4. 코드 예제
아래 예제는 PostgreSQL에서 그대로 실행할 수 있다.
-- 준비: 스키마와 데이터
DROP TABLE IF EXISTS orders;
DROP TABLE IF EXISTS employees;
DROP TABLE IF EXISTS customers;
CREATE TABLE customers (
id integer PRIMARY KEY,
name text NOT NULL
);
CREATE TABLE orders (
id integer PRIMARY KEY,
customer_id integer REFERENCES customers(id),
amount numeric(10,2) NOT NULL
);
CREATE TABLE employees (
id integer PRIMARY KEY,
name text NOT NULL,
dept text NOT NULL,
salary integer NOT NULL
);
INSERT INTO customers VALUES (1, 'Kim'), (2, 'Lee'), (3, 'Park');
INSERT INTO orders VALUES
(1, 1, 100.00), (2, 1, 250.00), (3, 2, 80.00);
INSERT INTO employees VALUES
(1, 'A', 'dev', 5000), (2, 'B', 'dev', 7000),
(3, 'C', 'ops', 4000), (4, 'D', 'ops', 4500);-- 1) 스칼라 서브쿼리: 전체 평균보다 급여가 높은 직원
SELECT name, salary
FROM employees
WHERE salary > (SELECT avg(salary) FROM employees);-- 2) 상관 서브쿼리: 자기 부서 평균보다 급여가 높은 직원
SELECT e.name, e.dept, e.salary
FROM employees e
WHERE e.salary > (
SELECT avg(x.salary)
FROM employees x
WHERE x.dept = e.dept
);-- 3) EXISTS / NOT EXISTS: 주문이 있는 고객, 없는 고객
SELECT c.name
FROM customers c
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);
SELECT c.name
FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);-- 4) 인라인 뷰: 고객별 주문 합계를 만든 뒤 다시 필터링
SELECT c.name, t.total
FROM customers c
JOIN (
SELECT customer_id, sum(amount) AS total
FROM orders
GROUP BY customer_id
) t ON t.customer_id = c.id
WHERE t.total > 100;-- 5) SELECT 목록의 스칼라 서브쿼리: 고객별 주문 건수
SELECT c.name,
(SELECT count(*) FROM orders o WHERE o.customer_id = c.id) AS order_count
FROM customers c;-- 6) NOT IN의 NULL 함정
-- 서브쿼리 결과에 NULL이 하나라도 있으면 NOT IN은 어떤 행도 참이 되지 않는다.
SELECT 1 AS ok WHERE 1 NOT IN (SELECT 2 UNION ALL SELECT NULL); -- 0행
SELECT 1 AS ok WHERE NOT EXISTS (SELECT 1 FROM (VALUES (2), (NULL)) v(x) WHERE v.x = 1); -- 1행5. 시간 복잡도 또는 성능 특성
서브쿼리 자체에 고정된 시간복잡도는 없다. 실행 계획과 인덱스에 따라 달라진다.
- 상관 서브쿼리를 행마다 실행하는 계획(SubPlan): 바깥 행이 N개이고 각 평가 비용이 C라면 대략 N × C다. 안쪽 조회 컬럼에 B-tree 인덱스가 있으면 각 평가가 인덱스 탐색(O(log M))에 가까워지고, 없으면 순차 스캔(O(M))이 되어 전체가 O(N × M)이 될 수 있다.
- 세미/안티 조인으로 변환된 경우: 해시 조인이 선택되면 일반적으로 양쪽 테이블을 한 번씩 읽는 형태로, 평균적으로 O(N + M) 수준이다.
- 비상관 스칼라 서브쿼리: 한 번 계산해 재사용(InitPlan)하는 계획이 일반적이다.
- 인라인 뷰: PostgreSQL은 단순한 서브쿼리를 상위 쿼리로 펼치는(pull-up) 최적화를 하지만,
GROUP BY,LIMIT, 집계 등이 있으면 펼치지 못할 수 있다. - CTE(`WITH`)와의 차이: PostgreSQL 12 이상에서는 부작용이 없고 한 번만 참조되는 비재귀 CTE를 인라인할 수 있다.
MATERIALIZED,NOT MATERIALIZED로 동작을 지정할 수 있다.
확인은 EXPLAIN (ANALYZE, BUFFERS)로 한다.
6. 실무 사용 사례
- 조건부 존재 확인: 권한, 구독, 주문 이력처럼 "있는지 없는지"만 필요할 때
EXISTS를 쓴다. 조인 후DISTINCT를 쓰는 것보다 중복 제거 부담이 없다. - 최신 레코드 선택: 사용자별 가장 최근 로그인 기록을 가져올 때
WHERE created_at = (SELECT max(created_at) ...)형태를 쓴다. 다만 동률 처리와 성능 면에서는 윈도 함수(ROW_NUMBER())나DISTINCT ON이 나은 경우가 많다. - 페이지네이션 후 상세 조인: 인라인 뷰에서
ORDER BY ... LIMIT로 ID만 먼저 추려낸 뒤 상세 테이블과 조인해 읽는 행 수를 줄인다. - 집계 후 재필터링: 통계 테이블에서 "합계 상위 고객" 등 집계 결과를 다시 조인하는 보고서 쿼리.
- UPDATE/DELETE 조건:
DELETE FROM t WHERE id IN (SELECT ...)처럼 다른 테이블의 조건으로 변경 대상을 지정한다.
7. 주의할 점
- `NOT IN`과 `NULL`: 서브쿼리 결과에
NULL이 있으면NOT IN은 결과가 비어 버린다. 부정 조건에는NOT EXISTS를 쓰는 것이 안전하다. - 스칼라 서브쿼리의 다중 행: 2행 이상이면 오류가 난다. 데이터가 바뀌며 어느 날 갑자기 실패할 수 있으므로 유일성이 보장되는 조건(PK, UNIQUE)인지 확인한다.
- SELECT 목록의 상관 스칼라 서브쿼리: 행마다 평가되는 SubPlan이 될 수 있다. 결과 행이 많다면
LEFT JOIN+GROUP BY로 바꿨을 때 더 빠른지EXPLAIN으로 비교한다. - 인덱스 부재: 상관 조건 컬럼(위 예의
orders.customer_id)에 인덱스가 없으면 풀 스캔이 반복될 수 있다. PostgreSQL은 외래 키에 인덱스를 자동 생성하지 않는다. - 가독성 한계: 3단 이상 중첩되면 CTE나 윈도 함수로 분리하는 편이 낫다.
- `IN`과 `EXISTS`의 우열은 고정되지 않는다: 최신 PostgreSQL에서는 둘 다 세미 조인으로 변환되는 경우가 많다. 경험칙보다 실행 계획을 근거로 판단한다.
8. 핵심 정리
- 서브쿼리는 다른 SQL 문 안에 포함된
SELECT로, 반환 형태에 따라 스칼라, 다중 행, 인라인 뷰로 나뉜다. - 상관 서브쿼리는 바깥 쿼리의 컬럼을 참조하며, 개념상 행마다 평가되지만 옵티마이저가 세미/안티 조인으로 바꿀 수 있다.
- 존재 여부 판단은
EXISTS, 부정 조건은NOT EXISTS가NULL문제에서 안전하다. - 스칼라 서브쿼리는 최대 1행이어야 하며, 0행이면
NULL이다. - 성능은 문법 형태가 아니라 실행 계획과 인덱스가 결정하므로
EXPLAIN (ANALYZE, BUFFERS)로 검증한다.