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에서 값을 꺼내야 하는 시점에 세션이 없어 예외가 터진 것이다.
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 객체 접근 -> 세션 닫힘 -> 예외
}
@DisplayName("AI 질문, 선택지와 답변을 저장할 수 있다.")
@Test
@Transactional
void saveAiUserAnswer() {
...
savedAnswers.get(0).getAiAnswerOption().getContent(); // 정상 작동
}
@Transactional
을 붙이면 Spring이 테스트 메서드를 하나의 트랜잭션으로 감싸고 해당 트랜잭션이 끝날 때까지 세션을 유지해준다. 이 방법은 테스트 시 가장 간편하고 일반적으로 추천되는 방식이다.
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 join
은 LazyInitializationException
을 완전히 막는 해결책은 아니라서 보통은 @Transactional
과 함께 사용해야 안전하다.테스트 환경에서 우연히 세션이 열려 있어 통과되는 것처럼 보여도 실무에서는 예외가 발생할 수 있으므로 주의해야 한다.
서비스 단에서 Entity
가 아닌 DTO
로 변환해 필요한 필드만 미리 꺼낸 뒤 테스트하거나 반환하는 방식이다. 이 방법은 실무에서도 Controller -> Service -> DTO
흐름이 명확할 때 선호되지만 테스트만을 위한 목적이라면 오버엔지니어링이 될 수도 있다.
이번 테스트에서는 단순히 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
을 사용해 데이터를 미리 로딩해오는 방식이 필요하다는 걸 알게 되었다. 앞으로는 단순 저장 테스트라도 세션 관리나 연관 관계 초기화 여부를 고려해야겠다고 느꼈다.