Skip to content

QA: 편지 관련 수정사항 진행#193

Merged
seueooo merged 5 commits into
developfrom
qa/letter/#191
Aug 22, 2025
Merged

QA: 편지 관련 수정사항 진행#193
seueooo merged 5 commits into
developfrom
qa/letter/#191

Conversation

@seung365
Copy link
Copy Markdown
Member

@seung365 seung365 commented Aug 22, 2025

📌 Summary

📚 Tasks

  • loading spinner 적용
  • image skeleton 적용
  • letters infinitequery 적용
  • Modal overlay 클릭시 닫히는 사항 props로 전달 받도록 수정

👀 To Reviewer

stack-layout의 경우에는 8번째 card를 본 후 20개의 아이템을 불러옵니다. 그 후 28번째, 48번째, ... 를 본 후 불러오게 됩니다.
grid-layout의 경우 무한 스크롤을 적용하였습니다.

📸 Screenshot

아래는 image skeleton 입니다.

2025-08-22.5.34.32.mov

Summary by CodeRabbit

  • 신기능

    • 캡슐 편지 목록에 무한 스크롤을 도입해 하단 근접 시 자동으로 더 불러옵니다.
    • 모달을 오버레이(바깥 영역) 클릭으로 닫을 수 있는 옵션이 추가되었습니다.
    • 초기 로딩 시 전용 로딩 스피너가 표시됩니다.
  • 개선사항

    • 이미지 로딩 중 스켈레톤(쉼머) 효과로 빈 화면을 최소화했습니다.
    • 헤더와 리스트에서 총 편지 수가 일관되게 표시됩니다.
    • 전환 애니메이션 중 카드 상호작용을 비활성화해 오작동을 방지합니다.
    • 추가 로딩 시 하단에 로딩 인디케이터가 나타납니다.

@seung365 seung365 self-assigned this Aug 22, 2025
@seung365 seung365 requested a review from seueooo as a code owner August 22, 2025 09:00
@vercel
Copy link
Copy Markdown

vercel Bot commented Aug 22, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Comments Updated (UTC)
time-capsule Ready Ready Preview Comment Aug 22, 2025 9:00am

@seung365 seung365 linked an issue Aug 22, 2025 that may be closed by this pull request
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Aug 22, 2025

Walkthrough

무한 스크롤로 편지 목록 로딩을 전환했습니다. 페이지 단의 useInfiniteQuery, 쿼리 키/옵션, 컴포넌트 props와 흐름이 업데이트되었습니다. Grid/Stack 레이아웃에 페이징 트리거가 추가되었고, 이미지 스켈레톤 로딩과 모달 오버레이 클릭 닫기 옵션이 도입되었습니다.

Changes

Cohort / File(s) Summary
Infinite pagination 전환
app/(sub)/capsule-detail/[invite-code]/[id]/letters/page.tsx, shared/api/queries/letter.ts, app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/stack-layout/index.tsx, app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/grid-layout/index.tsx
페이지네이션을 useInfiniteQuery로 마이그레이션, nextPage 계산/initialPageParam 추가, 리스트 총합 카운트/집계 적용, GridLayout에 footerRef+isFetchingNextPage 전달, StackLayout에 letterCount/fetchNextPage/hasNextPage/isFetchingNextPage 도입 및 임계치 기반 프리패치 로직 추가.
이미지 로딩 스켈레톤
.../grid-letter-card/index.tsx, .../grid-letter-card/grid-letter-card.css.ts, .../stack-letter-card/index.tsx, .../stack-letter-card/stack-letter-card.css.ts, .../letter-detail-modal/index.tsx, .../letter-detail-modal/letter-detail-modal.css.ts
data-loaded 속성과 onLoad 핸들러 도입, shimmer keyframes/gradient 배경을 활용한 스켈레톤 상태 스타일 추가. StackLetterCard에 disabled 게이트 추가.
모달 동작 확장
app/(sub)/capsule-detail/_components/modal/index.tsx, .../letters/_components/letter-detail-modal/index.tsx
Modal에 closeOnOverlayClick 옵션 추가 및 오버레이 클릭 핸들러 연계. LetterDetailModal에서 해당 옵션 활성화.
자잘한 정리
.../grid-letter-card/index.tsx
중복 import 정리 및 순서 조정(기능 변화 없음).

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor User as 사용자
  participant Grid as GridLayout
  participant IO as IntersectionObserver
  participant Page as letters/page.tsx
  participant ReactQ as useInfiniteQuery
  participant API as getLetterList

  User->>Grid: 스크롤 다운
  Grid->>IO: footerRef 관찰
  IO-->>Page: footer 교차 이벤트
  alt hasNextPage && !isFetchingNextPage
    Page->>ReactQ: fetchNextPage()
    ReactQ->>API: getLetterList(capsuleId, pageParam, 20)
    API-->>ReactQ: 페이지 데이터
    ReactQ-->>Page: pages 업데이트
    Page-->>Grid: letters 누적/로딩 상태 반영
  else 종료 조건
    IO-->>Page: 더 이상 로드 안함
  end
Loading
sequenceDiagram
  autonumber
  actor User as 사용자
  participant Stack as StackLayout
  participant ReactQ as useInfiniteQuery
  participant API as getLetterList

  User->>Stack: 다음/이전 카드 탐색
  Stack->>Stack: currentIndexRef 갱신
  alt currentIndexRef ≥ 8 && index%20==0 && hasNextPage && !isFetching
    Stack->>ReactQ: setTimeout(500ms) 후 fetchNextPage()
    ReactQ->>API: getLetterList(nextPage)
    API-->>ReactQ: 페이지 데이터
    ReactQ-->>Stack: pages 업데이트
  end
  note over Stack: 전환 중에는 카드 disabled로 클릭 차단
Loading
sequenceDiagram
  autonumber
  actor User as 사용자
  participant Modal as Modal
  participant LDM as LetterDetailModal

  User->>LDM: 이미지 카드 클릭
  LDM->>Modal: isOpen=true, closeOnOverlayClick=true
  User->>Modal: 오버레이 영역 클릭
  Modal->>Modal: event.target===currentTarget 확인
  alt closeOnOverlayClick=true
    Modal-->>LDM: onClose()
    LDM-->>User: 모달 닫힘
  else
    Modal-->>User: 유지
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Tip

🔌 Remote MCP (Model Context Protocol) integration is now available!

Pro plan users can now connect to remote MCP servers from the Integrations page. Connect with popular remote MCPs such as Notion and Linear to add more context to your reviews and chats.

✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch qa/letter/#191

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.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

@github-actions
Copy link
Copy Markdown

This pull request (commit 6bcf2db) has been deployed to Vercel ▲ - View GitHub Actions Workflow Logs

Name Link
🌐 Unique https://time-capsule-6priy5zgb-hs-projects-b4a69d5f.vercel.app
🔍 Inspect https://vercel.com/hs-projects-b4a69d5f/time-capsule/6G2Z2Kq6g4MZHcFQf9WJfRiTXtBe

@github-actions
Copy link
Copy Markdown

🚀 Storybook 배포

📖 Storybook: https://683d91ab23651aa0b399e435-ddzqhbphyh.chromatic.com/
🔗 Chromatic Build: https://www.chromatic.com/build?appId=683d91ab23651aa0b399e435&number=217
✅ Status: success

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/letter-detail-modal/letter-detail-modal.css.ts (1)

46-66: 모바일에서 스켈레톤 높이 0 가능성 (layout shift/UX 이슈).

현재 imagewidth: "100%", height: "auto"만 지정되어 있어 이미지 로드 전에는 고유 크기를 알 수 없어 높이가 0이 될 수 있습니다(특히 md 미만 브레이크포인트). 그 결과 스켈레톤이 보이지 않거나 레이아웃 점프가 발생할 수 있습니다.

모바일에서도 로드 전 공간을 확보하도록 최소 높이나 aspect-ratio를 지정하는 것을 권장합니다.

아래처럼 기본 구간에 aspectRatio 또는 minHeight를 추가해 주세요(둘 중 하나 또는 병행):

 export const image = style({
   flexShrink: 0,
   width: "100%",
   height: "auto",
   objectFit: "cover",
   borderRadius: "14px",
+  aspectRatio: "1",
+  minHeight: "16rem",
app/(sub)/capsule-detail/_components/modal/index.tsx (1)

61-68: 키보드(Enter/Space) 닫기 동작도 prop에 연동 필요.

현재는 closeOnOverlayClick이 false여도 오버레이에 포커스가 있을 때 Enter/Space로 닫힙니다. 클릭 정책과 불일치할 수 있어 동일 플래그로 가드하는 것을 권장합니다.

-      onKeyDown={(event) => {
-        if (
-          (event.key === "Enter" || event.key === " ") &&
-          event.target === event.currentTarget
-        ) {
-          onClose();
-        }
-      }}
+      onKeyDown={(event) => {
+        if (
+          closeOnOverlayClick &&
+          (event.key === "Enter" || event.key === " ") &&
+          event.target === event.currentTarget
+        ) {
+          onClose();
+        }
+      }}
app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/grid-layout/index.tsx (1)

23-31: 이미지 URL 탐색 O(n·m) → 맵으로 O(n) 개선.

현재 각 렌더에서 imageUrls.find(...)가 실행되어 리스트가 커질수록 비용이 커집니다. useMemoletterId → url 맵을 구성하면 렌더 성능을 개선할 수 있습니다.

  • 맵 생성(컴포넌트 상단에 추가):
// import 추가
// import { useMemo } from "react";

const imageUrlMap = useMemo(() => {
  return new Map(imageUrls.map((img) => [img.letterId, img.url]));
}, [imageUrls]);
  • 사용처 변경:
-        {letters.map((letter) => {
-          const imageUrl = imageUrls.find(
-            (img) => img.letterId === letter.letterId,
-          )?.url;
+        {letters.map((letter) => {
+          const imageUrl = imageUrlMap.get(letter.letterId) ?? null;
           return (
             <GridLetterCard
               key={letter.letterId}
               letter={letter}
               imageUrl={imageUrl}
             />
           );
         })}

참고: 위 변경에 따라 파일 상단에 useMemo import를 추가해 주세요.

import { useMemo } from "react";

Also applies to: 13-19

🧹 Nitpick comments (26)
app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/letter-detail-modal/letter-detail-modal.css.ts (2)

53-60: 감속 모드 지원 및 애니메이션 보간(선형) 제안.

  • 접근성: prefers-reduced-motion 환경에서 애니메이션을 끄는 것이 좋습니다.
  • 퍼포먼스/일관성: linear 보간을 명시해 라이트 바가 일정 속도로 흐르도록 하는 것이 일반적입니다.

다음과 같이 애니메이션 속성을 조정하고, 감속 모드에서 애니메이션을 해제해 주세요.

-      animation: `${shimmer} 5s  infinite`,
+      animation: `${shimmer} 5s linear infinite`,

감속 모드는 vanilla-extract에서 아래처럼 적용할 수 있습니다(선택):

export const image = style({
  // ...
  selectors: {
    '&[data-loaded="false"]': {
      // ...
      animation: `${shimmer} 5s linear infinite`,
    },
  },
  '@media': {
    '(prefers-reduced-motion: reduce)': {
      selectors: {
        '&[data-loaded="false"]': { animation: 'none' },
      },
    },
  },
});

41-44: shimmer 키프레임 중복 정의 – 공통화 제안.

동일한 shimmer가 여러 파일에서 반복 정의됩니다. shared/styles/motion.css.ts 같은 곳으로 이동해 재사용하면 유지보수가 쉬워지고 애니메이션 속도(4s/5s)도 일관화할 수 있습니다.

app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/stack-letter-card/stack-letter-card.css.ts (2)

49-56: 감속 모드 지원 및 애니메이션 보간(선형) 제안.

그리드/모달과 동일하게 접근성(감속 모드)과 일관된 보간을 권장합니다.

-      animation: `${shimmer} 5s  infinite`,
+      animation: `${shimmer} 5s linear infinite`,

선택: 감속 모드에서 애니메이션 비활성

export const image = style({
  // ...
  selectors: {
    '&[data-loaded="false"]': {
      animation: `${shimmer} 5s linear infinite`,
    },
  },
  '@media': {
    '(prefers-reduced-motion: reduce)': {
      selectors: {
        '&[data-loaded="false"]': { animation: 'none' },
      },
    },
  },
});

37-40: shimmer 키프레임 중복 정의 – 공통화 제안.

여러 컴포넌트에 동일한 shimmer가 존재합니다. 공통 유틸로 추출해 재사용하면 퍼포먼스/일관성 관리에 유리합니다(예: 속도 4s vs 5s 정합).

app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/grid-letter-card/grid-letter-card.css.ts (2)

45-52: 감속 모드 지원 및 애니메이션 보간(선형) 제안.

그리드가 많은 카드 수를 렌더링하므로 애니메이션 비용을 줄이는 것이 중요합니다. 선형 보간 지정과 감속 모드 비활성을 권장합니다.

-      animation: `${shimmer} 4s  infinite`,
+      animation: `${shimmer} 4s linear infinite`,

선택: 감속 모드에서 애니메이션 비활성

export const image = style({
  // ...
  selectors: {
    '&[data-loaded="false"]': {
      animation: `${shimmer} 4s linear infinite`,
    },
  },
  '@media': {
    '(prefers-reduced-motion: reduce)': {
      selectors: {
        '&[data-loaded="false"]': { animation: 'none' },
      },
    },
  },
});

33-36: shimmer 키프레임 중복 정의 – 공통화 제안.

세 파일(그리드/스택/모달) 모두 같은 키프레임을 가집니다. 공통화하여 유지보수성을 높이고, 속도(4s/5s)도 통일하면 UX 일관성이 좋아집니다.

app/(sub)/capsule-detail/_components/modal/index.tsx (1)

39-43: 스크롤 잠금 해제 시 unset 대신 빈 문자열 사용 권장.

document.body.style.overflow = "unset"은 기존 스타일 상속에 영향을 줄 수 있습니다. 명시적으로 인라인 스타일만 제거하려면 빈 문자열이 안전합니다.

   return () => {
     document.removeEventListener("keydown", handleEscapeKey);
-    document.body.style.overflow = "unset";
+    document.body.style.overflow = "";
   };
app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/grid-layout/index.tsx (1)

35-42: 로딩 영역 접근성 보강(스크린리더 알림).

무한 스크롤 로딩 상태 변화를 스크린리더가 인지하도록 aria-liverole="status"를 권장합니다.

-      <div style={{ minHeight: "1px", textAlign: "center" }} ref={footerRef}>
+      <div
+        style={{ minHeight: "1px", textAlign: "center" }}
+        ref={footerRef}
+        aria-live="polite"
+        role="status"
+      >
shared/api/queries/letter.ts (2)

19-26: 불필요한 재요청 억제(UX/트래픽 최적화)

무한스크롤에서는 포커스 전환이나 재연결 시의 재요청이 과해질 수 있습니다. 안정적인 UX를 위해 캐싱 파라미터를 함께 지정하는 것을 권장합니다.

     infiniteQueryOptions({
       queryKey: letterQueryKeys.listByCapsule(capsuleId),
       queryFn: ({ pageParam = 0 }) => getLetterList(capsuleId, pageParam, 20),
       getNextPageParam: (lastPage) => {
         const { page, totalPages } = lastPage.result;
         return page < totalPages - 1 ? page + 1 : undefined;
       },
       initialPageParam: 0,
+      staleTime: 60_000,          // 1분 동안 신선
+      gcTime: 5 * 60_000,         // 5분 후 가비지 컬렉션
+      refetchOnWindowFocus: false // 포커스 복귀 시 불필요한 리패치 방지

21-21: 매직 넘버 20 상수화로 의도 드러내기

페이지 사이즈(20)를 상수로 추출하면 가독성과 변경 용이성이 좋아집니다.

-      queryFn: ({ pageParam = 0 }) => getLetterList(capsuleId, pageParam, 20),
+      queryFn: ({ pageParam = 0 }) =>
+        getLetterList(capsuleId, pageParam, LETTERS_PAGE_SIZE),

파일 상단(임포트 아래)에 추가:

export const LETTERS_PAGE_SIZE = 20 as const;
app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/letter-detail-modal/index.tsx (1)

36-39: Next/Image의 로딩 완료 시점 정확화 및 실패 처리

onLoad는 디코딩 완료 전에도 호출될 수 있습니다. 스켈레톤 토글은 onLoadingComplete가 더 정확합니다. 또한 onError에서 스켈레톤 영구 표시를 방지하는 처리를 추가하세요.

-            data-loaded="false"
-            onLoad={(event) => {
-              event.currentTarget.setAttribute("data-loaded", "true");
-            }}
+            data-loaded="false"
+            onLoadingComplete={(img) => {
+              img.setAttribute("data-loaded", "true");
+            }}
+            onError={(e) => {
+              (e.currentTarget as HTMLImageElement).setAttribute(
+                "data-loaded",
+                "true",
+              );
+            }}
app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/grid-letter-card/index.tsx (1)

37-40: 스켈레톤 토글: onLoadingComplete 사용 및 오류 핸들링 추가 권장

그리드 카드도 디테일 모달과 동일하게 완료/실패 시점을 명확히 처리하면 UI 일관성이 좋아집니다.

-            data-loaded="false"
-            onLoad={(event) => {
-              event.currentTarget.setAttribute("data-loaded", "true");
-            }}
+            data-loaded="false"
+            onLoadingComplete={(img) => {
+              img.setAttribute("data-loaded", "true");
+            }}
+            onError={(e) => {
+              (e.currentTarget as HTMLImageElement).setAttribute(
+                "data-loaded",
+                "true",
+              );
+            }}
app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/stack-letter-card/index.tsx (2)

10-21: disabled 동작의 접근성/시각적 피드백 보완

if (disabled) return;으로 동작은 막았지만, 사용자는 카드가 비활성인지 알기 어렵습니다. 컨테이너에 aria-disabled/data-disabled를 반영하고 CSS로 포인터/커서 상태를 명확히 해주세요. 키보드 접근성(Enter/Space)도 비활성 시 무시되도록 함께 처리하는 것을 권장합니다.

비변경 구간 예시(섹션 요소):

<section
  className={styles.card}
  onClick={handleClick}
  role="button"
  aria-disabled={disabled}
  data-disabled={disabled}
  tabIndex={disabled ? -1 : 0}
/>

43-46: 이미지 로딩/실패 케이스 일관 처리

디테일/그리드와 동일하게 onLoadingComplete + onError로 스켈레톤 종료 시점을 통일하면 유지보수성이 올라갑니다.

-            data-loaded="false"
-            onLoad={(event) => {
-              event.currentTarget.setAttribute("data-loaded", "true");
-            }}
+            data-loaded="false"
+            onLoadingComplete={(img) => {
+              img.setAttribute("data-loaded", "true");
+            }}
+            onError={(e) => {
+              (e.currentTarget as HTMLImageElement).setAttribute(
+                "data-loaded",
+                "true",
+              );
+            }}
app/(sub)/capsule-detail/[invite-code]/[id]/letters/page.tsx (2)

44-51: 무한 스크롤 트리거를 조금 더 이르게 — 체감 성능 개선

threshold: 0.8은 화면에 거의 다 보일 때 페칭합니다. 이미지 프리사이닝 등을 고려하면 200~300px 앞에서 미리 페칭하는 편이 부드럽습니다.

-  const footerRef = useIntersectionObserver<HTMLDivElement>(
+  const footerRef = useIntersectionObserver<HTMLDivElement>(
     (entry) => {
       if (entry.isIntersecting && hasNextPage && !isFetchingNextPage) {
         fetchNextPage();
       }
     },
-    { threshold: 0.8 },
+    { rootMargin: "300px 0px", threshold: 0.1 },
   );

59-64: isInitialLoading 중복 조건 제거

(isLetterLoading && !letterData)가 중복되어 있습니다. 간결화하세요.

-  const isInitialLoading =
-    (isLetterLoading && !letterData) ||
-    (isLetterLoading && !letterData) ||
-    (isImageLoading && letters.length === 0) ||
-    isCapsuleLoading;
+  const isInitialLoading =
+    (isLetterLoading && !letterData) ||
+    (isImageLoading && letters.length === 0) ||
+    isCapsuleLoading;
app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/stack-layout/index.tsx (10)

29-36: fetchNextPage 타입의 any 축소 또는 제네릭화 권장

현재 fetchNextPage의 반환 타입이 any로 선언되어 있어 타입 안정성이 떨어집니다. 최소한 unknown으로 축소하거나, 제네릭으로 노출해 상위에서 실제 타입을 전달받는 쪽이 안전합니다.

다음과 같이 가볍게 unknown으로 축소하는 안:

 interface StackLayoutProps {
   letters: Letter[];
   letterCount: number;
   imageUrls: ImageUrl[];
-  fetchNextPage: () => Promise<InfiniteQueryObserverResult<any, Error>>;
+  fetchNextPage: () => Promise<InfiniteQueryObserverResult<unknown, Error>>;
   hasNextPage: boolean;
   isFetchingNextPage: boolean;
 }

혹은 제네릭을 여는 안(필수는 아님):

export interface StackLayoutProps<TData = unknown, TError = Error> {
  letters: Letter[];
  letterCount: number;
  imageUrls: ImageUrl[];
  fetchNextPage: () => Promise<InfiniteQueryObserverResult<TData, TError>>;
  hasNextPage: boolean;
  isFetchingNextPage: boolean;
}

51-51: 브라우저/Node 호환 타이머 타입

"use client" 환경에서는 setTimeout 반환 타입이 number이므로 NodeJS.Timeout과 호환성 문제가 발생할 수 있습니다. 교차 환경에서 안전하게 ReturnType<typeof setTimeout>을 사용하세요.

-  const timerRef = useRef<NodeJS.Timeout | null>(null);
+  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

72-88: Ref 동기화 후 프리페치 실행 순서 OK + 매직 넘버 상수화 제안

currentIndexRef를 먼저 동기화하고, 이후 프리페치 이펙트를 실행하는 순서가 올바릅니다. 다만 8, 20은 의미가 드러나지 않으므로 상수화하면 가독성이 좋아집니다.

-  const shouldLoadNext =
-      currentIndexRef.current >= 8 && (currentIndexRef.current - 8) % 20 === 0;
+  const shouldLoadNext =
+    currentIndexRef.current >= PREFETCH_START_INDEX &&
+    (currentIndexRef.current - PREFETCH_START_INDEX) % PREFETCH_STEP === 0;

아래 상수를 파일 상단(컴포넌트 외부) 등에 선언:

const PREFETCH_START_INDEX = 8;
const PREFETCH_STEP = 20;

97-111: 버퍼 끝에서의 사용자 입력 대응: 수동 프리페치 트리거

현재는 loaded 끝(= currentIndex >= letters.length - 1)에서 우측 버튼 클릭 시 아무 동작이 없습니다. hasNextPage가 true면 클릭을 수동 프리페치로 전환해 UX를 자연스럽게 만들 수 있습니다.

-  const nextLetter = () => {
-    if (currentIndex < letters.length - 1 && !isAnimating) {
-      setIsAnimating(true);
-      setDirection(1);
-      const nextIndex = currentIndex + 1;
-      setCurrentIndex(nextIndex);
-      currentIndexRef.current = nextIndex;
- 
-      timerRef.current = setTimeout(() => {
-        setDirection(0);
-        setIsAnimating(false);
-        timerRef.current = null;
-      }, 300);
-    }
-  };
+  const nextLetter = () => {
+    if (isAnimating) return;
+    const atTail = currentIndex >= letters.length - 1;
+    if (atTail) {
+      if (hasNextPage && !isFetchingNextPage) {
+        void fetchNextPage();
+      }
+      return;
+    }
+    setIsAnimating(true);
+    setDirection(1);
+    const nextIndex = currentIndex + 1;
+    setCurrentIndex(nextIndex);
+    currentIndexRef.current = nextIndex;
+    timerRef.current = setTimeout(() => {
+      setDirection(0);
+      setIsAnimating(false);
+      timerRef.current = null;
+    }, 300);
+  };

114-118: currentIndexRef 하향 이동 시 동기화 대칭성 확보

이전 이동에서는 currentIndexRef가 즉시 갱신되지 않아(다음 렌더의 이펙트를 기다림) 극히 드문 레이스가 있을 수 있습니다. next와 대칭으로 즉시 동기화해 주세요.

-      setCurrentIndex((prev) => prev - 1);
+      const prevIndex = currentIndex - 1;
+      setCurrentIndex(prevIndex);
+      currentIndexRef.current = prevIndex;

129-137: 접근성: 아이콘 버튼에 대체 텍스트 추가

아이콘만 있는 내비게이션 버튼에 aria-label/title을 추가해 스크린 리더와 툴팁 지원을 확보하는 것이 좋습니다.

       <button
         className={styles.navButton}
         onClick={prevLetter}
         disabled={isAnimating}
         style={{ visibility: currentIndex > 0 ? "visible" : "hidden" }}
         type="button"
+        aria-label="이전 편지"
+        title="이전 편지"
       >

146-149: 이미지 URL 탐색 비용 O(n) → O(1)로 축소 가능(미세 최적화)

현재 매 렌더에서 imageUrls.find를 호출합니다. 카드가 3장이라 영향은 작지만, 맵으로 변환해두면 깔끔합니다.

-            const imageUrl = imageUrls.find(
-              (img) => img.letterId === letter.letterId,
-            )?.url;
+            const imageUrl = imageUrlMap.get(letter.letterId) ?? null;

컴포넌트 상단에 맵 생성(외부 변경 없음):

import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";

const imageUrlMap = useMemo(
  () => new Map(imageUrls.map((i) => [i.letterId, i.url] as const)),
  [imageUrls],
);

60-66: Motion key로 index 대신 식별자 사용 권장

페이지네이션/추가 로딩에서 index 기반 key는 예기치 않은 재마운트를 유발할 수 있습니다. letter.letterId로 key를 바꾸면 더 안정적입니다.

       newVisibleLetters.push({
         letter: letters[letterIndex],
         stackIndex: i,
         letterIndex,
         isTop: i === 0,
-        key: letterIndex,
+        key: letters[letterIndex].letterId,
       });

172-181: 오른쪽 버튼 가시성: 더 로드 가능 시 계속 보여주기

로컬 버퍼의 끝에 도달했더라도 hasNextPage 또는 isFetchingNextPage면 버튼은 보이는 편이 UX상 자연스럽습니다(클릭 시 위의 수동 프리페치와 연계).

       <button
         className={styles.navButton}
         onClick={nextLetter}
         disabled={isAnimating}
         style={{
-          visibility: currentIndex < letters.length - 1 ? "visible" : "hidden",
+          visibility:
+            currentIndex < letters.length - 1 || hasNextPage || isFetchingNextPage
+              ? "visible"
+              : "hidden",
         }}
         type="button"
+        aria-label="다음 편지"
+        title="다음 편지"
       >

5-6: 중복 타입 제거: ImageUrl은 공용 타입 재사용 권장

파일 내 interface ImageUrlshared/types/api/letter.tsImageUrl과 중복됩니다. 공용 타입을 import해 단일 소스로 관리하세요.

-import type { Letter } from "@/shared/types/api/letter";
+import type { Letter, ImageUrl } from "@/shared/types/api/letter";
...
-interface ImageUrl {
-  letterId: number;
-  fileId: string;
-  url: string | null;
-}

Also applies to: 23-27

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between a4ca568 and 6bcf2db.

📒 Files selected for processing (11)
  • app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/grid-layout/index.tsx (2 hunks)
  • app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/grid-letter-card/grid-letter-card.css.ts (2 hunks)
  • app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/grid-letter-card/index.tsx (2 hunks)
  • app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/letter-detail-modal/index.tsx (2 hunks)
  • app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/letter-detail-modal/letter-detail-modal.css.ts (2 hunks)
  • app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/stack-layout/index.tsx (6 hunks)
  • app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/stack-letter-card/index.tsx (2 hunks)
  • app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/stack-letter-card/stack-letter-card.css.ts (2 hunks)
  • app/(sub)/capsule-detail/[invite-code]/[id]/letters/page.tsx (4 hunks)
  • app/(sub)/capsule-detail/_components/modal/index.tsx (4 hunks)
  • shared/api/queries/letter.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (7)
app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/stack-letter-card/stack-letter-card.css.ts (2)
app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/grid-letter-card/grid-letter-card.css.ts (1)
  • image (38-53)
app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/letter-detail-modal/letter-detail-modal.css.ts (1)
  • image (46-66)
app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/grid-layout/index.tsx (1)
shared/types/api/letter.ts (2)
  • Letter (1-10)
  • ImageUrl (12-16)
app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/letter-detail-modal/letter-detail-modal.css.ts (2)
app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/grid-letter-card/grid-letter-card.css.ts (1)
  • image (38-53)
app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/stack-letter-card/stack-letter-card.css.ts (1)
  • image (42-62)
app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/grid-letter-card/grid-letter-card.css.ts (2)
app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/letter-detail-modal/letter-detail-modal.css.ts (1)
  • image (46-66)
app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/stack-letter-card/stack-letter-card.css.ts (1)
  • image (42-62)
app/(sub)/capsule-detail/[invite-code]/[id]/letters/page.tsx (2)
shared/api/queries/letter.ts (1)
  • letterQueryOptions (17-36)
shared/hooks/use-letter-images.ts (1)
  • useLetterImages (6-33)
app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/stack-letter-card/index.tsx (2)
shared/types/api/letter.ts (1)
  • Letter (1-10)
shared/hooks/use-overlay.ts (1)
  • useOverlay (7-46)
app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/stack-layout/index.tsx (1)
shared/types/api/letter.ts (1)
  • ImageUrl (12-16)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: storybook-deploy
  • GitHub Check: deploy
  • GitHub Check: test
🔇 Additional comments (12)
app/(sub)/capsule-detail/_components/modal/index.tsx (2)

47-51: 오버레이 클릭 닫기 핸들링, 조건 분기 적절합니다.

event.target === event.currentTarget로 콘텐츠 클릭과 구분하고, prop 기반으로 토글되는 점 👍


15-16: 검증 완료: closeOnOverlayClick의 기본값(false)이 write-modal에 적용되고, letter-detail-modal에만 true로 전달되는 것을 확인했습니다. 의도치 않은 화면에 영향이 없으므로 이번 변경사항은 안전합니다. 수고하셨습니다!

app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/grid-layout/index.tsx (1)

2-2: 스피너 통합 👍

무한 스크롤 피드백으로 LoadingSpinner를 하단 센티널과 결합한 점 좋습니다.

shared/api/queries/letter.ts (1)

21-26: 페이지 인덱스(0/1) 기반 확인 필요
shared/types/api/letter.ts에서 page: numbertotalPages: number로 응답이 정의돼 있으나, 서버가 0-베이스(첫 페이지=0)인지 1-베이스(첫 페이지=1)인지 코드만으로는 알 수 없습니다. API 스펙 문서를 확인하여 아래 로직이 맞는지 검토해주세요.

점검 대상

  • shared/api/queries/letter.ts
    • initialPageParam: 0
    • queryFn: ({ pageParam = 0 }) => getLetterList(...)
    • getNextPageParam: return page < totalPages - 1 ? page + 1 : undefined;
  • shared/types/api/letter.ts
    • interface LetterListRes { page: number; totalPages: number; … }

1-베이스 페이지 인덱스일 경우 예시 수정(diff):

-      queryFn: ({ pageParam = 0 }) => getLetterList(capsuleId, pageParam, 20),
+      queryFn: ({ pageParam = 1 }) => getLetterList(capsuleId, pageParam, 20),
-        return page < totalPages - 1 ? page + 1 : undefined;
+        return page < totalPages ? page + 1 : undefined;
-      initialPageParam: 0,
+      initialPageParam: 1,
app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/letter-detail-modal/index.tsx (1)

26-26: closeOnOverlayClick Prop 지원 확인 완료

Modal 컴포넌트에서 closeOnOverlayClick이 아래와 같이 정상적으로 타입 정의 및 런타임 처리되고 있음을 확인했습니다. 추가 수정은 필요하지 않습니다.

  • 파일: app/(sub)/capsule-detail/_components/modal/index.tsx
    • 인터페이스 정의 (라인 15): closeOnOverlayClick?: boolean;
    • 디폴트 값 설정 (라인 25): closeOnOverlayClick = false
    • 오버레이 클릭 핸들러 (라인 48–50): 클릭 시 onClose() 호출
app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/grid-letter-card/index.tsx (1)

1-1: 임포트 정리 LGTM

useOverlay 임포트를 상단으로 정리한 변경은 일관성 측면에서 좋습니다.

app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/stack-letter-card/index.tsx (1)

37-37: key={imageUrl}로 리마운트해 스켈레톤 리셋 — 적절한 선택

이미지 src 변경 시 스켈레톤 상태를 초기화하려는 의도에 부합합니다. 성능 영향도 미미한 수준으로 보입니다.

app/(sub)/capsule-detail/[invite-code]/[id]/letters/page.tsx (2)

36-43: 무한 쿼리 연동 자체는 깔끔합니다

쿼리 키/옵션을 letterQueryOptions로 외부화하고, 페이지 집계/카운트 추출도 명확합니다. 이후 성능 관점에서 필요 시 useMemoletters/totalLetterCount 계산을 감싸는 정도만 고려하시면 충분합니다.


114-121: 확인 완료: 레이아웃 컴포넌트 Prop 전파 정상

  • StackLayoutProps(interface) 에는

    • letters, letterCount, imageUrls
    • fetchNextPage: () => Promise<…>
    • hasNextPage: boolean
    • isFetchingNextPage: boolean
      이 모두가 선언되어 있고, page.tsx에서 누락 없이 전달되고 있습니다.
      내부 useEffect(fetchNextPage 호출) 로직도 의도한 대로 동작합니다.
  • GridLayoutProps(interface) 에는

    • letters, imageUrls
    • footerRef: (el: HTMLDivElement | null) => void
    • isFetchingNextPage: boolean
      가 선언되어 있으며, page.tsx에서 footerRef와 isFetchingNextPage를 정상 전달하고 있습니다.
      Intersection Observer 콜백(fetchNextPage 호출)은 page.tsx에서 관리하도록 분리되어 있습니다.

따라서 현재 상태에서 Prop 선언·전달·의존 로직 모두 정확하므로 추가 변경은 필요 없습니다.

app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/stack-layout/index.tsx (3)

140-141: AnimatePresence 설정 적절

mode="popLayout"initial={false} 조합은 레이아웃 변동 시 깜빡임을 줄이고 첫 마운트 애니메이션을 억제하는데 유효합니다. 그대로 가시죠.


161-165: 전환 중 카드 인터랙션 비활성화 처리 LGTM

disabled={isAnimating}로 전환 중 상호작용을 막는 처리가 적절합니다.


185-187: 총 개수 표시를 서버 total과 동기화한 점 좋습니다

letters.length 대신 letterCount(total)를 사용해 UI 표시를 데이터 총량과 일치시킨 점이 적절합니다.

Copy link
Copy Markdown
Contributor

@seueooo seueooo left a comment

Choose a reason for hiding this comment

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

고생하셨슴당 !!

@seueooo seueooo merged commit b0c7f15 into develop Aug 22, 2025
11 checks passed
@seueooo seueooo deleted the qa/letter/#191 branch August 22, 2025 13:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[QA]: 편지 관련 수정사항 진행

2 participants