백엔드 데이터 인프라 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 에 의존하지 않으니 속도가 일정하다. 대용량에서 권장.
키셋 페이지네이션 (커서 + 다중 컬럼)
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(Spring Data 의 페이지 요청 인터페이스) 대신 "마지막 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표준
시리즈 다른 편 (앞뒤 글 모음)
이전 글:
- 26편 — UPDATE 깊이 HOT·fillfactor·FROM JOIN
- 27편 — DELETE 깊이 bloat·VACUUM·파티션 DROP
- 28편 — 쿼리 개요 SELECT 전체 구조
- 29편 — SELECT 절과 표현식의 깊이
- 30편 — 테이블 표현식 FROM 절의 깊이
다음 글: