본문으로 건너뛰기

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 scan

PostgreSQL은 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·filter

DB에서 처리 가능한 것은 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 bigint

PostgreSQL이 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)에서는 인덱스 자체의 안티패턴 — 인덱스 안티패턴을 봅니다.