LazyLoding의 기본 동작
LazyInitializationException은 영속성 컨텍스트가 종료된 상태에서 LAZY
전략을 사용하려고 할 때 발생한다.
@OneToMany
혹은 @ManyToMany
(xToMany) 연관관계 매핑 시 기본 전략은 LAZY
전략이다. 이는 실제 데이터에 접근할 때 까지 연관관계의 매핑된 데이터를 로드하지 않는다.
이로 인해 연관관계로 매핑된 엔티티에 대해서 초기화 되지 않은 프록시 객체로 유지되다가 연관관계로 매핑된 엔티티 필드(데이터가 실제로 필요할 때)에 접근하면 데이터베이스에서 실제 데이터를 조회하려고 시도한다.
이 때 JPA는 영속성 컨텍스트가 활성화된 상태에서 프록시를 초기화하고 데이터베이스와 상호작용을 하게되는데, Spring 에서는 트랜잭션의 범위가 종료되면 영속성 컨텍스트도 종료된다.
따라서 영속성 컨텍스트가 종료된 시점에서 연관관계로 매핑된 엔티티에 접근하게 될 경우 JPA는 데이터를 조회할 수 없기 때문에 LazyInitializationException을 발생시킨다.
문제의 코드
private UserResponse(Users user) {
this.username = user.getUsername();
this.phoneNumber = maskPhoneNumber(user.getPhoneNumber());
this.role = user.getRole();
this.posts = user.getPosts().stream()
.map(PostResponse::create)
.toList();
}
해당 생성자 메서드는 User를 조회하는 메서드의 응답형식이다.
여기서 현재 Post라는 엔티티가 있는데 특정 User 조회시 해당 유저가 작성한 게시글도 함께 조회될 수 있도록 User,Post 간의 연관관계 매핑을 해놓은 상태이다.
// Service
@Transactional(readOnly = true)
public Users getUser(Long id) {
return usersRepository.findById(id)
.orElseThrow(() -> new UserException(ErrorStatus.USER_NOT_FOUND));
}
// Controller
@GetMapping("/user/{id}")
public ResponseEntity<?> getUserDetail(@PathVariable Long id) {
return ResponseEntity.ok().body(UserResponse.create(
customUserDetailsService.getUser(id)
));
}
문제 발생 원인
위의 코드에서 해당 Service 레이어의 메서드 호출을 통해 User를 조회하려고 할 때 문제가 발생하는 것.
왜냐하면 findById
를 통해 User 엔티티를 조회하고 그대로 반환한다.
그럼 @Transactional(readOnly = true)
의 트랜잭션도 끝나고, 영속성 컨텍스트가 닫히게 되는데 닫히고 나서 Controller 레이어에서 UserResponse를 초기화 해주고있다.
그럼 당연히 트랜잭션이 끝난 시점(영속성 컨텍스트가 끝난 시점)에서 users.getPosts())가 호출 되는데, DB에 접근하려고 해도 영속성 컨텍스트가 닫혀있으니 LAZY 전략을 사용할 수 없는것이다.
그래서 해당 컬렉션을 초기화 하려고 할 때 LazyInitializationException이 발생한다.
해결 방안 - 첫 번째로는 연관관계 매핑 시 전략 자체를 EAGER로 매핑하는 것.
연관된 엔티티를 조회하게 될 경우, 즉 부모 엔티티를 조회할 경우 연관된 모든 자식 엔티티도 함께 조회해서 가지고 온다.
예를 들어서 User와 Post가 @OneToMany로 연결 되어있을 경우에 (User엔티티가 1이다.) findById() 메서드를 통해 User를 조회하게 되면 Post도 단일 쿼리로 즉시 함께 조회된다.
그럼 데이터를 이미 한번에 끌고왔기 때문에 User엔티티를 조회하는 과정에서 Post 엔티티도 함께 로딩되는데, 이 때 영속성 컨텍스트가 열려있는 상태에서 가져오기 때문에 LazyInitializationException 이 발생하지 않는다.
그리고 연관된 엔티티들이 User 엔티티 조회 시점에 관련된 엔티티들도 영속성 컨텍스트내에서 초기화 되었기 때문에 UserResponse 생성자 메서드에서 user.getPosts()를 호출할 때 영속성 컨텍스가 열려있던 닫혀있던 상관없이 데이터를 조회할 수 있게되는 것이다.
단점 : EAGER 전략 사용 시에는 불필요한 데이터를 조회할 수 있다. 이는 쿼리가 증가하고 성능 저하로 이어진다. 예를 들어 User엔티티 조회시 Post도 끌고오게 되어있는데 만약 Post 또한 Comments라는 엔티티가 있을 경우 조회 시점에서 의도치 않게, 원하지 않는 데이터 로딩 될 수 있고 그에 따른 쿼리가 증가하게 된다.
해결방안 - 두 번째로는 Fetch Join을 사용하는 것이다.
가장 대표적인 방식으로 사용할 수 있는 전략중에 하나인데, 연관관계 매핑 시 LAZY 지연전략으로 설정해놓고 엔티티 조회 시점에만 마치 EAGER 전략처럼 즉시 연관된 엔티티들을 즉시 조회하는 것이다. 해당 부분은 기본 전략은 LAZY이지만 조회 시에만 EAGER전략을 사용하는 것과 같다.
이는 직접 쿼리를 작성하는 방식인데 부모엔티티 조회시 원하는 자식 엔티티만 JOIN 함으로써 데이터를 즉시 로딩해서 가져올 수 있다. 그럼 똑같이 영속성 컨텍스트가 열려있던 닫혀잇던 상관없이 데이터를 조회할 수 있게 되는 것이다.
예를 들어 User와 관련된 엔티티들 중에 Post, Comments, Likes 등과 같은 엔티티들이 있을 수 있는데 이 중에 Fetch Join으로 원하는 엔티티들만 조합해서 즉시로딩을 할 수 있다는 것.
그리고 추가로 N+1 쿼리 문제를 해결 해주고, 연관된 엔티티의 데이터들을 한번에 조회하기 때문에 쿼리수가 줄어들고 성능 향상에 도움이 될 수 있다.
N+1 : 예를 들어 현재 DB에서 User를 조회할 때 Select 쿼리가 1번 나갈것이다. 그럼 각 User들은 Post를 들고 있으니 User를 조회하면서 Post도 조회되게 되는데, 이 때 각 유저의 Post를 조회하는 쿼리가 각 User의 수만큼 발생한다.
예를 들어 현재 User의 수가 10명이라 했을 때 User를 조회하는 Select 쿼리 한번이 나갈 것이고, 10명이 들고있는 Post 엔티티를 각각의 Select 쿼리가 10번 발생할 것이다. 총 11번의 Select 쿼리가 나가게 되는데 이를 N+1문제라고 한다.
User 엔티티 | 부모 엔티티 쿼리 조회(Users) | 자식 엔티티 쿼리(Post) | 총 쿼리 횟수 |
2명 | 1 | 2 | 3 |
10명 | 1 | 10 | 11 |
100명 | 1 | 100 | 101 |
1000명 | 1 | 1000 | 1001 |
10000명 | 1 | 10000 | 10001 |
단점 : JPQL 쿼리 작성 시 SQL 코드가 복잡해질 수 있다. 그리고 잘못된 쿼리를 입력하게 될 경우 원치 않는 데이터를 로딩할 수 있고, 또한 JOIN을 사용하기 때문에 쿼리가 커진다는 단점이 있다.
게다가 이외에도 둘 이상의 컬렉션에서는 Fetch Join을 할 수 없다는 점과, @OneToMany, @ManyToOne 에서는 Fetch Join을 사용할 수 있지만 @OneToMany 관계에서 Fetch Join 사용 시 중복 데이터 조회 및 페이징 처리를 할 수 없다는 점도 있다... 등등 주의 사항은 나중에 예제 코드와 함께 정리해 보겠다.
해결 방안 - 세 번째 트랜잭션 범위를 확장 시키기
해당 방식은 현재 나의 코드에서 나타난 문제를 해결하는데 사용 했다.
기존 코드 해당 코드에서는 findById 메서드로 찾은 Users 엔티티를 바로 반환한다.
바로 반환하면서 메서드가 끝나고 트랜잭션 또한 끝나면서 영속성 컨텍스트도 닫히게 된다.
@Transactional(readOnly = true)
public Users getUser(Long id) {
return usersRepository.findById(id)
.orElseThrow(() -> new UserException(ErrorStatus.USER_NOT_FOUND));
}
그리고 Controller 레이어에서 Users 엔티티를 DTO로 변환시키는 과정에서 users.getPosts() 메서드를 호출 하기 때문에 영속성 컨텍스트가 닫힌 시점에서 Lazy 초기와 에러가 발생하는 것이다.
해결 코드
@Transactional(readOnly = true)
public UserResponse getUser(Long id) {
Users user = usersRepository.findById(id)
.orElseThrow(() -> new UserException(ErrorStatus.USER_NOT_FOUND));
return UserResponse.create(user);
}
findById 메서드에서 찾은 Users 엔티티를 UserResponse DTO 변환 시킨 후 Controller로 반환시켜준다.
해당 코드에서는 getUser() 메서드가 아직 끝나지 않았기 때문에 트랜잭션 또한 종료되지 않았고 영속성 컨텍스트 또한 유효하다.
해당 트랜잭션 범위 내에서 findById 작업과 create 작업을 함께해주면 내부적으로 getPosts()호출 되는데 트랜잭션 범위 내이기 때문에 영속성 컨텍스트에서 해당엔티티를 조회하는데 있어서 문제가 없기 때문에 에러가 발생하지 않는다.
해당 코드에서 Fetch Join을 쓰지 않은 이유는 총 두번의 쿼리가 날라간다 1명의 User 엔티티 조회, 그리고 1명의 User 엔티티 조회시 필요한 Post 엔티티 조회.
해당 코드 같은 경우 단일 엔티티를 조회하는 코드이므로 N+1문제는 발생하지 않는다. 만약 모든 User에 대한 정보른 가지고 오는 findAll과 같은 메서드를 사용했다면 발생할 수도 있다. 쨋든 그래서 해당 코드에서는 Fetch Join을 사용하지 않고 문제를 해결했다.
'프로젝트 이슈 및 몰랐던점 정리 > CommunityAPI' 카테고리의 다른 글
[트러블 슈팅] ⚠️ Spring Boot LocalDateTime 변환 에러 해결법 (@JsonFormat vs @DateTimeFormat) (0) | 2025.03.09 |
---|---|
[트러블 슈팅] ⚠️ @RequestBody로 MultipartFile을 받을 수 없는 이유와 해결법(@RequestPart) (0) | 2025.03.09 |
[학습 포인트] 💡 JPA에서 update 시 @Modifying 애노테이션이 필요한 이유 (0) | 2025.03.09 |
[트러블 슈팅] Spring HATEOAS - 단일 리소스에 대한 API 엔드 포인트 제공 (0) | 2024.11.28 |
[트러블 슈팅] BindingResult 반환시 발생한 객체 직렬화 문제 (0) | 2024.11.26 |