서브쿼리와 CTE(WITH) — 가독성 좋은 쿼리
중첩 SELECT 와 WITH 절. 재귀 CTE 한 줄로 트리 다루기.
JOIN 만으로 안 되는 쿼리가 등장하기 시작합니다 — "각 카테고리에서 매출 1위 상품" 같은 것. 답은 서브쿼리(쿼리 안의 쿼리) 와 CTE(가독성 좋은 명명 쿼리). 10편은 둘의 형태와 언제 무엇을 쓸지 정리하고, 재귀 CTE 까지 미리 봅니다.
서브쿼리 — 형태에 따라 3가지
① scalar 서브쿼리 — 단일 값
-- WHERE 안에서
SELECT * FROM orders
WHERE total > (SELECT AVG(total) FROM orders);
-- SELECT 안에서 (각 행마다 실행됨 — 비싸기 쉬움)
SELECT o.id,
o.total,
(SELECT name FROM users WHERE id = o.user_id) AS user_name
FROM orders o;
② row 서브쿼리 — 한 행, 여러 컬럼
SELECT * FROM orders
WHERE (user_id, total) = (SELECT id, max_total FROM ...);
③ table 서브쿼리 — 여러 행 + 여러 컬럼 (FROM 절에)
-- 카테고리별 매출 1위
SELECT *
FROM (
SELECT category_id, name, total,
ROW_NUMBER() OVER (PARTITION BY category_id ORDER BY total DESC) AS rn
FROM products
) ranked
WHERE rn = 1;
IN · EXISTS · NOT EXISTS
-- IN (값 비교)
SELECT * FROM orders
WHERE user_id IN (SELECT id FROM users WHERE active = true);
-- EXISTS (존재만 확인 — 매치 즉시 멈춤, 보통 더 빠름)
SELECT * FROM orders o
WHERE EXISTS (SELECT 1 FROM users u WHERE u.id = o.user_id AND u.active);
-- NOT EXISTS (anti-join, NULL 함정 회피)
SELECT * FROM users u
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);
NOT IN 의 NULL 함정 (6편 다시). WHERE x NOT IN (subquery) 의 subquery 결과에 NULL 이 하나라도 있으면 전체 결과가 빈 행. NOT EXISTS 는 NULL 영향 없음 — "제외" 의도면 NOT EXISTS 가 안전.
WITH (CTE) — 명명된 임시 쿼리
WITH active_users AS (
SELECT id, name FROM users WHERE active = true
),
big_orders AS (
SELECT user_id, SUM(total) AS rev
FROM orders
WHERE total >= 50000
GROUP BY user_id
)
SELECT u.name, COALESCE(o.rev, 0) AS rev
FROM active_users u
LEFT JOIN big_orders o ON o.user_id = u.id
ORDER BY rev DESC;
같은 결과를 서브쿼리로 쓰면 중첩이 깊어집니다. WITH 로 분해하면 위에서 아래로 읽힘. 큰 쿼리는 거의 항상 WITH 로 구조를 잡는 게 가독성 정답.
CTE 의 성능 (Postgres 12+). 옛 Postgres 는 CTE 가 "최적화 장벽" 이라 느렸지만, 12부터는 인라인 최적화 가 기본이라 일반 서브쿼리와 비슷한 성능. 가독성 비용 거의 없이 분해할 수 있습니다. 일부러 장벽 만들고 싶으면 WITH x AS MATERIALIZED (...).
여러 CTE 체이닝 — 단계별 데이터 변환
-- 분석 파이프라인 — 단계별 명확
WITH raw AS (
SELECT user_id, total, created_at
FROM orders
WHERE created_at >= '2026-01-01'
),
by_month AS (
SELECT user_id,
date_trunc('month', created_at) AS month,
SUM(total) AS rev
FROM raw
GROUP BY user_id, date_trunc('month', created_at)
),
top_per_month AS (
SELECT *,
RANK() OVER (PARTITION BY month ORDER BY rev DESC) AS rk
FROM by_month
)
SELECT month, user_id, rev
FROM top_per_month
WHERE rk <= 3
ORDER BY month, rk;
재귀 CTE — 트리·계층 구조
-- 직원-매니저 트리, "준성" 의 모든 부하 직원 (간접 포함)
WITH RECURSIVE subordinates AS (
-- 1) 시작점
SELECT id, name, manager_id, 1 AS depth
FROM employees
WHERE name = '준성'
UNION ALL
-- 2) 재귀 — 직전 결과의 부하 직원
SELECT e.id, e.name, e.manager_id, s.depth + 1
FROM employees e
JOIN subordinates s ON e.manager_id = s.id
)
SELECT * FROM subordinates ORDER BY depth, name;
재귀 CTE 의 패턴: (anchor) UNION ALL (recursion). 트리·그래프·번호 시퀀스 생성 등에 사용. 무한 루프 위험 — 항상 depth < N 같은 조건이나 데이터에 사이클 없는 게 보장돼야 안전.
generate_series — 숫자/날짜 시퀀스
-- 1부터 10까지
SELECT * FROM generate_series(1, 10);
-- 날짜 (월별)
SELECT * FROM generate_series(
'2026-01-01'::date,
'2026-12-31'::date,
'1 month'::interval
);
-- "지난 30일 매일 매출" — 데이터 없는 날도 0 으로 채우기
WITH days AS (
SELECT generate_series(
CURRENT_DATE - INTERVAL '29 days',
CURRENT_DATE,
'1 day'::interval
)::date AS d
)
SELECT d.d, COALESCE(SUM(o.total), 0) AS rev
FROM days d
LEFT JOIN orders o ON o.created_at::date = d.d
GROUP BY d.d
ORDER BY d.d;
서브쿼리 vs CTE vs JOIN — 선택 가이드
| 상황 | 추천 |
|---|---|
| 단일 값 비교 (WHERE) | scalar 서브쿼리 |
| "있는지" 확인 | EXISTS / NOT EXISTS |
| "이 중 하나" (값 목록) | IN (단, NULL 주의) |
| 데이터 합치기 + 컬럼 | JOIN |
| 단계가 3개 이상 | WITH (CTE) |
| 트리·재귀 | WITH RECURSIVE |
| 번호/날짜 시퀀스 | generate_series |
11편 — 윈도우 함수 (OVER·PARTITION·ROW_NUMBER)
ROW_NUMBER·RANK·LAG·LEAD — 같은 결과에서 행 단위 분석.
이전: 9편 JOIN · 현재: 10편 (SQL 기초) · 다음 → 11편 윈도우 함수 · 진행: 10/24