• 20241119(화) ▶️ 굿즈샵페이지 스켈레톤 구현하기, Select 정렬하기

    2024. 11. 19.

    by. 서카츄

    오늘은 굿즈샵페이지에 데이터 불러오기 전, loading 컴포넌트UI를 스켈레톤으로 구현했고,
    Select 정렬을 구현해 보았다.
     
     

    스켈레톤 구현하기

    //hooks/queries/useShop.ts
    
    export const useShops = () => {
      return useQuery({
        queryKey: ["shops"],
        queryFn: () => getGoodsShop(),
      });
    };

     
    먼저 TanStack Query로 데이터를 불러오는 함수를 작성했다.
     
     
     
     
     
     
     

    //ShopList.tsx
    const ShopList = () => {
      const { data, error, isLoading } = useShops();
      
      useEffect(() => {
        if (data) return setShopItems(data);
      }, [data]);
    
      //loading
      if (isLoading) {
        return (
          <ul className="flex items-center justify-between flex-wrap">
            {Array.from({ length: 8 }).map((_, index) => (
              <ShopSkeleton key={index} />
            ))}
          </ul>
        );
      }
    
      //error
      if (error) return <Error />;
    
      return (
        <ul className="flex items-center justify-between flex-wrap">
          {shopItems.map((el) => (
            <ShopListItems key={el.title} item={el} />
          ))}
        </ul>
      );
    };
    
    export default ShopList;

     
    ul에 있는 내용 list들을 미리 사전에 보여 줄 예정이라 ul태그가 있는 컴포넌트에 작성했다.
    리스트는 8개 정도 보여주고 싶어서 `Array.from`을 사용하여 길이는 8로 설정해 주었다.
     
     
     
     
     
     
     

    //ShopSkeleton.tsx
    
    import { Skeleton } from "@/components/ui/skeleton";
    
    const ShopSkeleton = () => {
      return (
        <li className="w-[280px] border p-4 rounded-lg mb-10">
          <div className="relative w-full h-64 rounded-lg border">
            <Skeleton className="w-full h-full" />
          </div>
          <div className="flex flex-col gap-2 mt-4">
            <Skeleton className="h-6 w-3/4 rounded" />
            <Skeleton className="h-4 w-1/2 rounded" />
            <div className="flex items-center gap-2">
              <Skeleton className="h-6 w-8 rounded" />
              <Skeleton className="h-6 w-16 rounded" />
            </div>
            <div className="flex items-center gap-1">
              <Skeleton className="h-4 w-4 rounded-full" />
              <Skeleton className="h-4 w-10 rounded" />
            </div>
          </div>
        </li>
      );
    };
    
    export default ShopSkeleton;

     
    Skeleton은 li의 뼈대를 그대로 가져와서 `Skeleton`으로 바꿔주면 된다.
     
     
     
     
     

    결과

     
    이전 팀 프로젝트에서 맡았던 내용이라, 스켈레톤 UI는 금방 구현했다.😊
     
     
     
     
     
     
     
    스켈레톤 구현하는 김에 Error컴포넌트도 조금 수정해주었다.


    loading, error까지 구현 완료👍
     
     
     
     


     

    Select 정렬 구현하기

     
    select 정렬 기능을 만들어야 한다.
    option은 원래 인기순과 최신순만 있었는데
    리스트가 많아질때를 생각해서 가격 작은 순, 가격 높은 순 까지 추가해서 총 4개가 되었다.
     
     
     
     

    확장성 있게 만들어야 한다....?

    기존 인기순, 최신순만 고려했을때 코드

    export const getGoodsShop = async (sortBy: string) => {
      try {
        const { data, error } = await supabase
          .from("goods")
          .select("*")
          .order(sortBy === "best" ? "review_count" : "created_at", { ascending: false })
          .limit(20);
    
        if (error) throw error;
        return data;
      } catch (error) {
        if (error instanceof Error) {
          throw new Error(`상품 정보를 불러오는 데 실패했습니다. ${error.message}`);
        }
        throw error;
      }
    };

     
    처음에는 option이 2가지 뿐이라 3항 연산자로 구현을 했었다.
    처음 `default`값은 인기순인 best로 두었다. 
    `best`면 리뷰가 많은 순서대로, best가 아니면 최신순으로 나오게 했고
    `ascending(오름차순)`은 `false`로 설정해두었다. `(false - 결과 : 내림차순)`
    근데 코드를 작성해보고 생각해보니 점점 option이 추가 된다면 이렇게 코드를 짜면 안된다는 생각을 했다.
    항상 기능을 개발할때 여기서 끝내는게 아니라 확장성을 항상 생각하고 어떻게 해야 쉽게 할 수 있는지 생각이 들게 된 것이었다.🤔
     
     
     

    그래서 생각한 방안

    1. 항상 자주 썼던 if-else문
    2. switch문
    3. 상수로 설정해서 맵핑하기 (맵핑객체)
     
     
     

     

    if-else 사용시

    const getOrderByOption = (sortBy: string) => {
      if (sortBy === "best") return { column: "review_count", ascending: false };
      if (sortBy === "latest") return { column: "created_at", ascending: false };
      if (sortBy === "price_low_to_high") return { column: "price", ascending: true };
      if (sortBy === "price_high_to_low") return { column: "price", ascending: false };
    
      // 기본값
      return { column: "best", ascending: false };
    };

     
     
     
     
     
     
     

    switch문 사용시

    const getOrderByOption = (sortBy: string) => {
      switch (sortBy) {
        case "best":
          return { column: "review_count", ascending: false };
        case "latest":
          return { column: "created_at", ascending: false };
        case "price_low_to_high":
          return { column: "price", ascending: true };
        case "price_high_to_low":
          return { column: "price", ascending: false };
        default:
          return { column: "created_at", ascending: false };
      }
    };

     
     
     
     
     
     
     

    맵핑 객체 사용시

    const SORT_OPTIONS: Record<string, { column: string; ascending: boolean }> = {
      best: { column: "review_count", ascending: false },
      latest: { column: "created_at", ascending: false },
      price_low_to_high: { column: "price", ascending: true },
      price_high_to_low: { column: "price", ascending: false },
    };

     
     


     

    내가 선택한 방안

     
    switch문이 제일 가독성 좋아보여 switch문으로 할까? 생각했는데
    일단 제일 고려해야할 부분에서 `새로운 조건이 추가됐을 때 제일 간결하고 확장성이 좋은 것`이 무엇일까? 생각하게 된다면
    나는 무조건 맵핑 객체를 사용할 것 같아서 3번으로 선택했다.
    이유는 상수들을 모아서 따로 관리할 수 있고, 이미 프로젝트에 상수만 따로 관리해 놓는 곳이 있었다.
    그리고 복잡하게 switch, if-else문 사용 안해도 조건이 많아질 수 있거나 더 추가 될 수 있는 환경에서 더 간결하고 깔끔하게 작성할 수 있었다.
     
     

     

    방법 비교하기 (제가 생각한 내용을 쓴거라 다를 수 있습니다..)

    방법장점단점
    if-else직관적임, 간단함조건이 많아지면 길어짐
    switch직관적임, 간단함조건이 많아지면 길어짐
    삼항 연산자간단한 조건에 쓰기 좋음복잡해지면 가독성 저하 2중 3중으로 3항연산자 쓰게 됨
    맵핑 객체확장성이 좋음, 깔끔함, 상수데이터 따로 관리 가능초보자에게 첫 구현하기 힘들 수 있음(?)

     
     
     
     


     
     

    생각은 다했으니 이제 코드 구현해보기!

    맵핑 객체로 구현하기

    //굿즈샵 정렬 조건 객체 맵핑
    export const SORT_OPTIONS: Record<string, SortOption> = {
      best: { column: "review_count", ascending: false },
      latest: { column: "created_at", ascending: false },
      price_low_to_high: { column: "price", ascending: true },
      price_high_to_low: { column: "price", ascending: false },
    };

     
    타입스크립트의 유틸리티 타입인 `Record` 타입을 사용했다!
    유틸리티 타입 공부한거 까먹어서 정리한거 다시 보고왔다😂
     
     
    옛날에 정리해둔 것...
     

    타입스크립트의 유틸리티 타입

    유틸리티 타입이란?어떤 타입이 있고, 내 마음대로 요리할 수 있도록 다른 타입으로 만드는 것이 유틸리티 타입이다.   예시interface Profile { name: string; age: number; school: string; hobby?: string;} 먼저

    seokachu.tistory.com

     
    다시 복기 겸 여기다 작성해보면
    `Record` 타입은 객체의 key가 있고 타입을 value로 만들고 싶을 때 사용한다.
     
     
     
     
     
     

    상품목록 데이터 

    //상품 목록
    export const getGoodsShop = async (sortBy: String) => {
      try {
        const sortOption = SORT_OPTIONS[sortBy];
    
        if (!sortOption) {
          throw new Error(`sortBy error : ${sortBy}`);
        }
    
        const { data, error } = await supabase
          .from("goods")
          .select("*")
          .order(sortOption.column, { ascending: sortOption.ascending })
          .limit(20);
    
        if (error) throw error;
        return data;
      }
      {/* 생략 */}
    };

     
    먼저 정렬 데이터 먼저 손을 보게 되었다.
    객체를 동적으로 접근할 수 있는 `대괄호 표기법`을 사용해서 sortOption 변수에 담아주었다.
    그리고 변수에 있는 내용을 수파베이스에서 제공해주는 order속성에 넣어줘야 하니
    수파베이스에서 보낼 수 있는 뼈대 그대로 넣어주었다.

    .order(sortOption.column, { ascending: sortOption.ascending }) //이 부분!

     
     
     
     
     
     
     
     

    적용할 페이지

    //ShopContainer.tsx
    
    const ShopContainer = () => {
      const [sort, setSort] = useState("best");
      return (
        <section className="max-w-[1320px] m-auto px-5 pt-14 pb-28 lg:px-8">
          <div className="flex justify-between mb-8 flex-col lg:flex-row gap-5">
            <h2 className="text-2xl font-bold">굿즈샵</h2>
            <SelectMenu
              options={PRODUCT_SORT_OPTIONS}
              value={sort}
              onChange={setSort}
            />
          </div>
          <ShopList sort={sort} />
        </section>
      );
    };
    
    export default ShopContainer;

     
    그리고 정렬해줄 컴포넌트에 가서 sort의 기본설정은 "best"로 하고 자식 컴포넌트에게 props로 넘겨준다.
    나는 select메뉴와 list컴포넌트에게 넘겨줘야 하니 그의 부모인 `ShopContainer` 컴포넌트에서
    상태관리 할 useState를 선언해주고, 자식 컴포넌트들에게 정렬될 값을 넘겨주었다.
     
     
     
     
     

    SelectMenu 컴포넌트

    const SelectMenu = <T extends { value: string; title: string }>({
      options,
      value,
      onChange,
    }: SelectMenuProps<T>) => {
      return (
        <Select value={value} onValueChange={onChange}>
          <SelectTrigger className="lg:w-[180px] w-full">
            <SelectValue placeholder={options[0]?.title} />
          </SelectTrigger>
          <SelectContent>
            <SelectGroup>
              {options.map((el) => (
                <SelectItem key={el.value} value={el.value}>
                  {el.title}
                </SelectItem>
              ))}
            </SelectGroup>
          </SelectContent>
        </Select>
      );
    };
    
    export default SelectMenu;

     
    Select 컴포넌트는 제네릭 타입으로 만들어서 value와 title은 string타입으로 받아올 수 있게 되어있다.
    props로 넘겨받은 value(sort)와 onChange(setSort)를 select에 바인딩 시켜준다 
     

     

     
     

    ShopList 컴포넌트

    const ShopList = ({ sort }: SortProps) => {
      const { data, error, isLoading } = useShops(sort);
    
    	//중략..
    
      return (
        <ul className="flex items-center justify-between flex-wrap">
          {shopItems.map((el) => (
            <ShopListItems key={el.title} item={el} />
          ))}
        </ul>
      );
    };
    
    export default ShopList;

     
    정렬되는 데이터들을 이전에 작성했었던 useQuery에 매개변수로 넣어준다!
     
     
     
     
     
     
     

    useQuery

    //상품 목록
    export const useShops = (sortBy: string) => { //추가
      return useQuery({
        queryKey: ["shops", sortBy],
        queryFn: () => getGoodsShop(sortBy),
      });
    };

     
     
     


     
     

    Record 타입 더 좁히기

    내가 다시 정리한 유틸리티 타입 내용들을 보면서 string으로 전부 받아오는 것 보다
    더 안정성있게 type을 지정해서 key로 지정해주면 어떨까 생각했다.

    //굿즈샵 정렬 조건 객체 맵핑
    export const SORT_OPTIONS: Record<SortOptionList, SortOption> = {
      best: { column: "review_count", ascending: false },
      latest: { column: "created_at", ascending: false },
      price_low_to_high: { column: "price", ascending: true },
      price_high_to_low: { column: "price", ascending: false },
    };

     
    string 대신 SortOptionList를 추가했다.
     
     
     
     

    export type SortOptionList =
      | "best"
      | "latest"
      | "price_low_to_high"
      | "price_high_to_low";

     
    SortOptionList 에 타입이 들어올 유니온 타입을 명확하게 적어주었다.
     
     
     
     
     
     
     

    useQuery

    //상품 목록
    export const useShops = (sortBy: SortOptionList) => { //string 대신 유니온 타입으로 변경
      return useQuery({
        queryKey: ["shops", sortBy],
        queryFn: () => getGoodsShop(sortBy),
      });
    };

     
    useQuery 에서도 string대신 유니온 타입으로 변경해 주었다.
     
     
     
     
     
     

    적용할 부모 컴포넌트

    const ShopContainer = () => {
      const [sort, setSort] = useState<SortOptionList>("best");
      return (
        <section className="max-w-[1320px] m-auto px-5 pt-14 pb-28 lg:px-8">
          <div className="flex justify-between mb-8 flex-col lg:flex-row gap-5">
            <h2 className="text-2xl font-bold">굿즈샵</h2>
            <SelectMenu
              options={PRODUCT_SORT_OPTIONS}
              value={sort}
              onChange={setSort}
            />
          </div>
          <ShopList sort={sort} />
        </section>
      );
    };
    
    export default ShopContainer;

     

      const [sort, setSort] = useState<SortOptionList>("best");

     
    useState에 타입명시를 추가해주고, 자식한테 넘겨주는 props 타입도 변경해주었다.
     
     

     

     

    자식 컴포넌트 props 타입

    sort: string → 유니온 타입으로 변경

    export interface SortProps {
      sort: SortOptionList;
    }

     
     
     


     
     

    쉽게 적용할 줄 알았으나 여기서 문제점1)

    문제는 공통으로 쓰는 select 컴포넌트.....
    문제는 재사용성이 있는 select 컴포넌트의 타입지정이었다.
     
     
     
     

    SelectMenu.tsx

    interface SelectMenuProps<T> {
      options: T[];
      onChange: (value: string) => void;
      value: string;
    }

     
    onChange에 value: string 을 유니온 타입으로 지정해버리면
    다른곳에서 재사용할 수 있는 UI 컴포넌트를 복잡하게 만들어 줄 수 있기 때문이다.
    최대한 UI는 보여주는 곳은 안건드리고 타입을 안전하게 보낼 수 있는 방법을 고안해 봐야했다.
     
     
     
     
     

    부모 컴포넌트에서 SortOptionList 타입으로 맞춰주고 props로 안전하게 넘겨주기

    여기까진 생각을 했지만 진짜 도무지 몰라서 지피티의 힘을 빌렸다....😂

    const ShopContainer = () => {
      const [sort, setSort] = useState<SortOptionList>("best");
    
      const handleSortChange = (value: string) => {
        // type guard 함수로 분리
        const isSortOption = (value: string): value is SortOptionList => {
          return [
            "best",
            "latest",
            "price_low_to_high",
            "price_high_to_low",
          ].includes(value);
        };
    
        if (isSortOption(value)) {
          setSort(value);
        }
      };
    
      return (
        <SelectMenu
          options={PRODUCT_SORT_OPTIONS}
          value={sort}
          onChange={handleSortChange} //setSort대신 handleSortChange를 넘겨줌!
        />
        {/* 생략 */}
      );
    };
    
    export default ShopContainer;

     
     
     
     
    더 깊게 정리해보자면

    Type Guard란?

    직역하면 타입 가드(보호하다) 라는 뜻을 가지고 있다.

    // 이건 일반적인 타입 체크
    if (typeof value === "string") {
      // 여기서 TypeScript는 value가 string이라는 것을 알게 됨
    }

     
    직역하면 타입 가드(보호하다) 라는 뜻을 가지고 있다.
    만약에 value가 string 타입이면, 타입스크립트는 value가 string이라는 것을 알게 된다.

     
     

     
     
     

    더 나아가서 Custom Type Guard 만들기

    // SortOptionList 타입 정의
    type SortOptionList = "best" | "latest" | "price_low_to_high" | "price_high_to_low";
    
    // Type Guard 함수
    const isSortOption = (value: string): value is SortOptionList => {
      return ["best", "latest", "price_low_to_high", "price_high_to_low"].includes(value);
    };

     
    `value is SortOptionList`: 이 함수가 true를 반환하면, value는 SortOptionList 타입이라고 TypeScript에게 알려줌
    `includes(value)`: value가 배열에 있는 값 중 하나인지 확인
     
    좀 더 풀어서...
    isSortOption 변수를 지정해주고, value는 string이고 value is SortOptionList
    → 직역하면 value는 SortOptionList 입니다!
    그래서 return으로 (뱉어내는 애들은) 매개변수로 받아온 value에서 includes를 포함하는데
     

    ["best", "latest", "price_low_to_high", "price_high_to_low"].includes(value);

     
    대괄호 안에 있는 값을 리턴하는 것이고, value가 배열 안의 값들 중 하나와 일치하는지 확인하는 것이다.
     
     
     
     

    if (isSortOption(value)) {
      setSort(value);
    }

     
    if문으로 만약에 isSortOption(value) 값이 맞으면
    setSort에 value 값을 넣어준다는 뜻이다.
     
     
    결국은 Type Guard를 사용하면 TypeScript가 타입을 더 정확하게 이해할 수 있다!!!
     
     
     

    결론)

    // 1. 일반적인 타입 체크
    typeof value === "string"  // 이건 문자열이야!!
    
    // 2. 커스텀 타입 가드
    const isSortOption = (value: string): value is SortOptionList => {
      // 이건 그냥 문자열이 아니라, 특별히 'best', 'latest' 등의 값만 가질 수 있는 
      // SortOptionList 타입이에요!!! 라고 TypeScript에게 알려준다.
      return ["best", "latest", "price_low_to_high", "price_high_to_low"].includes(value);
    };
    
    // 3. 사용
    if (isSortOption(value)) {
      // TypeScript는 여기서 value가 단순 string이 아니라
      // SortOptionList ("best" | "latest" | ...) 타입이라는 걸 알게 됨
      setSort(value);  // 그래서 타입 에러 없이 setSort에 전달 가능!
    }

     
     
     
     


    3줄 요약

     

    타입 안전성을 지키면서도 SelectMenu의 재사용성을 해치지 않는 방법

    • SelectMenu는 일반적인 string 타입으로 재사용 가능
    • ShopContainer에서 타입 안전성 확보
    • Type guard로 더 안전한 타입 체크

     
     
     
    이론으로만 공부하다가 직접 적용해서 구현해보니까 이렇게 활용하는구나 싶고....
    이번에 새롭게 다른 관점으로 구현해 보면서 작동하는 모습을 보니까 뿌듯했다.😂
     
     
     
     
     
     

    번외? 지피티가 칭찬해줌 너란자쉭....

     

     
    내가 생각한게 잘못됐나 싶어서 지피티 형에게 물어봤더니 칭찬받았다
    내가 살다살다 지피티한테 칭찬받은 적은 처음이다 뿌듯🤣
     
     
     

    댓글