본문 바로가기

Spring

[Spring] CascadeType.ALL와 orphanRemoval = true에 대해서

JPA로 개발을 진행하다보면 테이블간 맵핑관계에서 CascadeType.ALL(또는 REMOVE, PERSIST)와 orphanRemoval = true를 붙이는 경우를 볼 수 있다. 나 역시 그랬다. CascadeType.ALL을 쓰게되면 부모 엔티티 생성 또는 삭제 시, 자식의 생명주기까지 관리할 수 있어서 깊이 생각하지 않고 사용했었다. 그러나 orphanRemoval는 자식이 연관관계가 끊어질 경우 고아객체가 되는 것을 방지한다. 정도로만 알고 있었지 CascadeType.ALL와 orphanRemoval의 상관관계에 대해서는 크게 신경쓰지 않았다.

 

각각은 어떤 속성을 가지고 있는 걸까?


결론부터 말하면 다음과 같다.

상황 CascadeType.REMOVE orphanRemoval=true
부모 엔티티 삭제 자식 엔티티도 함께 삭제됨 자식 엔티티도 함께 삭제됨
부모와 자식 사이의 관계 끊기 자식 엔티티는 남아 있음 (고아 객체가 아님) 자식 엔티티가 고아 객체로 간주되어 삭제됨
자식 엔티티를 직접 삭제할 때 직접 삭제가 필요함 직접 삭제가 필요 없음
(부모와의 관계 끊기만으로 삭제됨)

 

그럼 각각의 상황을 테스트 해보자.

 

먼저 테스트를 진행하기에 앞서 댓글(Comment)와 답글(Reply)의 관계 1 : N으로 아래와 같다.

 

Comment

@Entity
@Getter
@RequiredArgsConstructor
@ToString
public class Comment extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "comment_id")
    private Long id;

    @Column(name = "cmt_id")
    private String cmtId;

    @Column(columnDefinition = "longtext")
    private String textOriginal;

    private Long likeCount;

    private LocalDateTime publishedAt;

    //... 다른 필드 변수 생략
    
    @OneToMany(mappedBy = "comment")
    private List<Reply> replies = new ArrayList<>();

 

Reply

@Entity
@Getter
@Setter
@RequiredArgsConstructor
public class Reply extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "reply_id")
    private Long id;

    @Column(columnDefinition = "longtext")
    private String textOriginal;

    private Long likeCount;

    private LocalDateTime publishedAt;

    //... 다른 필드 변수 생략
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "comment_id")
    private Comment comment;

    public void changeComment(Comment comment) {
        // 새로운 연관관계 설정
        this.comment = comment;
        comment.getReplies().add(this);
    }

    public void detachReplyFromComment(Comment comment) {
        // 부모 댓글에서 자식 답글의 참조 제거
        comment.getReplies().remove(this);
        this.setComment(null);
    }
}

 

댓글과 답글의 데이터를 먼저 삽입한다.

# 댓글
INSERT INTO comment (comment_id, created_at, updated_at, author_display_name, author_profile_image_url, like_count, published_at, text_original, campaign_content_id)
VALUES (2, NOW(), NOW(), 'daniel2', 'daniel2', 1000, NOW(), '댓글내용입니다.', 2);

# 답글
INSERT INTO reply (reply_id, created_at, updated_at, author_display_name, author_profile_image_url, like_count, published_at, text_original, comment_id) VALUES (3, NOW(), NOW(), '답글게시자명1', '답글썸네일', 2000, NOW(), '첫번째 답글내용입니다.', 2);
INSERT INTO reply (reply_id, created_at, updated_at, author_display_name, author_profile_image_url, like_count, published_at, text_original, comment_id) VALUES (4, NOW(), NOW(), '답글게시자명2', '답글썸네일', 3000, NOW(), '두번째 답글내용입니다.', 2);

Cascade=REMOVE 설정 (orphanRemoval = true 미설정)

@OneToMany(mappedBy = "comment", cascade = CascadeType.REMOVE)
private List<Reply> replies = new ArrayList<>();

 

1. 부모 엔티티 삭제

@Test
@DisplayName("부모 엔티티를 삭제") 
void removeCommentOnlyCascadeTypeRemove() {
    Optional<Comment> byId = commentRepository.findById(2L);
    byId.ifPresent(comment -> commentRepository.delete(comment));

    // 변경 사항을 데이터베이스에 반영
    entityManager.flush();

    Optional<Reply> byId3 = replyRepository.findById(3L);
    Assertions.assertThat(byId3).isEmpty();

    Optional<Reply> byId4 = replyRepository.findById(4L);
    Assertions.assertThat(byId4).isEmpty();
}

 

테스트 실행 결과

부모 엔티티(Comment)를 삭제한 결과, 자식 엔티티인 Reply id = 1과 id = 2가 모두 삭제되었다. (총 3번의 DELETE 쿼리)

 

2. 부모와 자식 사이의 관계 끊기

@Test
@DisplayName("부모 엔티티와 자식 엔티티 관계 삭제")
void removeRelationshipOnlyCascadeTypeRemove() {
    Comment comment = commentRepository.findById(2L)
            .orElseThrow(() -> new NotFoundException("해당 댓글이 존재하지 않습니다."));

    Optional<Reply> reply1 = replyRepository.findById(3L);
    reply1.ifPresent(reply -> reply.detachReplyFromComment(comment));

    // 변경 사항을 데이터베이스에 반영
    entityManager.flush();

    Assertions.assertThat(replyRepository.findById(3L)).isEmpty();
}

 

테스트 실행 결과

부모 엔티티에서 자식 엔티티에 대한 참조를 제거하면, JPA는 해당 자식 엔티티가 더 이상 부모와 연결되어 있지 않다고 판단한다. 그러나

이 경우 자식 엔티티는 여전히 데이터베이스에 남아있다. 연관관계를 끊을 때 자식 엔티티의 상태가 변경되므로, JPA는 이를 반영하기 위해 UPDATE 쿼리를 실행한다. (DELETE 쿼리는 실행되지 않음)

 

orphanRemoval = true 설정 (Cascade=REMOVE 미설정)

@OneToMany(mappedBy = "comment", orphanRemoval = true)
private List<Reply> replies = new ArrayList<>();

 

1. 부모 엔티티 삭제

@Test
@DisplayName("부모 엔티티를 삭제") 
void removeCommentOnlyOrphanRemoval() {
    Optional<Comment> byId = commentRepository.findById(2L);
    byId.ifPresent(comment -> commentRepository.delete(comment));

    // 변경 사항을 데이터베이스에 반영
    entityManager.flush();

    Optional<Reply> byId3 = replyRepository.findById(3L);
    Assertions.assertThat(byId3).isEmpty();

    Optional<Reply> byId4 = replyRepository.findById(4L);
    Assertions.assertThat(byId4).isEmpty();
}

 

테스트 실행 결과

부모 엔티티(Comment)를 삭제한 결과, Cascade=REMOVE와 같이 자식 엔티티인 Reply id = 1과 id = 2가 모두 삭제되었다. (총 3번의 DELETE 쿼리)

 

2. 부모와 자식 사이의 관계 끊기

@Test
@DisplayName("부모 엔티티와 자식 엔티티 관계 삭제")
void removeRelationshipOnlyOrphanRemoval() {
    Comment comment = commentRepository.findById(2L)
            .orElseThrow(() -> new NotFoundException("해당 댓글이 존재하지 않습니다."));

    Optional<Reply> reply1 = replyRepository.findById(3L);
    reply1.ifPresent(reply -> reply.detachReplyFromComment(comment));

    // 변경 사항을 데이터베이스에 반영
    entityManager.flush();

    Assertions.assertThat(replyRepository.findById(3L)).isEmpty();
}

 

테스트 실행 결과

 

삭제되어야 할 자식 엔티티가 갑자기 UPDATE 쿼리가 실행됐다. 순간 테스트 코드를 잘못 짰나 의심하였다. 코드를 분석하던 중 원인을 확인하였다. (버그라고 하는데?)

보통 부모 엔티티가 자식 엔티티를 관리하는 경우에는 CascadeType.PERSIST + orphanRemoval을 함께 적용한다고 한다.

@OneToMany(mappedBy = "comment", cascade = CascadeType.PERSIST, orphanRemoval = true)

 

테스트 재시도 결과

부모와의 관계를 끊으면서 자식 엔티티는 고아 객체로 처리되어 DELETE 쿼리가 발생하였다. 

결국, CascadeType.ALL(또는 REMOVE, PERSIST)와 orphanRemoval = true를 붙이는 이유는 이 때문이 아니었을까 싶다.

  1. 엔티티의 일관성 유지
    자동 영속화: cascade = persist를 설정하면 부모 엔티티가 저장될 때 관련된 자식 엔티티도 자동으로 저장된다. 이를 통해 엔티티 간의 관계가 항상 일관된다.
    고아 객체 처리: orphanRemoval = true를 통해 부모와의 관계가 끊어진 자식 엔티티는 자동으로 삭제된다. 이를 통해 데이터베이스에 불필요한 고아 객체가 남지 않는다.
  2. 데이터 무결성
    부모-자식 관계에 대한 강한 제약: 부모 엔티티가 삭제되거나 관계가 끊어질 때, 자식 엔티티 또한 자동으로 삭제되므로 데이터 무결성을 유지할 수 있다.

위의 경우는 부모가 하나이다. 만약, 부모가 둘인 경우, cascade = persist나 orphanRemoval = true를 사용할 수 있지만, 각 부모의 역할과 관계를 고려해야한다.

 

학생과 수업의 다대다 관계일 경우 복잡성을 풀기 위해 중간 테이블을 두게 되는데 이 때가 부모가 둘이 되는 상황과 유사하다.

테이블 관계도

이 경우, Enrollment 엔티티가 학생과 수업 간의 관계를 관리한다. 하나의 학생은 여러 수업에 등록할 수 있고, 하나의 수업에는 여러 학생이 등록될 수 있다.

@Entity
public class Student {
    @Id
    private Long id;

    @OneToMany(mappedBy = "student")
    private List<Enrollment> enrollments = new ArrayList<>();
}
@Entity
public class Course {
    @Id
    private Long id;

    @OneToMany(mappedBy = "course")
    private List<Enrollment> enrollments = new ArrayList<>();
}
@Entity
public class Enrollment {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne
    private Student student;

    @ManyToOne
    private Course course;
}

 

 

orphanRemoval = true가 설정된 경우, 중간 테이블에서 부모와의 관계가 끊어진 자식 엔티티는 삭제된다. 어떤 부모와의 관계가 끊어졌는지 명확하지 않을 수 있어서, 의도치 않게 데이터를 삭제하게 된다.

 

이때에는 수동 관리가 명확하다고 보여진다. (개인적인 의견입니다.)


수동으로 엔티티를 추가하거나 삭제하면, 관계의 변화가 명확해지고, 의도한 대로 데이터베이스를 관리할 수 있다.

public void enrollStudentInCourse(Student student, Course course) {
    Enrollment enrollment = new Enrollment();
    enrollment.setStudent(student);
    enrollment.setCourse(course);
    enrollmentRepository.save(enrollment); // 수동으로 등록
}

public void removeStudentFromCourse(Student student, Course course) {
    Enrollment enrollment = enrollmentRepository.findByStudentAndCourse(student, course);
    if (enrollment != null) {
        enrollmentRepository.delete(enrollment); // 수동으로 삭제
    }
}

 

게다가 중간 테이블에서는 orphanRemoval = true 설정이 제대로 작동하지 않는다. 중간 테이블의 경우, 한 부모와의 관계가 끊어져도 다른 부모와의 관계는 여전히 존재하므로, 중간 테이블의 레코드는 삭제되지 않는다.

 

또한, 외래 키 제약이 존재하는 경우, 부모 엔티티가 삭제되면 이와 연결된 중간 테이블의 레코드가 삭제되지 않아 데이터 무결성이 유지되지 않을 수 있어 수동으로 중간 테이블의 레코드를 삭제해야 함으로 귀결될 것이다.