본문 바로가기
TYLER SONGBlog
← Blog index
CS6 min read

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에서 그대로 실행할 수 있다.

sql
-- 준비: 스키마와 데이터 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);
sql
-- 1) 스칼라 서브쿼리: 전체 평균보다 급여가 높은 직원 SELECT name, salary FROM employees WHERE salary > (SELECT avg(salary) FROM employees);
sql
-- 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 );
sql
-- 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);
sql
-- 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;
sql
-- 5) SELECT 목록의 스칼라 서브쿼리: 고객별 주문 건수 SELECT c.name, (SELECT count(*) FROM orders o WHERE o.customer_id = c.id) AS order_count FROM customers c;
sql
-- 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)로 검증한다.