Skip to content
Merged
66 changes: 12 additions & 54 deletions src/app/page.tsx
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} />;
}
71 changes: 71 additions & 0 deletions src/components/pages/group-list/index.tsx
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>
);
}
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';
206 changes: 206 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,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]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Dependency on items.length causes fetchNext to recreate frequently.

The fetchNext function includes items.length in its dependencies (line 148), causing it to be recreated every time items are added. This is used only for console logging (lines 108, 119-120, 125).

This frequent recreation has cascading performance implications:

  1. fetchNext is passed as onIntersect to useIntersectionObserver
  2. The observer recreates on every onIntersect change
  3. Observer disconnect/reconnect is expensive and happens after every data fetch

Solution: Remove items.length from dependencies or use a ref pattern.

Option 1 - Use a ref for logging (recommended):

 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 itemsRef = useRef(items);
+
+  useEffect(() => {
+    itemsRef.current = items;
+  }, [items]);

Then update the fetchNext function to use the ref:

   const fetchNext = useCallback(async (): Promise<void> => {
     if (!canFetchNext()) {
       return;
     }

     isFetchingRef.current = true;

     console.log('다음 페이지 요청 시작', {
       '요청 크기': GROUP_LIST_PAGE_SIZE,
       '현재 커서': nextCursor,
-      '현재 누적 데이터 개수': items.length,
+      '현재 누적 데이터 개수': itemsRef.current.length,
       키워드: keyword || '없음',
     });

     try {
       const response = await API.groupService.getGroups({
         keyword,
         cursor: nextCursor as number,
         size: GROUP_LIST_PAGE_SIZE,
       });

-      const previousItemsCount = items.length;
+      const previousItemsCount = itemsRef.current.length;
       const newItemsCount = previousItemsCount + response.items.length;

       // ... rest of the function
     }
-  }, [canFetchNext, nextCursor, keyword, items.length, createError]);
+  }, [canFetchNext, nextCursor, keyword, createError]);

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In src/hooks/use-group/use-group-infinite-list/index.ts around lines 98 to 148,
fetchNext currently lists items.length in its dependency array which forces
recreation on every item append and causes the intersection observer to
reconnect; change to track items length with a ref instead of depending on
items.length for logging: create an itemsCountRef that you update whenever items
change (e.g., in the setItems updater or an effect) and use that ref inside
fetchNext for console messages, then remove items.length from the useCallback
dependency array so fetchNext only depends on stable values (canFetchNext,
nextCursor, keyword, createError).


/**
* 재시도 함수
*/
const handleRetry = useCallback(() => {
setError(null);
if (items.length === 0) {
fetchFirstPage(initialKeyword);
} else {
fetchNext();
}
}, [items.length, initialKeyword, fetchFirstPage, fetchNext]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Potential keyword inconsistency in handleRetry.

Line 156 calls fetchFirstPage(initialKeyword), but the user may have updated the keyword since initialization. The current keyword state might differ from initialKeyword, leading to unexpected behavior where retry uses a stale keyword.

Consider using the current keyword state instead:

 const handleRetry = useCallback(() => {
   setError(null);
   if (items.length === 0) {
-    fetchFirstPage(initialKeyword);
+    fetchFirstPage(keyword);
   } else {
     fetchNext();
   }
-}, [items.length, initialKeyword, fetchFirstPage, fetchNext]);
+}, [items.length, keyword, fetchFirstPage, fetchNext]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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.


/**
* 상태 초기화 함수
*/
const reset = useCallback(() => {
setItems([]);
setNextCursor(null);
setError(null);
isFetchingRef.current = false;
}, []);

/**
* 입력 키워드 변경 감지 및 첫 페이지 재요청
*/
useEffect(() => {
if (prevKeywordRef.current === initialKeyword) return;

reset();
setKeyword(initialKeyword);
fetchFirstPage(initialKeyword);
prevKeywordRef.current = initialKeyword;
}, [initialKeyword, reset, fetchFirstPage]);

/**
* 초기 데이터 로그
*/
useEffect(() => {
console.log('초기 데이터 로드 완료', {
'요청 크기': GROUP_LIST_PAGE_SIZE,
'받은 데이터 개수': initialItems.length,
'누적 데이터 개수': initialItems.length,
'다음 커서': initialCursor,
키워드: initialKeyword || '없음',
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

return {
items,
nextCursor,
error,
fetchNext,
handleRetry,
reset,
};
};
Loading