이번에는 홈페이지 개발 프로젝트를 하면서 마주쳤었던 cascade를 양쪽 부모에 걸면 발생했던 문제를 다뤄보려고 합니다.

저희 프로젝트에서는 다대다 관계를 풀어내기 위해서, study_has_member와 같은 중간 테이블이 존재합니다.
현재 존재하는 중간 테이블은 아래와 같습니다.
등등 꽤 많이 존재하고 있습니다. (더 있습니다.)
Member Entity
@Getter
@Entity
@EqualsAndHashCode(of = {"id"})
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "member")
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", nullable = false, updatable = false)
private Long id;
@OneToMany(mappedBy = "member", cascade = ALL, orphanRemoval = true)
private final Set<StudyHasMember> studyMembers = new HashSet<>();
public void join(Study study) {
StudyHasMember studyMember = StudyHasMember.builder()
.study(study)
.member(this)
.build();
studyMembers.add(studyMember);
}
public void leave(Study study) {
studyMembers.removeIf(studyMember -> studyMember.getStudy().equals(study));
}
@Transactional
public void joinStudy(long studyId, long memberId) {
Study study = studyFindService.findById(studyId);
Member member = memberFindService.findById(memberId);
member.join(study);
}Study Entity
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "study")
public class Study extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", nullable = false, updatable = false)
private Long id;
@OneToMany(mappedBy = "study", cascade = REMOVE)
private final Set<StudyHasMember> studyMembers = new HashSet<>();
처음에 cascade 때문에 너무 헷갈려서 고생을 많이 했었는데...
Cascade옵션을 사용할 경우 고려해야 할 점을 참고해보면 '통상적으로 권장하는 cascade 범위는, 완전히 개인 소유하는 엔티티일 때, 예를 들어서 게시판과 첨부파일이 있을 때 첨부파일은 게시판 엔티티만 참조하므로, 개인 소유하는 경우에는 사용 가능'이라고 합니다.
- 완전 개인 소유인 경우에 사용할 수 있다.
- DDD의 Aggregate Root와 어울린다.
- 애매하면 사용하지 않는다.
명확한 기준이 없으면, cascade는 사용하지 않는 것이 더 나은 선택입니다. 물론 cascade 옵션을 사용해도, 한곳에서 사용해야 합니다. (양쪽에 두는게 가능해도 한쪽에 두는게 더 나은 선택입니다.)
위 답변에서도 볼 수 있고, 다른 글에서도 cascade 속성은 한쪽에만 걸어놓는게 좋다고 합니다. (한쪽에서만 생명주기 관리를 하도록)
그렇다면 위 처럼 양쪽에 cascade 속성을 걸어놓으면 어떻게 될까요? 왜 생명주기를 한쪽에서만 관리하는 게 좋을까요?
테스트 코드로 확인해보겠습니다.
@Test
@DisplayName("멤버를 삭제하면 스터디원도 모두 삭제된다.")
public void 멤버를_삭제하면_스터디원도_모두_삭제된다() throws Exception {
//given
Member member = memberTestHelper.generate();
Study study = studyTestHelper.generate();
member.join(study);
em.flush();
em.clear();
//when
member = memberRepository.findById(member.getId()).orElseThrow();
memberRepository.delete(member);
em.flush();
em.clear();
//then
assertThat(studyHasMemberRepository.findByStudyAndMember(study, member)).isEmpty();
}

멤버를 삭제했을 경우 그에 해당하는 StudyHasMember가 삭제가 잘 됩니다.
다음은 스터디를 삭제한 경우 테스트 코드 입니다.
@Test
@DisplayName("스터디를 삭제하면 스터디원도 모두 삭제된다.")
public void 스터디를_삭제하면_스터디원도_모두_삭제된다() throws Exception {
//given
Member member = memberTestHelper.generate();
Study study = studyTestHelper.generate();
member.join(study);
em.flush();
em.clear();
//when
study = studyRepository.findById(study.getId()).orElseThrow();
studyRepository.delete(study);
em.flush();
em.clear();
//then
assertThat(studyHasMemberRepository.findByStudyAndMember(study, member)).isEmpty();
}

마찬가지로, 삭제가 성공적으로 잘 됩니다.
@Transactional
public void delete(Member member, long studyId) {
Study study = studyFindService.findById(studyId);
if (!member.isHeadMember(study)) {
throw new BusinessException(study.getId(), "study", STUDY_INACCESSIBLE);
}
studyRepository.delete(study);
}
스터디 삭제 서비스 코드입니다. 스터디를 삭제하면, cascade.REMOVE가 적용되어 StudyHasMember가 정상적으로 삭제되어야 합니다.

이렇게 보면 테스트가 성공하여 문제가 없는 것 같습니다. delete 쿼리도 정상적으로 나갑니다. 스터디는 삭제되었고, 해당하는 StudyHasMember 역시 삭제되었습니다.

하지만, 동일한 상황에서 StudyHasMember가 영속에 들어오면 assertThat이 실패하는데요, 쿼리문을 살펴보면 delete 쿼리가 아예 안나간 것을 확인할 수 있습니다. delete 쿼리가 나가지 않음에도, 이에 대한 오류는 나지 않습니다.


반대로 Member의 cascadeType.ALL은 정상적으로 작동합니다. delete 쿼리가 나가요.

추측컨대, 동시에 영속성에 있을 때 cascadeType.ALL 속성을 가진 부모가 cascadeType.REMOVE 속성을 가진 부모보다 생명주기 관리에 있어서 우위를 가지는 것 같습니다.
그렇다면 이 우선순위는 어떻게 결정된 걸까요...?

당시 내렸던 결론은 "CascadeType Enum의 우선순위다." 였는데 이건 확실한 지 아직도 모르겠네요... 이건 공식 레퍼런스를 찾아봐야 확실하게 알 수 있을 것 같은데 찾기 어렵습니다. 혹시 아시는 분 있으면 댓글 부탁드립니다...!
@Transactional
public void delete(Member member, long studyId) {
Study study = studyFindService.findById(studyId);
System.out.println(member.getStudyMembers().size());
if (!member.isHeadMember(study)) {
throw new BusinessException(study.getId(), "study", STUDY_INACCESSIBLE);
}
studyRepository.delete(study);
}
실제로 위 처럼 Member가 갖고 있는 StudyHasMember를 영속성 컨텍스트로 불러오고, 프로덕션 api를 로컬에서 테스트 해보았습니다.
테스트 환경)
모두 영속에 들어오니, 정말로 오류 없이 삭제가 안됩니다...!


Study도 지워지지 않았고, (member_id = 3, study_id = 43)에 해당하는 StudyHasMember의 row도 삭제되지 않습니다. (delete 쿼리 자체가 나가지 않았습니다. 오류를 뱉지도 않고...)

그런데 또 영속성에 들어오지 않은 (member_id = 2, study_id = 43) 값을 가진 StudyHasMember는 삭제가 되었습니다. 매우 신기한 일이 발생해버린 것...
이렇게 실험을 해보니, cascade를 양쪽에 걸어두고 사용하는 건 많이 위험한 것 같습니다. (물론 스터디 삭제를 하는 상황에서 StudyHasMember를 조회하는 상황이 올까...?라는 생각이 들긴 합니다. 이런 상황은 거의 없을 것 같습니다.)
그래서 사용하는게 좋을까요, 사용을 지양하는 게 좋을까요...? 위 같은 문제가 있다는 걸 인지했으면 조심해서 사용하면 될까요? 🤔
다음 글 (cascadeType.REMOVE와 orphanRemoval 사용 시 성능 문제)까지 풀어내고 나서 결론을 내리도록 하겠습니다.