오늘도 프로젝트를 진행 중, 오류를 마주쳤다. (JPA를 잘 알지도 못하면서 덤벼든 나의 잘못이다..)
LazyInitializationException: failed to lazily initialize a collection of role: ...
저번에도 마주쳤던 그 녀석이다. 오류가 발생한 배경은 이렇다.
도서의 태그가 붙는데 도서와 태그는 다대다 관계이다. 그래서 다대일, 일대다 관계로 풀어내기 위해 BookTag
엔티티를 추가했다. (Book
은 BookTag
와 일대다 관계) @OneToMany
는 기본 fetch 전략이 Lazy인 것은 안다.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Book {
...
@OneToMany(mappedBy = "book")
private List<BookTag> bookTags = new ArrayList<>();
...
}
Book
의 bookTags
를 받아 BookTag
객체에서 Tag
를 꺼내 이름을 다시 리스트에 담는 로직이다. 여기서 BookTag
객체를 리스트에서 꺼낼 때 오류가 발생한 것이다.
...
@Override
public List<String> listConvertBookTagToString(List<BookTag> bookTags) {
List<String> tags = new ArrayList<>();
bookTags.forEach(bookTag -> { // 이 부분
String tagName = bookTag.getTag().getTagName();
tags.add(tagName);
});
return tags;
}
...
패치 전략을 Eager로 바꿔주면 잘 조회가 됐지만 분명히 이 방법은 좋은 방법이 아닐 것이다. (느낌이 말해준다.)
그나마 최근에 JPA 강의를 들으며 공부 중이었는데 이 부분에 대해 강사님께서 패치 조인이나 엔티티 그래프 기능(?) 등을 통해 해결이 가능하다고 하셨던 기억이 났다. 그래서 fetch join을 열심히 검색해 보고 적용해 봤다.
패치 조인은 연관된 엔티티를 한 번의 쿼리로 함께 조회하는 방식으로, JPQL에서 성능 최적화를 위해 제공하는 기능이라고 한다.
Repository
에 @Query
애노테이션을 사용해 fetch join 메서드를 만들어 봤다.
SELECT b FROM Book b JOIN FETCH b.bookTags WHERE b.id = :bookId
...
public interface BookRepository extends JpaRepository<Book, Long> {
...
@Query("SELECT b FROM Book b JOIN FETCH b.bookTags WHERE b.id = :bookId")
Optional<Book> findByIdFetchJoin(@Param("bookId") Long id);
...
}
기대를 품고 요청을 보내 보았다..! (두근두근..) 하지만 돌아오는 것은 LazyInitializationException
하지만 이번에는 오류 내용이 조금 달랐다.
could not initialize proxy [knu.networksecuritylab.appserver.entity.book.Tag#2]
@xToOne
연관관계 매핑의 경우 기본 패치 전략이 Eager라서 Lazy로 설정해 두었기 때문에 BookTag
의 Tag
도 함께 패치 조인을 해야 하는 것이다.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class BookTag {
...
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "tag_id")
private Tag tag;
...
}
이렇게 JOIN FETCH
를 두 번 써서 해결이 가능했다 ㅎㅎ
...
public interface BookRepository extends JpaRepository<Book, Long> {
...
@Query("SELECT b FROM Book b JOIN FETCH b.bookTags bt JOIN FETCH bt.tag WHERE b.id = :bookId")
Optional<Book> findByIdFetchJoin(@Param("bookId") Long id);
...
}
아니다. 해결이 안 됐다. 만약 태그가 없는 책을 조회하면 ‘해당 도서가 존재하지 않습니다.’라는 예외가 발생했다. (null
인 경우에 해당 예외가 던져지도록 예외 처리를 해두었었다.)
난 아까 그 오류보다 이 오류가 더 골치 아팠다 ㅋㅋㅋㅋ
이유를 찾았다. 쿼리문을 보니 inner join으로 날아간다. inner join이면 양쪽 테이블에 값이 있어야 조회되는 것은 대학교 데이터베이스 수업 때 배웠었다. 왼쪽 테이블이 기준이 되어야 하니까 left join으로 날아가야 제대로 조회될 것이다. 근데 left 조인으로 어떻게 하지..
https://www.logicbig.com/tutorials/java-ee-tutorial/jpa/fetch-join.html
그냥 LEFT JOIN FETCH
라고 작성하면 된단다. 멍청하게 JOIN FETCH
가 하나의 명령어라는 고정관념에 LEFT JOIN
이라고 작성할 생각을 못 했다.
진짜 최종 코드! (점점 길어지는 쿼리..)
...
public interface BookRepository extends JpaRepository<Book, Long> {
...
@Query("SELECT b FROM Book b LEFT JOIN FETCH b.bookTags bt LEFT JOIN FETCH bt.tag WHERE b.id = :bookId")
Optional<Book> findByIdFetchJoin(@Param("bookId") Long id);
...
}
이젠 책이 잘 조회된다 ㅎㅎ😆
아직 의문이 하나 남았다. 내 생각에는 BookTag
와 Tag
패치 조인 시에는 left join을 사용하지 않아도 될 것 같아서 그냥 JOIN FETCH
를 사용해 봤는데 태그가 없는 책은 조회가 되지 않았다. 이 부분은 열심히 고민해 보고 해결하면 추가로 작성해야겠다.
2023년 02월 16일 00시 12분
매우 간단한 이유였다. 난 당연히 뒤에 있는 조인이 수행되고 이후에 앞에 있는 조인이 수행될 것이라고 생각했는데 아니었다. 앞의 조인이 먼저 수행되고 뒤의 조인이 수행되기 때문에 뒤의 조인도 LEFT JOIN
이 되어야 했던 것이다.😅