Search
moon
sun

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에 들어있는 데이터가 적어야함. 다갖고올테니까