Conversation
- 확정된 주제와 제안된 주제를 분리하여 표시하는 목록 컴포넌트 추가 - 무한 스크롤 및 가상화(@tanstack/react-virtual)를 통한 성능 최적화 - 주제 좋아요, 삭제 기능 구현 - 빈 상태 및 로딩 스켈레톤 UI 추가 - TanStack Query를 활용한 서버 상태 관리
Walkthrough주제(Topics) 기능을 신규 추가합니다. 컴포넌트(7개), 훅(5개), API 래퍼·엔드포인트·모킹·타입, MeetingDetailPage 탭 통합 및 무한 스크롤 연동을 포함합니다. (요약 45단어) Changes
Sequence Diagram(s)mermaid Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
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: 9
🤖 Fix all issues with AI agents
In `@src/features/topics/components/ProposedTopicList.tsx`:
- Around line 28-32: ProposedTopicList (and likewise ConfirmedTopicList) is
using useVirtualList with only estimateSize which breaks layout for
variable-height TopicCard items; update the rendering of the virtual list so
each <li> gets the measureElement ref and a data-index attribute from
virtualItems (i.e., call measureElement as the ref for the list item and set
data-index={virtualItem.index}), ensure you use virtualItems.map(...) to render
items and pass key={virtualItem.key} and style={virtualItem.style} on the <li>
so the virtualizer can measure and position items correctly.
In `@src/features/topics/components/TopicHeader.tsx`:
- Around line 22-27: The issue is that destructuring a discriminated-union prop
in TopicHeader prevents TypeScript from narrowing types based on the
discriminant (activeTab), causing errors when accessing union-specific fields
like actions.canConfirm and actions.canViewPreOpinions; fix it by not
destructuring the union props—accept a single parameter (e.g., props) in
TopicHeader and reference props.activeTab and props.actions so TypeScript can
narrow correctly, or alternatively keep destructuring but perform explicit type
guards on props.activeTab before using actions (e.g., check props.activeTab ===
'...' then use props.actions.canConfirm), ensuring the code paths where
actions.canConfirm and actions.canViewPreOpinions are accessed are guarded by
the correct discriminant check.
In `@src/features/topics/hooks/useDeleteTopic.ts`:
- Around line 43-50: Replace the manual queryKey array passed into
queryClient.invalidateQueries in useDeleteTopic.ts with the topicQueryKeys
factory: call topicQueryKeys.proposedLists({ gatheringId: variables.gatheringId,
meetingId: variables.meetingId }) and pass that as the key (using the top-level
prefix is sufficient because invalidateQueries does prefix matching). Update the
invalidate call that currently references queryClient.invalidateQueries({
queryKey: [...] }) to use the factory result so key shapes remain consistent
with useLikeTopic and other callers.
In `@src/features/topics/hooks/useInfiniteScroll.ts`:
- Around line 1-36: This feature-level useInfiniteScroll duplicates
functionality present in src/shared/hooks/useInfiniteScroll.ts and misses
rootMargin (should default to 200px), isLoading/enabled flags and environment
checks; replace this hook with an import of the shared useInfiniteScroll
implementation (remove the local useInfiniteScroll), ensure caller uses the
shared API (including rootMargin, isLoading, enabled) and stop passing unstable
inline onLoadMore callbacks (either memoize the callback or avoid including
onLoadMore in the hook's dependency array) so the IntersectionObserver
(observerRef) is not recreated unnecessarily.
In `@src/features/topics/hooks/useLikeTopic.ts`:
- Line 48: The optimistic update fails because useLikeTopic builds a manual
queryKey const queryKey = ['topics','proposed','list',{gatheringId,meetingId}]
that omits pageSize while the real cache keys from useProposedTopics include
pageSize; update onMutate in useLikeTopic to match the actual cache keys by
either (A) accepting pageSize in the mutation variables (propagate pageSize from
the parent TopicCard into likeMutation.mutate and use that in
getQueryData/setQueryData), or (B) use getQueriesData() (or
topicQueryKeys.proposedList() helpers) to find and iterate all matching cached
entries and apply the optimistic change to each, or (C) switch to
topicQueryKeys.proposedList(...) if you can derive/track the full key; adjust
references to getQueryData, setQueryData, and useLikeTopic.onMutate accordingly
so keys match the real cache entries.
In `@src/features/topics/hooks/useProposedTopics.ts`:
- Line 50: The queryKey used in useProposedTopics includes pageSize but
useLikeTopic's onMutate calls setQueryData with a key that omits pageSize,
causing optimistic updates to miss the exact cache entry; fix by centralizing
keys (create or import topicQueryKeys key factory) and use it in both
useProposedTopics (for queryKey) and useLikeTopic.onMutate (for setQueryData and
cancelQueries/invalidateQueries) so they produce identical keys (include or
intentionally omit pageSize consistently), ensuring the optimistic update
targets the same cache entry.
In `@src/features/topics/topics.api.ts`:
- Line 38: The JSDoc default for pageSize is inconsistent with the actual
default used in code; update the documentation to match the real default
(PAGE_SIZES.TOPICS = 5). Specifically, change the JSDoc description for the
pageSize param in topics.api.ts (the param documented as "기본값: 10") to "기본값: 5"
and make the same correction in the related comments in topics.types.ts (the
comments around the pageSize fields at the locations referenced). Ensure the
wording references the actual default constant (PAGE_SIZES.TOPICS) so the docs
stay correct if the constant changes.
- Around line 159-174: The mock in likeTopicToggle currently returns a random
liked value which breaks optimistic-update testing; change the mock to toggle
deterministically using the caller-provided current state by extending
LikeTopicParams to include isLiked (or alternatively maintain an internal map
keyed by topicId) and, when USE_MOCK_DATA is true, compute liked = !isLiked and
newCount = isLiked ? Math.max(0, currentCount - 1) : currentCount + 1 (ensure
currentCount is passed or tracked), keep the 300ms delay for realism, and return
the updated { topicId, liked, newCount } so optimistic updates behave
predictably.
In `@src/features/topics/topics.types.ts`:
- Around line 110-111: The JSDoc for the optional pageSize field is wrong:
update the comment for pageSize in topics.types.ts (and the duplicate JSDoc at
the second occurrence around line 127) to reflect the real default used in code
(PAGE_SIZES.TOPICS which is 5) instead of "기본값: 10"; ensure both JSDoc lines
mention "기본값: 5" to match the implementation in topics.api.ts.
🧹 Nitpick comments (14)
src/shared/ui/LikeButton.tsx (1)
52-57:isPending상태에서 시각적 피드백이 없습니다.
disabled일 때는border-transparent opacity-100이 적용되지만,isPending만 true인 경우 별도 시각적 변화가 없어 사용자가 버튼이 비활성 상태임을 인지하기 어렵습니다.Optimistic update 패턴에서 의도된 동작이라면 무시해도 됩니다. 만약 피드백이 필요하다면
isPending시opacity-70이나cursor-wait등을 고려해 보세요.src/features/topics/hooks/useInfiniteScroll.ts (1)
34-34:onLoadMore가 useEffect 의존성 배열에 포함되어 있어 불필요한 observer 재생성 가능성이 있습니다.호출부에서 인라인 함수로
onLoadMore를 전달하면 매 렌더마다 observer가 disconnect/reconnect됩니다.useCallback으로 감싸거나, 내부에서useRef로 최신 콜백을 저장하는 패턴을 사용하세요.제안: ref로 최신 콜백 안정화
-import { useEffect, useRef } from 'react' +import { useCallback, useEffect, useRef } from 'react' export function useInfiniteScroll({ hasNextPage, isFetchingNextPage, onLoadMore, }: UseInfiniteScrollOptions) { const observerRef = useRef<HTMLDivElement>(null) + const onLoadMoreRef = useRef(onLoadMore) + onLoadMoreRef.current = onLoadMore useEffect(() => { if (!observerRef.current || !hasNextPage || isFetchingNextPage) return const observer = new IntersectionObserver( (entries) => { if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) { - onLoadMore?.() + onLoadMoreRef.current?.() } }, { threshold: 0.1 } ) observer.observe(observerRef.current) return () => observer.disconnect() - }, [hasNextPage, isFetchingNextPage, onLoadMore]) + }, [hasNextPage, isFetchingNextPage]) return observerRef }src/features/topics/topics.mock.ts (1)
401-442: Mock 함수의 기본pageSize(10)가 API 레이어의 기본값(5)과 다릅니다.
getMockProposedTopics(pageSize = 10)과getMockConfirmedTopics(pageSize = 10)의 기본값이PAGE_SIZES.TOPICS(5)와 불일치합니다. 현재 API 레이어에서 항상 pageSize를 넘기므로 런타임 영향은 없지만, 직접 호출 시 혼동 가능성이 있습니다.제안: 기본값 일치
export const getMockProposedTopics = ( - pageSize: number = 10, + pageSize: number = 5, cursorLikeCount?: number, cursorTopicId?: number ): GetProposedTopicsResponse => {
getMockConfirmedTopics에도 동일하게 적용하세요.src/features/topics/topics.types.ts (1)
31-82:ProposedTopicItem과ConfirmedTopicItem의 공통 필드가 많습니다.
topicId,title,description,topicType,topicTypeLabel,likeCount,createdByInfo등 7개 필드가 중복됩니다. 향후 필드 변경 시 양쪽을 모두 수정해야 하므로, 공통 base type 추출을 고려해 볼 수 있습니다.현재 규모에서는 급하지 않으나, 타입이 더 늘어날 경우 유지보수에 도움이 됩니다.
예시: 공통 base type 추출
type TopicItemBase = { topicId: number title: string description: string topicType: TopicType topicTypeLabel: string likeCount: number createdByInfo: { userId: number nickname: string } } export type ProposedTopicItem = TopicItemBase & { meetingId: number topicStatus: TopicStatus isLiked: boolean canDelete: boolean } export type ConfirmedTopicItem = TopicItemBase & { confirmOrder: number }src/features/topics/components/DefaultTopicCard.tsx (1)
13-15:justify-between이 자식 요소 하나에만 적용되어 효과가 없습니다.현재
<div className="flex justify-between items-end">안에<p>하나만 있어justify-between이 의미 없습니다. 향후 오른쪽에 요소가 추가될 예정이라면 무시해도 됩니다.src/features/topics/components/TopicHeader.tsx (1)
79-79:Check아이콘의size가 문자열로 전달되고 있습니다.
lucide-react의sizeprop은number | string이지만 숫자가 관례적입니다.-<Check size="20" /> +<Check size={20} />src/features/topics/hooks/useLikeTopic.ts (1)
70-72:likeCount가 0일 때 unlike하면 음수가 됩니다.데이터 불일치 시
likeCount - 1이 음수가 될 수 있습니다. 방어 코드를 추가하면 안전합니다.-likeCount: newIsLiked ? topic.likeCount + 1 : topic.likeCount - 1, +likeCount: newIsLiked ? topic.likeCount + 1 : Math.max(0, topic.likeCount - 1),src/features/topics/hooks/useConfirmedTopics.ts (1)
42-50:topicQueryKeys를 사용하지 않고 인라인 queryKey를 정의하고 있습니다.
topicQueryKeys모듈이 배럴에서 export되고 있지만 여기서는 사용하지 않습니다.useDeleteTopic,useLikeTopic등 mutation 훅에서도 동일한 키를 인라인으로 작성하고 있어, 키 불일치 시 캐시 무효화/낙관적 업데이트가 조용히 실패할 수 있습니다.중앙 관리 키를 일관되게 사용하면 이런 위험을 제거할 수 있습니다.
#!/bin/bash # topicQueryKeys가 어떻게 정의되어 있는지, 실제로 사용되는 곳이 있는지 확인 fd topicQueryKeys --type f --exec cat {} echo "---" rg -n "topicQueryKeys" --type tsAs per coding guidelines: "queryKey 안정성, enabled 조건, select 비용, invalidate/refetch 타이밍을 중점적으로 봐줘."
src/features/topics/components/TopicCard.tsx (1)
6-18:gatheringId,meetingId,topicId가 optional이면 mutation 호출 시점에 매번 방어 코드가 필요합니다.현재
handleDelete와handleLike에서 각각 falsy 체크를 하고 있어 동작은 정상이지만, 이 세 값이 없으면 카드가 사실상 상호작용 불가 상태입니다. 호출부에서required로 보장하거나, 별도의 "읽기 전용" variant를 분리하는 것도 고려할 수 있습니다.src/features/topics/index.ts (1)
13-13:topics.mock를 feature 배럴에서 re-export하고 있습니다.
topics.api.ts에서 이미 mock 함수를 직접 import하고 있어 번들 포함은 불가피하지만, 배럴에서 public으로 export하면 다른 feature에서 실수로 mock을 가져다 쓸 수 있습니다. mock 제거 시점에 배럴에서도 함께 제거하는 것을 잊지 마세요.src/pages/Meetings/MeetingDetailPage.tsx (3)
32-56: 두 탭의 무한 쿼리가 동시에 실행됩니다.
activeTab상태와 무관하게useProposedTopics와useConfirmedTopics가 컴포넌트 마운트 시 동시에 호출됩니다. 비활성 탭 데이터까지 즉시 fetch하면 불필요한 네트워크 요청이 발생합니다.각 훅의 호출부에서
enabled조건으로 활성 탭을 추가하면 비활성 탭의 초기 요청을 지연시킬 수 있습니다. 예를 들어useProposedTopics에enabled옵션을 전달하거나, 훅 내부에서 추가 조건을 받을 수 있도록 확장하는 방안을 고려해 보세요.
59-66: 에러 처리에alert()사용은 UX 측면에서 개선 여지가 있습니다.
alert()는 브라우저 메인 스레드를 블로킹하고, 두 에러가 동시에 발생하면alert가 연속으로 표시됩니다. Toast/Snackbar 등 비차단형 알림으로 전환하면 사용자 경험이 개선됩니다. 당장은 아니더라도 추후 개선 항목으로 남겨두시면 좋겠습니다.
138-163: 각TabsContent내부 구조가 거의 동일합니다 — 공통 추출 가능성 검토.두 탭(
PROPOSED,CONFIRMED)의 렌더링 패턴(로딩 → TopicHeader → TopicList)이 거의 동일합니다. 공통 래퍼 컴포넌트나 헬퍼 함수로 추출하면 반복을 줄이고 유지보수가 쉬워집니다. 당장 필수는 아니지만 탭이 추가되거나 로직이 변경될 때 유리합니다.Also applies to: 166-190
src/features/topics/components/ConfirmedTopicList.tsx (1)
16-81:ProposedTopicList와 구조가 거의 동일합니다 — 공통 추출 고려.가상화 컨테이너, 스켈레톤, 무한 스크롤 트리거 로직이
ProposedTopicList와 사실상 동일합니다. 공통VirtualTopicList베이스 컴포넌트로 추출하고 차이점(DefaultTopicCard, EmptyTopicList, TopicCard props)만 주입하면 중복을 크게 줄일 수 있습니다. 급하지는 않으니 후속 리팩터링으로 고려해 주세요.
TanStack Virtual을 사용한 가상화 기능을 제거하고 일반 리스트 렌더링으로 변경. 무한 스크롤 훅은 shared/hooks로 이동하여 재사용성 향상. 좋아요 낙관적 업데이트 로직을 개선하여 모든 관련 쿼리에 일관되게 적용. Query Key 관리를 topicQueryKeys로 통일하여 유지보수성 향상.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@src/features/topics/components/ConfirmedTopicList.tsx`:
- Around line 29-31: ConfirmedTopicList currently early-returns <EmptyTopicList
/> when topics.length === 0 which prevents the infinite-scroll sentinel from
mounting in the edge case of first-page [] + hasNext: true; instead, don't early
return — render EmptyTopicList as part of the normal tree and always render the
sentinel element that uses the ref from useInfiniteScroll (or attach the hook's
ref into EmptyTopicList) so the sentinel mounts even for empty topics; update
ConfirmedTopicList to render the sentinel div with the hook's ref (from
useInfiniteScroll) alongside or inside EmptyTopicList and keep the existing
hasNext logic intact.
🧹 Nitpick comments (2)
src/features/topics/hooks/useDeleteTopic.ts (1)
41-41:mutationFn래핑 함수 제거 가능
(params: DeleteTopicParams) => deleteTopic(params)→deleteTopic로 직접 전달하면 불필요한 래핑을 줄일 수 있습니다.♻️ 수정 제안
return useMutation<void, ApiError, DeleteTopicParams>({ - mutationFn: (params: DeleteTopicParams) => deleteTopic(params), + mutationFn: deleteTopic, onSuccess: (_, variables) => {src/features/topics/hooks/useLikeTopic.ts (1)
59-62:data!비-널 단언이.filter()전에 적용되어 타입 안전성이 깨집니다.
getQueriesData는data가undefined일 수 있습니다. 현재 코드는data!로 먼저 단언한 뒤.filter()로 걸러내는데, TypeScript 관점에서 이미 non-null로 캐스팅되어 필터의 타입 가드 효과가 사라집니다.→ 런타임에서는 동작하지만,
.filter()를 먼저 적용하면 타입도 안전해집니다.♻️ filter-first 방식으로 변경
const previousQueries = queryClient .getQueriesData<InfiniteData<GetProposedTopicsResponse>>({ queryKey: baseQueryKey }) - .map(([queryKey, data]) => ({ queryKey, data: data! })) - .filter((query) => query.data !== undefined) + .filter((entry): entry is [readonly unknown[], InfiniteData<GetProposedTopicsResponse>] => entry[1] !== undefined) + .map(([queryKey, data]) => ({ queryKey, data }))
🚀 풀 리퀘스트 제안
📋 작업 내용
🔧 변경 사항
컴포넌트
TopicHeader: 주제 탭 전환 및 헤더 UIConfirmedTopicList/ProposedTopicList: 각 탭별 주제 목록TopicCard: 개별 주제 카드 (좋아요, 삭제 등 상호작용 포함)TopicListSkeleton: 로딩 상태 스켈레톤EmptyTopicList: 빈 상태 UI데이터 레이어
useConfirmedTopics/useProposedTopics: 무한 스크롤 쿼리useLikeTopic/useDeleteTopic: 뮤테이션 훅성능 최적화
topicQueryKeys)기타
LikeButton컴포넌트 개선📸 스크린샷 (선택 사항)
Summary by CodeRabbit
새로운 기능
개선사항