JPA 엔티티를 삭제할 때 영속성과 연관 관계가 중요한 이유

@괜찮을지도 · October 13, 2023 · 9 min read

이 글은 우테코 괜찮을지도의 도이가 작성하였습니다.

삭제 기능에 대한 리팩터링 중, 회원 차단에 대한 기존 테스트가 실패해 이를 해결해야 했는데요.
JPA에 대한 지식이 부족한 상태에서 삽질을 하며 알게 된 것들을 기록하고자 합니다.

문제 상황 1 : 쿼리의 발생 시점 찾기

도메인: 회원 차단 기능

도메인에 대해 먼저 설명드리겠습니다.
관리자 API에서 회원을 차단하면, 차단한 회원의 지도 Topic, 핀 Pin, 핀 이미지 PinImage를 삭제 상태(soft delete)로 변경합니다.
그리고 매핑 테이블 역할을 하는 엔티티인 Bookmark 즐겨찾기, Atlas 모아보기, Permission 권한은 실제로 삭제(hard delete)합니다.

    public void blockMember(Long memberId) {
        Member member = findMemberById(memberId);
        member.updateStatus(Status.BLOCKED);

        deleteAllRelated(member);
    }

    private void deleteAllRelated(Member member) {
        List<Long> pinIds = extractPinIdsByMember(member);
        Long memberId = member.getId();

        permissionRepository.deleteAllByMemberId(memberId);
        atlasRepository.deleteAllByMemberId(memberId);
        bookmarkRepository.deleteAllByMemberId(memberId);
        pinImageRepository.deleteAllByPinIds(pinIds);
        pinRepository.deleteAllByMemberId(memberId);
        topicRepository.deleteAllByMemberId(memberId);
    }

아래와 같이 동작하기를 기대했습니다.

  1. 차단할 회원에 대한 정보 조회
  2. 회원의 상태를 차단으로 변경하는 update 쿼리 발생
  3. 매핑 테이블 엔티티들을 먼저 삭제해야 함, 이 때 delete 쿼리 발생
  4. 주요 도메인 엔티티들을 삭제 상태로 변경하는 update 쿼리 발생

예상과 다른 영속 동작

테스트 코드는 아래와 같습니다.

    @DisplayName("Member를 차단(탈퇴시킬)할 경우, Member가 생성한 지도, 핀, 핀 이미지를 삭제 상태(soft delete)로 변경한다.")
    @Test
    void blockMember_Success() {
        //given
        // ...    
        // 객체 생성, 저장 및 기존 상태 검증 코드 생략
        // ...
            
        //when
        adminCommandService.blockMember(member.getId());

        //then
        Member blockedMember = memberRepository.findById(member.getId()).get();

        assertAll(
            () -> assertThat(blockedMember.getStatus()).isEqualTo(Status.BLOCKED), // 실패
            () -> assertThat(bookmarkRepository.existsByMemberIdAndTopicId(member.getId(), topic.getId()))
            .isFalse(), // 실패
            () -> assertThat(atlasRepository.existsByMemberIdAndTopicId(member.getId(), topic.getId())).isFalse(), // 실패
            () -> assertThat(permissionRepository.existsByTopicIdAndMemberId(topic.getId(), member.getId()))
            .isFalse() // 실패
            () -> assertThat(topicRepository.existsById(topic.getId())).isFalse(), 
            () -> assertThat(pinRepository.existsById(pin.getId())).isFalse(),
            () -> assertThat(pinImageRepository.existsById(pinImage.getId())).isFalse(),
        );
    }

하지만 테스트가 실패해 로그를 보니, 실제 동작은 아래와 같았습니다. 😧😧

  1. 차단할 회원에 대한 정보 조회
  2. 회원의 상태를 차단으로 변경하는 update 쿼리 발생
  3. 매핑 테이블 엔티티들을 먼저 삭제해야 함, 이 때 delete 쿼리 발생
  4. 주요 도메인 엔티티들을 삭제 상태로 변경하는 update 쿼리 발생

// when 절의 코드를 호출한 뒤 entityManager로 flush, clear를 해주어도 마찬가지였습니다.

원인

이전에는 잘 통과하던 테스트인데, 왜 갑자기 예상과 다르게 작동할까요?
JPA 영속성 컨텍스트의 쓰기 지연 때문이었습니다.

  1. hard delete 메서드를 통해 해당 id를 가진 엔티티를 영속성 컨텍스트에서 제거한다.
  2. soft delete 메서드를 통해 update를 호출하면서, 연관된 엔티티들이 모두 영속화된다.
    ➡️ (1)에서 delete 해도, 2에서 조회할 때 함께 불러와지는 member, permission, atlas, bookmark까지 다시 영속화된다.
  3. 영속성 컨텍스트에는 (차단 상태가 아닌)member, permission, atlas, bookmark가 존재한다.
  4. blockMember() 호출 후 flush를 할 때, 영속성 컨텍스트의 상태를 기준으로 쿼리가 발생한다.
    ➡️ member의 차단 상태에 대한 변경 감지도 되지 않고, delete 쿼리도 나가지 않는다.

그래서 blockMember() 메서드를 호출한 뒤 flush를 해줘도 소용이 없었던 것입니다.

해결

단순히 해결부터 해보자면,
soft delete 메서드로 인한 영속화가 되기 이전에 flush해서 member의 변경, hard delete에 대한 쿼리를 발생시키면 됩니다.

flush로 쿼리 발생 시점 조정하기

아래 처럼 서비스 단에서 해주거나, @Modifying 어노테이션을 사용할 수 있습니다.

    private void deleteAllRelated(Member member) {
        permissionRepository.deleteAllByMemberId(memberId);
        atlasRepository.deleteAllByMemberId(memberId);
        bookmarkRepository.deleteAllByMemberId(memberId);
        // flush!
        bookmarkRepository.flush();
        pinImageRepository.deleteAllByPinIds(pinIds);
        pinRepository.deleteAllByMemberId(memberId);
        topicRepository.deleteAllByMemberId(memberId);
    }

문제 상황 2 : 테스트에서 발생하지 않는 일부 쿼리

그런데도 테스트는 성공하지 않았습니다. 🥲
delete bookmark~ 쿼리는 여전히 발생하지 않고 있었습니다.

테스트 메서드와 blockMember()는 같은 트랜잭션으로 묶이기 때문에 // when절에서의 영속화 상태 때문일 것이라 짐작했습니다.
bookmark를 참조하는 topic, 또는 member 객체가 영속화되어있기 때문에 delete 쿼리가 나가지 않는 것이라 생각했습니다.

그래서 이에 대해서는 blockMember() 호출 전에 testEntityManager.clear()로 영속성 컨텍스트를 초기화해두는 것으로 해결했습니다.

하지만 정확한 원인이 궁금했습니다. 왜 atlas, permission은 삭제되고 bookmark만 삭제되지 않았을까요?

원인

다른 매핑 테이블 엔티티들과 bookmark의 차이점을 살펴보았습니다.
해당 엔티티만이 topic과의 연관 관계에서 CasecadeType.PERSIST가 걸려 있었습니다.

    // Topic.class
    @OneToMany(mappedBy = "topic", cascade = CascadeType.PERSIST, orphanRemoval = true)
    private List<Bookmark> bookmarks = new ArrayList<>();

테스트 메서드의 트랜잭션 내에서, 영속성 컨텍스트에 존재하는 topicbookmark가 살아있기 때문에
PERSIST OPERATION이 발생하고 bookmark에 대한 delete 쿼리는 무시됩니다.

JPA 2.2 specification 문서 3.2 장 Entity Instance's Life Cycle에 따르면,
flush가 발생할 때 CascadeType.PERSISTCascadeType.ALL이 있을 경우 자식에 연쇄적으로 PERSIST OPERATION이 발생합니다. PERSIST OPERATION은 연관 관계 매핑된 list의 엔티티에 대해 모두 이루어지며, 기존에 없던 값이면 새로 저장합니다.

해결

이에 대해서 엔티티에 걸어놓은 조건에 따라 정상적으로 동작하도록 하려면,
bookmarkRepository를 통해 delete 쿼리를 호출하는 대신 연관 관계를 제거하는 방식으로 삭제해주었어야 했던 것입니다.

    // Topic.class
    public void removeBookmarkBy(Member member) {
        bookmarks.stream()
        .filter(bookmark -> bookmark.getMember().isSame(member))
        .findFirst()
        .ifPresent(this::removeBookmark);
        bookmarkCount--;
    }

    // AdminCommandService.class
    private void deleteAllRelatedMember(Member member) {
        List<Long> pinIds = extractPinIdsByMember(member);
        Long memberId = member.getId();

        permissionRepository.deleteAllByMemberId(memberId);
        atlasRepository.deleteAllByMemberId(memberId);
        // 변경된 부분
        topicRepository.findTopicsByBookmarksMemberId(memberId)
            .forEach(topic -> topic.removeBookmarkBy(member));
        atlasRepository.flush();
        pinImageRepository.deleteAllByPinIds(pinIds);
        pinRepository.deleteAllByMemberId(memberId);
        topicRepository.deleteAllByMemberId(memberId);
    }

아래와 같이 bookmark에 대한 삭제 로직을 수정하니,
테스트 코드에서 별도의 clear를 호출하지 않아도 잘 통과하는 것을 확인할 수 있었습니다.

결론

이번 삽질을 계기로 JPA를 잘 학습한 뒤 사용해야 한다는 교훈을 다시 한 번 몸소 느꼈습니다..

엔티티의 생명 주기에 대해 잘 이해하고 객체를 생성 및 삭제해야한다는 것, 삭제할 때에도 연관 관계의 관리가 중요하다는 것을 알았습니다. 이처럼 예상하지 못한 동작을 피하기 위해 삭제 로직에서도 연관 관계 편의 메서드를 정의하는 방식으로 코드를 잘 작성할 필요가 있어 보입니다.

참고 자료

@괜찮을지도
안녕하세요! 괜찮을지도 기술블로그입니다.