개인 프로젝트 썬카에서 차량 상세 조회 기능을 만들다가 쿼리 최적화 문제에 부딪혔다.
무려 10개의 엔티티를 전부 조회해야 했고 성능 부분을 신경쓰지 않을 수 없었기에, 자연스럽게 Lazy loading, fetchJoin, BatchSize에 대해서 조금 공부하게 되었다.
Lazy Loading
- JPA/Hibernate 에서 사용하는 데이터 로딩 전략
- 연관 엔티티의 데이터를 실제로 필요한 시점까지 DB에서 조회하지 않고 지연시키는 방식
Lazy Loading - 작동 원리
- 엔티티 조회 시 영속성 컨텍스트에 주 엔티티 데이터 로드
- 연관 엔티티는 실제 데이터 대신 프록시 객체(참조)로 대체됨
- 연관 엔티티(프록시)에 getter로 접근하는 시점에 추가 DB 쿼리가 발생함
// Lazy loading 설정
@Entity
public class Post {
@Id
private Long id;
// Lazy Loading 설정
@OneToMany(mappedBy = "post", fetch = FetchType.LAZY)
private List<Comment> comments;
}
Lazy Loading - 장점
- 필요한 데이터만 로딩하므로, 메모리 사용량 감소
- 주 엔티티만 먼저 로딩하므로 첫 쿼리 실행 시간 단축
- 실제로 사용되지 않는 연관 엔티티 데이터는 로딩하지 않음
Lazy Loading - 단점
- 반복문 등으로 연관 엔티티에 접근할 때마다 추가 쿼리 발생, N+1 문제 발생 가능
fetchJoin()
- 외래키 관계가 있는 엔티티의 데이터를, 즉시 함께 로딩해 주는 기능 ( 영속성 컨텍스트에 로드 )
- join/leftJoin 과는 다른 레벨의 개념으로, JPA의 영속성 컨텍스트 관리 방식에 영향을 주는 옵션
- Lazy Loading 설정을 무시하고, 외래키 관계 엔티티 데이터를 즉시 함께 로딩
fetchJoin - 장점
- 단 1번의 쿼리로 데이터를 조회하도록 강제함
- 연관 엔티티를 한 번에 로딩하고 영속성 컨텍스트에 적재하여, Lazy Loading 방식의 단점인 추가 쿼리를 방지한다.
fetchJoin - 단점
- 1:1, N:1 관계(ToOne)에만 fetchJoin을 사용하는 것이 좋음
- 컬렉션 관계(1:N, ToMany)에 사용할 경우, 카테시안 곱 문제가 발생할 수 있다.
// fetchJoin 없이 lazy loading 사용 예시
CarListing listing = repository.findById(1L).get(); // 첫 번째 쿼리
Car car = listing.getCar(); // 두 번째 쿼리 발생
Model model = car.getModel(); // 세 번째 쿼리 발생
User user = listing.getUser(); // 네 번째 쿼리 발생
fetchJoin 없이 Lazy Loading을 사용한 예시이다.
CarListing 엔티티를 조회한 후 연관 엔티티를 getter로 조회하게 되면 추가 쿼리가 발생한다.
// lazy loading + join은 사용하지만 fetchJoin()은 사용하지 않을 시
CarListing listing = queryFactory
.selectFrom(carListing)
.join(carListing.car, car)
.join(car.model, model)
.join(carListing.user, user)
.where(carListing.id.eq(1L))
.fetchOne();
Car car = listing.getCar(); // 두 번째 쿼리 발생
Model model = car.getModel(); // 세 번째 쿼리 발생
User user = listing.getUser(); // 네 번째 쿼리 발생
마찬가지로 fetchJoin 없이 Lazy Loading을 사용한 예시이다.
이 예시에서는 join을 사용했지만, fetchJoin()을 추가로 붙이지 않는다면 영속성 컨텍스트엔 주 엔티티 데이터만 적재된다.
즉 join을 사용했지만, 애플리케이션 입장에서는 CarListing만 조회한 것과 동일한 동작을 수행하게 된다.
이게 무슨 말이냐?
join을 사용할 경우 DB 레벨에서는 주 엔티티와 연관 엔티티들 데이터를 결합하여 전부 리턴해 준다.
그러나 애플리케이션 레벨, 즉 JPA에서는 fetchJoin을 사용하지 않을 경우 연관 엔티티들 데이터는 제외해 버리고 주 엔티티 데이터만 영속성 컨텍스트에 적재한다.
따라서 애플리케이션 레벨에서 join을 사용하더라도 fetchJoin()을 붙이지 않으면, DB 레벨에서는 1번의 쿼리로 동작하지만 애플리케이션 레벨에서 연관 엔티티 접근 시마다 추가 쿼리가 발생하게 된다. 결과적으로 위 2개의 예시는 같은 방식으로 동작하게 되며 동일하게 추가 쿼리가 발생한다.
// fetchJoin 사용 시 ( lazy loading을 무시하고 한 번에 로드 )
CarListing listing = queryFactory
.selectFrom(carListing)
.join(carListing.car, car).fetchJoin()
.join(car.model, model).fetchJoin()
.join(carListing.user, user).fetchJoin()
.where(carListing.id.eq(1L))
.fetchOne();
Car car = listing.getCar(); // 추가 쿼리 없음
Model model = car.getModel(); // 추가 쿼리 없음
User user = listing.getUser(); // 추가 쿼리 없음
Lazy Loading 방식의 추가 쿼리를 줄이고 싶다면, 위처럼 join을 사용하되 반드시 fetchJoin()을 붙여야 한다.
BatchSize
- Lazy Loading된 연관 엔티티를 조회할 때, 한 번에 가져올 수 있는 엔티티의 개수를 지정하는 기능
BatchSize - 장점
- N + 1문제를 효과적으로 해결
-> N + 1 문제를 (N/BatchSize + 1) 로 줄임
-> ex) 1000개 엔티티 조회 시 1001번 -> 11번 쿼리로 감소 (BatchSize=100인 경우)
- 적용이 매우 간단함
-> 설정만 해 놓으면, Hibernate가 내부적으로 쿼리 최적화를 수행하며 개발자는 기존 코드를 변경할 필요가 없음
- 카테시안 곱 문제 방지
-> 별도 쿼리로 연관 엔티티를 조회하기 때문에, join으로 인한 행 증가 문제가 발생하지 않음
BatchSize - 단점
- 사실상 단점이 거의 없다고 봐도 무방
- 기본적으로 설정해 두는 것이 좋음
- 실제로 많은 JPA 성능 튜닝 가이드에서, BatchSize 설정은 필수 항목으로 권장됨
// 여러 엔티티 조회
List<CarListing> listings = carListingRepository.findAll(); // 예: 100개
// 이들의 이미지에 순차적으로 접근
for (CarListing listing : listings) {
int imageCount = listing.getImages().size(); // 여기서 BatchSize 적용
}
CarListing 엔티티와 CarListingImage 엔티티는 컬렉션(1:N) 관계이다.
위 예시는 각 CarListing 엔티티와 연관된(총 100개) CarListingImage를 전부 조회하는 상황을 가정한다.
// BatchSize 없는 기본 동작 -> N개의 쿼리
SELECT * FROM car_listing_image WHERE car_listing_id = 1;
SELECT * FROM car_listing_image WHERE car_listing_id = 2;
SELECT * FROM car_listing_image WHERE car_listing_id = 3;
...
BatchSize를 설정하지 않은 예시이다.
CarListing 엔티티가 100개이니, CarListingImage를 조회하기 위해 별도의 쿼리가 100개 추가로 발생한다.
// BatchSize 100 적용된 동작 -> 1개 쿼리 (약 100분의 1로 줄어듦)
SELECT * FROM car_listing_image WHERE car_listing_id IN (1, 2, 3, ..., 100);
BatchSize = 100 설정한 예시이다.
Hibernate가 쿼리 최적화를 수행하며, 예시처럼 IN 구문을 활용한 단 한줄의 쿼리가 생성된다.
BatchSize 설정은 이런 식으로 N+1 문제와 카테시안 곱 문제를 효과적으로 해결할 수 있다.
// application.properties
// BatchSize 설정
spring.jpa.properties.hibernate.default_batch_fetch_size=100
BatchSize 적용 방법이다. application.properties 파일에 위와 같이 설정한다.
size는 DB 규모에 따라서 50 ~ 1000 정도로 설정하면 되는데, 너무 작게 설정하면 효과가 미미하며 너무 크게 설정하면 메모리 사용량이 증가하기 때문에 적절한 size로 설정해야 한다.
최적 접근법
모든 관계는 기본적으로 Lazy Loading 설정
-> 연관 엔티티 데이터가 필요한 시점에만 가져오도록 한다
BatchSize 설정 전역 적용
-> 개별 적용해도 되지만 전역 적용도 큰 문제 없음, 선택
1:1, N:1(ToOne) 관계 : 필요한 시점에 fetchJoin을 별도 적용
-> 1회 쿼리로 가져오므로 N + 1 문제 방지, ToOne 관계는 카테시안 곱 문제 발생 안함
컬렉션(ToMany) 관계 : BatchSize 설정으로 관리
-> 쿼리 수가 획기적으로 줄어드므로 N + 1문제 방지, join이 아니므로 카테시안 곱 문제 발생 안함
====================
아래는 Lazy Loading, fetchJoin(), BatchSize를 개인 프로젝트에 적용한 포스팅이다.
https://yskisking.tistory.com/317
[썬카/백엔드] 판매 차량 상세조회 기능 구현 - Lazy Loading, fetchJoin(), BatchSize
차량 상세조회 기능을 구현하며 차량 관련 엔티티 10개를 조회해야 했는데, 이 과정에서 Lazy Loading, fetchJoin, BatchSize를 공부하고 적용해 보았다. 백엔드에서 쿼리 짜는 게 제일 어려운 것 같다. 아
yskisking.tistory.com
'Development > Spring' 카테고리의 다른 글
스프링 입문 - 회원 서비스 테스트 (0) | 2023.11.07 |
---|---|
스프링 입문 - 회원 서비스 개발 (0) | 2023.11.07 |
스프링 입문 - 회원 리포지토리 테스트 케이스 작성 (0) | 2023.11.01 |
스프링 입문 - 회원 도메인과 리포지토리 만들기 (2) | 2023.10.29 |
스프링 입문 - 비즈니스 요구사항 정리 (0) | 2023.10.29 |