백엔드 데이터 인프라 28편. SELECT 의 전체 구조 — 9개 절·실행 순서·서브쿼리·UNION·INTERSECT 풀어쓴 학습 노트.
이 글은 백엔드 데이터 인프라 시리즈 70편 중 28편이에요. 11편 SELECT 에서 "기본 흐름" 을 다뤘으니, 이번 28편은 그 위에 — SELECT 의 9개 절 + 실행 순서 + 집합 연산.
SELECT 9개 절 — 풀 구조
WITH (CTE) -- 1
SELECT 컬럼들 -- 2
FROM 테이블·서브쿼리·JOIN -- 3
WHERE 조건 -- 4
GROUP BY 컬럼 -- 5
HAVING 그룹 조건 -- 6
ORDER BY 컬럼 -- 7
LIMIT 개수 -- 8
OFFSET 시작 -- 9
작성 순서는 위와 같지만 — 실행 순서는 다름:
1. FROM·JOIN → 데이터 소스 결정
2. WHERE → 행 필터
3. GROUP BY → 그룹화
4. HAVING → 그룹 필터
5. SELECT → 컬럼 선택·표현식
6. DISTINCT
7. ORDER BY
8. LIMIT·OFFSET
WHERE 가 SELECT 보다 먼저 실행 — 그래서 SELECT 별칭은 WHERE 에서 못 씀.
SELECT amount * 1.1 AS new_price FROM products
WHERE new_price > 100; -- ❌ ERROR — new_price 없음
WHERE amount * 1.1 > 100; -- ✅
ORDER BY 는 SELECT 후라 별칭 OK:
SELECT amount * 1.1 AS new_price FROM products
ORDER BY new_price; -- ✅
WITH (CTE) — 가독성·재사용
18편 에서 다룬 CTE 패턴.
WITH active_users AS (
SELECT id, name FROM users WHERE deleted_at IS NULL
),
recent_orders AS (
SELECT user_id, MAX(created_at) AS last_at
FROM orders WHERE status = 'PAID'
GROUP BY user_id
)
SELECT u.id, u.name, o.last_at
FROM active_users u
LEFT JOIN recent_orders o ON u.id = o.user_id;
복잡한 쿼리를 작은 조각 — 디버깅·이해 모두 쉬워짐.
FROM 절 — 데이터 소스
| 형태 | 의미 |
|---|---|
FROM table |
한 테이블 |
FROM table AS alias |
별칭 |
FROM t1 JOIN t2 ON ... |
JOIN |
FROM (SELECT ...) AS sub |
서브쿼리 |
FROM func() |
함수 호출 결과 |
FROM ROWS FROM(f1(), f2()) |
여러 함수 |
FROM ONLY parent |
상속 자식 제외 |
FROM table TABLESAMPLE BERNOULLI(10) |
샘플링 |
-- 함수 결과 from
SELECT * FROM generate_series(1, 10) AS num;
-- 샘플링
SELECT * FROM users TABLESAMPLE BERNOULLI(1); -- 1% 무작위
WHERE 절의 깊이
조건 결합 우선순위
WHERE a = 1 AND b = 2 OR c = 3
-- = (a = 1 AND b = 2) OR c = 3
WHERE a = 1 AND (b = 2 OR c = 3)
-- 명시
AND > OR 우선순위. 헷갈리면 괄호.
NULL safe 비교
WHERE col IS DISTINCT FROM 1
-- col != 1 OR col IS NULL
WHERE col IS NOT DISTINCT FROM 1
-- col = 1 (NULL safe)
PG 특별 — NULL 함정 회피.
GROUP BY 깊이
표준 GROUP BY
SELECT category, COUNT(*), SUM(amount)
FROM orders
GROUP BY category;
SELECT 의 모든 비집계 컬럼이 GROUP BY 에 있어야.
GROUP BY ROLLUP·CUBE·GROUPING SETS
-- 카테고리별 + 전체 합계
SELECT category, SUM(amount)
FROM orders
GROUP BY ROLLUP (category);
-- category 별 + brand 별 + 둘 다 + 전체
SELECT category, brand, SUM(amount)
FROM orders
GROUP BY CUBE (category, brand);
-- 특정 조합만
GROUP BY GROUPING SETS ((category), (brand), ());
여러 차원 집계 한 SQL로. 대시보드·OLAP 시나리오.
FILTER 집계
SELECT
user_id,
COUNT(*) AS total_orders,
COUNT(*) FILTER (WHERE status = 'PAID') AS paid_orders,
COUNT(*) FILTER (WHERE status = 'CANCELED') AS canceled_orders
FROM orders
GROUP BY user_id;
CASE WHEN 대신 — PG·SQL 표준 더 깔끔.
HAVING — 그룹 필터
SELECT user_id, COUNT(*) AS cnt
FROM orders
GROUP BY user_id
HAVING COUNT(*) > 10;
WHERE 와 차이: - WHERE = 그룹 전 행 필터 - HAVING = 그룹 후 그룹 필터
ORDER BY 깊이
ORDER BY col ASC -- 오름차순 (기본)
ORDER BY col DESC -- 내림차순
ORDER BY col ASC NULLS LAST -- NULL 끝
ORDER BY col DESC NULLS FIRST -- NULL 앞
ORDER BY 1, 2 DESC -- 위치 (SELECT 컬럼 순서)
ORDER BY (CASE WHEN ... END) -- 표현식
컬렉션·언어별 정렬
ORDER BY name COLLATE "ko_KR.utf8" -- 한국어 룰
ORDER BY name COLLATE "en_US.utf8" -- 영어 룰
LIMIT·OFFSET 깊이
LIMIT 10 OFFSET 100 -- 11~110번째? 아니, 101~110번째
LIMIT 10 -- 처음 10
LIMIT ALL -- 제한 없음 (= 생략)
FETCH FIRST 10 ROWS ONLY -- SQL 표준
OFFSET 100 ROWS -- SQL 표준
OFFSET 깊음 = 성능 위험
LIMIT 10 OFFSET 1000000 -- 1백만 행 스킵 — 느림
11편 에서 다룬 "커서 페이지네이션" 권장.
-- 커서 패턴
WHERE id > 1000 ORDER BY id LIMIT 10
DISTINCT
SELECT DISTINCT col FROM ... -- 중복 제거
SELECT DISTINCT a, b FROM ... -- (a, b) 조합 unique
SELECT DISTINCT ON (col) * FROM ... ORDER BY col, created_at DESC -- 그룹별 첫
11편 의 DISTINCT ON = PG 강점.
집합 연산 — UNION·INTERSECT·EXCEPT
여러 SELECT 결과를 "집합으로" 결합.
UNION — 합집합
SELECT id FROM users WHERE city = 'Seoul'
UNION
SELECT id FROM users WHERE age >= 65;
-- 둘 중 어느 조건이라도 만족 (중복 제거)
UNION ALL
-- 중복 안 제거 (더 빠름)
INTERSECT — 교집합
SELECT user_id FROM premium_users
INTERSECT
SELECT user_id FROM active_users;
-- 둘 다 만족
EXCEPT — 차집합
SELECT user_id FROM all_users
EXCEPT
SELECT user_id FROM banned_users;
-- A 에 있고 B 에 없음
규칙
- 컬럼 수·타입 같아야
- ORDER BY 는 마지막에 한 번만
서브쿼리 형태 4가지
(1) 스칼라 서브쿼리
SELECT name,
(SELECT AVG(amount) FROM orders) AS avg_amount
FROM users;
(2) 행 서브쿼리
SELECT * FROM orders
WHERE (user_id, amount) = (SELECT user_id, MAX(amount) FROM orders WHERE ...);
(3) 테이블 서브쿼리 (FROM 안)
SELECT u.name, agg.cnt
FROM users u
JOIN (SELECT user_id, COUNT(*) AS cnt FROM orders GROUP BY user_id) agg
ON u.id = agg.user_id;
(4) EXISTS·IN 서브쿼리
SELECT * FROM users u
WHERE EXISTS (SELECT 1 FROM orders WHERE user_id = u.id);
성능 — EXISTS 가 IN 보다 좋은 경우 많음 (NULL·짧은 회로).
LATERAL — 외부 컬럼 참조
SELECT u.name, recent.*
FROM users u
LEFT JOIN LATERAL (
SELECT id, amount FROM orders
WHERE user_id = u.id
ORDER BY created_at DESC LIMIT 3
) recent ON true;
12편 JOIN 의 LATERAL — "각 그룹 TOP-N" 표준.
함정 5가지
(1) WHERE 에서 SELECT 별칭 참조
위에서 다룬 실행 순서 함정.
(2) GROUP BY 누락
SELECT 의 비집계 컬럼이 GROUP BY 없으면 ERROR (또는 NON-STANDARD MySQL).
(3) UNION vs UNION ALL
UNION = 중복 제거 (느림). 중복 없는 확신 = UNION ALL.
(4) OFFSET 깊음
페이지네이션 시 커서로 전환.
(5) 서브쿼리 vs JOIN
비슷한 결과지만 — 큰 데이터에선 JOIN 이 효율적. EXPLAIN 으로 확인.
작성 = WITH·SELECT·FROM·WHERE·GROUP BY·HAVING·ORDER BY·LIMIT. 실행 = FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT. WHERE 에 SELECT 별칭 X, ORDER BY 는 OK. 집합 연산 + LATERAL + CTE 가 PG 강점.
한 줄 정리 — SELECT 9절 작성 순서 ≠ 실행 순서. WITH·GROUP BY ROLLUP·FILTER·DISTINCT ON·LATERAL·UNION 가 PG 강력 도구. WHERE 별칭 X·OFFSET 깊음 주의. EXISTS 가 IN 보다 보통 효율.
시험 직전 한 번 더 — 쿼리 개요 입문자가 매번 헷갈리는 것
- SELECT 9절 = WITH·SELECT·FROM·WHERE·GROUP BY·HAVING·ORDER BY·LIMIT·OFFSET
- 작성 순서 ≠ 실행 순서
- 실행 순서 = FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT
- WHERE 에 SELECT 별칭 X
- ORDER BY 에 별칭 OK (SELECT 후)
- WITH (CTE) = 가독성·재사용
- FROM = 테이블·서브쿼리·함수·샘플링
- TABLESAMPLE BERNOULLI = 무작위 샘플
- generate_series = 함수 from
- IS DISTINCT FROM = NULL safe 비교
- GROUP BY 비집계 컬럼 모두
- ROLLUP·CUBE·GROUPING SETS = 다차원 집계
- FILTER 집계 =
COUNT(*) FILTER (WHERE ...) - HAVING = 그룹 후 필터
- WHERE = 행 필터, HAVING = 그룹 필터
- ORDER BY NULLS FIRST·LAST
- COLLATE = 언어별 정렬
- LIMIT OFFSET = 페이지네이션 (깊으면 느림)
- 커서 페이지네이션 =
WHERE id > N - DISTINCT ON = 그룹별 첫 (PG 특별)
- UNION·INTERSECT·EXCEPT = 집합 연산
- UNION ALL = 중복 제거 X (빠름)
- EXISTS vs IN — EXISTS 보통 우수
- LATERAL = 외부 컬럼 참조 서브쿼리
- 서브쿼리 4종 = 스칼라·행·테이블·EXISTS
- 컬럼 수·타입 같아야 집합 연산
시리즈 다른 편
- Part 2 SQL Language 깊이: 21편 ~ 27편 DELETE 깊이 · 28편 (현재 글)
시리즈 다음 글
다음 글(29편)에서는 SELECT 절·표현식 — 컬럼 선택의 깊이 + 표현식 활용.
공식 문서: PostgreSQL 18 — Queries: Overview에서 더 자세한 사양을 확인할 수 있어요.