다른 쿼리 안에 들어간 SELECT. 바깥을 참조하느냐로 성격이 갈린다.
두 종류
-- 비상관(uncorrelated) — 혼자서도 실행된다. 한 번만 계산된다
SELECT * FROM emp WHERE salary > (SELECT avg(salary) FROM emp);
-- 상관(correlated) — 바깥 행(e)을 참조한다. 개념적으로 행마다 실행된다
SELECT * FROM emp e
WHERE salary > (SELECT avg(salary) FROM emp WHERE dept = e.dept);
상관 서브쿼리는 행마다 도는 것처럼 보이지만, 옵티마이저가 대개 조인이나 해시 집계로 바꿔 한 번에 처리한다. 그래서 "상관 서브쿼리는 무조건 느리다"는 옛말이다 — 다만 실행계획으로 확인해야 한다.
위치별 이름
SELECT (SELECT name FROM member WHERE id = o.member_id) -- 스칼라 서브쿼리 (1행 1칼럼)
FROM orders o
WHERE o.member_id IN (SELECT id FROM member WHERE grade='GOLD'); -- 조건절
SELECT * FROM (SELECT ... ) t; -- 인라인 뷰 (FROM 절)
스칼라 서브쿼리는 2행 이상을 반환하면 오류가 난다 — 데이터가 늘면서 어느 날 갑자기 터지는 유형이다.
IN vs EXISTS — NULL에서 갈린다
-- ⚠️ NOT IN 은 목록에 NULL 이 하나라도 있으면 결과가 통째로 비어 버린다
SELECT * FROM member WHERE id NOT IN (SELECT member_id FROM orders);
-- orders.member_id 에 NULL 이 있으면 → 0건
-- ✅ NOT EXISTS 는 NULL 에 안전하다
SELECT * FROM member m
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.member_id = m.id);
이유는 3값 논리다. id NOT IN (1, 2, NULL)은 id<>1 AND id<>2 AND id<>NULL이 되고, 마지막이 NULL이라 전체가 절대 true가 되지 않는다.
"NOT IN 대신 NOT EXISTS" 는 실무에서 가장 값어치 있는 습관 중 하나다.
EXISTS는 첫 일치에서 멈춘다
-- 존재 여부만 필요할 때 count 를 세지 않는다
-- ❌ 전부 센다
SELECT * FROM member m WHERE (SELECT count(*) FROM orders o WHERE o.member_id=m.id) > 0;
-- ✅ 하나 찾으면 즉시 종료
SELECT * FROM member m WHERE EXISTS (SELECT 1 FROM orders o WHERE o.member_id=m.id);
어떤 걸 고를까
- 결과가 작고 고정적 — IN 이 읽기 쉽다
- 바깥 행마다 존재만 확인 — EXISTS
- 집계 결과를 여러 번 참조 — CTE 로 빼는 게 낫다
- 행마다 값을 하나 붙인다 — 스칼라 서브쿼리 또는 LEFT JOIN
면접 함정
- ❌ "IN과 EXISTS는 성능만 다르다" →
NOT IN은 NULL에서 결과 자체가 다르다. - ❌ "서브쿼리는 조인으로 바꿔야 빠르다" → 옵티마이저가 대개 알아서 변환한다. 계획을 보고 판단한다.