SIDE PROJECT/AUTOFINDER

차량의 필터링(조건 검색) 기능 추가

sooindev 2025. 4. 17. 22:13
728x90

2025년 2월 21일에 작성됨

 

 

프로젝트에서 조회 기능까지는 구현을 했었다.
이번에 구현할 필터링 기능에 대해 포스팅하겠다.

 

 

필터링(조건 검색) 기능

프로젝트에 적용할 필터링 기능은 차종, 최소가격, 최대가격, 최소 주행거리, 최대 주행거리, 연식, 연료 타입, 판매 지역에 대한 필터를 적용할 것이다.

차종은 우선 차량의 브랜드, 모델명 모두를 아우르는 범위로 설계를 했다. 이는 나중에 여유가 된다면 브랜드와 모델명으로 나누고 싶긴하다.

최소가격, 최대가격, 최소 주행거리, 최대 주행거리는 사용자가 클라이언트(웹)에서 정수단위(Long)로 선택 가능하다.

연식은 크롤링한 중고차 웹페이지에서 21/01년 이런 방식으로 표시되고 있기 때문에 이를 2021년으로 변환해야하는 과정이 필요하다. 사실 구현할 때, 이 과정에서 매우 시간을 많이 소비했다. 이 변환과정은 백엔드에서 처리할지, 프론트엔드에서 처리할지에 대해 코드가 달라지고, 적용 효과도 많이 달라지기 때문에 고민을 좀 했다. 이는 뒤에서 더 자세히 다룰 것이다.

연료타입은 가솔린, 디젤, LPG, 하이브리드, 전기 이렇게 5가지 종류로 구분을 했다.

판매 지역은 우선 사용자가 직접 텍스트박스에 입력하는 방식으로 구현을 하였다.

React에서 필터링을 할 수 있는 UI를 만들고, 사용자가 입력한 값을 API로 보내는 과정을 구현했다.

 

 

필터링 UI 생성

검색 조건을 입력할 수 있는 UI를 구성했다.

import React, { useState } from "react";
import { Link } from "react-router-dom";
import axios from "axios";

const CarList = () => {
    // 차량 목록을 저장하는 상태 변수
    const [cars, setCars] = useState([]);

    // 검색 필터를 저장하는 상태 변수
    const [filters, setFilters] = useState({
        model: "",       // 차량 모델명
        minPrice: "",    // 최소 가격
        maxPrice: "",    // 최대 가격
        minMileage: "",  // 최소 주행거리
        maxMileage: "",  // 최대 주행거리
        fuel: "",        // 연료 타입
        region: "",      // 지역
        year: "",        // 연식
    });

    // 연식 선택을 위한 옵션 배열 (2024년부터 2000년까지)
    const years = Array.from({ length: 25 }, (_, i) => (new Date().getFullYear() - i).toString());

    // 검색 버튼 클릭 시 실행되는 함수
    const handleSearch = () => {
        const params = new URLSearchParams();

        // 필터 값이 존재하는 경우에만 URL 파라미터로 추가
        if (filters.model) params.append("model", filters.model);
        if (filters.minPrice) params.append("minPrice", filters.minPrice);
        if (filters.maxPrice) params.append("maxPrice", filters.maxPrice);
        if (filters.minMileage) params.append("minMileage", filters.minMileage);
        if (filters.maxMileage) params.append("maxMileage", filters.maxMileage);
        if (filters.fuel) params.append("fuel", encodeURIComponent(filters.fuel)); // 한글 인코딩 처리
        if (filters.region) params.append("region", filters.region);
        if (filters.year) params.append("year", filters.year);

        const queryString = params.toString(); // 🔗 최종 검색 URL 쿼리 문자열 생성
        console.log("검색 요청 URL:", `/api/cars?${queryString}`);

        // 서버로 GET 요청 보내기
        axios.get(`/api/cars?${queryString}`, {
            headers: {
                "Content-Type": "application/json",
                "Authorization": `Bearer ${localStorage.getItem("token")}`  // JWT 토큰 포함
            },
            withCredentials: true  // 쿠키 기반 인증 사용 시 필요
        })
            .then(response => {
                console.log("검색 결과:", response.data);
                setCars(response.data.content || []); // 검색 결과 저장
            })
            .catch(error => {
                console.error("차량 검색 오류:", error);
            });
    };

    // 입력값 변경 시 상태 업데이트
    const handleChange = (e) => {
        const { name, value } = e.target;
        setFilters({ ...filters, [name]: value });
    };

    return (
        <div className="container mx-auto p-8">
            {/* 제목 */}
            <h2 className="text-4xl font-bold text-center text-blue-600 mb-6">
                차량 검색 서비스
            </h2>

            {/* 검색 필터 UI */}
            <div className="flex flex-wrap gap-3 justify-center mb-6 p-4 bg-white shadow-md rounded-lg">
                <input type="text" name="model" placeholder="모델명" value={filters.model} onChange={handleChange} className="border p-2 rounded-md"/>
                <input type="number" name="minPrice" placeholder="최소 가격" value={filters.minPrice} onChange={handleChange} className="border p-2 rounded-md"/>
                <input type="number" name="maxPrice" placeholder="최대 가격" value={filters.maxPrice} onChange={handleChange} className="border p-2 rounded-md"/>
                <input type="number" name="minMileage" placeholder="최소 주행거리" value={filters.minMileage} onChange={handleChange} className="border p-2 rounded-md"/>
                <input type="number" name="maxMileage" placeholder="최대 주행거리" value={filters.maxMileage} onChange={handleChange} className="border p-2 rounded-md"/>

                {/* 연식 선택 */}
                <select name="year" value={filters.year} onChange={handleChange} className="border p-2 rounded-md">
                    <option value="">연식 선택</option>
                    {years.map(year => (
                        <option key={year} value={year}>{year}년식</option>
                    ))}
                </select>

                {/* 연료 선택 */}
                <select name="fuel" value={filters.fuel} onChange={handleChange} className="border p-2 rounded-md">
                    <option value="">연료 타입</option>
                    <option value="가솔린">가솔린</option>
                    <option value="디젤">디젤</option>
                    <option value="LPG">LPG</option>
                    <option value="하이브리드">하이브리드</option>
                    <option value="전기">전기</option>
                </select>

                {/* 지역 입력 */}
                <input type="text" name="region" placeholder="지역" value={filters.region} onChange={handleChange} className="border p-2 rounded-md"/>

                {/* 검색 버튼 */}
                <button onClick={handleSearch} className="bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600 transition">
                    검색
                </button>
            </div>

            {/* 검색 결과 UI */}
            {cars.length > 0 ? (
                <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
                    {cars.map(car => (
                        <div key={car.id} className="bg-white shadow-lg rounded-lg p-5 transition transform hover:scale-105 hover:shadow-xl">
                            <h3 className="text-xl font-semibold text-gray-900">{car.model}</h3>
                            <p className="text-gray-600">{car.year} | {car.fuel}</p>
                            <p className="text-gray-800 font-bold mt-2">{car.price?.toLocaleString() ?? "정보 없음"} 만원</p>
                            <p className="text-sm text-gray-500">{car.region}</p>
                            {/* 상세보기 버튼 */}
                            <Link to={`/cars/${car.id}`} className="mt-4 block text-center bg-blue-500 text-white py-2 rounded-md hover:bg-blue-600 transition">
                                상세보기
                            </Link>
                        </div>
                    ))}
                </div>
            ) : (
                <p className="text-center text-gray-600">불러올 차량이 없습니다.</p>
            )}
        </div>
    );
};

export default CarList;

차량 목록을 저장하는 변수, 검색 필터를 저장하는 변수를 각각 선언했다. 그리고 연식을 선택하기 위해 배열도 선언했다.

handleSearch 함수는 검색 버튼을 클릭했을 때에 실행된다. 즉, 필터링을 하는 주요 함수라고 할 수 있다.
사용자가 여러가지 조건 중 선택을 하는 경우도 있고, 안하는 경우도 있을 것이다. 그 경우를 생각해 필터링 값이 존재하는 경우에만 URL 파라미터로 추가하는 로직을 작성했다.
그리고 서버로 GET 요청을 보내게 된다.

나머지 코드는 디자인과 UI와 관련된 코드라 자세한 설명은 생략하겠다.
(백엔드부터 집중적으로 공부하고 싶기에..)

그러면 클라이언트에서 서버로 요청을 보냈다면 서버는 이를 어떻게 처리할까?
백엔드에서 차량 목록을 필터링하는 API 부분을 살펴보겠다.

// 차량 검색 + 필터링 API (페이징 포함)
    @GetMapping
    public ResponseEntity<?> searchCars(
            @RequestParam(required = false) String model,       // 차량 모델명 (예: "제네시스 GV80")
            @RequestParam(required = false) Integer minPrice,   // 최소 가격 (예: 1000)
            @RequestParam(required = false) Integer maxPrice,   // 최대 가격 (예: 5000)
            @RequestParam(required = false) Integer minMileage, // 최소 주행거리 (예: 10000)
            @RequestParam(required = false) Integer maxMileage, // 최대 주행거리 (예: 50000)
            @RequestParam(required = false) String fuel,        // 연료 타입 (예: "가솔린")
            @RequestParam(required = false) String region,      // 지역 (예: "서울")
            @RequestParam(required = false) String year,        // 연식 (예: "2021")
            Pageable pageable) {                                // 페이지네이션 객체

        try {
            // URL 디코딩 (한글 및 특수문자 처리)
            model = Optional.ofNullable(model)
                    .map(value -> URLDecoder.decode(value, StandardCharsets.UTF_8)) // URL 인코딩된 값을 UTF-8로 변환
                    .orElse(null);
            fuel = Optional.ofNullable(fuel)
                    .map(value -> URLDecoder.decode(value, StandardCharsets.UTF_8))
                    .orElse(null);
            region = Optional.ofNullable(region)
                    .map(value -> URLDecoder.decode(value, StandardCharsets.UTF_8))
                    .orElse(null);
            year = Optional.ofNullable(year)
                    .map(value -> URLDecoder.decode(value, StandardCharsets.UTF_8))
                    .map(this::convertYearFormat) // 연식 변환 (예: "2021" → "21/")
                    .orElse(null);

            // 차량 검색 서비스 호출 (필터링된 결과 조회)
            Page<Car> cars = carService.searchCars(model, minPrice, maxPrice, minMileage, maxMileage, fuel, region, year, pageable);

            // 검색 결과가 없을 경우 204 No Content 반환
            if (cars.isEmpty()) {
                return ResponseEntity.status(HttpStatus.NO_CONTENT).body("검색 결과가 없습니다.");
            }

            // 정상적인 검색 결과 반환
            return ResponseEntity.ok(cars);

        } catch (IllegalArgumentException e) {
            // 잘못된 요청 처리 (예: 숫자 형식 오류 등)
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("잘못된 요청: " + e.getMessage());
        } catch (Exception e) {
            // 서버 내부 오류 처리
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("서버 오류 발생: " + e.getMessage());
        }
    }

    /**
     * 연식 변환 메서드 (예: "2021" → "21/")
     * 차량 데이터가 "21/" 형식으로 저장된 경우, 검색 필터에서도 동일한 형식으로 변환해야 함
     */
    private String convertYearFormat(String year) {
        try {
            // 4자리 연도(예: 2021)를 2자리로 변환 후 "/" 추가 (예: "21/")
            if (year.length() == 4) {
                return year.substring(2) + "/";
            }
        } catch (Exception e) {
            System.err.println("연식 변환 오류: " + e.getMessage()); // 오류 발생 시 로그 출력
        }
        return year; // 변환 실패 시 원본 값 유지
    }

차량 모델명, 가격, 주행거리, 연료, 지역, 연식에 대한 매개변수를 등록했다.

그리고 URL을 디코딩한다. 인코딩되어있는 문자열을 UTF-8 형식으로 디코딩한다.
예를 들어, 제네시스 GV80이 URL 인코딩되어 %EC%A0%9C%EB%84%A4%EC%8B%9C%EC%8A%A4%20GV80 이런 식으로 들어올 경우, 이 코드가 실행되면서 원래 한글 "제네시스 GV80"로 변환해준다.

차량 검색 서비스를 호출하고, 여러가지 예외 처리를 했다. 여기에서 예외 처리 경우는 검색 결과가 없을 경우, 잘못된 요청 처리, 서버 내부 오류 처리에 대한 예외들이다.

그리고 메서드가 하나 더 있는데 이 메서드는 연식을 변환해주는 메서드이다. 위에서 언급한 시간이 많이 소요되었던 부분이 이 부분이었다..
사실 개발 과정에서 다양한 자료를 참고하면서 구현에 대한 고민보다는 어느 위치에, 어느 과정에 해당 메서드가 사용되어야할까에 대한 고민이 더 컸다. 크게 두 가지의 방식으로 변환을 할 수 있었다.

 

1. 백엔드에서 연식을 변환하는 경우

  • 프론트엔드에서 입력한 연식 데이터를 백엔드에서 받아서 변환 후 데이터베이스에서 검색할 때 사용한다.
  • 예) 사용자가 프론트엔드에서 "2021"을 입력하면, 백엔드에서 "21/"으로 변환한다. 그리고 변환된 값으로 데이터베이스에서 검색한다.

장점

  1. 프론트엔드에서 데이터를 가공할 필요 없이, 백엔드에서 일관되게 처리할 수 있다.
  2. 데이터베이스에서 직접 사용하는 검색 필터를 백엔드에서 최적화할 수 있다.

단점

  1. 프론트엔드에서 입력한 값을 백엔드에서 변환하는 과정에서 예상치 못한 에러가 발생할 수 있다.
  2. 백엔드 로직이 복잡해질 수 있다.

 

2. 프론트엔드에서 연식을 변환하는 경우

  • 사용자가 입력한 연식을 프론트엔드에서 변환 후, 변환된 값을 API 요청 시 백엔드로 전송한다.
  • 예) 사용자가 "2021"을 입력하면 프론트엔드에서 바로 "21/"으로 변환한다. 변환된 값을 /api/cars?year=21/ 형태로 백엔드에 전달한다. 백엔드는 변환된 값을 그대로 사용해서 검색한다.

장점

  1. 백엔드 로직을 단순하게 유지할 수 있다.
  2. 프론트엔드에서 변환 관련 로직을 쉽게 변경 가능하다. (예를 들어 "21/" 대신 "2021년"으로 검색하도록 수정 가능하다.)

단점

  1. 프론트엔드에서 변환 로직을 적용하지 않으면 백엔드에서 검색이 실패할 수 있다. (어쩌면 당연하다)
  2. 여러 프론트엔드(웹, 모바일 등)가 있으면 각각 변환 로직을 구현해야 한다.

 

필터링에 대한 로직 선택

나는 결과적으로 백엔드에서 연식을 변환하는 방식을 택했다. 이유는 백엔드 로직이 복잡해지더라도 다양한 플랫폼(PC, 모바일 등)에서 접속시, 이에 대한 대응이 힘들 것 같다고 판단했기 때문이다. 위에서도 언급했듯이 이 경우 각각의 변환 로직을 구현해야하는데 그러면 코드의 유지보수성이 매우 떨어질 것 같다고 생각했다.

사실 프론트엔드를 공부한다고 하면 앞으로 UI에 대한 측면 보다는 위처럼 JavaScript와 백엔드와 연동되는 부분을 집중적으로 살펴볼 것 같기는 하다.
데이터가 어떻게 오고가는지 궁금하기도 하고 이에 대해 알면 나중에 협업할 때에도 원활히 진행될 것 같다고 생각한다.