JPA의 프록시, FetchType, cascade와 orphanRemoval은 연관관계에서 함께 등장하지만 서로 다른 문제를 해결한다.
- 프록시는 아직 읽지 않은 엔티티를 어떤 참조로 표현할지에 관한 문제다.
FetchType과 fetch plan은 데이터를 언제, 어떤 SQL로 읽을지에 관한 문제다.- cascade는
persist,remove같은 영속성 작업을 어디까지 전파할지 정한다. orphanRemoval은 부모와의 관계에서 빠진 자식을 삭제할지 정한다.
이 네 축을 분리하면 “LAZY면 N+1이 없다”, “cascade와 고아 객체 삭제는 같다” 같은 오해를 피할 수 있다.
getReference()는 존재 확인용 조회가 아니다
EntityManager.getReference()는 주어진 식별자의 엔티티 참조를 반환한다. provider는 실제 상태가 처음 필요할 때 데이터를 읽도록 reference를 만들 수 있다.
Member member = entityManager.getReference(Member.class, memberId);
// 식별자만 연관관계에 연결하는 동안에는 실제 상태 조회를 미룰 수 있다.
order.assignMember(member);
Jakarta Persistence 명세상 이 reference는 상태에 처음 접근할 때 EntityNotFoundException을 던질 수 있다. 그러므로 getReference()로 객체를 받았다는 사실만으로 DB row가 존재한다고 판단하면 안 된다. 존재를 지금 확인해야 한다면 find()나 명시적인 query가 더 맞다.
같은 persistence context에는 같은 entity type과 primary key에 대해 최대 하나의 managed instance가 존재한다. 하지만 이를 근거로 “프록시와 실제 객체를 ==로 비교하면 항상 true”라고 일반화할 수는 없다. 먼저 find()했는지, getReference()했는지, 어느 persistence context인지에 따라 실제 instance와 초기화 시점이 달라질 수 있다.
애플리케이션의 업무 로직은 프록시 class 이름이나 getClass() 결과에 의존하지 않는 편이 안전하다. 식별자 기반 equals()를 구현할 때도 아직 ID가 없는 새 entity, 상속과 provider proxy를 함께 고려해야 하며, 단순한 class 비교 한 줄로 모든 경우를 해결할 수 없다.
초기화 여부와 persistence context의 범위
프록시나 lazy association은 열린 persistence context의 도움을 받아 초기화된다. transaction과 context가 끝난 뒤 아직 읽지 않은 상태에 접근하면 Hibernate에서는 LazyInitializationException이 발생할 수 있다.
확인·강제 초기화 API도 층이 다르다.
- 표준 JPA:
PersistenceUnitUtil.isLoaded(entity)로 load 상태를 확인할 수 있다. - Hibernate:
Hibernate.isInitialized()와Hibernate.initialize()같은 provider API가 있다. - 업무 코드: 필요한 graph를 query 시점에 명시하고 transaction 안에서 결과 DTO로 매핑하는 편이 보통 더 예측 가능하다.
Hibernate.initialize()를 여기저기 호출하는 방식은 query plan을 숨길 수 있다. 무엇을 읽어야 하는 유스케이스인지 먼저 정하고 fetch join, entity graph 또는 별도 query를 선택하는 것이 좋다.
EAGER와 LAZY는 SQL 개수를 보장하지 않는다
Jakarta Persistence에서 EAGER는 해당 상태를 eager하게 가져와야 한다는 provider의 의무이고, LAZY는 처음 접근할 때까지 미룰 수 있다는 힌트다. provider는 LAZY로 표시된 상태도 eager하게 가져올 수 있다.
연관관계의 기본값도 다르다.
| 연관관계 | JPA 기본 fetch |
|---|---|
@ManyToOne |
EAGER |
@OneToOne |
EAGER |
@OneToMany |
LAZY |
@ManyToMany |
LAZY |
Hibernate 애플리케이션에서는 to-one 관계도 LAZY로 명시하고, 유스케이스별 query에서 필요한 graph를 고르는 정책을 자주 쓴다. 다만 이것은 “모든 관계는 LAZY면 끝”이라는 뜻이 아니다. 목록을 조회한 뒤 각 entity의 lazy association을 반복해서 읽으면 최초 query 1개에 추가 query N개가 이어지는 N+1이 생길 수 있다.
@ManyToOne(fetch = FetchType.LAZY, optional = false)
private Team team;
해당 화면에서 회원과 팀 이름을 반드시 함께 써야 한다면 query 자체에 fetch plan을 넣는다.
select m
from Member m
join fetch m.team
where m.active = true
선택지는 하나가 아니다.
- fetch join: 한 query에서 필요한 연관관계를 함께 가져온다.
- entity graph: query 또는
find()의 fetch plan을 metadata로 지정한다. - batch fetching: 여러 미초기화 proxy·collection을 ID 묶음으로 조회해 추가 query 수를 줄인다.
- 별도 projection query: 화면·API에 필요한 열만 DTO로 직접 읽는다.
batch size를 설정했다고 항상 “1+1 query”가 되는 것은 아니다. 대상 수와 batch size, association 종류에 따라 추가 query 수가 달라진다. collection fetch join은 결과 row가 크게 불어나거나 pagination과 충돌할 수도 있다. mapping annotation만 보고 성능을 추측하지 말고 실제 SQL과 row 수를 확인해야 한다.
cascade는 영속성 작업의 전파다
cascade는 연관관계 자체를 저장해 주는 옵션이 아니라 persist, merge, remove, refresh, detach 작업을 관련 entity에 전파하는 규칙이다.
@OneToMany(
mappedBy = "order",
cascade = CascadeType.ALL,
orphanRemoval = true
)
private final List<OrderLine> lines = new ArrayList<>();
CascadeType.ALL은 편리하지만 부모가 자식의 생명주기를 정말 소유할 때만 자연스럽다. 여러 aggregate나 독립된 업무 흐름이 공유하는 entity에 REMOVE를 전파하면 한쪽 삭제가 다른 쪽 데이터까지 지울 수 있다. 특히 @ManyToMany의 상대 entity는 보통 독립 생명주기를 가지므로 remove cascade를 기계적으로 적용하면 안 된다.
orphanRemoval과 CascadeType.REMOVE는 시작점이 다르다
둘 다 delete로 이어질 수 있지만 동작을 시작시키는 사건이 다르다.
| 설정 | 삭제가 시작되는 상황 |
|---|---|
cascade = REMOVE |
부모 entity에 remove()를 적용할 때 자식으로 전파한다. |
orphanRemoval = true |
@OneToOne 또는 @OneToMany 관계에서 자식을 제거할 때 해당 자식을 삭제한다. |
Jakarta Persistence 명세는 orphanRemoval을 부모가 private하게 소유한 entity에 사용하도록 설명한다. 관계에서 빠진 entity는 flush 시점에 remove되며, portable application은 이를 다른 관계로 다시 옮기는 순서에 의존해서는 안 된다.
양방향 연관관계라면 helper method로 양쪽을 함께 맞춘다.
public void addLine(OrderLine line) {
lines.add(line);
line.assignOrder(this);
}
public void removeLine(OrderLine line) {
if (lines.remove(line)) {
line.clearOrder();
}
}
collection에서만 빼고 자식의 back-reference를 그대로 두면 memory 상태와 mapping 의도가 어긋날 수 있다.
cascade = ALL과 orphanRemoval = true를 함께 쓰면 부모를 통해 자식의 생성·변경·삭제 생명주기를 관리하기 쉬워진다. 그러나 annotation 조합만으로 DDD aggregate가 되는 것은 아니다. 외부에서 자식을 직접 참조해도 되는지, transaction 일관성 경계가 어디인지, 자식이 부모 없이 의미를 가지는지를 domain 규칙으로 먼저 판단해야 한다.
연관관계를 설계할 때 묻는 순서
- 이 association은 항상 필요한가, 특정 유스케이스에서만 필요한가?
- 목록 query에서 실제로 몇 row와 몇 SQL이 발생하는가?
- 연관 entity는 부모만 소유하는가, 다른 곳에서도 독립적으로 참조하는가?
- 부모를 삭제할 때 자식도 반드시 삭제해야 하는가?
- 관계에서 자식을 빼는 것과 자식 자체를 삭제하는 것이 같은 업무 의미인가?
- context가 끝난 뒤 web layer에서 lazy state를 읽고 있지는 않은가?
JPA 학습의 전체 순서는 JPA 공부 개요, managed·detached 상태와 flush는 JPA 영속성 관리에서 이어서 볼 수 있다.
정리
getReference()는 지연 가능한 참조이며 존재 확인 API가 아니다.- proxy identity를
==또는 concrete class 이름으로 일반화하지 않는다. EAGER·LAZY는 fetch timing contract이고 query 개수 자체는 fetch plan이 결정한다.- cascade는 operation propagation,
orphanRemoval은 관계에서 빠진 private-owned child 삭제다. - aggregate ownership을 먼저 정한 뒤 cascade와 orphan removal을 적용한다.
연관관계 mapping은 annotation 몇 개를 외우는 문제가 아니라 조회 계획과 생명주기 소유권을 각각 명시하는 일이다.
참고 자료
'배움과 성장 > 백엔드·데이터' 카테고리의 다른 글
| JPA 영속성 컨텍스트: 엔티티 상태·flush·commit 구분하기 (0) | 2022.12.28 |
|---|---|
| JPA 값 타입 정리: @Embeddable과 @ElementCollection의 경계 (2) | 2022.12.28 |
| JPA 공부 순서: ORM·영속성 컨텍스트부터 Spring Data JPA까지 (0) | 2022.12.28 |
| MySQL 버전 선택 기준: 5.7·8.0 유지 단계와 8.4·9.7 LTS (0) | 2022.12.03 |
| Spring Boot 버전 선택: Java·Jakarta·지원 기간으로 고르는 법 (0) | 2022.12.03 |
댓글