FetchType.LAZY와 Proxy, 그리고 Spring의 Transaction 처리

    오늘 할 것.

    앱센터 활동으로 학기 중 약소하게나마 ToDo List 프로젝트를 진행하고 있다.

    현재 JWT를 이용한 로그인 기능을 개발중인데 다른 바보같은 실수들도 많았지만

    그 중에서도 알고도 틀린 에러를 소개하려고 한다.

     

    LazyInitializationException

    그래. 어디서 많이 봐서 이젠 익숙하다 못해 지겨울 지경이다.

    아직까지 직접 마주한 적은 없었는데 굳이굳이 틀리며 또 배우게 되는구나.

    이번 글에선 LazyInitializationException이 발생한 이유와 javax.persistence의 enum 클래스,

    그 중에도 FetchType.LAZY와 Proxy에 대해 알아본다.

     

    현재 상황

    우선 로그를 보자.

    org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: com.example.todo.user.User.roles, could not initialize proxy - no Session
    	at org.hibernate.collection.internal.AbstractPersistentCollection.throwLazyInitializationException(AbstractPersistentCollection.java:614)
    	at org.hibernate.collection.internal.AbstractPersistentCollection.withTemporarySessionIfNeeded(AbstractPersistentCollection.java:218)
    	at org.hibernate.collection.internal.AbstractPersistentCollection.initialize(AbstractPersistentCollection.java:591)
    	at org.hibernate.collection.internal.AbstractPersistentCollection.read(AbstractPersistentCollection.java:149)
    	at org.hibernate.collection.internal.PersistentBag.iterator(PersistentBag.java:387)
    	at java.base/java.util.Spliterators$IteratorSpliterator.estimateSize(Spliterators.java:1821)
    	at java.base/java.util.Spliterator.getExactSizeIfKnown(Spliterator.java:408)
    	at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:483)
    	at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:474)
    	at java.base/java.util.stream.ReduceOps$ReduceOp.evaluateSequential(ReduceOps.java:913)
    	at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
    	at java.base/java.util.stream.ReferencePipeline.collect(ReferencePipeline.java:578)
    	at com.example.todo.security.CustomUserDetails.getAuthorities(CustomUserDetails.java:23)
    	at com.example.todo.security.JwtProvider.getAuthentication(JwtProvider.java:64)
    	at com.example.todo.security.JwtAuthenticationFilter.doFilterInternal(JwtAuthenticationFilter.java:29)
    	at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117)
    	at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:346)

    User 엔티티가 가진 roles의 lazily initailize에 실패했다고 하고, Session이 유지되지 않았기 때문에 Proxy 객체를 초기화할 수 없단다.

    JwtAuthenticationFilter를 처리하던 중 JwtProvider에서 User 정보를 조회하여 UserDetails를 구성하고

    UserDetails를 통해 User의 roles를 wrapping한 Authorities를 꺼내오는 과정에서 문제가 발생한다.

     

    소스코드를 살펴보자면 사용자 User와 권한을 의미하는 Authority 엔티티가 일대다 매핑, FetchType.LAZY로 설정되어 있다.

    Github 레포지토리

    @Entity
    @Getter
    @NoArgsConstructor(access = AccessLevel.PROTECTED)
    @AllArgsConstructor
    public class Authority {
    
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        @Column(name="authority_id")
        private Long authorityId;
    
        private String name;
    
        @JoinColumn(name="user")
        @ManyToOne(fetch = FetchType.LAZY)
        @JsonIgnore
        private User user;
    
        public Authority(String name) {
            this.name = name;
        }
    
        public void assignUser(User user) { this.user = user; }
    }
    @Table(name = "user_tb")
    @Entity
    @Getter
    @NoArgsConstructor(access = AccessLevel.PROTECTED)
    public class User {
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        @Column(name = "user_id")
        private Long userId;
    
        @Column(name = "login_id")
        private String loginId;
    
        private String password;
    
        private String name;
    
        @OneToMany(mappedBy="user", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
        private List<Task> tasks = new ArrayList<>();
    
        @OneToMany(mappedBy = "user", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
        private List<Authority> roles = new ArrayList<>();
    
        ...
    }

     

    JwtProvider 클래스의 getAuthentication() 메소드는 다음과 같다.

    토큰에서 계정명 정보를 가져오고 이를 인자로 UserDetailService의 loadUserByUsername()을 호출하여 UserDetail 클래스의 인스턴스를 만든다.

    JwtProvider

    해당 메소드를 살펴보면 주입받은 UserRepository를 이용해 계정명으로 User 엔티티를 조회.

    CustomUserDetails 클래스의 인스턴스를 생성한다.

    UserDetailService 구현체

     

    getAuthorities() 메소드를 살펴보자.

    이곳에선 User의 roles를 필드(Authority 엔티티와 일대다 매핑)를 꺼내와 Authentication 객체를 만들기위해,

    리스트 요소 Authority의 정보로 각각의 SimpleGrantedAuthority를 만든다.

    즉, List<Authority>를 List<SimpleGrantedAuthority>로 만들어 반환한다.

    UserDetails 구현체

     

    Proxy는 어디서 나왔을까?

    Hibernate의 연관관계 매핑 시 FetchType을 LAZY로 설정하면 해당 엔티티를 조회할 때,

    LAZY로 설정된 필드(연관관계가 맺어져있는 엔티티)는 곧 바로 조회해오지 않는다.

     

    이와는 반대로 FetchType.EAGER를 사용하면 항상 함께 조회해오게 되는데,

    이 경우 해당 필드의 내용이 필요하지 않은 경우에도 조회를 위한 쿼리가 발생하기 때문에 비효율적일 수 있다.

     

    이런 문제를 해소하기 위해 FetchType.LAZY는 해당 필드가 실제로 쓰일 경우에만 쿼리를 날려서 값을 채운다.

    (좀 더 정확히는 Proxy 객체는 엔티티에 대한 참조를 제공하며, 필요한 순간에 데이터를 로딩하여 제공한다.)

    그럼 그 전까지는 Null이냐? 

    그건 또 아니다. Null은 문제의 소지가 많다. 

    Spring은 이러한 문제를 Proxy 객체를 통해 해결한다.

    실제 값이 들어있진 않지만 Null은 아니기 때문에 NullPointerException과 같은 런타임 에러를 해결하는 방안이다.

     

    다음은 Hibernate 공식 가이드에서 발췌한 내용이다.

    A proxy is an object that masquerades as a real entity or collection, but doesn’t actually hold any state, because that state has not yet been fetched from the database. When you call a method of the proxy, Hibernate will detect the call and fetch the state from the database before allowing the invocation to proceed to the real entity object or collection.

    프록시는 실제 엔티티를 마스커레이드, 즉 자기가 실제 엔티티인 것처럼 가장하는 객체이다.

    다만, 아직까지 state(상태)가 데이터베이스로부터 fetch되지 않았기 때문에 실제론 아무런 state를 지니지 않는다.

    프록시의 메소드를 호출할 때, Hibernate는 이를 감지하여 데이터베이스로부터 state를 fetch한다.

    재밌는 점은 이렇게 fetch해서 사용하게 되는 객체도 결국 Proxy라는 것이다.

     

    더 자세히 들어가보자.

     

    왜 초기화가 안 될까?

    여기까지 글을 읽고계신 독자분들은 이제, 왜 Proxy 객체의 초기화가 안 되는지 궁금해 할 것이다.

    그리고 위에서 Session은 무엇을 의미할까?

     

    아. 이 글을 읽고있는 몇몇 분들은 '그럼 FetchType.EAGER를 쓰지 뭐.' 라고 생각하실수도 있겠다.

    음... 우선, FetchType.EAGER는 절대 좋은 생각이 아니다.

     

    Hibernate의 공식 가이드에는 다음과 같은 내용도 있다.

    2. A round trip to the database to fetch the state of a single entity instance is just about the least efficient way to access data. It almost inevitably leads to the infamous N+1 selects problem we’ll discuss later when we talk about how to optimize association fetching.

    여기서 rount trip이란 FetchType.EAGER를 설정해 하나의 엔티티 인스턴스의 정보를 fetch할 때,

    모든 연관된 다른 엔티티들의 정보를 fetch하기 위해 데이터베이스를 순회하는 것을 말한다.

     

    그리고 다음과 같은 내용이 뒷 따른다.

    We’re getting a bit ahead of ourselves here, but let’s quickly mention the general strategy we recommend to navigate past these gotchas:

    1. All associations should be set fetch=LAZY to avoid fetching extra data when it’s not needed. As we mentioned earlier, this setting is not the default for @ManyToOne associations, and must be specified explicitly.
    2. But strive to avoid writing code which triggers lazy fetching. Instead, fetch all the data you’ll need upfront at the beginning of a unit of work, using one of the techniques described in Association fetching, usually, using join fetch in HQL or an EntityGraph.

    기본적으로 필요없는 상황에서 추가적인 데이터의 fetch를 방지하기 위해 모든 연관관계를 fetch=LAZY로 설정하길 추천한다.

    만일 작업의 시작에 모든 데이터가 꼭 필요하다면 Association fetching에 기술된 테크닉을 사용하라고 한다.

    (이번 포스팅에서는 다루지 않는다.)

     

    예외의 발생원인은?

    자, 여기서부터 재미난 부분이니 좀 더 집중해보자.

    LazyInitializationException의 발생원인을 이해하고 이를 해결하려면 먼저 Hibernate가 엔티티를 어떻게 조회해오는지, 그리고 트랜잭션의 개념을 이해해야한다.

     

    Hibernate가 데이터베이스로부터 엔티티를 조회할 때는 세션 단위로 생각한다.

    세션을 열고(영속성 컨텍스트가 열리면) 데이터를 조회할 쿼리를 날리고, 데이터를 가져온다.

    그리고 엔티티의 형태로 변경한다. 조회가 끝나면 세션도 종료되고 영속성 컨텍스트 또한 닫힌다.

     

    우리는 이 부분에 집중해야한다.

    영속성 컨텍스트가 닫히면 더 이상 쿼리를 날려 Proxy 객체의 State를 Fetch 하기 위해 쿼리를 추가할 수 없다.

    (엔티티를 관리하던 Persistent Context가 이미 종료됐다!)

    따라서 더이상 Proxy를 Fetch 할 수 없는 상태가 되고 LazyInitializationException이 발생한다.

    (EntitiyManager와 Persistence Context에 대한 얘기는 추후 기회가 되면 분리된 글로 작성하겠다.)

    아래는 Hibernate 공식 가이드에서 발췌했다.

    1. Hibernate will only do this for an entity which is currently associated with a persistence context. Once the session ends, and the persistence context is cleaned up, the proxy is no longer fetchable, and instead its methods throw the hated LazyInitializationException.

    Hibernate는 현재 영속성 컨텍스트로 유지되고 있는 엔티티에 대해서만 Lazily Fetch를 수행할 수 있다.

    세션이 종료되고 영속성 컨텍스트가 클린-업되면 프록시는 더 이상 fetch 할 수 없는 상태가 된다. 

    따라서 세션이 종료되고 프록시의 메소드를 호출하게 되면 LazyInitializationException이 발생하게 되는 것이다.

     

     

    이렇게하면 되겠네!

    이제 문제에 대한 답은 간단해 보인다.

    그저 영속성 컨텍스트가 더 오랫동안(Lazily Fetch가 이루어지는) 유지되게 하면 되는게 아닌가?

    그래, @Transactional을 쓰자.

    메소드 단위로 트랜잭션의 범위를 명시적으로 지정하여 해당 메소드가 호출될 때 트랜잭션이 시작되고 메소드의 반환 시까지 트랜잭션이 유지된다. 

    결국 userRepository의 findByLoginId() 메소드는 loadUserByUsername()의 내부에서 호출되기 때문에 트랜잭션이 전파되는 범위 안에 존재한다.

    이로써 해당 기간동안 영속성 컨텍스트가 살아있으므로 Lazy Fetch가 가능하다.

     

    또 다른 방법.

    아래 글에서는 이를 해결하기 위해 JPQL을 이용하여 처음 조회 시, 필요한 모든 State를 Fetch하는 방식을 취한다.

    https://www.linkedin.com/pulse/hibernate-lazy-initialization-exception-spring-sara-alhosin/

     

    관련된 내용은 Hibernate의 공식 가이드의 다음 부분을 확인하자.

    https://docs.jboss.org/hibernate/orm/6.4/introduction/html_single/Hibernate_Introduction.html#association-fetching

    https://docs.jboss.org/hibernate/orm/6.4/introduction/html_single/Hibernate_Introduction.html#join-fetch

     

     

    결론.

    결론은 강력하고 단순한 정책으로 귀결된다.

    FetchType.EAGER는 끔찍하다. -> 개발 과정이 아니라면 쓰지마라. -> FetchType.LAZY를 명시적으로 지정하라.

    N+1 문제가 걱정되는 부분이나, 성능 상 EAGER가 필요한 경우에는 Association Fetching(주로 Join Fetch)을 수행하자.

     

    다만, 우리는 고민해야한다.

    1. @Transactional의 사용

    2. Association Fetching

    해당 메소드가 호출되는 컨텍스트를 고려하여 성능상 이점과 편리함, 트랜잭션이 문제를 일으킬 가능성 등 다양한 관점에서 비교하고 선택하는 것은 프로그래머의 책임이다.

     

    Reference.

    JPQL, Fetch Join 사용하여 해결하는 방법.

    https://www.linkedin.com/pulse/hibernate-lazy-initialization-exception-spring-sara-alhosin/

    마지막으로, 이번 글에서 계속해서 참조한 Hibernate의 공식 가이드 링크를 첨부한다.

    https://docs.jboss.org/hibernate/orm/6.4/introduction/html_single/Hibernate_Introduction.html#proxies-and-lazy-fetching

     

    댓글