> For the complete documentation index, see [llms.txt](https://leehyokyun.gitbook.io/openbook/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://leehyokyun.gitbook.io/openbook/2-12.-jpa-2-n+1-problem.md).

# 2-12. JPA #2 : N+1 Problem

JPA의 N+1 Problem에 대하여

### 📌 N+1 Problem에 대한 고찰

### 1. N+1 Problem의 본질과 고찰

관계형 데이터베이스와 객체지향 패러다임을 연결하는 JPA 환경에서 1:N 연관관계를 지닌 엔티티를 조회할 때, 의도하지 않은 수많은 추가 쿼리가 발생하는 N+1 문제는 단순한 기능적 오류가 아닌 성능적 비효율성을 유발하는 구조적 고찰 과제이다.

* N+1 Problem에 대한 핵심은 Eager, lazy가 아닌, 참조 관계에 있는 필요 객체 리스트를 조회할때 필요한 수 만큼 DB I/O가 발생한다는 점이다.
* 현상과 비효율성의 관점: 1관계에 있는 기준 엔티티를 조회하는 1개의 쿼리(주 쿼리)를 수행한 후, 조회된 엔티티가 N건일 때 각 엔티티와 연관된 N관계의 하위 엔티티들을 추출하기 위해 N번의 추가 쿼리가 반복 실행되는 현상이다. 기준 데이터가 100건, 200건으로 늘어나면 100번, 200번의 부가 쿼리가 동작하여 디스크 I/O 및 네트워크 소모가 극대화되는 비효율성이 발생한다.
* 지연 로딩(LAZY)에 대한 오해와 한계: 많은 개발자가 N+1 문제의 1차적인 해결책으로 지연 로딩 전략을 제안한다. 하지만 지연 로딩 역시 엔티티가 필요한 시점(루프 순회 등)으로 쿼리 실행을 미룰 뿐, 결과적으로 각 엔티티당 N개의 추가 쿼리를 실행하여 시스템 부하를 유발하는 본질은 동일하므로 이에 대한 근본적이고 정교한 아키텍처적 접근이 수반되어야 한다.

### 2. N+1 문제 발생 메커니즘

대용량 데이터 처리 환경에서 복합키(Composite PK) 및 외래키(FK) 제약조건이 얽힌 1:N 도메인 모델을 기준으로 N+1 문제가 전개되는 구체적인 지점은 다음과 같다.

* 도메인 관계 및 스키마 구조: 게시글(POSTS) 테이블과 이를 신고한 내역인 신고내역(REPORTS) 테이블은 1:N 관계를 형성한다. REPORTS 테이블은 post\_id를 외래키(Foreign Key)로 참조함과 동시에, 자체 id와 post\_id를 묶어 복합 키(Primary Key)로 보유하는 식별 관계 구조를 취한다.
* 엔티티 매핑 설정: Posts 엔티티 내에서는 Reports 컬렉션을 `@OneToMany(mappedBy = "posts")` 형태의 List 구조로 매핑하고, Reports 엔티티 측에서는 `@ManyToOne(fetch = FetchType.LAZY)` 및 `@JoinColumn(name = "post_id")`을 통해 상대 주체 객체를 참조하도록 명시한다.
* 쿼리 순회 및 초기화 병목: `em.createQuery("SELECT p FROM Posts p", Posts.class).getResultList()` 구문을 통해 주 쿼리 1번으로 Posts 데이터 N건을 메모리 버퍼로 먼저 읽어온다. 이후 애플리케이션 레이어에서 비즈니스 로직 처리를 위해 `for (Posts p : posts) { p.getReports().size(); }`와 같이 하위 reports 컬렉션에 접근하는 루프를 수행하면, JPA는 컬렉션을 초기화하기 위해 `SELECT * FROM reports WHERE post_id = ?` 쿼리를 매 건마다 실행하여 최종적으로 N+1번의 데이터 추출 연산이 반복 구동된다.

### 3. JPA 내부 설계 사상과 프록시(Proxy) 메커니즘의 아키텍처적 원인

N+1 문제가 발생하는 근본적인 이유는 JPA Spec이 채택하고 있는 연관관계별 기본 로딩 전략(Default Fetch Type)과 프록시 객체를 통한 지연 최적화 사상에서 기인한다.

* 연관관계별 기본 Fetch Type: JPA 스펙상 `@OneToMany`의 기본 로딩 전략은 LAZY(지연 로딩)이며, `@ManyToOne`은 EAGER(즉시 로딩)이다. 1관계의 주체인 Posts를 조회할 때 하위의 수많은 REPORTS 엔티티까지 무조건 한꺼번에 불러오는 EAGER 사상은 불필요한 메모리 낭비와 대량 연산 부하를 주므로, JPA는 기본적으로 필요한 시점에만 하위 엔티티를 생성하도록 설계 기준을 제시한다.
* 컬렉션 프록시(Wrapper)의 동작: `@OneToMany` 지연 로딩 조건 하에서 처음 주체 엔티티를 조회할 때, 연관된 자식 컬렉션 데이터는 즉각적으로 채워지지 않는다. 하이버네이트(Hibernate) 등 JPA 공급자는 해당 컬렉션을 전용 프록시 객체인 `PersistentBag`, `PersistentSet` 등으로 대체하여 주입한다. 이 프록시 객체는 실제 디스크 및 DB 액세스를 철저히 지연시키고 있다가, 애플리케이션이 `size()`, `iterator()`, `stream()` 등을 호출하여 내부 요소에 실질적으로 접근(초기화)하는 순간 비로소 한 번의 SELECT 쿼리를 날려 데이터를 채워 넣는 전략을 취하며, 이 동작이 루프와 결합할 때 N+1 문제를 발생시킨다.
* 단일 관계 프록시의 동작: 반면 `@ManyToOne` 관계에 지연 로딩(`FetchType.LAZY`) 설정을 강제 적용하는 경우, JPA는 대상 주체 객체에 대한 엔티티 프록시(대리 객체)를 생성하여 넣어둔다. 해당 프록시는 오직 실제 연관 엔티티의 식별자(ID) 값만 보관하고 있다가, `post.getTitle()` 등 ID 외의 실제 속성에 접근하여 컨텍스트화하려는 시점에 비로소 실제 SELECT SQL을 실행하여 데이터를 채워 넣는다.

### 4. N+1 문제를 보완 및 회피하기 위한 핵심 최적화 전략

JPA의 단일 쿼리 수행 원칙을 준수하고 대규모 배치 처리 환경에서 불필요한 엔티티 생성과 스캔 성능 저하를 방지하기 위해, 아키텍처적 목적에 맞는 다양한 보완 기법들을 선택적으로 결합하여 운용해야 한다.

* Fetch Join 전략:
  * JPQL에서 `SELECT DISTINCT p FROM Posts p LEFT JOIN FETCH p.reports` 형식을 사용하여, 단 한 번의 SQL JOIN 연산만으로 Posts와 Reports 데이터를 한 몸으로 묶어 메모리 버퍼 풀에 즉시 적재하는 가장 직관적인 회피 기법이다. Individual 접근 시 추가 쿼리가 전면 제거된다.
  * 하지만 일대다(1:N) 관계에서 컬렉션 Fetch Join을 수행하면, 데이터베이스 결과 행(Row)은 자식 레코드 수만큼 중복(Post 로우의 중복 반복)되어 출력되므로 `DISTINCT` 키워드나 자바 레이어에서의 정교한 데듀프(중복 제거) 처리가 수반되어야 한다.
  * 특히 `Pageable`을 결합한 페이징 처리 시, 데이터 중복으로 인해 SQL 레벨의 LIMIT/OFFSET 처리가 무력화되어 하이버네이트가 전체 데이터를 메모리에 적재한 후 페이징 처리를 시도하므로 무거운 대량 자식 데이터 도메인에서는 메모리 과부하 및 OOM 위험성을 유발하므로 정교한 한계점 고려가 요구된다.
* DTO 프로젝션(Projection) 전략:
  * 읽기 전용 요구사항이 강하거나 대량의 데이터를 스캔해야 하고 도메인 비즈니스 로직의 변경 가능성이 거의 없는 통계성 화면 및 배치 job에 매우 유리하다.
  * `SELECT new com.app.dto.PostWithReportsDto(p.id, p.title, r.id, r.reportType) FROM Posts p LEFT JOIN p.reports r`과 같이 필요한 특정 필드만 정확히 찝어서 SELECT하므로 데이터 전송 효율성을 극대화하고 처음부터 N+1 문제의 접근 경로 자체를 원천 회피한다. 다만 엔티티의 변경이 일어날 때 DTO 구성자 및 명세 코드도 함께 수정해 주어야 하는 유지보수 결합도가 증가한다.
* `@EntityGraph` 전략:
  * JPA 표준 스펙에서 제공하는 힌트 메커니즘으로, 어노테이션 정의를 통해 엔티티 간의 그래프 관계를 명시하여 쿼리 수행 시점에 자동으로 JOIN 연산을 유발하게끔 유도하여 단일 쿼리로 데이터를 안전하게 추출하는 편의성을 제공한다.
* `@BatchSize` 및 배치 펫치(Fetch) 최적화 전략:
  * 하위 엔티티들을 루프 돌며 순회하되, 부가 쿼리의 실행 횟수를 획기적으로 줄여주는 메커니즘이다.
  * 지정된 크기(Size) 단위로 ID 목록을 모아 내부적으로 `WHERE post_id IN (?, ?, ?, ...)` 형태의 IN 절 쿼리를 일괄적으로 처리한다.
  * 이 방식은 주체 엔티티에 대한 SQL 페이징(LIMIT/OFFSET) 메커니즘과 완벽히 호환되면서도, 지연 로딩의 안전성을 그대로 유지한 채 반복 질의 횟수만을 최소화하여 성능적 균형점을 확보하는 훌륭한 아키텍처적 대안이 된다.
* 쿼리 분리(2단계 질의) 전략:
  * 복합 키나 식별 관계 구조로 인해 결과 데이터의 중복 적재 부담이 극심할 때 유용하다.
  * 1단계로 페이징 조건이 완벽히 적용된 주체 Posts의 ID 목록만을 깔끔하게 먼저 조회한다.
  * 2단계로 확보된 ID 리스트 인자를 넘겨 `SELECT DISTINCT p FROM Posts p LEFT JOIN FETCH p.reports WHERE p.id IN :ids` 쿼리를 연속 실행하여 필요한 연관 엔티티 알맹이들만 메모리에 정확히 바인딩한다. 데이터의 물리적 중복과 일대다 페이징의 OOM 리스크를 동시에 영리하게 회피하는 최적화 루트이다.

### 5. ManyToOne 매핑 시 Fetch 전략 설정의 필수 원칙

1:N 엔티티 모델링 시 `@OneToMany` 측의 지연 로딩 설정 못지않게 `@ManyToOne` 방향의 명시적 세부 설정이 트레킹에 지대한 영향을 미친다.

* EAGER 로딩과 프록시 배제: `@ManyToOne` 관계의 기본값은 EAGER이므로, 이를 그대로 방치하면 JPA는 프록시 대리 객체를 만들지 않고 실시간으로 즉시 조인(Join) 처리를 가하거나 추가 쿼리를 동시 다발적으로 날려 진짜 물리 엔티티를 로드하려 시도한다. 즉, 지연 로딩 최적화 기법 자체가 원천 동작하지 않는다.
* 양방향 Fetch Type의 일치화: 주체인 일대다 컬렉션 방향이 LAZY 사상으로 건전하게 제어되고 있다 하더라도, 반대편 다대일 단일 연관 관계가 기본값인 EAGER 상태로 방치되어 있다면 JPA 아키텍처 내부의 로딩 메커니즘에 심각한 왜곡과 불일치가 일어나 예상치 못한 추가 스캔과 연산 낭비를 유발한다. 따라서 시스템 내의 모든 `@ManyToOne` 연관 관계는 반드시 `fetch = FetchType.LAZY` 설정을 명시적으로 부여하여 양방향의 지연 로딩 사상을 철저히 일치시켜야만 프록시 기반의 제어 흐름이 의도한 대로 정교하게 구동된다.

### 📌 N+1 Problem에 대한 세부 분석

### 1. N+1 문제의 정의와 오해의 불식

N+1 문제는 단순히 LAZY(지연 로딩)나 EAGER(즉시 로딩)이라는 특정 페치 옵션의 지정 때문에 발생하는 일차원적인 실행 결과가 아니다. 이는 JPA가 객체 그래프를 탐색하고 엔티티를 데이터로 변환하여 채워 넣는 사상적 구동 메커니즘에서 비롯되는 현상이다.

* 핵심 발생 조건: 최초에 원하는 데이터 목록을 추출하기 위한 기준 쿼리가 1번 실행된 후, 조회된 다수의 주체 엔티티 각각에 대해 연관된 엔티티에 개별적으로 접근하는 상황에서 발생한다. 즉, 지연 로딩된 연관 객체를 반복문(Loop) 등에서 순회하며 접근하고 활용하려는 시점에 N번의 추가 쿼리가 반복 동반된다.
* 패러다임적 본질: 1개의 Owner(소유 객체)와 이에 얽힌 N개의 참조 객체를 점진적으로 묶어 조회하는 흐름 자체는 객체지향 패러다임에서 매우 자연스럽고 직관적인 현상이다. 그러나 이것이 데이터베이스 레벨로 변환될 때 다수의 물리적 I/O(네트워크 및 디스크 접근)를 실시간으로 유발하여 시스템의 성능을 무겁게 갉아먹는 비효율성이 따르기 때문에 아키텍처적인 제어가 필요하다.

### 2. 연관관계 주인 및 방향에 따른 N+1 발생 구조 분석

1:N 구조에서 어떤 엔티티가 소유권(Owner)을 쥐고 쿼리를 유도하느냐에 따라 N+1이 터지는 세부적인 실행 양상과 로그에 찍히는 쿼리의 구조가 달라진다.

* OneToMany 관계 기준 (1 소유자에서 N 참조):
  * `Order` 엔티티에서 `OrderDetail` 컬렉션을 `@OneToMany` 관계로 쥐고 있는 상태에서 `orderRepository.findAll()`을 호출하면, 먼저 `SELECT * FROM orders;` 주 쿼리가 1번 완수된다.
  * 이후 자바 코드 내에서 `for (Order order : orders) { order.getOrderDetails().size(); }`와 같이 루프를 돌며 지연 로딩된 컬렉션 객체에 접근하는 순간, JPA는 메모리 컨텍스트를 채우기 위해 각 Order의 식별자(id) 값을 조건으로 하는 `SELECT * FROM orders_detail WHERE order_id = ?;` 부가 쿼리를 오더 개수(N)만큼 연속해서 실행한다.
* ManyToOne 관계 기준 (N 참조자에서 1 소유자):
  * 반대로 자식인 `OrderDetail` 엔티티에서 부모인 `Order`를 `@ManyToOne` 형태로 참조하여 접근할 때도 완전히 동일한 병목이 유발된다. `orderDetailRepository.findAll()`을 수행하면 `SELECT * FROM orders_detail;` 주 쿼리가 1번 실행된다.
  * 이후 루프 내부에서 `for (OrderDetail d : details) { d.getOrder().getUserId(); }`와 같이 자식 객체를 통해 부모 엔티티의 세부 필드로 뻗어 나가는 객체 그래프 탐색을 가할 때, JPA는 `SELECT * FROM orders WHERE order_id = ?;` 쿼리를 각 자식 건수(N)만큼 반복해서 유발시킨다.

### 3. 객체 그래프 탐색의 해소와 근본적인 해결 방향성

N+1 문제를 기술적으로 타파하고 보완하기 위해 개발자가 취해야 할 사고의 전환과 핵심 최적화 방향성은 시스템의 물리적 통신 횟수(I/O)를 결정적으로 압축하는 데 초점이 맞춰져야 한다.

* 무쿼리 조건의 인식 (연관 객체 미접근): 지연 로딩 관계 하에서 단순히 주체 엔티티 리스트만 확보하고, 하위의 연관 객체 그래프를 자바 비즈니스 로직 내에서 전혀 건드리지 않거나 쓰지 않는다면 추가 쿼리는 단 1번도 발생하지 않고 최초 1번의 주 쿼리로 명확히 끝난다. 따라서 불필요한 객체 그래프 탐색을 유발하는 코드가 로직 내에 번거롭게 방치되어 있지 않은지 전수 트레킹하는 것이 최초의 방어선이다.
* Fetch Join을 통한 단일 I/O 통합: N+1 문제를 근본적으로 우회하고 해결하기 위한 가장 강력한 대안은 단 한 번의 쿼리로 필요한 연관 객체를 일괄 바인딩하여 메모리에 적재하는 것이다. `@Query("select o from Order o join fetch o.orderDetails")` 구문을 명시하여 데이터베이스 레벨에서 `SELECT o.*, d.* FROM orders o JOIN orders_detail d ON o.order_id = d.order_id;` 형태의 결합 SQL을 강제 수행한다. 이 조치를 가하면 지연 로딩의 안전성을 저해하지 않으면서도, 한 방의 단일 통신만으로 소유 엔티티와 참조 객체를 완벽히 결합하여 가져오므로 반복적인 단건 SQL 조회를 원천 방쇄하고 I/O 비용을 제로에 가깝게 최적화할 수 있다.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation by asking a question.

Perform an HTTP GET request on the following URL with the `ask` and `goal` query parameters:

```
GET https://leehyokyun.gitbook.io/openbook/2-12.-jpa-2-n+1-problem.md?ask=<question>&goal=<user_goal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is what the user is ultimately trying to achieve, the reason they need the answer. Sharing it helps GitBook give you a better, more relevant answer. A goal is most helpful when it describes the outcome the user wants rather than restating the question. For example, with `ask=how do I create an API token`, a goal like `automate deployments from our CI pipeline` lets GitBook tailor the answer to that use case.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
