Jpa N+1 문제, 우리는 이렇게 잡았다 — 1:1 문의 Api 실전 최적화기
코드 리뷰 한 줄에서 시작된 쿼리 최적화 여정
1. 시작 — "일단 돌아가게 만들자"
Finders 프로젝트에서 1:1 문의(Inquiry) API를 맡았다. 현상소에 문의를 남기고, 답변을 받고, 목록을 조회하는 — 평범한 CRUD다.
"JPA 쓰면 쿼리 안 짜도 되는 거 아니야?"
솔직히 처음엔 그렇게 생각했다. JpaRepository에 findAll, 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을 걸었다. 잘 돌아갔다. 돌아가기는 했다.
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;
LAZY는 inquiry.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 경고 안 뜬다 ✅
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 삭제
) { }
불필요한 데이터를 안 가져오는 것이 최고의 최적화다.
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번 쿼리로 전부 가져온다. ✅
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건 조회 시
| 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. 정리 — 우리가 배운 것들
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로 줄여준다. 보험이다.