← 기술 블로그

Django + PostgreSQL 160만 건 검색, 4.5초→0.05초까지

Phase: 8 — 운영 최적화 시리즈: 운영 최적화

이 글을 읽으면 알 수 있는 것


문제: 검색 한 번에 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_relatedprefetch_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단계: 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초 안에 해결.


최종 결과

단계병목조치결과
1N+1 쿼리select_related / prefetch_related쿼리 수 20배 감소
2COUNT(*) 풀스캔CursorPagination2.3초 절약
3제조사 서브쿼리company_name 비정규화4.5초 → 0.2초
4UPPER(LIKE) 풀스캔GIN 인덱스 + ILIKE0.2초 → 0.05초
5DB 연결 지연CONN_MAX_AGE=None첫 검색 2초 → 0.1초

교훈