Spring boot, JPA could not initialize proxy - no Session
closing the session or not keeping the session open to fetch the LAZY loaded objects.
Sep 24, 2024
Contents
문제: Fetch TypeSpring Data JPA를 사용하다 보면 엔티티 간의 관계 설정에서 종종 에러를 마주치게 됩니다. 특히 Lazy Loading을 사용할 때 발생하는 'could not initialize proxy - no Session' 에러는 JPA에 익숙하지 않은 사람들을 곤혹스럽게 만듭니다. 이 글에서는 이 에러의 원인과 다양한 해결 방법을 살펴보겠습니다.
문제: Fetch Type
@ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "customer_id") private Customer customer;
JPA(Java Persistence API)는 엔티티 간의 관계를 정의할 때, 연관된 엔티티를 어떻게 로드할지를 결정하는 설정으로 fetch를 사용합니다.
FetchType은 2가지 enum을 가지고 있는데, LAZY와 EAGER입니다.
- Lazy (지연 로딩)
- Lazy 이 설정은 연관된 엔티티를 실제로 필요할 때까지 로드하지 않도록 합니다. 즉, 부모 엔티티를 조회할 때 자식 엔티티는 즉시 로드되지 않고, 해당 자식 엔티티에 접근할 때 쿼리가 실행되어 로드됩니다.
- 즉, 필요할 때만 로드합니다.
- 간혹가다 Lazy loading not working 이라는 키워드를 발견할 수 있는데, 제목과 같은 에러가 발생했다면 Lazy loading이 적용 중인 걸로 생각해주시면 됩니다.
- Eager (즉시 로딩)
- 이 설정은 부모 엔티티를 조회할 때 연관된 자식 엔티티도 즉시 로드하도록 합니다. 즉, 부모 엔티티를 가져오는 쿼리와 함께 자식 엔티티를 가져오는 쿼리가 동시에 실행됩니다.
- 연관된 데이터를 항상 필요로 할 때 유용하지만, 불필요한 데이터 로드를 초래할 수 있어 성능 저하를 일으킬 수 있습니다.
- 즉, 항상 로드합니다.
Eager 방식을 사용하면 즉시 참조 관계에 있는 엔티티를 불러와 쉽게 이용할 수 있지만 JPQL을 사용하면 1+N 문제가 발생할 수 있습니다.
JPQL ?
JPQL이란 Java Persistence Query Language의 약자로, Java EE 및 Java SE 환경에서 객체 관계 매핑(ORM) 기술을 사용하는 애플리케이션에서 데이터베이스 쿼리를 작성하기 위한 언어입니다. JPQL은 SQL과 유사하지만, 데이터베이스의 테이블이 아닌 Java 객체를 대상으로 쿼리를 작성합니다.
실제 사용은 아래와 같습니다.
@Query("UPDATE Email AS e SET e.isVerified = 1 WHERE e.email = :email")
EntityManager em = emf.createEntityManager(); String jpql = "SELECT e FROM Employee e WHERE e.department = :dept"; TypedQuery<Employee> query = em.createQuery(jpql, Employee.class); query.setParameter("dept", department); return query.getResultList();
JPA에서 repository.find, findAll, 또는 entityManager.find 함수를 호출하면 JPA는 내부적으로 최적화를 진행하여 Join 쿼리를 수행합니다. 하지만, JPQL을 사용할 때 1+N 문제가 발생할 수 있습니다.
이를 해결하기 위해 지연 로딩(Lazy Loading)을 사용하는데, 이때 could not initialize proxy - no Session 에러가 발생할 수 있습니다. 이 에러는 세션이 없어서 프록시 객체를 초기화할 수 없다는 의미입니다.
주된 원인은 참조 관계에 있는 객체를 불러올 때, 엔티티의 영속성 컨텍스트가 이미 닫혀있어 참조 데이터를 가져올 세션이 없기 때문입니다. Lazy Loading은 필요한 값만 불러오기 위해 지정하지만, 참조 관계에 있는 값을 가져오려 할 때 세션이 닫혀있으면 에러가 발생하게 됩니다.
해결 방법
위 문제를 해결하기 위해 5가지 해결책이 있습니다.
- Eager 사용
1+N 문제와 불 필요한 성능 저하나 메모리 문제를 감수하고 즉시 로딩 전략을 사용하는 것 입니다. 더불어 테이블의 정보 만 가져오고 싶을 때도 항상 참조 관계에 있는 다른 값들을 불러오기 때문에 예상치 못한 동작을 발생시킬 수 있다는 추가적인 단점이 있지만, 간단하게 사용할 수 있습니다.
- @Transactional 사용
- 성능 문제 조회 작업은 일반적으로 트랜잭션을 필요로 하지 않지만, @Transactional을 사용하면 트랜잭션을 시작하고 종료하는 오버헤드가 발생합니다. 이는 성능 저하를 초래할 수 있습니다.
- 데이터 일관성
- 복잡성 증가
- 예외 처리
참조 관계에 있는 값을 불러오지 못하는 것은 영속성 컨텍스트(Session)가 이미 닫혀있기 때문에 발생하는 문제로 @Transactional은 선언된 스코프안에서 살아있기 때문에 상위 레이어에서 Transactional을 선언하거나 해당 메서드 안에서 선언했다면 메서드가 종료될 때 까지 컨텍스트가 살아 있기 때문에 값을 불러옵니다. 다만 다음과 같은 사항들을 고려해야 하기 때문에 추천드리지는 않습니다.
@Transactional(readOnly = true)와 같이 설정할 수 있지만, 이 경우에도 트랜잭션이 시작되므로 데이터베이스의 잠금이나 일관성 문제를 유발할 수 있습니다. 특히, 데이터베이스의 읽기 전용 모드가 아닌 경우, 다른 트랜잭션과의 충돌이 발생할 수 있습니다.
트랜잭션을 관리하는 것은 복잡성을 증가시킬 수 있습니다. 단순한 조회 작업에 트랜잭션을 추가하면 코드의 가독성이 떨어지고, 유지보수가 어려워질 수 있습니다.
@Transactional이 적용된 메서드에서 예외가 발생하면, 해당 트랜잭션이 롤백됩니다. 조회 작업에서 예외가 발생할 경우, 의도치 않게 다른 작업에 영향을 줄 수 있습니다.
- OSIV (Open Session in View)
OSIV는 웹 애플리케이션에서 Hibernate와 같은 ORM 프레임워크를 사용할 때, 데이터베이스 세션을 요청의 전체 생애 주기 동안 유지하는 패턴입니다. HTTP 요청이 시작되면 데이터베이스 세션을 열고, 요청이 끝날 때까지 이를 유지함으로써 여러 번 데이터베이스에 접근할 수 있으며, Lazy 로딩을 사용할 수 있습니다. 하지만, OSIV를 활성화하면 데이터베이스 커넥션을 오랜 시간 점유하게 되어 성능 문제나 커넥션 풀 고갈을 초래할 수 있습니다.
OSIV는
application.yml
파일에서 아래와 같이 설정을 통해 활성화할 수 있습니다:spring.jpa.open-in-view=false
- Entity 객체를 DTO로 변환
엔티티 객체에서 필요한 데이터만 추출하여 DTO(Data Transfer Object)로 변환하는 방식으로, Lazy 로딩과 참조 관계를 사용하지 않기 때문에 오류가 발생하지 않습니다. 아래 코드는 엔티티에서 필요한 필드만 DTO로 변환하는 예시입니다:
User user = userRepository.findById(id) .orElseThrow(() -> new EntityNotFoundException("User not found")); // User 엔티티에서 필요한 데이터만 추출하여 UserDTO로 변환 return new UserDTO(user.getName(), user.getEmail());
- @EntityGraph를 통한 fetch join
- EntityGraph를 사용할 때는 Fetch Join이 아닌, 페이징이 되는 데이터 셋을 먼저 뽑은 후 해당 데이터 셋의 id를 통해 참조관계 데이터를 불러오는 것을 권장합니다.
일반적으로 Join을 사용할 때는 연관된 엔티티를 함께 조회하지 않지만, Fetch Join을 사용하면 연관된 엔티티도 함께 조회됩니다. JPA에서는 JPQL 없이도 @EntityGraph를 사용하여 Fetch Join을 적용할 수 있습니다. JPQL의 Join은 Inner Join으로 참조 관계에 데이터가 없으면 조회되지 않지만, @EntityGraph는 Left Outer Join을 사용하여 참조 관계에 데이터가 없어도 타겟 테이블의 데이터는 조회됩니다.
기본적으로는 Lazy 로딩 전략을 사용하지만, @EntityGraph를 통해 특정 메서드나 클래스에서 필요에 따라 Eager 로딩으로 변경할 수 있습니다. FetchType.LAZY와 FetchType.EAGER는 엔티티 클래스의 메타데이터에 정의되어 있어 컴파일 타임에 결정되며, 런타임에 동적으로 변경할 수 없습니다. 그러나 @EntityGraph를 사용하면 메타데이터에 정의된 Lazy 로딩을 Eager 로딩으로 변경할 수 있습니다.
다만, @EntityGraph와 Fetch Join은 만능이 아닙니다. Fetch Join을 사용한 쿼리 결과를 애플리케이션 메모리에 올려 페이징을 처리하면 OOM(Out Of Memory) 오류가 발생할 수 있으므로, 페이징 처리 시에는 신중하게 사용해야 합니다.
List<Long> ids = findAllbyId(Pageable pageable) List<User> users = findAll(ids)
실제 사용법은 다음과 같습니다.
@OneToMany(mappedBy = "email", cascade = CascadeType.ALL) private List<Email> emails; @ManyToOne(fetch = LAZY) @JoinColumn(name = "user_id") private User user; @EntityGraph(attributePaths = {"emails"}) Page<User> findAll(Specification<User> between, Pageable pageable);
참조 관계에 지정된 값의 key를 사용하여 attributePaths에 지정해주는 것으로 지연 로딩을 즉시 로딩으로 변경시킬 수 있습니다.
- 단순 Fetch Join
- 둘 이상의 컬렉션은 페치할 수 없음
- 페이징 처리
- 페치 조인에서는 별칭 사용을 권장하지 않음
단순 Fetch Join을 통해 참조관계에 있는 데이터를 한 번에 가져와 문제를 해결할 수 있습니다.
// 엔티티 fetch join @Query("SELECT m FROM Member m JOIN m.team t") List<Member> findMembersWithTeam(); // 컬렉션 fetch join @Query("SELECT t FROM Team t JOIN FETCH t.members") List<Team> findTeamWithMembers(); // 여러 관계 fetch @Query("SELECT m FROM Member m JOIN FETCH m.team t JOIN FETCH t.office") List<Member> findMembersWithTeamAndOffice();
물론 한계도 있습니다.
결론
JPA의 Lazy Loading 관련 에러를 해결하는 데에는 여러 가지 방법이 있습니다. 각 방법의 장단점을 이해하고, 프로젝트의 요구사항과 상황에 맞는 적절한 해결책을 성능, 유지보수성, 코드의 복잡도 등을 종합적으로 고려하여 최적의 방법을 선택하는 것이 중요합니다.
Share article