Cascade vs @Delete
Cascade vs @OnDelete
이번 포스트는 이전에 다루었던 Cascade의 연장선이다.
이전에 언급했던 것처럼 생명주기를 공유하는, 어느 한 엔티티의 생명주기에 의존하는 엔티티들이 존재할 경우 Cascade를 사용하면 좋다고 언급했다.
그 이유는 프로젝트 - Cascade의 오해 (tistory.com)에 설명해놓았지만 간단히 다시 살펴보겠다.
어떠한 게시판이 존재한다고 가정하자.
우리가 흔히 잘 알고있듯이 게시판에는 여러개의 게시글, 그 안에 여러개의 댓글이 존재한다. (추천 또한 존재할 수 있다.)
만약, 게시판이 존재한다면 어떻게 될까?
일반적인 경우에는 게시판이 제거됨과 동시에 그 안에 존재하는 게시글, 댓글 (혹은 추천)이 모두 제거될 것이다.
왜냐하면 그들은 게시판의 생명주기에 의존하며 더이상 존재해선 안되는 존재들인 것이다.
그렇기에 이러한 부분들에 대해서는 Cascade를 활용해도 좋을 것 같다.
하지만 우리는 이 부분에 대해서 간과하는 부분이 존재한다. Cascade를 걸어주려면 @OneToMany 부분에 Cascade 옵션을 걸어줘야 한다는 사실이다.
"이 부분이 크게 문제가 있는가?" 라고 생각한다면 충분히 그럴 수 있다고 생각한다.
하지만 이러한 입장에서 바라보자.
우리가 설계를 하는데 있어 대부분의 관계는 단방향 매핑으로 표현이 가능하다.
JPA에서의 설계에서는 양방향 매핑을 지양하고 단방향 매핑을 지향하라고 강조하곤 한다.
그 이유는 다양하다.
- 관리해야할 포인트가 늘어난다.
- 이전에 언급했던 것처럼 단방향 매핑으로도 충분히 표현이 가능하다.
- 롬복의 @ToString, @EqualsAndHashCode 등을 사용하는 경우 서로의 무한 참조로 인한 스택오버플로우 발생 가능성이 존재한다.
위에서 몇몇 언급했던 것처럼 단방향 매핑으로 사용이 가능할 경우 양방향 매핑을 전혀 사용할 필요가 없다.
하지만 우리가 Cascade를 사용할 경우 우리가 단지 N:1, 즉 @ManyToOne 어노테이션의 표현으로만 충분히 매핑이 가능한데도 불구하고 @OneToMany 어노테이션을 활용하고 불필요한 양방향 매핑을 사용하게 된다.
좀 더 분명하게 이해하기 위해 두개의 예제를 비교해보자.
먼저, 계층형 댓글에 대해서 살펴보자.
계층형 댓글이란, 다음 그림과 같다.
댓글 아래에 대댓글을 달고 이러한 식으로 꼬리를 무는 형식을 지칭하는데 이러한 부분은 1:N의 관계로 표현이 가능하다.
이러한 부분에 있어서는 양방향 매핑이 필요하다.
예를 들어, 삭제를 할 경우 아래에 댓글이 존재한다면 삭제하지 않고 존재하지 않을 경우에만 삭제가 진행되도록 해야한다.
이를 위해 자식 댓글의 참조가 필수적이기에 조회 빈도가 빈번해 이를 양방향 매핑으로 해도 무방하다는 생각이 든다.
하지만 그에 비해 회원과 게시글의 예를 살펴보자.
회원과 게시글은 1:N의 관계로 표현이 가능하다.
우리가 회원을 조회할 때 어떤 회원이 작성한 게시글들에 대해 항상 끌어와야할까?
전혀 그렇지 않다. 우리가 정말 원할때, 자신이 작성한 게시글들에 대한 조회를 위해 해당 탭에 들어갈 경우에만 필요할 것이다.
이러한 부분에 대해서는 굳이 객체에 저장할 필요가 없다는 생각이 든다.
위의 예제가 누구에게나 와닿는다는 느낌은 아니지만 대략적으로 양방향 매핑이 굳이 필요하지 않은 경우도 빈번하다는 사실을 강조하고 싶다.
그리고 두 번째는 이전 포스트에서 언급했던 부분이다.
우리가 Cascade 옵션으로 Persist와 Remove를 동시에 걸어준 경우, 우리가 자식 엔티티만 제거하려고 할 때, Persist 옵션으로 인해 삭제와 저장의 충돌로 인해 삭제 쿼리가 발생하지 못하는 부분이 존재한다.
이전에 들었던 예시와 다른 또 다른 예시를 들어보려고 한다.
위에서 언급했던 것처럼, 게시글(StudyArticle)안에는 댓글(StudyComment)가 존재하고 댓글 안에는 대댓글(StudyComment)가 존재한다.
이 세 엔티티는 게시글이라는 큰 생명주기안에 댓글과 대댓글이 포함되니 Cascade를 걸어주자.
public class StudyArticle extends EntityDate {
...
@OneToMany(mappedBy = "studyArticle", cascade = {CascadeType.PERSIST, CascadeType.REMOVE}, orphanRemoval = true)
private List<StudyComment> studyComments = new ArrayList<>();
...
}
public class StudyComment extends EntityDate {
...
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "article_id")
private StudyArticle studyArticle;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "parent_id")
private StudyComment parent;
@OneToMany(mappedBy = "parent", cascade = {CascadeType.PERSIST, CascadeType.REMOVE}, orphanRemoval = true)
private List<StudyComment> children = new ArrayList<>();
...
}
위에서 볼 수 있는 것처럼 게시글 -> 댓글 -> 대댓글의 형태로 Cascade가 걸려있다.
이 때, 우리는 대댓글만 삭제하려고 하여 단순 delete를 대댓글에만 적용해주면 될까?
그렇지 않다, 우리는 대댓글을 제거해주기 위에 위에 걸려있는 체인을 모두 끊어줘야한다.
이렇게 된다면 우리는 나머지 두개의 제거로직을 더 추가해야하므로 코드의 복잡성을 증진시키고 클린한 코드로 부터 멀어질 것이다.
위에서 간단하게 살펴본 것만으로도 두개의 문제점이 존재한다는 것을 여실히 보여준다.
그렇다면 이를 어떻게 해결할 수 있을까?
간단하다. 불필요한 연관관계 매핑을 제거해주고 연관관계를 제거해줄 수 있는 기능을 찾자.
바로 @Delete이다.
이는 Cascade와 살짝 다른 면이 존재한다.
Cascade는 JPA에 의해 처리되어 JPA에 의해 외래 키를 찾아가며 참조하는 레코드를 제거한다.
그에 반해, @Delete는 DB에 의해 직접 처리된다.
우리가 제거하기 위해서는 @OnDelete(action = OnDeleteAction.CASCADE)을 달아주면 된다.
그렇다면 Cascade와 같은 효과를 누릴 수 있다.
@Delete는 DB에서 처리해주기에 단일한 쿼리를 통해 연쇄적으로 제거할 수 있지만 Cascade의 경우에는 여러개의 쿼리를 날린다.
그렇기 때문에 @Delete가 효과적으로 보일 순 있으나 on delete cascade에 의해 어떠한 레코드의 참조 레코드까지 연쇄적으로 삭제해버릴 수 있는 여지가 존재한다는 단점이 존재한다.
하지만 위의 프로젝트에서는 단순히 필요하지 않은 연관관계 매핑을 제거해줄 수 있으면서 우리의 요구사항을 충족시킬 수 있다는 점(연쇄적 삭제)과 코드의 복잡성을 줄일 수 있다는 점 등 여러가지의 부분에서 장점이 있기에 @OnDelete(action = OnDeleteAction.CASCADE)를 채택하기로 하였다.
그래서 굳이 빈번하게 사용하지 않는 게시글에서 댓글의 Cascade의 경우 제거하고 댓글의 @ManyToOne의 어노테이션에
@OnDelete(action = OnDeleteAction.CASCADE)를 추가하였다.
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "article_id")
@OnDelete(action = OnDeleteAction.CASCADE)
private StudyArticle studyArticle;
이제 변경 후 코드가 잘 돌아가기 위해서 테스트를 돌려보자.
이렇게 코드를 변경한 후 테스트를 사용하면서 기능을 보장하는지 확인하는 것을 통해 테스트의 중요성을 더더욱 느껴간다.
@Test
@DisplayName("게시글이 삭제되면 해당 댓글들 모두 삭제된다.")
public void onDeleteTest() throws Exception {
//given
StudyArticle announceArticle = testDB.findAnnounceArticle();
//when
StudyArticleDeleteResponseDto ActualResult = studyArticleService.deleteArticle(announceArticle.getId());
persistContextClear();
//then
Assertions.assertEquals(0, studyCommentRepository.findAll().size());
}
private void persistContextClear() {
em.flush();
em.clear();
}
그 안에 있던 댓글 두개가 모두 사라지고 0이되는 것을 확인할 수 있다.
이번에 Cascade를 마냥 사용하는 것은 좋다고 할 수 없다는 것을 알아보았다.
그렇다고 @Delete 또한 무분별하게 사용하는 것을 추구할 수는 없다.