JPA의 LazyInitializationException

IAMCODER·2021년 3월 11일
5

JPA 기초를 배우고 자신감있게 프로젝트를 진행하려고 할 때 가장 막히는 부분은 바로 lazy loading 으로 인한 LazyInitializationException 이다.

구글링을 하면 정말 많은 글이 나오는 유서 깊은 문제이지만 무릎을 탁 치는 명쾌한 해답은 없는 문제이기도 하다.

스택오버플로우든 블로그 글이든 해결책은 3가지로 요약된다.

@Transactional

LazyInitializationException이 발생하는 주요 원인은 JPA에서 관리하는 세션이 종료 된 후(정확하게는 persistence context가 종료 된 후) 관계가 설정된 엔티티를 참조하려고 할 때 발생한다.
이것에서 착안해 DAO레이어(Spring data에서 Repository) 상위에서 세션을 시작해 DAO 계층 밖에서도 세션이 종료되지 않도록 트랜젝션을 거는 방법이다.

@Entity
public class User {

    @Id
    ...

    @OneToMany(fetch = FetchType.LAZY)
    private List<Order> orders;
}

...

@Transactional(readOnly = true)
public List<User> getUsers(String username) {
    var user = this.userRepository.findByUsername(username);

    // @Transactional 없다면 LazyInitializationException 이 발생한다.
    var orders = users.getOrders();
    ...
    return users;
}

비즈니스 로직에서 저장 작업을 위해서는 일반적으로 트랜젝션을 적용하므로 경우에 따라서는 유용한 방법이다. 그러나 조회 기능에서 트랜젝션을 사용할 경우 상황에 따라 재앙 같은 성능 저하로 이어 질 수 있다.
(2022. 05. 17 수정)
잘못된 내용을 바로 잡습니다.
Spring의 트랜젝션은 성능에 직접적인 영향을 미치지 않습니다.
오히려 readOnly 옵션을 true로 설정할 경우 조회 시 JPA 등 ORM에서 제공하는 기능에 따라 성능 최적화의 방법으로 사용될 수 있습니다.

hibernate.enable_lazy_load_no_trans

hibernate.enable_lazy_load_no_trans 설정을 true로 하는 방법을 추천하는 글도 볼 수 있다. 그리고 이 방법을 추천 하는 글 밑에는 해당 방법이 anti pattern 임을 경고하는 댓글이 항상 달리고 이 글의 링크가 붙는다.

해당 옵션은 말 그대로 lazy 로딩 시 LazyInitializationException 발생 할 것 같은 경우에 임시로 db에 연결해 데이터를 가져오도록 하는 옵션이다. Lazy 로드 대상이 늘어 날수록 서버의 db 커넥션 풀은 빈곤해지고 역시 성능 감소로 이어질 수 있다.

Fetch join

DAO 레이어에서 join 으로 관계된 엔티티를 함계 조회한 후 상위 레이어에 전달하는 방법이다. 보통 함께 사용하는 Spring data 만으로는 어렵고 JPQL 이나 QueryDSL 을 사용해야 가능하다.

QueryDSL 을 사용할 경우의 예시이다.

public class UserRepositoryImpl extends QuerydslRepositorySupport ... {
    private final JPAQueryFactory jpaQueryFactory;
    ...
    public List<User> findByUsername(String username) {
        var user = QUser.user;
        var order = QOrder.order;
        return this.jpaQueryFactory.selectFrom(user)
            .join(order).on(order.userId.eq(user.userId))
            //  fetchJoin 메소드로 order 값을 가져오도록 한다.
            .fetchJoin()
            .where(...)
            .fetch();
    }
}

...

public List<User> getUsers(String username) {
    var user = this.userRepository.findByUsername(username);

    // 이미 DAO 레이어에서 orders 를 가져온 상태로 반환 했기 때문에 동작한다.
    var orders = users.getOrders();
    ...
    return users;
}

이 방법은 사용하는 라이브러리와 개인 / 그룹의 취향과 규약에 따라 다양한 방법이 있을 수 있다.

@EntityGraph

마지막 방법은 EntityGraph annotation을 사용하는 것이다. 예시를 보자.
개인적으로 가장 선호하는 방법이다.

@Entity
public class User {

    @Id
    ...

    @OneToMany(fetch = FetchType.LAZY)
    private List<Order> orders;
}

...

public interface UserRepository extends JpaRepository<User, Long> {
    
    // 불러올 데이터의 변수명을 설정해준다. 배열로 한번에 여러 값을 설정 할 수 있다.
    @EntityGraph(attributePaths = { "orders" })
    Optional<List<User>> fetchUsers();
}
...

public List<User> getUsers {
    var user = this.userRepository.findByUsername(username);

    // @EntityGraph 없다면 orders를 불러 올 수 없어 LazyInitializationException 이 발생한다.
    var orders = users.getOrders();
    ...
    return users;
}

다른 사람의 구현을 보거나 서로의 접근 방법을 공유하면서 발전 시켜 나가면 오류 없는 행복한 개발을 할 수 있을 것이다.

2개의 댓글

comment-user-thumbnail
2022년 5월 8일

그러나 조회 기능에서 트랜젝션을 사용할 경우 상황에 따라 재앙 같은 성능 저하로 이어 질 수 있다.
이유 알수있을까요?

1개의 답글