Skip to content
Merged
67 changes: 15 additions & 52 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<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>
);
}
export default async function HomePage() {
const response = await API.groupService.getGroups({ size: GROUP_LIST_PAGE_SIZE });

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>
);
}
// React Query의 useInfiniteQuery에 맞는 initialData 형태로 변환
const initialData: InfiniteData<GetGroupsResponse, number | undefined> = {
pages: [response],
pageParams: [undefined], // 첫 페이지는 cursor가 없으므로 undefined
};

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 initialData={initialData} />;
}
84 changes: 84 additions & 0 deletions src/components/pages/group-list/index.tsx
Original file line number Diff line number Diff line change
@@ -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<GetGroupsResponse, number | undefined>;
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 (
<section className='min-h-screen bg-[#F1F5F9]'>
<div className='flex w-full flex-col gap-4 px-4 py-4'>
{error && items.length === 0 && (
<ErrorMessage
className='py-12'
message={error.message}
onRetry={() => window.location.reload()}
/>
)}

{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)}
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={() => window.location.reload()}
/>
)}

{/* sentinel 요소 생성: hasNextPage가 true이고 에러가 없으면 렌더 */}
{hasNextPage && !error && <div ref={sentinelRef} className='h-1' />}

{/* hasNextPage가 false이면 모든 데이터를 불러온 상태 */}
{!hasNextPage && items.length > 0 && !error && (
<div className='py-8 text-center text-gray-500'>모든 모임을 불러왔습니다.</div>
)}
</div>
</section>
);
}
17 changes: 17 additions & 0 deletions src/components/shared/error-message/index.tsx
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>
);
1 change: 1 addition & 0 deletions src/components/shared/index.ts
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';
135 changes: 135 additions & 0 deletions src/hooks/use-group/use-group-infinite-list/index.ts
Original file line number Diff line number Diff line change
@@ -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<GetGroupsResponse, number | undefined>;
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<GroupInfiniteData>(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<GroupInfiniteData>(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,
};
};
44 changes: 44 additions & 0 deletions src/hooks/use-intersection-observer/index.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(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;
};
2 changes: 2 additions & 0 deletions src/lib/constants/group-list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const GROUP_LIST_PAGE_SIZE = 10 as const;
export const INTERSECTION_OBSERVER_THRESHOLD = 0.1 as const;