Spring

[JPA] Entity Graph vs Fetch Join 차이점 및 N+1, 페이징 성능 최적화

으엉어엉 2025. 11. 20. 14:50
728x90

JPA를 사용하다 보면 N+1 문제를 해결하기 위해 가장 먼저 Fetch Join을 접하게 됩니다. 하지만 무분별한 Fetch Join 사용은 코드의 유연성을 해치거나, 심각한 성능 이슈(OOM)를 유발할 수 있습니다.

 

1. Entity Graph vs Fetch Join: 무엇이 다를까?

두 기능 모두 N+1 문제를 해결하고 연관된 엔티티를 한 번에 가져오는(Eager Loading) 역할을 합니다. 하지만 설계 관점에서 큰 차이가 있습니다.

1) 핵심 차이: 관심사의 분리 (Separation of Concerns)

  • Fetch Join: 쿼리 문장(JPQL) 안에 '비즈니스 로직(Where)'과 '페치 전략(Join fetch)'이 뒤섞여 있습니다.
  • Entity Graph: 비즈니스 로직은 쿼리 메서드에, 페치 전략은 어노테이션(@EntityGraph)에 맡겨 역할을 분리합니다.

2) 코드 비교

[Fetch Join 방식]

단순 조회임에도 쿼리 문자열이 길어지고, 오타 발생 위험이 있으며, 메서드 재사용이 어렵습니다.

 
public interface MemberRepository extends JpaRepository<Member, Long> {
    // 쿼리와 페치 전략이 강하게 결합됨 (하드코딩)
    @Query("select m from Member m join fetch m.team")
    List<Member> findAllWithTeam();
}

 

 

[Entity Graph 방식]

기본 메서드(또는 쿼리 메서드)는 그대로 두고, 필요할 때 어노테이션만 붙여서 페치 전략을 유연하게 적용할 수 있습니다.

 
public interface MemberRepository extends JpaRepository<Member, Long> {
    // 쿼리 메서드는 그대로 두고, 페치 전략만 주입 (가독성 UP)
    @EntityGraph(attributePaths = {"team"})
    List<Member> findAll();
}

 

단순 조회나 여러 화면에서 다른 연관 관계가 필요할 때는 Entity Graph가 훨씬 유연하고 깔끔합니다. 단, 복잡한 조건이나 Inner Join이 필수인 경우에는 Fetch Join(또는 QueryDSL)을 사용합니다.

 

 

2. 주의사항 1: MultipleBagFetchException

Entity Graph나 Fetch Join을 사용하여 두 개 이상의 컬렉션(List)을 한 번에 가져오려 하면 에러가 발생합니다.

  • 에러: org.hibernate.loader.MultipleBagFetchException
  • 원인: 1:N:M 관계가 되어 DB 데이터가 N × M개로 폭발(Cartesian Product)합니다. 하이버네이트는 이 뻥튀기된 데이터 중 무엇이 중복인지, 순서는 어떻게 되는지 판단할 수 없어 예외를 발생시킵니다.

해결 방법

  1. List 대신 Set 사용: 중복을 허용하지 않는 Set을 쓰면 해결되지만, 순서 보장이 어렵고 근본적인 데이터 뻥튀기 문제는 해결되지 않습니다.
  2. Batch Size 설정 (권장): 컬렉션은 지연 로딩으로 두고, default_batch_fetch_size 옵션을 사용합니다.

 

 

3. 주의사항 2: Fetch Join과 페이징(Paging)의 치명적 관계

실무에서 가장 조심해야 할 부분입니다. 컬렉션(OneToMany)을 Fetch Join 하면서 페이징 API(Pageable)를 사용하면 절대 안 됩니다.

Fetch Join + 페이징 시 동작 과정

  1. 하이버네이트가 경고 로그를 띄웁니다 (HHH000104: ... applying in memory!)
  2. DB에서 페이징(Limit, Offset) 없이 모든 데이터를 읽어옵니다.
  3. 애플리케이션 메모리에 적재한 뒤, 메모리 상에서 페이징을 시도합니다.
  4. 데이터가 많다면 **Out Of Memory(OOM)**로 서버가 다운됩니다.

 

 

4. 필승 전략: Batch Size 활용하기

컬렉션 조회와 페이징을 모두 잡으려면 Fetch Join을 포기하고 Batch Size를 활용하는 것이 정답입니다.

application.yml 설정

YAML
 
spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100 # 보통 100~1000 권장

동작 원리

  1. 부모 엔티티 조회: Member를 조회할 때 페이징 쿼리(Limit/Offset)가 DB에 정상적으로 나갑니다. (데이터 10개 조회)
  2. 자식 엔티티 조회: 조회된 Member들의 Team을 사용할 때, IN 쿼리로 한 번에 가져옵니다.
    • select * from team where member_id in (1, 2, ... 10)

성능 비교

방식 쿼리 수 데이터 전송량 페이징 가능 여부 위험도
Fetch Join 1번 많음 (중복 발생) 불가 (메모리 터짐) 높음
Batch Size 1+1번 최적화됨 가능 (DB 레벨) 낮음

 

5. 결론: 실무 적용 가이드

JPA 성능 최적화, 복잡하게 생각하지 말고 이 공식대로 적용해 보세요.

  1. XToOne (OneToOne, ManyToOne) 관계
    • 데이터 뻥튀기가 없으므로 Fetch Join (또는 Entity Graph)을 적극 사용하여 한 방 쿼리로 가져옵니다.
    • 페이징과 함께 사용해도 안전합니다.
  2. OneToMany (컬렉션) 관계
    • Fetch Join 사용 금지. (특히 페이징 필요 시)
    • 지연 로딩(Lazy)으로 두고 Batch Size 설정을 통해 해결합니다.
    • 이렇게 하면 N+1 문제는 해결하면서(정확히는 1+1), 페이징까지 깔끔하게 처리할 수 있습니다
728x90