JPA에서 양쪽에 cascade를 걸면 발생하는 문제

손현경 (보름)·2023년 8월 25일

keeper-homepage

목록 보기
4/5

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

다대다 테이블 설계, 자식 엔티티의 생명주기 관리

저희 프로젝트에서는 다대다 관계를 풀어내기 위해서, study_has_member와 같은 중간 테이블이 존재합니다.
현재 존재하는 중간 테이블은 아래와 같습니다.

  • study_has_member (스터디원)
  • member_has_comment_like, member_has_comment_dislike (댓글 좋아요, 싫어요)
  • member_has_posting_like, member_has_posting_dislike (게시글 좋아요, 싫어요)
  • friend (팔로이, 팔로워)
  • ctf_challenge_has_ctf_challenge_category (CTF 문제 카테고리 정보)
  • ctf_team_has_member (CTF 팀원)

등등 꽤 많이 존재하고 있습니다. (더 있습니다.)

Member & Study 엔티티

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));
  }
  • Member가 StudyHasMember를 cascade = ALL로 생명주기 전체를 관리하게 설계하였습니다.
  • join 메서드를 작성하여, StudyHasMember의 삽입을 직관적이고 간결하게 할 수 있게 되었습니다.
  • leave 메서드에서는, orphanRemoval을 사용하여 삭제를 직관적이고 간결하게 할 수 있게 되었습니다.
  • 서비스 코드에서는 스터디원 추가, 삭제를 할 때 join과 leave 메서드를 호출하기만 하면 되어 코드가 매우 간결해집니다.
    @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<>();
  • Study가 삭제될 시, 그에 해당하는 StudyHasMember를 함께 삭제하려고 cascade = REMOVE를 걸어두었습니다.

cascade 설정을 양쪽 부모에게 걸어놓으면 발생하는 문제

처음에 cascade 때문에 너무 헷갈려서 고생을 많이 했었는데...

https://www.inflearn.com/questions/32758/cascade-%EC%A7%88%EB%AC%B8-%EC%9E%88%EC%8A%B5%EB%8B%88%EB%8B%A4

Cascade옵션을 사용할 경우 고려해야 할 점을 참고해보면 '통상적으로 권장하는 cascade 범위는, 완전히 개인 소유하는 엔티티일 때, 예를 들어서 게시판과 첨부파일이 있을 때 첨부파일은 게시판 엔티티만 참조하므로, 개인 소유하는 경우에는 사용 가능'이라고 합니다.

  1. 완전 개인 소유인 경우에 사용할 수 있다.
  2. DDD의 Aggregate Root와 어울린다.
  3. 애매하면 사용하지 않는다.

명확한 기준이 없으면, 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 생성 (id = 43)
  • StudyHasMember 생성
    • (member_id = 2, study_id = 43) / (member_id = 3, study_id = 43) 두 개
  • id 3번 Member로 api test

모두 영속에 들어오니, 정말로 오류 없이 삭제가 안됩니다...!

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

그런데 또 영속성에 들어오지 않은 (member_id = 2, study_id = 43) 값을 가진 StudyHasMember는 삭제가 되었습니다. 매우 신기한 일이 발생해버린 것...


이렇게 실험을 해보니, cascade를 양쪽에 걸어두고 사용하는 건 많이 위험한 것 같습니다. (물론 스터디 삭제를 하는 상황에서 StudyHasMember를 조회하는 상황이 올까...?라는 생각이 들긴 합니다. 이런 상황은 거의 없을 것 같습니다.)

🤔 결론

그래서 사용하는게 좋을까요, 사용을 지양하는 게 좋을까요...? 위 같은 문제가 있다는 걸 인지했으면 조심해서 사용하면 될까요? 🤔

다음 글 (cascadeType.REMOVE와 orphanRemoval 사용 시 성능 문제)까지 풀어내고 나서 결론을 내리도록 하겠습니다.

profile
빛나는 개발자가 되는 그날까지...

0개의 댓글