본문으로 건너뛰기
김신건의 로그

N+1 쿼리 문제

· 수정 · 📖 약 5분 · 1,914자/단어 #orm #performance #query #n-plus-one #database
django-orm, N+1, N + 1, N plus 1, N+1 problem, N+1 문제, N+1 쿼리, N+1 query, ORM N+1, N plus one problem, n-plus-one

정의

N+1 쿼리 문제 = ORM 이 부모 entity N 개를 조회한 뒤, 각 부모의 연관 entity 를 하나씩 로드하면서 추가 SQL 이 N 번 발생하는 성능 안티패턴.

이름의 의미:

  • 1: 부모 목록 SELECT (1번)
  • N: 각 부모마다 연관 SELECT (N번)
  • 총 N+1 쿼리

SQL 한 번에 끝낼 작업을 부모 개수만큼 반복 발생시키므로, 데이터가 커질수록 응답시간이 선형이 아니라 쿼리 왕복 (round-trip) 만큼 폭발한다.

ORM (Hibernate/JPA, Active Record, Django ORM, Sequelize, TypeORM 등) 공통 문제. 원인은 대부분 lazy loading + 반복문의 조합.

시각화

lazy loading + 반복문이 만드는 N+1 → JOIN 으로 1쿼리에 해결.

왜 발생하나

전형적 시나리오:

// 1. Post 목록 조회 (SQL 1번)
List<Post> posts = postRepository.findAll();

// 2. 반복문에서 author 접근 (SQL N번)
for (Post p : posts) {
    System.out.println(p.getAuthor().getName());   // 매번 lazy proxy 초기화 → SELECT
}

발생 SQL:

sql
-- 코드 의도: posts 1번 + author 1번, 총 2쿼리
SELECT p.* FROM post p;
-- 각 post 의 author.getName() 접근
실행된 SQL
SELECT p.* FROM post p;                       -- 1번
SELECT u.* FROM user u WHERE u.id = 1;       -- N번 (post 마다)
SELECT u.* FROM user u WHERE u.id = 2;
SELECT u.* FROM user u WHERE u.id = 3;
... (post 개수만큼)

posts 100 개면 SQL 101 번. p.getAuthor() 를 호출하는 순간 ORM 이 lazy proxy 를 초기화하려고 개별 SELECT 를 날리기 때문.

눈에 안 보이는 위치에서도 발생

  • 템플릿 렌더링: Thymeleaf, JSP 에서 ${post.author.name} 순회
  • Jackson 직렬화: @JsonManagedReference 순회 시 lazy 컬렉션 접근
  • toString() / equals() / hashCode(): 연관 필드 사용하면 함정
  • 로깅: log.info("posts: {}", posts) 도 컬렉션 전체 순회

성능 영향 (실제 수치 감각)

DB 왕복 지연 (round-trip time, RTT):

  • 로컬 DB: 0.1 ~ 1 ms
  • 같은 리전 클라우드: 1 ~ 5 ms
  • 다른 리전: 20 ~ 100 ms

Post 100 건 조회 + 각 author lazy load 라면:

환경JOIN 1쿼리N+1 (101쿼리)차이
로컬 DB (1ms RTT)~10 ms~110 ms11 배
같은 리전 (3ms RTT)~15 ms~350 ms23 배
다른 리전 (50ms RTT)~80 ms~5,100 ms63 배

트래픽이 낮은 개발 환경에서는 문제가 안 보이지만 운영에서 QPS 가 오르면 즉시 스레드 풀 고갈 / DB connection 고갈로 연결된다.

응답시간뿐 아니라 DB 부하도 문제. 100 배 늘어난 쿼리 수는 커넥션 풀 대기, 락 경합, statement cache 압박을 동반한다.

감지 방법

1. SQL 로깅

가장 기본. 개발 / 스테이징에서 SQL 로그 켜고 순회 로직 실행:

# Spring / JPA
spring:
  jpa:
    show-sql: true
    properties:
      hibernate:
        format_sql: true
logging.level.org.hibernate.SQL: DEBUG
# Rails
ActiveRecord::Base.logger = Logger.new(STDOUT)
# Django
LOGGING = {
    "loggers": {"django.db.backends": {"level": "DEBUG", "handlers": ["console"]}}
}

같은 쿼리 패턴이 다른 파라미터로 반복되면 N+1 신호.

2. 자동 감지 도구

프레임워크도구
JPA / HibernateHypersistence Utils assertSelectCount()
Railsbullet gem: 개발 환경 자동 알림 + CI 실패
Rails 6.1+strict_loading: true 옵션
Djangodjango-debug-toolbar SQL 패널 + django-silk
Prismaprisma-query-count 유틸
TypeORMlogger: 'advanced-console' + 수동 추적

3. 통합 테스트에서 쿼리 수 assert

가장 견고한 방법. 쿼리 수를 명시적으로 검증한다.

// Spring 예시 (Hypersistence Utils)
@Test
void findAllWithAuthor_uses_single_query() {
    var assertion = SQLStatementCountValidator.reset();
    postService.getAllPostsWithAuthor();
    SQLStatementCountValidator.assertSelectCount(1);
}
# Rails / RSpec (with rspec-sqlimit)
it "no N+1" do
  expect { get "/posts" }.to make_database_queries(count: 2)
end
# Django
def test_list_no_n_plus_one(self):
    with self.assertNumQueries(2):
        response = self.client.get("/posts/")

리팩터링 시 N+1 재발을 막는 안전망.

해결 전략 3가지

전략 1: JOIN (한 쿼리에 다 가져오기)

부모와 자식을 한 번의 SQL JOIN 으로 조회. 왕복이 1번.

SELECT p.*, a.*
FROM post p
INNER JOIN user a ON p.author_id = a.id;
  • JPA: JOIN FETCH 또는 @EntityGraph
  • Rails: eager_load(:author) 또는 includes(:author).references(:author)
  • Django: select_related("author")

장점: 1쿼리 단점:

  • 컬렉션 (ToMany) 이면 카르테시안 곱 → row 곱해짐
  • 큰 결과에서 JOIN 자체가 무거움
  • Pageable 과 컬렉션 조합 시 문제 (JPA 는 in-memory paging 경고)

전략 2: IN 절 배치 조회 (부모 + 자식 2쿼리)

부모 먼저 조회, 자식은 WHERE parent_id IN (...) 로 한 번에.

-- 1
SELECT * FROM post;
-- 2
SELECT * FROM comment WHERE post_id IN (1, 2, 3, ..., 50);
  • JPA: @BatchSize, default_batch_fetch_size, @EntityGraph (fetch join 아닌 subselect)
  • Rails: preload(:comments) (항상 IN 방식) 또는 includes(:comments) (조건 없으면 자동 IN)
  • Django: prefetch_related("comments")

장점:

  • 카르테시안 곱 없음
  • 페이징과 잘 어울림
  • 컬렉션 다수 처리 가능

단점: 2쿼리 (그래도 N+1 대비 극적 개선)

전략 3: 캐시 / counter 컬럼

  • 자주 접근하는 count → 부모에 컬럼으로 저장 (Rails counter_cache, Hibernate @Formula)
  • 자주 접근하는 값 → 캐시 계층 (Redis, application cache)
  • 정말 자주 반복 조회 → CQRS / read model 분리

관계 로딩 자체를 제거하는 접근.

프레임워크별 해결

JPA / Hibernate

// 1. fetch join
@Query("SELECT p FROM Post p JOIN FETCH p.author")
List<Post> findAllWithAuthor();

// 2. @EntityGraph
@EntityGraph(attributePaths = {"author"})
List<Post> findAll();

// 3. @BatchSize (Hibernate 확장)
@Entity
class Post {
    @OneToMany(mappedBy = "post", fetch = LAZY)
    @BatchSize(size = 100)
    List<Comment> comments;
}

기본은 LAZY 로 매핑 + 쿼리마다 필요한 것만 명시. 자세히는 spring-jpa-fetch-typespring-jpa-fetch-join 참고.

Rails / Active Record

# 1. includes (자동 선택: preload 또는 eager_load)
Post.includes(:author)

# 2. preload (항상 IN 절)
Post.preload(:author)

# 3. eager_load (항상 LEFT OUTER JOIN)
Post.eager_load(:author)

# 4. strict_loading (Rails 6.1+): 의도 안 한 lazy 차단
class Post < ApplicationRecord
  has_many :comments, strict_loading: true
end

자세히는 rails-query-optimization 참고. 개발 환경에는 bullet gem 필수.

Django

# 1. select_related (FK, OneToOne → JOIN)
Post.objects.select_related("author")

# 2. prefetch_related (ToMany → IN 절)
Post.objects.prefetch_related("comments")

# 3. Prefetch: 세밀 제어
from django.db.models import Prefetch
Post.objects.prefetch_related(
    Prefetch(
        "comments",
        queryset=Comment.objects.filter(approved=True).select_related("author"),
    )
)

자세히는 django-orm-advanced 참고.

Node.js ORM (참고)

// Sequelize
Post.findAll({ include: [{ model: User, as: 'author' }] });

// TypeORM
postRepository.find({ relations: ['author'] });

// Prisma
prisma.post.findMany({ include: { author: true } });

세 라이브러리 모두 include 지정이 필수. Prisma 는 아예 lazy 개념이 없어 명시 없으면 관계가 로드되지 않는다 (다른 종류의 안전 장치).

프레임워크 공통 함정

함정 1: has_many :through / 컬렉션 N+1

부모 → 중간 테이블 → 손자 조회 시 두 레벨 N+1 이 동시에 발생.

# Rails: post → tags 를 통해 → categories
posts.each { |p| p.tags.each { |t| t.category } }

해결: 깊은 nested includes(tags: :category) 또는 단계별 preload.

함정 2: 직렬화에서 노출

컨트롤러에서는 fetch 명시했지만 serializer / view 에서 다른 관계를 추가로 접근하는 경우.

// PostDto
public String getAuthorProfileImage() {
    return post.getAuthor().getProfile().getImageUrl();
    //                       ^^^^^^^^^ 여기서 lazy proxy 초기화!
}

해결: DTO 로 미리 projection 하거나 fetch 시점에 모든 필요한 필드 포함.

함정 3: 자체 관계 (self-referencing)

트리 구조에서 재귀적으로 부모/자식 접근하면 depth 만큼 N+1 배가.

def get_ancestry(comment):
    return [comment] + get_ancestry(comment.parent) if comment.parent else [comment]
    # 각 재귀마다 SELECT

해결: recursive CTE (Postgres WITH RECURSIVE), materialized path, closure table 같은 트리 저장 패턴.

함정 4: 조건부 접근

if (post.getFeatured() && post.getComments().size() > 5) { ... }
//                        ^^^^^^^^^^^^^^^^^^^^^^^^^ featured 아닌 것도 있으면 안 걸리지만 걸리면 lazy 초기화

해결: 조건 검사에 필요한 값도 미리 fetch 하거나 SIZE() 를 SQL 수준에서 계산.

함정 5: Kotlin data class / Java record 의 자동 참조

@JsonAutoDetect, Lombok @ToString(includeAllFields=true) 등이 lazy 필드를 자동으로 건드릴 수 있음.

해결: @ToString(exclude = {"comments"}), @JsonIgnore 로 명시적 제외.

예방 도구 (환경별)

시나리오도구 / 설정
JPA 개발 환경SQL 로깅 + hibernate.session.events.log.LOG_QUERIES_SLOWER_THAN_MS
JPA 테스트Hypersistence Utils assertSelectCount()
JPA 운영p6spy, datasource-proxy, APM (Datadog / New Relic)
Rails 개발bullet gem, strict_loading
Rails 테스트rspec-sqlimit, make_database_queries matcher
Django 개발django-debug-toolbar, django-silk
Django 테스트assertNumQueries
프레임워크 무관pg_stat_statements (Postgres), slow query log (MySQL), APM 트레이스

왜 ORM 이 이런 함정을 만드나

ORM 의 핵심 가치는 객체 그래프처럼 데이터를 다루는 편의성. post.author.name 이 자연스럽게 SQL 로 바뀌면 좋겠지만, 그러려면 미리 어떤 관계가 필요할지 알아야 한다.

두 극단:

  • 모든 관계를 eager 로 미리 로드: 매 쿼리마다 불필요한 대량 데이터 (JPA 의 FetchType.EAGER 함정)
  • lazy 로만 두고 접근 시점에 초기화: N+1

해답은 옵트인 eager. 기본 lazy, 필요한 곳에서 명시적으로 fetch 선언. 모든 성숙한 ORM 이 이 방향으로 수렴한다 (JPA 는 @ManyToOne 기본이 EAGER 라 예외, 잘못된 기본값).

함정과 베스트 프랙티스

  • 연관 매핑은 기본 LAZY (JPA 는 @ManyToOne 도 명시적으로 LAZY 지정)
  • 쿼리 시점에 필요한 관계만 명시적으로 fetch (JOIN FETCH, includes, select_related)
  • JOIN vs IN 배치 상황별 선택: ToOne 은 JOIN, ToMany 는 IN 배치 우선
  • 컬렉션 여러 개 fetch 는 위험: 하나만 JOIN + 나머지는 IN 배치
  • 직렬화 / view 에서 관계 접근하지 마세요: DTO 로 미리 projection
  • 자동 감지 도구 항상 켜기: 개발에 bullet / debug-toolbar, 테스트에 assertNumQueries
  • 자주 접근하는 count 는 counter 컬럼: 관계 로딩 자체를 회피
  • 트랜잭션 밖에서 관계 접근 금지: LazyInitializationException 함정
  • APM 트레이스로 운영 관찰: SQL 로그로는 안 보이는 hot path 발견
  • QueryDSL / Criteria API 로 fetch 위치 명시: JPQL 이 익숙하지 않은 팀에서

관련 위키

이 글의 용어 (7개)
[비교] JPA EAGER vs Rails eager_loadframeworks
정의 JPA 와 Rails 는 둘 다 "eager" 라는 용어를 ORM 의 연관 로딩 전략에 사용한다. 이름은 비슷하지만 의미는 정반대. - JPA : 매핑 선언 시점에 영구 설…
[DB] EXPLAIN 읽기: scan, join, sort, costdatabase-internals
정의 EXPLAIN = 쿼리 실행 계획 표시. EXPLAIN ANALYZE = 실제로 실행 + 실제 시간 측정. Scan 종류 | Scan | 의미 | 적합 | |---|---|…
[Django] ORM 심화: N+1, prefetch, Subquery, Windowdjango
정의 Django ORM은 SQL을 잘 모르는 개발자에게도 안전하지만 비효율을 숨기기 쉽다. N+1, 비효율 JOIN, 큰 결과셋 메모리 폭발이 흔한 함정. selectrelat…
[JPA] fetch join: JOIN FETCH 로 연관 entity 즉시 로드spring
정의 fetch join 은 JPQL 에서 연관 entity 를 한 번의 SQL 로 함께 로드하는 문법. 로 선언한다. 일반 은 SQL JOIN 만 수행하고 결과 entity 만…
[JPA] FetchType: LAZY vs EAGER, N+1, fetch joinspring
정의 FetchType 은 JPA 가 연관 entity 를 언제 로드할지 결정하는 hint. - : entity 로딩 시 즉시 함께 SELECT - : 실제 사용 시점 (prox…
[JPA] PersistenceContext: 1차 캐시, dirty checking, flushspring
정의 PersistenceContext 는 가 관리하는 entity 의 메모리 저장소 + 변경 추적기. 트랜잭션 안에서만 살아 있다 ( 진입 시 생성, commit/rollbac…
[Rails] 쿼리 최적화: preload, eager_load, includes, referencesrails
정의 Active Record 는 연관 ( , ) 을 lazy 로딩한다. 부모 객체를 받아온 뒤 자식을 순회하면 자식마다 SELECT 가 1번씩 추가로 발생 = N+1 문제. R…

💬 댓글

사이트 검색 / 명령어

검색

스크롤 = 확대/축소 · 드래그 = 이동 · 0 = 원래 크기 · ESC = 닫기