N+1 문제 해결 방법 (feat.플러스주차 과제 트러블 슈팅)
지난 주부터 JPA심화, 테스트코드, 성능최적화를 배우고 있다.
JPA에서는 데이터베이스와 객체 지향 프로그래밍 간의 상호 작용을 효율적으로 관리하고,
성능을 최적화하기 위한 고급 기술을 학습하여 대규모 데이터를 다루는 서비스를 설계하고 유지하는 데 필요한 핵심 능력을 기른다.
또한, 테스트 코드를 작성하여 기능들이 정확히 작동하는지 검증하여 배포 전에 테스트를 통해 잠재적인 오류를 사전에 발견하고,
이를 방지하기 위해 기능들을 다방면에서 테스트 하는 노력을 한다.
마지막으로 성능 최적화로 속도를 개선하고, 리소스를 효율적으로 사용하여 서비스 품질 개선과 더 많은 요청을 안정적으로 처리할 수 있는 시스템을 구축하기 위해 리팩토링을 진행하여 서비스의 신뢰성과 안정성을 높히는 기술을 터득한다.
이번에 수행한 과제에서는 주어진 코드를 요구사항에 맞게 개선하여 문제를 해결하고 리팩토링을 진행한 후 테스트 코드를 작성하였다.
N+1 문제란?
N+1 문제는 JPQL 또는 Hibernate를 사용해서 데이터를 조회할 때 발생하는 성능 문제이다.
- 첫 번째 쿼리 (1회): Reservation 전체 목록을 가져온다.
- 이후 각 Reservation의 연관된 User와 Item 객체를 가져오기 위해 추가로 N번의 쿼리가 실행된다.
이로 인해 데이터가 많아질수록 성능 저하가 심각해진다.
해결 : Fetch Join을 사용하여 연관 데이터를 한 번에 가져오기
Fetch Join은 JPQL에서 연관된 테이블의 데이터를 한 번의 쿼리로 가져올 수 있도록 도와준다.
- Fetch Join 사용:
@Query를 통해 JOIN FETCH를 사용하여 Reservation, User, Item 데이터를 한 번의 쿼리로 가져온다. - N+1 문제 해결:
Hibernate가 연관 데이터를 지연로딩(Lazy Loading)하는 대신, 한 번의 쿼리로 데이터를 가져오기 때문에 쿼리 수가 줄어든다.
// ReservationRepository 에 Fetch Join 추가
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
public interface ReservationRepository extends JpaRepository<Reservation, Long> {
// Fetch Join을 사용해 User와 Item을 한 번에 가져오기
@Query("SELECT r FROM Reservation r JOIN FETCH r.user u JOIN FETCH r.item i")
List<Reservation> findAllWithUserAndItem();
}
// ReservationService
// findAllWithUserAndItem 메서드로 N+1 문제 해결
public List<ReservationResponseDto> getReservations() {
// Fetch Join으로 User와 Item을 한 번에 가져옴
List<Reservation> reservations = reservationRepository.findAllWithUserAndItem();
// DTO로 변환
return reservations.stream().map(reservation -> new ReservationResponseDto(
reservation.getId(),
reservation.getUser().getNickname(),
reservation.getItem().getName(),
reservation.getStartAt(),
reservation.getEndAt()
)).toList();
}
실행 결과
위의 findAllWithUserAndItem() 메서드가 실행되면 단 한 번의 SQL 쿼리로 실행된다.
SELECT r.id, r.start_at, r.end_at,
u.id, u.nickname,
i.id, i.name
FROM reservations r
JOIN users u ON r.user_id = u.id
JOIN items i ON r.item_id = i.id;
- N+1 문제 해결, 성능 개선
- 연관된 데이터(User, Item)를 한 번의 쿼리로 가져오기 때문에 불필요한 쿼리 실행이 사라진다.
위에서 Fetch Join 으로 N+1 문제를 해결했는데, 엔티티에서 FetchType.LAZY(지연 로딩)과 개념이 헷갈려서
아래에 다시 정리해보았다.
FetchType.LAZY로 해결하려는 접근에 대한 설명
FetchType.LAZY는 Hibernate가 연관된 엔티티를 즉시 가져오지 않고 실제로 필요할 때 쿼리를 실행하는 지연 로딩 이다.
Lazy Loading 적용의 문제점
// Reservation 엔티티
@Entity
@Getter
public class Reservation {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "item_id")
private Item item;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
private LocalDateTime startAt;
private LocalDateTime endAt;
private String status; // PENDING, APPROVED, CANCELED, EXPIRED
public Reservation(Item item, User user, String status, LocalDateTime startAt, LocalDateTime endAt) {
this.item = item;
this.user = user;
this.status = status;
this.startAt = startAt;
this.endAt = endAt;
}
public Reservation() {}
public void updateStatus(String status) {
this.status = status;
}
}
현재 코드와 같이 Reservation 엔티티에서 **User와 Item**을 가져오려면 문제가 발생한다.
- reservationRepository.findAll() 호출
- Reservation만 조회하는 1번의 쿼리가 실행된다.
- reservation.getUser() 또는 reservation.getItem() 호출
- User와 Item을 가져오기 위해 각 Reservation 엔티티마다 추가 쿼리가 실행된다.
- 결과적으로 N개의 쿼리가 발생
즉, Lazy Loading만 사용하면 N+1 문제가 해결되지 않는다.
Lazy Loading과 N+1 문제
FetchType.LAZY를 사용하면 객체는 프록시로 로드되지만, 연관 엔티티를 사용할 때마다 추가적인 SELECT 쿼리 실행
// ReservationService
List<Reservation> reservations = reservationRepository.findAll();
for (Reservation reservation : reservations) {
System.out.println(reservation.getUser().getNickname());
}
이 경우:
- 첫 번째 쿼리: SELECT * FROM reservations
- N개의 추가 쿼리: SELECT * FROM users WHERE id = ? (각 예약당 한 번씩 실행됨)
결과적으로 1 + N 번의 쿼리가 발생
해결 방법: Fetch Join과 Lazy Loading 조합
FetchType.LAZY는 엔티티를 지연 로딩하되, 필요할 때만 가져오기 때문에 성능을 최적화할 수 있다.
하지만 N+1 문제를 해결하려면 Fetch Join을 사용해 연관된 데이터를 한 번에 가져와야 gksek.
결론
- FetchType.LAZY는 그대로 유지
이는 엔티티가 항상 연관 데이터를 로드하지 않아도 되므로 불필요한 성능 낭비를 방지한다. - JPQL의 Fetch Join 사용
Fetch Join을 통해 N+1 문제를 한 번의 쿼리로 해결
// ReservationRepository 수정
@Query("SELECT r FROM Reservation r " +
"JOIN FETCH r.user u " +
"JOIN FETCH r.item i")
List<Reservation> findAllWithUserAndItem();
// ReservationService
public List<ReservationResponseDto> getReservations() {
List<Reservation> reservations = reservationRepository.findAllWithUserAndItem();
return reservations.stream()
.map(reservation -> new ReservationResponseDto(
reservation.getId(),
reservation.getUser().getNickname(),
reservation.getItem().getName(),
reservation.getStartAt(),
reservation.getEndAt()
))
.toList();
}
- FetchType.LAZY를 사용하는 것은 좋지만, N+1 문제를 해결하지 않는다.
- Fetch Join을 통해 필요할 때 연관된 데이터를 한 번의 쿼리로 가져오는 방식이 최적의 해결책이다.
- Lazy Loading과 Fetch Join을 함께 사용하면 유연한 로딩 전략을 유지하면서 N+1 문제를 해결할 수 있다.
추가적으로 N+1 문제를 해결하는 방법에 의문이 생겨서 추가적으로 알아보았다.
- Fetch Join, Batch Size, EntityGraph
1. Fetch Join
예를 들어, 부모 엔티티를 조회하고, 그에 해당하는 자식 엔티티를 추가로 조회하는 방식에서 N+1 문제가 발생한다. 기본적으로 JPA에서는 LAZY 로딩 전략을 사용하여 연관된 엔티티를 실제로 사용할 때까지 쿼리를 발생시키지 않는데, 이로 인해 여러 번의 쿼리가 실행된다.
Fetch Join을 사용하면 연관된 엔티티를 한 번의 쿼리로 함께 조회하여 성능을 최적화할 수 있다. JOIN을 사용해서 관련된 엔티티들을 한 번에 가져오기 때문에 N + 1 문제를 방지할 수 있다.
SELECT p FROM Parent p
JOIN FETCH p.children c
장점:
- 여러 번의 쿼리 대신 한 번의 쿼리로 데이터를 조회할 수 있어 성능 최적화가 가능하다.
단점:
- 너무 많은 데이터를 한 번에 조회하면 쿼리 결과가 매우 커질 수 있다. 이 경우 메모리나 성능 문제를 일으킬 수 있다.
2. Batch Size
Batch Size는 연관된 엔티티들을 한 번에 가져오는 수를 제한하는 방식이다. Batch Size를 설정하면, 연관된 엔티티들을 일정한 배치 크기만큼 한 번에 조회하도록 설정할 수 있다. 즉, LAZY 로딩이 되지만, 한 번에 여러 개의 자식 엔티티를 조회하는 것이다.
Batch Size는 @BatchSize 어노테이션을 사용하여 설정할 수 있다.
@Entity
@BatchSize(size = 10)
public class Parent {
@OneToMany(fetch = FetchType.LAZY)
private List<Child> children;
}
부모 엔티티를 조회한 후, 자식 엔티티를 최대 10개씩 묶어서 조회한다.
이 방식은 N + 1 문제를 어느 정도 해결할 수 있지만, 여전히 여러 번의 쿼리가 발생한다.
장점:
- Fetch Join보다는 효율적이지 않지만, 여러 번의 쿼리 대신 배치 단위로 데이터를 조회하여 성능을 개선할 수 있다.
단점:
- Batch Size를 잘못 설정하면 여전히 성능 문제가 발생할 수 있다.
3. EntityGraph
EntityGraph는 JPA 2.1에서 추가된 기능으로, 엔티티를 조회할 때 어떤 연관 관계를 EAGER로 로딩할지 미리 정의할 수 있다. 이를 통해 Fetch Join처럼 연관된 엔티티를 한 번에 조회할 수 있지만, 실제 로딩 전략은 필요할 때만 적용된다.
EntityGraph는 JPQL 쿼리에서 사용할 수 있으며, 주로 @EntityGraph 어노테이션이나 EntityManager의 createQuery 메서드에서 설정할 수 있다.
@EntityGraph(attributePaths = {"children"})
public List<Parent> findAll();
위와 같이 EntityGraph를 사용하면, Parent 엔티티를 조회할 때 children 연관 관계를 자동으로 EAGER 로딩하여 한 번의 쿼리로 조회할 수 있다.
장점:
- 연관된 엔티티를 동적으로 로딩 전략을 설정하여 성능을 최적화할 수 있다.
단점:
- 설정이 다소 복잡할 수 있으며, 다른 EntityGraph를 사용하는 쿼리에서 충돌이 발생할 가능성이 있다.
결론
세 가지 방법 모두 장단점이 있어서, 상황에 맞게 선택하여 사용하는 것이 중요하다.
예를 들어, 복잡한 연관 관계가 많고, 성능이 중요한 경우에는 Fetch Join을 사용하는 것이 좋다.
만약 쿼리 결과가 너무 커지거나 성능 문제를 일으킬 우려가 있다면 Batch Size나 EntityGraph를 사용해 최적화할 수 있다.
TIL 12월 19일
'TIL (ToDay I LearNEd) > K P T (keeP, pRoBlem, Try) & 트러블슈팅' 카테고리의 다른 글
아웃소싱 프로젝트 KPT (0) | 2024.12.08 |
---|---|
[Spring] Cookie 생성 오류 (0) | 2024.12.07 |
은행 환전 개인과제 트러블 슈팅_TIL (1) | 2024.11.29 |
뉴스피드 프로젝트_트러블 슈팅.TIL (0) | 2024.11.21 |
순환 참조란? Circular Dependency (일정 관리 앱 서버 DEVELOP 트러블 슈팅) (1) | 2024.11.15 |