
목록 화면을 여는데 쿼리 로그에 같은 모양의 SELECT가 수백 줄 찍힌다면 N+1 문제다. ORM을 쓰는 프로젝트에서 가장 흔하게 만나는 성능 문제이고, 원인을 알면 고치는 것은 대체로 한 줄이다. 이 글은 왜 생기는지, 어떻게 발견하는지, 어떻게 고치는지를 순서대로 다룬다.
어떻게 생기는가
주문 목록을 보여주면서 각 주문의 사용자 이름을 함께 출력한다고 하자.
orders = Order.objects.all()[:100] # 쿼리 1번
for o in orders:
print(o.user.name) # 주문마다 1번씩, 총 100번
첫 줄에서 주문 100건을 가져오는 쿼리가 한 번 나간다. 그다음 반복문에서 o.user에 접근할 때마다 사용자를 가져오는 쿼리가 한 번씩 나간다. 1 + N번이다.
문제는 이 코드가 전혀 이상해 보이지 않는다는 점이다. ORM이 관계를 객체 속성처럼 보여주기 때문에, 그 접근이 데이터베이스 왕복이라는 사실이 코드에서 안 보인다. 개발 환경에서 데이터가 10건이면 체감도 없다.

어떻게 발견하는가
의심만으로는 못 찾는다. 세 가지 방법이 있다.
쿼리 로그를 켜고 요청 하나를 처리해 본다. 가장 확실하다. 개발 환경에서 요청 한 번에 쿼리가 몇 개 나가는지 센다. 목록 화면인데 수십 개가 나가면 거의 확실하다.
쿼리 카운트를 테스트로 고정한다. 이게 가장 좋은 방법이다. "이 엔드포인트는 쿼리 5개 이하"를 테스트로 박아두면, 나중에 누가 코드를 고치다 N+1을 만들면 CI가 잡는다.
# Django 예시
def test_order_list_query_count(client):
with assertNumQueries(3):
client.get("/orders/")
APM이나 트레이싱을 본다. 프로덕션에서는 분산 추적으로 한 요청 안의 쿼리 수를 본다. 스팬이 수백 개 찍히는 요청이 있으면 그게 N+1이다.
어떻게 고치는가
핵심은 한 번에 모아서 가져오는 것이다. ORM마다 이름이 다를 뿐 원리는 같다.
# Django — 조인으로 한 번에
orders = Order.objects.select_related("user")[:100]
# Django — 별도 쿼리로 모아서 (다대다, 역참조)
orders = Order.objects.prefetch_related("items")[:100]
// JPA — fetch join
@Query("SELECT o FROM Order o JOIN FETCH o.user")
List<Order> findAllWithUser();
// 또는 EntityGraph
@EntityGraph(attributePaths = {"user"})
List<Order> findAll();
// Prisma
const orders = await prisma.order.findMany({
take: 100,
include: { user: true },
});
두 가지 방식이 있다는 점을 알아둘 필요가 있다.
조인(select_related, JOIN FETCH)은 쿼리 한 번으로 끝난다. 일대일이나 다대일 관계에 적합하다. 대신 결과 행이 곱해져서 데이터가 중복 전송될 수 있다.
별도 쿼리(prefetch_related)는 쿼리 두 번을 쓴다. 주문을 가져오고, 거기 등장한 사용자 ID를 모아 WHERE id IN (...)으로 한 번 더 가져온다. 일대다나 다대다에서 조인보다 효율적이다. 101번이 2번이 되므로 충분히 좋다.
고치다가 만드는 새로운 문제
N+1을 고치는 과정에서 자주 나오는 실수가 둘 있다.
필요 없는 관계까지 다 가져오기. include에 관계를 전부 나열하면 쿼리는 줄지만 전송량이 폭증한다. 화면에 안 쓰는 데이터를 가져오는 것은 다른 형태의 낭비다. 화면에 실제로 쓰는 것만 가져와야 한다.
일대다 조인에서 페이지네이션이 깨지기. 주문과 주문 항목을 조인하면 주문 하나가 항목 개수만큼 행으로 늘어난다. 여기에 LIMIT 100을 걸면 주문 100건이 아니라 행 100개가 나온다. 이 경우 별도 쿼리 방식을 써야 한다. JPA에서 JOIN FETCH와 페이징을 함께 쓰면 전체를 메모리로 가져와 자르는 동작이 일어나는데, 데이터가 많으면 그대로 장애다.
ORM 을 탓할 문제는 아니다
N+1 때문에 ORM을 쓰지 말자는 이야기가 나오곤 한다. 나는 동의하지 않는다.
ORM이 하는 일은 객체와 테이블의 매핑이고, 그건 반복적이고 실수하기 쉬운 작업이다. 직접 SQL을 쓰면 N+1은 안 생기지만 대신 매핑 코드를 전부 손으로 쓰게 된다. 그 과정에서 다른 종류의 버그가 생긴다.
문제의 본질은 ORM이 아니라 비용이 안 보이는 추상화다. o.user가 데이터베이스 왕복이라는 사실이 코드에서 드러나지 않는다. 그래서 해법도 추상화를 걷어내는 것이 아니라, 비용을 보이게 만드는 것이어야 한다. 쿼리 카운트 테스트가 정확히 그 일을 한다.
한 가지 덧붙이면, 복잡한 집계나 통계 쿼리까지 ORM으로 짜려고 애쓸 필요는 없다. 그런 쿼리는 SQL로 쓰는 편이 읽기도 쉽고 성능도 예측 가능하다. 단순 CRUD는 ORM, 복잡한 조회는 SQL로 나누는 것이 실용적이다. 도구를 하나만 고집하는 것보다 각자 잘하는 곳에 쓰는 편이 낫다.
참고 자료
- Django: Database access optimization —
select_related와prefetch_related의 차이와 사용 기준 - Prisma: Solving the n+1 problem — 관계 조회와 배치 로딩
- PostgreSQL: Using EXPLAIN — 고친 뒤 실행 계획으로 확인하는 법
'IT' 카테고리의 다른 글
| 구조화 로깅, 장애 때 찾을 수 있는 로그 남기기 (0) | 2026.10.15 |
|---|---|
| 캐시 전략, 무효화와 스탬피드 대응 (0) | 2026.10.14 |
| 인덱스를 만들었는데 쿼리가 느린 이유 (0) | 2026.10.12 |
| AI 코딩 에이전트 시대의 코드 리뷰 (0) | 2026.10.11 |
| Git 브랜치 전략, rebase와 merge 중 무엇을 쓸까 (0) | 2026.10.10 |
