목록 화면 하나에 쿼리가 300개 나가고 있었습니다—N+1 문제와 배치 로딩을 정리한 글입니다.
N+1은 어떻게 조용히 생기는가
N+1은 이름부터 결과를 그대로 담고 있습니다. 목록을 한 번 조회하는 쿼리 1개, 그리고 그 목록의 각 항목마다 연관 데이터를 다시 읽는 쿼리 N개. 항목이 20개면 21개, 100개면 101개의 쿼리가 나가죠. 무서운 점은 이 패턴이 코드상으로는 전혀 위험해 보이지 않는다는 것입니다.
주문 목록을 보여주는 아주 평범한 코드를 예로 들어보겠습니다.
# 주문 목록 조회
orders = Order.objects.filter(status="paid")
for order in orders:
# 반복문 안에서 회원 정보 접근
print(order.customer.name)
order.customer.name, 이 한 줄이 전부입니다. 그런데 ORM은 order.customer에 처음 접근하는 순간 회원 테이블로 SELECT * FROM customer WHERE id = ? 쿼리를 한 번 더 날립니다. 반복문이 20번 돌면 이 회원 조회만 20번 반복되죠. 개발자 눈에는 그저 속성 하나를 읽는 코드처럼 보이는데, 실제로는 그 뒤에서 네트워크 왕복이 스무 번 일어나는 셈입니다.
이걸 숨기는 주범이 바로 ORM의 지연 로딩(lazy loading)입니다. 지연 로딩은 원래 좋은 의도로 만들어진 기능입니다. 연관 객체를 미리 다 읽어오면 낭비가 심하니, 실제로 접근하는 순간까지 조회를 미뤄두는 최적화죠. 문제는 이 "미뤄둠"이 반복문을 만나면 정반대로 작동한다는 점입니다. 각 반복에서 처음 접근하니, 각 반복마다 새 쿼리가 나가는 것입니다.

지연 로딩이 위험한 이유를 정리하면 이렇습니다.
- 코드 리뷰에서 안 보입니다. 쿼리를 명시적으로 쓰지 않으니 리뷰어도 놓치기 쉽습니다.
- 로컬에서는 멀쩡합니다. 테스트 데이터가 5건이면 쿼리 6개, 아무도 이상함을 못 느끼죠.
- 데이터가 커질수록 선형으로 악화됩니다. 운영에서 항목이 200건으로 늘어나면 그대로 201개가 됩니다.
특히 연관이 중첩되면 곱셈으로 번집니다. 주문마다 회원을 읽고, 회원마다 등급 정보를 또 읽으면 N+1이 아니라 1+N+N 구조가 되죠. 제가 겪은 사고도 바로 이 중첩 구조에서 터졌습니다. 여기에 직렬화 단계에서 이미지 URL이나 배송지 같은 또 다른 연관까지 건드리면, 화면 하나를 그리는 데 쿼리가 수백 개로 불어나는 것도 순식간입니다.
쿼리를 배치로 묶는 법
해법의 핵심은 단순합니다. N번 나갈 쿼리를 한 번으로 묶는 것. 접근 방식은 크게 네 가지이고, 각각 장단이 뚜렷합니다.
1) Eager 로딩 (JOIN 방식)
연관 엔티티를 처음부터 JOIN으로 함께 읽어옵니다. Hibernate의 JOIN FETCH, Django의 select_related가 여기에 해당하죠.
SELECT o.*, c.* FROM orders o
JOIN customer c ON o.customer_id = c.id
WHERE o.status = 'paid'
- 장점: 쿼리 1개로 끝납니다. 관계가 1:1이나 N:1일 때 가장 깔끔합니다.
- 단점: 1:N(컬렉션)을 JOIN하면 부모 행이 자식 수만큼 뻥튀기됩니다. 페이징과 함께 쓰면 결과가 어긋나기도 하죠.
2) IN 절 배치 조회
부모를 먼저 다 읽고, 거기서 모은 외래키를 IN 절 하나로 묶어 자식을 한 번에 가져옵니다.
SELECT * FROM customer WHERE id IN (11, 27, 42, 88, ...)
- 장점: 컬렉션 뻥튀기가 없고 페이징과도 잘 맞습니다. 쿼리는 2개(부모 1 + 자식 1)로 고정됩니다.
- 단점: IN 목록이 수천 개로 길어지면 DB가 부담스러워하므로 청크로 쪼개야 합니다.
3) 명시적 JOIN FETCH
JPQL/HQL에서 JOIN FETCH로 어떤 연관을 함께 읽을지 직접 지정합니다. eager 로딩을 쿼리 단위로 켜는 방식이라 전역 설정보다 통제가 쉽죠.
4) 애플리케이션 레벨 배치 (DataLoader)
GraphQL 진영에서 많이 쓰는 방식입니다. 개별 요청을 즉시 실행하지 않고 한 틱 동안 모았다가, 모인 키를 IN 절 한 방으로 처리한 뒤 각 호출부에 나눠 돌려줍니다.
- 장점: 코드 곳곳에 흩어진 개별 접근을 자동으로 묶어줍니다. 여러 리졸버가 각자 회원을 조회해도 실제 쿼리는 한 번입니다.
- 단점: 프레임워크와 사고방식을 새로 얹어야 하고, 캐시 수명 관리가 까다롭습니다.

제 기준을 말씀드리면, N:1 단일 연관은 select_related(JOIN)로, 1:N 컬렉션은 IN 절 배치로 가는 편이 가장 무난했습니다. 전역 eager 설정은 처음엔 편하지만 필요 없는 화면까지 무거운 JOIN을 끌고 다녀서, 결국 화면별로 필요한 것만 명시하는 쪽으로 정착했죠. 어느 방식이든 원칙은 하나입니다. 반복문이 도는 횟수만큼 쿼리가 늘어나면 안 되고, 연관 데이터는 반복이 시작되기 전에 한 번에 모아둬야 한다는 것입니다.
쿼리 로그를 켜 본 순간
2024년 가을, 커머스 백오피스의 주문 목록 API가 유독 느리다는 제보를 받았습니다. p95 응답 시간이 1,900ms를 넘겼고, 항목이 많은 특정 판매자 계정에서는 3초 가까이 찍혔습니다. 캐시를 붙이자는 이야기부터 나왔지만, 저는 먼저 실제로 무슨 쿼리가 나가는지 눈으로 보고 싶었습니다.
Django였기에 settings에 로깅을 한 줄 켰습니다.
LOGGING = {
"loggers": {"django.db.backends": {"level": "DEBUG"}},
}
목록을 한 번 새로고침하고 로그를 세어보니, 거의 똑같은 SELECT ... FROM customer WHERE id = ?가 끝없이 이어졌습니다. 총 쿼리 수는 287개. 한 화면에 주문 100건을 뿌리는데, 주문 1건마다 회원 1번, 회원마다 등급 1번씩 붙어서 1 + 100 + ... 구조로 불어난 것이었죠. 로그로 직접 확인하기 전까지는 "설마 이 정도겠어" 싶었는데, 숫자를 세는 순간 원인이 명확해졌습니다.
수정은 오히려 간단했습니다. 반복문 안의 지연 접근을 걷어내고, 목록 쿼리에서 필요한 연관을 한 번에 끌어오도록 바꿨습니다.
# 회원(N:1)은 JOIN, 등급 정보는 배치 조회
orders = (Order.objects
.filter(status="paid")
.select_related("customer")
.prefetch_related("customer__grade"))
select_related가 회원을 JOIN으로 붙이고, prefetch_related가 등급을 IN 절 하나로 모아 읽으니 반복문 안에서 새 쿼리가 더는 나가지 않았습니다. 다시 로그를 켜고 세어보니 쿼리 수는 287개에서 3개로 줄었습니다. 목록 1, 회원 JOIN에 포함, 등급 IN 조회 1, 그리고 페이징 카운트 1이었죠.
결과 수치는 기대 이상이었습니다.
| 지표 | 개선 전 | 개선 후 |
|---|---|---|
| 쿼리 수 | 287개 | 3개 |
| p95 응답 | 1,920ms | 180ms |
| 최악 계정 | 2,970ms | 210ms |
p95가 1,920ms에서 180ms로, 약 10배 빨라졌습니다. 캐시는 한 줄도 붙이지 않았고요. 이 경험 이후로 저는 새 목록 API를 만들 때마다 개발 단계에서 쿼리 카운트를 로그로 찍어보는 습관이 생겼습니다. 개인적으로 N+1은 "느려서 잡는 문제"가 아니라 "안 보여서 못 잡는 문제"라고 생각합니다. 로그 한 줄을 켜는 5분이, 캐시와 인프라 증설로 우회하며 날릴 며칠을 아껴주죠.

정리하면, N+1은 특별한 실수가 아니라 지연 로딩이 깔린 환경이라면 누구에게나 조용히 생기는 기본값에 가깝습니다. 무서운 건 느림 자체가 아니라 그 느림이 코드에 드러나지 않는다는 점이죠. 그러니 목록을 다루는 코드를 짤 때는 한 번쯤 쿼리 로그를 켜보시길 권합니다. 287이라는 숫자를 직접 마주하는 것보다 확실한 교훈은 없더군요.