Development/Spring

JPA 쿼리 최적화 - Lazy loading, fetchJoin(), BatchSize

양선규 2025. 4. 8. 17:22
728x90
반응형

JPA

 

개인 프로젝트 썬카에서 차량 상세 조회 기능을 만들다가 쿼리 최적화 문제에 부딪혔다.

무려 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

 

 

 

728x90
반응형