JPA LazyInitializationException 발생한 이유와 해결 방법

송현진·2025년 5월 21일
0

Jpa

목록 보기
10/11

문제 상황

UserAnswerServiceTest 클래스의 테스트 메서드 중 하나인 saveAiUserAnswer()를 작성하던 중 org.hibernate.LazyInitializationException 이라는 예외를 마주했다.

org.hibernate.LazyInitializationException: 
could not initialize proxy [com.example.giftrecommender.domain.entity.answer_option.AiAnswerOption#2] - no Session

문제가 된 테스트는 다음과 같다.

@DisplayName("AI 질문, 선택지와 답변을 저장할 수 있다.")
@Test
void saveAiUserAnswer() {
    // given
    ...
    // when
    userAnswerService.saveAiQuestionAndAnswer(...);

    // then
    List<UserAnswer> savedAnswers = userAnswerRepository.findAll();
    savedAnswers.get(0).getAiAnswerOption().getContent(); // 여기서 LazyInitializationException 발생
}

위 코드는 UserAnswer가 참조하는 AiAnswerOption 엔티티를 LAZY 로딩 방식으로 매핑해두었고 테스트 메서드는 기본적으로 트랜잭션 없이 실행되므로 DB 세션은 이미 닫혀 있는 상태였다. 즉, UserAnswer.getAiAnswerOption()은 Hibernate가 프록시 객체로 들고 있지만 실제 DB에서 값을 꺼내야 하는 시점에 세션이 없어 예외가 터진 것이다.

❓LazyInitializationException이란?

JPA에서 @ManyToOne(fetch = FetchType.LAZY) 또는 @OneToMany(fetch = FetchType.LAZY) 같은 지연 로딩 필드를 사용할 경우 해당 연관 객체는 처음부터 로딩되는 것이 아니라 필요한 시점에 SQL을 추가로 실행해서 가져온다. 이때 중요한 전제 조건은 "엔티티가 영속성 컨텍스트 안에 있을 때만 Lazy 로딩이 가능하다"는 것이다. 즉, 세션이 닫힌 상태에서 Lazy 필드에 접근하면 다음과 같은 예외가 발생한다

org.hibernate.LazyInitializationException: could not initialize proxy - no Session

왜 테스트에서 이 문제가 자주 발생할까?

Spring Boot에서 일반적인 단위 테스트(@Test)는 기본적으로 트랜잭션 경계를 열지 않는다. 따라서 아래처럼 작성된 테스트는 메서드 실행이 끝난 시점에는 DB 세션도 자동으로 닫혀 있다.

@DisplayName("AI 질문, 선택지와 답변을 저장할 수 있다.")
@Test
void saveAiUserAnswer() {
    List<User> users = userRepository.findAll(); // OK
    users.get(0).getOrders().size(); // Lazy 객체 접근 -> 세션 닫힘 -> 예외
}

해결 방법

방법 1. @Transactional을 테스트 메서드에 붙인다.

@DisplayName("AI 질문, 선택지와 답변을 저장할 수 있다.")
@Test
@Transactional
void saveAiUserAnswer() {
    ...
    savedAnswers.get(0).getAiAnswerOption().getContent(); // 정상 작동
}

@Transactional을 붙이면 Spring이 테스트 메서드를 하나의 트랜잭션으로 감싸고 해당 트랜잭션이 끝날 때까지 세션을 유지해준다. 이 방법은 테스트 시 가장 간편하고 일반적으로 추천되는 방식이다.

방법 2. fetch Lazy 객체를 미리 로딩하기

Repository에 쿼리를 별도로 정의해서 지연 로딩 객체를 미리 초기화해 가져오는 방법이다.

@Query("SELECT ua FROM UserAnswer ua JOIN FETCH ua.aiAnswerOption")
List<UserAnswer> findAllWithAiOption();

이후 테스트에서 해당 쿼리를 호출하면 Lazy가 아닌 EAGER처럼 사용 가능하다.

@DisplayName("AI 질문, 선택지와 답변을 저장할 수 있다.")
@Test
void saveAiUserAnswer() {
    ...
    List<UserAnswer> saved = userAnswerRepository.findAllWithAiOption();
    savedAnswers.get(0).getAiAnswerOption().getContent(); // 정상 작동
}

fetch join은 지연 로딩된 연관 객체를 즉시 로딩(EAGER)처럼 미리 함께 조회하는 방식이다. 다만 fetch join은 데이터 조회 시점에 초기화만 보장하며 이후 해당 객체를 접근할 때도 여전히 세션이 살아 있어야 Lazy 로딩이 완전히 동작한다. 즉, fetch joinLazyInitializationException을 완전히 막는 해결책은 아니라서 보통은 @Transactional과 함께 사용해야 안전하다.테스트 환경에서 우연히 세션이 열려 있어 통과되는 것처럼 보여도 실무에서는 예외가 발생할 수 있으므로 주의해야 한다.

방법 2. DTO로 변환해서 반환하기

서비스 단에서 Entity가 아닌 DTO로 변환해 필요한 필드만 미리 꺼낸 뒤 테스트하거나 반환하는 방식이다. 이 방법은 실무에서도 Controller -> Service -> DTO 흐름이 명확할 때 선호되지만 테스트만을 위한 목적이라면 오버엔지니어링이 될 수도 있다.

내가 선택한 방법: @Transactional

이번 테스트에서는 단순히 Lazy 객체에 접근해서 필드를 확인하고자 하는 목적이기 때문에 @Transactional을 테스트 메서드에 붙여서 해결했다.

@DisplayName("AI 질문, 선택지와 답변을 저장할 수 있다.")
@Test
@Transactional
void saveAiUserAnswer() {
    // given
    ...
    // when
    userAnswerService.saveAiQuestionAndAnswer(...);

    // then
    List<UserAnswer> savedAnswers = userAnswerRepository.findAll();
    savedAnswers.get(0).getAiAnswerOption().getContent();
}

LazyInitializationException은 사라졌고 테스트는 정상적으로 통과했다.

📝 배운점

이번 경험을 통해 JPA에서 연관된 데이터를 LAZY로 설정하면 데이터를 실제로 꺼내는 시점에 DB 연결(Session)이 필요하다는 걸 다시 느꼈다. 테스트에서는 트랜잭션을 따로 열어주지 않으면 세션이 닫혀 있어서 연관 데이터를 꺼내려 할 때 에러가 나는 걸 직접 확인했다. 특히 반환값(Response)이 없는 메서드의 경우 테스트에서 Lazy 필드에 접근해야 한다면 @Transactional을 붙이거나 fetch join을 사용해 데이터를 미리 로딩해오는 방식이 필요하다는 걸 알게 되었다. 앞으로는 단순 저장 테스트라도 세션 관리나 연관 관계 초기화 여부를 고려해야겠다고 느꼈다.

profile
개발자가 되고 싶은 취준생

0개의 댓글