# Jpa N+1 문제, 우리는 이렇게 잡았다 — 1:1 문의 Api 실전 최적화기

> 코드 리뷰 한 줄에서 시작된 쿼리 최적화 여정

---

## 1\. 시작 — "일단 돌아가게 만들자"

Finders 프로젝트에서 **1:1 문의(Inquiry) API**를 맡았다. 현상소에 문의를 남기고, 답변을 받고, 목록을 조회하는 — 평범한 CRUD다.

> "JPA 쓰면 쿼리 안 짜도 되는 거 아니야?"

솔직히 처음엔 그렇게 생각했다. `JpaRepository`에 `findAll`, `findById` 쓰면 끝이니까.

```java
// 첫 번째 버전의 목록 조회 (QueryDSL)
public List<Inquiry> findByMemberId(Long memberId, int page, int size) {
    return queryFactory
            .selectFrom(inquiry)
            .leftJoin(inquiry.photoLab).fetchJoin()
            .leftJoin(inquiry.replies, inquiryReply).fetchJoin()
            .where(inquiry.member.id.eq(memberId))
            .orderBy(inquiry.createdAt.desc())
            .offset((long) page * size)
            .limit(size)
            .fetch();
}
```

문의 목록을 가져올 때, 현상소 이름도 보여줘야 하고 답변이 있는지도 보여줘야 해서 `fetchJoin`을 걸었다. 잘 돌아갔다. **돌아가기는 했다.**

([#137](https://github.com/Finders-Official/BE/issues/137), [PR #138](https://github.com/Finders-Official/BE/pull/138))

---

## 2\. 잠깐, N+1이 뭔데?

블로그를 쓰는 김에, N+1 문제가 뭔지 한번 쉽게 설명해보겠다.

### 상황 설정

문의 10개를 목록 조회한다고 하자. 각 문의에는 **현상소(PhotoLab)** 정보가 필요하다.

### 😱 N+1이 터지면

```plaintext
[쿼리 1] SELECT * FROM inquiry WHERE member_id = 1 LIMIT 10    ← 문의 10개 가져옴
[쿼리 2] SELECT * FROM photo_lab WHERE id = 3                   ← 1번 문의의 현상소
[쿼리 3] SELECT * FROM photo_lab WHERE id = 7                   ← 2번 문의의 현상소
[쿼리 4] SELECT * FROM photo_lab WHERE id = 3                   ← 3번 문의의 현상소 (중복!)
  ...
[쿼리 11] SELECT * FROM photo_lab WHERE id = 12                 ← 10번 문의의 현상소
```

**1번의 쿼리**로 문의 목록을 가져오고, 각 문의마다 현상소를 **N번 추가 조회**한다. 그래서 **1 + N = N+1** 문제다.

문의가 10개면 11번, 100개면 101번, 1000개면 1001번. 데이터가 늘수록 쿼리가 **선형으로 증가**한다.

```mermaid
sequenceDiagram
    participant App as Spring 서버
    participant DB as MySQL

    Note over App,DB: 😱 N+1 발생 시

    App->>DB: SELECT * FROM inquiry LIMIT 10
    DB-->>App: 문의 10건

    loop 문의 1건마다
        App->>DB: SELECT * FROM photo_lab WHERE id = ?
        DB-->>App: 현상소 1건
    end

    Note over App,DB: 총 11번 쿼리 실행!
```

### 😎 fetchJoin으로 해결하면

```plaintext
[쿼리 1] SELECT i.*, pl.*
         FROM inquiry i
         LEFT JOIN photo_lab pl ON i.photo_lab_id = pl.id
         WHERE i.member_id = 1
         LIMIT 10
```

**딱 1번**. 끝.

```mermaid
sequenceDiagram
    participant App as Spring 서버
    participant DB as MySQL

    Note over App,DB: 😎 fetchJoin 적용 후

    App->>DB: SELECT i.*, pl.* FROM inquiry i LEFT JOIN photo_lab pl ...
    DB-->>App: 문의 10건 + 현상소 정보 한 번에!

    Note over App,DB: 총 1번 쿼리 실행!
```

### 왜 이렇게 되는 걸까?

JPA는 기본적으로 **지연 로딩(Lazy Loading)**을 쓴다.

```java
@ManyToOne(fetch = FetchType.LAZY)  // ← 이게 기본
@JoinColumn(name = "photo_lab_id")
private PhotoLab photoLab;
```

`LAZY`는 `inquiry.getPhotoLab().getName()`처럼 **실제로 접근하는 순간**에 쿼리를 날린다. 문의 1건만 볼 때는 괜찮지만, **목록으로 10건을 루프 돌면** 10번 추가 쿼리가 나간다.

> LAZY 자체가 나쁜 게 아니다. **목록 조회에서 연관 엔티티에 접근할 때** 문제가 된다.

---

## 3\. 첫 번째 발견: fetchJoin + 페이징 = 💥

돌아가는 코드를 올렸더니, **코드 리뷰**에서 이런 피드백이 왔다.

> "fetchJoin이랑 offset/limit 같이 쓰면 Hibernate가 메모리에서 페이징해요. 데이터 많아지면 터집니다."

무슨 소리지? 싶었는데, 실제로 로그를 보니:

```plaintext
WARN  HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
```

⚠️ **Hibernate가 경고를 보내고 있었다.**

### 왜 이런 일이?

`fetchJoin`은 SQL의 `JOIN`으로 바뀌는데, 컬렉션(replies 같은 `@OneToMany`)을 fetchJoin하면 **한 문의에 답변이 3개일 때 결과 행이 3배**로 뻥튀기된다.

```plaintext
문의1 - 답변A
문의1 - 답변B
문의1 - 답변C  ← 같은 문의인데 행이 3개!
문의2 - 답변D
```

이 상태에서 `LIMIT 10`을 걸면? **문의 10개가 아니라 행 10개**를 자른다. 그래서 Hibernate는 **전체 데이터를 메모리에 올린 다음** 애플리케이션에서 페이징한다.

```mermaid
flowchart LR
    A["fetchJoin + 페이징"] --> B{"컬렉션 JOIN?"}
    B -->|YES| C["❌ Hibernate가\n전체 데이터 메모리 로드\n→ OOM 위험"]
    B -->|NO| D["✅ 정상 작동\n(단건 연관관계는 OK)"]

    style C fill:#fee,stroke:#c33
    style D fill:#efe,stroke:#3a3
```

### 해결: 2단계 쿼리 패턴

코드 리뷰 피드백을 반영해서 **ID를 먼저 뽑고, 그 ID로 데이터를 가져오는** 2단계 구조로 바꿨다.

```java
// ✅ 개선된 버전 — 2단계 쿼리
public List<Inquiry> findByMemberId(Long memberId, int page, int size) {

    // 1단계: ID만 페이징해서 가져온다 (가볍다)
    List<Long> ids = queryFactory
            .select(inquiry.id)
            .from(inquiry)
            .where(inquiry.member.id.eq(memberId))
            .orderBy(inquiry.createdAt.desc())
            .offset((long) page * size)
            .limit(size)
            .fetch();

    if (ids.isEmpty()) {
        return Collections.emptyList();
    }

    // 2단계: ID 목록으로 fetchJoin (페이징 없이!)
    return queryFactory
            .selectFrom(inquiry)
            .leftJoin(inquiry.photoLab).fetchJoin()
            .leftJoin(inquiry.replies, inquiryReply).fetchJoin()
            .where(inquiry.id.in(ids))
            .orderBy(inquiry.createdAt.desc())
            .fetch();
}
```

**왜 이게 되는 걸까?**

* 1단계에서 `LIMIT`으로 **정확히 10개의 문의 ID**를 가져온다
    
* 2단계에서는 `WHERE id IN (1, 2, 3, ...)` 으로 **딱 그 문의들만** fetchJoin한다
    
* 페이징은 1단계에서 끝났으니, 2단계에서 Hibernate 경고 안 뜬다 ✅
    

([커밋 `82b42c7`](https://github.com/Finders-Official/BE/commit/82b42c72))

---

## 4\. 두 번째 발견: "그 데이터, 진짜 필요해?"

2단계 쿼리로 바꾸고 나서 다시 코드를 봤다.

```java
// 2단계에서 replies를 fetchJoin하고 있었다
.leftJoin(inquiry.replies, inquiryReply).fetchJoin()
```

문의 **목록** 화면에서 답변 전체 내용이 필요할까?

> 사실 목록에서는 **"답변이 있는지 없는지"**만 보여주면 됐다.

그런데 기존 코드는 이렇게 하고 있었다:

```java
// ❌ Before: replies를 전부 로드해서 비었는지 체크
.hasReply(!inquiry.getReplies().isEmpty())
.latestReply(latestReply != null ? ReplyPreviewDTO.from(latestReply) : null)
```

`getReplies()`를 호출하는 순간, JPA는 replies 전체를 DB에서 가져온다. 문의 1건에 답변이 20개면? **20건의 데이터가 메모리에 올라간다.** 목록에서 문의 10개를 보여줄 때, **답변 200개가 같이 딸려온다.**

### 해결: status로 판단하기

문의 엔티티에는 이미 `status` 필드가 있었다.

```java
public enum InquiryStatus {
    PENDING,    // 답변 대기
    ANSWERED,   // 답변 완료
    CLOSED      // 종료
}
```

답변이 등록되면 `ANSWERED`로 바뀌니까, **replies를 안 가져와도** 답변 여부를 알 수 있다!

```java
// ✅ After: status만으로 판단, replies 전혀 안 건드림
.hasReply(inquiry.getStatus() != InquiryStatus.PENDING)
```

QueryDSL 쿼리에서도 **replies fetchJoin을 통째로 삭제**했다:

```java
// ✅ After: replies JOIN 자체를 제거
return queryFactory
        .selectFrom(inquiry)
        .leftJoin(inquiry.photoLab).fetchJoin()  // 현상소 이름은 필요하니까 유지
        // .leftJoin(inquiry.replies, inquiryReply).fetchJoin()  ← 삭제!
        .where(inquiry.id.in(ids))
        .orderBy(inquiry.createdAt.desc())
        .fetch();
```

DTO에서도 `latestReply` 필드를 아예 제거했다:

```java
// ❌ Before
public record InquiryItemDTO(
    Long id, String title, InquiryStatus status,
    String photoLabName, LocalDateTime createdAt,
    boolean hasReply,
    ReplyPreviewDTO latestReply  // ← 이것 때문에 replies 전체를 로드
) { }

// ✅ After
public record InquiryItemDTO(
    Long id, String title, String content, InquiryStatus status,
    String photoLabName, LocalDateTime createdAt,
    boolean hasReply              // ← status로 판단, latestReply 삭제
) { }
```

> **불필요한 데이터를 안 가져오는 것이 최고의 최적화다.**

([커밋 `a996e61`](https://github.com/Finders-Official/BE/commit/a996e610))

---

## 5\. 세 번째 발견: 상세 조회도 N+1이었다

문의 **상세** 조회에서는 답변 내용, 작성자 이름, 현상소 정보가 전부 필요하다.

처음에 이렇게 짰다:

```java
// ❌ Before: member를 안 가져옴
@Query("SELECT i FROM Inquiry i " +
       "LEFT JOIN FETCH i.photoLab " +
       "LEFT JOIN FETCH i.replies r " +
       "LEFT JOIN FETCH r.replier " +
       "WHERE i.id = :id")
Optional<Inquiry> findByIdWithDetails(@Param("id") Long id);
```

근데 DTO 변환할 때 `inquiry.getMember().getName()`을 호출하고 있었다. member를 fetchJoin하지 않았으니, **여기서 추가 쿼리 1개가 나간다.**

```plaintext
[쿼리 1] SELECT i.*, pl.*, r.*, rp.*     ← 문의 + 현상소 + 답변 + 답변자
         FROM inquiry i
         LEFT JOIN photo_lab pl ...
         LEFT JOIN inquiry_reply r ...
         LEFT JOIN member rp ...
         WHERE i.id = 1

[쿼리 2] SELECT * FROM member WHERE id = 5  ← 😱 문의 작성자 (N+1!)
```

### 해결: member도 fetchJoin에 추가

```java
// ✅ After: member 추가
@Query("SELECT i FROM Inquiry i " +
       "LEFT JOIN FETCH i.member " +       // ← 추가!
       "LEFT JOIN FETCH i.photoLab " +
       "LEFT JOIN FETCH i.replies r " +
       "LEFT JOIN FETCH r.replier " +
       "WHERE i.id = :id")
Optional<Inquiry> findByIdWithDetails(@Param("id") Long id);
```

이제 상세 조회도 **딱 1번 쿼리**로 전부 가져온다. ✅

([커밋 `25c991b`](https://github.com/Finders-Official/BE/commit/25c991b6))

---

## 6\. 방어선 만들기 — 놓쳐도 터지지 않게

모든 쿼리를 완벽하게 최적화할 수는 없다. 그래서 **안전망**을 깔아뒀다.

### `default_batch_fetch_size`

```yaml
# application.yml
spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100
```

이 설정 하나면, fetchJoin을 깜빡 잊어도 **N+1이 N/100+1로 줄어든다.**

예를 들어 문의 100개를 조회하는데 현상소를 fetchJoin하지 않았다면:

* 설정 없이: 100번 추가 쿼리 (N+1)
    
* `batch_size: 100`: **1번** 추가 쿼리 (`WHERE id IN (1,2,3,...100)`)
    

### `@BatchSize` — 특정 컬렉션만 배치 로딩

```java
@OneToMany(mappedBy = "inquiry", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("displayOrder ASC")
@BatchSize(size = 10)  // ← 이미지는 한 번에 10개씩 배치 로딩
private List<InquiryImage> images = new ArrayList<>();
```

### 목록 vs 상세: DTO 분리

```mermaid
flowchart TB
    subgraph 목록 조회
        A["InquiryItemDTO"] --> B["id, title, status\nphotoLabName, hasReply"]
        A --> C["❌ replies 안 가져옴\n❌ images 안 가져옴\n❌ member 안 가져옴"]
    end

    subgraph 상세 조회
        D["InquiryDetailDTO"] --> E["id, title, content, status\nphotoLab, member\nimages, replies"]
        D --> F["✅ fetchJoin 한 방 쿼리\n✅ 모든 연관 데이터 포함"]
    end

    style C fill:#fee,stroke:#c33
    style F fill:#efe,stroke:#3a3
```

**같은 엔티티라도 화면마다 필요한 데이터가 다르다.** 목록에서는 가볍게, 상세에서는 한 방에.

---

## 7\. Before vs After

### 문의 목록 10건 조회 시

|  | Before | After |
| --- | --- | --- |
| **쿼리 수** | 1 (메모리 페이징⚠️) | 2 (ID 페이징 + fetchJoin) |
| **메모리 로드** | 전체 replies 포함 | replies 제외 |
| **Hibernate 경고** | `HHH90003004` ⚠️ | 없음 ✅ |
| **불필요한 데이터** | `latestReply` DTO 포함 | `hasReply`만 (status 기반) |

### 문의 상세 1건 조회 시

|  | Before | After |
| --- | --- | --- |
| **쿼리 수** | 2 (member 추가 쿼리) | 1 (한 방 fetchJoin) |
| **JOIN 대상** | photoLab, replies, replier | **member**, photoLab, replies, replier |

### 안전망

| 설정 | 효과 |
| --- | --- |
| `default_batch_fetch_size: 100` | 깜빡 잊어도 N+1 → 최대 2번 |
| `@BatchSize(size = 10)` | images 컬렉션 배치 로딩 |
| DTO 분리 (목록/상세) | 화면별 최소 데이터만 로드 |

---

## 8\. 정리 — 우리가 배운 것들

```mermaid
timeline
    title Inquiry 쿼리 최적화 타임라인
    1월 16일 : 1:1 문의 API 첫 구현
            : fetchJoin + 페이징 조합
    1월 17일 : 코드 리뷰에서 문제 발견
            : 2단계 쿼리 패턴 도입
    1월 17일 : 목록 조회 replies 제거
            : status 기반 hasReply 판단
    1월 17일 : 상세 조회 member N+1 수정
            : 보안 어노테이션 추가
    1월 28일 : 중복 로직 헬퍼 메서드 추출
            : 코드 정리 완료
```

4가지 교훈:

### 1\. fetchJoin + 페이징 = 위험

> 컬렉션(`@OneToMany`)을 fetchJoin하면서 페이징하면 **메모리 페이징**이 발생한다. 2단계 쿼리(ID 먼저 → fetchJoin 나중)로 해결.

### 2\. 안 가져오는 게 최고의 최적화

> 목록에서 `replies`를 fetchJoin하던 걸 **통째로 삭제**했다. 이미 있는 `status` 필드로 대체.

### 3\. N+1은 한 곳만 있지 않다

> 목록 조회를 고쳤더니, 상세 조회에서 또 N+1이 있었다. **모든 조회 경로를 의심**해야 한다.

### 4\. 안전망을 깔아두자

> `default_batch_fetch_size`와 `@BatchSize`는 fetchJoin을 깜빡해도 **N+1을 N/100+1로** 줄여준다. 보험이다.

---

## 참고

* [Finders 1:1 문의 API 구현 — Issue #137](https://github.com/Finders-Official/BE/issues/137)
    
* [fetchJoin + 페이징 수정 — 커밋 82b42c7](https://github.com/Finders-Official/BE/commit/82b42c72)
    
* [replies 로딩 최적화 — 커밋 a996e61](https://github.com/Finders-Official/BE/commit/a996e610)
    
* [상세 조회 N+1 수정 — 커밋 25c991b](https://github.com/Finders-Official/BE/commit/25c991b6)
    
* [Hibernate ORM User Guide — Fetching](https://docs.jboss.org/hibernate/orm/6.4/userguide/html_single/Hibernate_User_Guide.html#fetching)
    
* [Spring Data JPA Reference — Query Methods](https://docs.spring.io/spring-data/jpa/reference/jpa/query-methods.html)
