diff --git a/src/components/pages/group-list/index.tsx b/src/components/pages/group-list/index.tsx index 82d76e34..7e09d187 100644 --- a/src/components/pages/group-list/index.tsx +++ b/src/components/pages/group-list/index.tsx @@ -1,5 +1,7 @@ 'use client'; +import { useRouter } from 'next/navigation'; + import { InfiniteData } from '@tanstack/react-query'; import { ErrorMessage } from '@/components/shared'; @@ -16,10 +18,12 @@ interface GroupListProps { } export default function GroupList({ initialData, initialKeyword }: GroupListProps) { - const { items, error, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteGroupList({ - initialData, - initialKeyword, - }); + const router = useRouter(); + const { items, error, fetchNextPage, hasNextPage, isFetchingNextPage, completedMessage } = + useInfiniteGroupList({ + initialData, + initialKeyword, + }); // IntersectionObserver를 통한 무한 스크롤 감지 // React Query의 fetchNextPage를 트리거하는 역할만 수행 @@ -59,6 +63,7 @@ export default function GroupList({ initialData, initialKeyword }: GroupListProp profileImage={meeting.createdBy.profileImage} tags={meeting.tags} title={meeting.title} + onClick={() => router.push(`/meetup/${meeting.id}`)} /> )) )} @@ -76,7 +81,7 @@ export default function GroupList({ initialData, initialKeyword }: GroupListProp {/* hasNextPage가 false이면 모든 데이터를 불러온 상태 */} {!hasNextPage && items.length > 0 && !error && ( -
모든 모임을 불러왔습니다.
+
{completedMessage}
)} diff --git a/src/hooks/use-group/use-group-infinite-list/index.ts b/src/hooks/use-group/use-group-infinite-list/index.ts index ac0ca36c..e40aab64 100644 --- a/src/hooks/use-group/use-group-infinite-list/index.ts +++ b/src/hooks/use-group/use-group-infinite-list/index.ts @@ -6,17 +6,60 @@ import { API } from '@/api'; import { GROUP_LIST_PAGE_SIZE } from '@/lib/constants/group-list'; import { GetGroupsResponse, GroupListItemResponse } from '@/types/service/group'; +// 기본 타입 (그룹 목록용) type GroupInfiniteData = InfiniteData; type GroupQueryKey = ['groups', string | undefined]; const STALE_TIME = 3 * 1000; // 3초 -const ERROR_MESSAGE = '모임 목록을 불러오는데 실패했습니다.'; +const DEFAULT_ERROR_MESSAGE = '데이터를 불러오는데 실패했습니다.'; +// 범용 무한 스크롤 응답 타입 (다른 페이지에서도 사용 가능) +export interface InfiniteScrollResponse { + items: T[]; + nextCursor: number | null; +} + +// 범용 무한 스크롤 파라미터 타입 +export interface UseInfiniteScrollParams { + queryFn: (params: { + cursor?: number; + keyword?: string; + size: number; + }) => Promise>; + + queryKey: TQueryKey; + initialData?: InfiniteData, number | undefined>; + keyword?: string; + pageSize?: number; + staleTime?: number; + errorMessage?: string; + // 콘솔 로그 활성화 여부 (선택, 기본값: true) + enableLogging?: boolean; + // 모든 데이터 로드 완료 메시지 (선택, 기본값: "모든 데이터를 불러왔습니다.") + completedMessage?: string; +} + +// 범용 무한 스크롤 반환 타입 +export interface UseInfiniteScrollReturn { + items: TItem[]; + nextCursor: number | null; + error: Error | null; + fetchNextPage: () => void; + hasNextPage: boolean; + isFetchingNextPage: boolean; + isFetching: boolean; + refetch: () => void; + // 모든 데이터 로드 완료시 메시지 + completedMessage: string; +} + +// 그룹 목록 전용 파라미터 interface UseInfiniteGroupListParams { initialData?: GroupInfiniteData; initialKeyword?: string; } +// 그룹 목록 전용 반환 타입 interface UseInfiniteGroupListReturn { items: GroupListItemResponse[]; nextCursor: number | null; @@ -26,67 +69,83 @@ interface UseInfiniteGroupListReturn { isFetchingNextPage: boolean; isFetching: boolean; refetch: () => void; + completedMessage: string; } /** - * Cursor Pagination 기반 무한 스크롤 커스텀 훅 + * 범용 Cursor Pagination 기반 무한 스크롤 커스텀 훅 * React Query의 useInfiniteQuery를 활용하여 자동 중복 호출 방지, 요청 상태 관리, 캐싱 처리 + * 다른 페이지에서도 재사용 가능한 상태입니당 (pr 참고) */ -export const useInfiniteGroupList = ({ +// eslint-disable-next-line func-style +export function useInfiniteScroll({ + queryFn, + queryKey, initialData, - initialKeyword, -}: UseInfiniteGroupListParams): UseInfiniteGroupListReturn => { + keyword, + pageSize = 10, + staleTime = STALE_TIME, + errorMessage = DEFAULT_ERROR_MESSAGE, + enableLogging = true, + completedMessage = '모든 데이터를 불러왔습니다.', +}: UseInfiniteScrollParams): UseInfiniteScrollReturn { const queryClient = useQueryClient(); - const queryKey: GroupQueryKey = ['groups', initialKeyword]; + + type InfiniteScrollData = InfiniteData, number | undefined>; const { data, error, fetchNextPage, hasNextPage, isFetchingNextPage, isFetching, refetch } = useInfiniteQuery< - GetGroupsResponse, + InfiniteScrollResponse, Error, - GroupInfiniteData, - GroupQueryKey, + InfiniteScrollData, + TQueryKey, number | undefined >({ queryKey, queryFn: async ({ pageParam }) => { // 다음 페이지 요청 시작 로그 - if (pageParam !== undefined) { - const queryData = queryClient.getQueryData(queryKey); - const currentItemsCount = queryData?.pages.flatMap((page) => page.items).length ?? 0; + if (pageParam !== undefined && enableLogging) { + const queryData = queryClient.getQueryData(queryKey); + const currentItemsCount = + queryData?.pages.flatMap((page: InfiniteScrollResponse) => page.items).length ?? + 0; console.log('다음 페이지 요청 시작', { - '요청 크기': GROUP_LIST_PAGE_SIZE, + '요청 크기': pageSize, '현재 커서': pageParam, '현재 누적 데이터 개수': currentItemsCount, - 키워드: initialKeyword || '없음', + 키워드: keyword || '없음', }); } - const response = await API.groupService.getGroups({ - keyword: initialKeyword, + const response = await queryFn({ cursor: pageParam, - size: GROUP_LIST_PAGE_SIZE, + keyword, + size: pageSize, }); // 다음 페이지 요청 완료 로그 - if (pageParam !== undefined) { - const queryData = queryClient.getQueryData(queryKey); - const previousItemsCount = queryData?.pages.flatMap((page) => page.items).length ?? 0; + if (pageParam !== undefined && enableLogging) { + const queryData = queryClient.getQueryData(queryKey); + const previousItemsCount = + queryData?.pages.flatMap((page: InfiniteScrollResponse) => page.items).length ?? + 0; const newItemsCount = previousItemsCount + response.items.length; console.log('다음 페이지 요청 완료', { - '요청 크기': GROUP_LIST_PAGE_SIZE, + '요청 크기': pageSize, '받은 데이터 개수': response.items.length, '이전 누적 데이터 개수': previousItemsCount, '새로운 누적 데이터 개수': newItemsCount, '다음 커서': response.nextCursor, - 키워드: initialKeyword || '없음', + 키워드: keyword || '없음', }); if (response.nextCursor === null) { console.log('모든 데이터 로드 완료', { '총 데이터 개수': newItemsCount, - 키워드: initialKeyword || '없음', + 키워드: keyword || '없음', + 메시지: completedMessage, }); } } @@ -94,12 +153,12 @@ export const useInfiniteGroupList = ({ return response; }, initialPageParam: undefined, - getNextPageParam: (lastPage: GetGroupsResponse) => { + getNextPageParam: (lastPage) => { // nextCursor가 null이면 더 이상 요청하지 않음 return lastPage.nextCursor ?? undefined; }, - initialData: initialData as GroupInfiniteData | undefined, - staleTime: STALE_TIME, + initialData: initialData as InfiniteScrollData | undefined, + staleTime, }); // 여러 페이지의 아이템을 하나의 배열로 합치기 @@ -119,8 +178,8 @@ export const useInfiniteGroupList = ({ const errorObject = useMemo(() => { if (!error) return null; if (error instanceof Error) return error; - return new Error(ERROR_MESSAGE); - }, [error]); + return new Error(errorMessage); + }, [error, errorMessage]); return { items, @@ -131,5 +190,36 @@ export const useInfiniteGroupList = ({ isFetchingNextPage, isFetching, refetch, + completedMessage, }; +} + +/** + * 그룹 목록 전용 무한 스크롤 훅 + * 내부적으로 useInfiniteScroll을 사용 + */ +export const useInfiniteGroupList = ({ + initialData, + initialKeyword, +}: UseInfiniteGroupListParams): UseInfiniteGroupListReturn => { + const queryKey: GroupQueryKey = ['groups', initialKeyword]; + + return useInfiniteScroll({ + queryFn: async ({ cursor, keyword, size }) => { + const response = await API.groupService.getGroups({ + keyword, + cursor, + size, + }); + return response; + }, + queryKey, + initialData, + keyword: initialKeyword, + pageSize: GROUP_LIST_PAGE_SIZE, + staleTime: STALE_TIME, + errorMessage: '모임 목록을 불러오는데 실패했습니다.', + enableLogging: true, + completedMessage: '모든 모임을 불러왔습니다.', + }); };