티스토리 뷰

반응형

✏️ 들어가며

최근 프로젝트에서 데이터를 조회하던 중, 팀원으로부터 “이거 N+1 문제가 발생할 수 있어요”라는 얘기를 들었습니다.  
사실 N+1 문제는 이전부터 익숙한 주제였고, 면접 준비할 때도 공부했던 부분이지만… 막상 실제 상황에서는 바로 캐치하지 못했던 것이죠. 😅  
이번 기회에 다시 한 번 개념을 정리하고, 실제 코드에서 어떤 문제가 생기는지, 그리고 어떻게 해결할 수 있는지 기록해두려 합니다. 이 글은 미래의 저를 위한 기억 보조장치이자, 같은 고민을 하는 분들께도 도움이 되었으면 좋겠습니다.

 

✏️ N+1 문제란?

JPA에서 특정 엔티티를 조회한 뒤, 그 연관된 엔티티들을 지연 로딩(LAZY)할 때 발생하는 문제입니다.

예를 들어, 회원(Member) 엔티티가 팀(Team)과 연관되어 있고 LAZY로딩으로 설정되어 있다면 아래 코드처럼 될 수 있습니다.

아래의 경우

  • 첫 번째 쿼리로 회원 전체를 한 번에 조회하고(1),
  • 각 회원마다 팀 정보를 가져오기 위해 추가 쿼리 N개가 실행됩니다.

총 N+1개의 쿼리가 발생하게 되며, 이를 N+1문제라고 부릅니다.

List<Member> members = memberRepository.findAll(); // 1번 쿼리

for (Member member : members) {
    System.out.println(member.getTeam().getName()); // N번 쿼리
}

 

✏️  N+1 문제가 발생하면 어떤 문제가 발생하는가?

1. 쿼리 수 증가 -> 성능 저하

  • 회원이 100명이라면 팀 조회 쿼리가 100번 추가로 실행됨
  • 쿼리 수가 많아질수록 DB 부하가 급격히 증가함

2. 페이지네이션과 충돌

  • Fetch Join으로 해결하려고 할 경우, @OneToMany 관계에서는 페이징이 제대로 안 됨

3. 디버깅 어려움

  • 처음엔 잘 동작하는 것처럼 보여도, 데이터량이 많아지면 갑자기 느려지기 시작
  • 쿼리가 자동으로 발생하므로 로그를 꼼꼼히 보지 않으면 놓치기 쉬움

 

✏️ 문제 상황

val reviewList = reviewRepository.findAllByUserId(user.userId)

val categoryCounts = reviewList
    .flatMap { review -> reviewCategoryRepository.findAllByReview(review) }
    .groupingBy { it.category }
    .eachCount()
  • 1차 쿼리: findAllByUserId() → 유저의 모든 리뷰를 가져옴 (reviewList)
  • N차 쿼리: 각 Review 마다 findAllByReview() → 리뷰에 속한 카테고리를 매번 DB에서 조회

즉,
리뷰가 10개라면 → 1 + 10 = 11개의 쿼리 발생
리뷰가 100개라면 → 1 + 100 = 101개의 쿼리 발생

이게 바로 전형적인 N+1 문제입니다.

 

✏️ 문제 발생 원인

@OneToMany(mappedBy = "review", fetch = FetchType.LAZY)
var categories: MutableList<ReviewCategory> = mutableListOf()
  • Review → ReviewCategory는 지연 로딩(LAZY) 설정
  • 그래서 review.categories를 조회할 때마다 별도 쿼리가 나감

그리고 현재 로직에서는 review.categories를 사용하지 않고
reviewCategoryRepository.findAllByReview(review)를 호출하고 있으므로,
명시적으로 N개의 select 쿼리를 반복 호출하고 있는 상황입니다.

 

✏️ 개선 방향 요약

해결 방안 1: Fetch Join(저같은 경우는 이 방식을 사용하였습니다)

@Query("SELECT r FROM Review r JOIN FETCH r.categories WHERE r.userId = :userId")
fun findAllWithCategoriesByUserId(@Param("userId") userId: Long): List<Review>

 

해결 방안 2: DTO 쿼리

@Query("""
    SELECT new com.example.UserReviewCategoryDto(rc.category, COUNT(rc)) 
    FROM Review r
    JOIN ReviewCategory rc ON rc.review = r
    WHERE r.userId = :userId
    GROUP BY rc.category
""")
fun getUserReviewCategorySummary(@Param("userId") userId: Long): List<UserReviewCategoryDto>

 

✏️ 마치며

JPA를 쓰다 보면 "내가 쓴 코드에서 쿼리가 이렇게 많이 나가고 있었어?" 하는 순간이 종종 찾아옵니다.
이번에도 팀원의 피드백 덕분에 N+1 문제를 인지하고 다시 점검할 수 있었고,
그 과정을 정리하면서 JPA에서 연관 관계를 다룰 땐 쿼리 수까지 항상 같이 생각해야 한다는 점을 다시 한번 느꼈습니다.

앞으로는 연관된 데이터를 조회할 때
단순히 코드만 보지 않고, 쿼리 로그를 함께 보면서
내가 지금 어떤 방식으로 데이터를 끌고 오고 있는지 꼼꼼히 체크해보려고 합니다.

이 글이 과거의 저처럼 JPA 사용 중 갑자기 느려진 쿼리 앞에서 당황하고 있는 분들께
조금이나마 도움이 되었으면 좋겠습니다. 😊

반응형
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/04   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30
글 보관함