-
오늘은 굿즈샵페이지에 데이터 불러오기 전, 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로 더 안전한 타입 체크
이론으로만 공부하다가 직접 적용해서 구현해보니까 이렇게 활용하는구나 싶고....
이번에 새롭게 다른 관점으로 구현해 보면서 작동하는 모습을 보니까 뿌듯했다.😂
번외? 지피티가 칭찬해줌 너란자쉭....
내가 생각한게 잘못됐나 싶어서 지피티 형에게 물어봤더니 칭찬받았다
내가 살다살다 지피티한테 칭찬받은 적은 처음이다 뿌듯🤣
'✨ 기억보다 기록을 > 프로젝트' 카테고리의 다른 글
20241121(목) ▶️ 굿즈샵 상세보기 페이지 - 가격 정보 데이터 불러오기 (0) 2024.11.21 20241120(수) ▶️ 굿즈샵 select 클라이언트에서 sort 해결하기+메모이제이션 (0) 2024.11.20 20241118(월) ▶️ 굿즈샵페이지 Badge 공통 컴포넌트 만들기 (0) 2024.11.19 20241118(월) ▶️ 굿즈샵페이지 데이터 불러오기, 리뷰 평점 평균값 계산, 할인율 계산하기 (0) 2024.11.18 20241117(일) ▶️ 메인페이지 앨범 리스트 불러오기, 음악 스트리밍 링크 동적으로 설정하기 (0) 2024.11.17 댓글