Skip to content

[FEAT]: 상품 상세 리뷰 탭 실데이터 연동 및 요약 통계 개선#67

Merged
Seoje1405 merged 23 commits intoIT-Cotato:developfrom
Seoje1405:refactor/review
Feb 17, 2026
Merged

[FEAT]: 상품 상세 리뷰 탭 실데이터 연동 및 요약 통계 개선#67
Seoje1405 merged 23 commits intoIT-Cotato:developfrom
Seoje1405:refactor/review

Conversation

@Seoje1405
Copy link
Copy Markdown
Contributor

@Seoje1405 Seoje1405 commented Feb 16, 2026

📝 개요

상품 상세의 소재/리뷰 영역을 목데이터 기반에서 실데이터 기반으로 전환하고,
리뷰 목록/요약/필터/정렬 흐름을 API 스펙에 맞게 정비한 작업입니다.

기존에는 리뷰 리스트/요약 일부가 mock 또는 임시 계산 로직에 의존했고,
모달 통계와 리스트 필터 기준이 달라 사용자 입장에서 결과 일관성이 떨어지는 문제가 있었습니다.
또한 모달/시트 접근성 속성 누락 및 미사용 컴포넌트가 남아 있어 정리 필요가 있었습니다.

이번 PR은 실데이터 연동 + 집계/필터 일관성 확보 + 접근성/구조 정리 + UI 가독성 보완을 목표로 리팩터링했습니다.

관련 이슈 번호: #이슈번호

Closes #이슈번호

🎯 변경 의도

  • 리뷰 탭 데이터를 서버 응답 기준으로 일원화해 신뢰도 개선
  • 리뷰 리스트와 요약 모달의 필터 결과 일치
  • 도메인 타입 정비로 확장/유지보수성 강화
  • 접근성 경고 제거 및 미사용 코드 정리
  • 소재 캐러셀 가독성 개선

🚀 주요 변경 사항

1) Review 도메인/액션 정비

  • review 도메인 타입 확장 (목록/상세/요약 응답 대응)
  • 리뷰 서버 액션 추가/정리
    • 목록 조회
    • 상세 조회
    • 도움돼요 토글
    • 요약 조회
  • 정렬 enum 서버 스펙 반영 (BEST, RECENT, RATING_HIGH, RATING_LOW)

2) 상품 상세에서 리뷰 데이터 연동

  • 상품 상세 페이지에서 리뷰 요약 데이터 조회 후 리뷰 섹션에 전달
  • 리뷰 카운트 및 탭 표시가 실데이터 기준으로 동작하도록 정리

3) 리뷰 리스트 실데이터 전환

  • mock 기반 리스트 제거
  • 필터/정렬/페이징 API 연동
  • 빈 상태 메시지 처리 개선

4) 리뷰 아이템 렌더링/상호작용 개선

  • 리뷰 아이템 필드(사이즈/소재/색감/기타) 실데이터 렌더링
  • 도움돼요 optimistic UI + 서버 동기화 적용
  • 리뷰 이미지 모달 연동 유지

5) 리뷰 요약 집계/모달 로직 정비

  • 집계 유틸 추가 (review-summary-utils)
  • 다건 조회 + 클라이언트 보정 유틸 추가 (review-fetch-utils)
  • 요약 모달 필터 통계 로직 개선
  • 리스트/모달 필터 기준 불일치 보정 (결합 필터 기준)

6) 리뷰 섹션 UX/상태 처리 보완

  • 탭 전환/빈 상태 처리 보정
  • 월간 리뷰 요약/렌더 흐름 정리
  • 옵션/정렬 시트 UI 동작 안정화

7) 접근성 및 코드 정리

  • DialogContent / SheetContent 접근성 타이틀 보완
  • 미사용 review-detail-modal 제거
  • 소재 캐러셀 카드 폭/문장 흐름 스타일 조정

🧪 테스트/검증 포인트

Review 탭

  • 상품 상세 진입 후 리뷰 탭 정상 렌더 확인
  • 정렬 변경(베스트/최신/별점순) 결과 확인
  • 옵션 필터(색상/사이즈) 적용 결과 확인
  • 페이징(리뷰 더보기) 동작 확인
  • 탭 전환(전체/한달 후) 시 데이터/빈 상태 정상 확인

Review 요약 모달

  • 자세히 보기 진입/닫기 동작 확인
  • 모달 내 옵션 변경 시 통계 재계산 확인
  • 리스트와 모달의 필터 기준/결과 일치 확인

접근성/구조

  • Dialog/Sheet 접근성 경고 미발생 확인
  • 제거된 미사용 컴포넌트 참조 에러 없음 확인

UI

  • AI 소재 캐러셀 너비/텍스트 가독성 확인

✅ 체크리스트

  • 코드 컨벤션 준수
  • 기능 단위 커밋 분리
  • 린트/빌드 최종 확인
  • 주요 사용자 플로우 수동 검증 (리뷰 탭/요약 모달/필터/정렬)
  • 접근성 경고 포인트 보완 적용

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Feb 16, 2026

Walkthrough

리뷰 서브시스템을 API 기반으로 전환하고 서버 액션·타입·유틸·UI를 확장합니다. 리뷰 요약/목록/상세/도움돼요 토글 액션 추가, 페이지네이션·캐싱·클라이언트 필터링·낙관적 UI 및 관련 컴포넌트/타입 변경이 포함됩니다.

Changes

Cohort / File(s) Summary
Server Review Actions
src/app/actions/review.ts
신규 액션 추가: toggleReviewHelpfulAction, getReviewDetailAction, getProductReviewsAction, getProductReviewsSummaryAction. ProductReviewsQuery 타입·기본값·빈 응답 헬퍼·404 및 에러 처리 추가.
Product Page & Detail View
src/app/product/[id]/page.tsx, src/components/product/product-detail-view.tsx
페이지: Promise.allSettled로 병렬 페칭 내결함성 적용, 리뷰 요약 통합. DetailView: productReviewSummary? prop 추가, 옵션 기반 sizes/colors 및 recommendedSize 도출.
Review Domain Types
src/types/domain/review.ts
리뷰 도메인 대규모 확장: ProductReviewListItem 추가, reviewer/usualSize optional화, 초기/월간 답변 구조 확장 및 옵션 상수/타입 추가.
Review List / Item / Section
src/components/product/review/review-list.tsx, src/components/product/review/review-item.tsx, src/components/product/review/review-section.tsx
리스트: API 기반 페이지네이션·정렬 매핑·로딩/더보기 구현. 아이템: ProductReviewListItem 사용, 낙관적 '도움돼요' 토글(확인/롤백), 방어적 필드 접근. 섹션: month 탭 빈 상태 처리·recommendedSize 전달.
Review Summary & Modal & Utils
src/components/product/review/review-summary.tsx, src/components/product/review/review-summary-modal.tsx, src/components/product/review/review-summary-utils.ts
요약: ONE_MONTH 조건부 페칭·monthQuestionSummaries 캐시 및 제목/부제 유연화. 모달: 선택 상태·hydrate 초기답변·fetch/hydration 로직 추가. 유틸: buildCategorySummaryFromReviews 추가.
Client Fetch Utilities
src/components/product/review/review-fetch-utils.ts
fetchAllProductReviews 추가: in-flight dedupe 캐시, 최대 페이지(100*20) 배치 페칭, 클라이언트 사이즈/컬러 필터 적용 및 로그.
Review Constants & Analysis UI
src/components/product/review/review-constants.ts, src/components/product/review/analysis-content.tsx
상수: 평가 키·보조 답변 규칙·월별 질문·옵션 re-export 추가. AnalysisContent: title/subtitle/maxItems/isLoading 등으로 일반화, 빈 상태 메시지 개선.
UI/Accessibility tweaks
src/components/product/review/review-option-sheet.tsx, src/components/product/review/review-sort-sheet.tsx, src/components/product/review/review-image-modal.tsx, src/components/product/review/review-tabs.tsx
Sheet에 SheetTitle 추가(sr-only), 오픈 핸들러 변경, 탭/시트 레이아웃 및 접근성·스타일 조정. 이미지 모달에 sr-only 타이틀 추가.
Removed / Re-exports cleaned
src/components/product/product-service.ts, src/components/product/index.tsx, src/components/product/review/review-detail-modal.tsx
모의 데이터 기반 product-service 삭제 및 관련 re-export 제거. ReviewDetailModal 컴포넌트 삭제.
Minor UI style
src/components/product/review/ai-material-carousel.tsx, src/components/ui/scroll-to-top.tsx, src/components/ui/view-count-toast.tsx
타이포·카드 크기·이미지 className="h-auto w-auto" 등 프레젠테이션 변경.

Sequence Diagrams

sequenceDiagram
    participant Client as Browser
    participant DetailPage as Product Detail Page
    participant ReviewAction as Review Server Action
    participant API as Backend API
    participant ReviewList as Review List Component

    Client->>DetailPage: 요청 /product/[id]
    par Parallel Fetch
        DetailPage->>ReviewAction: getProductReviewsSummaryAction(productId)
        ReviewAction->>API: GET /api/reviews/summary
        API-->>ReviewAction: ReviewStatsData / error
        ReviewAction-->>DetailPage: 리뷰 요약 (or 빈값)
        and
        DetailPage->>API: 상품/위시리스트/유사상품 등
        API-->>DetailPage: 기본 데이터
    end
    DetailPage->>DetailPage: resolved* 병합 및 fallback 적용
    DetailPage-->>Client: 렌더링
Loading
sequenceDiagram
    participant Client as User
    participant SummaryModal as Review Summary Modal
    participant FetchUtils as Review Fetch Utils
    participant ReviewAction as Review Server Action
    participant API as Backend API
    participant Cache as In-Flight Cache

    Client->>SummaryModal: 모달 오픈(productId, reviewType='ONE_MONTH')
    SummaryModal->>FetchUtils: fetchAllProductReviews(productId, query)
    FetchUtils->>Cache: 체크(키: productId+query)
    alt cached in-flight
        Cache-->>FetchUtils: 기존 Promise 반환
    else
        FetchUtils->>ReviewAction: 여러 페이지 getProductReviewsAction(...)
        loop 0..N
            ReviewAction->>API: GET /api/products/{id}/reviews?page={i}&pageSize=100
            API-->>ReviewAction: PageResponse
            ReviewAction-->>FetchUtils: 내용 누적
        end
        FetchUtils->>FetchUtils: applyClientOptionFilter
        FetchUtils-->>SummaryModal: ProductReviewListItem[]
    end
    SummaryModal->>SummaryModal: buildCategorySummaryFromReviews -> state 업데이트
Loading
sequenceDiagram
    participant Client as User
    participant ReviewItem as Review Item Component
    participant Action as Toggle Helpful Action
    participant API as Backend API

    Client->>ReviewItem: '도움돼요' 클릭
    ReviewItem->>ReviewItem: 낙관적 상태 적용 (optimisticState), pending=true
    par Async
        ReviewItem->>Action: toggleReviewHelpfulAction(reviewId)
        Action->>API: PATCH /api/reviews/{id}/helpful
        alt 200
            API-->>Action: ReviewHelpfulData
            Action-->>ReviewItem: confirmedState
        else 404
            API-->>Action: 404
            Action-->>ReviewItem: {success:false} -> 롤백
        else error
            API-->>Action: error -> rethrowNextError -> 롤백
        end
    end
    ReviewItem->>Client: 최종 UI 반영
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

Suggested labels

✨ FEATURE, ♻️ REFACTOR

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed 제목은 이 PR의 주요 변경을 명확히 요약하고 있습니다: 상품 상세 리뷰 탭에서 목데이터에서 실데이터로 전환하고 요약 통계를 개선하는 작업입니다.
Description check ✅ Passed 설명은 변경사항과 명확히 연관되어 있으며, 개요, 변경 의도, 주요 변경사항, 테스트 포인트 및 체크리스트를 포괄적으로 다루고 있습니다.
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into develop

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@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: 9

Caution

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

⚠️ Outside diff range comments (4)
src/types/domain/review.ts (1)

197-200: ⚠️ Potential issue | 🟠 Major

ProductReviewListResponseProductReviewListItem을 사용해야 함 — 현재 ProductWithReviewStats 사용으로 타입 불일치

API 구현을 확인한 결과, getProductReviewsAction의 실제 반환 타입은 Promise<PageResponse<ProductReviewListItem>>입니다(line 283). 그러나 ProductReviewListResponsePageResponse<ProductWithReviewStats>로 정의되어 있어 타입이 맞지 않습니다.

"상품별 리뷰 목록 조회" 응답은 리뷰 데이터(ProductReviewListItem)를 담고 있으므로, 다음과 같이 수정해야 합니다:

export type ProductReviewListResponse = ApiResponse<
  PageResponse<ProductReviewListItem>
>;

ProductWithReviewStats는 내가 작성한 리뷰 조회(MyReviewListResponse)에만 사용되는 것이 의도로 보입니다.

src/components/product/review/review-tabs.tsx (1)

14-14: 🛠️ Refactor suggestion | 🟠 Major

export default function 패턴을 사용해야 합니다.

코딩 가이드라인에 따라 src/components/**/*.tsx 파일은 export default function 패턴을 따라야 합니다.

♻️ 수정 제안
-export function ReviewTabs({
+export default function ReviewTabs({

As per coding guidelines, src/components/**/*.tsx: - export default function 패턴 (화살표 함수 금지)

src/components/product/review/review-item.tsx (1)

176-176: ⚠️ Potential issue | 🟡 Minor

aria-label에 이중 공백이 있습니다.

`리뷰 이미지 ${index + 1} 확대보기`1확대보기 사이 공백이 2개입니다.

🔧 수정
-                  aria-label={`리뷰 이미지 ${index + 1}  확대보기`}
+                  aria-label={`리뷰 이미지 ${index + 1} 확대보기`}
src/app/actions/review.ts (1)

206-210: ⚠️ Potential issue | 🟡 Minor

프로덕션 환경에서 사용자 콘텐츠 로깅 주의.

payloadtextReview(사용자 작성 텍스트), reviewImageUrls 등이 포함되어 서버 로그에 기록됩니다. 디버깅용이라면 LOG_LEVEL 기반 조건부 로깅이나, 프로덕션 배포 전 제거를 권장합니다.

🤖 Fix all issues with AI agents
In `@src/app/actions/review.ts`:
- Around line 270-280: The params construction can be overwritten by ...query
(so page/pageSize default values get lost); fix by building params from query
first and then explicitly setting overrides, e.g. start with params = {...query}
and then assign params.page = page ?? 0; params.pageSize = pageSize ??
REVIEW_LIST_DEFAULT_PAGE_SIZE; params.sort = 'BEST' (or equivalently create
params with explicit fields rather than spreading query after them). Update the
code that defines page, pageSize and params to ensure page/pageSize/sort are set
last so query cannot overwrite them.

In `@src/components/product/review/analysis-content.tsx`:
- Around line 31-44: getEmptyMessage contains an unreachable branch for the
color variant checking selectedOption === 'all' because callers only invoke
getEmptyMessage when isEmpty && isFiltered and isFiltered is computed as
selectedOption !== 'all'; remove the unreachable branch and simplify the 'color'
branch to always return the message for a specific color (e.g.,
`"${selectedOption}" 색상을 선택한 분의 후기가 없어요"`) in the getEmptyMessage function, or
alternatively ensure callers can call getEmptyMessage when selectedOption ===
'all' if that behavior is desired (check uses of isEmpty, isFiltered, and
selectedOption to decide which approach).

In `@src/components/product/review/review-constants.ts`:
- Around line 1-37: These constants and types (CLOTHING_CATEGORY_OPTIONS,
SIZE_ANSWER_OPTIONS, MATERIAL_ANSWER_OPTIONS, COLOR_ANSWER_OPTIONS and their
corresponding types ClothingCategory, SizeAnswer, MaterialAnswer, ColorAnswer)
are duplicated; make src/types/domain/review.ts the canonical source and remove
the duplicate definitions here, then import and re-export those symbols from
src/types/domain/review.ts (e.g. export { CLOTHING_CATEGORY_OPTIONS,
ClothingCategory, ... } from 'src/types/domain/review') so the same exact types
are used everywhere and avoid the mismatched strict vs. `| string` variants.

In `@src/components/product/review/review-fetch-utils.ts`:
- Around line 67-98: The current sequential loop inside the IIFE (variable
request) performs up to maxPages (20) sequential getProductReviewsAction calls
(pageSize 100) causing heavy waterfall and memory load; update
fetchAllProductReviews (the IIFE/request block) to avoid strict sequential
fetching by either batching pages and using Promise.all with a controlled
concurrency limiter (e.g., map pages to fetches in chunks) or add a TTL-backed
cache around the aggregated result so repeated calls return cached data; ensure
you keep the same merging logic (allReviews and applyClientOptionFilter) and
preserve the last/page checks when switching to parallel fetching, and reference
getProductReviewsAction, page/pageSize/maxPages, and applyClientOptionFilter
when making the changes.

In `@src/components/product/review/review-item.tsx`:
- Line 39: Change the named export to a default function export by replacing the
declaration "export function ReviewItem(...)" with "export default function
ReviewItem(...)" (keep the same params and ReviewItemProps type) and avoid using
an arrow function; also search for any places importing { ReviewItem } and
update them to use the default import (import ReviewItem from '...') so
references remain correct.

In `@src/components/product/review/review-list.tsx`:
- Around line 93-127: The fetchFirstPage useEffect can suffer race conditions
when filters/sort change rapidly; fix it by cancelling or ignoring stale
requests: create an AbortController (or a monotonically-incremented requestId)
inside the effect before calling getProductReviewsAction(productId, {...}) and
either pass controller.signal to getProductReviewsAction or capture the
requestId; on cleanup (return from useEffect) call controller.abort() or mark
the requestId as stale, and only call setReviews, setPage, setHasMore when the
response comes back for the current (non-aborted / matching requestId) request
so that fetchFirstPage results cannot overwrite newer responses.

In `@src/components/product/review/review-section.tsx`:
- Around line 93-97: The default filters state sets mySize: true which hides
reviews for users without saved body info; change the initialization of the
FilterState used by useState (filters / setFilters) so mySize is false by
default or derived from the actual user size presence (e.g., use a user profile
flag like userHasSize or a hook) — update the initializer for filters (and any
place that assumes mySize true) to use mySize: false or mySize:
Boolean(userHasSize) so users without size data still see reviews on first load.

In `@src/components/product/review/review-summary-modal.tsx`:
- Line 39: The component is using a named export but the codebase requires the
"export default function" declaration pattern; change the declaration of
ReviewSummaryModal from a named export to a default function export (i.e.,
replace "export function ReviewSummaryModal(...)" or any arrow-function export
with "export default function ReviewSummaryModal(...)" so it remains a function
declaration), and update any local imports that relied on the previous named
export to use the default import if necessary.
- Around line 169-221: The effect that fetches ONE_MONTH base stats should not
re-run when UI filters change: remove selectedSize and selectedColor from the
dependency array of the useEffect that calls fetchAllProductReviews and builds
derivedOverall/derivedChanges (the effect containing fetchAllProductReviews,
buildCategorySummaryFromReviews, setBaseMonthSizeStat, setBaseMonthColorStat).
Move the conditional setSizeStat/setColorStat work (the selectedSize === 'all' /
selectedColor === 'all' branches that call setSizeStat and setColorStat) out of
this effect into a separate effect or initialization logic that depends on
selectedSize/selectedColor only, so the base-month fetch remains triggered only
by isOpen, reviewType, productId, stats, sizeCategory, and colorCategory.
🧹 Nitpick comments (17)
src/components/product/review/review-image-modal.tsx (1)

44-49: api.on('select', ...) 리스너 cleanup 누락

이번 변경 범위는 아니지만, api.on('select', ...) 등록 후 cleanup이 없어 모달이 열릴 때마다 리스너가 누적될 수 있습니다.

♻️ 리스너 정리 제안
 useEffect(() => {
   if (!api) return;
   setCount(api.scrollSnapList().length);
   setCurrent(api.selectedScrollSnap() + 1);
-  api.on('select', () => setCurrent(api.selectedScrollSnap() + 1));
+  const onSelect = () => setCurrent(api.selectedScrollSnap() + 1);
+  api.on('select', onSelect);
+  return () => {
+    api.off('select', onSelect);
+  };
 }, [api]);
src/components/product/review/review-sort-sheet.tsx (1)

31-44: SortOptionItem 버튼에 type="button" 누락

현재 <button>type 속성이 없습니다. Sheet 컨텍스트에서 문제 가능성은 낮지만, <form> 안에서 재사용될 경우 의도치 않은 submit이 발생할 수 있습니다.

제안
     <button
+      type="button"
       onClick={onSelect}
src/types/domain/review.ts (2)

74-76: | string 유니온 확장으로 타입 안전성이 무효화됨

ClothingCategory, SizeAnswer, MaterialAnswer, ColorAnswer 모두 (typeof OPTIONS)[number] | string으로 정의되어 있어, 결국 string과 동일합니다. as const 배열의 이점이 사라집니다.

또한 src/components/product/review/review-constants.ts에 동일한 상수와 타입이 | string 없이 중복 정의되어 있어 불일치가 발생합니다. 한 곳에서만 정의하고 re-export하는 것을 권장합니다.

제안: 서버 응답의 예상치 못한 값 처리가 목적이라면

타입 가드를 사용하거나, 도메인 레이어에서는 strict 타입을 유지하고 API 응답 파싱 시 validation하는 것이 더 안전합니다.

-export type SizeAnswer = (typeof SIZE_ANSWER_OPTIONS)[number] | string;
+export type SizeAnswer = (typeof SIZE_ANSWER_OPTIONS)[number];

중복 제거: review-constants.ts의 정의를 canonical source로 삼고, 이 파일에서는 re-export하세요.

Also applies to: 85-85, 94-94, 101-101


153-170: initialFirstAnswersanswers 필드 공존 — 용도 명확화 필요

ProductReviewListIteminitialFirstAnswersanswers가 모두 optional로 존재합니다. review-summary-utils.ts에서는 review.initialFirstAnswers ?? review.answers로 폴백하고 있는데, 어느 것이 canonical인지 타입 레벨에서 불명확합니다. API 응답 스펙에 따라 하나로 통일하거나, 명확한 discriminant를 추가하세요.

src/components/product/review/review-summary-utils.ts (1)

41-47: topAnswerCount가 데이터 없을 때 0 반환 — null이 더 적절

topAnswernull일 때 topAnswerCount0이 됩니다. ReviewCategorySummary 인터페이스에서 topAnswerCount: number | null로 정의되어 있으므로, topAnswer가 없을 때 null을 반환하는 것이 의미적으로 일관됩니다.

제안
   return {
     category,
     totalCount: total,
     topAnswer: answerStats[0]?.answer ?? null,
-    topAnswerCount: answerStats[0]?.count ?? 0,
+    topAnswerCount: answerStats[0]?.count ?? null,
     answerStats,
   };
src/app/product/[id]/page.tsx (1)

38-42: resolvedBodyInfo 폴백 타입이 불완전할 수 있음

{ success: false } 폴백에는 data 프로퍼티가 없으므로, Line 52에서 resolvedBodyInfo.data?.hasBodyInfo 접근 시 TypeScript strict 모드에서 타입 에러가 발생할 수 있습니다. 런타임 동작은 optional chaining으로 안전하지만, 타입 명시가 필요합니다.

제안
   const resolvedBodyInfo =
     bodyInfoResult.status === 'fulfilled'
       ? bodyInfoResult.value
-      : { success: false };
+      : { success: false as const, data: null };

Also applies to: 51-54

src/components/product/product-detail-view.tsx (1)

96-122: 기본 ReviewStatsData 객체가 review-section.tsxcreateEmptyStats와 중복됩니다.

review-section.tsxcreateEmptyStats (Line 41-68)와 동일한 빈 통계 구조를 인라인으로 정의하고 있습니다. 공통 유틸로 추출하면 불일치 위험을 줄일 수 있습니다.

♻️ 예시
+import { createEmptyReviewStats } from '@/components/product/review/review-utils';
 ...
-  const reviewStats = productReviewSummary ||
-    product.reviewSummary || {
-      averageRating: 0,
-      initialReviewCount: 0,
-      oneMonthReviewCount: 0,
-      sizeSummary: { ... },
-      colorSummary: { ... },
-      materialSummary: { ... },
-    };
+  const reviewStats = productReviewSummary
+    ?? product.reviewSummary
+    ?? createEmptyReviewStats();
src/components/product/review/review-tabs.tsx (1)

27-27: text-[15px]가 내부 text-2xl에 의해 완전히 덮어써집니다.

버튼에 text-[15px]을 적용했지만 Line 33/52의 내부 divtext-2xl이 선언되어 실질적으로 dead class입니다. 의도에 맞는 쪽만 남기세요.

src/components/product/review/review-fetch-utils.ts (1)

47-52: 프로덕션 코드에 console.log가 남아 있습니다.

디버깅용 로그는 제거하거나 debug-level logger 뒤로 숨기세요.

src/components/product/review/review-summary-modal.tsx (1)

223-343: fetchSizeStatfetchColorStat이 거의 동일한 구조로 중복됩니다.

카테고리 키와 setter만 다를 뿐 로직이 동일합니다. 공통 헬퍼로 추출하면 유지보수가 훨씬 수월합니다.

src/components/product/review/review-section.tsx (1)

184-184: top-[188px] 매직 넘버는 상위 레이아웃 변경 시 깨지기 쉽습니다.

헤더·탭 높이가 변경되면 이 값도 함께 수정해야 합니다. CSS 변수나 계산된 값으로 관리하면 유지보수가 편합니다.

src/components/product/review/review-summary.tsx (2)

85-115: review-summary-modal.tsx와 동일한 ONE_MONTH 전체 리뷰 페칭이 중복됩니다.

ReviewSummarySectionReviewSummaryModal 양쪽에서 fetchAllProductReviews(productId, { reviewType: 'ONE_MONTH', sort: 'BEST' })를 독립적으로 호출합니다. in-flight 중복은 방지되지만 시간차 호출 시 재페칭됩니다. 부모에서 한 번 fetching 후 props로 내리거나, 결과를 캐싱(TTL)하는 구조를 권장합니다.


148-158: MONTH_REVIEW_QUESTIONS[index]의 배열 범위 안전성을 확인하세요.

현재 summaryItems가 항상 2개이므로 MONTH_REVIEW_QUESTIONS[0], [1]만 접근하여 안전하지만, 추후 항목 수가 달라지면 undefined 접근이 발생합니다. Optional chaining이나 범위 검사를 추가해두면 방어적입니다.

src/components/product/review/review-item.tsx (1)

198-215: REVIEW_CONTENT_CONFIG를 순회하면서 값 접근은 하드코딩하는 것은 설정 기반 렌더링의 이점을 상쇄합니다.

key로 필터하고 순회하지만 실제 값은 review.sizeReview?.[0] / review.materialReview?.[0]으로 직접 접근합니다. 설정 배열의 key를 이용해 review[key]처럼 동적 접근하거나, 아예 설정 없이 명시적 블록으로 작성하는 쪽이 의도가 명확합니다.

src/components/product/review/review-list.tsx (1)

147-187: handleLoadMore 내부에서 불필요한 중첩 async 함수를 정의하고 있습니다.

handleLoadMore 자체를 async로 선언하면 중첩 없이 깔끔합니다.

♻️ 간소화
- const handleLoadMore = () => {
+ const handleLoadMore = async () => {
    if (isLoading || !hasMore) return;
    const nextPage = Number.isFinite(page) ? page + 1 : 1;
-   const fetchNextPage = async () => {
-     setIsLoading(true);
-     try { ... } catch { ... } finally { ... }
-   };
-   fetchNextPage();
+   setIsLoading(true);
+   try { ... } catch { ... } finally { ... }
  };
src/app/actions/review.ts (2)

59-67: | string 유니온이 리터럴 타입을 무력화합니다.

'BEST' | 'RECENT' | 'RATING_HIGH' | 'RATING_LOW' | string 형태는 TypeScript에서 결국 string으로 추론되어 자동완성과 타입 가드가 무효화됩니다. 허용된 값만 사용하도록 리터럴 유니온만 유지하거나, 확장이 필요하다면 (string & {}) 패턴을 사용하세요.

♻️ 제안
 export interface ProductReviewsQuery {
-  reviewType?: 'INITIAL' | 'ONE_MONTH' | string;
-  size?: string | string[];
-  color?: string | string[];
-  sort?: 'BEST' | 'RECENT' | 'RATING_HIGH' | 'RATING_LOW' | string;
+  reviewType?: 'INITIAL' | 'ONE_MONTH';
+  size?: string | string[];
+  color?: string | string[];
+  sort?: 'BEST' | 'RECENT' | 'RATING_HIGH' | 'RATING_LOW';
   mySizeOnly?: boolean;
   page?: number;
   pageSize?: number;
 }

245-263: 에러 처리 패턴이 불일치합니다. 다만 호출부에서는 Promise.allSettled로 안전하게 처리 중입니다.

getProductReviewsActiongetProductReviewsSummaryAction은 에러 시 빈 폴백을 반환하지만, getReviewDetailAction은 404 외 에러에서 throw합니다. 다만 유일한 호출부(review-summary-modal.tsx line 103)에서는 Promise.allSettled()로 래핑되어 있어 rejected 상태가 안전하게 필터링됩니다.

일관성을 위해 getReviewDetailAction도 null 반환으로 통일하거나, 다른 함수들도 throw하는 방식으로 정렬하는 것을 권장합니다.

Comment thread src/app/actions/review.ts Outdated
Comment thread src/components/product/review/analysis-content.tsx
Comment thread src/components/product/review/review-constants.ts Outdated
Comment on lines +67 to +98
const request = (async () => {
const pageSize = 100;
const maxPages = 20;
let page = 0;
let hasMore = true;
const allReviews: ProductReviewListItem[] = [];

while (hasMore && page < maxPages) {
const response = await getProductReviewsAction(productId, {
...query,
page,
pageSize,
});
allReviews.push(...response.content);
if (response.content.length === 0) {
hasMore = false;
break;
}
hasMore = !response.last;
page += 1;
}

if (hasMore) {
console.warn('리뷰 통계 집계가 최대 페이지 제한에 도달했습니다.', {
productId,
query,
maxPages,
});
}

return applyClientOptionFilter(allReviews, query);
})();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

클라이언트에서 최대 2,000건의 리뷰를 순차 페칭하는 것은 성능·UX 리스크가 큽니다.

fetchAllProductReviews는 최대 20페이지 × 100건을 순차적으로 호출합니다. 리뷰가 많은 상품에서 네트워크 waterfall이 심각해질 수 있고, 모바일 환경에서는 메모리 부담도 큽니다. 이상적으로는 서버 사이드에서 집계 API를 제공하는 것이 맞지만, 당장 변경이 어렵다면 최소한 Promise.all 병렬 페칭이나 결과 캐싱(TTL 기반)을 검토해 주세요.

🤖 Prompt for AI Agents
In `@src/components/product/review/review-fetch-utils.ts` around lines 67 - 98,
The current sequential loop inside the IIFE (variable request) performs up to
maxPages (20) sequential getProductReviewsAction calls (pageSize 100) causing
heavy waterfall and memory load; update fetchAllProductReviews (the IIFE/request
block) to avoid strict sequential fetching by either batching pages and using
Promise.all with a controlled concurrency limiter (e.g., map pages to fetches in
chunks) or add a TTL-backed cache around the aggregated result so repeated calls
return cached data; ensure you keep the same merging logic (allReviews and
applyClientOptionFilter) and preserve the last/page checks when switching to
parallel fetching, and reference getProductReviewsAction,
page/pageSize/maxPages, and applyClientOptionFilter when making the changes.

return Array.isArray(value) ? value.filter(Boolean) : [value].filter(Boolean);
}

export function ReviewItem({ review, isAccessory = false }: ReviewItemProps) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

export default function 패턴을 사용해야 합니다.

As per coding guidelines, src/components/**/*.tsx: - export default function 패턴 (화살표 함수 금지)

🤖 Prompt for AI Agents
In `@src/components/product/review/review-item.tsx` at line 39, Change the named
export to a default function export by replacing the declaration "export
function ReviewItem(...)" with "export default function ReviewItem(...)" (keep
the same params and ReviewItemProps type) and avoid using an arrow function;
also search for any places importing { ReviewItem } and update them to use the
default import (import ReviewItem from '...') so references remain correct.

Comment thread src/components/product/review/review-list.tsx
Comment thread src/components/product/review/review-section.tsx
Comment thread src/components/product/review/review-summary-modal.tsx Outdated
Comment thread src/components/product/review/review-summary-modal.tsx
Copy link
Copy Markdown
Contributor

@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: 5

Caution

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

⚠️ Outside diff range comments (1)
src/components/product/review/review-list.tsx (1)

141-154: ⚠️ Potential issue | 🟡 Minor

초기 로딩 시에도 빈 상태 메시지가 잠깐 노출될 수 있습니다.

!isLoading && reviews.length === 0 조건은 첫 렌더 시점(isLoading이 아직 false이고 reviews[]인 순간)에도 참이 됩니다. useEffect가 비동기로 setIsLoading(true)를 호출하기 전에 한 프레임 동안 빈 상태가 보입니다.

🛡️ 수정 제안
- const [isLoading, setIsLoading] = useState(false);
+ const [isLoading, setIsLoading] = useState(true);

초기값을 true로 두면 첫 fetch 완료 전까지 로딩 상태를 표시합니다.

🤖 Fix all issues with AI agents
In `@src/components/product/review/review-list.tsx`:
- Around line 156-192: The load-more race occurs when handleLoadMore appends
responses after filters/tabs changed; fix by snapshotting the current filter
context into a component-level ref (e.g., currentFilterRef) before initiating
getProductReviewsAction in handleLoadMore and then, when the response returns,
compare the response's filter/page context (or the snapshot) to currentFilterRef
and only call setReviews((prev) => [...prev, ...response.content]), setPage and
setHasMore if they match; update the ref whenever filters, activeReviewType,
sortOption, selectedSizes, selectedColors, or page reset in the useEffect that
clears reviews so the comparison rejects stale responses.

In `@src/components/product/review/review-summary-modal.tsx`:
- Around line 100-109: The code limits hydration to the first 30 items via
uncachedTargets.slice(0, 30), causing inaccurate statistics; update the logic in
the block that builds hydratedEntries (which maps uncachedTargets and calls
getReviewDetailAction and collects initialFirstAnswers) to either remove the
hard slice limit so all uncachedTargets are hydrated, or implement
chunked/batched hydration (iterate uncachedTargets in batches, run
Promise.allSettled per batch, merge results into hydratedEntries) and ensure
aggregation uses the full set; if you must keep a limit, surface a clear UI
notice stating only the first N reviews were hydrated and statistics may be
partial.
- Around line 321-335: handleSizeChange와 handleColorChange에서 빠른 연속 선택 시 이전 fetch
호출의 stale 응답이 최신 선택을 덮어쓰는 경쟁 상태가 발생합니다; fix는 fetchSizeStat/fetchColorStat 호출을 취소
가능한 방식으로 바꾸거나(AbortController 사용) 각각의 호출에 증가하는 요청 ID를 부여해 응답 수신 시 현재
선택(selectedSize/selectedColor)과 요청 ID를 비교하여 stale 응답을 무시하도록 구현하는 것입니다; 구체적으로
handleSizeChange와 handleColorChange는 setSelectedSize/setSelectedColor 후 새
AbortController를 생성해 이전 컨트롤러를 abort하거나(컨트롤러를 useRef에 보관) 호출 시 해당 컨트롤러.signal을
fetchSizeStat/fetchColorStat에 전달하거나, 또는 요청 ID(useRef) 증가 → fetch 호출에 ID 바인딩 → 응답
핸들러에서 최신 ID와 비교하여 setState/통계 업데이트를 수행하도록 변경하세요.
- Around line 39-47: Remove the unused recommendedSize prop from the
ReviewSummaryModal component: delete it from the ReviewSummaryModal function
parameter list and from the ReviewSummaryModalProps type/interface, and remove
it from any dependency arrays (e.g., the useEffect that currently lists
recommendedSize) so there are no leftover references; also search for and remove
any places that pass recommendedSize into ReviewSummaryModal to keep callers
consistent.

In `@src/components/product/review/review-tabs.tsx`:
- Around line 26-38: The active tab's font-weight is being overridden because
the inner div in each tab render has a hardcoded "font-medium"; remove
"font-medium" from the inner <div> that wraps the tab labels (the div that
currently has className "flex flex-col items-center text-2xl leading-[1.5]
font-medium not-italic") in both tab blocks so font-weight is controlled only by
the outer button's className conditional (which uses activeTab to apply
"font-bold" vs the inactive style); update both the "전체 리뷰" (uses generalCount)
tab and the other tab the same way.
🧹 Nitpick comments (10)
src/components/product/review/review-tabs.tsx (1)

22-59: 탭 접근성: role="tablist" / role="tab" / aria-selected 누락

현재 <button> 요소만 사용하고 있어 스크린 리더에서 탭 패턴으로 인식되지 않습니다. WAI-ARIA Tabs 패턴 적용을 권장합니다.

♻️ 접근성 개선 제안
-      <div className="flex items-center border-y border-black px-4 py-4">
+      <div role="tablist" className="flex items-center border-y border-black px-4 py-4">
         <div className="flex-1">
           <button
+            role="tab"
+            aria-selected={activeTab === 'general'}
             onClick={() => onTabChange('general')}
             ...
           >

두 번째 버튼에도 동일하게 role="tab", aria-selected={activeTab === 'month'} 추가.

src/components/product/review/review-fetch-utils.ts (1)

7-12: responseCache가 무한 증가할 수 있음

만료된 엔트리에 대한 eviction 로직이 없어, 장시간 세션에서 responseCache가 계속 커집니다. 주기적 정리 또는 LRU 제한을 고려해 주세요.

♻️ 간단한 eviction 예시
+const MAX_CACHE_SIZE = 50;
+
 export async function fetchAllProductReviews(
   productId: number,
   query: ProductReviewsQuery,
 ): Promise<ProductReviewListItem[]> {
   const queryKey = JSON.stringify({ productId, ...query });
   const cached = responseCache.get(queryKey);
   if (cached && cached.expiresAt > Date.now()) {
     return cached.data;
   }
+  // 만료 엔트리 정리
+  if (responseCache.size > MAX_CACHE_SIZE) {
+    const now = Date.now();
+    for (const [key, val] of responseCache) {
+      if (val.expiresAt <= now) responseCache.delete(key);
+    }
+  }
src/components/product/review/review-summary.tsx (1)

85-115: useEffect 의존성 배열에 stats 객체 참조 — 불필요한 재호출 위험

stats는 객체이므로 부모가 리렌더될 때마다 새 참조가 생성될 수 있습니다. 이 경우 fetchAllProductReviews가 반복 호출됩니다. 캐시(30s TTL)가 완화해 주지만, effect 자체의 재실행과 state 업데이트는 발생합니다.

안정적인 의존성(예: stats.sizeSummary.category 같은 primitive)으로 교체하거나, 부모에서 useMemostats를 안정화하는 것을 권장합니다.

♻️ 의존성 안정화 예시
+ const statsCategoryKey = `${stats?.sizeSummary?.category}-${stats?.colorSummary?.category}`;
+
  useEffect(() => {
    if (!stats || reviewType !== 'ONE_MONTH' || reviewCount === 0) return;
    // ...fetch logic
- }, [reviewType, reviewCount, productId, stats]);
+ }, [reviewType, reviewCount, productId, statsCategoryKey]);
src/components/product/review/review-item.tsx (1)

201-218: REVIEW_CONTENT_CONFIG 순회 내부에서 key별 분기가 하드코딩됨

key'sizeReview'인지 여부로 데이터 접근을 분기하고 있어, config 항목이 추가되면 이 로직이 자동으로 확장되지 않습니다. 현재는 textReview를 필터링하여 2개만 남으므로 동작하지만, review[key] 처럼 동적 접근이 가능하면 확장성이 개선됩니다.

♻️ 동적 접근 제안
  {REVIEW_CONTENT_CONFIG.filter((item) => item.key !== 'textReview').map(
    ({ label, key }) => {
-     const text =
-       key === 'sizeReview'
-         ? review.sizeReview?.[0]
-         : review.materialReview?.[0];
+     const values = review[key];
+     const text = Array.isArray(values) ? values[0] : values;

      return (

textReviewstring이고 나머지는 string[]?이므로, 타입에 맞게 처리가 필요할 수 있습니다.

src/components/product/review/review-section.tsx (2)

41-68: createEmptyStatsreview.tscreateEmptyReviewSummary와 중복됩니다.

src/app/actions/review.tscreateEmptyReviewSummary()와 거의 동일한 구조이며, 유일한 차이점은 oneMonthReviewCount 파라미터뿐입니다. 공통 팩토리 함수를 하나로 통합하면 유지보수가 편해집니다.

♻️ 제안: 공통 유틸로 추출

예를 들어 src/types/domain/review.ts 또는 별도 유틸에:

export function createEmptyReviewStats(
  overrides?: Partial<ReviewStatsData>,
): ReviewStatsData {
  return {
    averageRating: 0,
    initialReviewCount: 0,
    oneMonthReviewCount: 0,
    sizeSummary: { category: '사이즈', totalCount: 0, topAnswer: null, topAnswerCount: 0, answerStats: [] },
    colorSummary: { category: '색감', totalCount: 0, topAnswer: null, topAnswerCount: 0, answerStats: [] },
    materialSummary: { category: '소재', totalCount: 0, topAnswer: null, topAnswerCount: 0, answerStats: [] },
    ...overrides,
  };
}

그러면 양쪽에서:

- createEmptyStats(stats.oneMonthReviewCount)
+ createEmptyReviewStats({ oneMonthReviewCount: stats.oneMonthReviewCount })

184-184: top-[188px] 매직 넘버.

sticky offset이 하드코딩되어 있어, 상단 헤더/탭 레이아웃 변경 시 동기화가 깨질 수 있습니다. CSS 변수나 상수로 관리하는 것을 고려해 보세요.

src/components/product/review/review-list.tsx (1)

21-49: resolvePageNumber / resolveHasMore — 방어적 파싱은 양호하나 타입이 느슨합니다.

인라인 구조체 타입 대신 PageResponse에서 직접 추론하면 API 응답 구조 변경 시 컴파일 타임에 잡을 수 있습니다. 현재 구조체는 PageResponse와 동기화가 보장되지 않습니다.

src/components/product/review/review-summary-modal.tsx (1)

227-319: fetchSizeStatfetchColorStat이 거의 동일합니다.

두 함수는 category, summaryKey, setter, loadingSetter만 다르고 나머지 로직(필터 구성 → fetch → hydrate → build summary → set state)이 동일합니다. 공통 함수로 추출하면 유지보수성이 크게 개선됩니다.

♻️ 추출 예시
const fetchFilteredStat = useCallback(
  async (opts: {
    filterSize?: string;
    filterColor?: string;
    category: string;
    summaryKey: string;
    setStat: (v: ReviewCategorySummary | null) => void;
    setLoading: (v: boolean) => void;
  }) => {
    const nextSize = opts.filterSize === 'all' ? undefined : opts.filterSize;
    const nextColor = opts.filterColor === 'all' ? undefined : opts.filterColor;
    if (!nextSize && !nextColor) { opts.setStat(null); return; }

    opts.setLoading(true);
    try {
      const reviews = await fetchAllProductReviews(productId, {
        reviewType, size: nextSize, color: nextColor, sort: 'BEST',
      });
      const hydrated = await hydrateInitialAnswers(reviews);
      opts.setStat(buildCategorySummaryFromReviews(opts.category, hydrated, opts.summaryKey));
    } catch (e) {
      console.error('통계 필터 조회 실패:', e);
    } finally {
      opts.setLoading(false);
    }
  },
  [productId, reviewType, hydrateInitialAnswers],
);
src/app/actions/review.ts (2)

234-252: getReviewDetailAction — 비-404 에러 시 throw하는 패턴이 다른 액션과 다릅니다.

getProductReviewsActiongetProductReviewsSummaryAction은 에러 시 빈 fallback을 반환하지만, 이 함수는 throw new Error()를 합니다. 호출부(hydrateInitialAnswers)에서 Promise.allSettled로 처리하고 있어 현재는 문제없지만, 향후 단독 호출 시 예상치 못한 예외가 발생할 수 있습니다.

일관성을 위해 null 반환으로 통일하거나, JSDoc에 throw 가능성을 명시하는 것을 권장합니다.


283-296: getProductReviewsSummaryAction — 에러 시 빈 요약 반환으로 UI 안정성 확보.

API 실패 시에도 UI가 깨지지 않도록 createEmptyReviewSummary()를 반환하는 패턴이 적절합니다. 다만 사용자가 "리뷰 0건"으로 인식할 수 있으므로, 에러 상태를 구분할 수 있는 메커니즘(예: isError 플래그)을 장기적으로 검토해 보세요.

Comment on lines +156 to +192
const handleLoadMore = async () => {
if (isLoading || !hasMore) return;
const nextPage = Number.isFinite(page) ? page + 1 : 1;

setIsLoading(true);
try {
const response = await getProductReviewsAction(productId, {
reviewType: activeReviewType,
size: selectedSizes,
color: selectedColors,
sort: toApiSort(sortOption),
mySizeOnly: filters.mySize,
page: nextPage,
pageSize: 10,
});

setReviews((prev) => [...prev, ...response.content]);
setPage(resolvePageNumber(response));
setHasMore(resolveHasMore(response));
} catch (error) {
console.error('리뷰 목록 추가 조회 실패:', {
error,
query: {
productId,
reviewType: activeReviewType,
size: selectedSizes,
color: selectedColors,
sort: toApiSort(sortOption),
mySizeOnly: filters.mySize,
page: nextPage,
pageSize: 10,
},
});
} finally {
setIsLoading(false);
}
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

handleLoadMore에서 필터 변경과의 경쟁 상태가 남아있습니다.

사용자가 "리뷰 더보기"를 클릭한 직후 필터/탭을 변경하면, useEffectreviews를 리셋하지만 진행 중인 handleLoadMore의 응답이 나중에 도착하여 setReviews((prev) => [...prev, ...response.content])로 이전 필터의 데이터가 append될 수 있습니다.

🛡️ 완화 방안

컴포넌트 레벨 ref를 사용해 현재 필터 컨텍스트와 비교:

+ const fetchIdRef = useRef(0);
+
  useEffect(() => {
    let isStale = false;
+   fetchIdRef.current += 1;
    const fetchFirstPage = async () => {
      // ...existing code
    };
    fetchFirstPage();
    return () => { isStale = true; };
  }, [productId, activeReviewType, selectedSizes, selectedColors, sortOption, filters.mySize]);

  const handleLoadMore = async () => {
    if (isLoading || !hasMore) return;
+   const currentFetchId = fetchIdRef.current;
    const nextPage = Number.isFinite(page) ? page + 1 : 1;
    setIsLoading(true);
    try {
      const response = await getProductReviewsAction(productId, { /* ... */ });
+     if (fetchIdRef.current !== currentFetchId) return;
      setReviews((prev) => [...prev, ...response.content]);
      // ...
    } catch (error) {
+     if (fetchIdRef.current !== currentFetchId) return;
      // ...
    } finally {
+     if (fetchIdRef.current === currentFetchId) setIsLoading(false);
-     setIsLoading(false);
    }
  };
🤖 Prompt for AI Agents
In `@src/components/product/review/review-list.tsx` around lines 156 - 192, The
load-more race occurs when handleLoadMore appends responses after filters/tabs
changed; fix by snapshotting the current filter context into a component-level
ref (e.g., currentFilterRef) before initiating getProductReviewsAction in
handleLoadMore and then, when the response returns, compare the response's
filter/page context (or the snapshot) to currentFilterRef and only call
setReviews((prev) => [...prev, ...response.content]), setPage and setHasMore if
they match; update the ref whenever filters, activeReviewType, sortOption,
selectedSizes, selectedColors, or page reset in the useEffect that clears
reviews so the comparison rejects stale responses.

Comment on lines +39 to 47
export default function ReviewSummaryModal({
isOpen,
onClose,
productId,
reviewType,
recommendedSize,
stats,
availableOptions = { sizes: [], colors: [] },
}: ReviewSummaryModalProps) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check all occurrences of recommendedSize in the component
rg -n 'recommendedSize' src/components/product/review/review-summary-modal.tsx

Repository: IT-Cotato/12th-OnGil-FE

Length of output: 186


🏁 Script executed:

#!/bin/bash
# Read the full file to understand context and verify if recommendedSize is used anywhere
cat -n src/components/product/review/review-summary-modal.tsx | head -200

Repository: IT-Cotato/12th-OnGil-FE

Length of output: 7364


🏁 Script executed:

#!/bin/bash
# Read more of the file to see if recommendedSize is used elsewhere
cat -n src/components/product/review/review-summary-modal.tsx | tail -n +200

Repository: IT-Cotato/12th-OnGil-FE

Length of output: 8808


recommendedSize prop을 제거하세요.

선언되었지만 실제로 사용되지 않습니다. Line 167의 useEffect 의존성 배열에만 존재하고, 효과 본문이나 렌더링 로직 어디에서도 참조되지 않습니다. 미사용 코드는 정리가 필요합니다.

🤖 Prompt for AI Agents
In `@src/components/product/review/review-summary-modal.tsx` around lines 39 - 47,
Remove the unused recommendedSize prop from the ReviewSummaryModal component:
delete it from the ReviewSummaryModal function parameter list and from the
ReviewSummaryModalProps type/interface, and remove it from any dependency arrays
(e.g., the useEffect that currently lists recommendedSize) so there are no
leftover references; also search for and remove any places that pass
recommendedSize into ReviewSummaryModal to keep callers consistent.

Comment on lines +100 to +109
if (uncachedTargets.length > 0) {
const hydratedEntries = await Promise.allSettled(
uncachedTargets.slice(0, 30).map(async (review) => {
const detail = await getReviewDetailAction(review.reviewId);
return {
reviewId: review.reviewId,
initialFirstAnswers: detail?.initialFirstAnswers ?? null,
};
}),
);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

hydration 대상이 30건으로 제한되어 통계가 부정확할 수 있습니다.

uncachedTargets.slice(0, 30)으로 인해 31번째 이후 리뷰의 initialFirstAnswers는 hydration되지 않습니다. 전체 리뷰 수가 많을 경우 사이즈/색감 통계 집계 결과가 실제와 달라질 수 있습니다. 제한 사유가 있다면 사용자에게 안내하거나, batch 처리를 고려해 보세요.

🤖 Prompt for AI Agents
In `@src/components/product/review/review-summary-modal.tsx` around lines 100 -
109, The code limits hydration to the first 30 items via
uncachedTargets.slice(0, 30), causing inaccurate statistics; update the logic in
the block that builds hydratedEntries (which maps uncachedTargets and calls
getReviewDetailAction and collects initialFirstAnswers) to either remove the
hard slice limit so all uncachedTargets are hydrated, or implement
chunked/batched hydration (iterate uncachedTargets in batches, run
Promise.allSettled per batch, merge results into hydratedEntries) and ensure
aggregation uses the full set; if you must keep a limit, surface a clear UI
notice stating only the first N reviews were hydrated and statistics may be
partial.

Comment on lines +321 to +335
const handleSizeChange = useCallback(
(value: string) => {
setSelectedSize(value);
fetchSizeStat(value);
},
[fetchSizeStat],
);

const handleColorChange = useCallback(
(value: string) => {
setSelectedColor(value);
fetchColorStat(value);
},
[fetchColorStat],
);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

handleSizeChange / handleColorChange에서 빠른 연속 선택 시 경쟁 상태.

사용자가 사이즈 옵션을 빠르게 변경하면 이전 fetchSizeStat 호출의 결과가 최신 선택을 덮어쓸 수 있습니다. AbortController나 요청 ID 패턴으로 stale 응답을 무시하는 것이 안전합니다.

🤖 Prompt for AI Agents
In `@src/components/product/review/review-summary-modal.tsx` around lines 321 -
335, handleSizeChange와 handleColorChange에서 빠른 연속 선택 시 이전 fetch 호출의 stale 응답이 최신
선택을 덮어쓰는 경쟁 상태가 발생합니다; fix는 fetchSizeStat/fetchColorStat 호출을 취소 가능한 방식으로
바꾸거나(AbortController 사용) 각각의 호출에 증가하는 요청 ID를 부여해 응답 수신 시 현재
선택(selectedSize/selectedColor)과 요청 ID를 비교하여 stale 응답을 무시하도록 구현하는 것입니다; 구체적으로
handleSizeChange와 handleColorChange는 setSelectedSize/setSelectedColor 후 새
AbortController를 생성해 이전 컨트롤러를 abort하거나(컨트롤러를 useRef에 보관) 호출 시 해당 컨트롤러.signal을
fetchSizeStat/fetchColorStat에 전달하거나, 또는 요청 ID(useRef) 증가 → fetch 호출에 ID 바인딩 → 응답
핸들러에서 최신 ID와 비교하여 setState/통계 업데이트를 수행하도록 변경하세요.

Comment on lines 26 to 38
className={cn(
'px-2 py-1 text-[15px] transition-colors',
'w-full px-2 py-1 transition-colors',
activeTab === 'general'
? 'font-bold text-[#1DAEFF]'
: 'text-gray-400 hover:text-gray-600',
)}
>
<div className="text-2xl leading-[1.5] flex flex-col items-center font-medium not-italic">
<span>전체 리뷰</span>
<span>({generalCount}개)</span>
<div className="flex flex-col items-center text-2xl leading-[1.5] font-medium not-italic">
<span className="break-keep whitespace-nowrap">전체 리뷰</span>
<span className="break-keep whitespace-nowrap">
({generalCount}개)
</span>
</div>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

font-bold(버튼)이 내부 font-medium(div)에 의해 덮어씌워짐

활성 탭에 font-bold가 적용되지만 Lines 33, 52의 내부 <div>font-medium이 항상 붙어 있어 상속된 font-bold를 override합니다. 결과적으로 활성/비활성 탭 모두 동일한 font-medium으로 렌더링됩니다.

🐛 수정 제안: 내부 div에서 font-weight 제거 후 버튼 레벨에서만 제어
           <div
-            className="flex flex-col items-center text-2xl leading-[1.5] font-medium not-italic"
+            className="flex flex-col items-center text-2xl leading-[1.5] not-italic"
           >

두 탭 모두(Line 33, 52)에 동일 적용.

📝 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
className={cn(
'px-2 py-1 text-[15px] transition-colors',
'w-full px-2 py-1 transition-colors',
activeTab === 'general'
? 'font-bold text-[#1DAEFF]'
: 'text-gray-400 hover:text-gray-600',
)}
>
<div className="text-2xl leading-[1.5] flex flex-col items-center font-medium not-italic">
<span>전체 리뷰</span>
<span>({generalCount}개)</span>
<div className="flex flex-col items-center text-2xl leading-[1.5] font-medium not-italic">
<span className="break-keep whitespace-nowrap">전체 리뷰</span>
<span className="break-keep whitespace-nowrap">
({generalCount})
</span>
</div>
className={cn(
'w-full px-2 py-1 transition-colors',
activeTab === 'general'
? 'font-bold text-[`#1DAEFF`]'
: 'text-gray-400 hover:text-gray-600',
)}
>
<div className="flex flex-col items-center text-2xl leading-[1.5] not-italic">
<span className="break-keep whitespace-nowrap">전체 리뷰</span>
<span className="break-keep whitespace-nowrap">
({generalCount})
</span>
</div>
🤖 Prompt for AI Agents
In `@src/components/product/review/review-tabs.tsx` around lines 26 - 38, The
active tab's font-weight is being overridden because the inner div in each tab
render has a hardcoded "font-medium"; remove "font-medium" from the inner <div>
that wraps the tab labels (the div that currently has className "flex flex-col
items-center text-2xl leading-[1.5] font-medium not-italic") in both tab blocks
so font-weight is controlled only by the outer button's className conditional
(which uses activeTab to apply "font-bold" vs the inactive style); update both
the "전체 리뷰" (uses generalCount) tab and the other tab the same way.

Copy link
Copy Markdown
Contributor

@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.

🧹 Nitpick comments (10)
src/components/product/review/analysis-content.tsx (4)

36-47: 카테고리 매칭에 하드코딩된 한국어 문자열 사용

'사이즈', '색감', '소재' 같은 매직 스트링으로 분기하고 있습니다. 서버 응답의 category 값이 조금이라도 달라지면(e.g. '사이즈감', '색상') 폴백이 빈 배열이 됩니다. review-constants.ts 등에 enum/상수로 관리하면 변경에 강해집니다.

참고로 Line 111에서는 category === '색상'으로 비교하는데, 여기서는 '색감'을 사용합니다 — 두 값이 의도적으로 다른 것인지 확인하세요.


74-89: sorted + fallback 병합 로직 — 정상 동작하나 엣지 케이스 점검

전체적으로 잘 구성되어 있습니다. 몇 가지 확인 포인트:

  1. stat.answerStatsundefined일 때 ?? [] 폴백이 Line 74, 84 두 곳에 중복됩니다. 상단에서 한 번 정규화하면 깔끔합니다.
  2. isEmpty && !isFiltered(전체 보기인데 데이터 0건)인 경우 빈 메시지 없이 0명짜리 폴백 항목이 렌더됩니다 — 의도된 동작인지 확인하세요.
answerStats 정규화 제안
+ const answerStats = stat.answerStats ?? [];
- const sorted = [...(stat.answerStats ?? [])].sort(
+ const sorted = [...answerStats].sort(
    (a, b) => b.count - a.count,
  );
  ...
- const totalCount = (stat.answerStats ?? []).reduce(
+ const totalCount = answerStats.reduce(
    (sum, item) => sum + item.count,
    0,
  );

105-105: onOptionChangeundefined일 때 Select 동작 확인

onOptionChange는 optional prop이고 기본값이 없으므로 undefinedonValueChange에 전달됩니다. Radix Select는 undefined 콜백을 무시하므로 런타임 오류는 없지만, 필터 UI가 표시되면서 변경이 불가능한 상태가 됩니다. filterOptions.length > 0이면 onOptionChange도 필수로 받도록 타입을 강화하거나, 가드를 추가하는 것이 안전합니다.


126-135: 로딩·빈 상태 처리 — 접근성 개선 여지

로딩 상태에서 시각적 텍스트만 표시되고 있습니다. 스크린리더 사용자를 위해 aria-busy 또는 role="status"를 추가하면 좋습니다.

- <div className="flex h-[340px] items-center justify-center text-base text-gray-500">
+ <div className="flex h-[340px] items-center justify-center text-base text-gray-500" role="status" aria-busy="true">
src/components/product/review/review-item.tsx (1)

85-101: API 실패 시 revert에서 stale closure 참조 가능성.

toggleHelpful 내부에서 confirmedState를 직접 참조하여 revert하고 있습니다. isPending 가드가 있어 대부분 안전하지만, pending 중 props(review.isHelpful, review.helpfulCount)가 변경되면 useEffect(Line 57-62)가 confirmedState를 갱신하고, catch/else 블록의 confirmedState는 호출 시점의 stale 값을 사용합니다.

함수형 업데이트 또는 ref로 최신 값을 참조하면 더 안전합니다.

제안
-      } else {
-        setConfirmedState(confirmedState);
-      }
-    } catch (error) {
-      console.error('도움돼요 토글 실패:', {
-        error,
-        reviewId: review.reviewId,
-      });
-      setConfirmedState(confirmedState);
+      } else {
+        setConfirmedState((prev) => prev);
+      }
+    } catch (error) {
+      console.error('도움돼요 토글 실패:', {
+        error,
+        reviewId: review.reviewId,
+      });
+      setConfirmedState((prev) => prev);

또는 confirmedStateRef를 사용하여 항상 최신 값을 참조하도록 변경:

+  const confirmedStateRef = useRef(confirmedState);
+  confirmedStateRef.current = confirmedState;
   ...
-      setConfirmedState(confirmedState);
+      setConfirmedState(confirmedStateRef.current);
src/components/product/review/review-summary.tsx (2)

85-115: useEffect 의존성에 stats 객체 참조 — 불필요한 리페치 가능성.

stats는 객체이므로 부모가 매 렌더마다 새 참조를 생성하면 이 effect가 반복 실행됩니다. fetchAllProductReviews의 내부 캐싱이 있어 실제 API 호출은 억제되지만, buildCategorySummaryFromReviews 연산과 state 업데이트는 매번 발생합니다.

stats에서 실제로 사용하는 값(stats.sizeSummary.category, stats.colorSummary.category)만 의존성에 넣거나, 부모에서 useMemo로 안정화하는 것을 권장합니다.

제안
   useEffect(() => {
     if (!stats || reviewType !== 'ONE_MONTH' || reviewCount === 0) return;
     // ...
-  }, [reviewType, reviewCount, productId, stats]);
+  }, [reviewType, reviewCount, productId, sizeCategory, colorCategory]);

이때 sizeCategorycolorCategory를 effect 위에서 미리 계산해두면 됩니다 (현재 stats?.sizeSummary.category를 직접 쓰고 있지 않으므로 추가 변수 필요).


120-124: ONE_MONTH 로딩 중 INITIAL 통계가 폴백으로 노출.

monthQuestionSummariesnull(아직 fetch 미완료)일 때 stats.sizeSummary, stats.colorSummary(INITIAL 데이터)가 폴백으로 사용됩니다. 한달 후 리뷰 탭에서 순간적으로 초기 리뷰 통계가 보일 수 있어 사용자 혼란 가능성이 있습니다.

로딩 스켈레톤이나 빈 상태를 보여주는 것이 더 자연스럽습니다.

src/components/product/review/review-section.tsx (2)

170-230: 불필요한 Fragment <> 래퍼.

Line 170과 230의 <>...</>는 자식이 하나의 조건부 렌더링뿐이므로 불필요합니다. 제거하면 JSX가 더 깔끔해집니다.

제안
-      <>
-        {isMonthTabEmpty ? (
+      {isMonthTabEmpty ? (
           <EmptyReviewState variant="month" />
         ) : (
           <>
             ...
           </>
-        )}
-      </>
+        )}

41-68: createEmptyStatstopAnswerCount0이지만 타입은 number | null.

ReviewCategorySummary.topAnswerCountnumber | null인데 여기서 0을 넣고 있습니다. 데이터가 없는 경우 null이 더 정확한 표현입니다. ReviewStatItem (review-summary.tsx Line 37)에서 stat.topAnswerCount ? ...로 falsy 체크하므로 0null도 동일하게 '-'로 렌더링되긴 하지만, 의미적으로 null이 더 적합합니다.

src/components/product/review/review-summary-modal.tsx (1)

384-433: 카드 렌더링에서 index 기반 역할 결정 — 취약하지만 현재는 동작.

isSizeCard = index === 0, isColorCard = index === 1로 카드 역할을 결정합니다. cards 배열 구조가 고정되어 있어 현재는 안전하지만, 카드 순서 변경 시 조용히 깨질 수 있습니다.

카드별 메타데이터(type, options, handler)를 배열에 포함시키면 더 견고합니다.

구조 개선 예시
const cardConfigs = cards.map((card, index) => ({
  card,
  type: index === 0 ? 'size' : index === 1 ? 'color' : 'material',
  options: index === 0 ? availableOptions.sizes : index === 1 ? availableOptions.colors : [],
  handler: index === 0 ? handleSizeChange : index === 1 ? handleColorChange : undefined,
  isLoading: index === 0 ? isSizeLoading : index === 1 ? isColorLoading : false,
}));

이렇게 하면 cards와 메타데이터를 한곳에서 관리할 수 있습니다.

@Seoje1405 Seoje1405 added ♻️ REFACTOR 리팩토링 🐛 BUG 버그 수정 작업이슈 labels Feb 16, 2026
@Seoje1405 Seoje1405 merged commit fb4b21a into IT-Cotato:develop Feb 17, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🐛 BUG 버그 수정 작업이슈 ♻️ REFACTOR 리팩토링

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant