개요
N+1 쿼리 문제는 ORM에서 자주 발생하는 성능 이슈로, 기본 쿼리 1회 이후 연관 데이터를 접근하는 시점마다 추가 쿼리가 N회 발생하는 상황을 의미함 Django ORM은 기본적으로 lazy-loading 전략을 사용하므로 관계 필드 접근 시점에 쿼리가 실행됨 이 글에서는 N+1의 동작 맥락과 lazy-loading의 특성, 그리고 select_related와 prefetch_related로 N+1을 예방하는 방법을 정리함
N+1 쿼리 문제 예시
방문 기록 Visitor와 방문자 정보 Person이 외래키로 연결된 상황 가정
visitors = Visitor.objects.filter(visit_date__year=2022)
for v in visitors:
print(v.person.name, v.visit_date)위 코드는 다음 흐름으로 동작함
- visitors 평가 시 방문 기록 목록 읽기 1회
- 루프에서 v.person 접근마다 Person 조회가 지연 실행되어 N회 발생
실제로는 아래 쿼리 패턴이 반복됨
SELECT id, person_id, visit_date
FROM visitors
WHERE EXTRACT(YEAR FROM visit_date) = 2022SELECT id, name
FROM person
WHERE id = %s방문 기록 수가 늘어날수록 추가 쿼리 수가 선형 증가하여 응답 지연 및 DB 연결 부하 상승으로 이어짐
Lazy Loading 이해
lazy-loading은 객체에 실제 접근하기 전까지 쿼리를 미루는 전략
- 장점: 불필요한 데이터 선로딩을 피해 초기 비용 절감
- 단점: 루프 내 관계 접근 같은 패턴에서 N+1로 확장되기 쉬움
Django ORM의 기본 동작은 lazy-loading이며, 관계 필드 접근 시점에 쿼리를 실행함
Eager Loading으로 N+1 피하기
Django는 select_related와 prefetch_related 두 가지 방법을 제공
- select_related: SQL JOIN으로 한 번의 쿼리에서 외래키 또는 일대일 관계를 함께 로딩
- prefetch_related: 별도 쿼리로 관련 객체를 일괄 조회 후 파이썬 메모리에서 매핑
두 방법 모두 lazy-loading으로 인한 다중 쿼리 발생을 사전에 차단하는 eager-loading 전략에 해당
select_related 사용처와 예시
외래키 FK 또는 일대일 OneToOne 관계에 적합 조인으로 한 번에 가져와 루프 내 추가 쿼리 없음
visitors = (
Visitor.objects
.filter(visit_date__year=2022)
.select_related("person")
)
for v in visitors:
_ = v.person.name # 추가 쿼리 발생하지 않음대표적인 SQL 형태
SELECT v.id, v.person_id, v.visit_date, p.id, p.name
FROM visitors v
INNER JOIN person p ON v.person_id = p.id
WHERE EXTRACT(YEAR FROM v.visit_date) = 2022정확한 이해 포인트
- select_related는 추가 쿼리를 반복 실행하지 않음
- 하나의 조인 쿼리로 모든 행을 가져오되 조인으로 인해 결과 행이 늘 수 있으며 동일한 person 데이터가 여러 행에 중복 포함될 수 있음
prefetch_related 사용처와 예시
다대다 ManyToMany 또는 역참조 ManyToOne 관계에 적합 두 번째 쿼리로 관련 객체를 일괄 조회한 뒤 파이썬에서 매핑
visitors = (
Visitor.objects
.filter(visit_date__year=2022)
.prefetch_related("person")
)
for v in visitors:
_ = v.person.name # 추가 쿼리 발생하지 않음대표적인 SQL 형태
SELECT id, person_id, visit_date
FROM visitors
WHERE EXTRACT(YEAR FROM visit_date) = 2022SELECT id, name
FROM person
WHERE id IN (%s, %s, ...)정확한 이해 포인트
- prefetch_related는 최소 두 번의 쿼리를 실행하되 중복 조회를 피하고 메모리 상에서 관계를 연결
- 조인을 사용하지 않으므로 원본 테이블의 행 폭증이나 중복 전송을 완화하는 데 유리한 경우가 있음
select_related vs prefetch_related 선택 기준
- 관계 타입 기준 선택이 기본
- FK, OneToOne 관계는 select_related 선호
- 역참조 ManyToOne, ManyToMany는 prefetch_related 선호
- 데이터 카디널리티 고려
- 조인으로 행 수가 크게 늘어날 경우 prefetch_related가 안정적일 수 있음
- 상대적으로 얕고 단일 참조인 경우 select_related로 단일 쿼리 유지가 단순
- 중복 쿼리 오해 방지
- select_related는 조인 1쿼리만 수행하며 같은 연관 객체를 위해 추가 쿼리를 반복하지 않음
- 중복은 쿼리 수가 아니라 조인 결과의 데이터 전송 중복 가능성 의미
간단 체크리스트와 주의사항
- 루프 안에서 관계 필드에 접근하는 코드가 있는지 점검
- 관계 타입에 맞춰 select_related 또는 prefetch_related를 명시적으로 사용
- 필요한 관계만 명시해 과도한 선로딩 지양
- 쿼리 수와 실행 계획을 주기적으로 확인해 회귀 방지
마무리
N+1 문제는 lazy-loading과 루프 내 관계 접근이 결합될 때 빈번히 발생함 관계 타입과 데이터 카디널리티를 기준으로 select_related와 prefetch_related를 적절히 사용하면 N+1을 안정적으로 차단 가능 최소한의 선로딩으로 필요한 시점에만 데이터를 당겨오는 균형 유지가 핵심임