개요
영속성 전이(Cascade)와 고아 제거(Orphan Removal)는 부모-자식 관계의 엔티티 상태를 자동으로 관리하는 JPA의 편의 기능이다. 올바르게 사용하면 코드를 간결하게 만들 수 있지만, 잘못 사용하면 의도하지 않은 데이터 손실을 초래할 수 있어 주의가 필요하다.
1. 영속성 전이 (Cascade)
핵심 개념
부모 엔티티의 영속성 상태 변화를 자식 엔티티에 자동으로 전파하는 기능
기본 설정
@OneToMany(cascade = CascadeType.ALL)
private List<Child> children = new ArrayList<>();
중요: Cascade의 기본값은 없다(빈 배열). 명시적으로 설정하지 않으면 아무것도 전이되지 않는다.
CascadeType 상세 설명
1) CascadeType.PERSIST
부모 엔티티를 저장할 때 연관된 자식 엔티티도 함께 저장
@Entity
public class Parent {
@OneToMany(cascade = CascadeType.PERSIST, mappedBy = "parent")
private List<Child> children = new ArrayList<>();
}
// 사용 예시
Parent parent = new Parent();
Child child = new Child();
parent.addChild(child);
entityManager.persist(parent); // child도 자동으로 persist됨
사용 시기: 부모와 자식이 항상 함께 생성되는 경우
2) CascadeType.REMOVE
부모 엔티티를 삭제할 때 연관된 자식 엔티티도 함께 삭제
entityManager.remove(parent); // 모든 자식도 함께 삭제됨
⚠️ 주의: 자식 엔티티가 다른 곳에서도 참조되고 있다면 데이터 무결성 문제가 발생할 수 있음
3) CascadeType.MERGE
준영속 상태의 부모 엔티티를 병합할 때 자식 엔티티의 변경사항도 함께 적용됨
준영속 상태란?
- 영속성 컨텍스트에서 분리된 엔티티
- 트랜잭션 커밋 후 영속성 컨텍스트가 종료되거나
- entityManager.detach(), clear(), close() 호출 시
- 직렬화 후 역직렬화된 엔티티
// 준영속 상태의 엔티티
Parent detachedParent = ...; // 세션 밖에서 가져온 엔티티
// 병합 시 자식의 변경사항도 함께 적용됨
Parent mergedParent = entityManager.merge(detachedParent);
4) CascadeType.REFRESH
부모 엔티티를 데이터베이스에서 다시 읽어올 때 자식 엔티티도 함께 새로고침함
entityManager.refresh(parent); // 자식도 DB에서 다시 조회됨
5) CascadeType.DETACH
부모 엔티티가 준영속 상태가 될 때 자식 엔티티도 준영속 상태로 전환됨
entityManager.detach(parent); // 자식도 함께 detach됨
6) CascadeType.ALL
위의 모든 옵션을 포함
사용 시기: 부모와 자식의 생명주기가 완전히 일치하는 경우 (예: 게시글-첨부파일, 주문-주문상세)
2. 고아 제거 (Orphan Removal)
핵심 개념
부모-자식 연관관계가 끊어진 자식 엔티티를 자동으로 삭제하는 기능
기본 설정
@OneToMany(mappedBy = "parent", orphanRemoval = true)
private List<Child> children = new ArrayList<>();
기본값: false (명시적으로 활성화해야 함)
고아가 되는 3가지 상황
1) 컬렉션에서 자식 제거
parent.getChildren().remove(0); // 첫 번째 자식 제거
// 트랜잭션 커밋 시 DELETE 쿼리 실행
2) 부모의 컬렉션을 새로운 컬렉션으로 교체
parent.setChildren(new ArrayList<>()); // 기존 자식들은 모두 고아가 됨
// 트랜잭션 커밋 시 기존 자식들 DELETE
3) 부모 엔티티 삭제
entityManager.remove(parent); // 자식도 함께 삭제됨
결과: CascadeType.REMOVE와 동일한 결과이지만, 의미와 동작 방식이 다름
3. Cascade vs Orphan Removal 비교
구분 Cascade Orphan Removal
| 목적 | 부모의 상태 변화 전파 | 연관관계가 끊긴 자식 삭제 |
| 적용 범위 | 모든 영속성 작업 | 삭제 작업에만 영향 |
| 부모 삭제 시 | REMOVE 타입만 자식 삭제 | 항상 자식 삭제 |
| 컬렉션에서 제거 시 | 영향 없음 | 자식 엔티티 삭제 |
| 단독 사용 | 가능 | 가능 |
실제 차이 예시
// 1. CascadeType.REMOVE만 사용
@OneToMany(cascade = CascadeType.REMOVE, mappedBy = "parent")
private List<Child> children;
parent.getChildren().remove(0); // DB에서 삭제되지 않음! 단지 연관관계만 끊김
// 2. orphanRemoval = true만 사용
@OneToMany(orphanRemoval = true, mappedBy = "parent")
private List<Child> children;
parent.getChildren().remove(0); // DB에서 삭제됨!
// 3. 둘 다 사용
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true, mappedBy = "parent")
private List<Child> children;
// 완전한 부모-자식 생명주기 관리
4. 실무에서의 사용 가이드
✅ 사용해도 되는 경우
- 완전한 소유 관계
// 게시글과 첨부파일
@Entity
public class Post {
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
private List<Attachment> attachments;
}
2. 생명주기가 일치하는 경우
// 주문과 주문 상세
@Entity
public class Order {
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> orderItems;
}
3. 집계 루트 패턴 (DDD)
@Entity
public class Aggregate {
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
private List<ValueObject> values;
}
⚠️ 주의해야 하는 경우
- 자식이 여러 부모에 속할 수 있는 경우
// 나쁜 예: 학생과 수업
@Entity
public class Course {
@OneToMany(cascade = CascadeType.ALL) // ❌ 위험!
private List<Student> students; // 수업을 삭제하면 학생도 삭제됨!
}
- 자식이 독립적인 엔티티인 경우
// 나쁜 예: 주문과 상품
@Entity
public class Order {
@OneToMany(cascade = CascadeType.ALL) // ❌ 위험!
private List<Product> products; // 주문을 삭제하면 상품도 삭제됨!
}
🎯 Best Practice
@Entity
public class Parent {
@OneToMany(mappedBy = "parent")
private List<Child> children = new ArrayList<>();
// 연관관계 편의 메서드
public void addChild(Child child) {
children.add(child);
child.setParent(this);
}
public void removeChild(Child child) {
children.remove(child);
child.setParent(null);
}
}
권장 전략:
- 기본적으로 Cascade를 사용하지 않음
- 필요한 경우 CascadeType.PERSIST 정도만 사용
- CascadeType.REMOVE와 orphanRemoval은 매우 신중하게 사용
- 명시적인 저장/삭제 로직 작성을 선호
5. 실전 팁
1) 양방향 관계에서만 사용
// 단방향에서는 사용하지 않음
@Entity
public class Parent {
@OneToMany // 양방향 아님
@JoinColumn(name = "parent_id")
private List<Child> children; // cascade 사용 자제
}
// 양방향에서 사용
@Entity
public class Parent {
@OneToMany(mappedBy = "parent", cascade = CascadeType.PERSIST)
private List<Child> children; // OK
}
@Entity
public class Child {
@ManyToOne
private Parent parent;
}
2) Soft Delete 고려
@Entity
public class Parent {
@OneToMany(mappedBy = "parent")
private List<Child> children;
private boolean deleted = false; // Hard Delete 대신 Soft Delete
}
3) 로깅 및 감사
@PreRemove
public void preRemove() {
log.warn("Deleting parent {} with {} children", id, children.size());
}
결론
영속성 전이와 고아 제거는 편리한 기능이지만, 의도하지 않은 데이터 삭제를 초래할 수 있습니다.
- 안전 우선: 기본적으로 사용하지 않고, 필요한 경우에만 최소한으로 적용
- 명시적 관리: 가능하면 명시적으로 저장/삭제 로직을 작성
- 완전한 소유 관계: 부모-자식이 1:1 소유 관계일 때만 CascadeType.ALL + orphanRemoval = true 사용
- 충분한 테스트: 삭제 로직은 반드시 통합 테스트 작성
현업에서는 보수적으로 접근하여 데이터 안정성을 최우선으로 고려하는 것이 좋음.
함부로 데이터 삭제하지 말 것. 데이터 = 돈!
'ETC > 1. Today I Learned' 카테고리의 다른 글
| [Java] Integer 클래스 (0) | 2026.01.15 |
|---|---|
| [Spring] Validation (0) | 2026.01.07 |
| 개발 주요 개념 정리 - 클래스, 인터페이스, JSON, (0) | 2025.12.24 |
| [Java] Collections에 관하여 (0) | 2025.12.23 |
| Iterator 활용하기 - Iterable이 아닌 경우 (0) | 2025.12.23 |