[FE] 여러 조건들이 맞물린 복잡한 필터링 기능 깔끔하게 구현하기

osohyun0224·2024년 4월 7일
1

VACGOM 개발이야기

목록 보기
3/4
post-thumbnail

안녕하세요, 대학생 웹 프론트엔드 개발자 가든입니다 ;)

최근에 참가한 해커톤에서 약 21개의 질병 목록들에 대해 지원사업 대상, 나이, 성별 조건 필터를 적용하여 사용자가 선택한 조건에 맞는 질병의 목록들만 보여줘야하는 기능을 구현하게 되었습니다.

해당 기능을 어떻게 구현하였는지, 그리고 구현한 코드를 함께 정리해보겠습니다. Nextjs 14, typescript로 개발하였으니 코드에 참고해주세요 ;)

00. 개발 설계하기

이 기능을 구현하는 방법에는 2가지 방법이 있습니다.

  1. 프론트엔드에서 질병 데이터를 모두 json으로 정의하고 가지고 있다가 사용자의 필터 조건에 의해 전체 데이터를 적용해 보여주기 (전체 데이터를 이미 클라이언트에서 상태 보유해 가지고 있는 상태 -> 필터링 적용 구현을 클라이언트에서 모두 처리)

  2. 프론트엔드에서는 사용자가 필터를 적용할때마다 백엔드에게 api 요청을 통해 특정 조건에 부합하는 질병 데이터만 받아와 보여주기 (첫 페이지 로드시 전체 데이터 api 한번 호출, 사용자가 각각 필터를 적용할때마다 api 각각 호출 -> 백엔드에게 필터링 적용 구현 권한 넘김)

위의 2가지 방법 중에서 저는 1번을 선택해 클라이언트에서 모두 구현하기로 했습니다.
그 이유는 사용자가 필터 조건을 계속 바꿀 상황이 많을 텐데 이때마다 계속 api 호출을 하게 되기 때문에 서버 비용이 절감 차원, 또한 프론트에서 api 호출을 그렇게 많이하는 걸 선호하지 않기 때문이빈다,,,, 프론트가 고생해보자구여 ㅎㅎ

01. 필터링 조건 확인

제가 구현하고자 하는 질병들의 조건 필터링을 정리하면 아래와 같았습니다.
[1] 연령에 대한 필터링 조건

[2] 상황에 대한 필터링 조건

[3] 사용자의 현재 세션이 어떤 국가 예방 접종인지 조건
아래의 사진은 필수 예방 , 국가 예방, 기타 예방 중 필수 예방 입니다

진짜 조건들 별로 보여지는 질병 목록 대상도 다르고, 심지어 같이 보여줘야하는 질병목록도 있었습니..ㅏ,,, 이때 처음 질병 데이터 정리하면서 어떻게 구현해야할지 참 막막했는데 좋은 방법을 하나 생각했습니다.

02. 까다로운 필터링 구현

우선 질병 데이터들에 대해서 까다로운 조건을 각각 정리하는게 필요했고, 그리고 이 조건들을 어떻게 연산해서 알맞게 보여줘야할지 고민했습니다.

따라서 저는 0,1의 연산으로 상태 관리와 조건부 렌더링을 활용하여, 사용자가 선택한 필터 조건에 따라 질병 목록을 동적으로 보여주도록 구현했습니다.

[1] 질병 데이터 정의

아래는 제가 정의한 각 필터 조건에 대해 부합하는 것은 1, 해당하지 않는 것은 0으로 정리한 데이터의 일부분 입니다.

  {
    id: 8,
    iconsImage: Images.ico_vac8,
    vacName: '수두',
    vacDes: '수두는 수두-대상포진 바이러스(Varicella-Zoster virus, VZV)에 의한 일차 감염으로 전염력이 매우 강한 급성 감염질환입니다. 급성의 미열로 시작되고 전신적으로 가렵고 발진성 수포가 발생하는 질환입니다.',
    qaList: [
      {
        id: 1,
        ques:'수두는 어떻게 전파되나요?',
        ans:'수두 바이러스는 호흡기 분비물 등의 비말(미세 침방울, droplet)을 통해 호흡기로 감염되거나 피부 병변 수포액에 직접 접촉함으로써 사람에서 사람으로 전파될 수 있습니다.'
      },
....
    ],
    age: [ 1, 1, 1, 0, 0, 0],
    sit: [ 1,1,	1,1,1,0,0,0,0,1,0,1	,0	,1],
  },
  {
    id: 9,
    iconsImage: Images.ico_vac9,
    vacName: '일본뇌염',
    vacDes: '일본뇌염은 Flavivirus 속 일본뇌염 바이러스(Japanese encephalitis virus)에 의한 인수공통감염병으로 작은빨간집모기(Culex tritaeniorhynchus)에 의해 감염되어 뇌염을 일으키는 질환입니다. 일단 일본뇌염에 걸리면 특별한 치료방법이 없으므로 백신 접종을 통한 예방이 최선입니다.',
    qaList: [
      {
        id: 1,
        ques:'일본뇌염은 어떻게 전파되나요?',
        ans:'일본뇌염은 Flavivirus 속 일본뇌염 바이러스(Japanese encephalitis virus)에 의한 인수공통감염병으로 작은빨간집모기(Culex tritaeniorhynchus)에 의해 감염되어 뇌염을 일으키는 질환입니다. 일단 일본뇌염에 걸리면 특별한 치료방법이 없으므로 백신 접종을 통한 예방이 최선입니다.'
...
    ],
    age: [ 0, 0, 0, 0, 0, 0],
    sit: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1], 
  },

[2] 필터링 구현

위의 데이터를 보면 각각 나이와 상황 조건에 대해 0,1로 조건을 적용하고 이를 적용하는 필터링을 다음과 같이 구현했습니다.

const EssentialDiseaseSection = ({ selectedSection }) => {
  const [ageFilter, setAgeFilter] = useState('전체');
  const [sitFilter, setSitFilter] = useState('해당 없음');
  const [isAgeModalOpen, setIsAgeModalOpen] = useState(false);
  const [isSitModalOpen, setIsSitModalOpen] = useState(false);
  const [selectedAgeOptions, setSelectedAgeOptions] = useState([]);
  const [selectedSitOptions, setSelectedSitOptions] = useState([]);
  const [diseaseList, setDiseaseList] = useState([]);

  useEffect(() => {
    console.log('필터링 시작', { ageFilter, sitFilter });

    const filterDiseases = () => {
      const ageIndex = ageFilter === '전체' ? -1 : ageRanges.indexOf(ageFilter);
      const sitIndex =
        sitFilter === '해당 없음' ? -1 : situationRanges.indexOf(sitFilter);

      const filtered = essentialDiseaseList.filter((disease) => {
        const ageCondition = ageIndex === -1 || disease.age[ageIndex - 1] === 1;
        const sitCondition = sitIndex === -1 || disease.sit[sitIndex - 1] === 1;
        return ageCondition && sitCondition;
      });

      setDiseaseList(filtered);
      console.log('필터링 결과', filtered);
    };

    filterDiseases();
  }, [ageFilter, sitFilter]);

  const handleAgeSelect = (selectedOptions: string[]) => {
    setSelectedAgeOptions(selectedOptions);
    let text = selectedOptions[0] || '전체';

    if (selectedOptions.length > 1) {
      text =
        text.length > 8
          ? `${text.slice(0, 6)}... 외 ${selectedOptions.length - 1}`
          : `${text}${selectedOptions.length - 1}`;
    }
    console.log('Age Filter:', text);
    setAgeFilter(text);
    setIsAgeModalOpen(false);
  };

  const handleSitSelect = (selectedOptions: string[]) => {
    setSelectedSitOptions(selectedOptions);
    let text = selectedOptions[0] || '해당 없음';

    if (selectedOptions.length > 1) {
      text =
        text.length > 8
          ? `${text.slice(0, 6)}... 외 ${selectedOptions.length - 1}`
          : `${text}${selectedOptions.length - 1}`;
    }
    console.log('Situation Filter:', text);
    setSitFilter(text);
    setIsSitModalOpen(false);
  };

  const resetAgeOptions = () => {
    setSelectedAgeOptions([]);
  };

  const resetSitOptions = () => {
    setSelectedSitOptions([]);
  };

  const clearAgeFilter = () => {
    setAgeFilter('전체');
    setSelectedAgeOptions([]);
  };

  const clearSitFilter = () => {
    setSitFilter('해당 없음');
    setSelectedSitOptions([]);
  };

  return (
    <div>
      <FiltersContainer>
        <Image
          src={
            ageFilter === '전체' && sitFilter === '해당 없음'
              ? Images.adjustment_unselec
              : Images.adjustment_selec
          }
          alt="Filter Icon"
          width={24}
          height={24}
        />
        <Filter
          label="연령"
          selectedValue={ageFilter}
          onSelect={() => setIsAgeModalOpen(true)}
          onClear={clearAgeFilter}
          isSelected={ageFilter !== '전체'}
        />
        <Filter
          label="상황"
          selectedValue={sitFilter}
          onSelect={() => setIsSitModalOpen(true)}
          onClear={clearSitFilter}
          isSelected={sitFilter !== '해당 없음'}
        />
      </FiltersContainer>
      <Fragment>
        <FilterModal
          isOpen={isAgeModalOpen}
          title="연령"
          options={ageRanges}
          selectedOptions={selectedAgeOptions}
          onClose={() => setIsAgeModalOpen(false)}
          onOptionSelect={handleAgeSelect}
          onReset={resetAgeOptions}
        />
        <FilterModal
          isOpen={isSitModalOpen}
          title="상황"
          options={situationRanges}
          selectedOptions={selectedSitOptions}
          onClose={() => setIsSitModalOpen(false)}
          onOptionSelect={handleSitSelect}
          onReset={resetSitOptions}
        />
      </Fragment>
      <DiseaseContainer>
        {diseaseList.map((disease) => (
          <DiseaseCard
            key={disease.id}
            id={disease.id}
            diseaseName={disease.vacName}
            imageUrl={disease.iconsImage}
          />
        ))}
      </DiseaseContainer>
    </div>
  );
};

위의 코드가 해커톤 기간내에 하다보니 지금보니까 많이 더럽네요,,, 리팩토링을 얼른 진행하도록 하겠습니다 ㅋㅋㅋ
암튼 코드를 어떻게 구현했는지 살펴보겠습니다.

1) 상태 관리

  const [ageFilter, setAgeFilter] = useState('전체');
  const [sitFilter, setSitFilter] = useState('해당 없음');
  const [isAgeModalOpen, setIsAgeModalOpen] = useState(false);
  const [isSitModalOpen, setIsSitModalOpen] = useState(false);
  const [selectedAgeOptions, setSelectedAgeOptions] = useState([]);
  const [selectedSitOptions, setSelectedSitOptions] = useState([]);
  const [diseaseList, setDiseaseList] = useState([]);
  1. 연령(ageFilter)과 상황(sitFilter) 상태는 사용자가 선택한 연령과 상황 필터 값을 저장하기 위한 상태입니다. 초기값은 각각 '전체'와 '해당 없음'으로 설정되어 모든 조건을 포함하도록 합니다.

  2. 모달 상태(isAgeModalOpen, isSitModalOpen) 상태는 연령과 상황 선택을 위한 모달 창의 표시 상태를 관리합니다.

  3. 선택된 옵션 상태(selectedAgeOptions, selectedSitOptions) 상태는 사용자가 모달에서 선택한 연령과 상황의 옵션을 배열 형태로 저장합니다.

  4. 질병 목록 상태(diseaseList) 상태는 필터링된 결과를 저장하는 상태입니다.

2) 비트연산 필터링 구현

  useEffect(() => {
    console.log('필터링 시작', { ageFilter, sitFilter });

    const filterDiseases = () => {
      const ageIndex = ageFilter === '전체' ? -1 : ageRanges.indexOf(ageFilter);
      const sitIndex =
        sitFilter === '해당 없음' ? -1 : situationRanges.indexOf(sitFilter);

      const filtered = essentialDiseaseList.filter((disease) => {
        const ageCondition = ageIndex === -1 || disease.age[ageIndex - 1] === 1;
        const sitCondition = sitIndex === -1 || disease.sit[sitIndex - 1] === 1;
        return ageCondition && sitCondition;
      });

      setDiseaseList(filtered);
      console.log('필터링 결과', filtered);
    };

    filterDiseases();
  }, [ageFilter, sitFilter]);

  const handleAgeSelect = (selectedOptions: string[]) => {
    setSelectedAgeOptions(selectedOptions);
    let text = selectedOptions[0] || '전체';

    if (selectedOptions.length > 1) {
      text =
        text.length > 8
          ? `${text.slice(0, 6)}... 외 ${selectedOptions.length - 1}`
          : `${text}${selectedOptions.length - 1}`;
    }
    console.log('Age Filter:', text);
    setAgeFilter(text);
    setIsAgeModalOpen(false);
  };

  const handleSitSelect = (selectedOptions: string[]) => {
    setSelectedSitOptions(selectedOptions);
    let text = selectedOptions[0] || '해당 없음';

    if (selectedOptions.length > 1) {
      text =
        text.length > 8
          ? `${text.slice(0, 6)}... 외 ${selectedOptions.length - 1}`
          : `${text}${selectedOptions.length - 1}`;
    }
    console.log('Situation Filter:', text);
    setSitFilter(text);
    setIsSitModalOpen(false);
  };

  const resetAgeOptions = () => {
    setSelectedAgeOptions([]);
  };

  const resetSitOptions = () => {
    setSelectedSitOptions([]);
  };

  const clearAgeFilter = () => {
    setAgeFilter('전체');
    setSelectedAgeOptions([]);
  };

  const clearSitFilter = () => {
    setSitFilter('해당 없음');
    setSelectedSitOptions([]);
  };
  1. 연령과 상황 인덱스 계산하는 로직은 사용자가 선택한 연령과 상황 필터 값에 따른 인덱스를 계산합니다. '전체'나 '해당 없음'은 모든 조건을 포함하므로 인덱스는 -1로 설정됩니다.

  2. 필터링 조건을 확인하는 로직은 각 질병의 age와 sit 배열에서, 계산된 인덱스 위치의 값이 1인지 확인하여 필터 조건에 부합하는지 검사합니다. -1인 경우 모든 조건을 포함하므로 자동으로 조건을 만족하게 됩니다.

  3. 필터링 결과를 저장하여 조건을 만족하는 질병들만을 필터링하여 diseaseList 상태에 저장합니다.

위의 상태 관리와 필터링 구현목록을 구현하면 아래와 같이 작성할 수 있습니다.

  return (
    <div>
      <FiltersContainer>
        <Image
          src={
            ageFilter === '전체' && sitFilter === '해당 없음'
              ? Images.adjustment_unselec
              : Images.adjustment_selec
          }
          alt="Filter Icon"
          width={24}
          height={24}
        />
        <Filter
          label="연령"
          selectedValue={ageFilter}
          onSelect={() => setIsAgeModalOpen(true)}
          onClear={clearAgeFilter}
          isSelected={ageFilter !== '전체'}
        />
        <Filter
          label="상황"
          selectedValue={sitFilter}
          onSelect={() => setIsSitModalOpen(true)}
          onClear={clearSitFilter}
          isSelected={sitFilter !== '해당 없음'}
        />
      </FiltersContainer>
      <Fragment>
        <FilterModal
          isOpen={isAgeModalOpen}
          title="연령"
          options={ageRanges}
          selectedOptions={selectedAgeOptions}
          onClose={() => setIsAgeModalOpen(false)}
          onOptionSelect={handleAgeSelect}
          onReset={resetAgeOptions}
        />
        <FilterModal
          isOpen={isSitModalOpen}
          title="상황"
          options={situationRanges}
          selectedOptions={selectedSitOptions}
          onClose={() => setIsSitModalOpen(false)}
          onOptionSelect={handleSitSelect}
          onReset={resetSitOptions}
        />
      </Fragment>
      <DiseaseContainer>
        {diseaseList.map((disease) => (
          <DiseaseCard
            key={disease.id}
            id={disease.id}
            diseaseName={disease.vacName}
            imageUrl={disease.iconsImage}
          />
        ))}
      </DiseaseContainer>
    </div>
  );
};

[3] 각각 질병들의 상세 데이터

위와 같이 구현하면 다음과 같이 필터링을 구현하게 됩니다.
그리고 해당 질병목록에 대해 실제 상세 페이지도 구현을 했는데 이 상세 페이지에 필요한 데이터도 해당 질병 데이터 안에 정의해주면 함께 상태값을 가지고 구현할 수 있습니다.

 {
    id: 1,
    iconsImage: Images.ico_vac1,
    vacName: '결핵',
    vacDes: '결핵은 결핵균(Mycobacterium tuberculosis)에 의한 공기매개 감염질환으로 폐를 침범할 뿐만 아니라 흉막, 림프절, 복부, 골 및 관절, 중추신경계, 비뇨생식기, 기도, 심낭 등 신체의 여러 부분을 침범하는 질환입니다.',
    qaList: [
      {
        id: 1,
        ques:'결핵은 어떻게 전파되나요?',
        ans:'호흡기 결핵 환자의 기침, 재채기 등을 통해 나오는 미세한 비말형태의 분비물을 통하여 다른 사람에게 전파됩니다.'
      },
      {
        id: 2,
        ques:'결핵의 증상은 무엇인가요?',
        ans:'열, 식욕부진, 체중감소, 야간발한 등의 전신증상이 있을 수 있습니다. 폐결핵의 경우 지속되는 기침, 가래, 객혈(가래에 피가 섞임) 등의 호흡기 증상이 있고 영아에서는 마른기침, 경한 호흡곤란이 가장 흔한 증상으로 나타납니다. 폐외결핵의 경우 발열, 식욕부진, 체중감소, 쇠약감, 오한 등의 전신증상과 감염부위의 통증 등의 국소증상이 나타날 수 있습니다'
      },
      {
        id: 3,
        ques:'결핵의 치료는 어떻게 하나요?',
        ans:'항결핵제를 복용하는 내과적 치료를 실시하며 수술과 같은 외과적 치료를 병행할 수 있습니다. 결핵이 발병한 사람은 의사의 지시에 따라 치료효과와 부작용에 대한 검사를 정기적으로 받고, 처방된 약을 꾸준히 복용해야 내성균 발현을 막고 결핵을 완치할 수 있습니다.'
      },
      {
        id: 4,
        ques:'결핵은 어떻게 예방하나요?',
        ans:'BCG 예방접종을 통해 결핵을 예방할 수 있습니다.'
      }
    ],
    age: [1, 1, 1, 1, 1, 1], 
    sit: [1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 0, 1, 0, 1],
  },

03. 구현 화면

위와 같이 구현한 화면은 아래와 같습니다 !

profile
학부생 Frontend Developer

0개의 댓글