이 글을 읽으면 알 수 있는 것
- Django ORM이 만드는 SQL의 함정과 N+1 문제 해결법
- 160만 건에서 COUNT(*)가 치명적인 이유와 커서 페이지네이션
- 비정규화를 결정하는 기준: 원칙 vs 측정
- pg_trgm GIN 인덱스로 ILIKE 검색 가속하기
- CONN_MAX_AGE 설정이 프리티어 RDS에서 특히 중요한 이유
문제: 검색 한 번에 4.5초
서비스를 주변 소수의 지인들에게 처음 공개하고, 가장 먼저 들은 말이 “검색이 너무 느려요”였습니다.
검색창에 “우유”를 치면 4~5초. 간혹 타임아웃. 160만 건의 제품 데이터를 만들어놓고 검색이 안 되면 의미가 없습니다.
여기서 시작된 최적화가 4단계에 걸쳐 이어졌고, 매 단계마다 병목이 달랐습니다. 한 가지를 고치면 다음 병목이 드러나는 양파 구조였습니다.
1단계: N+1 쿼리 제거
EXPLAIN ANALYZE로 확인하니, 검색 한 번에 SQL이 수십 개 날아가고 있었습니다.
Django ORM은 관련 데이터를 기본적으로 “필요할 때마다 따로” 가져옵니다. 제품 20개를 보여주려면, 각 제품의 제조사·카테고리·영양정보를 제품마다 한 번씩 별도로 조회합니다.
SELECT * FROM product WHERE name ILIKE '%우유%' LIMIT 20;
SELECT * FROM company WHERE id = 1; -- 제품 1의 제조사
SELECT * FROM company WHERE id = 2; -- 제품 2의 제조사
... (20번 반복)
select_related와 prefetch_related로 JOIN 한 방에 가져오게 변경. 쿼리 수가 20배 이상 감소했지만, 전체 응답 시간은 여전히 수 초 단위.
2단계: COUNT(*) 제거 → CursorPagination
“2,847건 중 1-20”을 보여주기 위한 COUNT(*) 쿼리가 문제였습니다.
SELECT COUNT(*) FROM product WHERE UPPER(name) LIKE UPPER('%우유%');
-- 실행 시간: 2.3초 (160만 건 풀스캔)
Django REST Framework의 기본 PageNumberPagination은 매 요청마다 이 COUNT를 실행합니다. 160만 건 테이블에서 조건부 COUNT는 인덱스를 타지 못하고 풀스캔.
CursorPagination으로 전환해서 COUNT를 완전히 제거했습니다. “다음 페이지” 커서만 제공하고, 전체 건수는 세지 않는 방식입니다. “총 몇 건”을 포기하는 트레이드오프였지만, 2.3초를 절약한 건 확실한 이득.
3단계: 서브쿼리 제거 → company_name 비정규화
N+1과 COUNT를 잡았는데, 제품 20개를 가져오는 쿼리 자체가 여전히 수백 ms. SQL을 보니 제조사 이름이 서브쿼리로 남아 있었습니다.
SELECT p.*,
(SELECT c.name FROM company c WHERE c.id = p.company_id) as company_name
FROM product p
WHERE ...
매 행마다 company 테이블을 다시 조회. 해결: product 테이블에 company_name 컬럼을 직접 추가하는 비정규화.
교과서적으로는 “나쁜 설계”입니다. 하지만 측정된 숫자가 말해주고 있었습니다.
- 비정규화 전: 4.5초
- 비정규화 후: 0.2초
비정규화는 “나쁜 설계”가 아니라 “측정된 트레이드오프”입니다.
4단계: GIN 인덱스 + raw ILIKE
0.2초까지 줄었지만, Django의 icontains 필터가 만드는 SQL에 함정이 있었습니다.
-- Django icontains가 생성하는 SQL
WHERE UPPER(name) LIKE UPPER('%우유%')
UPPER()로 감싸는 순간, PostgreSQL의 일반 인덱스는 무용지물. 매번 풀스캔.
PostgreSQL의 pg_trgm 확장과 GIN 인덱스를 활용하면, ILIKE 패턴 검색에 인덱스를 탈 수 있습니다.
-- GIN 인덱스 생성
CREATE INDEX idx_product_name_gin ON product
USING gin (name gin_trgm_ops);
-- ORM icontains 대신 raw ILIKE
WHERE name ILIKE '%우유%'
0.2초 → 0.05초. 체감상 “즉시” 수준.
보너스: 첫 검색만 2초 걸리는 문제
모든 최적화 후에도, 서버 재시작 직후 첫 번째 검색만 2초가 걸리는 현상이 남았습니다. 코드가 아니라 DB 연결 설정 문제.
Django 기본값은 CONN_MAX_AGE=0 — 매 HTTP 요청마다 DB 연결을 새로 맺고 끊습니다. AWS 프리티어 RDS와의 첫 연결(TCP handshake + SSL + 인증)에 1~2초.
DATABASES = {
'default': {
...
'CONN_MAX_AGE': None, # 연결 영구 재사용
'CONN_HEALTH_CHECKS': True, # 끊어진 연결 자동 감지
}
}
첫 검색도 0.1초 안에 해결.
최종 결과
| 단계 | 병목 | 조치 | 결과 |
|---|---|---|---|
| 1 | N+1 쿼리 | select_related / prefetch_related | 쿼리 수 20배 감소 |
| 2 | COUNT(*) 풀스캔 | CursorPagination | 2.3초 절약 |
| 3 | 제조사 서브쿼리 | company_name 비정규화 | 4.5초 → 0.2초 |
| 4 | UPPER(LIKE) 풀스캔 | GIN 인덱스 + ILIKE | 0.2초 → 0.05초 |
| 5 | DB 연결 지연 | CONN_MAX_AGE=None | 첫 검색 2초 → 0.1초 |
교훈
- ORM이 생성하는 SQL을 직접 보지 않으면 병목을 못 찾는다. Django의 편의 기능이 오히려 성능을 잡아먹는 경우가 있다.
- COUNT(*)는 대용량 테이블에서 치명적이다. 커서 페이지네이션이 정답.
- 비정규화는 측정 결과로 정당화된다. 원칙이 아니라 숫자로 판단.
- 마지막 병목은 코드가 아닐 수도 있다. DB 연결 설정 한 줄이 2초를 먹고 있었다.