신뢰성이 중요한 중고차 거래에 필요한 차량 정보를 담기 위해서는 꽤 많은 테이블이 필요했다.
차량 목록 조회 기능을 구현하는 것에만 5개의 테이블이 엮여있었고, 5개 테이블을 전부 조인해서 데이터를 가져와야 했다.
썬카는 차량 한대 당 메인 이미지 1개와 나머지 이미지들이 존재하며, 대표 이미지 여부는 is_primary 필드로 구분된다.
메인 이미지는 가장 먼저 화면에 보여야 하기 때문에, 나머지 이미지들과 dto 별도의 필드에 담길 필요가 있었다.
따라서 처음엔 메인 이미지를 조회한 후 나머지 이미지를 추가로 조회하는 방식으로 진행했었는데, 이 과정에서 N + 1 문제가 발생했다.
N + 1문제
- 최초 1번의 쿼리로 N개의 레코드를 조회한 후, 추가로 N개의 쿼리를 실행하게 되는 상황
- 자동차가 10대라면 11번의 쿼리지만, 자동차가 1000대일 경우 1001번의 쿼리가 필요하다.
N + 1문제가 심각한 이유
1. 데이터베이스 부하 증가
- DB는 각 쿼리마다 실행 계획을 생성하고 처리하기 때문에, 많은 수의 작은 쿼리들은 하나의 최적화된 쿼리보다 훨씬 많은 리소스를 소모한다.
2. 네트워크 오버헤드
- 각 쿼리마다 DB와 서버 간의 네트워크 왕복이 발생한다.
3. 애플리케이션 지연
- 당연한 소리겠지만, 레코드 수만큼 실행되는 엄청난 수의 쿼리는 매우 긴 지연시간을 초래한다.
기존 코드 ( N + 1 문제 발생 )
// 차량 정보와 메인 이미지 조회 (1번 쿼리)
List<CarListResponseDto> results = getQueryFactory()
.select(...)
.from(carListing)
.join(...)
.fetch();
// 각 차량(N)에 대한 나머지 이미지들 조회 (N번 쿼리)
for (CarListResponseDto dto : results) {
List<String> otherImageUrls = getQueryFactory()
.select(QCarListingImage.carListingImage.imageUrl)
.from(QCarListingImage.carListingImage)
.where(QCarListingImage.carListingImage.carListing.id.eq(dto.getCarListingId())
.and(QCarListingImage.carListingImage.isPrimary.isFalse()))
.fetch();
dto.setOtherImageUrls(otherImageUrls);
}
기존 코드는, 1번의 쿼리로 모든 차량의 기본정보 + 메인 이미지를 가져온 후, 각 차량(N대)에 대하여 나머지 이미지들을 N번 추가로 조회했다.
N + 1 문제의 정확한 예시라고 볼 수 있다. 데이터가 많아질 수록 성능은 기하급수적으로 저하된다.
개선 코드 ( 1번 쿼리, N + 1 문제 해결 )
// 차량 정보와 메인 + 나머지 이미지 정보를 한 번에 조회 (단 1번의 쿼리)
List<Tuple> results = getQueryFactory()
.select(
carListing.id,
// ... 기타 필드들
carListingImage.imageUrl,
carListingImage.isPrimary
)
.from(carListing)
.join(car).on(carListing.car.id.eq(car.id))
// ... 기타 조인들
.join(carListingImage).on(carListing.id.eq(carListingImage.carListing.id))
.fetch();
// 이후 Java 코드에서 결과를 가공
Map<Long, CarListResponseDto> dtoMap = new HashMap<>();
for (Tuple tuple : results) {
/** 쿼리결과에서 listing.id, imageUrl과 대표이미지 여부 가져오기 */
Long carListingId = tuple.get(0, Long.class);
String imageUrl = tuple.get(8, String.class);
Boolean isPrimary = tuple.get(9, Boolean.class);
/** 해당 carListingId의 DTO가 없다면 새로 생성 */
if (!dtoMap.containsKey(carListingId)) {
CarListResponseDto dto = CarListResponseDto.builder()
.carListingId(carListingId)
.price(tuple.get(1, BigDecimal.class))
.carName(tuple.get(2, String.class))
.fuelType(tuple.get(3, String.class))
.year(tuple.get(4, Integer.class))
.month(tuple.get(5, Integer.class))
.brandName(tuple.get(6, String.class))
.distance(tuple.get(7, Integer.class))
.build();
/** 만들어진 dto를 dtoMap에 넣기 */
dtoMap.put(carListingId, dto);
}
/** 대표이미지 여부에 따라 dto 필드에 추가 */
CarListResponseDto dto = dtoMap.get(carListingId);
if (isPrimary) {
dto.setMainImageUrl(imageUrl);
}
else {
/** List의 참조를 get 하기에 add로 값이 변경된다 */
dto.getOtherImageUrls().add(imageUrl);
}
}
좀 길어 보이긴 하는데, 요약해 보자면
기존
- N대의 차량 정보 + 메인 이미지를 1번의 쿼리로 가져옴
- 차량 나머지 이미지를 가져오기 위해, N번 만큼 추가 쿼리
개선
- N대의 차량 정보 + 메인/나머지 이미지를 1번의 쿼리로 모두 가져옴 -> 필요한 데이터를 1번의 쿼리로 전부 꺼냈음
- 가져온 데이터를 메인, 나머지 이미지로 분류하여 dto에 담음 -> Java코드로 직접 가공
이렇게 N + 1 문제를 해결할 수 있었다.
기타 개선사항 ( DB 자원 대신 서버 자원 활용 )
단 한번의 쿼리로 모든 데이터를 가져왔고, 데이터 가공 작업은 서버 측에서 진행하였다.
N + 1 문제도 해결하였지만, DB 자원 대신 서버 자원을 사용하게 한 것도 큰 개선사항 중에 하나이다. ( Java 코드로 직접 가공한 것 )
왜 서버 자원을 사용하게 하는 것이 개선사항인 것일까?
1. 리소스 분배와 확장성
- DB 서버는 일반적으로 애플리케이션 서버보다 확장하기 어렵고 비용이 큼
- 가능하다면 복잡한 연산이나 작업들을 애플리케이션 서버로 옮기는 것이 효율적
2. 메모리 vs 디스크
- 애플리케이션 서버의 메모리 내 처리가, 디스크 기반 DB 작업보다 훨씬 빠름
- 현대 애플리케이션 서버는 일반적으로 대용량 메모리를 가지고 있기 때문에 서버 메모리에서 처리하는 것이 효율적
3. 유연성, 유지보수성
- 데이터 가공 로직을 Java 코드에 두면 수정하기 쉽고 디버깅이 용이함
- 복잡한 쿼리는 유지보수가 어렵고, DB 종속적일 수 있다
이러한 이유들로, 복잡한 연산이나 데이터 가공 등은 웬만하면 애플리케이션 서버에서 처리하는 것이 낫다.
DB는 데이터를 "찾는 것"에 특화되어 있고 자원도 서버에 비해 부족하다.
따라서 최대한 적고 최적화된 쿼리로 데이터를 조회한 후, 가능하다면 데이터 가공 작업은 서버에서 진행하는 것이 좋은 방향일 것이다.
=============
'Development > 썬카(SunCar) - 개인 프로젝트' 카테고리의 다른 글
[썬카/백엔드] DTO <-> 엔티티 변환을 위한 매퍼 클래스 도입 및 적용 (0) | 2025.04.02 |
---|---|
[썬카/정기 회고] 스프린트 2 종료 - N+1 문제 (2) | 2025.03.25 |
[썬카/백엔드] Swagger를 이용한 API 명세서 도입 (0) | 2025.03.22 |
[썬카/백엔드] dotenv를 사용한 중요 데이터 환경변수화 (0) | 2025.03.22 |
[썬카/정기 회고] 스프린트 1 종료 (1) | 2025.03.21 |