새소식

DB

[DB] JPA의 orphanremoval 또는 CascadeType.REMOVE 에서 발생할 수 있는 성능 문제와 버그(?)

  • -

이번에 프로젝트를 진행하며, orphanremoval = true 로 엔티티 삭제를 구현해볼까? 라는 생각이 들었습니다.

그런데 실제로 적용해보고 쿼리가 나가는 것을 확인해보며 이런저런 문제가 있다는 것을 느끼게 되었습니다.

 

상황

게시글(Post) 엔티티는 해시태그(Hashtag) 엔티티를 자식으로 가지고 있습니다. (OneToMany)

  • 게시글(Post) : 게시글 정보를 담고 있음
  • 해시태그(Hashtag) : OneToMany, 해시태그 여러개를 담고 있음

요구사항 : 게시글을 지우면, 게시글에 있는 여러개의 해시태그도 모두 삭제해야 합니다.

 

간소화된 엔티티 코드는 다음과 같습니다.

 

Post.java

public class Post {
    @Id
    @GeneratedValue(strategy= GenerationType.IDENTITY)
    @Column(name="POST_ID")
    private Long id;

    @OneToMany(mappedBy = "post", orphanRemoval = true)
    private List<Hashtag> hashtags;
}

 

Hashtag.java

public class Hashtag {
    @Id
    @GeneratedValue(strategy= GenerationType.IDENTITY)
    @Column(name="TAG_ID")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name="POST_ID")
    private Post post;
}

현재 Post 엔티티에는 orphanRemoval = true 옵션을 추가했습니다.

 

그렇다면 실제로 Post를 삭제해보겠습니다.

 

 

성능 문제

 

다음과 같이 Post 하나와 그와 연결된 Hashtag를 3개 생성 후에 테스트를 진행합니다.

@Test
void test() {

    Post post = new Post();
    postRepository.save(post);

    List<Hashtag> hashtags = List.of(
            new Hashtag(post),
            new Hashtag(post),
            new Hashtag(post)
    );
    hashtagRepository.saveAll(hashtags);

    Long postId = post.getId();

    em.flush();
    em.clear();
    System.out.println("========================");

    System.out.println("== Step 1 ==");
    Post myPost = postRepository.findById(postId).get();
    System.out.println("== Step 2 ==");
    postRepository.delete(myPost);
    System.out.println("== Step 3 ==");
    em.flush();
    System.out.println("== Step 4 ==");
}

 

출력 결과를 볼까요?

Step 을 찍어놓은 것을 잘 참고해주시면 될 것 같습니다.

 

== Step 1 == //Post myPost = postRepository.findById(postId).get();
Hibernate: 
    select
        p1_0.post_id 
    from
        post p1_0 
    where
        p1_0.post_id=?
== Step 2 == //postRepository.delete(myPost);
Hibernate: 
    select
        h1_0.post_id,
        h1_0.tag_id 
    from
        hashtag h1_0 
    where
        h1_0.post_id=?
== Step 3 == //em.flush();
Hibernate: 
    delete 
    from
        hashtag 
    where
        tag_id=?
Hibernate: 
    delete 
    from
        hashtag 
    where
        tag_id=?
Hibernate: 
    delete 
    from
        hashtag 
    where
        tag_id=?
Hibernate: 
    delete 
    from
        post 
    where
        post_id=?
== Step 4 ==

 

정상적으로 Delete 쿼리가 나간 것을 확인하실 수 있습니다!

그러나 "이게 최선인가?" 싶은 생각이 들게 만드는 길고 긴 쿼리들입니다.

 

1. 왜 삭제 전에 hashtag 를 전부 조회하는걸까?

hashtag를 조회할 이유는 사실 없습니다. 정상적인 사고로 SQL을 직접 작성한다면, 굳이 Hashtag가 있는지 신경쓸 필요도 없이 해당 post와 연관된 hashtag를 지우는 쿼리를 날릴 겁니다. exist 여부를 체크할 필요가 없죠.

 

아무래도 orphanremoval 옵션을 통해서 지우게 되면 영속성 등록 후에 삭제 쿼리가 나가는 듯 합니다.

 

해당 쿼리를 조금이나마 줄이고 싶다면 이렇게 fetch join을 사용하는 방법이 있을 겁니다.

@Query("select p from Post p left join fetch p.shorts where p.id = :postId")
Optional<Post> findPostFetchJoin(@Param("postId") Long postId);

 

 

 

2. Delete 쿼리가 N 번 나간다.

헉. 이 문제는 조금 심각해 보입니다.

Hashtag야 그렇다 치고, 만약 좋아요 같이 N이 굉장히 많은 비즈니스 로직이면, 엄청난 부하가 걸릴 것 입니다.

hashtag 삭제는 delete from hastag where post_id=:postid 이 쿼리 하나로도 충분합니다.

@Query("delete from Hashtag h where h.post.id = :postId")
@Modifying
void deleteAllByPostId(@Param("postId") Long postId);

 

해결 방법?

해결 방법이라기보다, 차라리 orpharnremoval을 사용하지 않고, 다음과 같이 한 번에 지우는 쿼리를 작성하면 1,2 번 문제가 모두 해결되고 깔끔한 쿼리가 나가게 됩니다.

Post myPost = postRepository.findById(postId).get();
hashtagRepository.deleteAllByPostId(myPost.getId());
postRepository.delete(myPost);
@Query("delete from Hashtag h where h.post.id = :postId")
@Modifying
void deleteAllByPostId(@Param("postId") Long postId);
Hibernate: 
    select
        p1_0.post_id 
    from
        post p1_0 
    where
        p1_0.post_id=?
Hibernate: 
    delete 
    from
        hashtag 
    where
        post_id=?
Hibernate: 
    delete 
    from
        post 
    where
        post_id=?

 

깔끔하죠? post 존재 여부만 체크하고, delete 쿼리 두 방으로 모두 삭제되었습니다.

개인적으로는 오히려 이 방식이 명시적인 삭제를 하게 되어서 보기에 좋은 것 같습니다.

 

성능 문제에 대한 결론

위의 모든 과정은 CascadeType=remove 를 적용해도 똑같은 동작이 발생했습니다.

 

결론 짓자면, orpharnremoval 또는 CascadeType=remove 는 N+1 문제가 발생합니다.

영속성 등록을 위해 불필요한 조회 쿼리가 나가고, 컬럼 개수 N개 만큼 쿼리가 나가게 됩니다.
게다가 삭제라는 행위는 관계형 데이터베이스에 있어서 굉장히 큰 cost입니다.

 

그리고 좀 전에 말씀드렸던 명시적인 삭제가 좀 더 직관적이고 안정적인 코드가 될 수 있습니다.

삭제라는 행위는 서비스에 있어서 꽤 민감한 문제입니다. 생명 주기를 관리하기 위해 위와 같은 방식을 쓰기 보다, 조금 더 명확하게 삭제하는 부분을 기술한다면 좋은 코드가 될 수 있을 것 같습니다.

 

팀원과 함께 문제 공유&의논

 

 

 

번외1 - 소소한 버그가 있다.

당시 상황을 재현해 보자면 이렇다.

 

 

영속성 상태를 clear() 로 날려서, post 값만 저장되어 있는 영속성이 해제된 상태의 객체였는데,
이 객체를 그냥 바로 delete를 호출했었다.

 

이렇게 했더니 post는 정상적으로 지워졌지만, orpharnremoval 이 전혀 동작하지 않았다.

아마 Entity Manager에서, 영속성으로 관리되는 객체만 orpharnremoval 옵션이 작동하는 것 같았다.

 

현재 스프링 3.0 이후의 JPA에서는 해당 버그가 발생하지 않는다.

영속성으로 관리되지 않는 객체의 delete 쿼리를 날리면 Exception이 발생하도록 패치되었기 때문... ㅋㅋ

 

그래도 많은 사람들이 아직 스프링 2.7 버전을 사용하고 있고, 해당 버그에 대해서 검색해도 잘 나오지 않으니 조심해야 할 것 같다.

(물론 테스트가 아니고서야, 이렇게 코드를 작성할 일이 잘 없긴 하다.)

 

이 문제로 3시간은 날린 것 같다.

 

 

 

+ 추가

번외2 - 벌크 쿼리로 직접 지우면 orphanremoval 이 작동하지 않는다.

 

Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.