도입
SQL을 작성하다 보면 “먼저 A를 구하고 → 그 결과로 B를 걸러야 한다” 같은 단계적 사고가 자주 등장합니다. 이때 가장 직관적으로 문제를 풀게 해주는 문법이 서브쿼리(Subquery)입니다.
하지만 서브쿼리는 위치(WHERE/SELECT/FROM/HAVING)와 형태(스칼라/다중행/테이블), 그리고 상관 여부(비상관/상관)에 따라 성능과 결과가 크게 달라질 수 있습니다. 이 글에서는 “그냥 되는 쿼리”가 아니라, 의도/정확성/성능까지 챙기는 서브쿼리 사용법을 한 번에 정리합니다.
정의
서브쿼리(Subquery)는 다른 SQL 문 내부에 포함되는 SELECT 문입니다. 보통 필터 조건을 만들거나, 파생(derived) 데이터를 생성하거나, 존재 여부를 검사할 때 사용합니다.
- 스칼라 서브쿼리(Scalar): 1행 1열(단일 값) 반환
- 다중 행 서브쿼리(Multi-row): 여러 행(대개 1열) 반환 → IN/EXISTS/ANY/ALL 등에 사용
- 테이블 서브쿼리(Table / Derived table): 여러 열/행 반환 → FROM 절 인라인 뷰 형태
- 비상관 서브쿼리(Non-correlated): 내부 쿼리가 외부 컬럼을 참조하지 않음
- 상관 서브쿼리(Correlated): 내부 쿼리가 외부 컬럼을 참조함 → 외부 행마다 평가될 수 있음
필요성
- 집계 결과(평균/최대/최소/합계)를 기준으로 필터링할 수 있다. (예: 평균보다 큰 값)
- 존재 여부를 간결하고 안전하게 표현할 수 있다. (EXISTS/NOT EXISTS)
- 임시 결과를 테이블처럼 만들어 단계적으로 가독성 있게 쿼리를 구성할 수 있다. (FROM 서브쿼리/CTE)
- UPDATE/DELETE 같은 DML에서도 “어떤 행을 바꿀지”를 정교하게 지정할 수 있다.
JOIN vs Subquery
| 구분 | 서브쿼리 | JOIN | 윈도우 함수 / CTE |
|---|---|---|---|
| 강점 | 단계적 조건, 존재 여부, “먼저 구하고 비교” | 관계 결합의 정석, 집계/결합 최적화에 강함 | 행 유지 + 통계 붙이기(윈도우), 구조 분리(CTE) |
| 주의 | 상관 서브쿼리는 대량 데이터에서 비용 증가 가능 | 조인 조건 누락 시 곱집합/중복 폭증 | DB별 지원/최적화 방식 차이 존재 |
| 추천 상황 | 존재/부재 검사(EXISTS), 단일 값 비교(AVG 등) | 가져올 컬럼이 많고 결합이 명확할 때 | “그룹 평균보다 큰 행”, “상위 N개” 같은 분석 |
기본 사용법
-- 평균 급여보다 높은 직원
SELECT e.*
FROM employees e
WHERE e.salary > (SELECT AVG(salary) FROM employees);
-- 각 부서별 직원 수를 열로 붙이기(스칼라)
SELECT d.dept_id,
d.dept_name,
(SELECT COUNT(*)
FROM employees e
WHERE e.dept_id = d.dept_id) AS emp_cnt
FROM departments d;
-- 부서별 평균을 만든 뒤 조건 적용
SELECT t.dept_id, t.avg_salary
FROM (
SELECT dept_id, AVG(salary) AS avg_salary
FROM employees
GROUP BY dept_id
) t
WHERE t.avg_salary >= 7000;
-- 전체 평균보다 부서 평균이 높은 부서
SELECT dept_id, AVG(salary) AS dept_avg
FROM employees
GROUP BY dept_id
HAVING AVG(salary) > (SELECT AVG(salary) FROM employees);
-- 부서 평균이 전체 평균보다 높은 부서의 직원 급여 5% 인상
UPDATE employees e
SET e.salary = e.salary * 1.05
WHERE e.dept_id IN (
SELECT dept_id
FROM employees
GROUP BY dept_id
HAVING AVG(salary) > (SELECT AVG(salary) FROM employees)
);
서브쿼리 패턴
실무에서 가장 많이 쓰는 서브쿼리 패턴은 사실 몇 가지로 압축됩니다. 아래 패턴을 익히면 “뭘로 써야 하는지”가 훨씬 빨리 결정됩니다.
-- 주문을 한 번이라도 한 고객
SELECT c.*
FROM customers c
WHERE c.customer_id IN (
SELECT o.customer_id
FROM orders o
);
-- 존재 여부만 중요할 때(상관 서브쿼리로 표현)
SELECT c.*
FROM customers c
WHERE EXISTS (
SELECT 1
FROM orders o
WHERE o.customer_id = c.customer_id
);
-- 각 부서의 평균 급여보다 높은 직원
SELECT e.*
FROM employees e
WHERE e.salary > (
SELECT AVG(e2.salary)
FROM employees e2
WHERE e2.dept_id = e.dept_id
);
상관 서브쿼리는 의도가 직관적이지만, 외부 행마다 평가될 수 있어 대량 데이터에서 비용이 커질 수 있습니다. 아래처럼 집계를 먼저 만들고 JOIN하거나, 가능하면 윈도우 함수로 바꾸는 패턴도 함께 기억해두면 좋습니다.
-- (대체) 부서 평균을 먼저 만든 뒤 JOIN
SELECT e.*
FROM employees e
JOIN (
SELECT dept_id, AVG(salary) AS dept_avg
FROM employees
GROUP BY dept_id
) a ON a.dept_id = e.dept_id
WHERE e.salary > a.dept_avg;
null 처리
서브쿼리에서 가장 유명한 함정은 NOT IN + NULL 입니다. 서브쿼리 결과에 NULL이 포함되면, NOT IN은 의도와 다르게 동작할 수 있습니다. (SQL의 3값 논리: TRUE / FALSE / UNKNOWN)
-- 의도: “주문이 없는 고객”
-- 위험: orders.customer_id에 NULL이 존재하면 결과가 비정상적일 수 있음
SELECT c.*
FROM customers c
WHERE c.customer_id NOT IN (
SELECT o.customer_id
FROM orders o
);
SELECT c.*
FROM customers c
WHERE NOT EXISTS (
SELECT 1
FROM orders o
WHERE o.customer_id = c.customer_id
);
주의점
-- BAD: 여러 행이 나오면 에러(또는 DB에 따라 예측 불가한 동작)
SELECT e.*
FROM employees e
WHERE e.dept_id = (SELECT dept_id FROM departments);
해결은 단순합니다. “여러 개일 수 있다”면 IN을 쓰거나, 서브쿼리 자체가 단일 행이 되도록 조건을 제한해야 합니다.
-- DB에 따라 alias가 없으면 오류가 발생할 수 있음
SELECT *
FROM (
SELECT dept_id, AVG(salary) AS avg_salary
FROM employees
GROUP BY dept_id
) t; -- alias 필수(권장)
자주 하는 실수
SELECT/WHERE에서 = (subquery) 형태를 쓰면 “반드시 1값”이어야 합니다. 현실에서는 데이터가 늘면서 단일 값이 깨지는 순간이 자주 생깁니다. (예: 중복 데이터)
서브쿼리 결과에 NULL이 포함될 가능성이 있다면, “부재” 판정은 NOT EXISTS가 더 안전합니다.
상관 서브쿼리는 “읽기 쉬운 대신” 대량 데이터에서 비용이 커질 수 있습니다. 같은 목적이라면 집계 결과를 먼저 만들고 JOIN하는 방식이 더 안정적인 경우가 많습니다.
성능 팁
- 존재 여부만 필요하면 EXISTS / NOT EXISTS를 우선 고려한다.
- 상관 서브쿼리로 반복 계산되는 집계가 있다면, 사전 집계(derived table/CTE) 후 JOIN을 고려한다.
- “행을 유지한 채 그룹 통계를 붙이는” 요구라면 윈도우 함수가 더 간결하고 빠를 수 있다.
- 서브쿼리/조인의 키 컬럼에 인덱스가 있는지 확인한다. (특히 EXISTS의 상관 조건)
- DB마다 최적화가 다르므로 EXPLAIN으로 실제 계획(스캔 방식, 조인 방식, 인덱스 사용)을 확인한다.
요약
- ✅ 서브쿼리는 반환 형태(스칼라/다중행/테이블)와 상관 여부(비상관/상관)로 나뉜다.
- ✅ 스칼라 서브쿼리는 반드시 1행 1열을 보장해야 한다.
- ✅ “존재/부재”는 EXISTS/NOT EXISTS가 의도 명확 + NULL 이슈에 안전하다.
- ✅ 상관 서브쿼리는 편하지만 대량 데이터에서 비용이 커질 수 있어 사전 집계 + JOIN/윈도우 함수로 대체를 검토한다.
- ✅ 최종 판단은 실행 계획(EXPLAIN)과 인덱스 확인으로 한다.