N+1 쿼리 문제: 증상, 탐지, 해결까지
“DB가 느리다"는 신고의 상당수는 DB가 아니라 쿼리 횟수 문제, 그중에서도 N+1입니다. 목록 한 번(1)과 목록의 각 행마다 한 번씩(N), 합쳐서 N+1번의 쿼리가 나가는 패턴입니다. 쿼리 하나하나는 밀리초라서 개발 환경에서는 멀쩡해 보이다가, 행 수와 트래픽이 늘면 DB 커넥션과 왕복 시간이 곱셈으로 불어납니다. 이 글은 만들어지는 원리부터 탐지, 해결, 예방까지의 전체 흐름입니다.
만들어지는 원리: 지연 로딩 #
# ORM 코드는 무해해 보입니다
orders = session.query(Order).limit(100).all() # 쿼리 1번
for order in orders:
print(order.user.name) # 매번 쿼리 1번씩, 총 100번ORM의 관계 필드는 기본이 지연 로딩(lazy loading)인 경우가 많습니다. order.user에 접근하는 순간 그 사용자를 SELECT하는 방식으로, 코드에는 쿼리가 보이지 않는데 런타임에는 반복문 횟수만큼 나갑니다. 문제의 본질은 ORM이 아니라 “컬렉션을 순회하며 관계에 접근하는” 코드 모양이고, ORM 없이 손으로 짠 코드에서도 같은 모양이면 같은 문제가 납니다.
탐지: 로그와 pg_stat_statements #
개발 단계의 탐지는 애플리케이션 쪽이 빠릅니다. ORM의 쿼리 로그(SQLAlchemy echo=True, Django connection.queries 등)를 켜고 요청 하나에 나가는 쿼리 수를 세면 됩니다. “요청당 쿼리 수"를 테스트에서 상한으로 강제하는 방법(쿼리 카운트 assert)은 회귀 방지까지 해 줍니다.
운영 단계의 탐지는 DB 쪽 누적 통계가 정확합니다. pg_stat_statements에서 calls가 비정상적으로 큰 쿼리를 찾는 것입니다.
SELECT calls, round(mean_exec_time::numeric, 2) AS mean_ms,
left(query, 60) AS query
FROM pg_stat_statements
ORDER BY calls DESC LIMIT 10;WHERE id = $1 모양의 단건 조회가 호출 수 상위를 독차지하고 있다면 전형적인 N+1의 흔적입니다. 평균은 1ms라 mean 기준으로는 안 보이고, calls와 total 기준으로만 보이는 범인이라는 것이 N+1 탐지의 요점입니다.
해결: 세 가지 형태 #
① JOIN으로 한 방에. 목록과 관계를 한 쿼리로 가져옵니다. 조인 기본기의 영역이고, 1:1·N:1 관계(주문→사용자)에 잘 맞습니다. 단, 1:N 컬렉션(사용자→주문들)을 JOIN으로 당기면 행이 곱으로 불어나는 문제가 있어 다음 형태가 낫습니다.
② IN으로 일괄 로딩. 쿼리 2번으로 끝내는 형태입니다. 목록을 가져온 뒤, 관계를 WHERE user_id IN (...) 한 방으로 모아서 가져와 애플리케이션에서 붙입니다. N+1이 2로 줄고, 1:N 관계에서도 행 폭발이 없습니다.
SELECT * FROM orders LIMIT 100; -- 1번
SELECT * FROM users WHERE id IN (…목록의 user_id들…); -- 2번③ ORM의 즉시 로딩 옵션. 위 두 형태를 ORM이 대신 짜 주게 하는 것입니다. SQLAlchemy라면 joinedload(JOIN형)와 selectinload(IN형), Django라면 select_related와 prefetch_related가 각각 대응합니다. SQLAlchemy 강좌에서 다룬 로딩 전략이 바로 이 문제의 도구 상자입니다. 실무 감각으로는 N:1은 JOIN형, 1:N 컬렉션은 IN형이 기본 짝입니다.
예방: 쿼리 수가 아니라 데이터 모양으로 설계 #
해결보다 중요한 것은 다시 만들지 않는 습관입니다. 원칙은 하나입니다. “이 화면(엔드포인트)이 필요로 하는 데이터의 모양"을 먼저 정하고, 그 모양을 최소 횟수의 쿼리로 가져오는 코드를 씁니다. 반복문 안에서 관계 필드에 접근하는 코드가 보이면 그 자리가 경보 지점입니다. 덧붙여, 요청당 쿼리 수 상한을 CI 테스트로 걸어 두면 리팩터링 중에 조용히 되살아나는 N+1을 기계적으로 잡을 수 있습니다.
참고로 N+1을 다 잡은 뒤에도 남는 “호출 수는 정상인데 느린” 문제는 다른 계열이고, 그때는 성능 진단 루틴의 mean 기준과 EXPLAIN으로 넘어갑니다.
정리 #
- N+1은 목록 1번 + 행마다 1번의 쿼리 패턴입니다. 원인은 대개 ORM 지연 로딩과 “반복문 안 관계 접근” 코드 모양입니다.
- 개발 단계는 쿼리 로그와 쿼리 카운트 테스트로, 운영 단계는 pg_stat_statements의 calls 상위로 탐지합니다.
- 해법은 JOIN(N:1), IN 일괄 로딩(1:N), 그리고 이를 대신 짜 주는 ORM 즉시 로딩 옵션입니다.
- mean 기준으로는 안 보입니다. calls·total 기준으로 봐야 보이는 범인이라는 것이 탐지의 요점입니다.
- 예방은 화면이 필요로 하는 데이터 모양을 먼저 정하는 설계 습관, 그리고 요청당 쿼리 수 상한 테스트입니다.