[TriFly] 항공권 필터링

진욱·2024년 10월 10일

TriFly

목록 보기
4/4
post-thumbnail

항공권 정렬 및 필터 구현

항공권 검색 후 사용자가 설정한 조건(직항, 출발시간, 도착시간, 항공사, 가격대)에 따라 원하는 검색 결과를 확인할 수 있는 필터를 구현하였다. 또한 사용자가 선택한 기준에 맞게 검색 결과를 정렬하는 기능도 추가하였다.

정렬 및 필터 적용 결과가 즉시 화면에 반영되도록 설계하였으며, 반응형 UI를 기본으로 하는 우리 서비스의 특성상 필터 역시 PC, 모바일 모두에서 편리하게 이용할 수 있도록 개발했다.


UI 및 구현

전체

전체적인 UI는 다음과 같다. PC 화면에서는 검색 결과 좌측에 필터를 배치하고 상단에 정렬 Select Box를 배치하여 적용 결과를 바로 확인할 수 있게 하였고, 모바일에서는 더 편리한 UX를 제공하기 위해 모달 형식으로 정렬 및 필터를 띄워 선택 완료 클릭 시 결과 화면을 확인할 수 있게 하였다.

모바일

직항/경유 필터

직항/경유 필터는 공통컴포넌트인 Badge의 스타일을 사용하되 토글 버튼의 기능을 가지도록 설계했다. 당연히 사용자가 경유편을 포함하여 검색했을 경우만 나타나게 하였다. 검색창에서 "직항만 검색"을 체크했을 경우 이 필터는 사용자에게 보이지 않는다.

직항 필터링
const handleNonStopChange = () => {
  setIsNonStop(!isNonStop);
  handleFilterChange({ nonStop: !isNonStop });
};
적용 전적용 후

출/도착시간 필터

처음에 디자인 시안을 보고 "어 어떡하지 CSS 덩어리다"라고 생각했지만 이번 프로젝트를 진행하며 익숙해진 덕분인지 생각보다 구현하는 것이 어렵지는 않았던 출/도착시간 필터이다. checkbox 타입의 input 요소를 활용하였으며 각 선택 영역은 label을 이용하여 설계하였다. 시간대별 눈금은 시간 범주 요소에 ::after 가상 요소를 활용하여 추가하였다.

출/도착시간 필터링

input에 이벤트가 발생하면 setTime 함수를 통해 출/도착 시간 상태를 변경하는데, 이 setter 함수 내에서 filter 변경 함수를 호출하면 setter 내부에서 또 한 번 setter를 호출하여 상태 관리에서 문제가 발생하기 때문에 useEffect 훅을 이용해 출/도착 시간 상태가 업데이트 될 경우 filter 변경 함수를 호출하는 로직을 추가하였다.

useEffect(() => {
  handleFilterChange({ originDepTime });
}, [originDepTime]);

const handleOriginDepChange = (e: ChangeEvent<HTMLInputElement>) => {
  const value = Number(e.target.value);

  setOriginDepTime((prev) => {
    if (!prev) {
      return [value];
    }

    if (e.target.checked) {
      return [...prev, value];
    }
    return prev.filter((time) => time !== value);
  });
};
적용 전적용 후

항공사 필터

항공사 필터 부분이 구현에서 가장 어려웠던 요소인 것 같다. 모두선택/모두해제 버튼을 구현하였고 각 동맹체를 체크하면 그에 해당하는 항공사들이 선택 또는 취소되도록 하였다. 물론 항공사 하나하나 선택도 가능하다.

동맹체/항공사 필터링

초기 데이터를 설계할 때 각 항공사 정보에 항공 동맹체 정보를 다음과 같이 추가했었다.

⬇️ 아래는 항공사 데이터의 타입

export interface AirlineData {
  code: string;
  value: string;
  nameKor: string;
  nameEng: string;
  allianceKor: "스카이팀" | "스타얼라이언스" | "원월드" | "";
  allianceEng: "Skyteam" | "Star Alliance" | "oneworld" | "";
  carrierType: "FSC" | "LCC";
}

위 타입에 따라 설계된 항공사 데이터에 들어있는 동맹체 정보를 바탕으로, 특정 동맹체 선택 시 동맹체에 속한 항공사들이 일괄적으로 선택 또는 취소될 수 있는 코드를 구현하는 것이 항공사 필터 구현의 핵심이었다고 할 수 있을 것이다!

useEffect(() => {
  const updatedAirlines: string[] = [];

  allianceCheck.forEach((alliance) => {
    if (alliance.checked) {
      carrierCodes.forEach((carrierCode) => {
        if (airline[carrierCode].allianceEng === alliance.name) {
          updatedAirlines.push(carrierCode);
        } else if (
          airline[carrierCode].allianceEng === "" &&
          alliance.name === "others"
        ) {
          updatedAirlines.push(carrierCode);
        }
      });
    }
  });

  setSelectedAirlines(updatedAirlines);
}, [allianceCheck]);
적용 전적용 후

가격 필터

가격 필터는 발자국 페이지의 항공권 꾸미기에 사용되는 펜 두께 설정 슬라이드를 공통으로 사용하여 구현하였다. 처음 개발 시에는 탑승객 전체 가격대라는 명시도 없었고, 가장 중요한 부분인 가격 표시 부분에서 검색 결과 중 최고가를 보여준 것이 아니라 임의로 설정한 가격을 보여주는 문제가 있었지만 QA와 리팩토링 과정을 거치며 최고가를 보여줄 수 있게 수정할 수 있었다.

항공권 가격 필터링

가격 슬라이드의 핵심은 항공권 가격 / 최고가를 계산하여 슬라이드의 움직임을 표현하는 것이었는데, 아래와 같이 e.target 요소의 스타일 속성에 접근하여 backgroundlinear-gradient를 조정하는 방식으로 만들어 낼 수 있었다.

const handlePriceChange = (e: ChangeEvent<HTMLInputElement>) => {
  const { value, style, max } = e.target;
  const newMaxPrice = Number(value);
  setMaxPrice(newMaxPrice);
  handleFilterChange({ maxPrice: newMaxPrice });
  const percent = 100 / +max;
  style.background = `linear-gradient(to right, var(--color-primary) 0%, var(--color-primary) ${percent * +value}%, var(--color-gray-50) ${percent * +value}%, var(--color-gray-50) 100%`;
};
적용 전적용 후

정렬

정렬 컴포넌트는 팀원(소정님)이 공통으로 스타일을 지정한 select 요소를 사용하였다. "가격 낮은 순", "비행시간 짧은 순", "출발/도착 시간 빠른 순" 등의 기준으로 검색 및 필터링 된 항공권을 정렬하는 기능을 구현하였다. 다음과 같이 케이스를 나누고 케이스 별 정렬 알고리즘을 설계하였는데 비행시간 등의 정보가 객체 내부에 있는 배열의 0번째, 1번째 등으로 중첩된 구조 내부에 존재하다보니 코드가 상당히 복잡해 보이긴 한다,,

case "priceLow":
  sortedData.sort(
    (a, b) => Number(a.price.grandTotal) - Number(b.price.grandTotal),
  );
  break;

case "durationShort":
  sortedData.sort((a, b) => {
    const durationA = a.itineraries.reduce(
      (acc, itinerary) =>
      acc +
      itinerary.segments.reduce(
        (segAcc, segment) =>
        segAcc + convertToMinutes(segment.duration),
        0,
      ),
      0,
    );

    const durationB = b.itineraries.reduce(
      (acc, itinerary) =>
      acc +
      itinerary.segments.reduce(
        (segAcc, segment) =>
        segAcc + convertToMinutes(segment.duration),
        0,
      ),
      0,
    );

    if (durationA !== durationB) {
      return durationA - durationB;
    }
    
    const priceA = Number(a.price.grandTotal);
    const priceB = Number(b.price.grandTotal);

    return priceA - priceB;
  });
  break;

...
적용 전적용 후

필터링 성능 개선

정렬 및 필터링 정보 유지

처음 정렬 및 필터 기능을 개발하고 나서는 잘 작동되는 줄 알았는데, 완성하고 발표까지 마친 후 리팩토링을 하려고 살펴보니 웬 걸! 필터링한 정보를 정렬하는 건 잘 작동하는데 정렬한 이후에 필터를 수정하면 정렬 기준이 내부적으로 초기화되어 하나도 반영이 되지 않는 것이었다. 이유를 보아하니, 정렬은 filteredData라는 필터링 된 데이터를 기준으로 수행하는데 반대로 필터링을 할 때는 그저 업데이트된 filteredData를 사용하다보니 정렬된 것이 반영이 되지 않는 것이었다.

따라서 다음과 같이 정렬 기준이 변경되거나 필터가 변경되는 경우 모두에 대응할 수 있는 코드로 변경하여 정렬 이후 필터를 수정하여도 정렬 기준에 따라 항공권을 보여줄 수 있도록 하였다.

useEffect(() => {
  const sortedAndFilteredData = sortData(applyFilters());
  console.log("정렬 및 필터 변경");
  setFilteredData(sortedAndFilteredData);
}, [filters, sortBy, applyFilters, sortData]);

메모이제이션

컴포넌트 구조

항공권 검색 결과는 다음과 같은 컴포넌트들로 구성되어있다. Result 함수 내에 필터를 업데이트하는 handleFilterChange 함수와 정렬 옵션을 업데이트하는 handleSorting 함수가 존재하며 Filter 컴포넌트와 Sorting 컴포넌트에 props 형태로 전달된다. 따라서 필터가 업데이트 될 때와 정렬 옵션이 업데이트 될 때 전체가 다시 렌더링되어 성능이 떨어지는 문제가 발생하고 있었다.

handleSorting을 메모이제이션

현재는 정렬 옵션이 변경되면 handleSorting이라는 함수가 다시 생성되고 이를 props로 전달받는 Sorting 컴포넌트의 렌더링이 발생하는 상태이다.

  1. handleSorting 함수 자체는 한 번 생성되면 다시 생성될 필요가 없는 함수이므로 useCallback 훅을 이용하여 처음 렌더링 시에만 생성되도록 메모이제이션 해 주었다.
  2. 이렇게만 하면 Sorting 컴포넌트의 렌더링을 막을 수 있는가? 아니다! useCallback으로 함수를 메모이제이션 하더라도 이를 전달받는 자식 컴포넌트를 메모이제이션 하지 않으면 그대로 렌더링이 일어난다. 따라서 Sorting 컴포넌트를 React.memo로 한 번 더 메모이제이션 해 주어야 한다.
메모이제이션 전메모이제이션 후

handleFilterChange 메모이제이션

필터도 마찬가지! handleFilterChange라는 함수가 다시 생성되고 이를 props로 전달받는 Filter 컴포넌트에서 다시 렌더링이 일어나고 있으므로 handleFilterChangeuseCallback으로 감싸고 자식 컴포넌트를 React.memo로 메모이제이션하였다.

문제는 그런데도 항공사 필터를 변경할 때, 가격을 변경할 때 다음과 같이 렌더링이 발생하는 것이었다....

항공사로 인한 리렌더링 발생가격으로 인한 리렌더링 발생

나는 가장 상위의 Result 컴포넌트에서 data라는 props로 검색 결과를 전달받은 이후에 data가 변경되지 않으면 해당 data로부터 만들어지는 carrierCodesprices 변수는 그대로 유지된다고 생각했는데, 그것이 아니었다.

정렬이나 필터 변경 시 Result 컴포넌트 역시 다시 렌더링되므로 carrierCodesprices도 다시 생성되고 따라서 이 변수들로부터 상태를 생성하는 항공권 필터와 가격 필터에서 또 한 번 렌더링이 일어나고 있었다.

따라서 carrierCodes 변수와 prices 변수를 다음과 같이 메모이제이션하였다.

const carrierCodes = useMemo(
  () => [...new Set(extractCarrierCodes(data))],
  [data],
);

const prices = useMemo(() => {
  const priceList: number[] = [];
  data.forEach((item) => priceList.push(Number(item.price.grandTotal)));
  return priceList;
}, [data]);

추가적으로 필터의 요소인 직항/경유 필터, 출발시간/도착시간 필터, 항공사 필터, 가격 필터를 모두 컴포넌트로 분리하여 각각 메모이제이션하여 하나의 필터가 변경되었을 때 다른 필터들이 불필요하게 렌더링되지 않도록 하여 성능을 개선하였다. 그 결과 항공권 결과를 로딩하고 정렬과 필터를 변경했을 때 다음과 같이 컴포넌트가 렌더링되는 모습을 확인할 수 있었다.

초기 렌더링

초기 렌더링 시 Result 컴포넌트, Sorting 컴포넌트, Filter 컴포넌트가 차례로 렌더링되며 setter가 있는 항공사 필터, 가격 필터로 인해 렌더링이 한 번씩 더 발생하게 된다.

정렬 변경 시

정렬 옵션이 변경되었을 때는 굳이 변경될 필요 없는 필터는 다시 렌더링이 발생하지 않는다.

필터 변경 시

필터 변경 시에도 변경된 해당 필터만 다시 렌더링되고 항공권 검색 결과를 제외한 나머지 컴포넌트들은 렌더링되지 않도록 하였다.

최종

전체적으로 정렬 및 필터가 동작하는 모습이다.


필터링 내용 저장

작업 중...

필터링 내용을 웹 스토리지, 그 중 세션 스토리지를 이용해서 저장하도록 구현하였으나, 필터 성능을 개선하는 작업을 진행하면서 일단 코드에서 배제한 상태이다.

먼저 필터는 항공권 조회 결과를 사용자가 설정한 조건에 맞게 필터링할 때 사용하는데, 이를 항공권 결제 전 단계까지 유지한다면 사용자가 항공권을 구매하려다 다시 조회 결과 화면으로 돌아가더라도 이전에 검색하던 정보가 남아있어서 사용성을 높일 수 있을 것이라고 판단했다.

따라서 url이 resultorder일 때는 필터를 세션 스토리지에 저장하고, 그 이외에는 스토리지 내부의 데이터를 삭제하는 FilterStateManager를 먼저 설계하였다.

"use client";

import { usePathname } from "next/navigation";
import { useEffect } from "react";
import { useSetRecoilState } from "recoil";
import { filterState } from "./atoms/atoms";

const FilterStateManager = () => {
  const pathname = usePathname();
  const setFilters = useSetRecoilState(filterState);

  useEffect(() => {
    if (!pathname.includes("result") && !pathname.includes("order")) {
      setFilters({});
    }
  }, [pathname]);

  return null;
};

export default FilterStateManager;

또한 RecoilWrapper 안에 이 FilterStateManager를 위치시켜 전역 상태에 접근할 수 있도록 했다.

import FilterStateManager from "./FilterStateManager";

const RecoilRootWrapper = ({ children }: { children: React.ReactNode }) => {
  return (
    <RecoilRoot>
      {children}
      <FilterStateManager />
	  ...
    </RecoilRoot>
  );
	

이제 필터링 정보를 세션 스토리지에 담아주면 되므로 recoil의 atom에 다음과 같은 FilterProps 타입을 지정하고 filterState 상태를 생성해주었다.

export interface FilterProps {
  nonStop?: boolean;
  originDepTime?: number[];
  originArrTime?: number[];
  returnDepTime?: number[];
  returnArrTime?: number[];
  airline?: string[];
  maxPrice?: number;
}

export const filterState = atom<FilterProps>({
  key: "filterState",
  default: {},
  effects_UNSTABLE: [sessionPersistAtom],
});

이후 작업은 현재 컴포넌트 분리가 완료된 상태이기 때문에 이어서 작업 후 작성 예정이다!

0개의 댓글