diff --git a/src/app/page.tsx b/src/app/page.tsx index cc7d6e0c..967055d1 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,58 +1,21 @@ -'use client'; +import { InfiniteData } from '@tanstack/react-query'; -import Card from '@/components/shared/card'; -import { useGetGroups } from '@/hooks/use-group/use-group-get-list'; -import { formatDateTime } from '@/lib/formatDateTime'; +import { API } from '@/api'; +import GroupList from '@/components/pages/group-list'; +import { GROUP_LIST_PAGE_SIZE } from '@/lib/constants/group-list'; +import { GetGroupsResponse } from '@/types/service/group'; -export default function HomePage() { - const { data, isLoading, error } = useGetGroups({ size: 10 }); +export const dynamic = 'force-dynamic'; - if (isLoading) { - return ( -
-
-
로딩 중...
-
-
- ); - } +export default async function HomePage() { + const response = await API.groupService.getGroups({ size: GROUP_LIST_PAGE_SIZE }); - if (error) { - return ( -
-
-
- 데이터를 불러오는 중 오류가 발생했습니다. -
-
-
- ); - } + // React Query의 useInfiniteQuery에 맞는 initialData 형태로 변환 + const initialData: InfiniteData = { + pages: [response], + pageParams: [undefined], // 첫 페이지는 cursor가 없으므로 undefined + }; - const meetings = data?.items || []; - - return ( -
-
- {meetings.length === 0 ? ( -
모임이 없습니다.
- ) : ( - meetings.map((meeting) => ( - - )) - )} -
-
- ); + // 초기 데이터를 전달해서 무한 스크롤 시작 + return ; } diff --git a/src/components/pages/group-list/index.tsx b/src/components/pages/group-list/index.tsx new file mode 100644 index 00000000..82d76e34 --- /dev/null +++ b/src/components/pages/group-list/index.tsx @@ -0,0 +1,84 @@ +'use client'; + +import { InfiniteData } from '@tanstack/react-query'; + +import { ErrorMessage } from '@/components/shared'; +import Card from '@/components/shared/card'; +import { useInfiniteGroupList } from '@/hooks/use-group/use-group-infinite-list'; +import { useIntersectionObserver } from '@/hooks/use-intersection-observer'; +import { INTERSECTION_OBSERVER_THRESHOLD } from '@/lib/constants/group-list'; +import { formatDateTime } from '@/lib/formatDateTime'; +import { GetGroupsResponse } from '@/types/service/group'; + +interface GroupListProps { + initialData?: InfiniteData; + initialKeyword?: string; +} + +export default function GroupList({ initialData, initialKeyword }: GroupListProps) { + const { items, error, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteGroupList({ + initialData, + initialKeyword, + }); + + // IntersectionObserver를 통한 무한 스크롤 감지 + // React Query의 fetchNextPage를 트리거하는 역할만 수행 + const sentinelRef = useIntersectionObserver({ + onIntersect: () => { + if (hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, + enabled: hasNextPage && error === null, + threshold: INTERSECTION_OBSERVER_THRESHOLD, + }); + + return ( +
+
+ {error && items.length === 0 && ( + window.location.reload()} + /> + )} + + {items.length === 0 && !error ? ( +
모임이 없습니다.
+ ) : ( + items.map((meeting) => ( + + )) + )} + + {error && items.length > 0 && ( + window.location.reload()} + /> + )} + + {/* sentinel 요소 생성: hasNextPage가 true이고 에러가 없으면 렌더 */} + {hasNextPage && !error &&
} + + {/* hasNextPage가 false이면 모든 데이터를 불러온 상태 */} + {!hasNextPage && items.length > 0 && !error && ( +
모든 모임을 불러왔습니다.
+ )} +
+
+ ); +} diff --git a/src/components/shared/error-message/index.tsx b/src/components/shared/error-message/index.tsx new file mode 100644 index 00000000..2133a468 --- /dev/null +++ b/src/components/shared/error-message/index.tsx @@ -0,0 +1,17 @@ +interface ErrorMessageProps { + message: string; + onRetry: () => void; + className?: string; +} + +export const ErrorMessage = ({ className = '', message, onRetry }: ErrorMessageProps) => ( +
+

{message}

+ +
+); diff --git a/src/components/shared/index.ts b/src/components/shared/index.ts index e04ccb2d..5d2bd2be 100644 --- a/src/components/shared/index.ts +++ b/src/components/shared/index.ts @@ -1,5 +1,6 @@ export { AnimateDynamicHeight } from './animate-dynamic-height'; export { AuthSwitch } from './auth-switch-link'; +export { ErrorMessage } from './error-message'; export { FormInput } from './form-input'; export { SearchBar } from './search-bar'; export { TabNavigation } from './tab-navigation'; diff --git a/src/hooks/use-group/use-group-infinite-list/index.ts b/src/hooks/use-group/use-group-infinite-list/index.ts new file mode 100644 index 00000000..ac0ca36c --- /dev/null +++ b/src/hooks/use-group/use-group-infinite-list/index.ts @@ -0,0 +1,135 @@ +import { useMemo } from 'react'; + +import { InfiniteData, useInfiniteQuery, useQueryClient } from '@tanstack/react-query'; + +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 = '모임 목록을 불러오는데 실패했습니다.'; + +interface UseInfiniteGroupListParams { + initialData?: GroupInfiniteData; + initialKeyword?: string; +} + +interface UseInfiniteGroupListReturn { + items: GroupListItemResponse[]; + nextCursor: number | null; + error: Error | null; + fetchNextPage: () => void; + hasNextPage: boolean; + isFetchingNextPage: boolean; + isFetching: boolean; + refetch: () => void; +} + +/** + * Cursor Pagination 기반 무한 스크롤 커스텀 훅 + * React Query의 useInfiniteQuery를 활용하여 자동 중복 호출 방지, 요청 상태 관리, 캐싱 처리 + */ +export const useInfiniteGroupList = ({ + initialData, + initialKeyword, +}: UseInfiniteGroupListParams): UseInfiniteGroupListReturn => { + const queryClient = useQueryClient(); + const queryKey: GroupQueryKey = ['groups', initialKeyword]; + + const { data, error, fetchNextPage, hasNextPage, isFetchingNextPage, isFetching, refetch } = + useInfiniteQuery< + GetGroupsResponse, + Error, + GroupInfiniteData, + GroupQueryKey, + number | undefined + >({ + queryKey, + queryFn: async ({ pageParam }) => { + // 다음 페이지 요청 시작 로그 + if (pageParam !== undefined) { + const queryData = queryClient.getQueryData(queryKey); + const currentItemsCount = queryData?.pages.flatMap((page) => page.items).length ?? 0; + + console.log('다음 페이지 요청 시작', { + '요청 크기': GROUP_LIST_PAGE_SIZE, + '현재 커서': pageParam, + '현재 누적 데이터 개수': currentItemsCount, + 키워드: initialKeyword || '없음', + }); + } + + const response = await API.groupService.getGroups({ + keyword: initialKeyword, + cursor: pageParam, + size: GROUP_LIST_PAGE_SIZE, + }); + + // 다음 페이지 요청 완료 로그 + if (pageParam !== undefined) { + const queryData = queryClient.getQueryData(queryKey); + const previousItemsCount = queryData?.pages.flatMap((page) => page.items).length ?? 0; + const newItemsCount = previousItemsCount + response.items.length; + + console.log('다음 페이지 요청 완료', { + '요청 크기': GROUP_LIST_PAGE_SIZE, + '받은 데이터 개수': response.items.length, + '이전 누적 데이터 개수': previousItemsCount, + '새로운 누적 데이터 개수': newItemsCount, + '다음 커서': response.nextCursor, + 키워드: initialKeyword || '없음', + }); + + if (response.nextCursor === null) { + console.log('모든 데이터 로드 완료', { + '총 데이터 개수': newItemsCount, + 키워드: initialKeyword || '없음', + }); + } + } + + return response; + }, + initialPageParam: undefined, + getNextPageParam: (lastPage: GetGroupsResponse) => { + // nextCursor가 null이면 더 이상 요청하지 않음 + return lastPage.nextCursor ?? undefined; + }, + initialData: initialData as GroupInfiniteData | undefined, + staleTime: STALE_TIME, + }); + + // 여러 페이지의 아이템을 하나의 배열로 합치기 + const items = useMemo(() => { + if (!data?.pages) return []; + return data.pages.flatMap((page) => page.items); + }, [data]); + + // 마지막 페이지의 nextCursor 값 + const nextCursor = useMemo(() => { + if (!data?.pages || data.pages.length === 0) return null; + const lastPage = data.pages[data.pages.length - 1]; + return lastPage?.nextCursor ?? null; + }, [data]); + + // 에러 객체 변환 + const errorObject = useMemo(() => { + if (!error) return null; + if (error instanceof Error) return error; + return new Error(ERROR_MESSAGE); + }, [error]); + + return { + items, + nextCursor, + error: errorObject, + fetchNextPage, + hasNextPage: hasNextPage ?? false, + isFetchingNextPage, + isFetching, + refetch, + }; +}; diff --git a/src/hooks/use-intersection-observer/index.ts b/src/hooks/use-intersection-observer/index.ts new file mode 100644 index 00000000..f0921095 --- /dev/null +++ b/src/hooks/use-intersection-observer/index.ts @@ -0,0 +1,44 @@ +import { useEffect, useRef } from 'react'; + +import { INTERSECTION_OBSERVER_THRESHOLD } from '@/lib/constants/group-list'; + +interface UseIntersectionObserverParams { + onIntersect: () => void; + enabled?: boolean; + threshold?: number; + root?: Element | null; +} + +export const useIntersectionObserver = ({ + onIntersect, // 요소가 화면에 보일 때 실행할 콜백 함수 + enabled = true, // observer 활성화 여부 (기본값: true) + threshold = INTERSECTION_OBSERVER_THRESHOLD, // 요소가 얼마나 보여야 감지할지 (기본값: 10%로 설정) + root = null, // 관찰 기준 요소 (기본값: null = 뷰포트) +}: UseIntersectionObserverParams) => { + const targetRef = useRef(null); + + useEffect(() => { + const target = targetRef.current; + if (!target || !enabled) return; + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting) { + onIntersect(); + } + }, + { + threshold, + root, + }, + ); + + observer.observe(target); + + return () => { + observer.disconnect(); + }; + }, [onIntersect, enabled, threshold, root]); + + return targetRef; +}; diff --git a/src/lib/constants/group-list.ts b/src/lib/constants/group-list.ts new file mode 100644 index 00000000..7856d7ff --- /dev/null +++ b/src/lib/constants/group-list.ts @@ -0,0 +1,2 @@ +export const GROUP_LIST_PAGE_SIZE = 10 as const; +export const INTERSECTION_OBSERVER_THRESHOLD = 0.1 as const;