Skip to main content

Command Palette

Search for a command to run...

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

Published
8 min read

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


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

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

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

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

// 첫 번째 버전의 목록 조회 (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, PR #138)


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

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

상황 설정

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

😱 N+1이 터지면

[쿼리 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번. 데이터가 늘수록 쿼리가 선형으로 증가한다.

😎 fetchJoin으로 해결하면

[쿼리 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번. 끝.

왜 이렇게 되는 걸까?

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

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

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

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


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

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

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

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

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

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

왜 이런 일이?

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

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

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

해결: 2단계 쿼리 패턴

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

// ✅ 개선된 버전 — 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)


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

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

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

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

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

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

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

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

해결: status로 판단하기

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

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

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

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

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

// ✅ 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 필드를 아예 제거했다:

// ❌ 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)


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

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

처음에 이렇게 짰다:

// ❌ 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개가 나간다.

[쿼리 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에 추가

// ✅ 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)


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

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

default_batch_fetch_size

# 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 — 특정 컬렉션만 배치 로딩

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

목록 vs 상세: DTO 분리

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


7. Before vs After

문의 목록 10건 조회 시

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

문의 상세 1건 조회 시

BeforeAfter
쿼리 수2 (member 추가 쿼리)1 (한 방 fetchJoin)
JOIN 대상photoLab, replies, repliermember, photoLab, replies, replier

안전망

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

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

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로 줄여준다. 보험이다.


참고

More from this blog

[Elasticsearch] Elasticsearch 기본용어와 CRUD 명령어

-elastic ▶ 들어가며 이번 글에서는 Elasticsearch를 공부하면서 가장 먼저 익혀야 하는 기본 용어를 정리하고,직접 코드를 쳐가며 CRUD(Create / Read / Update / Delete) 명령어에 익숙해지는 시간을 가져보려고 한다. Elasticsearch는 처음 보면 생소한 용어가 많아서 막막할 수 있는데,사실 구조적으로는 우리가 익숙한 MySQL과 닮은 부분이 굉장히 많다. 둘 다 데이터베이스라는 큰 틀 안에서 데...

Feb 17, 20264 min read

[모니터링] Sentry 도입부터 Discord 에러 알림까지 — 서버 감시 시스템 구축기

"서버 죽었는데 아무도 몰랐다"에서 "에러나면 1분 안에 안다"까지 1. 모니터링을 시작한 계기 프론트엔드: "API 안 되는데요?"백엔드: "엥? 언제부터요?"프론트엔드: "...2시간 전부터요?"백엔드: 😱 어느 날 서버가 죽어있었는데 아무도 몰랐다. 그날 이후, 모니터링 시스템 구축을 결심했다. (Issue #102) 2. 모니터링 도구 비교: 뭘 쓸까? 처음에는 여러 도구를 비교했다. 도구무료 티어장점단점 Sent...

Feb 11, 20268 min read

[CI/CD] 수동 태그에서 자동 릴리즈까지 — Git Flow와 Auto Release

🚀 우리 auto-release.yml 바로 보러가기 → 이 글에서 설명하는 워크플로우의 전체 코드를 바로 확인할 수 있다! main에 머지만 하면 버전 태그부터 릴리즈 노트까지 알아서 생긴다 1. 우리의 Git 전략: Git Flow (경량 버전) Finders 프로젝트는 Git Flow 전략을 사용하고 있다. 다만 hotfix나 release 브랜치 없이, 조금 가볍게 운영한다. main ← 운영 서버 (prod) 배포 브...

Feb 11, 20265 min read

[ci/cd] 대학생 팀의 배포 파이프라인 진화기

PR 하나면 끝나는 무중단 배포까지, 삽질의 기록 1. 시작은 단순했다 "서버에 올려야 하는데... 어떻게 하지?" Finders 프로젝트를 시작했을 때, 배포라는 걸 해본 적이 없었다. 그래서 처음에는 이렇게 했다. 1. SSH로 서버 접속 2. git pull 3. ./gradlew build 4. java -jar app.jar 당연히 문제가 생겼다. 빌드하는 동안 서버가 꺼져있음 (프론트: "API 왜 안 돼요?" 🔥) 빌...

Feb 11, 20267 min read
F

Finders Tech Blog

16 posts