본문으로 바로가기

Lazy Loading

JPA의 유일한 단점은 사용하기 쉬운만큼 성능적인 측면에서 발생할 수 있는 이슈를 간과하기 쉽다는 것인데, 성능이 안나올때 가장 먼저 고려해봐야할 부분이 즉시로딩(EAGER LOADING)으로 설정된 Fetch 전략이 있는지 확인하는 것이다.

하지만 @OneToOne 매핑시 Fetch 전략을 Lazy로 설정해도 EAGER로 동작하는 경우가 있다. 어떤 경우에 이러한 문제점이 발생하는지, 해결책은 무엇인지 예시를 통해 알아보도록 하자

예시 1. @OneToOne 단방향 매핑

@Entity
public class User {
    @Id
    @GeneratedValue
    @Column(name = "USER_ID")
    private Long id;

    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "CART_ID")
    private Cart cart;

}
@Entity
public class Cart {
    @Id
    @GeneratedValue
    private Long id;
}

해당 예제는 일대일 단방향 매핑으로 외래키를 가지고 있는 USER가 연관관계의 주인이다.

다음과 같이 USER를 조회해보자.

userRepository.findByEmail(email).orElseThrow();

실행된 쿼리

select
    user0_.user_id as user_id2_9_,
    user0_1_.created_date as created_3_9_,
    user0_1_.modified_date as modified4_9_,
from
    user user0_ 
where
    user0_1_.email=?

실행된 쿼리를 보면 Lazy Loadging이 정상적으로 동작한다는 것을 알 수 있다.

예시 2. @OneToOne 양방향 매핑

@Entity
public class User {
    @Id
    @GeneratedValue
    @Column(name = "USER_ID")
    private Long id;

    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "CART_ID")
    private Cart cart;

}
@Entity
public class Cart {

    @Id
    @GeneratedValue
    @Column("CART_ID")
    private Long id;

    @OneToOne(mappedBy = "cart", fetch = FetchType.LAZY)
    private User user;
}

해당 예제는 User와 Cart가 일대일 양방향 관계를 맺고 있고, 예시1과 동일하게 연관관계의 주인은 외래키를 가지고있는 USER다. 여기서도 아래와 같이 USER를 조회하면 정상적으로 Lazy Loading이 동작한다.

userRepository.findByEmail(email).orElseThrow();

하지만 문제는 Cart를 조회할때 발생한다. 다음과 같이 Cart를 조회해보자.

cartRepository.findById(id).get();

실행된 쿼리

select
        cart0_.id as id1_4_0_ 
    from
        cart cart0_ 
    where
        cart0_.id=?

  select
        user0_.user_id as user_id2_9_1_,
        user0_1_.created_date as created_3_9_1_,
        user0_1_.modified_date as modified4_9_1_,
        user0_.cart_id as cart_id10_8_1_,
    from
        user user0_ 
    where
        user0_.cart_id=?

분명히 Fetch 전략을 Lazy로 설정하고 조회했음에도 불구하고 cart 조회시 user를 조회하는 쿼리가 함께 실행된다. Lazy Loading이 아닌 Eager Loading으로 동작했다는 뜻이다.

 

문제 원인

위 예시들을 통해 정리해보면 연관관계의 주인이 호출할 때는 지연 로딩이 정상적으로 동작하지만, 연관관계의 주인이 아닌 곳에서 호출한다면 지연 로딩이 아닌 즉시 로딩으로 동작한다는 것을 알 수 있다.

 

왜 이런 문제가 발생하는지 알아보기 전에, 지연 로딩이 동작하는 매커니즘을 이해해야 한다.

  1. 지연 로딩은 로딩되는 시점에 Fetch 전략이 Lazy로 설정되어있는 엔티티를 프록시 객체로 가져온다. 해당 예제에서는 User를 조회할때 cart를 프록시 객체로 가져오게 된다.
  2. 이후 실제로 Cart 객체를 사용하는 시점에 초기화 되면서 쿼리가 실행된다.
    • 예를들어, getCart() 처럼 cart 객체가 사용되었을때 쿼리가 실행되는 것이다.

이렇게 지연 로딩으로 설정이 되어있는 엔티티를 조회할 때는 프록시로 감싸서 동작하게 되는데, 프록시는 null을 감쌀 수 없기 때문에 이와 같은 문제점이 발생하게 된다. , 프록시의 한계로 인해 발생하는 문제이다.

 

예시2에서 사용한 예제를 DB 테이블로 표현하면 아래와 같은 테이블이 형성된다.

 

 CART라는 테이블에는 USER를 참조할 수 있는 컬럼이 존재하지 않는다. 따라서 CART는 어떤 USER에 의해 참조되고 있는지 알 수 없다.

 

CART가 어떤 USER에 의해 참조되고 있는지 알 수 없다는 뜻은 만약 USER가 null이더라도 CART는 이 사실을 알지 못한다는 것이다.

 

만약 USER가 null이 아니라고 해도, CART의 입장에서는 USER가null인지 null이 아닌지 확인할 방법이 없다.

따라서 USER의 존재 여부를 확인하는 쿼리를 실행하기 때문에 지연 로딩으로 동작하지 않는 것이다.

해결 방법

해결 방법이라고 했지만 완벽하게 이 문제를 해결할 수 있는 방법은 존재하지 않는다. 따라서 아래와 같은 해결책을 고려한 후 현재 상황에 알맞게 적용해야 한다.

  1. 구조 변경하기
    • 양방향 매핑이 반드시 필요한 상황인지 다시한번 생각해본다.
    • OneToOne -> OneToMany 또는 ManyToOne 관계로 변경이 가능한지 생각해본다.
  2. 구조를 유지한채 해결하기
    • CART를 조회할때 USER도 함께 조회한다. (Fetch Join)
    • batch fetch size를 사용한다.

 


결론

# OneToOne 양방향 매핑에서 연관관계의 주인이 아닌 쪽에서 조회하게 되면 프록시 객체를 생성할 수 없기 때문에 지연 로딩으로 설정해도 즉시 로딩으로 동작하게 된다.

 

# 그 이유는 프록시는 null을 감쌀 수 없기 때문에 참조하고 있는 객체가 null인지 null이 아닌지 확인하는 쿼리를 실행해야 하기 때문이다.

 


 

참고 문헌

 

자바 ORM 표준 JPA 프로그래밍

자바 ORM 표준 JPA는 SQL 작성 없이 객체를 데이터베이스에 직접 저장할 수 있게 도와주고, 객체와 관계형 데이터베이스의 차이도 중간에서 해결해준다. 이 책은 JPA 기초 이론과 핵심 원리, 그리고

digital.kyobobook.co.kr