JPA LazyInitializationException 해결하기
JPA 사용시 발생하는 LazyInitializationException을 해결하는 방법에 대해 정리한 내용입니다.
1. LazyInitializationException 예외가 발생하는 이유
1.1. 지연 로딩(LAZY LOADING) 으로 인하여 발생하는 LazyInitializationException
JPA를 이용하여 객체의 양방향 연관관계를 구성한 후에 엔티티를 조회했을 때 LazyInitializationException 예외가 발생하는 경우가 있습니다. FetchType.LAZY 로 연관관계가 설정된 필드는 지연 로딩으로 조회 되는데 이 때 해당 필드의 엔티티를 프록시 객체로 조회하게 됩니다. 프록시 객체로 조회된 엔티티는 초기화 되어있지 않기 때문에 해당 엔티티를 조회하려 할 때 LazyInitializationException 예외가 발생하게 됩니다.
지연 로딩의 확인을 위해 다음과 같이 Board 와 BoardComment 클래스를 연관관계를 구성해줍니다.
@Getter
@NoArgsConstructor
@Entity(name = "board")
public class Board {
...
@JsonIgnore
@OneToMany(
mappedBy = "board",
cascade = {CascadeType.ALL},
orphanRemoval = true
)
private List<BoardComment> boardComments;
...
}
@Getter
@NoArgsConstructor
@Entity(name = "board_comment")
public class BoardComment {
...
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "board_id")
private Board board;
...
}
서비스 레벨에서 Board 엔티티를 조회해보면 연관관계의 BoardComment 엔티티에서 LazyInitializationException 예외가 발생하는 것을 확인할 수 있습니다.
@RequiredArgsConstructor
@Service
public class BoardService {
private final BoardRepository boardRepository;
...
public List<BoardDto.ResponseDto> getBoards() throws Exception {
List<BoardDto.ResponseDto> boardsResponseDto = boardRepository.findAll()
.stream()
.map(board -> {
return BoardDto.ResponseDto.builder()
.boardId(board.getBoardId())
.title(board.getTitle())
.content(board.getContent())
.author(board.getAuthor())
.boardComments(board.getBoardComments())
.build();
})
.collect(Collectors.toList());
return boardsResponseDto;
}
...
}
1.2. 프록시 객체
엔티티를 조회할 때 사용하지 않는 연관관계의 엔티티도 매번 함께 조회해온다면 효율적이지 않게 됩니다. 이러한 문제를 해결하기 위해 JPA에서는 지연 로딩을 이용하여 엔티티의 데이터베이스 조회를 지연하는 방법을 제공합니다. 지연 로딩을 사용하여 엔티티를 조회했을 때 실제 엔티티 객체 대신에 Mock Object 역할을 하는 가짜 객체를 사용하게 되는데 이것을 프록시 객체라고 합니다.
1.3. 즉시 로딩과 지연 로딩
즉시 로딩과 지연 로딩은 연관관계의 엔티티를 조회하기 위해 사용하는 방법입니다. 영속성 컨텍스트에 실제 엔티티를 모두 생성해 놓거나 필요한 시점에 생성하여 사용하는 것에 따른 차이가 있습니다.
- 즉시 로딩 (EAGER LOADING)
@ManyToOne(fetch = FetchType.EAGER) 설정하여 사용.
엔티티를 조회할 때 연관된 엔티티도 함께 조회.
JPA에서는 즉시 로딩을 최적화하기 위해 연관된 엔티티에 대한 추가 쿼리를 실행하기 보다는 가능하면 조인 쿼리를 사용.
@ManyToOne, @OneToOne 과 같이 연관된 엔티티가 하나인 경우에 대한 디폴트 패치 전략. - 지연 로딩 (LAZY LOADING)
@ManyToOne(fetch = FetchType.LAZY) 설정하여 사용.
엔티티를 실제 사용할 때 조회.
연관된 객체의 멤버변수에 프록시 객체를 넣어둠.
실제 데이터가 필요한 시점에 데이터베이스를 조회해서 프록시 객체를 초기화.
@OneToMany, @ManyToMany 와 같이 연관된 엔티티가 여러 개인 경우에 대한 디폴트 패치 전략.
2. LazyInitializationException 해결하기 (Anti-Pattern)
LazyInitializationException 예외 해결을 위한 여러가지 방법이 있지만 다음의 방법들은 권장되지 않는 해결 방법입니다.
2.1. OSIV(Open Session In View) 활성화
Spring의 open-in-view 프로퍼티를 활성화 해줍니다. 기본값은 true 이며 활성화 되었을 땐 응답이 완료되거나 뷰가 렌더링 될 때까지 영속성 컨텍스트를 유지합니다. @Transactional 어노테이션이 사용된 메서드가 종료되어도 컨트롤러 리턴 시점까지 세션을 유지해줍니다. 이 방법은 데이터베이스 커넥션을 계속 이어서 사용하고 세션의 수명이 길어짐에 따라 성능과 확장성 측면에서 좋지 않기 때문에 권장되지 않습니다.
spring.jpa.open-in-view: true
2.2. hibernate.enable_lazy_load_no_trans 활성화
Spring의 hibernate.enable_lazy_load_no_trans 프로퍼티를 활성화 해줍니다. 이 방법은 세션이 종료된 이후에도 다른 세션을 사용하여 데이터를 조회합니다. 여러 개의 지연 로딩이 있는 경우에 대해 각각 새로운 데이터베이스 커넥션을 획득하여 조회하기 때문에 성능 측면에서 좋지 않고 커넥션 풀을 고갈시키는 장애를 유발할 수 있기 때문에 권장되지 않습니다.
spring.jpa.properties.hibernate.enable_lazy_load_no_trans: true
2.3. 지연 로딩을 즉시 로딩으로 바꾸기
연관관계가 설정된 엔티티에 FetchType.EAGER를 사용하여 즉시 로딩으로 엔티티를 조회하면 연관된 객체가 모두 영속성 컨텍스트에 생성되어 해결이 가능합니다. 하지만 연관된 엔티티가 컬렉션과 같은 경우엔 성능에 문제가 발생할 수 있기 때문에 권장되지 않습니다.
3. LazyInitializationException 해결하기 (권장 방법)
다음의 방법들을 사용하여 LazyInitializationException 예외를 해결하는 것이 권장됩니다.
3.1. @Transactional 사용하기
서비스 계층에서 @Transactional 어노테이션을 읽기 전용(readOnly = true)으로 사용하여 해결하는 방법입니다. 트랜잭션을 읽기 전용으로 사용하면 Dirty Checking을 하지 않아 성능을 향상시키고 데이터의 의도하지 않은 변경을 방지해줍니다. LazyInitializationException 예외는 JPA의 영속성 컨텍스트가 종료된 후에 연관관계가 설정된 엔티티를 조회하려고 할 때 발생하기 때문에 세션이 유지되도록 트랜잭션을 설정해줍니다. 서비스 계층에서 트랜잭션을 시작하면 Repository까지 해당 트랜잭션이 전파되어 사용됩니다. 따라서 지연 로딩 시점까지 세션을 유지하여 사용할 수 있습니다.
@Transactional(readOnly = true)
public List<BoardDto.ResponseDto> getBoards() throws Exception {
List<BoardDto.ResponseDto> boardsResponseDto = boardRepository.findAll()
.stream()
.map(board -> {
return BoardDto.ResponseDto.builder()
.boardId(board.getBoardId())
.title(board.getTitle())
.content(board.getContent())
.author(board.getAuthor())
.boardComments(board.getBoardComments())
.build();
})
.collect(Collectors.toList());
return boardsResponseDto;
}
3.2. Transactional 내부에서 연관된 엔티티 조회하기 (DTO 변환)
트랜잭션이 설정된 서비스 계층에서 연관관계의 엔티티를 미리 조회하고 트랜잭션 종료 시점에 DTO로 변환하는 방법입니다. 세션이 유지된 상태에서 조회하기 때문에 지연 로딩 조회가 가능합니다. 또한 엔티티를 단일 DTO로 변환하는 것은 즉시 로딩(FetchType.EAGER)을 사용하는 것과 동일하기 때문에 경우에 따라 필요한 필드만 DTO로 변환해주는 것이 권장됩니다.
@Transactional(readOnly = true)
public List<BoardDto.ResponseDto> getBoards() throws Exception {
List<BoardDto.ResponseDto> boardsResponseDto = boardRepository.findAll()
.stream()
.map(board -> {
return BoardDto.ResponseDto.builder()
.boardId(board.getBoardId())
.title(board.getTitle())
.content(board.getContent())
.author(board.getAuthor())
.boardComments(
board.getBoardComments().stream().map(boardComment -> {
return BoardComment.builder()
.boardCommentId(boardComment.getBoardCommentId())
.board(board)
.content(boardComment.getContent())
.author(boardComment.getAuthor())
.build();
}).collect(Collectors.toList())
)
.build();
})
.collect(Collectors.toList());
return boardsResponseDto;
}
3.3. @EntityGraph 사용하기
RDB에도 JPA 엔티티와 같이 연관관계가 설정되어 있지 않은 경우엔 LazyInitializationException 예외가 계속 발생할 수 있습니다. 이러한 경우에 @ManyToOne 관계를 설정해준 엔티티에서 @EntityGraph 어노테이션을 사용하여 연관된 객체를 조회할 수 있습니다. @EntityGraph 어노테이션은 즉시 로딩으로 연관관계의 객체를 조회해오고 Left Outer Join으로 읽어오기 때문에 LazyInitializtionException 예외를 해결 할 수 있습니다. 하지만 연관관계의 상위 엔티티를 중복 조회하기 때문에 JPQL 사용 시 distinct 설정을 사용하거나 Set 컬렉션을 사용하는 것이 권장됩니다.
@ManyToOne 으로 설정된 BoardComment 객체의 Repository 메서드에 아래와 같이 @EntityGraph 어노테이션을 설정해줍니다. attributePaths 에는 조회할 엔티티명을 작성하고 type 에는 패치 전략을 설정해줍니다.
@Repository
public interface BoardCommentRepository extends JpaRepository<BoardComment, String> {
@EntityGraph(
attributePaths = {"board"},
type = EntityGraph.EntityGraphType.FETCH
)
Optional<BoardComment> findByBoardCommentId(String boardCommentId);
}
@EntityGraph 어노테이션의 type 프로퍼티는 다음과 같이 2가지 패치 유형을 제공합니다.
- EntityGraph.EntityGraphType.FETCH
attributePaths에 명시한 엔티티는 EAGER로 패치하고 나머지 엔티티는 LAZY로 패치. - EntityGraph.EntityGraphType.LOAD
attributePaths에 명시한 엔티티는 EAGER로 패치하고 나머지 엔티티는 각 엔티티에 명시한 FetchType 또는 디폴트 FetchType으로 패치. (@OneToMany, @ManyToMany 디폴트 패치는 LAZY 이고 @OneToOne, @ManyToOne 디폴트 패치는 EAGER)
서비스 계층에서는 @EntityGraph 어노테이션을 이용하여 조회해온 엔티티를 사용할 수 있습니다.
public BoardCommentDto.ResponseDto getBoardComment(String boardCommentId) throws Exception {
BoardComment findBoardComment = boardCommentRepository
.findByBoardCommentId(boardCommentId).orElseThrow(NoSuchElementException::new);
...
return BoardCommentDto.ResponseDto.builder()
.boardCommentId(findBoardComment.getBoardCommentId())
.board(findBoardComment.getBoard())
.content(findBoardComment.getContent())
.author(findBoardComment.getAuthor())
.build();
}
이상으로 JPA 사용 시 발생하는 LazyInitializationException 예외를 해결하는 방법에 대해 알아봤습니다.
※ References
- 김영한 지음, 『자바 ORM 표준 JPA 프로그래밍』, 에이콘 (2015), 8장 프록시와 연관관계 관리 (p288 ~ p317)
- resilient-923.tistory.com, [Spring / TIL] @Transactional(readOnly=true) 가 꼭 필요한가?, https://resilient-923.tistory.com/391
- jsonobject.tistory.com, Spring Boot, JPA, LazyInitializationException 예외 설명 및 해결책 정리, https://jsonobject.tistory.com/605
- velog.io/@oenomel87, JPA의 LazyInitializationException, https://velog.io/@oenomel87/JPA-LazyInitializationException
- programmer-chocho.tistory.com, JPA에서 지연로딩(LAZY LOADING)이란, https://programmer-chocho.tistory.com/81
- blog.jiniworld.me, [Spring Data JPA Tutorial] 10. LazyInitializationException 해결하기 2. @OneToMany, https://blog.jiniworld.me/152#a02-2
- wonin.tistory.com, @EntityGraph에 대해 알아보자, https://wonin.tistory.com/496
- vladmihalcea.com, The hibernate.enable_lazy_load_no_trans Anti-Pattern, https://vladmihalcea.com/the-hibernate-enable_lazy_load_no_trans-anti-pattern/
- vladmihalcea.com, The best way to map a projection query to a DTO (Data Transfer Object) with JPA and Hibernate, https://vladmihalcea.com/the-best-way-to-map-a-projection-query-to-a-dto-with-jpa-and-hibernate/