자바(JAVA) JPA 영속성 컨텍스트 지연로딩(Lazy loading) N + 1문제
자바(JAVA) JPA 영속성 컨텍스트 지연로딩(Lazy loading) N + 1문제 — #자바JPA #JPA지연로딩 #지연로딩 #LazyLoading #Lazy로딩 #JAVAJPA #JPQN+1 #N+!문...
#자바JPA #JPA지연로딩 #지연로딩 #LazyLoading #Lazy로딩 #JAVAJPA #JPQN+1 #N+!문제 #JPA영속성컨텍스트 #JPQPersistencecontext #개발자의도구들
🚨📝 📖 📒✏️💡🔍
AI스쿨 msa기반 java 백엔드 코스 중에 공부한 내용을 작성하였습니다
\* 현재 Spring 프로젝트를 진행 중입니다. 전체적인 목차를 보시려면 여기를 눌러주세요.
나타난 문제점
계속해서 JPA에 대해서 다루고 있습니다. 오늘은 JPA의 Entity설계중 나타날 수 있는 N+1문제에 대해서 다뤄보고자 합니다.
우선 아래 코드를 봐주세요.
// Service layerpublic Optional<List<BoardEntity>> getAllBoard() { List<BoardEntity> boards = boardRepository.findAll(); return boards.isEmpty() ? Optional.empty() : Optional.of(boards);} |
|---|
// Controller layer@GetMapping("/boards") public ResponseEntity<List<BoardEntity>> getAllBoardEntity() { return boardService.getAllBoard() .map(ResponseEntity::ok) .orElseGet(() -> ResponseEntity.notFound().build()); } |
|---|
Spring에서 흔하게 볼 수 있는 MSC모델 형태로 코드를 구현하였습니다. 로직은 매우 단순한데, 그냥 DB에 저장되어 있는 모든 Boards테이블의 값을 찾아서 그대로 반환해주는 로직입니다.
하지만 아래 문제가 발생하였습니다.
| failed to lazily initialize a collection of role: |
|---|
네, 오늘 글의 주제인 지연로딩 문제였습니다.
JPA가 익숙하지 않아서 그 구조와 동작원리를 파악하고 있는 상태로 개발을 진행하다보니 막혔습니다. 이를 통해 공부한 내용을 공유하고자 합니다.
영속성 컨텍스트란?
JPA의 기본 로직부터 다시 짚어볼 필요가 있습니다. 간단하게 요약하자면 아래와 같습니다.
- 엔티티 클래스를 정의하고, JPA가 이를 DB와 매핑시킵니다.
- EntityManager를 통해 DB에 쿼리를 날려 로직을 수행합니다.
- 이 과정에서 영속성 컨텍스트를 통해 Entity를 관리합니다.
- 여러작업을 하나의 트랜잭션으로 처리합니다. 성공시 DB에 반영, 실패시 롤백합니다.
위 로직을 보시면 아시겠지만, JPA는 DB와 연동된 객체를 영속성 컨텍스트에서 관리를 하고 있습니다. 로직이 발생할때마다 영속성 컨텍스트는 실행되며, 끝나면 영속성 컨텍스트는 소멸됩니다.
영속성 컨텍스트는 JPA가 DB와 관련된 로직을 수행하는 일종의 작업 영역으로 생각하시면 편합니다. 메모리 공간을 소유하여, 관련 엔티티를 그 영역에 보관해두고 있다고 생각해보세요.
생성과 소멸
앞서 이야기 했든 영속성 컨텍스틑 Transaction이 생성되면 생성되
Transaction은 언제 생성되는가?
Transaction은 DB에서 실행되는 작업 영역 단위입니다. 이는 하나의 로직만을 가지고 있을 수도 있고, 여러개의 로직, 동일한 로직들을 포함하고 있을 수 있습니다.
일반적으로 Transaction은 두 가지 경우에 생성되고 소멸됩니다.
- @Transactional이 붙은 method가 시작되면 생성, 끝나면 소멸
- 없는 경우, 하나의 쿼리를 transaction으로 간주, 쿼리 실행 후 소멸
앞서 이야기했든, transaction의 생성과 소멸은 영속성 컨텍스트의 생성과 소멸이랑 동일합니다.
지연 로딩
lazy loading
영속성 context의 주요 특징중 하나는 지연 로딩(Lazy Loading)입니다. 이는 Entity를 실제 필요한 시점에 로딩하는 설정으로 성능을 위해 default로 설정되어 있습니다.
문제의 원인
문제의 원인은 Entity의 구조에 있었습니다.
public class BoardEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name="id") private long id; @Column(name = "title", nullable = false) private String title; @Column(name = "writer", nullable = false) private String writer; @Column(name = "writing\_time", nullable = false) private LocalDateTime writingTime; @Column(name = "reading\_count", nullable = false) private Integer readingCount; @Column(name = "text\_content", nullable = false) private String textContent; @OneToMany(mappedBy = "board", cascade = CascadeType.ALL) @JsonManagedReference private List<CommentEntity> comments;} |
|---|
| public class CommentEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name="id") private long id; @ManyToOne @JoinColumn(name="board\_id") @JsonBackReference private BoardEntity board; @Column(name="writer") private String writer; @Column(name="writing\_time") private LocalDateTime writingTime; @Column(name="text\_content") private String textContent;} |
|---|
BoardEntity를 보시면 List<CommentEntity> comments를 포함하고 있습니다. 1: N관계로 게시물에는 여러개의 댓글이 달릴 수 있어서 이는 매우 일반적인 형태입니다.
하지만 Service와 Controller를 다시보시면 문제가 어디서 발생하는 알 수 있습니다.
// Service layerpublic Optional<List<BoardEntity>> getAllBoard() { List<BoardEntity> boards = boardRepository.findAll(); return boards.isEmpty() ? Optional.empty() : Optional.of(boards);} |
|---|
// Controller layer@GetMapping("/boards") public ResponseEntity<List<BoardEntity>> getAllBoardEntity() { return boardService.getAllBoard() .map(ResponseEntity::ok) .orElseGet(() -> ResponseEntity.notFound().build()); } |
|---|
Service의 getAllBoard()는 findAll()메서드를 사용하여 확실히 BoardEntity를 호출하고 있습니다. 하지만 CommentEntity를 직접적으로 호출하지는 않습니다. 이는 Controller에서도 마찬가지입니다. 이는 영속선 컨텍스트 내부에 BoardEntity는 들어있지만, CommentEntity는 들어있지 않음을 의미합니다.
💡CommentEntity는 어디서 사용할까요?
Controller의 경우 ResponseEntity로 받은 List<BoardEntity>를 Json으로 변환하여 Client에게 전달합니다. 이때 BoardEntity의 값은 영속성 컨텍스트에서 확인이 가능하지만, 내부의 CommentEntity는 영속성 컨텍스트에서 확인이 불가능합니다. 이 때문에 지연로딩이 발생하게 되는 것입니다.
| 🚨위 예제는 DTO를 전혀 고려하지 않고 만든 예제입니다. JPA 초기에 공부한 예제로 DTO에 대한 개념조차 몰랐을 때 사용한 코드입니다. 여기서 의구심이 드실 수 있습니다. 왜 영속성 컨텍스트가 Controller까지 살아있는가? -> 이 문제는 Spring의 치명적인 설계 결함의 Open-in-view에서 다룰 예정입니다. |
|---|
이를 해결하려면? - N + 1문제
이를 해결하기 위해서는 어떻게 해야할까요? 정답은 아주 간단합니다. 그냥 CommentEntity를 미리 호출한 후 넘겨주면 됩니다.
// Service layerList<BoardEntity> boards = boardRepository.findAll(); |
|---|
이 코드를 다시보겠습니다. 지연로딩으로 인해서 CommentEntity는 영속성 컨텍스트에 등록이 안되기 때문에 반드시 미리 호출을 해줘야합니다. 그럼 아래와 같이 코드를 작성할 수 있겠네요.
for (BoardEntity board : boards) { List<CommentEntity> comments = board.getComments();} |
|---|
확실하게 CommentEntity를 호출하면 등록이 가능합니다. 하지만 이번에는 N + 1문제가 발생합니다..
N + 1문제
SQL은 상당히 무거운 로직입니다. 따라서 성능을 위해서는 최대한 SQL을 적게 사용하는 것이 좋습니다.
위 코드는 각 BoardEntity마다 아래의 쿼리가 추가로 실행되게 됩니다.
| SELECT \* FROM comment WHERE board\_id = ?; |
|---|
이 쿼리가 List<BoardEntity>내부의 각 Entity마다 호출이 됩니다. 그럼 N개의 Entity가 들어있는 List라면 N + 1번의 호출이 계속 일어나겠죠? 이게 바로 N + 1문제입니다.
N + 1 문제 해결
위 문제를 해결의 핵심은 바로 쿼리의 수를 줄이는 것입니다. 연관된 두 테이블의 데이터를 한번에 불러오는게 좋겠습니다. 이를 위해서는 아시다 싶이 JOIN이 사용됩니다.
즉시 로딩 사용
public class BoardEntity { // 생략 @OneToMany(mappedBy = "board", cascade = CascadeType.ALL, fetch = FetchType.EAGER) @JsonManagedReference private List<CommentEntity> comments;} |
|---|
@OneToMang는 기본적으로 지연로딩을 사용합니다. 이를 즉시 로징으로 사용하면 JPA가 자동으로 관계에서 Join을 만들어 CommentEntity까지 영속성 컨텍스트에 등록해줍니다.
하지만 중요한 것은 Join역시도 연관된 데이터가 많아지면 많아질 수록 성능에 영향을 미칩니다. 특히나 CommentEntity가 필요없는 경우 불필요하게 성능을 낭비할 수 있습니다. (ex, count() )
Join 직접 사용하기
| @Query("SELECT b FROM BoardEntity b JOIN FETCH b.comments WHERE b.id = :id")BoardEntity findBoardWithComments(@Param("id") Long id); |
|---|
명시적으로 JPQL 메서드를 만들어 필요할 때만 Query를 날려 가져오도록 할수도 있습니다. 필요할때마다 해줘야하는 번거로움이 있을 수 있겠습니다.
총 정리
- JPA의 영속성 컨텍스트는 Entity의 생명주기를 관리하며 지연로딩 기법을 사용한다.
- 지연로딩은 호출 시점에 Query를 DB에 날려 영속성 컨텍스트에 등록하는 것을 말한다.
- 지연로딩으로 인해 Lazy Intialization 오류가 발생할 수 있다.
- 이를 해결하기 위해 관계된 Entity를 직접호출해야한다.
- 하지만, 쿼리 날리는 수가 늘어나 N + 1문제가 발생한다.
- N + 1문제의 핵심은 쿼리 수를 줄이는 것이다.
- 이를 위해 Join 혹은 Eager옵션을 사용할 수 있다. (각 각의 장단점이 존재)
