Zustand를 사용해서 웹툰 관련 상태와 API 호출 함수들을 관리하는 스토어인 WebtoonStore.ts를 작성해 보았다.
Zustand는 React 애플리케이션에서 전역 상태 관리를 쉽게 할 수 있도록 도와주는 경량 라이브러리이다.
import { create } from "zustand";
interface WebtoonState {
webtoons: WebtoonData[]; // 웹툰 목록
tagList: Tag[]; // 태그 목록
selectedProvider: string; // 배급사
selectedTag: string; // 검색 태그
selectedTerm: string; // 검색어
selectedTagIds: number[]; // 선택한 태그
selectedTags: string[]; // 선택한 태그
setSelectedProvider: (provider: string) => void;
setSelectedTag: (tag: string) => void;
setSelectedTerm: (term: string) => void;
toggleTag: (tag: string, tagId: number) => void;
resetSearchState: () => void; // 선택한 검색어들 리셋
resetTag: () => void; // 선택한 태그 리셋
allWebtoons: () => Promise<void>; // 웹툰 전체 목록 조회
globalSearch: (
prov: string,
selectedTag?: string,
selectedTerm?: string,
) => Promise<void>; // 통합 검색
categoryTags: (category: string) => Promise<void>; // 카테고리별 태그 조회
tagSearch: (
selectedTagIds: number[],
selectedTags: string[],
) => Promise<void>; // 태그별 검색
tagSort: (selectedTagIds: number[], sort: string) => Promise<void>; // 태그별 검색 정렬
daySearch: (serial_day: string, serial_type: string) => Promise<void>; // 연재별 검색
daySort: (
serial_day: string,
serial_type: string,
sort: string,
) => Promise<void>; // 연재별 검색 정렬
etcSearch: (serial_type: string) => Promise<void>; // 기타 검색 (요일별 - 기타)
etcSort: (sort: string, serial_type: string) => Promise<void>; // 기타 검색 정렬
}
먼저 인터페이스로 스토어에서 관리할 상태와 액션들을 정의한다.
웹툰과 태그는 API를 통해 받아온 데이터를 넣어줄 것이다.
const useWebtoonStore = create<WebtoonState>((set) => ({
webtoons: [],
tagList: [],
selectedProvider: "",
// ... 기타 상태와 액션들
}));
Zustand의 create 함수로 전역 상태 저장소, store를 생성해준다.
위에서 정의한 WebtoonState 타입의 store를 생성!
create 함수는 내부적으로 store를 생성하고, 해당 store에 접근할 수 있는 커스텀 훅 (여기서는 useWebtoonStore)을 반환한다.
set의 경우 Zustand store 내부의 상태를 업데이트하기 위해 제공되는 함수이다.
상태를 업데이트하면 이를 구독하고 있는 모든 컴포넌트가 리렌더링되어 최신 상태를 반영한다!
setSelectedProvider: (provider: string) =>
set({ selectedProvider: provider }),
위 코드에서 set을 사용해 selectedProvider라는 상태를 업데이트!
toggleTag: (tag: string, tagId: number) =>
set((state) => {
const isSelected = state.selectedTags.includes(tag);
const updatedTags = isSelected
? state.selectedTags.filter((t) => t !== tag)
: [...state.selectedTags, tag];
return { selectedTags: updatedTags };
}),
이렇게 현재 상태(state)를 인자로 받아서 상태에 의존한 업데이트를 하기도 한다.
여기서는 기본에 선택된 태그 배열을 기반으로 새로운 배열을 만들어 반환한다.
(선택한 태그가 이미 배열에 있다면 빼고, 없다면 넣고!)
API 호출 함수가 여러개라서, 통합 검색 API만 살펴보도록 하겠다.
// 통합 검색 API (배급사, 태그, 검색어 적용)
globalSearch: async (prov, searchTag?, searchTerm?) => {
try {
const queryString = `provider=${engProviderMapping[prov]}${searchTag ? `&tag=${searchTag}` : ""}${searchTerm ? `&term=${searchTerm}` : ""}`;
const res = await fetch(
`${process.env.NEXT_PUBLIC_SERVER_URL}/webtoons/search?${queryString}`,
);
if (!res.ok)
throw new Error(
"웹툰 목록을 불러오는 데 실패했습니다. 잠시 후 다시 시도해 주세요.",
);
const data = await res.json();
set({
webtoons: data,
selectedProvider: prov,
selectedTag: searchTag,
selectedTerm: searchTerm,
});
} catch (error) {
console.error("Error fetching search results:", error);
}
},
배급사와 태그, 검색어를 기반으로 웹툰 목록을 검색하는 API 호출 함수이다.
쿼리스트링에 영어로 변환된 provider, tag, term을 포함해서 요청 URL을 구성한다.
fetch로 요청을 보내고 응답을 받아서 set 함수로 웹툰 목록, 선택한 배급사, 선택한 태그, 선택한 검색어를 업데이트 한다!
전역으로 상태를 관리하고 있기 때문에 검색 후 검색창에 검색어를 유지할 때도 여기서 꺼내쓰면 된당.
import useWebtoonStore from "@/stores/webtoonStore";
const { globalSearch } = useWebtoonStore();
globalSearch('kakao', '판타지', '골동품')
사용할 때는 컴포넌트에서 이런 식으로 globalSearch를 호출하면 된다.
import { Tag, WebtoonData } from "@/components/common/webtoonCard/type";
import { engProviderMapping } from "@/lib/utils/engFomatter";
import { create } from "zustand";
interface WebtoonState {
webtoons: WebtoonData[]; // 웹툰 목록
tagList: Tag[]; // 태그 목록
selectedProvider: string; // 배급사
selectedTag: string; // 검색 태그
selectedTerm: string; // 검색어
selectedTagIds: number[]; // 선택한 태그
selectedTags: string[]; // 선택한 태그
setSelectedProvider: (provider: string) => void;
setSelectedTag: (tag: string) => void;
setSelectedTerm: (term: string) => void;
toggleTag: (tag: string, tagId: number) => void;
resetSearchState: () => void;
resetTag: () => void;
allWebtoons: () => Promise<void>; // 웹툰 전체 목록 조회
globalSearch: (
prov: string,
selectedTag?: string,
selectedTerm?: string,
) => Promise<void>; // 통합 검색
categoryTags: (category: string) => Promise<void>; // 카테고리별 태그 조회
tagSearch: (
selectedTagIds: number[],
selectedTags: string[],
) => Promise<void>; // 태그별 검색
tagSort: (selectedTagIds: number[], sort: string) => Promise<void>;
daySearch: (serial_day: string, serial_type: string) => Promise<void>; // 연재별 검색
daySort: (
serial_day: string,
serial_type: string,
sort: string,
) => Promise<void>;
etcSearch: (serial_type: string) => Promise<void>;
etcSort: (sort: string, serial_type: string) => Promise<void>;
}
const useWebtoonStore = create<WebtoonState>((set) => ({
webtoons: [],
tagList: [],
selectedProvider: "",
selectedTag: "",
selectedTerm: "",
selectedTagIds: [],
selectedTags: [],
setSelectedProvider: (provider: string) =>
set({ selectedProvider: provider }),
setSelectedTag: (tag: string) => set({ selectedTag: tag }),
setSelectedTerm: (term: string) => set({ selectedTerm: term }),
resetSearchState: () =>
set({
webtoons: [],
selectedProvider: "",
selectedTag: "",
selectedTerm: "",
selectedTagIds: [],
selectedTags: [],
}),
resetTag: () => {
set(() => {
return {
selectedTags: [],
selectedTagIds: [],
};
});
// 상태 업데이트 후 실행되도록 setTimeout 사용
setTimeout(() => {
const store = useWebtoonStore.getState(); // 최신 상태 가져오기
store.tagSearch(store.selectedTagIds, store.selectedTags);
}, 0);
},
toggleTag: (tag: string, tagId: number) =>
set((state) => {
const isSelected = state.selectedTags.includes(tag);
const isSelectedId = state.selectedTagIds.includes(tagId);
const updatedTags = isSelected
? state.selectedTags.filter((t) => t !== tag)
: [...state.selectedTags, tag];
const updatedTagIds = isSelectedId
? state.selectedTagIds.filter((t) => t !== tagId)
: [...state.selectedTagIds, tagId];
// 상태 업데이트
const newState = {
selectedTags: updatedTags,
selectedTagIds: updatedTagIds,
};
// 상태 업데이트 후 실행되도록 setTimeout 사용
setTimeout(() => {
const store = useWebtoonStore.getState(); // 최신 상태 가져오기
store.tagSearch(store.selectedTagIds, store.selectedTags);
}, 0);
return newState;
}),
// 전체 웹툰 목록 조회
allWebtoons: async () => {
try {
const res = await fetch(
`${process.env.NEXT_PUBLIC_SERVER_URL}/webtoons/request`,
);
if (!res.ok)
throw new Error(
"웹툰 목록을 불러오는 데 실패했습니다. 잠시 후 다시 시도해 주세요.",
);
const data = await res.json();
set({ webtoons: data });
} catch (error) {
console.error("Error fetching all webtoons:", error);
}
},
// 통합 검색 API (배급사, 태그, 검색어 적용)
globalSearch: async (prov, searchTag?, searchTerm?) => {
try {
const queryString = `provider=${engProviderMapping[prov]}${searchTag ? `&tag=${searchTag}` : ""}${searchTerm ? `&term=${searchTerm}` : ""}`;
const res = await fetch(
`${process.env.NEXT_PUBLIC_SERVER_URL}/webtoons/search?${queryString}`,
);
if (!res.ok)
throw new Error(
"웹툰 목록을 불러오는 데 실패했습니다. 잠시 후 다시 시도해 주세요.",
);
const data = await res.json();
set({
webtoons: data,
selectedProvider: prov,
selectedTag: searchTag,
selectedTerm: searchTerm,
});
} catch (error) {
console.error("Error fetching search results:", error);
}
},
// 카테고리별 태그 조회
categoryTags: async (category) => {
try {
const res = await fetch(
`${process.env.NEXT_PUBLIC_SERVER_URL}/webtoons/tag?category=${category}`,
);
if (!res.ok)
throw new Error(
"태그 목록을 불러오는 데 실패했습니다. 잠시 후 다시 시도해 주세요.",
);
const data = await res.json();
set({ tagList: data });
} catch (error) {
console.error("Error fetching search results:", error);
}
},
// 태그별 검색
// 드롭다운에서 태그 선택 시
// 웹툰 카드에서 태그 선택 시
tagSearch: async (selectedTagIds, selectedTags) => {
try {
const queryString = selectedTagIds.map((id) => `id=${id}`).join("&");
const res = await fetch(
`${process.env.NEXT_PUBLIC_SERVER_URL}/webtoons/search/tag?${queryString}`,
);
if (!res.ok)
throw new Error(
"웹툰 목록을 불러오는 데 실패했습니다. 잠시 후 다시 시도해 주세요.",
);
const data = await res.json();
set({ webtoons: data, selectedTags: selectedTags });
} catch (error) {
console.error("Error fetching search results:", error);
}
},
// 태그 - 드롭다운 정렬
tagSort: async (selectedTagIds, sort) => {
try {
const queryString = selectedTagIds.map((id) => `id=${id}`).join("&");
const res = await fetch(
`${process.env.NEXT_PUBLIC_SERVER_URL}/webtoons/list?${queryString}&sort=${sort}`,
);
if (!res.ok)
throw new Error(
"웹툰 목록을 불러오는 데 실패했습니다. 잠시 후 다시 시도해 주세요.",
);
const data = await res.json();
set({ webtoons: data });
} catch (error) {
console.error("Error fetching search results:", error);
}
},
// 요일별 검색
daySearch: async (serial_day, serial_type) => {
try {
const res = await fetch(
`${process.env.NEXT_PUBLIC_SERVER_URL}/webtoons/list?day=${serial_day}&status=${serial_type}`,
);
if (!res.ok)
throw new Error(
"웹툰 목록을 불러오는 데 실패했습니다. 잠시 후 다시 시도해 주세요.",
);
const data = await res.json();
set({ webtoons: data });
} catch (error) {
console.error("Error fetching search results:", error);
}
},
daySort: async (serial_day, serial_type, sort) => {
try {
const res = await fetch(
`${process.env.NEXT_PUBLIC_SERVER_URL}/webtoons/list?day=${serial_day}&sort=${sort}&status=${serial_type}`,
);
if (!res.ok)
throw new Error(
"웹툰 목록을 불러오는 데 실패했습니다. 잠시 후 다시 시도해 주세요.",
);
const data = await res.json();
set({ webtoons: data });
} catch (error) {
console.error("Error fetching search results:", error);
}
},
etcSearch: async (serial_type) => {
try {
const res = await fetch(
`${process.env.NEXT_PUBLIC_SERVER_URL}/webtoons/list?status=${serial_type}`,
);
if (!res.ok)
throw new Error(
"웹툰 목록을 불러오는 데 실패했습니다. 잠시 후 다시 시도해 주세요.",
);
const data = await res.json();
set({ webtoons: data });
} catch (error) {
console.error("Error fetching search results:", error);
}
},
etcSort: async (sort, serial_type) => {
try {
const res = await fetch(
`${process.env.NEXT_PUBLIC_SERVER_URL}/webtoons/list?sort=${sort}&status=${serial_type}`,
);
if (!res.ok)
throw new Error(
"웹툰 목록을 불러오는 데 실패했습니다. 잠시 후 다시 시도해 주세요.",
);
const data = await res.json();
set({ webtoons: data });
} catch (error) {
console.error("Error fetching search results:", error);
}
},
}));
export default useWebtoonStore;
전체 코드는 이렇게 된다.
생각했던 것보다 API 호출할 일이 많았고, 모아놓으니 코드 길이가 꽤 됐다.
간단할 줄 알았는데...! (이 정도면 간단한건가)
근데 GET만 써서 이정도면 간단한 건 맞는 것 같다.
Zustand는 처음 써보는데 이렇게 간편하게 작성하고 상태도 관리할 수 있어서 편리한 것 같다.
자주 써먹어야지 *_*
'개발 > 프로젝트' 카테고리의 다른 글
[리팩토링] 새로고침 해도 검색 결과가 남아있도록 하기 (1) | 2025.02.28 |
---|---|
Tailwind CSS + SCSS로 반응형 구현하기 (0) | 2025.02.25 |
Material UI를 사용하여 페이지네이션 구현하기 (2) | 2025.02.13 |
Swiper.js 라이브러리를 사용해서 슬라이더 구현하기 (Next.js + React) (0) | 2025.02.13 |
자동 로그인 구현하기 (0) | 2025.01.12 |