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)합니다. 하이버네이트는 이 뻥튀기된 데이터 중 무엇이 중복인지, 순서는 어떻게 되는지 판단할 수 없어 예외를 발생시킵니다.
해결 방법
- List 대신 Set 사용: 중복을 허용하지 않는 Set을 쓰면 해결되지만, 순서 보장이 어렵고 근본적인 데이터 뻥튀기 문제는 해결되지 않습니다.
- Batch Size 설정 (권장): 컬렉션은 지연 로딩으로 두고, default_batch_fetch_size 옵션을 사용합니다.
3. 주의사항 2: Fetch Join과 페이징(Paging)의 치명적 관계
실무에서 가장 조심해야 할 부분입니다. 컬렉션(OneToMany)을 Fetch Join 하면서 페이징 API(Pageable)를 사용하면 절대 안 됩니다.
Fetch Join + 페이징 시 동작 과정
- 하이버네이트가 경고 로그를 띄웁니다 (HHH000104: ... applying in memory!)
- DB에서 페이징(Limit, Offset) 없이 모든 데이터를 읽어옵니다.
- 애플리케이션 메모리에 적재한 뒤, 메모리 상에서 페이징을 시도합니다.
- 데이터가 많다면 **Out Of Memory(OOM)**로 서버가 다운됩니다.
4. 필승 전략: Batch Size 활용하기
컬렉션 조회와 페이징을 모두 잡으려면 Fetch Join을 포기하고 Batch Size를 활용하는 것이 정답입니다.
application.yml 설정
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100 # 보통 100~1000 권장
동작 원리
- 부모 엔티티 조회: Member를 조회할 때 페이징 쿼리(Limit/Offset)가 DB에 정상적으로 나갑니다. (데이터 10개 조회)
- 자식 엔티티 조회: 조회된 Member들의 Team을 사용할 때, IN 쿼리로 한 번에 가져옵니다.
- select * from team where member_id in (1, 2, ... 10)
성능 비교
| 방식 | 쿼리 수 | 데이터 전송량 | 페이징 가능 여부 | 위험도 |
| Fetch Join | 1번 | 많음 (중복 발생) | 불가 (메모리 터짐) | 높음 |
| Batch Size | 1+1번 | 최적화됨 | 가능 (DB 레벨) | 낮음 |
5. 결론: 실무 적용 가이드
JPA 성능 최적화, 복잡하게 생각하지 말고 이 공식대로 적용해 보세요.
- XToOne (OneToOne, ManyToOne) 관계
- 데이터 뻥튀기가 없으므로 Fetch Join (또는 Entity Graph)을 적극 사용하여 한 방 쿼리로 가져옵니다.
- 페이징과 함께 사용해도 안전합니다.
- OneToMany (컬렉션) 관계
- Fetch Join 사용 금지. (특히 페이징 필요 시)
- 지연 로딩(Lazy)으로 두고 Batch Size 설정을 통해 해결합니다.
- 이렇게 하면 N+1 문제는 해결하면서(정확히는 1+1), 페이징까지 깔끔하게 처리할 수 있습니다
'Spring' 카테고리의 다른 글
| Docker와 Kubernetes (0) | 2025.11.24 |
|---|---|
| AOP와 Redisson (0) | 2025.11.24 |
| 락 선택 기준표와 실무 트러블슈팅 (Deadlock, Timeout) (1) | 2025.10.27 |
| 게시글 동시성 처리 - 비관적 락은 언제 써야 할까? (0) | 2025.10.27 |
| 분산 락(Distributed Lock), 여러 서버에서 하나만 실행하기 (0) | 2025.10.27 |