고딩왕 코범석

JPA @OneToMany cascade, orphanRemoval 정리 본문

Language & Framework/Spring

JPA @OneToMany cascade, orphanRemoval 정리

고딩왕 코범석 2021. 8. 2. 21:27
반응형

안녕하세요! 이번 포스팅에서는 제가 JPA를 사용하면서 엔티티에 따라 어떤 쿼리가 날라가고, 제 나름대로 생각한 최선의 방법이 무엇인지 정리해보려고 합니다. 항상 제 의견이 맞는건 아니니 틀린 지식에 대한 피드백은 환영합니다!


이번 포스팅 목차 겸 확인할 부분

  • orphanremoval=true 에서 delete 쿼리
  • orphanremoval=false 에서 변경 감지
  • Cascade=ALL 남발 주의하기

우선, 제가 확인해보고 싶은 부분 및 기능을 정의해보겠습니다.

기능

  • 회원과 게시글이 있으며, 회원은 게시글을 찜할 수 있다.
  • 회원이 게시글을 찜할 경우, 여러 게시글을 동시에 찜할 수 없다. 즉, 게시글 하나씩 찜을 하거나 삭제할 수 있다.
  • 어떤 게시글을 삭제할 때, 이 게시글을 찜한 회원들의 찜목록에서도 삭제해야 한다.

엔티티

Member (회원)

회원 엔티티에서 OneToMany 어노테이션 옵션에 무지성으로 설정한 CascadeType.ALL, orphanremoval = true를 잘 기억해주세요!


이 설정은 회원을 save할 경우에 favoriteArticleList에 엔티티가 담겨있으면 자동으로 insert 쿼리가 나가게 설정한 것입니다. 또한 favoriteArticleList에서 엔티티를 하나 지웠을 때, 변경 감지 기능에 의해 자동으로 delete 쿼리가 나갑니다.


사실 회원가입을 할 때, 게시글을 찜할 수 없지만 제가 프로젝트를 하면서 무지성으로 일대다 연관관계에 있어 OneToMany의 옵션을 CascadeType.ALL, orphanremoval=true 로 주었음을 반성하기 위해 작성했어요.


image


Article (게시글)

게시글 같은 경우는 게시글이 삭제될 때, 연관 관계가 끊어진 고아객체들을 삭제하기 위해 orphanremoval=true 만 주었습니다.


image


FavoriteArticle (회원이 찜한 게시글들)

참고로, 회원이 게시글을 찜하는 기능에 있어서 다대다 관계가 형성된다고 생각하기 때문에 ManyToMany가 아닌 OneToMany, ManyToOne 으로 연관관계를 풀었습니다.


image


초기 DB 설정

H2 데이터베이스를 사용했고, 테스트를 편하게 하기 위해 설정한 DML은 다음과 같습니다.


image


1. 제일 궁금했던 회원을 지워보기

다시 한번 짚어볼게요! cascade를 ALL로 주었고, orphanRemoval=true 로 주었기에 1번 회원을 지웠을 때, 벌어지는 일들을 살펴보겠습니다.


image


제가 작성한 삭제 로직입니다. 상당히 간단하죠? 에제이기 때문에 굳이 Service 영역을 거치지 않고 delete해보겠습니다. 1번 회원은 1,2,3,4번 게시글을 찜하고 있는 상황입니다. 이 상황에서 deleteById를 호출했을 때, 실행되는 쿼리는 총 7회입니다! 도당체 무슨일잉교..


일단 영속성 컨텍스트에서는 MemberId만 알고 있기 때문에 memberId에 해당하는 Member 엔티티를 가져와야 합니다. 그 다음, Member가 갖고 있는 FavoriteArticle을 가져와야 하는데, OneToMany가 기본적으로 fetchType 옵션이 LAZY로 설정되어 있기 때문에 FavoriteArticle 엔티티가 프록시 객체라서 쿼리가 한번 더, 총 두번 날라갔습니다.


그 다음으로 발생하는 쿼리는 Delete 쿼리입니다. orphanRemoval=true 설정에 의해 Member가 가지고 있는 FavoriteArticle을 하나씩 지웁니다. 1번 회원이 4개의 게시글을 찜하고 있기 때문에 여기서 네번의 쿼리가 발생해 총 여섯번 실행되었습니다.


마지막으로 Member 엔티티를 지우게 되는데, FavoriteArticle가 먼저 지워진 이유는 데이터베이스 외래키 제약조건에 의해 자식 데이터를 먼저 삭제하게 되고, 그 다음 부모 데이터를 삭제하게 되는 것입니다.


음... 쿼리가 좀 비효율적으로 나간다는 생각이 들었습니다. 그래서 orphanremoval 옵션을 따로 주지 않고, 직접 delete를 하기로 했습니다.


수정된 Member

image


수정 로직

컨트롤러에서 바로 deleteById를 하지 않고, service 계층에서 지워보겠습니다.


image


서비스 계층입니다. 외래키 제약조건이 있기 때문에, favoriteRepository에서 memberId에 해당하는 정보들을 먼저 지우겠습니다.


image


참고로 JpaRepository에서 데이터를 지울때는 Modifying 을 달아줘야 한다고 합니다. 그리고 1번 멤버가 찜한 목록들 이므로 delete from에서 where 절에 회원 id를 넣어주었습니다.


image


이렇게 해서 회원을 지울 경우 나가는 쿼리는 총 네번 이었습니다. 제가 예상했던 쿼리는 두번 이었는데 왜 두번이 더 나갔을까요... 바로 앞에서 쿼리가 7번 실행되었을 때 짚고 넘어가지 못한 부분을 여기서 설명드리려고 합니다.


우선 Delete From FavoriteArticle 쿼리에 의해 네번 delete 쿼리가 한번에 처리되었습니다. 하지만 멤버를 지울 경우 앞에서 벌어졌던 Member 엔티티를 조회하고, FavoriteArticle 엔티티를 조회하는 쿼리가 다시 발생했습니다. 저는 그냥 delete from member, delete from favoriteArticle 총 두번의 쿼리만 나가길 바랬는데 말이죠.


그 이유는 deleteById의 경우 findById를 실행한다는 것이었습니다. 코드를 추적해보겠습니다.


image


CrudRepository 인터페이스에서 deleteById를 구현한 코드로 가보겠습니다.


image


CrudRepository 인터페이스를 구현한 SimpleJpaRepository 에서는 delete할 때, 해당 엔티를 find하는 것을 알 수 있습니다!


image


즉, deleteById를 쓰지 않고 쿼리를 작성하여 실행하는 방식으로 개선해보겠습니다.


image


이렇게 개선 후 다시 실행해보면 쿼리 두번에 모든 데이터가 삭제되는 것을 확인할 수 있습니다.


주의사항

쿼리가 두번 나가는 상황은 Member엔티티를 select 하지 않습니다. 그 얘기는 즉, 영속성 컨텍스트 1차 캐시에 Member 및 FavoriteArticle 엔티티가 있지 않음을 의미합니다. 혹여나 1차 캐시에 있는 엔티티를 수정하고, 수정된 값을 써야되는 상황에서는 @Modifying 에서 clearAutomatically 옵션을 true로 주어야 영속성 컨텍스트의 1차캐시가 비워지고 수정된 엔티티의 정보를 알맞게 가져옵니다.


2. orphanRemoval=false에서 자식엔티티 하나를 삭제할 때 변경감지 기능이 작동할까?

맨 위에서 게시글은 하나씩 찜하거나 삭제할 수 있다고 기재했습니다.


orphanremoval 옵션을 false로 수정하고 CascadeType.ALL 에서 삭제 쿼리를 직접 실행시켜 총 두번의 쿼리가 실행되는 것으로 개선되었지만 Member 엔티티에 있는 FavoriteAritcle 리스트를 하나씩 빼거나 넣을 때는 어떤 상황이 발생할 지 실행해보겠습니다.


먼저, 컨트롤러를 다음과 같이 설정했습니다. 찜하거나 찜목록에서 제거할 멤버의 ID와 삭제,추가할 게시글의 ID가 필요합니다.


image


그 다음, service 로직입니다. List에서 add하거나 delete하는 로직입니다. findByIdFetch 메서드는 member와 List를 fetch join으로 한번에 가져오는 메서드입니다.


image


먼저 3번 회원이 2번의 article을 찜했을 경우, article select 쿼리 한번과 member select 쿼리 한번, favorite_article 테이블에 insert 하는 쿼리까지 총 3회가 실행됩니다. 이번에는 1번 회원이 찜한 1번 게시글을 삭제해보겠습니다.


image


우선 객체안에 담긴 favoriteArticleList에서는 4개의 article이 3개로 줄어든 것을 확인할 수 있지만, 실제 delete 쿼리는 발생하지 않았습니다. 즉, 변경을 감지하지 못합니다.


orphanremoval 옵션을 false로 해놓았기 때문에 실제로 DB에 반영되지 않습니다. 그렇지만 이 옵션을 다시 true로 하기에는 쿼리가 상당히 많이 발생하기 때문에 별도의 delete 쿼리를 따로 날려줘야 할 것 같습니다.


3. Cascade=ALL

갑자기 하나씩 찜할 수 있는 기능이 한 회원은 여러 게시글을 동시에 찜할 수 있다 로 변경되었다고 가정해보겠습니다. 컨트롤러에 추가된 코드 입니다.


image


여러 Article ID를 받을 수 있는 DTO 입니다.


image


서비스 로직입니다. 일단, 중복을 생각하지 않고(예제에 충실하기 위해!) 회원 및 게시글 엔티티를 찾아온 다음, Article 리스트를 통으로 넣는 코드입니다.


image


마지막으로 회원 엔티티에서 FavoriteArticle 객체를 찜할 게시글 갯수만큼 만들어 줘 실제 리스트에 넣어주는 로직입니다.


image


이제 3번 회원으로 1, 4번 게시글을 찜해보겠습니다.


image


찜한 게시글의 수 만큼 insert쿼리가 나가게 됩니다. 이 경우에는 cascade 옵션을 벗겨내고 insert 쿼리를 한번에 보내는 방법을 써야될 것 같습니다.


결론

  • 무지성으로 OneToMany 어노테이션에 cascade ALL 및 orphanRemoval true 걸어주지 않기.
  • 상황에 따라 달라지겠지만, 자식 엔티티를 하나씩 추가하고 삭제시 모든 자식 엔티티를 삭제해야 할 경우에는 Cascade 옵션을 Persist만 작성하고 orphanRemoval은 false로 두기, 또한 별도의 delete 메서드를 작성하고 영속성 컨텍스트의 1차 캐시 꼭 신경쓰기.
  • 여러 자식 엔티티를 한번에 추가해야할 경우는 cascade 옵션을 ALL 혹은 PERSIST로 설정되었을 때, 여러 자식 엔티티를 한번에 지워야 되는 경우 처럼 insert 쿼리가 자식의 갯수 만큼 발생한다. cascade 옵션을 주지 말고 별도의 insert 쿼리를 날려야 한다.

이번 포스팅은 여기서 마무리하겠습니다. 끝까지 읽어주셔서 감사합니다!

반응형