본문 바로가기

개발일지

Cascade의 오해

Cascade의 오해

나는 현재 스터디 커뮤니티에 대한 프로젝트를 진행 중에 있다.

위 주제에 대해 살펴보기 전에, 간단하게 요구사항에 대한 부분을 살펴보자.

  • 회원은 스터디에 가입, 생성, 수정, 삭제할 수 있다.
  • 회원은 여러 스터디에 참여할 수 있다.
  • 회원은 스터디에 가입하기 위해 신청을 진행한 후, 스터디 생성자 혹은 관리자가 수락한다면 가입할 수 있다.
  • 스터디 생성자 혹은 관리자는 스터디 신청자를 거절할 수 있다.
  • 스터디는 각 스터디 게시판, 게시글에 대한 조회, 생성, 수정, 삭제가 가능하다.

이 요구사항에 맞게 도출된 ERD를 살펴보면 다음 그림과 같다.

물론, 이 요구사항 이상의 사항들을 ERD에서 포함하고 있지만 이 주제에 대해 다루기에는 충분하기에 생략하겠다.

나는 이러한 부분에 대한 연관관계를 JPA를 통해 구성하였다.

이를 구성하는데 있어 스터디에 대한 "생명주기"에 대해 생각해보았다.

한 스터디 안에 여러 개의 게시판들(디폴트 게시판, 커스텀 게시판)과 여러 개의 게시글들 및 댓글들이 존재할 수 있다.

이들은 스터디라는 생명주기를 공유하고 있다는 사실을 누구나 쉽게 생각할 수 있다.

예를 들자면, 스터디 내부에 존재하는 게시판, 게시글, 댓글들은 당연히 지워질 것이다.

이러한 경우에 JPA에서의 연관 관계에서 Cascade 옵션을 통해 이러한 생명주기를 관리하기 쉽게 도와준다.

Cascade에 대해서 좀 더 자세하게 알아보자.

Cascade는 Entity의 상태 변화를 전파시키는 옵션이다.

만약 Entity의 상태 변화가 존재한다면 연관되어 있는 Entity에도 상태 변화를 전이시키는 옵션인 것이다.

예를 들자면, 연관 관계 속에서 부모 Entity가 영속화 된다면 자식 Entity도 같이 영속화되고 부모 Entity가 삭제될 때 자식 Entity도 같이 삭제된다.

이러한 Cascade의 Type에는 다양한 Type들이 존재한다.

  • CascadeType.ALL: 모든 Cascade를 적용
  • CascadeType.PERSIST: 엔티티를 영속화할 때, 연관된 엔티티도 함께 영속화
  • CascadeType.MERGE: 엔티티 상태를 병합(Merge)할 때, 연관된 엔티티도 모두 병합
  • CascadeType.REMOVE: 엔티티를 제거할 때, 연관된 엔티티도 모두 제거
  • CascadeType.DETACH: 부모 엔티티를 detach() 수행하면, 연관 엔티티도 detach()상태가 되어 변경 사항 반영 X
  • CascadeType.REFRESH: 상위 엔티티를 새로고침(Refresh)할 때, 연관된 엔티티도 모두 새로고침

위에서 설명한 예처럼 부모 Entity가 영속화될 때, 자식 Entity도 같이 영속화하고 싶다면 Cascade 옵션으로 CascadeType.PERSIST를 걸어준다.

다르게 부모 Entity가 삭제될 때, 자식 Entity도 같이 제거하고 싶다면 Cascade 옵션으로 CascadeType.REMOVE를 걸어주면 된다.

이러한 부분이 무슨 이점이 있느냐?

편의성에서 큰 장점이 존재한다.

간단하게 영속화의 예제를 살펴보자.

부모라는 A Entity와 자식이라는 B Entity가 존재한다고 생각해보자.

그렇다면 영속화를 진행할 때 어떻게 진행해야할까?

...

@Autowired EntityManager em;

public void persist_fail() {
    A aInstance = new A();
    B bInstance = new B();
    aInstance.add(B);

    em.persist(aInstance) // 실패
}

public void persist_success() {
    A aInstance = new A();
    B bInstance = new B();
    em.persist(bInstance);
    aInstance.add(B);

    em.persist(aInstance) // 성공
}

다음과 같이 자식 Entity인 B Entity를 "꼭" 영속화 시켜야한다.

흔히 이러한 부분을 흔히 간과하게 된다면 Exception을 마주하게 될 것이다.

하지만 위에서 설명한 Cascade를 적용하게 될 경우 위에서 fail한 코드에 대해 성공을 도출해낼 것이다.

바로 엔티티를 영속화할 때, 연관된 Entity를 함께 영속화해주기 때문이다.

우리는 Cascade에 대해서 들을 때면 항상 orpahnRemoval에 대해서도 듣곤한다.

흔히 Cascade와도 헷갈릴 수 있는 개념이다.

Cascade의 REMOVE의 경우에는 부모가 "제거된다면" 자식도 "제거한다"라는 개념이다.

orpahnRemoval의 경우에는 부무와 "연관관계가 끊어진다면" 자식을 "제거한다"라는 개념이다.

즉, 고아 객체가 된다면 제거된다는 뜻이다.

그래서 orpahnRemoval이 true인 경우 자식 컬렉션에서 remove한다면 제거되고 부모를 제거하면 개념적으로 고아 객체가 된다는 것이기에 제거된다.

그래서 우리는 이러한 두 부분을 모두 활용한다면 다음과 같은 결론을 지을 수 있다.

  • 두 옵션을 모두 활성화 하면 부모 엔티티를 통해서 자식의 생명 주기를 관리할 수 있음
  • 도메인 주도 설계의 Aggregate Root개념을 구현할 때 유용하다.

서론이 길었지만 이제 진짜 마주할 수 있는 문제에 대해 살펴보자.

나는 이러한 Cascade 옵션을 지정한 하나의 엔티티를 살펴보고자 한다.

바로 StudyJoin이다.

@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString
public class Study extends EntityDate {

    ...

    @OneToMany(mappedBy = "study", cascade = {CascadeType.PERSIST, CascadeType.REMOVE})
    private List<StudyJoin> studyJoins = new ArrayList<>();

    ...
}

JPA에서 N:M 매핑인 @ManyToMany를 사용하면 좋지 않다. (그 이유는 다음 포스트에서 확인가능하다.)

그래서 나는 스터디와 회원에 대한 참가에 대한 N:M 매핑을 중간 테이블을 자체적으로 생성해 이 관계를 1:N 매핑으로 풀어냈다.

이러한 부분에 대해 Study는 StudyJoin에 대해 1:N의 관계를 가지게 되었고 스터디가 사라진다면 당연히 스터디에 참가한 부분이 삭제되어야 하기에 Cascade로 PERSIST와 REMOVE를 걸어주었다.(PERSIST의 경우 영속화할 경우 같이 영속화 하기에 이를 설정해주었다.)

나는 위의 요구사항에서 다음과 같은 사항을 언급했다.

  • 스터디 생성자 혹은 관리자는 스터디 참가 여부에 대한 거절을 진행할 수 있다.

StudyJoin은 실제로 다음과 같이 구성되어있다.

@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class StudyJoin extends EntityDate {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "studyjoin_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "study_id")
    private Study study;

    @Enumerated(EnumType.STRING)
    private StudyRole studyRole;

    public StudyJoin(Long id) {
        this.id = id;
    }

    @Builder
    public StudyJoin(Member member, Study study, StudyRole studyRole) {
        this.member = member;
        this.study = study;
        this.studyRole = studyRole;
    }

    public void setStudy(Study study) {
        this.study = study;
    }

    public void acceptMember() {
        this.studyRole = StudyRole.MEMBER;
    }
}

StudyRole에 주목하자.

StudyRole에는 참가 신청 상태, 회원, 관리자, 생성자라는 등급으로 이루어져 있다.

우리는 참가 신청 상태인 회원에 대해 거절을 진행할 수 있기에 이에 대한 로직을 서비스 레이어에 다음과 같이 구현하였다.

@Override
@Transactional
public StudyJoinResponseDto rejectJoin(StudyJoinRequestDto requestDto) {
    validateJoinCondition(requestDto);
    StudyJoin studyJoin = studyJoinRepository.findApplyStudy(requestDto.getStudyId(), requestDto.getMemberId())
        .orElseThrow(StudyJoinNotFoundException::new);
    studyJoinRepository.delete(studyJoin);
    return StudyJoinResponseDto.create(studyJoin);
}

private void validateJoinCondition(StudyJoinRequestDto requestDto) {
    Study study = studyRepository.findById(requestDto.getStudyId()).orElseThrow(StudyNotFoundException::new);
    if (studyJoinRepository.exist(requestDto.getStudyId(), requestDto.getMemberId()))
        throw new AlreadyJoinStudyMember();
    if (study.getHeadCount() <= studyJoinRepository.findStudyJoinCount(requestDto.getStudyId()))
        throw new ExceedMaximumStudyMember();
}

실제로 참여 조건을 확인하고 이에 대한 StudyJoin을 찾아 삭제를 진행했다.

그리고 테스트를 진행해보자.

@Test
@DisplayName("스터디 참가 신청을 거절한다.")
public void rejectJoin() {
    //given
    Member member = testDB.findStudyApplyMember();
    Study study = testDB.findBackEndStudy();
    StudyJoinRequestDto requestDto = StudyJoinFactory.makeRequestDto(study, member);

    //when
    StudyJoinResponseDto ActualResult = studyJoinService.rejectJoin(requestDto);

    //then
    Assertions.assertEquals(Optional.empty(), studyJoinRepository.findApplyStudy(study.getId(), member.getId()));
    Assertions.assertEquals(member.getId(), ActualResult.getMemberId());
}

실제 스터디에 신청한 멤버와 가입한 스터디에 대해 초기화해주고 요청을 생성한 뒤 실제 거절 로직을 실행해보면 다음과 같다.

분명 지워져야 했을 부분이 남아 empty를 출력하고 있지 않다.

그리고 쿼리를 보면 delete 쿼리가 발생하고 있지 않다.

이 부분에 대한 부분에 대한 이유는 다음과 같다.

실제 우리는 Study에 대한 부분을 Cascade로 걸어주었다.

그리고 위 서비스에서

studyJoinRepository.delete(studyJoin);

다음과 같이 지워주고 있다.

여기에 주목하자.

delete를 진행하긴 하지만 study->studyJoin으로의 Cascade 연관관계가 남아있어 충돌이 발생하는 것이다.

studyJoin에서는 delete를 통해 지우려고하고 study에서는 Cascade 관계인데 연관관계에 객체가 남아있으니 저장하려고 하는 것이다.

그래서 결국 JPA에서는 삭제하지 않고 이를 남겨두는 것이다.

그래서 이를 해결하기 위해서는 delete 위에 다음과 같이 연관관계 자식 컬렉션에 이를 지워주면 된다.

studyJoin.getStudy().getStudyJoins().remove(studyJoin); // cascade
studyJoinRepository.delete(studyJoin);

그리고 테스트를 진행해보자.

delete 쿼리를 잘 날리며 테스트가 통과하는 부분을 보여준다.

다음과 같이 Cascade와 orpahnRemoval은 우리에게 편의성을 제공하지만 신경써야할 부분이 많다는 것을 여실히 보여주는 예제이다.

그러니 이러한 부분에 대해서 진행할 경우에는 신경써서 개발해야 할 것이다.

'개발일지' 카테고리의 다른 글

@Embeddable 활용기  (0) 2021.12.31
Page, Slice에 대해  (0) 2021.12.31
Cascade vs @Delete  (1) 2021.12.28
알림 기능을 구현해보자 - SSE(Server-Sent-Events)!  (8) 2021.12.24
검증의 책임에 대해  (0) 2021.12.15