JPA Cascade와 orphanRemoval 완전 정복
JPA의 cascade 설정과 orphanRemoval 옵션을 완전히 이해합니다. CascadeType 6가지(PERSIST·MERGE·REMOVE·REFRESH·DETACH·ALL)의 역할과 전파 원리, orphanRemoval과 CascadeType.REMOVE의 차이, 부모-자식 완전 소유 관계에서만 사용해야 하는 이유, 공유 엔티티에 cascade를 잘못 적용했을 때 발생하는 위험을 코드 예제와 함께 정리합니다.
지난 글에서 N+1 문제와 해결 전략을 다뤘습니다. 이번에는 JPA의 **cascade(영속성 전파)**와 orphanRemoval(고아 제거) 설정을 깊이 살펴봅니다. 이 두 옵션은 부모 엔티티의 연산을 자식 엔티티에 자동으로 전파하는 강력한 기능이지만, 잘못 사용하면 의도하지 않은 데이터 삭제·변경이 발생할 수 있습니다.
Cascade란
cascade 설정은 부모 엔티티에 수행하는 JPA 연산(persist, merge, remove 등)을 자식 엔티티에도 자동으로 전파합니다. @OneToMany, @OneToOne, @ManyToMany 등 모든 연관 관계 어노테이션에 설정할 수 있습니다.
// cascade 없는 경우 — 자식도 따로 저장해야 함
Post post = new Post("제목");
Comment comment = new Comment("댓글 내용");
post.getComments().add(comment);
comment.setPost(post);
em.persist(post); // post 저장
em.persist(comment); // comment 별도 저장 필요
// cascade = ALL이 있는 경우 — 자식 자동 저장
em.persist(post); // post와 comment 함께 INSERT
CascadeType 종류
CascadeType.PERSIST
부모를 em.persist() 할 때 자식도 함께 영속화합니다. 부모와 자식을 한 번의 save()로 처리할 수 있어 가장 많이 사용합니다.
Post post = new Post("Spring JPA");
post.addComment(new Comment("좋은 글이네요"));
post.addComment(new Comment("도움됐습니다"));
postRepository.save(post);
// INSERT INTO post ...
// INSERT INTO comment ... (2번)
CascadeType.MERGE
부모를 em.merge() 할 때 자식도 함께 병합합니다. 준영속 상태의 엔티티를 다시 영속화할 때 사용됩니다.
CascadeType.REMOVE
부모를 em.remove() 할 때 자식도 함께 삭제합니다. orphanRemoval = true와 동작이 비슷하지만 차이가 있습니다(아래 설명 참고).
Post post = postRepository.findById(1L).orElseThrow();
postRepository.delete(post);
// DELETE FROM comment WHERE post_id = 1
// DELETE FROM post WHERE id = 1
CascadeType.ALL
위 5가지(PERSIST, MERGE, REMOVE, REFRESH, DETACH) 모두 적용합니다. 부모-자식 생명주기가 완전히 일치하는 경우 CascadeType.ALL을 사용합니다.
orphanRemoval이란
컬렉션에서 제거된 자식 엔티티를 자동으로 DELETE하는 옵션입니다. 부모-자식 관계에서 자식이 부모 컬렉션과 분리되면 더 이상 의미 없는 “고아(orphan)“로 판단하여 DB에서 삭제합니다.
@Entity
public class Post {
@OneToMany(mappedBy = "post",
cascade = CascadeType.ALL,
orphanRemoval = true)
private List<Comment> comments = new ArrayList<>();
// 연관 편의 메서드
public void addComment(Comment comment) {
comments.add(comment);
comment.setPost(this);
}
public void removeComment(Comment comment) {
comments.remove(comment);
comment.setPost(null);
}
}
// orphanRemoval 동작 예시
@Transactional
public void removeFirstComment(Long postId) {
Post post = postRepository.findById(postId).orElseThrow();
Comment first = post.getComments().get(0);
post.removeComment(first);
// 트랜잭션 종료 시: DELETE FROM comment WHERE id = ?
// commentRepository.delete() 호출 없이 자동 삭제
}
CascadeType.REMOVE vs orphanRemoval 차이
두 옵션 모두 자식을 삭제하지만, 트리거가 다릅니다.
| 옵션 | 트리거 | 설명 |
|---|---|---|
CascadeType.REMOVE | 부모 엔티티 자체 삭제 | em.remove(parent) 시 자식 전파 삭제 |
orphanRemoval = true | 컬렉션에서 제거 | 부모 컬렉션에서 자식 제거 시 자동 DELETE |
두 옵션을 함께 쓰면(cascade = ALL + orphanRemoval = true) 부모 삭제뿐 아니라 컬렉션에서 제거하는 것만으로도 자식이 삭제됩니다. 부모 없이 자식이 존재해선 안 되는 완전한 소유 관계에서 이 조합을 사용합니다.
올바른 사용 vs 잘못된 사용
올바른 사용: 완전한 부모-자식 소유 관계
// ✓ Post → Comment: Comment는 Post 없이 존재 불가
@Entity
public class Post {
@OneToMany(mappedBy = "post",
cascade = CascadeType.ALL,
orphanRemoval = true)
private List<Comment> comments = new ArrayList<>();
}
// ✓ Order → OrderItem: OrderItem은 Order 없이 의미 없음
@Entity
public class Order {
@OneToMany(mappedBy = "order",
cascade = CascadeType.ALL,
orphanRemoval = true)
private List<OrderItem> orderItems = new ArrayList<>();
}
잘못된 사용: 공유 자원에 cascade 적용
// ✗ Tag는 여러 Post가 공유 — cascade 사용 금지
@Entity
public class Post {
@ManyToMany(cascade = CascadeType.ALL) // ← 위험!
private List<Tag> tags;
}
// post 삭제 시 다른 Post도 참조하던 Tag가 같이 삭제됨
// ✗ Member는 공유 자원 — Order에서 cascade 금지
@Entity
public class Order {
@ManyToOne(cascade = CascadeType.REMOVE) // ← 위험!
private Member member;
}
// order 삭제 시 member가 삭제됨 (다른 order도 연결 끊김)
실무 설계 체크리스트
cascade 적용 전 다음 질문에 답합니다.
// 1. 자식이 부모 없이 존재할 수 있는가?
// → NO : cascade ALL + orphanRemoval = true 적합
// → YES : cascade 사용 지양, 별도 처리
// 2. 자식이 다른 부모와도 연관될 수 있는가?
// → YES : cascade 사용 금지 (공유 자원)
// → NO : cascade 사용 가능
// 3. cascade 적용 범위 — PERSIST만 필요한 경우
@OneToMany(mappedBy = "post",
cascade = CascadeType.PERSIST) // PERSIST만
private List<Comment> comments;
// 자식 자동 저장은 허용, 삭제는 명시적으로만
cascade 없이 직접 관리하는 패턴
cascade가 부담스럽거나 정밀 제어가 필요할 때는 직접 저장하는 방식을 사용합니다.
@Transactional
public Post createPost(CreatePostRequest req) {
Post post = Post.of(req.title(), req.content());
postRepository.save(post); // post 먼저 저장
List<Comment> comments = req.comments().stream()
.map(c -> Comment.of(post, c.content()))
.toList();
commentRepository.saveAll(comments); // 자식 별도 저장
return post;
}
저장 순서를 명시적으로 제어할 수 있고, 복잡한 비즈니스 로직이 있을 때 더 명확합니다.
정리
CascadeType.PERSIST: 부모 저장 시 자식도 함께 INSERTCascadeType.REMOVE: 부모 삭제 시 자식도 함께 DELETECascadeType.ALL: 모든 연산 전파 — 완전한 부모-자식에서만orphanRemoval = true: 컬렉션에서 제거된 자식 자동 DELETEcascade = ALL + orphanRemoval = true: 자식이 부모 없이 존재 불가한 완전 소유 관계에서 사용- 공유 자원(
Tag,Category,Member)에는 cascade 절대 금지
지난 글: JPA N+1 문제 완전 정복 — 원인과 해결 전략
다음 글: JPA 상속 매핑 전략 완전 정복
읽어주셔서 감사합니다. 😊