차량 상세조회 기능을 구현하며 차량 관련 엔티티 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 요청 결과
정상적으로 응답이 온 모습이다.
마무리
정말 쿼리 짜는 건 너무 어려운 것 같다. 10개나 되는 엔티티를 조회하니만큼 성능을 신경쓰지 않을 수가 없었다. 코드를 짜는 데 걸리는 시간도 길지만, 코드를 짜며 배운 것들을 정리하는 시간도 그에 못지 않게 긴 것 같다. 그래도 이렇게 정리하다 보면 내가 뭘 모르는지, 확실히 이해하고 있는건지 확인할 수 있고 기억에 오래 남는다. 근데.. 이런 식으로 진행하니 정말 구현이 너무 지체된다. 그래도, 죄다 처음 해 보는 거니까 오래 걸리는 거지 나중에 다시 할때는 금방금방 할 거라고 생각한다.
=============
썬카 노션
https://lava-move-d1e.notion.site/SunCar-1a754e6b788180f598cdea3bfaff3139?pvs=4
깃허브
'Development > 썬카(SunCar) - 개인 프로젝트' 카테고리의 다른 글
[썬카/백엔드] 서비스 계층 및 커스텀 리파지터리 테스트 코드 작성, 팩토리/빌더 클래스 설계와 영속성 컨텍스트, Hibernate 통계 관리(Statistics) (0) | 2025.04.18 |
---|---|
[썬카/정기 회고] 스프린트 3 종료 - 객체지향적 설계와 쿼리 최적화 (3) | 2025.04.09 |
[썬카/백엔드] 차량 관련 Facade 서비스 인터페이스 세분화 (LSP, ISP 원칙) (0) | 2025.04.04 |
[썬카/백엔드] 차량 판매등록 기능 구현 - 더미 데이터 생성과 객체지향적 설계 (0) | 2025.04.03 |
[썬카/백엔드] DTO <-> 엔티티 변환을 위한 매퍼 클래스 도입 및 적용 (0) | 2025.04.02 |