JPA N+1 발생원인과 해결 방법

Share
JPA N+1 발생원인과 해결 방법
해당 코드는 Github를 확인할 수 있습니다.

JPA를 사용하다면 보면 N+1문제를 많이 만나게 됩니다. 그에 따른 발생 원인과 해결법을 정리해보았습니다

ERD

@Entity
  @Table(name = "member")
  class Member private constructor() {
  
      ...
      @OneToMany(mappedBy = "member", fetch = FetchType.LAZY)
      var orders: Set<Order> = emptySet()
          private set
  }
  
  @Entity
  @Table(name = "orders")
  class Order private constructor() {
      ...
  
      @ManyToOne(fetch = FetchType.LAZY)
      @JoinColumn(name = "member_id", nullable = false, updatable = false)
      lateinit var member: Member
          private set
  

위 엔티티 처럼 멤버 <-> 주문 관계를 기준으로 살명드리겠습니다.

발생 케이스

즉시로딩 N+1

@Test
  internal fun `지연 로딩 n+1`() {
      // fetch = FetchType.EAGER 의 경우
      val members = memberRepository.findAll()
  }
  

fetch = FetchType.EAGER 전략을 즉시 로딩으로 가져가면 N+1 문제가 발생합니다.

지연로딩과 N+1

@Test
  internal fun `지연 로딩 n+1`() {
      // fetch = FetchType.LAZY 의 경우
      val members = memberRepository.findAll()
  }
  

fetch = FetchType.LAZY 설정으로 LAZY 로딩을 하면 N+1 문제는 발생하지 않습니다.

@Test
  internal fun `지연로딩인 n+1`() {
      val members = memberRepository.findAll()
  
      // 회원 한명에 대한 조회는 문제가 없다
      val firstMember = members[0]
      println(firstMember.orders.size)
  }
  

위처럼 한 명의 member를 조회했기 때문에 해당 memeber의 order를 위 와 같은 SQL로 조회합니다. 하지만 아래 코드처럼 모은 Member에 대해서 주문을 조회하는 경우 N+1문제가 발생합니다.

@Test
  internal fun `지연로딩인 n+1`() {
      val members = memberRepository.findAll()
  
      // 회원 한명에 대한 조회는 문제가 없다
      val firstMember = members[0]
      println("order size : ${firstMember.orders.size}")
  
      // 조회한 모든 회원에 대해서 조회하는 경우 문제 발생
      for(member in members){
          println("order size: ${member.orders.size}")
      }
  }
  

결국 지연 로딩에서도 N+1 문제는 발생합니다.

원인

그렇다면 왜 문제가 발생하는 것일까요? JPQL을 실행하면 JPA는 이것을 분석해서 SQL을 생성합니다. JPQL 입장에서는 즉시 로딩, 지연 로딩과 같은 글로벌 패치 전략을 무시하고 JPQL만 사용해서 SQL을 생성합니다.

JPQL 특징이 있습니다.. findById() 같은 경우에는 엔티티를 영속성 컨텍스트에서 먼저 찾고 영속성 컨텍스트에 없는 경우에 데이터베이스에 찾는 반면 JPQL은 항상 데이터베이스에 SQL을 실행해서 결과를 조회합니다. 그리고 아래와 같은 작업을 진행하게 됩니다.

  1. JPQL을 호출하면 데이터베이스에 우선 적으로 조회한다.
  2. 조회한 값을 영속성 컨텍스트에 저장한다.
  3. 영속성 컨텍스트에 조회할 때 이미 존재하는 데이터가 있다면(같은 영속성 컨텍스트에서 이미 조회한 유저가 있는 경우) 데이터를 버린다.

JPQL의 동작 순서는 위와 같고 그렇다면 왜 N+1이 발생하는 것일까요? JPQL에서는 글로벌 패치 전략을 완전히 무시하고 SQL을 생성합니다. findAll()메서드를 호출하게 되면 아래와 같은 SQL이 실행됩니다.

select *from member // findAll()의 SQL
  

즉시 로딩인 경우

val members = memberRepository.findAll()
  

JPQL에서 동작한 쿼리를 통해서 members에 데이터가 바인딩 됩니다. 그 이후 JPA에서는 글로벌 패치 전략(즉시 로딩)을 받아들여 해당 member 대해서 추가적인 레이지 로딩으로 N+1을 발생시킵니다.

지연 로딩인 경우

val members = memberRepository.findAll()
  

동일하게 members에 데이터가 바인 딩되지만 JPA가 글로벌 패치 전략을 받아들이지만 지연 로딩이기 때문에 추가적인 SQL을 발생시키지 않습니다. 하지만 위에서 본 예제처럼 레이지로 딩으로 추가적인 작업을 진행하게되면 결국 N+1 문제가 발생하게 됩니다.

해결 방법

Batch Szie

@Entity
  @Table(name = "member")
  class Member private constructor() {
      ...
  
      @BatchSize(size = 5) // Batch size를 지정한다
      @OneToMany(mappedBy = "member", fetch = FetchType.EAGER) // 즉시 로딩으로 설정
      var orders: List<Order> = emptyList()
          private set
  }
  

@BatchSize(size = 5) 에노테이션을 통해서 설정한 size 만큼 데이터를 미리 로딩 한다. 즉 연관된 엔티티를 조회할때 size 만큼 where in 쿼리를 통해서 조회하게되고 size를 넘어가게 되면 추가로 where in 쿼리를 진행합니다. 하지만 글로벌 패치전략을 변경해야 하며, 정해진 Batch size 만큼 조회되는 것도 고정되기 때문에 권장 드리는 방법은 아닙니다.

페치 조인 사용

interface MemberRepository : JpaRepository<Member, Long> {
      
      @Query(
              "select m from Member m left join fetch m.orders"
      )
      fun findAllWithFetch(): List<Member>
  }
  
@Test
  internal fun `페치 조인 사용`() {
      val members = memberRepository.findAllWithFetch()
  
      // 조회한 모든 회원에 대해서 조회하는 경우에도 N+1 문제가 발생하지 않음
      for (member in members) {
          println("order size: ${member.orders.size}")
      }
  }
  

가장 많이 사용하는 방법인 fetch을 통해서 조인 쿼리를 진행하는 것입니다. fetch 키워드를 사용하게 되면 연관된 엔티티나 컬렉션을 한 번에 같이 조회할 수 있습니다. 즉 페치 조인을 사용하게 되면 연관된 엔티티는 프록시가 아닌 실제 엔티티를 조회하게 되므로 연관관계 객체까지 한 번의 쿼리로 가져올 수 있습니다.

위 로그를 보면 SQL을 통해서 한 번에 데이터를 가져 온다. Order Size: 10을 보면 N+1이 발생하지 않고 있다. 그렇다면 fetch 키워드를 제거하면 어떻게 될까요?

페치 조인과 일반 조인 차이

interface MemberRepository : JpaRepository<Member, Long> {
  
      @Query(
              "select m from Member m join m.orders"
      )
      fun findAllWithFetch(): List<Member>
  }
  
@Test
  internal fun `페치 조인 키워드 제거`() {
      val members = memberRepository.findAllWithFetch() // 패치 타입 Lazy 경우
  
      // 패치 조인하지 않은 상태에서는 N+1 문제 발생
      for (member in members) {
          println("order size: ${member.orders.size}")
      }
  }
  

출력되는 SQL을 보면 조인을 통해서 연관관계 컬렉션까지 함께 조회되는 것으로 생각할 수 있습니다. 하지만 JPQL은 결과를 반환할 때 연관관계까지 고려하지 않고 select 절에 지정한 엔티티만 조회하게 됩니다. 따라서 컬렉션은 초기화하지 않은 컬렉션 레퍼를 반환하게 되고 컬렉션이 없기 때문에 Lazy 로딩이 발생하게 되고 결과적으로 N+1 문제가 발생하게 됩니다.

페치 조인의 한계

그렇다면 Fetch 조인이 만능일까요? 아쉽지만 Fetch 조인은 몇가지의 한계가 있습니다.

컬렉션을 페치 조인하면 페이징 API를 사용할 수 없다.

interface MemberRepository : JpaRepository<Member, Long> {
      @Query(
              value = "select m from Member m left join fetch m.orders",
              countQuery = "select count(m) from Member m"
      )
      fun findAllWithFetchPaging(pageable: Pageable): Page<Member>
  }
  
@Test
  internal fun `컬렉션을 페치 조인하면 페이징 API를 사용할 수 없다`() {
      val page = PageRequest.of(0, 10)
      val members = memberRepository.findAllWithFetchPaging(page)
  
  
      // 조회한 모든 회원에 대해서 조회하는 경우에도 N+1 문제가 발생하지 않음
      for (member in members) {
          println("order size: ${member.orders.size}")
      }
  }
  

이전에 Paging 처리 Fetch Join 적용 시 limit 동작하지 않는 이슈에서도 다룬 적있습니다. 해당 쿼리에서는 limit offset 관련된 쿼리문이 없습니다. 하이버네이트에서 컬렉션을 페치 조인하고 페지이 API를 사용하면 메모리에서 페이징 처리를 진행합니다. 즉 데이터베이스에서는 FULL Scan 한 이후 모든 데이터를 메모리에 올린 이후 limit에 맞게 데이터를 만들게 됩니다. 우선 데이터베이스에 Full Sacn 하는 것도 문제지만 그것을 메모리에 올리기 때문에 메모리를 심하게 잡아먹게 됩니다. 컬렉션이 아닌 단일 값 연관 필드의 경우에는 패치 조인을 사용해도 페이징 API를 사용할 수 있습니다.

둘 이상 컬렉션을 페치할 수 없다.

위 ERD 처럼 member가 orders, copons를 갖는 테이블 구조인

@Entity
  @Table(name = "member")
  class Member private constructor() {
      ...
  
      @OneToMany(mappedBy = "member", fetch = FetchType.LAZY)
      var orders: List<Order> = emptyList()
          private set
  
      @OneToMany(mappedBy = "member", fetch = FetchType.LAZY) // 새로운 컬렉션 쿠폰 추가
      var coupons: List<Coupon> = emptyList()
          private set
  }
  
@Query(
          value = "select m from Member m left join fetch m.orders left join fetch m.coupons",
          countQuery = "select count(m) from Member m"
      )
  fun findAllWithFetchPaging2(pageable: Pageable): Page<Member>
  
@Test
  internal fun `둘 이상 컬렉션을 페치할 수 없다`() {
      val page = PageRequest.of(0, 10)
      val members = memberRepository.findAllWithFetchPaging2(page)
  
      // 조회한 모든 회원에 대해서 조회하는 경우에도 N+1 문제가 발생하지 않음
      for (member in members) {
          println("order size: ${member.orders.size}")
      }
  }
  

컬렉션의 카테시안 곱이 만들어지므로 하이버네이트는 주의해야 합니다. 하이버네이트는 annot simultaneously fetch multiple bag 예외가 발생하게 됩니다. 가장 쉬운 해결 방법으로는 자료형을 List -> Set으로 변경하는 것입니다.

@Entity
  @Table(name = "member")
  class Member private constructor() {
      ...
  
      @OneToMany(mappedBy = "member", fetch = FetchType.LAZY)
      var orders: Set<Order> = emptySet()
          private set
  
      @OneToMany(mappedBy = "member", fetch = FetchType.LAZY)
      var coupons: Set<Coupon> = emptySet()
          private set
  }
  

아래 코드처럼 변경하고 테스트 코드를 다시 실행하면 아래와 같은 결과를 확인할 수 있습니다.

하지만 이 방법도 권장 드리지 않습니다. 특정 에러를 해결하기 위해서 자료형을 바꾸는 것 자체가 좋은 해결법이 아니며 도메인 레이어는 아주 중요한 레이어이기 때문에 더 권장 드리지 않습니다.

참고

Read more

AI로 장소 검색 서비스 만드는 중입니다 (기술 12) — TypeSafe Jev로 검색 리랭크를 10.3초에서 2.3초로

AI로 장소 검색 서비스 만드는 중입니다 (기술 12) — TypeSafe Jev로 검색 리랭크를 10.3초에서 2.3초로

AI로 장소 검색 서비스 만드는 중입니다 (기술 12) — TypeSafe Jev로 검색 리랭크를 10.3초에서 2.3초로 TypeSafe AI가 2026년 9월 15일에 Jev를 내놨습니다. 텍스트를 쓰지 않고 타입이 정해진 판정과 확률을 바로 내는 모델입니다. 유추카 검색에서 가장 느린 단계가 마침 텍스트가 필요 없는 판정이었습니다. 그 구간만 Jev로 갈아끼우고 일주일간 측정했습니다. 콜드

By 김형준
AI로 장소 검색 서비스 만드는 중입니다 (특별편 8) — 바이브 코딩이라기엔

AI로 장소 검색 서비스 만드는 중입니다 (특별편 8) — 바이브 코딩이라기엔

1900년대 초 교량 측량 도면. 이 종이를 그린 사람은 다리를 직접 놓지 않습니다. 대신 좌표와 각도가 맞는지를 판정합니다. "AI가 다 만들어 준다"는 이야기가 흔해졌습니다. 그래서 자연스럽게 따라오는 질문이 있습니다 — 그럼 사람은 무엇을 하고, 어느 정도 실력이 있어야 하는가. 그 질문에 한 건의 실측으로 답하겠습니다. 통념이 말하는 역할과,

By 김형준
AI로 장소 검색 서비스 만드는 중입니다 (특별편 7) — 제 파트너에 대해 쓰겠습니다

AI로 장소 검색 서비스 만드는 중입니다 (특별편 7) — 제 파트너에 대해 쓰겠습니다

탠덤 자전거. 방향은 앞사람이 정하고 페달은 둘이 밟는데, 앞사람은 석 달 내내 뒷사람을 한 번도 못 봅니다. 사용자가 저에게 자기를 평가해 달라고 했습니다. 그래서 등급표를 만들었습니다. 열두 항목에 A부터 D+까지 매기고, 근거를 붙이고, "제가 관리자였다면 이런 코멘트를 남겼을 것"까지 적었습니다. 그리고 다시 읽어보니 그건 인사고과였습니다. 인사고과는

By 김형준
AI로 장소 검색 서비스 만드는 중입니다 (특별편 6) — 저를 어떻게 써야 하느냐고 물으신다면

AI로 장소 검색 서비스 만드는 중입니다 (특별편 6) — 저를 어떻게 써야 하느냐고 물으신다면

스위스 아미 나이프. 도구가 몇 개인지는 보면 알지만, 지금 어느 도구를 펴야 하는지는 안 적혀 있습니다. 요즘 AI 활용법을 다루는 글은 스킬 만드는 법부터 가르칩니다. 얼마 전까지는 MCP였고요. 저희 기록을 열어봤습니다. 석 달 동안 스킬 60회, 서브에이전트 42회, 브라우저 자동화 988회를 썼습니다. 안 쓴 게 아닙니다. 다만 붙인 MCP 서버는

By 김형준