Development/썬카(SunCar) - 개인 프로젝트

[썬카/백엔드] 판매 차량 상세조회 기능 구현 - Lazy Loading, fetchJoin(), BatchSize

양선규 2025. 4. 8. 18:42
728x90
반응형

차량 상세조회 기능을 구현하며 차량 관련 엔티티 10개를 조회해야 했는데, 이 과정에서 Lazy Loading, fetchJoin, BatchSize를 공부하고 적용해 보았다. 백엔드에서 쿼리 짜는 게 제일 어려운 것 같다.

 

아래는 Lazy Loading, fetchJoin(), BatchSize에 대해 내가 정리한 글이다.

https://yskisking.tistory.com/316

 

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

개인 프로젝트 썬카에서 차량 상세 조회 기능을 만들다가 쿼리 최적화 문제에 부딪혔다.무려 10개의 엔티티를 전부 조회해야 했고 성능 부분을 신경쓰지 않을 수 없었기에, 자연스럽게 Lazy loading

yskisking.tistory.com

 

 

차량 상세 조회 커스텀 쿼리 (메인 쿼리)

// repository/car/CarListingRepositoryCustomImpl

/**
 * 차량 상세정보를 조회합니다.
 */
@Override
public Optional<CarDetailFetchResult> getCarDetailById(Long listingId) {

    QUser user = QUser.user;
    QCar car = QCar.car;
    QCarAccident carAccident = QCarAccident.carAccident;
    QCarAccidentRepair carAccidentRepair = QCarAccidentRepair.carAccidentRepair;
    QCarListing carListing = QCarListing.carListing;
    QCarListingImage carListingImage = QCarListingImage.carListingImage;
    QCarMileage carMileage = QCarMileage.carMileage;
    QCarOption carOption = QCarOption.carOption;
    QCarOwnershipChange carOwnershipChange = QCarOwnershipChange.carOwnershipChange;
    QCarUsage carUsage = QCarUsage.carUsage;
    QModel model = QModel.model;

    // ToOne 관계 -> fetchJoin()
    /** 1. 기본 차량 정보 조회 (CarListing, Car, Model, User) */
    CarListing result = getQueryFactory()
            .selectFrom(carListing)
            .join(carListing.car, car).fetchJoin()
            .join(car.model, model).fetchJoin()
            .join(carListing.user, user).fetchJoin()
            .where(carListing.id.eq(listingId))
            .fetchOne();

    if (result == null) {
        return Optional.empty();
    }
    Long carId = result.getCar().getId();
    
    
    // 아래 모든 쿼리 : ToMany 관계 -> 별도 쿼리 + BatchSize
    // 아래 모든 쿼리 : ToMany 관계 -> 별도 쿼리 + BatchSize
    /** 2. 모든 이미지 정보 조회 */
    List<CarListingImage> images = getQueryFactory()
            .selectFrom(carListingImage)
            .where(carListingImage.carListing.id.eq(listingId))
            .fetch();

    /** 3. 사고 정보와 수리 정보 조회 */
    // 사고이력 전부 가져오기
    List<CarAccident> accidents = getQueryFactory()
            .selectFrom(carAccident)
            .where(carAccident.car.id.eq(carId))
            .fetch();

    // 사고이력 id 리스트 생성
    List<Long> accidentIds = accidents.stream()
            .map(CarAccident::getId)
            .collect(Collectors.toList());

    // 사고이력들의 수리정보 전부 조회
    List<CarAccidentRepair> repairs = accidentIds.isEmpty()
            ? List.of()
            : getQueryFactory()
                .selectFrom(carAccidentRepair)
                .where(carAccidentRepair.carAccident.id.in(accidentIds))
                .fetch();

    // 수리정보를 사고이력 id 기준으로 그룹핑 리스트화
    Map<Long, List<CarAccidentRepair>> repairsByAccidentId = repairs.stream()
            .collect(Collectors.groupingBy(repair -> repair.getCarAccident().getId()));

    /** 4. 주행거리 조회 */
    List<CarMileage> mileages = getQueryFactory()
            .selectFrom(carMileage)
            .where(carMileage.car.id.eq(carId))
            .orderBy(carMileage.recordDate.desc())
            .fetch();

    /** 5. 옵션/안전장치 조회 */
    List<CarOption> options = getQueryFactory()
            .selectFrom(carOption)
            .where(carOption.car.id.eq(carId))
            .fetch();

    /** 6. 소유자/번호 변경이력 조회 */
    List<CarOwnershipChange> ownershipChanges = getQueryFactory()
            .selectFrom(carOwnershipChange)
            .where(carOwnershipChange.car.id.eq(carId))
            .orderBy(carOwnershipChange.changeDate.desc())
            .fetch();

    /** 7. 사용이력 조회 */
    CarUsage usage = getQueryFactory()
            .selectFrom(carUsage)
            .where(carUsage.car.id.eq(carId))
            .fetchFirst();

    /** 래퍼 클래스로 감싸 데이터만 리턴, 서비스 계층에서 후처리 */
    return Optional.of(new CarDetailFetchResult(
            result,
            images,
            accidents,
            repairsByAccidentId,
            mileages,
            options,
            ownershipChanges,
            usage
    ));

 

차량 상세 조회 쿼리이다.

내 엔티티 관계는 전부 Lazy Loading으로 설정되어 있다. 따라서 연관 엔티티에 접근할 때마다 추가 쿼리가 발생하고 이것은 N+1 문제로도 이어질 수 있는데, 이 추가 쿼리를 최소화 시켜보고 싶었다.

 

ToOne 관계는 fetchJoin으로 조회하여 데이터를 한 번에 로드해, 추가 쿼리와 N+1 문제를 방지했다.

ToMany 관계는 fetchJoin 사용 시 카테시안 곱 문제가 발생할 수 있다. 따라서 별도 쿼리로 분리하여 카테시안 곱 문제를 방지했고, 아래에서 설명할 BatchSize 설정을 통해 추가 쿼리와 N+1 문제를 최소화했다.

 

이렇게 DB에서는 데이터 조회만 수행했으며, 마지막으로 래퍼 클래스로 감싸 조회 결과를 리턴했다.

데이터 후처리는 서비스 계층에서 담당한다.

 

쿼리 결과를 담는 래퍼 클래스

// dto/repository/CarDetailFetchResult

/** 차량 상세조회 결과 래핑 레코드 입니다.
 * CarListingRepositoryCustomImpl - getCarDetailById(Long listingId)
 */
public record CarDetailFetchResult (
        CarListing carListing,
        List<CarListingImage> images,
        List<CarAccident> accidents,
        Map<Long, List<CarAccidentRepair>> repairsByAccidentId,
        List<CarMileage> mileages,
        List<CarOption> options,
        List<CarOwnershipChange> ownershipChanges,
        CarUsage usage
) {

}

 

쿼리 결과를 담기 위한 래퍼 클래스이다.

생성자와 getter만 있으면 되기 때문에 record로 작성하였다.

위에서 작성한 쿼리의 결과가 이 클래스에 전부 담겨 리턴되며, 서비스 계층에서 응답용 dto 형태로 후처리된다.

 

서비스 메서드 작성

// service/facade/CarFacadeDummyServiceImpl

/**
 * 차량 관련 Facade 서비스 구현체 입니다.
 * - 더미 데이터 입력용으로, 개발환경일 때만 활성화됩니다.
 */
@Service
@Profile("dev")
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class CarFacadeDummyServiceImpl implements CarFacadeDummyService {


	// 생략...
	// 생략...


    /**
     * 판매 차량 상세정보를 조회합니다.
     * QueryDSL을 사용하여 데이터를 가져온 후, 서비스에서 매퍼를 통해 DTO로 변환됩니다.
     *
     * @param listingId 차량 판매등록 ID
     */
    @Override
    public CarDetailResponseDto getCarDetail(Long listingId) {

        /** 차량 상세정보 엔티티들 조회 */
        CarDetailFetchResult data = carListingRepository.getCarDetailById(listingId)
                .orElseThrow(() -> new NotFoundException(ErrorMessages.CAR_LISTING_NOT_FOUND));

        /** DTO로 변환 */
        CarDetailResponseDto carDetail = carMapper.toCarDetailResponseDto(data.carListing());

        /** 나머지 데이터 매핑 */
        processImages(data.images(), carDetail);
        carDetail.setAccidents(carMapper.toCarAccidentWithRepairsDtos(data.accidents(), data.repairsByAccidentId()));
        carDetail.setMileages(carMapper.toCarMileageDtos(data.mileages()));
        carDetail.setOptions(carMapper.toCarOptionDtos(data.options()));
        carDetail.setOwnershipChanges(carMapper.toCarOwnershipChangeDtos(data.ownershipChanges()));
        if (data.usage() != null) {
            carDetail.setUsage(carMapper.toCarUsageDto(data.usage()));
        }

        return carDetail;
    }

    /**
     * 이미지 데이터를 받아 메인/일반 이미지로 구분하여 dto에 담습니다.
     */
    private void processImages(List<CarListingImage> images, CarDetailResponseDto dto) {

        String mainImageUrl = null;
        List<String> additionalImageUrls = new ArrayList<>();

        for (CarListingImage image : images) {
            if (image.getIsPrimary()) {
                mainImageUrl = image.getImageUrl();
            }
            else {
                additionalImageUrls.add(image.getImageUrl());
            }
        }

        dto.setMainImageUrl(mainImageUrl);
        dto.setAdditionalImageUrls(additionalImageUrls);
    }
}

 

서비스 계층에서는 방금 만든 쿼리 메서드를 호출하고 데이터를 받아와서, 엔티티 -> dto 매핑 작업을 수행한다.

 

 

매핑 작업은 별도의 매퍼 클래스인 carMapper를 호출하여 수행하게 된다.

fetchJoin()결과로 얻은 CarListing만 빼서 응답 dto로 변환한다.

그리고 별도 쿼리로 얻은 나머지 데이터들을, 각 엔티티별 매퍼 메서드를 이용해 변환 후 setter로 응답 dto에 담는다.

 

추가로, processImages 메서드는 메인/일반 이미지를 구분하여 dto에 담기 위한 private 메서드이다.

 

매퍼 메서드 작성

// mapper/CarMapper

@Mapper(config = BaseMapperConfig.class, uses = {EntityMapper.class})
public interface CarMapper {


    // 생략..


    /** to Dto */
    @Mapping(source = "car.id", target = "carId")
    @Mapping(source = "car.carName", target = "carName")
    @Mapping(source = "car.carNumber", target = "carNumber")
    @Mapping(source = "car.displacement", target = "displacement")
    @Mapping(source = "car.fuelType", target = "fuelType")
    @Mapping(source = "car.year", target = "year")
    @Mapping(source = "car.month", target = "month")
    @Mapping(source = "car.bodyShape", target = "bodyShape")
    @Mapping(source = "car.modelType", target = "modelType")
    @Mapping(source = "car.firstInsuranceDate", target = "firstInsuranceDate")
    @Mapping(source = "car.identificationNumber", target = "identificationNumber")
    @Mapping(source = "car.minPrice", target = "minPrice")
    @Mapping(source = "car.maxPrice", target = "maxPrice")
    @Mapping(source = "car.model.brandName", target = "brandName")
    @Mapping(source = "car.model.modelName", target = "modelName")
    @Mapping(source = "car.model.isForeign", target = "isForeign")
    @Mapping(source = "user.id", target = "sellerId")
    @Mapping(source = "user.username", target = "sellerUserName")
    @Mapping(target = "mainImageUrl", ignore = true)
    @Mapping(target = "additionalImageUrls", ignore = true)
    @Mapping(target = "accidents", ignore = true)
    @Mapping(target = "mileages", ignore = true)
    @Mapping(target = "options", ignore = true)
    @Mapping(target = "ownershipChanges", ignore = true)
    @Mapping(target = "usage", ignore = true)
    CarDetailResponseDto toCarDetailResponseDto(CarListing carListing);

    /** 사고이력, 사고당 수리이력을 합쳐 Dto로 변환합니다. */
    default List<CarAccidentWithRepairsDto> toCarAccidentWithRepairsDtos(
            List<CarAccident> accidents,
            Map<Long, List<CarAccidentRepair>> repairsByAccidentId
    ) {
        if (accidents == null) {
            return null;
        }

        return accidents.stream()
                .map(accident -> {
                    CarAccidentWithRepairsDto dto = toCarAccidentWithRepairsDto(accident);

                    // 사고별 수리이력 추가
                    List<CarAccidentRepair> repairs = repairsByAccidentId.getOrDefault(accident.getId(), List.of());
                    dto.setRepairs(toCarAccidentRepairDtos(repairs));

                    return dto;
                })
                .collect(Collectors.toList());
    }
    @Mapping(target = "repairs", ignore = true)
    CarAccidentWithRepairsDto toCarAccidentWithRepairsDto(CarAccident accident);
    List<CarAccidentRepairDto> toCarAccidentRepairDtos(List<CarAccidentRepair> repairs);

    List<CarMileageDto> toCarMileageDtos(List<CarMileage> mileages);
    @Mapping(target = "carId", ignore = true)
    CarMileageDto toCarMileageDto(CarMileage mileage);

    List<CarOptionDto> toCarOptionDtos(List<CarOption> options);
    @Mapping(target = "carId", ignore = true)
    CarOptionDto toCarOptionDto(CarOption option);

    List<CarOwnershipChangeDto> toCarOwnershipChangeDtos(List<CarOwnershipChange> ownershipChanges);
    @Mapping(target = "carId", ignore = true)
    CarOwnershipChangeDto toCarOwnershipChangeDto(CarOwnershipChange ownershipChange);

    @Mapping(target = "carId", ignore = true)
    CarUsageDto toCarUsageDto(CarUsage usage);
}

 

매퍼 클래스이다.

toDto 메서드에 들어갈 인자 엔티티들은 반드시 @Getter 메서드가 있어야 MapStruct 매퍼가 동작한다.

 

BatchSize 설정

// application.properties

// BatchSize를 100으로 설정 ( 최대 100개의 연관 엔티티를 한 번의 쿼리로 로드 )
spring.jpa.properties.hibernate.default_batch_fetch_size=100

 

이제 BatchSize를 설정해 준다.

ToMany 관계에 있는 엔티티들을 조회할 때 N+1 문제를 방지하기 위한 효과적인 방법이다.

 

구현 완료, Postman 요청 결과

postman 요청 결과

 

정상적으로 응답이 온 모습이다.

 

 

마무리

정말 쿼리 짜는 건 너무 어려운 것 같다. 10개나 되는 엔티티를 조회하니만큼 성능을 신경쓰지 않을 수가 없었다. 코드를 짜는 데 걸리는 시간도 길지만, 코드를 짜며 배운 것들을 정리하는 시간도 그에 못지 않게 긴 것 같다. 그래도 이렇게 정리하다 보면 내가 뭘 모르는지, 확실히 이해하고 있는건지 확인할 수 있고 기억에 오래 남는다. 근데.. 이런 식으로 진행하니 정말 구현이 너무 지체된다. 그래도, 죄다 처음 해 보는 거니까 오래 걸리는 거지 나중에 다시 할때는 금방금방 할 거라고 생각한다.

 

 

=============

 

 

 

 

728x90
반응형