[refactor] 모임 상세 페이지 성능 최적화 - #230
Conversation
📝 WalkthroughWalkthrough모임 상세 페이지 성능을 최적화하기 위해 컴포넌트 구조를 재설계했습니다. 큰 컴포넌트를 작은 단위로 분해하고, 메모이제이션을 추가하며, 권장 모임 섹션을 별도 async 컴포넌트로 이전했습니다. Changes
Sequence Diagram(s)sequenceDiagram
participant Page as MoimDetailPage
participant Section as RecommendedMeetingsSection
participant Server as Server
participant Parser as parseMeetingsPage
participant Sort as sortRecommendedMeetings
participant Wrapper as RecommendedMeetingsWrapper
participant Client as RecommendedMeetingsClient
Page->>Section: meetingId 전달
Section->>Server: 모임 상세 정보 요청
Server-->>Section: meetingDetail 응답
Section->>Server: 모든 모임 목록 페칭 (커서 기반)
Server-->>Section: 모임 데이터 응답
Section->>Parser: 응답 데이터 정규화
Parser-->>Section: 파싱된 모임 배열
Section->>Sort: 현재 모임 정보 기반 정렬
Sort-->>Section: 정렬된 권장 모임
Section->>Wrapper: 권장 모임 데이터 전달
Wrapper->>Client: 클라이언트 렌더링
Client-->>Page: 권장 모임 UI 표시
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/web/src/_pages/moim-detail/ui/recommended-meetings-client.tsx`:
- Around line 90-96: The inline onLike prop creates a new function each render
causing potential re-renders of CompactCard; wrap the onLike handler in
useCallback (e.g. const onLike = useCallback(() => { if (isPending) return
false; return handleToggleLike(meeting.id, meeting.isLiked); }, [isPending,
meeting.id, meeting.isLiked, handleToggleLike])) and pass that onLike to
CompactCard, and also ensure handleToggleLike itself is memoized with
useCallback so its reference is stable.
In `@apps/web/src/_pages/moim-detail/ui/recommended-meetings-section.tsx`:
- Around line 136-137: The catch block in recommended-meetings-section.tsx
currently swallows all exceptions (catch { return null; }), so change it to
capture the exception (catch (err)) and emit a structured error log before
returning null; use the project's logger (or console.error if none) and include
context keys such as component: "recommended-meetings-section", any relevant IDs
or props (e.g., moimId or userId) and the error object to aid triage, e.g.,
logger.error({ err, component: "recommended-meetings-section", moimId, props },
"Failed to load recommended meetings") then return null as before.
- Around line 76-88: getAllMeetings can loop forever if the API returns the same
cursor repeatedly; update getAllMeetings to guard against repeated/unchanged
cursors by tracking seen cursors (e.g., a Set) or comparing the new nextCursor
to the previous cursor and breaking/throwing if it repeats or is unchanged, and
optionally enforce a max-iteration safety limit; reference getAllMeetings,
cursor, nextCursor, parseMeetingsPage, and isRecommendedMeetingItem when
applying the fix.
- Around line 93-133: RecommendedMeetingsSection currently does all data
fetching (getApi, getAllMeetings, apiClient.meetings.getDetail,
sortRecommendedMeetings, mapMeetingToRecommendedMeetingData) before rendering,
causing the section title to wait; extract the async logic into a new async
child component (e.g., RecommendedMeetingsLoader or RecommendedMeetingsContent)
that performs getApi, getAllMeetings, apiClient.meetings.getDetail, builds
sortableMeetingDetail, calls sortRecommendedMeetings and
mapMeetingToRecommendedMeetingData and returns <RecommendedMeetingsWrapper
meetings={...}>; make RecommendedMeetingsSection synchronous so it immediately
renders the static title and a Suspense that lazy-loads the new async child;
keep the existing Suspense fallback UI and ensure you reference
RecommendedMeetingsWrapper from the child.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: 1ee6af79-ec5a-4d49-9278-a28af8cffcaa
📒 Files selected for processing (9)
apps/web/src/_pages/moim-detail/lib/scroll-reset.tsxapps/web/src/_pages/moim-detail/ui/hero-image-section.tsxapps/web/src/_pages/moim-detail/ui/information-container.tsxapps/web/src/_pages/moim-detail/ui/moim-detail-client.tsxapps/web/src/_pages/moim-detail/ui/recommended-meetings-client.tsxapps/web/src/_pages/moim-detail/ui/recommended-meetings-section.tsxapps/web/src/_pages/moim-detail/ui/recommended-meetings-wrapper.tsxapps/web/src/app/(main)/moim-detail/[meetingId]/page.tsxapps/web/src/entities/moim-detail/model/sort-recommended-meetings.ts
| onLike={() => { | ||
| if (isPending) { | ||
| return false; | ||
| } | ||
|
|
||
| return handleToggleLike(meeting.id, meeting.isLiked); | ||
| }} |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
인라인 함수를 useCallback으로 메모이제이션하는 것을 고려해 보세요.
CompactCard가 메모이제이션된 컴포넌트라면, 매 렌더링마다 새로운 onLike 함수 참조가 생성되어 자식 컴포넌트의 불필요한 리렌더링을 유발할 수 있습니다. 성능에 민감한 리스트에서는 useCallback을 사용하여 함수 참조를 안정화하는 것이 좋습니다.
♻️ 선택적 리팩토링 제안
+ const handleLikeForMeeting = useCallback(
+ (meetingId: number, isLiked: boolean) => {
+ if (pendingLikeIds.includes(meetingId)) {
+ return false;
+ }
+ return handleToggleLike(meetingId, isLiked);
+ },
+ [pendingLikeIds, handleToggleLike],
+ );그리고 JSX에서:
onLike={() => {
- if (isPending) {
- return false;
- }
- return handleToggleLike(meeting.id, meeting.isLiked);
+ return handleLikeForMeeting(meeting.id, meeting.isLiked);
}}단, handleToggleLike 함수도 useCallback으로 래핑해야 합니다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/web/src/_pages/moim-detail/ui/recommended-meetings-client.tsx` around
lines 90 - 96, The inline onLike prop creates a new function each render causing
potential re-renders of CompactCard; wrap the onLike handler in useCallback
(e.g. const onLike = useCallback(() => { if (isPending) return false; return
handleToggleLike(meeting.id, meeting.isLiked); }, [isPending, meeting.id,
meeting.isLiked, handleToggleLike])) and pass that onLike to CompactCard, and
also ensure handleToggleLike itself is memoized with useCallback so its
reference is stable.
| const getAllMeetings = async (apiClient: Awaited<ReturnType<typeof getApi>>) => { | ||
| const allMeetings: RecommendedMeetingItem[] = []; | ||
| let cursor: string | null = null; | ||
|
|
||
| while (true) { | ||
| const response = await apiClient.meetings.getList(cursor ? { cursor } : undefined); | ||
| const { data, nextCursor } = parseMeetingsPage<RecommendedMeetingItem>(response); | ||
|
|
||
| allMeetings.push(...data.filter(isRecommendedMeetingItem)); | ||
|
|
||
| if (!nextCursor) break; | ||
| cursor = nextCursor; | ||
| } |
There was a problem hiding this comment.
커서 순환 응답 시 while (true)가 종료되지 않을 수 있습니다.
Line 80~88은 nextCursor만으로 탈출하는 구조라, API가 동일 커서를 반복 반환하면 요청이 끝나지 않고 서버 렌더링이 매달릴 수 있습니다.
🔧 제안 수정안
const getAllMeetings = async (apiClient: Awaited<ReturnType<typeof getApi>>) => {
const allMeetings: RecommendedMeetingItem[] = [];
let cursor: string | null = null;
+ const seenCursors = new Set<string>();
+ let pageCount = 0;
+ const MAX_PAGES = 100;
while (true) {
+ if (pageCount++ >= MAX_PAGES) break;
+ if (cursor && seenCursors.has(cursor)) break;
+ if (cursor) seenCursors.add(cursor);
+
const response = await apiClient.meetings.getList(cursor ? { cursor } : undefined);
const { data, nextCursor } = parseMeetingsPage<RecommendedMeetingItem>(response);
allMeetings.push(...data.filter(isRecommendedMeetingItem));
- if (!nextCursor) break;
+ if (!nextCursor || nextCursor === cursor) break;
cursor = nextCursor;
}
return allMeetings;
};📝 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.
| const getAllMeetings = async (apiClient: Awaited<ReturnType<typeof getApi>>) => { | |
| const allMeetings: RecommendedMeetingItem[] = []; | |
| let cursor: string | null = null; | |
| while (true) { | |
| const response = await apiClient.meetings.getList(cursor ? { cursor } : undefined); | |
| const { data, nextCursor } = parseMeetingsPage<RecommendedMeetingItem>(response); | |
| allMeetings.push(...data.filter(isRecommendedMeetingItem)); | |
| if (!nextCursor) break; | |
| cursor = nextCursor; | |
| } | |
| const getAllMeetings = async (apiClient: Awaited<ReturnType<typeof getApi>>) => { | |
| const allMeetings: RecommendedMeetingItem[] = []; | |
| let cursor: string | null = null; | |
| const seenCursors = new Set<string>(); | |
| let pageCount = 0; | |
| const MAX_PAGES = 100; | |
| while (true) { | |
| if (pageCount++ >= MAX_PAGES) break; | |
| if (cursor && seenCursors.has(cursor)) break; | |
| if (cursor) seenCursors.add(cursor); | |
| const response = await apiClient.meetings.getList(cursor ? { cursor } : undefined); | |
| const { data, nextCursor } = parseMeetingsPage<RecommendedMeetingItem>(response); | |
| allMeetings.push(...data.filter(isRecommendedMeetingItem)); | |
| if (!nextCursor || nextCursor === cursor) break; | |
| cursor = nextCursor; | |
| } | |
| return allMeetings; | |
| }; |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/web/src/_pages/moim-detail/ui/recommended-meetings-section.tsx` around
lines 76 - 88, getAllMeetings can loop forever if the API returns the same
cursor repeatedly; update getAllMeetings to guard against repeated/unchanged
cursors by tracking seen cursors (e.g., a Set) or comparing the new nextCursor
to the previous cursor and breaking/throwing if it repeats or is unchanged, and
optionally enforce a max-iteration safety limit; reference getAllMeetings,
cursor, nextCursor, parseMeetingsPage, and isRecommendedMeetingItem when
applying the fix.
| export async function RecommendedMeetingsSection({ meetingId }: Props) { | ||
| const apiClient = await getApi(); | ||
|
|
||
| try { | ||
| const [meetingDetailResponse, allMeetings] = await Promise.all([ | ||
| apiClient.meetings.getDetail(meetingId), | ||
| getAllMeetings(apiClient), | ||
| ]); | ||
|
|
||
| const meetingDetail = "data" in meetingDetailResponse ? meetingDetailResponse.data : meetingDetailResponse; | ||
|
|
||
| if (!meetingDetail) return null; | ||
|
|
||
| const sortableMeetingDetail: CurrentMeetingSortable = { | ||
| id: meetingDetail.id, | ||
| type: meetingDetail.type, | ||
| region: meetingDetail.region, | ||
| hostId: meetingDetail.hostId, | ||
| }; | ||
|
|
||
| const recommendedMeetings = sortRecommendedMeetings(allMeetings, sortableMeetingDetail).map( | ||
| mapMeetingToRecommendedMeetingData, | ||
| ); | ||
|
|
||
| if (recommendedMeetings.length === 0) return null; | ||
|
|
||
| return ( | ||
| <section className="mx-auto flex w-full max-w-6xl flex-col gap-4 pb-20"> | ||
| <h2 className="font-semibold text-black text-xl leading-[1.4]">이런 모임은 어때요?</h2> | ||
|
|
||
| <Suspense | ||
| fallback={ | ||
| <div className="grid grid-cols-2 gap-4 xl:grid-cols-4"> | ||
| {[1, 2, 3, 4].map((num) => ( | ||
| <div key={num} className="aspect-[162/114] w-full animate-pulse rounded-xl bg-slate-100" /> | ||
| ))} | ||
| </div> | ||
| } | ||
| > | ||
| <RecommendedMeetingsWrapper meetings={recommendedMeetings} /> | ||
| </Suspense> |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "1) page.tsx에서 RecommendedMeetingsSection 주위 Suspense 경계 확인"
rg -n -C3 'RecommendedMeetingsSection|<Suspense' apps/web/src/app
echo
echo "2) recommended-meetings-wrapper.tsx의 client/dynamic(ssr) 설정 확인"
fd -i 'recommended-meetings-wrapper.tsx' apps/web/src | xargs -I{} sh -c 'echo "--- {} ---"; sed -n "1,220p" "{}"'
echo
echo "3) recommended-meetings-section.tsx의 await 위치 확인"
sed -n '90,180p' apps/web/src/_pages/moim-detail/ui/recommended-meetings-section.tsxRepository: sprint-13/moum-zip
Length of output: 12271
🏁 Script executed:
#!/bin/bash
# Check if MoimDetailContent is async and understand parent structure
echo "=== MoimDetailContent component structure ==="
sed -n '115,170p' apps/web/src/app/(main)/moim-detail/\[meetingId\]/page.tsx
echo
echo "=== Check for any Suspense wrapping RecommendedMeetingsSection ==="
rg -B5 -A5 'RecommendedMeetingsSection' apps/web/src/app/(main)/moim-detail/\[meetingId\]/page.tsxRepository: sprint-13/moum-zip
Length of output: 288
🏁 Script executed:
#!/bin/bash
# Check MoimDetailContent component - use quotes to handle brackets
echo "=== MoimDetailContent component structure ==="
sed -n '115,170p' 'apps/web/src/app/(main)/moim-detail/[meetingId]/page.tsx'
echo
echo "=== Full context around RecommendedMeetingsSection ==="
sed -n '130,160p' 'apps/web/src/app/(main)/moim-detail/[meetingId]/page.tsx'Repository: sprint-13/moum-zip
Length of output: 2974
섹션 제목을 독립적으로 스트리밍하려면 비동기 로직 분리가 필요합니다.
현재 구조에서는 RecommendedMeetingsSection이 모든 데이터를 fetch하고 난 후에 JSX를 반환하므로, 라인 123의 내부 Suspense는 카드 영역 로딩에만 관여합니다. 더불어 RecommendedMeetingsSection이 MoimDetailContent 내에서 호출되고 부모 페이지에 Suspense 경계가 전체를 감싸고 있어서, 이 컴포넌트의 데이터 로딩이 부모의 풀스크린 로더 표시를 유발합니다. 결과적으로 제목은 모든 API 요청이 완료될 때까지 화면에 나타나지 않습니다.
타이틀과 카드 영역의 로딩을 분리하려면 비동기 로직을 자식 컴포넌트로 추출하고 섹션 제목은 동기 렌더링으로 변경해야 합니다:
제안하는 구조 변경
-export async function RecommendedMeetingsSection({ meetingId }: Props) {
- const apiClient = await getApi();
- try {
- const [meetingDetailResponse, allMeetings] = await Promise.all([
- apiClient.meetings.getDetail(meetingId),
- getAllMeetings(apiClient),
- ]);
- // ... 로직
- return (
- <section ...>
- <h2>이런 모임은 어때요?</h2>
- <Suspense fallback={...}>
- <RecommendedMeetingsWrapper meetings={recommendedMeetings} />
- </Suspense>
- </section>
- );
- } catch {
- return null;
- }
-}
+export function RecommendedMeetingsSection({ meetingId }: Props) {
+ return (
+ <section className="mx-auto flex w-full max-w-6xl flex-col gap-4 pb-20">
+ <h2 className="font-semibold text-black text-xl leading-[1.4]">이런 모임은 어때요?</h2>
+ <Suspense fallback={
+ <div className="grid grid-cols-2 gap-4 xl:grid-cols-4">
+ {[1, 2, 3, 4].map((num) => (
+ <div key={num} className="aspect-[162/114] w-full animate-pulse rounded-xl bg-slate-100" />
+ ))}
+ </div>
+ }>
+ <RecommendedMeetingsContent meetingId={meetingId} />
+ </Suspense>
+ </section>
+ );
+}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/web/src/_pages/moim-detail/ui/recommended-meetings-section.tsx` around
lines 93 - 133, RecommendedMeetingsSection currently does all data fetching
(getApi, getAllMeetings, apiClient.meetings.getDetail, sortRecommendedMeetings,
mapMeetingToRecommendedMeetingData) before rendering, causing the section title
to wait; extract the async logic into a new async child component (e.g.,
RecommendedMeetingsLoader or RecommendedMeetingsContent) that performs getApi,
getAllMeetings, apiClient.meetings.getDetail, builds sortableMeetingDetail,
calls sortRecommendedMeetings and mapMeetingToRecommendedMeetingData and returns
<RecommendedMeetingsWrapper meetings={...}>; make RecommendedMeetingsSection
synchronous so it immediately renders the static title and a Suspense that
lazy-loads the new async child; keep the existing Suspense fallback UI and
ensure you reference RecommendedMeetingsWrapper from the child.
| } catch { | ||
| return null; |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
예외를 무음 처리하면 성능/데이터 장애 추적이 어려워집니다.
Line 136~137에서 모든 예외를 null로 삼키고 있어, 추천 섹션 미노출의 원인(API 실패/파싱 실패/데이터 품질)을 운영에서 식별하기 어렵습니다. 최소한 구조화 로그는 남겨 주세요.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/web/src/_pages/moim-detail/ui/recommended-meetings-section.tsx` around
lines 136 - 137, The catch block in recommended-meetings-section.tsx currently
swallows all exceptions (catch { return null; }), so change it to capture the
exception (catch (err)) and emit a structured error log before returning null;
use the project's logger (or console.error if none) and include context keys
such as component: "recommended-meetings-section", any relevant IDs or props
(e.g., moimId or userId) and the error object to aid triage, e.g.,
logger.error({ err, component: "recommended-meetings-section", moimId, props },
"Failed to load recommended meetings") then return null as before.
miloupark
left a comment
There was a problem hiding this comment.
PR에 구조 분리랑 개선 방향을 꼼꼼하게 적어주셔서 내용 파악하기 좋았습니다!
코드래빗 코멘트만 반영되면 더 좋을 것 같습니다:)
고생하셨습니다! 🙌
📌 Summary
모임 상세 페이지 Lighthouse 성능 측정 후, 최적화를 진행했습니다.
📄 Tasks
1. 이미지 최적화
priority,sizes속성 적용sizes속성 추가2. 추천 모임 섹션 구조 개선
Suspense+ lazy 렌더링 적용RecommendedMeetingsClient를 dynamic import로 분리하여 client-side에서만 렌더링되도록 적용3. 렌더링 최적화
MoimDetailClient의 불필요한 RESET useEffect 제거InformationContainer에서 반복적으로 생성되던 값 및 핸들러를 memoization 처리4. 추가 리팩토링
_pages/moim-detail/lib/scroll-reset.tsx)5. 성능 개선 결과
👀 To Reviewer
📸 Screenshot (Before / After 비교)
빌드 환경 + 시크릿 모드 + Lighthouse 점수 Before
빌드 환경 + 시크릿 모드 + Lighthouse 점수 After
Summary by CodeRabbit
릴리즈 노트
새로운 기능
개선사항