14.2 쿼리 안티패턴
쿼리 한 줄이 운영을 망치는 사고는 매일 발생합니다. 자주 보는 쿼리 안티패턴과 대안을 정리합니다.
1. SELECT *
-- 안티패턴
SELECT * FROM orders;단점:
- 필요 없는 컬럼까지 네트워크·메모리·인덱스 활용 어렵게
- 컬럼 추가 시 모든 SELECT *의 결과 변경 — 깨지는 코드
- index-only scan 불가 (대부분의 경우)
대안: 필요한 컬럼만 명시합니다.
SELECT id, user_id, total FROM orders;2. NOT IN (서브쿼리 + NULL)
-- 안티패턴
SELECT * FROM users
WHERE id NOT IN (SELECT user_id FROM banned_users);banned_users.user_id에 NULL이 하나라도 있으면 결과가 비어 버린다 — NULL = ?가 unknown이기 때문.
대안: NOT EXISTS.
SELECT * FROM users u
WHERE NOT EXISTS (SELECT 1 FROM banned_users b WHERE b.user_id = u.id);NULL-safe + 일반적으로 더 빠른 plan (anti-join).
3. OR 폭주
-- 안티패턴
SELECT * FROM orders
WHERE user_id = 1 OR user_id = 2 OR user_id = 3 OR user_id = 4 OR user_id = 5;옵티마이저가 OR을 IN으로 변환하지만, 여러 컬럼에 OR이면 인덱스 활용 어렵습니다.
WHERE country = 'KR' OR phone LIKE '+82%';
-- 두 컬럼에 따로 인덱스 있어도 함께 활용 어려움대안:
-- 1. IN으로 묶음
WHERE user_id IN (1, 2, 3, 4, 5)
-- 2. UNION ALL
SELECT * FROM orders WHERE country = 'KR'
UNION ALL
SELECT * FROM orders WHERE country <> 'KR' AND phone LIKE '+82%';4. 함수 호출 WHERE
-- 안티패턴
WHERE LOWER(email) = 'a@b.com'; -- 인덱스 못 씀
WHERE created_at::date = '2026-05-23'; -- partition pruning 안 됨
WHERE EXTRACT(year FROM created_at) = 2026; -- 같음대안: expression index 또는 범위 비교로 재작성합니다.
-- expression index
CREATE INDEX ON users(LOWER(email));
-- 범위 비교
WHERE created_at >= '2026-05-23' AND created_at < '2026-05-24';5. LIKE '%word%'
WHERE name LIKE '%kim%';prefix wildcard로는 일반 B-tree 인덱스 활용 불가능합니다.
대안:
pg_trgm+ GIN- full-text search (
tsvector @@ tsquery)
CREATE EXTENSION pg_trgm;
CREATE INDEX ON users USING gin (name gin_trgm_ops);
-- 이제 인덱스 활용
WHERE name ILIKE '%kim%';6. N+1 query
users = db.query("SELECT * FROM users")
for u in users:
orders = db.query("SELECT * FROM orders WHERE user_id = %s", u.id)100명이면 1 + 100 = 101 쿼리. 네트워크 round trip 폭증합니다.
대안: JOIN 또는 IN 한 번에.
users = db.query("""
SELECT u.*, json_agg(o.*) AS orders
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
GROUP BY u.id
""")또는 ORM의 eager loading.
7. OFFSET N (페이지네이션)
-- 안티패턴 — 깊은 페이지
SELECT * FROM orders ORDER BY id DESC OFFSET 100000 LIMIT 20;PostgreSQL은 100000 row를 모두 읽고 버립니다. 페이지가 깊어질수록 느려집니다.
대안: keyset pagination (cursor 기반).
-- 첫 페이지
SELECT * FROM orders ORDER BY id DESC LIMIT 20;
-- 마지막 id 저장
-- 다음 페이지
SELECT * FROM orders WHERE id < :last_id ORDER BY id DESC LIMIT 20;인덱스 활용 + O(1) 비용입니다.
8. count(*)을 매번
-- 안티패턴 — 큰 테이블에서
SELECT count(*) FROM events; -- seq scanPostgreSQL은 MVCC라 정확한 count가 늘 비쌉니다. 1억 row면 seq scan 수 초.
대안:
- 근사 카운트 충분하면
pg_class.reltuples
SELECT reltuples::bigint AS approx_rows FROM pg_class WHERE relname = 'events';- 자주 필요하면 별도 카운터 테이블(triggers·application 갱신)
9. GROUP BY 후 큰 결과 후처리
-- 안티패턴
SELECT user_id, count(*)
FROM events
GROUP BY user_id;
-- application에서 100만 row 받아 sort·filterDB에서 처리 가능한 것은 DB에서.
SELECT user_id, count(*) AS cnt
FROM events
GROUP BY user_id
HAVING count(*) > 100
ORDER BY cnt DESC
LIMIT 10;10. SELECT ... FOR UPDATE를 매번
-- 안티패턴 — 단순 조회에도
BEGIN;
SELECT * FROM orders WHERE id = 42 FOR UPDATE;
COMMIT;FOR UPDATE는 row lock. 쓰기가 정말 필요할 때만.
대안: 단순 SELECT에는 lock 없이.
SELECT * FROM orders WHERE id = 42; -- MVCC가 일관성 보장 (3.2)11. Implicit conversion (타입 mismatch)
-- orders.id is bigint
WHERE id = '42'; -- text vs bigintPostgreSQL이 cast해 주지만 인덱스 활용을 망칠 수 있습니다.
WHERE id = 42; -- 같은 타입ORM이 자동 cast하는 경우 EXPLAIN으로 점검합니다.
12. CTE에 의존 (옛 PG)
PG 11까지는 CTE가 항상 materialize됐습니다. 큰 CTE는 임시 결과 저장 → 느림.
WITH big_set AS (
SELECT * FROM events WHERE created_at > now() - '1 hour'
)
SELECT count(*) FROM big_set WHERE user_id = 42;PG 12+는 CTE가 기본적으로 inline 처리합니다. 옛 코드 점검 필요합니다.
WITH big_set AS NOT MATERIALIZED (...) -- 명시 inline
WITH big_set AS MATERIALIZED (...) -- 명시 구체화 (옛 동작)13. DISTINCT로 deduplication
-- 안티패턴 — JOIN으로 중복 생긴 후
SELECT DISTINCT user_id FROM users u JOIN orders o ON o.user_id = u.id;JOIN 자체가 큰 중간 결과 만든 뒤 DISTINCT — 비용 큽니다.
대안: EXISTS 또는 GROUP BY.
SELECT u.id FROM users u
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);14. JSONB의 동적 키 검색
-- 안티패턴
WHERE payload->>'dynamic_field_42' = 'X';각 키마다 인덱스 만들기 비현실.
대안: 자주 쓰는 키는 컬럼으로 추출, 동적 키만 GIN.
15. 매우 긴 IN 리스트
WHERE id IN (1, 2, 3, ..., 10000); -- 1만 개옵티마이저 plan time 폭증합니다.
대안: 임시 테이블 또는 VALUES 절.
WITH ids(id) AS (VALUES (1),(2),(3),...)
SELECT * FROM orders WHERE id IN (SELECT id FROM ids);또는 array unnest:
WHERE id = ANY('{1,2,3,...,10000}'::bigint[]);16. CASE WHEN 폭주
SELECT
CASE status
WHEN 'paid' THEN ...
WHEN 'pending' THEN ...
...50 cases...
END
FROM orders;가독성 ↓, 옵티마이저 어렵습니다.
대안: lookup table JOIN.
17. 인덱스가 없는 정렬
-- 안티패턴 — 대용량 정렬
SELECT * FROM orders ORDER BY created_at DESC LIMIT 100;
-- created_at에 인덱스 없으면 전체 정렬Sort 노드가 큰 데이터를 모두 정렬. work_mem 초과 시 디스크 spill.
대안: 정렬 컬럼에 인덱스. PG는 인덱스 순서로 직접 반환.
정리
SELECT *회피,NOT EXISTS사용- 함수 WHERE는 expression index로
- 깊은 OFFSET 대신 keyset pagination
- 정확 count 필요하면 별도 카운터, 근사면
reltuples - N+1 회피 — JOIN 또는 batch
- CTE는 PG 12+ inline. NOT MATERIALIZED 명시 가능
- 매우 긴 IN은 VALUES 또는 ANY(배열)
- 정렬·필터에 인덱스가 쿼리 패턴과 정렬되도록
다음 절(14.3)에서는 인덱스 자체의 안티패턴 — 인덱스 안티패턴을 봅니다.