N+1 쿼리를 발견하는 법 — 로그를 보면 바로 보인다
ORM에서 흔한 N+1 문제의 판별과 해결. 즉시 로딩이 항상 답은 아닌 이유.
증상
목록 화면이 항목 수에 비례해 느려진다. 10건일 때는 괜찮은데 100건에서 눈에 띄게 느리다.
원인은 대개 이 패턴이다.
```
1회: SELECT * FROM posts LIMIT 100
100회: SELECT * FROM users WHERE id = ?
```
목록 1번 + 각 항목마다 1번 = N+1번의 쿼리.
발견하는 법
추측하지 말고 쿼리 로그를 본다. 대부분의 ORM이 개발 환경에서 SQL 로그를 켤 수 있다.
로그에서 볼 것:
```
같은 형태의 쿼리가 반복되는가
반복 횟수가 목록 건수와 일치하는가
```
이 둘이 맞으면 N+1이다. 로그를 켜는 것만으로 대부분 즉시 발견된다.
배포 환경이라면 APM 도구의 트랜잭션 상세에서 같은 패턴을 볼 수 있다.
해결 1 — 함께 가져오기
ORM마다 이름이 다르지만 개념은 같다. 연관 데이터를 미리 함께 조회한다.
```
문제
posts = Post.all
for p in posts: print(p.author.name) # 여기서 매번 쿼리
해결
posts = Post.all.includes(:author) # 미리 함께 조회
```
내부적으로는 두 가지 방식이 쓰인다.
- JOIN 한 번 — 쿼리 1회. 다만 1:N에서 행이 중복되어 결과가 커질 수 있음
- IN 절로 두 번 —
SELECT * FROM users WHERE id IN (...). 중복 없이 2회
ORM에 따라 어느 쪽을 쓰는지 다르고, 선택할 수 있는 경우도 있다.
해결 2 — 필요한 컬럼만
연관 객체 전체가 아니라 특정 필드만 필요한 경우가 많다.
```sql
SELECT p.id, p.title, u.name
FROM posts p JOIN users u ON u.id = p.user_id
```
객체 전체를 가져오는 대신 필요한 것만 조회하면 전송량과 메모리가 함께 줄어든다.
즉시 로딩이 답이 아닌 경우
includes를 습관적으로 붙이면 다른 문제가 생긴다.
1. 안 쓰는 데이터를 가져옴
목록에서 저자 이름만 쓰는데 저자 객체 전체와 그 하위 연관까지 로딩되면 오히려 느려진다.
2. 조인 결과 폭증
1:N 관계 여러 개를 한 번에 조인하면 행 수가 곱으로 늘어난다.
```
게시글 100 × 댓글 평균 10 × 태그 평균 5 = 5,000행
```
이 경우 조인 대신 별도 쿼리(IN 절)로 나누는 편이 낫다.
3. 페이지네이션과 충돌
1:N 조인 후 LIMIT을 걸면 의도한 건수가 안 나온다. 행이 중복되기 때문이다.
판단 순서
- 쿼리 로그로 N+1 확인
- 그 화면에서 실제로 쓰는 필드를 확인
- 필요한 것만 함께 조회
- 1:N이 여러 개면 조인 대신 분리 조회
- 다시 로그로 쿼리 수 확인
5번이 빠지면 고쳤다고 생각했는데 다른 곳에서 N+1이 남아 있는 경우가 흔하다.
예방
코드 리뷰에서 볼 만한 신호:
- 반복문 안에서 연관 객체에 접근하는가
- 목록 조회 후 각 항목을 가공하는 로직이 있는가
- 뷰 템플릿에서 연관 데이터를 참조하는가 (특히 놓치기 쉬움)
세 번째가 자주 빠진다. 컨트롤러 코드는 깨끗한데 템플릿에서 N+1이 발생하는 경우다.
최종 수정 2026-08-28