List
Search
•
컬렉션 로딩의 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에 들어있는 데이터가 적어야함. 다갖고올테니까