ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • JPA의 연관관계에 대한 고찰
    Back-End/JPA 2022. 11. 18. 13:58
    728x90

    JPA의 XtoOne 관계를 설정할 때, fetchType 옵션은 기본적으로 eager로 설정되어있다.

     

    따라서, 따로 Lazy 타입으로 설정을 하지 않는 경우 자신과 연관관계가 있는 엔티티를 호출하는 쿼리가 자동으로 나가게 되고

     

    성능상으로 굉장한 손해가 온다(고 한다).

     

    이론상으로만 배워왔기에 매번 헷갈리는 경우가 많았고, 따라서 한 번 실습을 진행 해 보면서 실제로 쿼리가 동작하는 것을 관찰 해 보려고 한다.

     

    또한, eager로 fetch된 엔티티와 연관관계가 있는 또 다른 entity가 있고, 이들 또한 eager로 설정이 되어있다면 어떻게 query가 나가게 되는지를 관찰 해 보려 한다.

     

     

    먼저, 예시 Entity를 만들기 위해, 다음과 같이 Entity 관계를 설정하였다.

     

     

     

    만들고 나니 굉장히 이진트리스럽다.

     

    review 테이블과 review_image는 1:N, review와 review_reply는 1:1의 관계이다.

     

    또한 두 개의 관계 모두 양방향 매핑을 사용 해 보았다.

     

    그리고 각 관계에서 review를 제외한 나머지 한 쪽이 모두 연관관계의 주인이 된다.

     

    이번 실험에서 진행 해 볼 것은

     

    1. review 데이터를 호출 해 보고,

     

    2. review_reply의 데이터를 호출 해 보고,

     

    3. review_image 데이터를 호출 해 볼 것이다.

     


     

    1. review를 호출하는 경우

     

     

     

    여기서도, 단건 조회 쿼리와 전체 조회 쿼리가 다르게 나간다.

     

     

     

    1) 전건 조회 시

     

     

     

    review List를 불러오는 조회 작업이다보니, review 엔티티의 필드와 매핑되는 값만 가져온다.

     

    따라서 review.getContent()를 호출하는 경우 더 이상 쿼리가 나가지 않고 잘 작동을 한다.

     

    reviewImage는 프록시 객체로 감싸져 있는 상황이다. 

     

    reviewReply는 알 수 없다.(이유는 아래 참조)

     

     

    2) 단건 조회 시

     

     

     

    예상했던 대로 review_reply를 eager fetching을 하는 현상이 나타난다.

     

    그런데 여기서 새로 알게 된 사실이 있다.

     

    review와 review_reply의 1대1 관계에서, 연관관계의 주인은 review_reply여서 @JoinColumn을 review_reply에 명시를 하였고 fetchType을 Lazy로 했을 때 review_reply 조회 시 review는 lazy fetching이 적용된다.

     

    하지만 반대로 review에서 review_reply를 조회할 시, left outer join이 나가게 된다.

     

    그래서, review_reply 필드에 fetch type을 Lazy로 적용 해 보았다.

     

    이번에는 다음과 같이 쿼리가 2번 나가게 된다. 이유가 무엇일까?

     

    JPA 프로그래밍 책을 살펴보면

    이것은 프록시의 한계 때문에 발생하는 문제인데 프록시 대신에 bytecode instrumentation을 사용하면 해결할 수 있다.

    라고 명시가 되어있다.

     

    알아본 결과, JPA 구현체는 Lazy Fetching 시 연관 엔티티 필드 값(review_reply)에 프록시 객체(id)를 넣거나 null을 넣어줘야 하는데, review 테이블에는 review_reply에 대한 정보가 없으므로 해당 테이블에 관계가 있는 레코드가 존재하는 지를 알 수 없다. 따라서, 이를 알아내기 위해 review_reply를 조회하는 쿼리를 한방 더 날려서 해당 사항을 결정하는 것이다. 즉 값이 있다면 프록시 객체로, 없다면 null을 넣는 것이다.

     

    이를 해결하는 방법으로, 김영한님은 총 5가지의 방법을 제시한다. 

     

    https://www.inflearn.com/questions/40670

     

    onetoone에서 lazy관련해서 질문드립니다~! - 인프런 | 질문 & 답변

    안녕하세요, 고퀄리티 강의 감사합니다 영한님. onetoone과 lazy에 관련해서 궁금한점이 있어 질문 남깁니다. ※ table구조 - member (1) - (1) phone   (onetoone)  - fk는 phone에 member_id...

    www.inflearn.com

     

    내가 생각하기에 가장 이상적인 방법은 review -> review_reply 매핑관계를 끊고, review_reply가 필요한 경우 fetch join을 사용해서 가져오는 방법이 적합하다고 생각한다. 외래키를 review에 두는 방법도 있겠지만, review에 불필요한 필드가 추가되어 비즈니스 로직이 더 복잡해지는 문제가 생길 것이라 생각하기 때문이다.

     

     

    3) review의 reviewImage를 조회할 때, fetch join 사용 여부에 따른 차이점

     

     

     

    먼저, fetch join을 사용하지 않고 review를 불러온다.

     

    List<Review> reviews = reviewRepository.findAll();

    이 후, 모든 review들에 대해 각 reviewImage들의 정보를 꺼내본다.

     

     

     

     

     

    여기서 중요한 것은, review.getReviewImages()를 호출하여도 쿼리가 나가지 않는다.

     

    현재 프록시 객체를 들고 있는 상태이고, reviewImage.getImagePath()등 reviewImage를 사용하는 시점에서야 쿼리가 나가게 된다.

     

    나간 쿼리를 살펴보면

     

     

    다음과 같이 review를 불러온 뒤 더미데이터에 들어있는 review 2개에 대해 각 reviewImage를 조회하는 쿼리가 2번이 또 나가게 된다.

     

    이번에는 리뷰와 리뷰 이미지를 fetch join을 사용해서 가져와 본다.

     

    @Query("select r from Review r join fetch r.reviewImages")
        List<Review> findWithReviewImage();

     

    해당 쿼리를 호출하고, 위와 동일하게 코드를 실행 해 보면

     

     

    다음과 같이 inner join 쿼리 한 방으로 쉽게 해결이 가능하다.

     

    만약 review가 100개, 1000개였으면 쿼리가 그만큼 나가게 될 것인데, fetch join을 사용하면 단 한 방의 쿼리로 해결함으로써 성능 최적화를 할 수 있다.

     

     

    2. review_reply를 호출하는 경우

     

    1) 전건 조회 시

     

     

    역시 해당 엔티티만 불러오는 것을 알 수 있다. 연관된 review 객체는 불러오지 않는다.

     

     

     

     

    2) 단건 조회 시

     

     

    left outer join을 통해, review의 정보를 함께 불러온다.

     

    그런데, 왜 Eager의 경우 left outer join을 사용할까? inner join을 사용하면 될 텐데. 하는 생각이 들어서 조사를 해 보았고

     

    review 외래키값이 null인 경우도 있을 수 있으니 이런식으로 쿼리가 구성되어있음을 알아냈다.

     

    따라서 inner join을 쓰고 싶다면 외래키가 null이 아니라는 것을 구현체에 알려주면 되므로

     

     

    다음과 같이 nullable = false 값을 준다.

     

     

     

    물론, fetchType을 LAZY로 바꾸면 생각하지 않아도 되는 문제이긴 하다.

     

     

     

    3. review_image를 호출하는 경우

     

     

     

    1) 전건 조회 시

     

     

     

    위와 동일하다.

     

     

     

    2) 단건 조회 시

     

     

     

     

     

    놀랍게도, left outer join 연산이 2번이나 일어난다.

     

    이는 reviewImage를 호출하면서 FetchType이 Eager이므로 Review를 함께 호출하고,

     

    그 Review에서 또한 OneToOne관계를 갖는 review_reply를 호출하기 때문에 이런 문제가 발생하는 것이다.

     

    그러므로 먼저 review에서 review_reply 방향으로의 매핑을 없애주고(위에서 제시된 해결책처럼)

     

    review의 fetchType을 Lazy로 바꿔보면

     

     

     

    다음과 같이 해결이 가능하다.

     

     

     


     

    이번 실험으로 얻은 결과를 정리해보면

     

    • FetchType.EAGER는 ToOne 관계의 엔티티를 left outer join을 통해 불러온다. 따라서, 연관관계의 엔티티가 필요없는 상황에서 비효율적이다. 또한 review와 연관된 다른 엔티티에서 review를 불러올 때도 작동을 해서 left outer join이 불필요하게 연쇄적으로 작동한다. 그래서 FetchType.LAZY를 사용하면 이를 해결할 수 있다. 
      • 주의해야 할 것은 연관된 엔티티를 사용하는 경우인데, 이 경우 LAZY를 사용한다 해도 결국 연관 엔티티를 사용해야 하므로 다시 그 연관 엔티티를 조회하는 쿼리가 나가서 비효율적이게 된다. 따라서 fetch join을 사용해서 한 번에 연관된 엔티티를 불러오는 작업을 해야 한다.
    • @OneToOne 관계에서, 연관관계 주인이 아닌 쪽에서 주인으로의 방향 설정을 하는 경우, FetchType.LAZY가 먹히지 않는다. 따라서, 이를 해결하기 위한 방법이 몇 가지가 있는데 개인적인 의견으로는 해당 방향 매핑을 없애고, 필요할 때 fetch join을 해서 가져오는 것이다.

     

     

    결국 fetchType.LAZY + fetch join 꼭 써라. 2번 써라가 결론인듯하다..

     

    댓글

Designed by Tistory.