Search

JPA의 사실과 오해 (8) 컬렉션로딩 문제 (지연/즉시 로딩)

Tags
Study
JPA
Database
Last edited time
2025/08/31 11:49
2 more properties
Search
JPA의 사실과 오해 (9) 동시성 제어 / 애그리거트 완성하기
Study
JPA
Database
JPA의 사실과 오해 (9) 동시성 제어 / 애그리거트 완성하기
Study
JPA
Database
컬렉션 로딩의 2가지 문제
즉시 로딩 → 데카르트 곱 문제
지연 로딩 → N+1 문제

1. 즉시 로딩

1.1. 데카르트 곱 문제

1.1.1. 문제 원인

JPA에서 1:N 컬렉션을 2개 이상 즉시로딩(fetch join)하면, SQL 쿼리 결과에서 N x M(데카르트 곱) row가 발생
여러 테이블을 조인할 때 조인 조건이 부족해서 한 테이블의 모든 행과 다른 테이블의 모든 행이 모두 조합되어 중복 데이터가 생기는 현상

1.1.2. 예시

예시 상황
policy_id=1인 정책만 조회하는데 discount_condition(3개), policy_prices(3개)이 조인됨
3 x 3 = 9개의 중복된 결과가 발생
발생 원인
조인 조건이 policy_id만 일치해서 각 조건마다 모든 가격이 다 붙음
즉, 필요한 만큼만 결과가 나와야 하는데 불필요하게 곱해짐
@Entity public abstract class DiscountPolicy { @Id private Long id; @OneToMany(fetch = FetchType.EAGER) @JoinColumn(name = "POLICY_ID") private Set<DiscountCondition> conditions = new HashSet<>(); @ElementCollection(fetch = FetchType.EAGER) @CollectionTable(name = "POLICY_PRICES", joinColumns = @JoinColumn(name = "POLICY_ID")) private Set<Money> prices = new HashSet<>(); }
Java
복사
select discountpol0_.id as id1_0_0_, conditions1_.id as id1_1_1_, prices2_.amount as amount2_2_2_ from discount_policy discountpol0_ left outer join discount_condition conditions1_ on discountpol0_.id=conditions1_.policy_id left outer join policy_prices prices2_ on discountpol0_.id=prices2_.policy_id where discountpol0_.id=1
SQL
복사
실제 출력되는 결과
id
조건 id
금액
1
1
100
1
1
200
1
1
300
1
2
100
1
2
200
1
2
300
1
3
100
1
3
200
1
3
300
엔티티 매핑
DiscountPolicy 엔티티는 중복을 걸러서
conditions: 3개
prices: 3개
(내부적으로 Set 구조이므로 중복 제거)
하지만 쿼리 단계에서는 불필요하게 9배의 데이터가 발생

1.2. 데카르트 곱 문제 해결

1.2.1. JOIN 대신 추가 SELECT를 이용해 데이터를 즉시 로드

한 번의 거대한 JOIN 쿼리가 아니라, 필요한 컬렉션을 각각 별도의 쿼리로 나눠서 조회
@Fetch(FetchMode.SELECT)
Hibernate가 별도의 SELECT 쿼리로 각 컬렉션을 로딩하게 지정
@OneToMany(fetch = FetchType.EAGER) @JoinColumn(name = "POLICY_ID") @Fetch(org.hibernate.annotations.FetchMode.SELECT) private Set<DiscountCondition> conditions = new HashSet<>(); @ElementCollection(fetch = FetchType.EAGER) @CollectionTable(name = "POLICY_PRICES", joinColumns = @JoinColumn(name = "POLICY_ID")) @Fetch(org.hibernate.annotations.FetchMode.SELECT) private Set<Money> prices = new HashSet<>();
Java
복사
동작 흐름
메인 엔티티(DiscountPolicy)만 먼저 조회
select * from discount_policy where id = 1;
SQL
복사
이후 연관된 컬렉션(conditions, prices)을 각각 별도의 SELECT로 조회
select * from discount_condition where policy_id = 1; select * from policy_prices where policy_id = 1;
SQL
복사
JPA가 내부적으로 위 3개의 쿼리를 실행해서 결과를 합쳐서 엔티티에 넣어줌

1.2.2. 엔티티 전역 설정의 문제 (JPQL)

다중 SELECT 즉시로딩은 단건 조회시에는 효과적
JPQL을 통해 N 건 조회 시 비효율적인 쿼리 발행
em.createQuery("select p from DiscountPolicy p", DiscountPolicy.class).getResultList();
JPQL의 동작 원리
엔티티에 설정된 로딩 옵션은 JPQL 실행 후 바로 적용
JPQL은 기본적으로 메인 테이블만 조회
하지만 DiscountPolicy 엔티티에 EAGER 패치가 걸려 있다면,
JPA는 각 DiscountPolicy의 컬렉션 필드(conditions, prices)도 자동으로 "즉시 로딩"하려고 함
FetchMode.SELECT 적용시 쿼리 흐름
EAGER+FetchMode.SELECT 설정이면 DiscountPolicy 한 번 조회
각 정책마다 조건(conditions), 가격(prices)을 별도 쿼리로 N+1번씩 추가 조회
예시)
select * from discount_policy; select * from discount_condition where policy_id=1; select * from discount_condition where policy_id=2; ... select * from policy_prices where policy_id=1; select * from policy_prices where policy_id=2; ...
SQL
복사
N+1 문제 발생
데카르트 곱을 피하는 대신 정책이 4개면, 컬렉션마다 +4번의 쿼리가 추가됨
그래서 그냥 “기본으로 LAZY 셋팅을 다 해놓자” 라고 현업에서 얘기가 나오는 이유임
즉시 로딩은 컨트롤하기 어려워서 많은 곳에서 지연 로딩 사용
전역 EAGER 설정을 무시할 수 있는 2가지 방법
JPQL 페치 조인
엔티티 그래프

1.3. 전역 EAGER 설정 해결 방법

1.3.1. JPQL 페치 조인 (inner join)

JPQL 내부에서 fetch join
DiscountPolicy를 조회할 때 관련된 DiscountCondition 컬렉션을 즉시 조인해서 한 번의 쿼리로 가져옴
DiscountPolicy와 DiscountCondition의 연관 데이터를 INNER JOIN으로 한 번에 가져오므로
DiscountPolicy의 conditions 속성이 비어있지 않은 애들만 결과로 조회됨
List<DiscountPolicy> policies = em.createQuery( "select p from DiscountPolicy p join fetch p.conditions", DiscountPolicy.class ).getResultList();
Java
복사
select ... from discount_policy dp1_0 join discount_condition c1_0 on dp1_0.id = c1_0.policy_id
SQL
복사
페치 조인의 한계
페치 조인을 사용해도 엔티티의 EAGER 설정은 무시되지 않음
예를 들어,
DiscountPolicy에서 conditions만 fetch join 했을 때
prices 컬렉션은 EAGER 패치가 그대로 적용됨
결론
페치 조인은 원하는 컬렉션을 한 번에 가져올 수 있어 N+1을 막는 효과가 있음
하지만 엔티티에 EAGER가 걸린 다른 컬렉션은 별도 추가 쿼리가 나가서, 완벽한 해결책이 아님 (다른 컬렉션에는 여전히 N+1)
즉, fetch join은 지정한 컬렉션만 즉시 조인, 나머지 EAGER 컬렉션은 기존 로딩 전략을 그대로 따름

1.3.2. 엔티티 그래프

정의
조회할 때마다 “이번엔 이 관계만 즉시로딩”을 동적으로 지정 가능
어디서부터 어디까지 읽어야할 경로/범위를 설정할 수 있음
LAZY/EAGER 같은 전역 설정을 무시할 수 있음
실전에서 효율적 데이터 로딩 컨트롤에 매우 강력
엔티티 그래프의 동작 방식
어노테이션(@NamedEntityGraph)으로 엔티티 내에 원하는 fetch 경로(속성)를 정의
JPA의 find/JPQL/Repository 메소드에서 엔티티 그래프를 사용하겠다고 명시
지정한 속성(예: conditions)만 즉시로딩
나머지는 LAZY처럼 동작 → 동적로딩 범위 지정
예시)
엔티티 그래프 정의
엔티티 그래프의 conditions는 엔티티의 필드명으로 일치 필요
@Entity @NamedEntityGraph( name = "Policy.conditions", attributeNodes = @NamedAttributeNode("conditions") ) public abstract class DiscountPolicy { ... }
Java
복사
엔티티 그래프를 이용한 조회
Map<String, Object> hints = new HashMap<>(); hints.put("javax.persistence.fetchgraph", em.getEntityGraph("Policy.conditions")); DiscountPolicy policy = em.find(DiscountPolicy.class, id, hints);
Java
복사
이렇게 하면 DiscountPolicy를 조회하면서 conditions 컬렉션만 즉시 로딩됨
엔티티 그래프에서 설정한 조건만 outer join
다른 컬렉션(price 등)은 쿼리에서 빠짐 → 전역 EAGER와 다름
select ... from discount_policy dp1_0 left join discount_condition c1_0 on dp1_0.id = c1_0.policy_id where dp1_0.id = 1
SQL
복사
Spring Data JPA에서도 간단하게 적용
Repository에서 메서드 하나로 손쉽게 조건부 즉시 로딩 구현
public interface DiscountPolicyRepository extends JpaRepository<DiscountPolicy, Long> { @EntityGraph("Policy.conditions") List<DiscountPolicy> findAll(); }
Java
복사
복잡한 엔티티 트리(서브그래프)도 지원
예: Movie - DiscountPolicy - DiscountCondition까지 한 번에 로딩하고 싶을 때
엔티티 그래프에 서브그래프를 명시해주면 됨
2가지 페치 옵션
fetchgraph
엔티티 그래프에 명시된 경로만 즉시 로딩
나머지는 LAZY
loadgraph
그래프에 명시된 경로 즉시 로딩 +
EAGER로 지정된 속성도 추가로 별도의 쿼리로 조회 (즉시 로딩)
엔티티 그래프에 포함되지 않은 EAGER 필드
fetchgraph 사용 시
엔티티 그래프에 명시하지 않은 EAGER 필드는 즉시로딩 안함(LAZY처럼 동작)
loadgraph 사용 시
그래프에 없더라도, EAGER로 지정된 속성은 별도 쿼리로 즉시로딩
엔티티 그래프(EntityGraph)를 활용하면, 전역 EAGER/LAZY 설정에 구애받지 않고, 조회할 때마다 원하는 관계만 즉시로딩해서 성능과 유지보수성을 모두 잡을 수 있음

2. 지연 로딩

2.1. 지연로딩과 컬렉션 타입에 따른 동작 방식

2.1.1. 백(Bag)과 셋(Set)의 정의

Bag
중복 허용, 순서 유지하지 않음
중복 체크 안함
Collection<Movie> conditions = new ArrayList<>();
Set
중복 허용 안함. 순서유지 않음
중복 체크안함
Set<Movie> conditions = new HashSet<>();

2.1.2. 지연 로딩으로 설정된 양방향 백(Bag) 데이터 추가

DiscountPolicy DiscountCondition
One to Many 양방향 / Bag
public abstract class DiscountPolicy { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true, mappedBy = "policy", fetch = FetchType.LAZY) private Collection<DiscountCondition> conditions = new ArrayList<>(); public DiscountPolicy(Set<DiscountCondition> conditions) { this.conditions = conditions; this.conditions.forEach(condition -> condition.setDiscountPolicy(this)); } public void addDiscountCondition(DiscountCondition condition) { this.conditions.add(condition); condition.setDiscountPolicy(this); } }
Java
복사
public abstract class DiscountCondition { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @ManyToOne @JoinColumn(name = "POLICY_ID") private DiscountPolicy policy; public void setDiscountPolicy(DiscountPolicy policy) { this.policy = policy; } }
Java
복사
할인 정책(부모)쪽에서 할인 조건을 추가하는 경우
컬렉션을 로딩하지 않고 추가 가능
loadedPolicy.addDiscountCondition(new SequenceCondition(2))
이 시점에 JPA는 기존 DiscountCondition 리스트를 DB에서 SELECT하지 않음
새로운 SequenceCondition만 insert

2.1.3. 지연 로딩으로 설정된 양방향 셋(Set) 데이터 추가

Bag → Set으로 변경
ArrayList → HashSet
중복 여부를 확인하기 위해 DiscountCondition 컬렉션을 로딩

2.1.4. 단방향 백에 데이터 추가

One To Many
할인 정책(부모)(쪽에만 관계 설정한 경우
public abstract class DiscountPolicy { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) @JoinColumn(name = "POLICY_ID") private Collection<DiscountCondition> conditions = new ArrayList<>(); public DiscountPolicy(DiscountCondition... conditions) { this.conditions = new ArrayList<>(List.of(conditions)); } public void addDiscountCondition(DiscountCondition condition) { this.conditions.add(condition); } }
Java
복사
public abstract class DiscountCondition { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; }
Java
복사
예시 코드
var policy = em.find(DiscountPolicy.class, 1L); policy.addDiscountCondition(new SequenceCondition(5));
Java
복사
JPA 동작 과정
1.
DiscountPolicy를 조회 (select)
2.
컬렉션 전체를 무조건 select(로딩)
기존 DiscountCondition 전체 조회 쿼리가 실행됨
PersistentBag(내부 컬렉션 구현체)이 새로 추가되는 SequenceCondition의 PK/연관관계 정보를 알아야 하기 때문
3.
새 자식(SequenceCondition) insert
4.
필요시 update로 policy_id 세팅
PersistentBag 처리
자식(SequenceCondition) 객체에 policy_id 정보가 없으므로
JPA는 컬렉션(PersistentBag)을 정확히 관리하기 위해 기존 컬렉션 전체를 select해서 메모리로 올림
이후 새로운 자식을 추가해야만 insert + 연관관계 update 처리 가능

2.2. 백(Bag)과 셋(Set) 요약

2.2.1. 지연 로딩

양방향 매핑의 경우 컬렉션을 로딩하지 않고 요소 추가
요소 접근 없이 추가하는 경우가 많다면? 양방향 백으로 매핑 필요
단방향 매핑의 경우 항상 컬렉션 로딩
단방향/양방향 모두 컬렉션 로딩 후 요소 추가

2.2.2. 즉시 로딩

컬렉션을 조인하지 않고 별도의 쿼리로 조회
컬렉션을 조인하여 단일 쿼리로 조회

2.2.3. 백과 셋을 같이 즉시 로딩하는 경우

셋은 조인해서 갖고오고, 백은 별도 쿼리로 갖고옴
쿼리는 늘지만 가져오는 데이터량은 줄음 → 데카르트 곱 문제 어느정도 해소

2.3. N+1 문제

2.3.1. 문제 원인

"1번의 메인 쿼리 + N번의 추가 쿼리"가 발생하는 현상
LAZY로딩 컬렉션을 반복문에서 접근할 때,
→ 각 부모 엔티티마다 자식 컬렉션을 추가로 쿼리하게 되어
→ 전체 쿼리 수가 급증(N+1)

2.3.2. 문제 해결 (1) 일괄 패치

단방향 셋 지연 로딩 예제
1:N (DiscountPolicy: DiscountCondition)에서 N쪽에 각각 100개씩 데이터가 있다면? → N+1 문제 발생
N의 갯수를 줄여야함
일괄 페치
일정 갯수의 DiscountPolicy 만큼 연결된 DiscountCondition을 함께 조회
하이버네이트 전용 (JPA 표준은 아님)
배치 사이즈 조정 가능
지연 로딩 시 배치 사이즈 씩 컬렉션 패치
N+1 쿼리 갯수를 배치사이즈 만큼 줄일 수 있음
예시)
@BatchSize(size = 2)
Hibernate 어노테이션
컬렉션 지연로딩 시, 한 번에 최대 2개 부모에 해당하는 자식 컬렉션을 IN 조건으로 묶어 한 번에 프리패치
즉, 2개씩 묶어서 컬렉션 데이터를 가져옴
@Entity public abstract class DiscountPolicy { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) @JoinColumn(name = "POLICY_ID") @org.hibernate.annotations.BatchSize(size = 2) private Set<DiscountCondition> conditions = new HashSet<>(); public DiscountPolicy(DiscountCondition... conditions) { this.conditions = new HashSet<>(List.of(conditions)); } public void addDiscountCondition(DiscountCondition condition) { this.conditions.add(condition); } }
Java
복사
쿼리 실행 흐름
1.
select ... from discount_policy dp1_0
DiscountPolicy 여러 개 조회
2.
DiscountCondition 컬렉션 접근 시
정책 2개씩 IN 조건으로 묶어서 자식들 select
select ... from discount_condition c1_0 where c1_0.policy_id in (1, 2); select ... from discount_condition c1_0 where c1_0.policy_id in (3, 4);
SQL
복사
N+1에서 N이 2개씩 묶여 N/2번 쿼리로 감소

2.3.3. 문제 해결 (2) 서브 쿼리

DiscountPolicy를 로드하는데 사용하는 쿼리를 서브쿼리로 이용해서 DisountCondition 전체를 한번에 조회
@Entity public abstract class DiscountPolicy { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) @JoinColumn(name = "POLICY_ID") @org.hibernate.annotations.Fetch( org.hibernate.annotations.FetchMode.SUBSELECT) private Set<DiscountCondition> conditions = new HashSet<>(); public DiscountPolicy(DiscountCondition... conditions) { this.conditions = new HashSet<>(List.of(conditions)); } public void addDiscountCondition(DiscountCondition condition) { this.conditions.add(condition); } }
Java
복사
쿼리 실행 흐름
1.
부모인 DiscountPolicy를 조회
select ... from discount_policy dp1_0
SQL
복사
2.
자식인 DiscountPolicy를 서브쿼리로 조회
select ... from discount_condition c1_0 where c1_0.policy_id in ( select dp1_0.id from discount_policy dp1_0 )
SQL
복사
주의 사항
이 방법을 하기 위해서는 N에 들어있는 데이터가 적어야함. 다갖고올테니까