failed to lazily initialize a collection of role: could not initialize proxy - no Session
어느 날 개발을 하다보니 위와 같은 예외가 발생하였다. 메시지를 읽어보니 지연 로딩이 발생했고, 세션이 없는 것이 이유인 것 같았다. 결론은 잘못된 트랜젝션 매니저를 사용한 것이 원인이었는데 이를 찾기 위한 과정을 적어보았다.
해당 상황을 재현하기 위해 간단한 예제를 만들었다. 오래된 책 목록을 조회하여 책의 버전 정보를 로그에 남긴 뒤 삭제하는 간단한 기능이다.
@Entity
@Getter
@AllArgsConstructor
@NoArgsConstructor
@Table(name = "book")
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private LocalDateTime publishedDate;
@OneToMany(mappedBy = "book", cascade = CascadeType.ALL)
private List<Edition> editions;
}
도서 정보 엔티티는 제목과 출판일 속성을 갖는다. 도서에는 여러 판본이 있어 판본 정보 엔티티를 리스트로 갖는다.
@Entity
@Getter
@AllArgsConstructor
@NoArgsConstructor
@Table(name="edition")
public class Edition {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Integer version;
@ManyToOne(fetch = FetchType.LAZY)
private Book book;
}
판본 정보는 버전 정보 속성을 갖고, 도서 엔티티와 연관되어 있다.
도서정보와 판본 정보는 1:N 관계이다.
@Service
@RequiredArgsConstructor
public class ExpireService {
private static final int EXPIRATION_YEAR = 10;
private final BookSearchService bookSearchService;
private final BookDeleteService bookDeleteService;
public void expire(){
LocalDateTime old = LocalDateTime.now().minusYears(EXPIRATION_YEAR);
List<Book> oldBooks = bookSearchService.findOldBook(old);
for (Book oldBook : oldBooks) {
bookDeleteService.deleteBook(oldBook);
}
}
}
ExpireService에서는 10년 이상 지난 책 목록을 조회 한 후 목록을 순회하면서 도서 정보를 삭제한다.
전혀 어려울 것이 없고 오류가 날 부분도 없었는데 문제는 다음의 BookDeleteService에서 발생했다.
@Service
@RequiredArgsConstructor
@Slf4j
public class BookDeleteService {
private final BookRepository repository;
@Transactional
public void deleteBook(Book book){
book = repository.findById(book.getId())
.orElseThrow(EntityNotFoundException::new);
for (Edition edition : book.getEditions()) {
log.info("edition delete. {}", edition.getId());
}
repository.delete(book);
}
}
BookDeleteService의 deleteBook
메소드에서는 인자로 전달받은 도서 객체를 리포지토리에서 다시 조회한 후 해당 도서의 판본 정보를 순회하여 로그를 출력하고 도서 정보를 삭제한다.
그런데 판본정보를 가져오기 위한 book.getEditions()
부분에서 서두에 나온 LazyInitializationException
이 발생하였다.
오류를 접하고 가장 먼저 든 생각은 트랜젝션을 벗어나서 지연 로딩이 동작하지 않는다는 것이었고, @Transactional
어노테이션이 있는지부터 살폈다. 하지만 어노테이션이 분명히 붙어 있었다.
원인을 찾아보려 디버그 모드를 켜고 확인 해 봤는데 역시 LazyInitializationException
때문에 판본 정보를 가져오지 못하고 있었다.
이제 JPA에 적응이 되었고 지연 로딩 관련 오류에 대해 이해하고 있다고 생각하던 상황에서 이런 오류를 해결하지 못해 많이 답답해 하면서 여기 저기 검색을 많이 하다보니 DataSource
를 여러 개 사용하는 상황에 대한 글이 여기 저기 보였다.
사실 이 예제에 숨겨진 맥락이 있는데 두개의 데이터베이스를 사용한다는 것이다.
전자책을 위한 데이터 소스와 종이책을 위한 데이터 소스를 별도로 사용하고 있고, 종이책 데이터 소스는 중간에 새로 추가된 것이다.
@Transactional
에 대해 조금 더 검색해보니 어떤 트랜젝션 매니저를 사용할 지 지정하지 않으면 기본적으로 @Primary
가 적용된 트랜젝션 매니저를 사용한다고 되어 있었다.
잘못된 트랜젝션 매니저를 사용하고 있어서 마치 트랜젝션의 범위 밖에서 지연로딩을 시도한 것과 동일한 상황이 된 것이었다.
해결을 위해 아래와 같이 어떤 트랜젝션 매니저를 사용할지 지정해주었다.
@Transactional(transactionManager = "paperTransactionManager")
public void deleteBook(Book book){
book = repository.findById(book.getId())
.orElseThrow(EntityNotFoundException::new);
for (Edition edition : book.getEditions()) {
log.info("edition delete. {}", edition.getId());
}
repository.delete(book);
}
기능이 정상적으로 동작하게 되었다.
생각해보면 너무나 당연한 내용인데 여러 데이터베이스를 사용한 경험이 많지 않아서 원인을 빨리 찾지 못했다. 혹시 몰라 다시 디버그 모드에서 스택을 뒤져보았다. 정확히 어떤 부분을 확인해야 하는지는 모르겠지만 paperDataSource
가 있어야 할 자리에 ebookDataSource
가 있었다.
구조를 더 공부해보고 다음에는 다른 오류가 발생해도 빠르게 원인을 발견할 수 있도록 해야겠다.