JPA @OneToOne Lazy 로딩 전략 작동 이슈

HYEON·2024년 1월 30일
0

지구미

목록 보기
2/2
post-thumbnail

문제 상황

마커를 찍을 때 어떤 방법이 효율적인지 테스트를 하던 도중 엔티티를 가져와 어플리케이션에서 정제해 DTO로 반환하는 방법에서 Lazy 로딩이 제대로 작동하지 않고, 사용하지 않는 Goods와 연관된 엔티티 중 일부를 다 가져오는 상황이 일어났다.

왜 그런지 궁금증이 들어 이 글을 작성하게 됐다.

문제 상황 이해

아래는 문제가 발생한 엔티티들이다.

@Entity
public class Goods extends BaseTimeEntity {
    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "category_id")
    private Category category;

    @OneToOne(mappedBy = "goods", fetch = LAZY)
    private Sell sell;

    @OneToOne(mappedBy = "goods", fetch = LAZY)
    private Board board;
}
@Entity
public class Sell {
    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "goods_id")
    private Goods goods;
}

@Entity
public class Board extends BaseTimeEntity {
    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "goods_id")
    private Goods goods;
}

아래는 문제의 쿼리이다.

@Query("select co "+
            "from GoodsCoordinate co " +
            "join fetch co.goods g " +
            "where ST_CONTAINS(:area, co.coordinate) " +
            "and g.goodsStatus = :status")
    List<GoodsCoordinate> findMarkerListFromCoordinateV2(@Param("area") final Polygon area,
                                                 @Param("status") GoodsStatus goodsStatus);

이렇게 작성하고 Postman을 이용해서 요청을 보내니 엄청난 양의 쿼리가 로그에 표시되었다.

로그를 천천히 보면서 확인하니 SellBoard가 Eager로 동작하고 있었고, 두 관계는 @OneToOne이라는 공통점이 있었다.

도대체 왜 이렇게 동작한 것일까?

이유

결론적으로 바로 말하자면 다음과 같은 이유에서 였다.

@OneToOne 양방향 연관 관계에서 연관 관계의 주인이 아닌 쪽 엔티티를 조회할 때, Lazy로 동작할 수 없다.

왜 일까?

기본적으로 연관 관계 엔티티를 LAZY로 조회하기 위해서는 JPA가 ‘프록시’를 만들어 이를 이용한다.

JPA는 null 값이 가능한 상황에서 해당 값을 프록시 객체로 감싸는 것은 불가능하다. ****

만약 null 값이 가능한 @OneToOne 연관관계를 지닌 엔티티를 프록시 객체가 참조한다면 그 순간 null이 아닌 프록시 객체를 리턴하는 상황이 발생하게 된다.

그러면 NPE를 제대로 던질 수 없기 때문에 나중에 어떤 오류가 발생할지 모른다.

따라서 JPA는 연관 관계 엔티티에서 null 혹은 프록시 객체 둘 중 하나가 반드시 할당되어야 한다.

자 이제 다시 곰곰히 생각해보자.

JPA에서 연관 관계의 주인이 FK를 가지고 있고 연관 관계의 주인이 아닌 엔티티는 FK를 가지고 있지 않다.

따라서 연관 관계의 주인이 아닌 엔티티를 조회 할 때에는 그 엔티티와 연결되어 있는 엔티티가 있는지 아니면 Null인지 알 수 없다.

Goods 테이블이다.

Goods 테이블만 조회하면 연관 관계에 있는 엔티티가 Null인지 확인할 수 없다.

왜나면, 해당 테이블에 다른 엔티티에 대한 정보가 하나도 없기 때문이다.

따라서 JPA가 연결된 엔티티가 있는지 없는지 모르기 때문에 null 혹은 프록시 객체 중 어떤 객체를 넣어야 할지 몰라서 Eager로 동작해 실제 연결된 엔티티가 있는지 없는지 확인하는 것이다.

그럼 @ManyToOne, @OneToMany은 어떻게 LAZY 로딩이 되는 것일까?

ManyToOne은 당연하게도 연관 관계의 주인이 Many기 때문에 해당 테이블을 조회하면 FK로 연결된 엔티티가 있는지 확인하기 때문에 LAZY 로딩이 가능하다.

OneToMany가 문제인데 OneToMany는 왜 LAZY 로딩이 가능 한 것일까? FK를 가지고 있지도 않은데 말이다.

답은 컬렉션에 있다. 컬렉션은 null을 표현할 방법이 있다.

그래서 프록시 객체를 만들어 놓고 실제 조회 시에 빈 컬렉션을 반환하여 null임을 표시할 수 있기에 LAZY로 동작이 가능하다.

해결방안

해결 방안은 간단하게는 fetch join으로 처음부터 다 가져와서 사용하는 방법이 있고 연관관계의 구조를 바꾸거나  byte code instrument을 이용하는 방법이 있다.

이 방법에서도 CTW(compile time weaver), LTW(load time weaver) 두 가지 방식으로 나뉜다. 그리고 Hibernate의 내부에 이미 구현된 FieldHandler를 사용하는 방법이 있다.

자세한 건 검색을 참고해보자.

난 join fetch와 테이블 설계를 변경하는 법을 이용했다. 지도에 마커를 찍는 것은 Projection을 이용해서 Dto로 반환 받는 것이 유리했고,

마커 기능을 제외하고 Goods가 필요한 경우 애플리케이션 구조 상 board와 sell, goods가 동시에 필요한 경우가 많다고 판단해 이 비즈니스 관점에서 테이블 분석 후 테이블 설계 변경으로 해결했다.

느낀 점

트러블 슈팅은 일어날 때 마다 매번 깊게 공부해야 한다는 점을 명심하게 된다. 이번에도 JPA 작동 원리에 대해 얕게 알고 있던 내 잘못이였다.

프록시로 작동한다는 사실은 알고 있었지만, 연관 관계의 주인과 DB적으로 조금 깊게 생각하고 JPA 프록시에 작동 방식에 대해 얕은 지식을 가지고 문제를 찾았기 때문에 시간도 조금 걸렸던 것 같다.

그리고 테스트는 역시 몇 번을 해도 부족하지 않은 것 같다.

아무튼 무지성으로 양방향을 쓰지 말고 연관 관계의 주인, DB, 프록시에 대해 생각하면서 써보자 !

profile
레벨업하는 개발자

0개의 댓글