본문 바로가기

Dot Programming/JPA

[JPA] Fetch 전략 공부하기 - @OneToOne 양방향 매핑에서 Lazy가 동작하지 않는 이유

    Fetch 전략

    기존 Hibernate의 Fetch 전략은 모두 Lazy였는데, Hibernate 5.x 버전부터 JPA와 똑같이 전략이 변경되었다. Hibernate User Guide의 일부를 보면 다음과 같다.

    The Hibernate recommendation is to statically mark all associations lazy and to use dynamic fetching strategies for eagerness. This is unfortunately at odds with the JPA specification which defines that all one-to-one and many-to-one associations should be eagerly fetched by default. Hibernate, as a JPA provider, honors that default

     

    그래서 HIbernate와 JPA 모두 Fetch전략은 @XToOne은 모두 EAGER이고 @XToMany는 모두 LAZY로 설정되어 있다.

    • 연관된 엔티티가 하나면 즉시 로딩, 컬렉션이면 지연 로딩을 사용한다. 컬렉션을 EAGER로 로딩하는 것은 비용이 많이 들고 잘못하면 너무 많은 데이터를 로딩할 수도 있기 때문이다. 
    OneToOne: EAGER
    ManyToOne: EAGER
    OneToMany: LAZY
    ManyToMany: LAZY

     

    지연 로딩(LAZY)

    하이버네이트는 지연 로딩을 사용하기 위해 실제 엔티티 대신 데이터베이스 조회를 지연할 수 있는 프록시 객체(가짜 객체)를 사용하는 방법과 하이버네이트 바이트코드를 강화하는 설정을 추가하는 방법을 제공하고 있다.

    • 하이버네이트 바이트코드를 강화하여 지연로딩 사용하는 방법 (check)

     

    FetchType.LAZY는 엔티티 간의 관계가 여럿 있을 때 데이터베이스에서 조회에 연관되어 있는 엔터티만 가져오도록 Hibernate에 지시한다.

    1. 로딩되는 시점에 Lazy 로딩 설정이 되어있는 Team 엔티티는 프록시 객체로 가져온다.
    2. 그리고 실제 객체를 사용하는 시점에(Team을 사용하는 시점에) 초기화가 된다.

     

    프록시 구조와 위임

    프록시를 간단하게 살펴보면, 프록시 클래스는 실제 클래스를 상속 받아서 만들어지므로 실제 클래스와 겉모양이 같다. 따라서 사용하는 입장에서는 실제 객체인지 프록시 객체인지 구분하지 않고 사용할 수 있다.

    프록시 구조

     

    프록시 객체는 실제 객체에 대한 참조를 보관한다. 그리고 프록시 객체의 메소드를 호출하면 프록시 객체는 실제 객체의 메소드를 호출한다.

    프록시 위임

     

    즉시 로딩(EAGER)

    FetchType.EAGER는 루트 엔티티를 조회할 때 연관되어 있는 모든 요소를 ​​가져오도록 Hibernate에 지시한다. JPA 구현체는 즉시 로딩을 최적화하기 위해 가능하면 조인 쿼리를 사용한다.

    1. 로딩되는 시점에 연관되어 있는 모든 요소들을 다 조회해온다.
    2. 그리고 그 모든 요소들은 프록시가 아닌 진짜 객체에 담는다.

     

    NULL 제약 조건과 JPA 조인 전략

    JPA는 즉시 로딩 실행 SQL에서 내부 조인이 아닌 외부 조인을 사용한다. 왜냐하면 조인하는 테이블이 NULL일 가능성이 있기 때문이다. 따라서 팀이 없는 유저가 있을 수도 있다. 팀이 없는 유저와 팀을 내부 조인하면 어떤 데이터도 조회할 수 없다. 그래서 외부조인을 사용하는 것이다. 그런데 내부 조인이 성능 최적화에서 더 유리하다. 그럼 내부 조인은 어떻게 사용해야 할까? 외래 키에 NOT NULL 제약 조건을 걸어주면 된다. 

     

    정리하면 JPA는 엔티티를 조회할 때는 선택적 관계면 외부 조인을 사용하고 필수 관계면 내부 조인을 사용한다.

    @XToOne(optional = true, fetch = FetchType.EAGER) // 외부 조인으로 동작
    @XToOne(optional = false, fetch = FetchType.EAGER) // 내부 조인으로 동작

     

    그런데 컬렉션을 조회할 때는 항상 외부 조인을 사용한다.

    • not null 제약 조건을 걸어두면 모든 유저는 팀에 소속되므로 항상 (@xToOne) 내부 조인을 사용하게 된다. 그런데 반대로 팀 테이블에서 회원 테이블로 일대다 관계를 조인(@xToMany)할 때 회원이 한 명도 없는 팀을 내부 조인하면 팀까지 조회되지 않는 문제가 발생한다. 그래서 항상 외부 조인으로 동작한다.
    @XToMany(optional = true, fetch = FetchType.EAGER) // 외부 조인으로 동작
    @XToMany(optional = false, fetch = FetchType.EAGER) // 외부 조인으로 동작

     

    지연 로딩(LAZY) vs 즉시 로딩(EAGER)

    • EAGER? 처음부터 연관된 엔티티를 모두 영속성 컨텍스트에 올려두는 것은 현실적이지 않다.
    • LAZY? 필요할 때마다 SQL을 실행해서 연관된 엔티티를 지연 로딩하는 것도 최적화 관점에서 보면 꼭 좋은 것만은 아니다.

     

    예를 들어 대부분의 애플리케이션의 로직에서 유저와 지갑 엔티티를 같이 사용한다면 SQL 조인을 사용해서 유저와 지갑 엔티티를 한 번에 조회하는 것이 더 효율적이다. 결국 연관된 엔티티를 즉시 로딩하는 것이 좋은지 아니면 실제 사용할 때까지 지연해서 로딩하는 것이 좋은지는 상황에 따라 다르다.

     

    실무에 추천하는 방식은 모든 연관관계 설정을 지연 로딩을 사용하고 최적화를 이룰 때 즉시 로딩 설정을 적용해주는 것이다. 단, 주의해야 할 점은 컬렉션을 하나 이상 즉시 로딩하면 NxM이 되면서 너무 많은 데이터를 반환할 수 있고 위에서 언급한 바와 같이 @XToMany 관계에서 EAGER는 항상 외부조인으로 동작해야 하므로 성능상 피하는 것이 좋다.

     

    @OneToOne 양방향 매핑 중 LAZY가 먹히지 않는 경우 

    이러한 Fetch 전략에서 이상한 현상이 발생하는 경우가 있다. @ManyToOne에서는 LAZY 전략이 무리없이 잘 동작하지만 @OneToOne에서는 LAZY 전략이 동작하지 않을 때가 있다.

     

    바로 연관관계의 주인이 아닌 곳에서 값을 불러올 때이다. null 값이 가능한 (optional=true, 외부 조인) 상황에서 해당 값을 프록시 객체로 감싸는 것은 불가능하다. 만약 null 값이 가능한 @OneToOne 연관관계를 지닌 엔티티를 프록시 객체가 참조한다면 그 순간 null이 아닌 프록시 객체를 리턴하는 상황이 발생하게 된다. 그러면 NPE를 제대로 던질 수 없기 때문에 나중에 어떤 오류가 발생할지 모른다. 그래서 연관된 엔티티 객체가 null 값인지 아닌지 확인하기 위해 LAZY가 아닌 EAGER로 동작하는 것이다.

     

    직접 확인해보자. Member와 Wallet 두 테이블이 @OneToOne으로 양방향 매핑이 되어 있다. Member가 연관관계의 주인이다.

    @Entity
    @Data
    public class Member { 
    	@Id @GeneratedValue
    	@Column(name = "member_id")
    	private Long id;
    
    	private String name;
    
    	@OneToOne(fetch = FetchType.LAZY) // 연관관계 주인
    	@JoinColumn(name = "wallet_id")
    	private Wallet wallet;
    }
    
    @Entity
    @Data
    public class Wallet {
    
    	@Id @GeneratedValue
    	@Column(name = "wallet_id")
    	private Long id;
    
    	private String amount;
    
    	@OneToOne(mappedBy = "wallet", fetch = FetchType.LAZY) // 연관관계 주인이 아님
    	private Member member;
    }

     

    연관관계이 주인인 Member를 조회해보자.

    Member member = em.createQuery("select m from Member m "
    			+ "where m.id = :id"
    			, Member.class)
    			.setParameter("id", mId)
    			.getSingleResult();

     

    그러면 다음과 같이 무리없이 Lazy Loading으로 Member만 조회가 되는 것을 볼 수 있고 Wallet 객체가 프록시 객체에 감싸져 있는 것을 확인할 수 있다.

     

     

     

    그런데 연관관계의 주인이 아닌 Wallet을 조회해보자.

    Wallet wallet = em.createQuery("select w from Wallet w "+
    			"where w.id = :id"
    			, Wallet.class)
    			.setParameter("id" , wId)
    			.getSingleResult();

     

    다음과 같이 Lazy Loading이 먹히지 않고 Eager 전략으로 연관되어 있는 Member의 값도 조회하는 것을 볼 수 있다. 그리고 가져온 Member 객체도 진짜 객체에 담아져 있는 것을 확인할 수 있다.

     

     

     

    해결방법

    따라서 JPA, Hibernate는 기본적은 OneToOne 관계에서 LAZY를 허용하지 않고 EAGER로 값을 즉시 불러온다. @OneToOne에서 양방향 매핑으로 LAZY 전략을 사용하고 싶다면 byte code instrument을 이용하는 방법이 있다. 이 방법에서도 CTW(compile time weaver), LTW(load time weaver) 두 가지 방식으로 나뉜다. 그리고 Hibernate의 내부에 이미 구현된 FieldHandler를 사용하는 방법이 있다. 자세한 방법은 다음 블로그의 글을 참고하였다.

     

    또 다른 해결방법

    @OneToOne에서 양방향 매핑 LAZY 전략을 가져오기는 간단하지 않다. 그래서 이러한 설계가 최선인지도 다시 한번 생각해 볼 필요도 있다.

    1. 구조 변경하기
      • 양방향 매핑이 반드시 필요한 상황인지 다시 한번 생각해본다.
      • OneToMany 또는 ManyToOne 관계로 변경이 가능한지 생각해본다.
    2. 구조를 유지한채 해결하기
      • 페치 조인으로 한 번에 조회한다.
      • batch fetch size를 사용한다.

     

    @OneToMany나 @ManyToOne에서는 왜 LAZY가 동작할까

    @ManyToOne이나 @OneToMany에서는 One에 해당하는 컬럼이 Null이어도 LAZY가 가능하다.

    • Many측을 조회할 때는 컬렉션으로 조회되므로 size=0으로 빈 값을 표현할 수 있다.

     

    이유는 간단하다. DB에서는 기본적으로 Many측에서 상대방 FK를 지니고 있기 때문이다. (연관관계 매핑 주인도 Many측에 하는 이유도 이와 같다.) 그래서 굳이 연관된 엔티티를 직접 조회하지 않아도 FK값의 여부를 판단하면 간접적으로 null인지 아닌지 확인할 수 있다. 따라서 큰 문제없이 연관된 엔티티 값이 null이라면 상황에 알맞게 NPE를 던질 수 있다.

     

     

     


    참고

    자바 ORM 표준 JPA 프로그래밍

    https://docs.jboss.org/hibernate/orm/5.3/userguide/html_single/chapters/pc/BytecodeEnhancement.html

    https://stackoverflow.com/questions/26601032/default-fetch-type-for-one-to-one-many-to-one-and-one-to-many-in-hibernate/42168093#42168093

    https://stackoverflow.com/questions/17987638/hibernate-one-to-one-lazy-loading-optional-false