자동차 견적 시스템 만들기
자동차 견적을 정확하게 뽑는 건 쉽지 않지만, 데이터를 기준으로 대략적인 시세를 추정하는 건 가능하다. 이제 사용자가 보낸 조건에 따라 유사한 자동차를 필터링하고 평균값을 계산해 견적을 산출하는 시스템을 설계해보자.
이 과정의 핵심은 정확한 시세 추정보다는, TypeORM을 통해 복합적인 조건의 쿼리를 어떻게 작성하고 실행할 수 있는지를 배우는 것에 있다.
견적 생성 로직은 아래 다이어그램처럼 생각할 수 있다.
•
각 단계는 다음과 같은 목적을 갖는다.
1.
제조사 + 모델이 동일한 보고서만 필터링
•
완전히 동일한 차종만 대상으로 삼기
2.
경도/위도 기준 ±5도 범위 필터링 (약 300마일 이내, 약 480km 이내)
•
최대한 지리적으로 가까운 차량 선택
3.
연도 기준 ±3년 필터링
•
신차/구형차 차이를 줄이기 위한 조치
4.
마일리지 차이순 정렬
•
주행 거리 차이가 적은 차량 우선 선택
5.
상위 3개의 평균 가격 반환
•
상위 3개 차량 평균으로 최종 견적 계산
견적 생성을 위한 쿼리 작성하기
이제 위 기준을 기반으로 실제 쿼리를 작성해보자.
ReportsService에 createEstimate() 메서드를 만든다.
// reports.service.ts
createEstimate(estimateDto: GetEstimateDto) {
return this.repo
.createQueryBuilder()
.select('*')
.where('make = :make', { make: estimateDto.make })
.getRawMany();
}
TypeScript
복사
이 쿼리는 make가 사용자가 입력한 값과 일치하는 모든 레코드를 반환한다.
파라미터 바인딩
참고로, :make는 파라미터 바인딩을 위한 문법이다. { make: estimateDto.make }는 실제 값을 바인딩하여 SQL 인젝션 공격을 방지하는 역할을 한다.
컨트롤러에서 위 메서드를 호출하도록 한다.
// reports.controller.ts
@Get('/estimates')
getEstimate(@Query() query: GetEstimateDto) {
return this.reportsService.createEstimate(query);
}
TypeScript
복사
이제 API 클라이언트를 통해 다음과 같이 요청을 보내면 제조사(make)가 도요타(toyota)인 모든 차량을 받을 수 있다. 이때 make를 다른 제조사로 바꿔서 보내면 데이터가 없기 때문에 빈 배열이 반환된다.
GET http://localhost:3000/reports?make=toyota&model=corolla&lng=0&lat=0&mileage=20000&year=1980
TypeScript
복사
이제 남은 조건들을 추가해보자.
두 번째 조건을 추가하고 싶다면 where() 대신 andWhere()를 사용해야 한다. where()를 두 번 호출하면 앞의 조건이 덮어쓰기 되기 때문이다. 즉, 오버라이딩된다.
.andWhere('report.model = :model', { model: estimateDto.model })
TypeScript
복사
위치 데이터를 활용해 경도(lng), 위도(lat)가 ±5도 범위에 있는 보고서만 조회한다.
.andWhere('ABS(report.lng - :lng) BETWEEN 0 AND 5', { lng: estimateDto.lng })
.andWhere('ABS(report.lat - :lat) BETWEEN 0 AND 5', { lat: estimateDto.lat })
TypeScript
복사
마찬가지로 연도는 ±3년 범위를 기준으로 필터링한다.
.andWhere('ABS(report.year - :year) BETWEEN 0 AND 3', { year: estimateDto.year })
TypeScript
복사
마일리지를 기준으로 입력값과 가장 가까운 보고서를 찾기 위해 절댓값 차이 기준으로 정렬한다.
이때 orderBy()는 매개변수 객체를 두 번째 인자로 받을 수 없기 때문에 .setParameters()를 사용해야 한다.
.orderBy('ABS(report.mileage - :mileage)', 'ASC')
.setParameters({ mileage: estimateDto.mileage });
TypeScript
복사
상위 3개 결과만 조회하기 위해 .limit()을 이용한다.
.limit(3)
TypeScript
복사
최종적으로 조회된 보고서들에서 평균 가격을 계산하려면 AVG(price)를 select()에 추가하고, getRawOne()으로 결과를 하나의 행으로 받는다.
.select('AVG(report.price)', 'price')
.getRawOne();
TypeScript
복사
전체 쿼리는 아래와 같이 구성된다. DTO를 분리하여 사용함으로써 데이터 구조를 명확히 하고, 유효성 검사를 쉽게 수행할 수 있게 했다.
createEstimate({ make, model, lng, lat, year, mileage }: GetEstimateDto) {
return this.repo
.createQueryBuilder()
.select('AVG(price)', 'price')
.where('make = :make', { make })
.andWhere('model = :model', { model })
.andWhere('ABS(lng - :lng) BETWEEN 0 AND 5', { lng })
.andWhere('ABS(lat - :lat) BETWEEN 0 AND 5', { lat })
.andWhere('ABS(year - :year) BETWEEN 0 AND 3', { year })
.orderBy('ABS(mileage - :mileage)', 'ASC')
.setParameters({ mileage })
.limit(3)
.getRawOne();
}
TypeScript
복사
견적 논리 테스트하기
먼저, 견적 생성을 위해 몇 가지 자동차 데이터를 생성한다.
•
첫 번째 차량
{
"make": "ford",
"model": "mustang",
"year": 1980,
"mileage": 50000,
"lng": 45,
"lat": 45,
"price": 10000
}
JSON
복사
•
두 번째 차량
{
// ... 나머지 속성은 첫 번째 차량과 동일
"year": 1981,
"price": 15000
}
JSON
복사
•
세 번째 차량
{
// ... 나머지 속성은 첫 번째 차량과 동일
"year": 1982,
"price": 20000
}
JSON
복사
이제 이 데이터를 바탕으로 견적을 생성하는 쿼리를 실행하면, 예상대로 평균 가격인 15,000이 나오는 것을 확인할 수 있다.
GET http://localhost:3000/reports?make=ford&model=mustang&lng=45&lat=45&mileage=20000&year=1981
JSON
복사
{
"price": 15000
}
JSON
복사
하지만, 견적을 생성하는 과정에서 사용되는 데이터들은 반드시 관리자의 승인을 받은 데이터여야만 한다. 따라서, 쿼리문에 approved IS TRUE 조건을 추가한 후, 승인 된 보고서만을 반영하는 방식으로 견적 생성이 제대로 동작하는지도 확인해보자.
// reports.service.ts
createEstimate({ make, model, lng, lat, year, mileage }: GetEstimateDto) {
return this.repo
.createQueryBuilder()
.select('AVG(price)', 'price')
.where('make = :make', { make })
.andWhere('model = :model', { model })
.andWhere('ABS(lng - :lng) BETWEEN 0 AND 5', { lng })
.andWhere('ABS(lat - :lat) BETWEEN 0 AND 5', { lat })
.andWhere('ABS(year - :year) BETWEEN 0 AND 3', { year })
.andWhere('approved IS TRUE')
.orderBy('ABS(mileage - :mileage)', 'ASC')
.setParameters({ mileage })
.limit(3)
.getRawOne();
}
TypeScript
복사
다시 견적을 생성해보면 생성한 세 가지 데이터 모두 승인되지 않은 보고서이기 때문에 값을 계산할 수 없어 null이 나오는 것을 확인할 수 있다.
{
"price": null
}
TypeScript
복사
이제 애플리케이션에서 필요한 코드가 모두 작성되었다. 인증 논리와 보고서 논리도 마무리되었고, 모든 기능이 정상적으로 작동함을 확인했다. 따라서 애플리케이션 배포를 준비할 수 있는 상태가 되었다.