ETC/1. Today I Learned

[Spring] Spring JPA 영속성 전이(Cascade)와 고아 제거(Orphan Removal)

montmer27 2026. 1. 6. 21:07

개요

영속성 전이(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. 실무에서의 사용 가이드

✅ 사용해도 되는 경우

  1. 완전한 소유 관계
// 게시글과 첨부파일 
@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; 
}

 

⚠️ 주의해야 하는 경우

  1. 자식이 여러 부모에 속할 수 있는 경우
// 나쁜 예: 학생과 수업 
@Entity 
public class Course { 
@OneToMany(cascade = CascadeType.ALL) // ❌ 위험! 
private List<Student> students; // 수업을 삭제하면 학생도 삭제됨! 
}
  1. 자식이 독립적인 엔티티인 경우
// 나쁜 예: 주문과 상품 
@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 사용
  • 충분한 테스트: 삭제 로직은 반드시 통합 테스트 작성

현업에서는 보수적으로 접근하여 데이터 안정성을 최우선으로 고려하는 것이 좋음.

함부로 데이터 삭제하지 말 것. 데이터 = 돈!