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,