
우테코 오픈 미션 개발을 마친 뒤 Postman으로 API 테스트를 하던 중 예상치 못한 500 Internal Server Error가 발생했다.
Postman에서는 단순히 500만 표시될 뿐 원인을 알 수 없었기 때문에 서버 로그를 직접 확인했는데, 그 안에는 LazyInitializationException이라는 낯선 오류가 찍혀 있었다.
“이게 왜 터진 걸까?”라는 궁금증이 생겼고 이를 이해하기 위해 JPA의 동작 원리를 깊게 파고들었다.
이 글은 그 과정에서 내가 정리한 공부 기록이자 회고이다.
개발이 끝나고 도서 단건 조회 API(/api/books/{id})를 호출했을 때 Postman에서 갑자기 500 Internal Server Error가 떨어졌다.
처음에는 단순한 DTO 변환 문제나 JSON 직렬화 과정에서의 오류라고 생각했다.
하지만 응답에는 에러 메시지도 설명도 없었고 단순히 500만 표시되었기 때문에 원인을 전혀 알 수 없었다.
이 찝찝함을 해결하기 위해 서버 로그를 직접 확인해 보기로 결정했다.
IntelliJ Run 탭에서 서버 로그를 확인하자 엄청나게 긴 스택트레이스가 출력되었다.
그중에서도 시선을 사로잡은 문장은 아래 한 줄이었다.
org.hibernate.LazyInitializationException: failed to lazily initialize a collection ... no Session
그리고 그 아래에는 정확히 어떤 부분에서 문제가 발생했는지 위치까지 찍혀 있었다.
at smiinii.object_oriented_library.dto.book.BookResponse.from(BookResponse.java:24)
at smiinii.object_oriented_library.controller.BookController.getBook(BookController.java:44)
이 두 줄을 보고 처음으로 상황이 명확해지기 시작했다.
즉, 컨트롤러 계층에서 엔티티의 LAZY 필드에 접근했기 때문에 터진 오류였다.
그렇다면 자연스럽게 다음 질문이 생겼다.
“컨트롤러에서는 왜 세션이 닫힌 상태였을까?”
“트랜잭션은 어디서 끝나는 걸까?”
이를 이해하기 위해 JPA 트랜잭션과 LAZY 동작 방식을 다시 정리해 보았다.
내 서비스 메서드는 기본적으로 @Transactional(readOnly = true)로 감싸져 있었다.
public Book getBook(Long id) {
return bookRepository.findById(id).orElseThrow(...);
}
서비스 메서드가 종료되면 트랜잭션도 함께 종료되고 JPA의 영속성 컨텍스트(DB 세션)도 함께 닫힌다.
Book 엔티티의 소장본 목록은 이렇게 설정해 두었다.
@OneToMany(..., fetch = FetchType.LAZY)
private List<StoredBook> storedBooks;
즉, Book을 조회할 때는 storedBooks까지 가져오지 않고
나중에 book.getStoredBooks().size()같은 메서드를 호출하는 순간 DB에 접근한다.
문제가 된 코드는 다음과 같았다.
@GetMapping("/{id}")
public ResponseEntity<BookResponse> getBook(@PathVariable Long id) {
Book book = bookService.getBook(id); // 여기서 트랜잭션 끝남
return ResponseEntity.ok(BookResponse.from(book)); // Lazy 접근 발생
}
public static BookResponse from(Book book) {
return new BookResponse(
book.getId(),
book.getTitle(),
book.getAuthor(),
book.getStoredBooks().size() // ← DB 접근 필요 → 세션 없음 → 예외 발생
);
}
LazyInitializationException 발생문제의 근본 원인은 트랜잭션 밖(컨트롤러)에서 지연 로딩 필드에 접근했다는 점이었다.
재미있게도 BookResponse.from(book) 내부에서
book.getId() / book.getTitle() / book.getAuthor()는 정상 작동했다
이유는 간단하다.
id, title, author 같은 필드는 엔티티 자체의 컬럼 값이라 Book을 조회하는 시점에 이미 DB에서 함께 가져온 상태다.storedBooks는 @OneToMany(fetch = FetchType.LAZY)로 매핑된 연관 컬렉션이라 Book을 조회할 때 실제 데이터 대신 “나중에 필요하면 불러오겠다”는 프록시만 들고 있다.그래서 컨트롤러에서 BookResponse.from(book)을 호출하면
book.getId(); // 이미 메모리에 있는 값
book.getTitle(); // 이미 메모리에 있는 값
book.getAuthor(); // 이미 메모리에 있는 값
book.getStoredBooks().size(); // ← 이때 처음으로 DB 접근 필요
이 마지막 줄에서 Hibernate는 “아, 이제 진짜 storedBooks가 필요하구나. 그럼 DB에서 조회할게.”라고 시도하지만
그 순간에는 이미 트랜잭션과 세션이 종료된 상태라 DB에 접근할 수 없다.
그래서 LazyInitializationException이 발생한다.
@Transactional(readOnly = true)
public Book getBook(Long bookId) {
return bookRepository.findById(bookId)
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 도서입니다."));
}
@GetMapping("/{id}")
public ResponseEntity<BookResponse> getBook(@PathVariable Long id) {
Book book = bookService.getBook(id);
return ResponseEntity.ok(BookResponse.from(book));
}
컨트롤러에서 Lazy 접근 → LazyInitializationException
@Transactional(readOnly = true)
public BookResponse getBook(Long bookId) {
Book book = bookRepository.findById(bookId)
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 도서입니다."));
return BookResponse.from(book); // 트랜잭션 안에서 Lazy 해결 가능
}
@GetMapping("/{id}")
public ResponseEntity<BookResponse> getBook(@PathVariable Long id) {
return ResponseEntity.ok(bookService.getBook(id)); // DTO만 다룸
}
이번 오류는 단순한 버그 해결이 아니라 내가 지금까지 공부해 온 영속성 컨텍스트, 지연 로딩, 연관관계 개념이 실제 코드 흐름에서 어떻게 작동하는지 처음으로 명확하게 체감한 순간이었다.
오픈미션 기간 동안 아래 글들을 통해 이론은 여러 번 공부했었다.
하지만 이번 오류를 만나기 전까지는 솔직히 “왜 서비스 밖(컨트롤러)에서 엔티티를 사용하면 안 되는지” 그 이유를 완전히 이해하지 못했다.
이번 경험을 통해 나는 짧지만 확실한 두 가지를 배웠다.
1) 트랜잭션이 끝나면 영속성 컨텍스트도 함께 사라진다.
그동안 머리로만 이해했던 문장이 실제 코드 흐름과 함께 하나로 연결됐다.
트랜잭션 밖에서 Lazy 필드에 접근하면 당연히 터질 수밖에 없다는 것.
2) “엔티티는 컨트롤러까지 절대 가져가지 말라”는 조언의 진짜 이유
이제는 그저 규칙이 아니라 지연 로딩·트랜잭션·계층 구조가 모두 맞물리는 설계 원칙이라는 걸 확실히 알게 됐다.
이번 경험은 단순히 한 번의 예외를 해결하는 것이 아니라 JPA의 내부 동작과 계층 구조의 역할을 몸으로 이해하게 만든 중요한 계기였다.
앞으로는 엔티티의 생명 주기와 트랜잭션 경계를 더 신중하게 바라보며
서비스와 컨트롤러가 맡아야 할 책임을 명확히 구분하는 설계를 지속해서 실천하고자 한다.
작은 오류 하나가 나의 설계 관점 전체를 흔든 경험이었고 이 깨달음은 앞으로의 개발 과정에서 큰 자산이 될 것 같다.
LazyInitializationException 왜 발생하고, 어떻게 해결할까🧐
orghibernatelazyinitializationexception-could-not-initialize-proxy 에러 해결 방법