JPA orphanRemoval 사용 시 쿼리 문제

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

keeper-homepage

목록 보기
5/5

한창 머리박고 기능 개발을 하던 와중...

orphanRemoval을 사용한 것에 대한 성능 문제가 있음을 인지하게 되었습니다.

이슈에 적힌 대로, OneToMany 설계 전반을 한번 돌아봐야겠다는 생각이 들어서 글을 쓰게 되었습니다.

앞서 썼던 글 JPA에서 양쪽에 cascade를 걸면 발생하는 문제에 이어서 써내려가는 글입니다! 엔티티 설계에 대한 설명을 작성해두었습니다.

바로 Member의 leave 메서드를 살펴봅시다.

@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 leave(Study study) {
    studyMembers.removeIf(studyMember -> studyMember.getStudy().equals(study));
  }

cascadeType.REMOVE

부모 객체가 제거되면, 자식 객체도 제거됩니다. 그러나 cascadeType.REMOVE는 부모 객체와 자식 객체간의 관계를 끊는다고 해서, 자식 객체가 제거되지는 않습니다.

+) 한편, cascadeType.REMOVE로 자식을 지우면 delete 쿼리가 N번 나갑니다.

orphanRemoval=true

이 속성은, 부모 객체가 제거되면 자식 객체도 제거하고, 부모와 자식 객체 사이의 관계가 끊어져도 자식 객체를 고아로 취급하여 제거합니다. 위의 leave 메서드는 그 속성을 이용하여 StudyHasMember를 컬렉션에서 찾아서 제거하고 있습니다.

@Test
@DisplayName("orphanRemoval 제거 테스트")
public void orphanRemoval_제거_테스트() throws Exception {
  //given
  Member member1 = memberTestHelper.generate();
  Member member2 = memberTestHelper.generate();
  Member member3 = memberTestHelper.generate();
  Member member4 = memberTestHelper.generate();
  Member member5 = memberTestHelper.generate();
  Member member6 = memberTestHelper.generate();

  Study study = studyTestHelper.generate();

  member1.join(study);
  member2.join(study);
  member3.join(study);
  member4.join(study);
  member5.join(study);
  member6.join(study);

  em.flush();
  em.clear();
  //when
  study = studyRepository.findById(study.getId()).orElseThrow();
  member1 = memberRepository.findById(member1.getId()).orElseThrow();
  member2 = memberRepository.findById(member2.getId()).orElseThrow();
  member3 = memberRepository.findById(member3.getId()).orElseThrow();
  member4 = memberRepository.findById(member4.getId()).orElseThrow();
  member5 = memberRepository.findById(member5.getId()).orElseThrow();
  member6 = memberRepository.findById(member6.getId()).orElseThrow();

  member1.leave(study);
  member2.leave(study);
  member3.leave(study);
  member4.leave(study);
  member5.leave(study);
  member6.leave(study);

  em.flush();
  em.clear();
}

테스트 코드는 위 처럼 짰습니다.

2023-08-25T19:01:24.492+09:00  INFO 69140 --- [           main] p6spy                                    : [statement] | 1 ms | 
    select
        s1_0.member_id,
        s1_0.study_id,
        s1_0.register_time      
    from
        study_has_member s1_0      
    where
        s1_0.member_id=64
2023-08-25T19:01:24.494+09:00  INFO 69140 --- [           main] p6spy                                    : [statement] | 0 ms | 
    select
        s1_0.member_id,
        s1_0.study_id,
        s1_0.register_time      
    from
        study_has_member s1_0      
    where
        s1_0.member_id=65
2023-08-25T19:01:24.495+09:00  INFO 69140 --- [           main] p6spy                                    : [statement] | 0 ms | 
    select
        s1_0.member_id,
        s1_0.study_id,
        s1_0.register_time      
    from
        study_has_member s1_0      
    where
        s1_0.member_id=66
2023-08-25T19:01:24.496+09:00  INFO 69140 --- [           main] p6spy                                    : [statement] | 0 ms | 
    select
        s1_0.member_id,
        s1_0.study_id,
        s1_0.register_time      
    from
        study_has_member s1_0      
    where
        s1_0.member_id=67
2023-08-25T19:01:24.498+09:00  INFO 69140 --- [           main] p6spy                                    : [statement] | 1 ms | 
    select
        s1_0.member_id,
        s1_0.study_id,
        s1_0.register_time      
    from
        study_has_member s1_0      
    where
        s1_0.member_id=68
2023-08-25T19:01:24.499+09:00  INFO 69140 --- [           main] p6spy                                    : [statement] | 0 ms | 
    select
        s1_0.member_id,
        s1_0.study_id,
        s1_0.register_time      
    from
        study_has_member s1_0      
    where
        s1_0.member_id=69
2023-08-25T19:01:24.505+09:00  INFO 69140 --- [           main] p6spy                                    : [statement] | 1 ms | 
    delete      
    from
        study_has_member      
    where
        member_id=64          
        and study_id=49
2023-08-25T19:01:24.509+09:00  INFO 69140 --- [           main] p6spy                                    : [statement] | 0 ms | 
    delete      
    from
        study_has_member      
    where
        member_id=65          
        and study_id=49
2023-08-25T19:01:24.510+09:00  INFO 69140 --- [           main] p6spy                                    : [statement] | 0 ms | 
    delete      
    from
        study_has_member      
    where
        member_id=66          
        and study_id=49
2023-08-25T19:01:24.511+09:00  INFO 69140 --- [           main] p6spy                                    : [statement] | 0 ms | 
    delete      
    from
        study_has_member      
    where
        member_id=67          
        and study_id=49
2023-08-25T19:01:24.544+09:00  INFO 69140 --- [           main] p6spy                                    : [statement] | 33 ms | 
    delete      
    from
        study_has_member      
    where
        member_id=68          
        and study_id=49
2023-08-25T19:01:24.545+09:00  INFO 69140 --- [           main] p6spy                                    : [statement] | 0 ms | 
    delete      
    from
        study_has_member      
    where
        member_id=69          
        and study_id=49

쿼리 결과를 보면...! 삭제를 하기 전에 불필요한 조회를 하고 있습니다. 따라서 조회 쿼리 N개 + 삭제 쿼리 N개가 나가게 되어 데이터가 많아진다면, 이는 성능 이슈가 충분히 될 수 있겠습니다.

JPA의 deleteAllBy()

대안으로는, JPA에서 제공하는 deleteAllBy()를 사용하면 되겠거니 했습니다.

2023-08-25T19:07:08.529+09:00  INFO 69207 --- [           main] p6spy                                    : [statement] | 0 ms | 
    delete      
    from
        study_has_member      
    where
        member_id=74          
        and study_id=51
2023-08-25T19:07:08.531+09:00  INFO 69207 --- [           main] p6spy                                    : [statement] | 1 ms | 
    delete      
    from
        study_has_member      
    where
        member_id=75          
        and study_id=51
2023-08-25T19:07:08.532+09:00  INFO 69207 --- [           main] p6spy                                    : [statement] | 0 ms | 
    delete      
    from
        study_has_member      
    where
        member_id=76          
        and study_id=51
2023-08-25T19:07:08.533+09:00  INFO 69207 --- [           main] p6spy                                    : [statement] | 0 ms | 
    delete      
    from
        study_has_member      
    where
        member_id=77          
        and study_id=51

그러나, deleteAllBy를 사용했을 경우 delete 쿼리가 N번 나감을 확인했습니다.

@Query 작성

@Modifying
@Query("delete from StudyHasMember s where s.study=:study")
void deleteAllByStudy(@Param("study") Study study);

Study에 해당하는 StudyHasMember를 한방에 지우는 쿼리를 작성하였습니다.

삭제 쿼리가 1번 나갑니다!


orphanRemoval 사용 할까? 말까?

기존에 orphanRemoval을 사용한 이유도, 사실은 가독성 때문이 컸습니다. 하지만 이렇게 문제가 있음을 깨닫고 보니, 오히려 쿼리를 통해서 명시적으로 삭제하는 코드가 있는 것도 가독성 측면에서 나쁘지 않은 것 같다는 생각이 듭니다.

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

0개의 댓글