-
Notifications
You must be signed in to change notification settings - Fork 0
[Feat] Cursor Pagination 기반 무한 스크롤 구현 #177
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 7 commits
d699546
e64b968
f5b151a
a7f5377
7b33ee0
bda3288
ab312d2
2d804e8
383ec38
c508711
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,58 +1,16 @@ | ||
| 'use client'; | ||
| import { API } from '@/api'; | ||
| import GroupList from '@/components/pages/group-list'; | ||
| import { GROUP_LIST_PAGE_SIZE } from '@/lib/constants/group-list'; | ||
|
|
||
| import Card from '@/components/shared/card'; | ||
| import { useGetGroups } from '@/hooks/use-group/use-group-get-list'; | ||
| import { formatDateTime } from '@/lib/formatDateTime'; | ||
| export const dynamic = 'force-dynamic'; | ||
|
|
||
| export default function HomePage() { | ||
| const { data, isLoading, error } = useGetGroups({ size: 10 }); | ||
| export default async function HomePage() { | ||
| const response = await API.groupService.getGroups({ size: GROUP_LIST_PAGE_SIZE }); | ||
| // 초기 모임 목록 데이터 추출 | ||
| const initialItems = response.items; | ||
| // 다음 페이지 요청을 위한 커서 값 추출 | ||
| const initialCursor = response.nextCursor; | ||
|
|
||
| if (isLoading) { | ||
| return ( | ||
| <main className='min-h-screen bg-[#F1F5F9]'> | ||
| <section className='flex w-full flex-col gap-4 px-4 py-4'> | ||
| <div className='py-8 text-center text-gray-500'>로딩 중...</div> | ||
| </section> | ||
| </main> | ||
| ); | ||
| } | ||
|
|
||
| if (error) { | ||
| return ( | ||
| <main className='min-h-screen bg-[#F1F5F9]'> | ||
| <section className='flex w-full flex-col gap-4 px-4 py-4'> | ||
| <div className='py-8 text-center text-red-500'> | ||
| 데이터를 불러오는 중 오류가 발생했습니다. | ||
| </div> | ||
| </section> | ||
| </main> | ||
| ); | ||
| } | ||
|
|
||
| const meetings = data?.items || []; | ||
|
|
||
| return ( | ||
| <main className='min-h-screen bg-[#F1F5F9]'> | ||
| <section className='flex w-full flex-col gap-4 px-4 py-4'> | ||
| {meetings.length === 0 ? ( | ||
| <div className='py-8 text-center text-gray-500'>모임이 없습니다.</div> | ||
| ) : ( | ||
| meetings.map((meeting) => ( | ||
| <Card | ||
| key={meeting.id} | ||
| dateTime={formatDateTime(meeting.startTime, meeting.endTime)} | ||
| images={meeting.images} | ||
| location={meeting.location} | ||
| maxParticipants={meeting.maxParticipants} | ||
| nickName={meeting.createdBy.nickName} | ||
| participantCount={meeting.participantCount} | ||
| profileImage={meeting.createdBy.profileImage} | ||
| tags={meeting.tags} | ||
| title={meeting.title} | ||
| /> | ||
| )) | ||
| )} | ||
| </section> | ||
| </main> | ||
| ); | ||
| // 초기 데이터를 전달해서 무한 스크롤 시작할거임 | ||
| return <GroupList initialCursor={initialCursor} initialItems={initialItems} />; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| 'use client'; | ||
|
|
||
| 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 { GroupListItemResponse } from '@/types/service/group'; | ||
|
|
||
| interface GroupListProps { | ||
| initialCursor: number | null; | ||
| initialItems: GroupListItemResponse[]; | ||
| initialKeyword?: string; | ||
| } | ||
|
|
||
| export default function GroupList({ initialCursor, initialItems, initialKeyword }: GroupListProps) { | ||
| const { items, nextCursor, error, fetchNext, handleRetry } = useInfiniteGroupList({ | ||
| initialCursor, | ||
| initialItems, | ||
| initialKeyword, | ||
| }); | ||
|
|
||
| // IntersectionObserver를 통한 무한 스크롤 감지 | ||
| const sentinelRef = useIntersectionObserver({ | ||
| onIntersect: fetchNext, | ||
| enabled: nextCursor !== null && error === null, | ||
| threshold: INTERSECTION_OBSERVER_THRESHOLD, | ||
| }); | ||
|
|
||
| return ( | ||
| <main className='min-h-screen bg-[#F1F5F9]'> | ||
| <section className='flex w-full flex-col gap-4 px-4 py-4'> | ||
| {error && items.length === 0 && ( | ||
| <ErrorMessage className='py-12' message={error.message} onRetry={handleRetry} /> | ||
| )} | ||
|
|
||
| {items.length === 0 && !error ? ( | ||
| <div className='py-8 text-center text-gray-500'>모임이 없습니다.</div> | ||
| ) : ( | ||
| items.map((meeting) => ( | ||
| <Card | ||
| key={meeting.id} | ||
| dateTime={formatDateTime(meeting.startTime, meeting.endTime)} | ||
| images={meeting.images} | ||
| location={meeting.location} | ||
| maxParticipants={meeting.maxParticipants} | ||
| nickName={meeting.createdBy.nickName} | ||
| participantCount={meeting.participantCount} | ||
| profileImage={meeting.createdBy.profileImage} | ||
| tags={meeting.tags} | ||
| title={meeting.title} | ||
| /> | ||
| )) | ||
| )} | ||
|
|
||
| {error && items.length > 0 && ( | ||
| <ErrorMessage className='py-8' message={error.message} onRetry={handleRetry} /> | ||
| )} | ||
|
|
||
| {/* sentinel 요소 생성: nextCursor가 null이거나 에러가 있으면 미렌더 */} | ||
| {nextCursor !== null && !error && <div ref={sentinelRef} className='h-1' />} | ||
|
|
||
| {/* nextCursor가 null이면 모든 데이터를 불러온 상태 */} | ||
| {nextCursor === null && items.length > 0 && !error && ( | ||
| <div className='py-8 text-center text-gray-500'>모든 모임을 불러왔습니다.</div> // 이후 수정 예정 | ||
| )} | ||
| </section> | ||
| </main> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| interface ErrorMessageProps { | ||
| message: string; | ||
| onRetry: () => void; | ||
| className?: string; | ||
| } | ||
|
|
||
| export const ErrorMessage = ({ className = '', message, onRetry }: ErrorMessageProps) => ( | ||
| <div className={`flex flex-col items-center justify-center gap-4 ${className}`}> | ||
| <p className='text-center text-gray-600'>{message}</p> | ||
| <button | ||
| className='bg-mint-500 hover:bg-mint-600 rounded-lg px-6 py-2 text-white transition-colors' | ||
| onClick={onRetry} | ||
| > | ||
| 다시 시도 | ||
| </button> | ||
| </div> | ||
| ); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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'; |
claudia99503 marked this conversation as resolved.
Show resolved
Hide resolved
|
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,206 @@ | ||||||||||||||||||||||||||||||||||
| import { useCallback, useEffect, useRef, useState } from 'react'; | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| import { API } from '@/api'; | ||||||||||||||||||||||||||||||||||
| import { GROUP_LIST_PAGE_SIZE } from '@/lib/constants/group-list'; | ||||||||||||||||||||||||||||||||||
| import { GroupListItemResponse } from '@/types/service/group'; | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| interface UseInfiniteGroupListParams { | ||||||||||||||||||||||||||||||||||
| initialCursor: number | null; | ||||||||||||||||||||||||||||||||||
| initialItems: GroupListItemResponse[]; | ||||||||||||||||||||||||||||||||||
| initialKeyword?: string; | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| interface UseInfiniteGroupListReturn { | ||||||||||||||||||||||||||||||||||
| items: GroupListItemResponse[]; | ||||||||||||||||||||||||||||||||||
| nextCursor: number | null; | ||||||||||||||||||||||||||||||||||
| error: Error | null; | ||||||||||||||||||||||||||||||||||
| fetchNext: () => Promise<void>; | ||||||||||||||||||||||||||||||||||
| handleRetry: () => void; | ||||||||||||||||||||||||||||||||||
| reset: () => void; | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||
| * 무한 스크롤 커스텀 훅 | ||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||
| export const useInfiniteGroupList = ({ | ||||||||||||||||||||||||||||||||||
| initialCursor, | ||||||||||||||||||||||||||||||||||
| initialItems, | ||||||||||||||||||||||||||||||||||
| initialKeyword, | ||||||||||||||||||||||||||||||||||
| }: UseInfiniteGroupListParams): UseInfiniteGroupListReturn => { | ||||||||||||||||||||||||||||||||||
| const [keyword, setKeyword] = useState<string | undefined>(initialKeyword); | ||||||||||||||||||||||||||||||||||
| const [items, setItems] = useState<GroupListItemResponse[]>(initialItems); | ||||||||||||||||||||||||||||||||||
| const [nextCursor, setNextCursor] = useState<number | null>(initialCursor); | ||||||||||||||||||||||||||||||||||
| const [error, setError] = useState<Error | null>(null); | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| const isFetchingRef = useRef(false); | ||||||||||||||||||||||||||||||||||
| const prevKeywordRef = useRef(initialKeyword); | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||
| * 에러 객체 생성 함수 | ||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||
| const createError = useCallback((err: unknown, defaultMessage: string): Error => { | ||||||||||||||||||||||||||||||||||
| return err instanceof Error ? err : new Error(defaultMessage); | ||||||||||||||||||||||||||||||||||
| }, []); | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||
| * 첫 페이지 조회 함수 // 콘솔은 지우지 말아주세요 🙏🏻 | ||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||
| const fetchFirstPage = useCallback( | ||||||||||||||||||||||||||||||||||
| async (searchKeyword?: string): Promise<void> => { | ||||||||||||||||||||||||||||||||||
| if (isFetchingRef.current) return; | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| isFetchingRef.current = true; | ||||||||||||||||||||||||||||||||||
| const currentKeyword = searchKeyword ?? keyword; | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| console.log('첫 페이지 요청 시작', { | ||||||||||||||||||||||||||||||||||
| '요청 크기': GROUP_LIST_PAGE_SIZE, | ||||||||||||||||||||||||||||||||||
| 키워드: currentKeyword || '없음', | ||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||
| const response = await API.groupService.getGroups({ | ||||||||||||||||||||||||||||||||||
| keyword: currentKeyword, | ||||||||||||||||||||||||||||||||||
| size: GROUP_LIST_PAGE_SIZE, | ||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| console.log('첫 페이지 요청 완료', { | ||||||||||||||||||||||||||||||||||
| '요청 크기': GROUP_LIST_PAGE_SIZE, | ||||||||||||||||||||||||||||||||||
| '받은 데이터 개수': response.items.length, | ||||||||||||||||||||||||||||||||||
| '누적 데이터 개수': response.items.length, | ||||||||||||||||||||||||||||||||||
| '다음 커서': response.nextCursor, | ||||||||||||||||||||||||||||||||||
| 키워드: currentKeyword || '없음', | ||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| setItems(response.items); | ||||||||||||||||||||||||||||||||||
| setNextCursor(response.nextCursor); | ||||||||||||||||||||||||||||||||||
| setError(null); | ||||||||||||||||||||||||||||||||||
| } catch (err) { | ||||||||||||||||||||||||||||||||||
| const error = createError(err, '모임 목록을 불러오는데 실패했습니다.'); | ||||||||||||||||||||||||||||||||||
| console.error('첫 페이지 조회 실패:', error); | ||||||||||||||||||||||||||||||||||
| setError(error); | ||||||||||||||||||||||||||||||||||
| } finally { | ||||||||||||||||||||||||||||||||||
| isFetchingRef.current = false; | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||
| [keyword, createError], | ||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||
| * 다음 페이지 조회 가능 여부 확인 | ||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||
| const canFetchNext = useCallback((): boolean => { | ||||||||||||||||||||||||||||||||||
| return nextCursor !== null && !isFetchingRef.current; | ||||||||||||||||||||||||||||||||||
| }, [nextCursor]); | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||
| * 다음 페이지 요청 함수 | ||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||
| const fetchNext = useCallback(async (): Promise<void> => { | ||||||||||||||||||||||||||||||||||
| if (!canFetchNext()) { | ||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| isFetchingRef.current = true; | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| console.log('다음 페이지 요청 시작', { | ||||||||||||||||||||||||||||||||||
| '요청 크기': GROUP_LIST_PAGE_SIZE, | ||||||||||||||||||||||||||||||||||
| '현재 커서': nextCursor, | ||||||||||||||||||||||||||||||||||
| '현재 누적 데이터 개수': items.length, | ||||||||||||||||||||||||||||||||||
| 키워드: keyword || '없음', | ||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||
| const response = await API.groupService.getGroups({ | ||||||||||||||||||||||||||||||||||
| keyword, | ||||||||||||||||||||||||||||||||||
| cursor: nextCursor as number, | ||||||||||||||||||||||||||||||||||
| size: GROUP_LIST_PAGE_SIZE, | ||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| const previousItemsCount = items.length; | ||||||||||||||||||||||||||||||||||
| const newItemsCount = previousItemsCount + response.items.length; | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| console.log('다음 페이지 요청 완료', { | ||||||||||||||||||||||||||||||||||
| '요청 크기': GROUP_LIST_PAGE_SIZE, | ||||||||||||||||||||||||||||||||||
| '받은 데이터 개수': response.items.length, | ||||||||||||||||||||||||||||||||||
| '이전 누적 데이터 개수': previousItemsCount, | ||||||||||||||||||||||||||||||||||
| '새로운 누적 데이터 개수': newItemsCount, | ||||||||||||||||||||||||||||||||||
| '다음 커서': response.nextCursor, | ||||||||||||||||||||||||||||||||||
| 키워드: keyword || '없음', | ||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| if (response.nextCursor === null) { | ||||||||||||||||||||||||||||||||||
| console.log('모든 데이터 로드 완료', { | ||||||||||||||||||||||||||||||||||
| '총 데이터 개수': newItemsCount, | ||||||||||||||||||||||||||||||||||
| 키워드: keyword || '없음', | ||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| setItems((prevItems) => [...prevItems, ...response.items]); | ||||||||||||||||||||||||||||||||||
| setNextCursor(response.nextCursor); | ||||||||||||||||||||||||||||||||||
| setError(null); | ||||||||||||||||||||||||||||||||||
| } catch (err) { | ||||||||||||||||||||||||||||||||||
| const error = createError(err, '다음 페이지를 불러오는데 실패했습니다.'); | ||||||||||||||||||||||||||||||||||
| console.error('다음 페이지 조회 실패:', error); | ||||||||||||||||||||||||||||||||||
| setError(error); | ||||||||||||||||||||||||||||||||||
| } finally { | ||||||||||||||||||||||||||||||||||
| isFetchingRef.current = false; | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| }, [canFetchNext, nextCursor, keyword, items.length, createError]); | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||
| * 재시도 함수 | ||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||
| const handleRetry = useCallback(() => { | ||||||||||||||||||||||||||||||||||
| setError(null); | ||||||||||||||||||||||||||||||||||
| if (items.length === 0) { | ||||||||||||||||||||||||||||||||||
| fetchFirstPage(initialKeyword); | ||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||
| fetchNext(); | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| }, [items.length, initialKeyword, fetchFirstPage, fetchNext]); | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
| const handleRetry = useCallback(() => { | |
| setError(null); | |
| if (items.length === 0) { | |
| fetchFirstPage(initialKeyword); | |
| } else { | |
| fetchNext(); | |
| } | |
| }, [items.length, initialKeyword, fetchFirstPage, fetchNext]); | |
| const handleRetry = useCallback(() => { | |
| setError(null); | |
| if (items.length === 0) { | |
| fetchFirstPage(keyword); | |
| } else { | |
| fetchNext(); | |
| } | |
| }, [items.length, keyword, fetchFirstPage, fetchNext]); |
🤖 Prompt for AI Agents
In src/hooks/use-group/use-group-infinite-list/index.ts around lines 153 to 160,
handleRetry currently calls fetchFirstPage(initialKeyword) which can use a stale
value if the user changed the keyword; change it to call fetchFirstPage(keyword)
so the retry uses the current keyword state, and update the useCallback
dependency array to include keyword (and any other needed stable refs) to avoid
stale closures.
Uh oh!
There was an error while loading. Please reload this page.