"Life is Full of Possibilities" - Soul, 2020

우아한테크코스

페어 프로그래밍 - 해시태그 및 필터링 구현 (React + Typescript)

m2ndy 2024. 8. 24. 15:29
본 글은 2024.11.15에 마지막으로 수정되었습니다.

 

 

4차 스프린트에서의 페어 프로그래밍으로 해시태그 및 필터링 구현을 담당했습니다.

Devel Up에서 제공하는 미션의 종류와 풀이의 개수가 많아지면서, 원하는 컨텐츠를 분류하여 볼 수 있으면 좋겠다는 사용자 피드백을 받았습니다. 팀원들과 필터링 기능의 도입 여부에 대해 논의한 끝에, 1. 빠르게 구현 가능하고 2. 도입 시 큰 효과를 볼 수 있다는 판단이 들어 해당 기능을 구현하기로 결정했습니다.

 

 

가장 먼저 페어였던 아톰과 함께 할 일과 일정을 산정했습니다.
 
페어와 함께 해야 할 일로는 다음과 같았습니다.


1. 전체 해시 태그 조회
2. 미션에 해시 태그 추가
3. 솔루션에 해시 태그 추가
4. 미션 필터링 검색
5. 솔루션 필터링 검색
 
이렇게 다섯 가지가 있었고,
0단계로 API 명세 작성, 1단계로 2, 3번을 진행한 뒤 2단계로 1, 4, 5번을 진행하기로 했습니다.
 
 

0단계 : API 명세 작성

 
가장 먼저 페어와 함께 API 명세를 작성했습니다. 피그마 목업 디자인을 보며, hashTag가 필요한 곳이 mission 리스트, 단일 mission, solution 리스트, 단일 solution임을 확인한 후, 기존의 mission과 solution API에 hashTags를 추가했습니다.

 

hashTags는 camelCase로 지정하기로 결정했으며, 그 결과물은 다음과 같습니다.

 

// mission API

{
    "data": {
        "id": number,
        "title": string,
        "descriptionUrl": string,
        "thumbnail": string,
        "url": string,
        "isStarted": boolean,
        "hashTags": [
            {
                "id": number,
                "name": string
            },
        ]
    }
}

 

// solution API

{
  "data": {
    "id": number,
    "thumbnail": string,
    "title": string,
    "description": string,
    "hashTags" : [
        {
           "id": number,
           "name" : string
        },
    ]
  }
}

 
 

1단계 : 컴포넌트에 해시 태그 추가

카드 컴포넌트의 너비가 최대 300px로 넓지 않아서 여러 개의 해시태그를 담기 어려웠습니다. 정책 상 미션과 풀이는 최대 8개의 해시태그를 가질 수 있었는데, 8개의 해시태그를 담는 경우 또는 해시태그의 길이가 길어지는 경우 레이아웃이 깨지는 문제가 발생했습니다.

 

그래서 팀원들에게 조언을 구했고, 투표를 통해 태그는 한 줄로 표시하는 것으로 결정했습니다. 추가로, 해시태그의 개수가 많아지는 경우 슬라이드를 넣으면 좋겠다는 의견이 있어서 이를 반영했습니다.

 

 
 
 
결과물: 
 

 

useDragScroll 훅을 통해 가로 스크롤 기능을 구현했습니다.

 

// useDragScroll.ts

import type React from 'react';
import { useState } from 'react';

const useDragScroll = <T extends HTMLElement>() => {
  const [isActive, setIsActive] = useState(false);
  const [prevPositionX, setPrevPositionX] = useState(0);
  const [mouseDownClientX, setMouseDownClientX] = useState(0);
  const [isDragging, setIsDragging] = useState(false);

  const inActive = () => {
    setIsActive(false);
    setIsDragging(false);
  };

  const onMouseMove: React.MouseEventHandler<T> = (e) => {
    if (isActive) {
      setIsDragging(true);
      const moveX = e.clientX - mouseDownClientX;
      e.currentTarget.scrollTo(prevPositionX - moveX, 0);
    }
  };

  const onMouseDown: React.MouseEventHandler<T> = (e) => {
    setIsActive(true);
    setIsDragging(false);
    setMouseDownClientX(e.clientX);
    setPrevPositionX(e.currentTarget.scrollLeft);
    e.currentTarget.style.cursor = 'grabbing';
  };

  const onMouseUp: React.MouseEventHandler<T> = (e) => {
    setIsActive(false);
    e.currentTarget.style.cursor = 'grab';
  };

  return { inActive, onMouseDown, onMouseMove, onMouseUp, isDragging };
};

export default useDragScroll;

 

먼저 useDragScroll에 제네릭을 사용하여 다양한 HTML 요소에 적용할 수 있도록 지정했습니다.

 

각 state들을 살펴보면,

  • isActive: 마우스가 눌린 상태(드래그가 시작된 상태)인지 나타냅니다.
  • prevPositionX: 드래그 시작 시점의 스크롤 위치(scrollLeft)를 저장합니다.
  • mouseDownClientX: 마우스를 누른 시점의 X 좌표(clientX)를 저장합니다.
  • isDragging: 현재 드래그 중인지 나타냅니다.

각 함수들을 살펴보면,

  •  inActive: 드래그 동작이 종료될 때 호출됩니다. isActive와 isDragging 상태를 false로 초기화합니다.
const inActive = () => {
  setIsActive(false);
  setIsDragging(false);
};

 

  • onMouseMove: isActive가 true일 때만 실행되며, 마우스가 눌린 상태에서만 스크롤을 조작하기 위함입니다.
    • 마우스의 현재 x 좌표 (e.clientX)에서 눌렀던 시점의 x 좌표 (mouseDownClientX)를 뺀 값을 계산하여 moveX를 구합니다.
    • 기존 스크롤 위치(prevPositionX)에 moveX를 적용하여 가로 스크롤 위치를 업데이트합니다.
    • 이 동작을 통해 마우스 움직임에 따라 요소가 드래그되는 효과를 제공합니다.
const onMouseMove: React.MouseEventHandler<T> = (e) => {
  if (isActive) {
    setIsDragging(true);
    const moveX = e.clientX - mouseDownClientX;
    e.currentTarget.scrollTo(prevPositionX - moveX, 0);
  }
};

 

  • onMouseDown: 마우스가 눌렸을 때 실행됩니다.
    • isActive: 드래그 시작 상태로 설정합니다.
    • isDragging: 초기화합니다(마우스 움직임에 따라 다시 활성화됨).
    • mouseDownClientX: 마우스 눌린 위치의 X 좌표를 저장합니다.
    • prevPositionX: 요소의 현재 스크롤 위치를 저장합니다.
    • style 변경: 커서를 grabbing으로 변경하여 드래그 중임을 시각적으로 표시합니다.
const onMouseDown: React.MouseEventHandler<T> = (e) => {
  setIsActive(true);
  setIsDragging(false);
  setMouseDownClientX(e.clientX);
  setPrevPositionX(e.currentTarget.scrollLeft);
  e.currentTarget.style.cursor = 'grabbing';
};

 

 

  • onMouseUp: 마우스 버튼을 놓았을 때 실행합니다.
    • isActive: 드래그 상태를 비활성화합니다.
    • style 변경: 커서를 grab으로 변경하여 드래그가 종료됨을 표시합니다.
const onMouseUp: React.MouseEventHandler<T> = (e) => {
  setIsActive(false);
  e.currentTarget.style.cursor = 'grab';
};

 

 

useDragScroll 훅은 아래와 같이 사용할 수 있습니다.

// infoCard.tsx

export default function InfoCard({
  // ... props
}: InfoCardProps) {
  const { onMouseDown, onMouseMove, onMouseUp, inActive, isDragging } =
    useDragScroll<HTMLUListElement>();

  const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
    if (isDragging) {
      e.preventDefault();
    }
  };

  return (
    <Card
      key={id}
      thumbnailSrc={thumbnailSrc}
      thumbnailFallbackText={thumbnailFallbackText}
      contentElement={
        <S.InfoCardContainer onClick={handleClick}>
          // ... 기타 코드들
          <S.TagWrapper
            onMouseDown={onMouseDown}
            onMouseMove={onMouseMove}
            onMouseUp={onMouseUp}
            onMouseLeave={inActive}
          >
            // ... 내부 hashTags
          </S.TagWrapper>
        </S.InfoCardContainer>
      }
    />
  );
}

 


 
 
 

2단계 : 필터링 구현

 

해시태그별로 분류할 수 있으면 더 편리할 것 같다는 사용자의 의견이 있어 필터링 기능을 구현했습니다.

 

기본적으로는 전체 리스트를 보여주고, 해시태그 토글 버튼을 누르면 해당 해시태그로 분류된 API를 호출해 리스트를 다시 렌더링 하는 방식입니다. 처음에는 전체 해시태그는 /missions로 호출하고, 필터링 시에만 쿼리 파라미터를 추가하는 방식(/missions&hash-tag="JAVA")을 사용하려 했습니다. 그런데 캐싱을 위해 Tanstack Query(React Query)를 사용했기 때문에 queryKey가 필요했고, 필터링이 없는 전체 리스트에 대한 queryKey 값을 ["missions", ""]로 작성하는 것은 가독성이 떨어진다고 생각했습니다. 따라서 전체 리스트의 경우 쿼리 파라미터를 "ALL"로 호출하는 방식으로 변경했습니다.

 

// MissionListPage.tsx

import MissionList from '@/components/MissionList';
import * as S from './MissionListPage.styled';
import useMissions from '@/hooks/useMissions';
import useHashTags from '@/hooks/useHashTags';
import HashTagList from '@/components/HashTagList';
import { useState } from 'react';
import { HASHTAGS } from '@/constants/hashTags';

export default function MissionListPage() {
  const [selectedHashTag, setSelectedHashTag] = useState(HASHTAGS.all);
  const { data: allMissions } = useMissions(selectedHashTag);
  const { data: allHashTags } = useHashTags();

  return (
    <S.MissionListPageContainer>
      <S.TitleWrapper>
        <S.MissionListTitle>🎯 지금 참여할 수 있는 미션</S.MissionListTitle>
        <S.Subtitle>미션에 참여하고 의견을 주고받을 수 있어요!</S.Subtitle>
      </S.TitleWrapper>
      <HashTagList
        hashTags={allHashTags}
        setSelectedHashTag={setSelectedHashTag}
        selectedHashTag={selectedHashTag}
      />
      <MissionList missions={allMissions} />
    </S.MissionListPageContainer>
  );
}
// useMissions.ts

import { useSuspenseQuery } from '@tanstack/react-query';
import type { Mission } from '@/types';
import { getMissions } from '@/apis/missionAPI';
import { missionKeys } from './queries/keys';
import { HASHTAGS } from '@/constants/hashTags';

const useMissions = (filter: string = HASHTAGS.all) => {
  return useSuspenseQuery<Mission[]>({
    queryKey: [...missionKeys.all, filter],
    queryFn: () => getMissions(filter),
  });
};

export default useMissions;
// mission API

export const getMissions = async (filter: string = HASHTAGS.all): Promise<Mission[]> => {
  const { data } = await develupAPIClient.get<getAllMissionResponse>(`${PATH.missionList}`, {
    hashTag: filter,
  });

  return data;
};

 



결과물 : 

 
캐싱 되어있지 않은 API를 호출할 때 화면이 깜빡이는 현상이 있는데, 이를 어떻게 해결해야 할지 고민 중입니다..!

 

 

 

> 2024.10.20 추가 
 

위와 같이 깜빡이는 현상은 Suspense Fallback UI 때문이었습니다. 컴포넌트가 로딩 중일 때 화면 전체를 로딩 스피너로 보여주는 형식이었는데, 로딩 스피너 배경이 grey color로 지정되어 있어 깜빡이는 듯한 모습을 보였던 것이었습니다.

따라서 로딩스피너의 배경 색을 삭제하는 것으로 해결 완료!

 

 

 

 

 

> 이후 HashTagList에 변경사항이 발생했습니다. 처음에는 해시태그만 필터링하면 되었기 때문에 HashTagList를 아래와 같이 구현했는데요, hashTags 배열을 순회하는 곳에서 isSelected를 보시면 name이 selectedHashTag와 같을 때 true가 됩니다.

디스커션을 구현하면서 해시태그뿐만 아니라 미션도 필터링을 해야 했습니다. 그런데 여기서 'name'이라는 속성이 문제가 되었습니다. 미션은 name이라는 속성이 없고, name 대신 title이 있었기 때문입니다.

// HashTagList.tsx

export default function HashTagList({
  hashTags,
  selectedHashTag,
  setSelectedHashTag,
}: HashTagListProps) {
  return (
    <S.HashTagListContainer>
      {hashTags.map(({ id, name }) => {
        const isSelected = name === selectedHashTag; // 문제가 되었던 부분
        return (
          <HashTagButton
            isSelected={isSelected}
            onClick={() => setSelectedHashTag(isSelected ? HASHTAGS.all : name)}
            key={id}
          >
            {name}
          </HashTagButton>
        );
      })}
    </S.HashTagListContainer>
  );
}

 

 

따라서 위 문제를 해결하기 위해 다음과 같은 개선 과정을 진행했습니다.

  1. HashTagList 네이밍을 TagList로 변경 : 해시태그와 미션 필터링에 쓰여야 하므로 좀 더 범용적인 Tag로 수정
  2. selectedHashTag 타입 변경 : selectedHashTag가 name만을 저장하는 string이었다면, 이를 id와 name을 모두 갖는 객체로 변경.
    • TagList에서 isSelected를 판별할 때 name이 아닌 id값으로 판별하기 위함
    • string 비교보다는 id 비교가 정확할 것으로 판단
  3. TagList 제네릭 데이터 변경 : 제네릭 타입을 사용하여 다양한 데이터를 받을 수 있도록 지정. 
    • id는 반드시 포함해야 하는 속성
    • keyName을 추가로 받아, 외부로부터 받아온 tag객체에서 화면에 표시할 텍스트를 동적으로 선택할 수 있게 함
    • ex. id와 name이라는 key 값을 갖는 selectedHashTag와, id와 title이라는 key 값을 갖는 selectedMission을 모두 받을 수 있도록 함


변경 후 코드는 다음과 같습니다.

// TagList.tsx

interface TagListProps<T> {
  tags: T[];
  selectedTag: T | null;
  setSelectedTag: (tag: T | null) => void;
  variant?: TagButtonVariant;
  label?: string;
  keyName: keyof T;
}

export default function TagList<T extends { id: number }>({
  tags,
  selectedTag,
  setSelectedTag,
  variant,
  label,
  keyName,
}: TagListProps<T>) {
  return (
    <S.Container>
      <S.Label>{label}</S.Label>
      <S.TagListWrapper>
        {tags.map((tag) => {
          const name = tag[keyName] as string;
          const isSelected = tag.id === selectedTag?.id;
          return (
            <TagButton
              aria-label={`${name} 태그`}
              key={tag.id}
              isSelected={isSelected}
              onClick={() => setSelectedTag(isSelected ? null : tag)}
              variant={variant}
            >
              {name}
            </TagButton>
          );
        })}
      </S.TagListWrapper>
    </S.Container>
  );
}
// TagList 사용처

export default function MissionListPage() {
  const [selectedHashTag, setSelectedHashTag] = useState<HashTag | null>(null);
  const { missions } = useMissions({
    filter: selectedHashTag?.name ?? HASHTAGS.all,
  });
  const { data: allHashTags } = useHashTags();

  return (
    <S.MissionListPageContainer>
      // ... 기타 코드들
      <TagList
        tags={allHashTags}
        setSelectedTag={setSelectedHashTag}
        selectedTag={selectedHashTag}
        keyName="name"
        aria-labelledby="mission-title"
      />
    </S.MissionListPageContainer>
  );
}

 

 

추가로 여러 개를 다중 선택할 수 있는 TagMultipleList를 구현했습니다.

 

TagList와 다른 점은, props로 받는 selectedTags가 배열이고 setSelectedTags의 인자도 배열 형태입니다. 어찌 보면 TagList와 유사한 형태라 공통 컴포넌트로 구현할 수 있었지만, 그렇게 되면 코드를 작성할 때 해당 UI가 다중 선택이 가능한 부분인지 혹은 단일 선택만 가능한지 확인하기 어려울 것이라는 생각이 들었습니다. 또한, 오히려 로직이 복잡해져 나중에 유지보수가 어려울 것이라는 판단이 들어 TagMultipleList로 분리하기로 결정했습니다.

 

// TagMultipleList.tsx

interface TagMultipleListProps<T> {
  tags: T[];
  selectedTags: T[];
  setSelectedTags: (updatedSelectedTags: T[]) => void;
  variant?: TagButtonVariant;
  label?: string;
  keyName: keyof T;
}

export default function TagMultipleList<T extends { id: number }>({
  tags,
  selectedTags,
  setSelectedTags,
  variant = 'default',
  label,
  keyName,
}: TagMultipleListProps<T>) {
  const handleSelectedTags = (tag: T) => {
    const isSelected = selectedTags.some((selectedTag) => selectedTag.id === tag.id);

    if (isSelected) {
      const updatedSelectedTags = selectedTags.filter((selectedTag) => selectedTag.id !== tag.id);
      setSelectedTags(updatedSelectedTags);
    } else {
      setSelectedTags([...selectedTags, tag]);
    }
  };

  return (
    <S.Container>
      <S.Label>{label}</S.Label>
      <S.TagMultipleListWrapper>
        {tags.map((tag) => {
          const name = tag[keyName] as string;
          const isSelected = selectedTags.some((selectedTag) => selectedTag.id === tag.id);
          return (
            <TagButton
              key={tag.id}
              isSelected={isSelected}
              onClick={() => handleSelectedTags(tag)}
              variant={variant}
            >
              # {name}
            </TagButton>
          );
        })}
      </S.TagMultipleListWrapper>
    </S.Container>
  );
}

 

selectedTags가 배열 형태이기 때문에 handleSelectedTags에서 선택된 태그를 업데이트하는 로직이 추가되었습니다.

 

결과물 :