From ce4a25a10b0b30e25e77a64f4bbbfc952f0dbd4e Mon Sep 17 00:00:00 2001 From: hyeonsu Date: Wed, 3 Sep 2025 17:57:37 +0900 Subject: [PATCH] =?UTF-8?q?=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20?= =?UTF-8?q?=EB=AC=B4=ED=95=9C=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/admin/CodeCategoryPanel.tsx | 74 +++++++----- frontend/hooks/queries/useCategories.ts | 4 +- .../hooks/queries/useCategoriesInfinite.ts | 42 +++++++ frontend/hooks/useInfiniteScroll.ts | 107 ++++++++++++++++++ frontend/lib/queryKeys.ts | 4 + 5 files changed, 201 insertions(+), 30 deletions(-) create mode 100644 frontend/hooks/queries/useCategoriesInfinite.ts create mode 100644 frontend/hooks/useInfiniteScroll.ts diff --git a/frontend/components/admin/CodeCategoryPanel.tsx b/frontend/components/admin/CodeCategoryPanel.tsx index 57a7c799..6cd0ca63 100644 --- a/frontend/components/admin/CodeCategoryPanel.tsx +++ b/frontend/components/admin/CodeCategoryPanel.tsx @@ -8,8 +8,8 @@ import { CodeCategoryFormModal } from "./CodeCategoryFormModal"; import { CategoryItem } from "./CategoryItem"; import { AlertModal } from "@/components/common/AlertModal"; import { Search, Plus } from "lucide-react"; -import { useCategories, useDeleteCategory } from "@/hooks/queries/useCategories"; -import { useSearchAndFilter } from "@/hooks/useSearchAndFilter"; +import { useDeleteCategory } from "@/hooks/queries/useCategories"; +import { useCategoriesInfinite } from "@/hooks/queries/useCategoriesInfinite"; interface CodeCategoryPanelProps { selectedCategoryCode: string; @@ -17,20 +17,23 @@ interface CodeCategoryPanelProps { } export function CodeCategoryPanel({ selectedCategoryCode, onSelectCategory }: CodeCategoryPanelProps) { - // React Query로 카테고리 데이터 관리 - const { data: categories = [], isLoading, error } = useCategories(); - const deleteCategoryMutation = useDeleteCategory(); + // 검색 및 필터 상태 (먼저 선언) + const [searchTerm, setSearchTerm] = useState(""); + const [showActiveOnly, setShowActiveOnly] = useState(false); - // 검색 및 필터링 훅 사용 + // React Query로 카테고리 데이터 관리 (무한 스크롤) const { - searchTerm, - setSearchTerm, - showActiveOnly, - setShowActiveOnly, - filteredItems: filteredCategories, - } = useSearchAndFilter(categories, { - searchFields: ["category_name", "category_code"], + data: categories = [], + isLoading, + error, + handleScroll, + isFetchingNextPage, + hasNextPage, + } = useCategoriesInfinite({ + search: searchTerm || undefined, + active: showActiveOnly || undefined, // isActive -> active로 수정 }); + const deleteCategoryMutation = useDeleteCategory(); // 모달 상태 const [showFormModal, setShowFormModal] = useState(false); @@ -125,29 +128,44 @@ export function CodeCategoryPanel({ selectedCategoryCode, onSelectCategory }: Co - {/* 카테고리 목록 */} -
+ {/* 카테고리 목록 (무한 스크롤) */} +
{isLoading ? (
- ) : filteredCategories.length === 0 ? ( + ) : categories.length === 0 ? (
{searchTerm ? "검색 결과가 없습니다." : "카테고리가 없습니다."}
) : ( -
- {filteredCategories.map((category) => ( - onSelectCategory(category.category_code)} - onEdit={() => handleEditCategory(category.category_code)} - onDelete={() => handleDeleteCategory(category.category_code)} - /> - ))} -
+ <> +
+ {categories.map((category, index) => ( + onSelectCategory(category.category_code)} + onEdit={() => handleEditCategory(category.category_code)} + onDelete={() => handleDeleteCategory(category.category_code)} + /> + ))} +
+ + {/* 추가 로딩 표시 */} + {isFetchingNextPage && ( +
+ + 추가 로딩 중... +
+ )} + + {/* 더 이상 데이터가 없을 때 */} + {!hasNextPage && categories.length > 0 && ( +
모든 카테고리를 불러왔습니다.
+ )} + )}
diff --git a/frontend/hooks/queries/useCategories.ts b/frontend/hooks/queries/useCategories.ts index 6665f591..fa148c11 100644 --- a/frontend/hooks/queries/useCategories.ts +++ b/frontend/hooks/queries/useCategories.ts @@ -46,8 +46,8 @@ export function useUpdateCategory() { queryClient.invalidateQueries({ queryKey: queryKeys.categories.detail(variables.categoryCode), }); - // 모든 카테고리 목록 쿼리 무효화 - queryClient.invalidateQueries({ queryKey: queryKeys.categories.lists() }); + // 모든 카테고리 관련 쿼리 무효화 (일반 목록 + 무한 스크롤) + queryClient.invalidateQueries({ queryKey: queryKeys.categories.all }); }, onError: (error) => { console.error("카테고리 수정 실패:", error); diff --git a/frontend/hooks/queries/useCategoriesInfinite.ts b/frontend/hooks/queries/useCategoriesInfinite.ts new file mode 100644 index 00000000..691b03fb --- /dev/null +++ b/frontend/hooks/queries/useCategoriesInfinite.ts @@ -0,0 +1,42 @@ +import { commonCodeApi } from "@/lib/api/commonCode"; +import { queryKeys } from "@/lib/queryKeys"; +import { useInfiniteScroll } from "@/hooks/useInfiniteScroll"; +import type { CategoryFilter } from "@/lib/schemas/commonCode"; +import type { CodeCategory } from "@/types/commonCode"; + +/** + * 카테고리 무한 스크롤 훅 + */ +export function useCategoriesInfinite(filters?: CategoryFilter) { + return useInfiniteScroll({ + queryKey: queryKeys.categories.infinite(filters), + queryFn: async ({ pageParam, ...params }) => { + // API 호출 시 페이지 정보 포함 + const expectedSize = pageParam === 1 ? 20 : 10; // 첫 페이지는 20개, 이후는 10개씩 + const response = await commonCodeApi.categories.getList({ + ...params, + page: pageParam, + size: expectedSize, + }); + + return { + data: response.data || [], + total: response.total, + hasMore: (response.data?.length || 0) >= expectedSize, // 예상 크기와 같거나 크면 더 있을 수 있음 + }; + }, + initialPageParam: 1, + pageSize: 20, // 첫 페이지 기준 + params: filters, + staleTime: 5 * 60 * 1000, // 5분 캐싱 + // 커스텀 getNextPageParam 제공 + getNextPageParam: (lastPage, allPages, lastPageParam) => { + // 마지막 페이지의 데이터 개수가 요청한 크기보다 작으면 더 이상 페이지 없음 + const expectedSize = lastPageParam === 1 ? 20 : 10; + if ((lastPage.data?.length || 0) < expectedSize) { + return undefined; + } + return lastPageParam + 1; + }, + }); +} diff --git a/frontend/hooks/useInfiniteScroll.ts b/frontend/hooks/useInfiniteScroll.ts new file mode 100644 index 00000000..dec4b25d --- /dev/null +++ b/frontend/hooks/useInfiniteScroll.ts @@ -0,0 +1,107 @@ +import { useInfiniteQuery } from "@tanstack/react-query"; +import { useMemo, useCallback } from "react"; + +export interface InfiniteScrollConfig> { + queryKey: any[]; + queryFn: (params: { pageParam: number } & TParams) => Promise<{ + data: TData[]; + total?: number; + hasMore?: boolean; + }>; + initialPageParam?: number; + getNextPageParam?: (lastPage: any, allPages: any[], lastPageParam: number) => number | undefined; + pageSize?: number; + enabled?: boolean; + staleTime?: number; + params?: TParams; +} + +export function useInfiniteScroll>({ + queryKey, + queryFn, + initialPageParam = 1, + getNextPageParam, + pageSize = 20, + enabled = true, + staleTime = 5 * 60 * 1000, // 5분 + params = {} as TParams, +}: InfiniteScrollConfig) { + // React Query의 useInfiniteQuery 사용 + const infiniteQuery = useInfiniteQuery({ + queryKey: [...queryKey, params], + queryFn: ({ pageParam }) => queryFn({ pageParam, ...params }), + initialPageParam, + getNextPageParam: + getNextPageParam || + ((lastPage, allPages, lastPageParam) => { + // 기본 페이지네이션 로직 + if (lastPage.data.length < pageSize) { + return undefined; // 더 이상 페이지 없음 + } + return lastPageParam + 1; + }), + enabled, + staleTime, + }); + + // 모든 페이지의 데이터를 평탄화 + const flatData = useMemo(() => { + return infiniteQuery.data?.pages.flatMap((page) => page.data) || []; + }, [infiniteQuery.data]); + + // 총 개수 계산 (첫 번째 페이지의 total 사용) + const totalCount = useMemo(() => { + return infiniteQuery.data?.pages[0]?.total || 0; + }, [infiniteQuery.data]); + + // 다음 페이지 로드 함수 + const loadMore = useCallback(() => { + if (infiniteQuery.hasNextPage && !infiniteQuery.isFetchingNextPage) { + infiniteQuery.fetchNextPage(); + } + }, [infiniteQuery]); + + // 스크롤 이벤트 핸들러 + const handleScroll = useCallback( + (e: React.UIEvent) => { + const { scrollTop, scrollHeight, clientHeight } = e.currentTarget; + + // 스크롤이 하단에서 100px 이내에 도달하면 다음 페이지 로드 + if (scrollHeight - scrollTop <= clientHeight + 100) { + loadMore(); + } + }, + [loadMore], + ); + + // 무한 스크롤 상태 정보 + const infiniteScrollState = { + // 데이터 + data: flatData, + totalCount, + + // 로딩 상태 + isLoading: infiniteQuery.isLoading, + isFetchingNextPage: infiniteQuery.isFetchingNextPage, + hasNextPage: infiniteQuery.hasNextPage, + + // 에러 상태 + error: infiniteQuery.error, + isError: infiniteQuery.isError, + + // 기타 상태 + isSuccess: infiniteQuery.isSuccess, + isFetching: infiniteQuery.isFetching, + }; + + return { + ...infiniteScrollState, + loadMore, + handleScroll, + refetch: infiniteQuery.refetch, + invalidate: infiniteQuery.refetch, + }; +} + +// 편의를 위한 타입 정의 +export type InfiniteScrollReturn = ReturnType>; diff --git a/frontend/lib/queryKeys.ts b/frontend/lib/queryKeys.ts index 0f19fdb2..67ead15d 100644 --- a/frontend/lib/queryKeys.ts +++ b/frontend/lib/queryKeys.ts @@ -9,6 +9,8 @@ export const queryKeys = { all: ["categories"] as const, lists: () => [...queryKeys.categories.all, "list"] as const, list: (filters?: { active?: boolean; search?: string }) => [...queryKeys.categories.lists(), filters] as const, + infinite: (filters?: { active?: boolean; search?: string }) => + [...queryKeys.categories.all, "infinite", filters] as const, details: () => [...queryKeys.categories.all, "detail"] as const, detail: (categoryCode: string) => [...queryKeys.categories.details(), categoryCode] as const, }, @@ -19,6 +21,8 @@ export const queryKeys = { lists: () => [...queryKeys.codes.all, "list"] as const, list: (categoryCode: string, filters?: { active?: boolean; search?: string }) => [...queryKeys.codes.lists(), categoryCode, filters] as const, + infinite: (categoryCode: string, filters?: { active?: boolean; search?: string }) => + [...queryKeys.codes.all, "infinite", categoryCode, filters] as const, details: () => [...queryKeys.codes.all, "detail"] as const, detail: (categoryCode: string, codeValue: string) => [...queryKeys.codes.details(), categoryCode, codeValue] as const,