백엔드 데이터 인프라 31편. 쿼리 절의 종합 깊이 — WHERE·GROUP BY·HAVING·ORDER BY·LIMIT 운영 함정과 최적 풀어쓴 학습 노트.
이 글은 백엔드 데이터 인프라 시리즈 70편 중 31편이에요. 29편 SELECT 절 ·30편 FROM 절 까지 SELECT 9절 중 2개 깊이. 이번 31편은 나머지 — WHERE·GROUP BY·HAVING·ORDER BY·LIMIT 종합.
WHERE — 행 필터의 깊이
WHERE 조건1 AND 조건2 OR 조건3
비교 12가지
= <> != > < >= <= -- 표준
IS NULL IS NOT NULL -- NULL
IS DISTINCT FROM IS NOT DISTINCT FROM -- NULL safe
BETWEEN a AND b -- 범위
IN (...) -- 여러 값
LIKE / NOT LIKE -- 패턴
ILIKE / NOT ILIKE -- PG 대소문자 무시
SIMILAR TO -- SQL 표준 패턴
~ / ~* / !~ / !~* -- PG 정규식
인덱스 활용 조건
WHERE 가 "인덱스 가능한 형태" 인지가 성능 결정.
WHERE col = 1 -- ✅ 인덱스 사용 (Equality)
WHERE col > 100 -- ✅ Range 스캔
WHERE col IN (1, 2, 3) -- ✅
WHERE col LIKE 'A%' -- ✅ Prefix 매칭
WHERE col LIKE '%abc' -- ❌ 풀스캔
WHERE LOWER(col) = 'abc' -- ❌ 함수 인덱스 필요
WHERE col + 1 = 100 -- ❌ 표현식 — 인덱스 X
36편 인덱스 에서 깊이.
단축 회로
WHERE col1 = 1 AND col2 = 2
-- PG = col1 조건 먼저 평가, col1 가 false 면 col2 안 평가 (단축)
성능 차원에서 — "가장 선별적인 조건 먼저". 단 PG 계획자가 자동 최적.
GROUP BY 깊이
표준 GROUP BY
SELECT category, COUNT(*), AVG(price)
FROM products
GROUP BY category;
SELECT 의 비집계 컬럼 = GROUP BY 에 모두.
여러 컬럼
SELECT country, city, COUNT(*)
FROM users
GROUP BY country, city;
-- 또는 GROUP BY 1, 2 (위치)
ROLLUP — 계층 집계
SELECT country, city, SUM(amount)
FROM orders
GROUP BY ROLLUP (country, city);
결과:
country | city | sum
---------+--------+------
Korea | Seoul | 1000 ← Korea·Seoul
Korea | Busan | 500 ← Korea·Busan
Korea | NULL | 1500 ← Korea 소계
Japan | Tokyo | 800
Japan | NULL | 800 ← Japan 소계
NULL | NULL | 2300 ← 전체 합계
소계·합계 한 쿼리.
CUBE — 모든 조합
GROUP BY CUBE (category, brand);
-- (category, brand) + (category) + (brand) + ()
ROLLUP 보다 더 많은 차원. 데이터 큐브.
GROUPING SETS — 명시적
GROUP BY GROUPING SETS (
(category, brand),
(category),
(region),
()
);
원하는 그룹 조합만.
GROUPING() 함수
SELECT
category, brand,
GROUPING(category) AS g_cat,
GROUPING(brand) AS g_brand,
SUM(amount)
FROM orders
GROUP BY ROLLUP (category, brand);
GROUPING(col) = "이 행에서 그 컬럼이 ROLLUP 의 NULL 인가" — 소계·합계 구분.
HAVING — 그룹 필터
SELECT user_id, COUNT(*) AS cnt
FROM orders
GROUP BY user_id
HAVING COUNT(*) > 10
AND SUM(amount) > 1000000;
WHERE 와 차이: - WHERE = 그룹 전 (인덱스 활용 가능) - HAVING = 그룹 후 (집계 결과 필터)
룰 — WHERE 로 박을 수 있으면 WHERE.
-- 비효율
SELECT user_id, COUNT(*) FROM orders
GROUP BY user_id
HAVING user_id > 100; -- ❌ user_id 필터를 HAVING에
-- 효율
SELECT user_id, COUNT(*) FROM orders
WHERE user_id > 100 -- ✅
GROUP BY user_id;
ORDER BY 깊이
ORDER BY col1 ASC NULLS LAST, col2 DESC, ...
인덱스 활용
CREATE INDEX idx_orders_created ON orders(created_at DESC);
SELECT * FROM orders ORDER BY created_at DESC LIMIT 10;
-- 인덱스 그대로 사용 — 정렬 비용 0
ORDER BY 와 같은 방향의 인덱스 = 정렬 비용 0. 운영 표준.
다중 컬럼 인덱스
CREATE INDEX idx_user_created ON orders(user_id, created_at DESC);
SELECT * FROM orders
WHERE user_id = 1
ORDER BY created_at DESC LIMIT 10;
-- 인덱스 그대로
WHERE + ORDER BY 가 한 인덱스에 들어가면 최고.
LIMIT·OFFSET 깊이
LIMIT 10 OFFSET 1000 -- 1010번째까지 스캔 후 처음 1000개 버림 — 느림
커서 페이지네이션
-- 첫 페이지
SELECT * FROM orders WHERE user_id = 1
ORDER BY id ASC LIMIT 10;
-- 마지막 id = 100
-- 다음 페이지
SELECT * FROM orders WHERE user_id = 1 AND id > 100
ORDER BY id ASC LIMIT 10;
OFFSET 의존 X — 일정한 속도. 대용량 권장.
키셋 페이지네이션 (커서 + 다중 컬럼)
WHERE (created_at, id) > ('2026-05-17 10:00:00', 100)
ORDER BY created_at, id LIMIT 10;
(a, b) > (a0, b0) = 행 비교 — 효율적.
DISTINCT 와 GROUP BY
SELECT DISTINCT category FROM products;
-- ≡ SELECT category FROM products GROUP BY category;
거의 같음. DISTINCT 가 가독성 좋음. PG 계획자도 비슷.
집합 연산 종합
28편 쿼리 개요 의 집합 연산 깊이.
-- 합집합 (중복 제거)
SELECT user_id FROM premium UNION SELECT user_id FROM vip;
-- 합집합 (빠름)
SELECT user_id FROM premium UNION ALL SELECT user_id FROM vip;
-- 교집합
SELECT user_id FROM active INTERSECT SELECT user_id FROM premium;
-- 차집합
SELECT user_id FROM users EXCEPT SELECT user_id FROM banned;
규칙: - 컬럼 수·타입 같아야 - ORDER BY 는 마지막 한 번 - 인덱스 활용 가능 시 빠름
운영 표준 — 페이지네이션
-- ❌ 옛 스타일 — OFFSET 깊으면 폭주
SELECT * FROM orders
WHERE user_id = 1
ORDER BY created_at DESC
LIMIT 20 OFFSET 1000;
-- ✅ 커서 페이지네이션
SELECT * FROM orders
WHERE user_id = 1 AND created_at < $cursor
ORDER BY created_at DESC
LIMIT 20;
Spring 백엔드에서 Pageable 대신 — "마지막 created_at 을 다음 요청 cursor 로".
함정 5가지
(1) HAVING 에 WHERE 가능 조건
HAVING category = 'A' -- ❌
WHERE category = 'A' ... GROUP BY ... HAVING SUM(...) > 100 -- ✅
(2) ORDER BY 의 인덱스 방향 불일치
CREATE INDEX idx ON orders(created_at ASC);
SELECT ... ORDER BY created_at DESC; -- 인덱스 역방향 스캔 (가능하지만 일부 느림)
자주 쓰는 방향으로 인덱스.
(3) GROUPING SETS 미활용
복잡한 대시보드 = 여러 SELECT 조합. 한 SQL GROUPING SETS 으로 N배 빠름.
(4) ROLLUP NULL 혼동
ROLLUP 의 NULL = "소계·합계 행". 원본 NULL 과 구분 — GROUPING() 함수.
(5) UNION DISTINCT 비용
UNION -- 중복 제거 (해시 또는 정렬 필요 — 비용)
UNION ALL -- 중복 안 제거 (빠름)
중복 없는 확신 = UNION ALL.
(1) WHERE 인덱스 활용. (2) GROUP BY = ROLLUP/CUBE/GROUPING SETS 한 SQL 다차원. (3) HAVING ≠ WHERE. (4) ORDER BY 인덱스 방향 일치. (5) 커서 페이지네이션 — OFFSET 회피.
한 줄 정리 — WHERE 인덱스 활용·GROUP BY ROLLUP/CUBE/GROUPING SETS 다차원·HAVING 그룹 후 필터·ORDER BY 인덱스 방향·LIMIT 커서. 페이지네이션은 OFFSET 대신 커서. UNION ALL 빠름.
시험 직전 한 번 더 — 쿼리 절 종합 입문자가 매번 헷갈리는 것
- WHERE 비교 12가지 = =·<>·>·<·>=·<=·IN·BETWEEN·LIKE·ILIKE·SIMILAR TO·~·IS NULL
- IS DISTINCT FROM = NULL safe
- 인덱스 활용 = =·<·>·LIKE 'A%'·IN
- 인덱스 X = LIKE '%X'·LOWER(col)·col + 1
- GROUP BY = 비집계 컬럼 모두
- ROLLUP = 계층 소계·합계
- CUBE = 모든 차원 조합
- GROUPING SETS = 명시적 조합
GROUPING(col)= ROLLUP NULL 구분- HAVING = 그룹 후 필터
- WHERE 가능하면 WHERE (인덱스)
- ORDER BY + 인덱스 = 정렬 비용 0
- 인덱스 방향 일치 중요
- LIMIT OFFSET 깊으면 폭주
- 커서 페이지네이션 = WHERE id > cursor LIMIT N
- 다중 컬럼 키셋 = (a, b) > (a0, b0)
- DISTINCT ≈ GROUP BY
- UNION ALL = 빠름 (중복 제거 X)
- INTERSECT·EXCEPT = 교집합·차집합
- 집합 연산 = 컬럼 수·타입 일치
- FILTER 집계 =
COUNT(*) FILTER (WHERE ...) - WHERE 단축 회로 = 선별적 조건 먼저
- 자주 쓰는 ORDER 방향 = 인덱스 같은 방향
- Spring Pageable = 커서로 대체 권장
- 운영 페이지네이션 =
WHERE col > $last표준
시리즈 다른 편
- Part 2 SQL Language 깊이: 28편 쿼리 개요 · 29편 SELECT 절 · 30편 FROM 절 · 31편 (현재 글)
시리즈 다음 글
다음 글(32편)에서는 데이터 타입 개요 — PG 풍부한 타입 시스템의 큰 그림.
공식 문서: PostgreSQL 18 — Queries에서 더 자세한 사양을 확인할 수 있어요.