그룹채팅 구현 및 로그인 적용 - #16
Conversation
📝 WalkthroughWalkthrough그룹 채팅 기능 추가: 그룹 메타 조회와 페이징된 메시지 API/검증, 그룹 전용 Zustand 저장소, STOMP 소켓 훅과 GroupChatRoom UI가 추가되었고, 글로벌 채팅은 페이징·무한스크롤 리팩터링 및 JWT 기반 사용자 ID 추출로 전환되었습니다. (≤50단어) Changes
Sequence DiagramsequenceDiagram
participant User as 사용자
participant UI as GroupChatRoom
participant Store as GroupChatStore
participant API as API 클라이언트
participant Socket as STOMP 클라이언트
participant Server as 채팅 서버
User->>UI: 컴포넌트 마운트 / 입력
UI->>API: fetchGroupChatMeta()
API->>Server: GET /chat/group/me
Server-->>API: group meta
API-->>UI: GroupChatMeta
UI->>API: fetchGroupChatMessages()
API->>Server: GET /chat/group/messages?size=...
Server-->>API: messages page
API-->>UI: GroupChatMessagesPage
UI->>Store: setInitialPage(page)
User->>UI: 메시지 전송
UI->>Store: sendMessage(client, content)
Store->>Socket: PUBLISH /app/chat/group/send
Socket->>Server: 전송
Server->>Socket: BROADCAST /topic/chat/group/{groupId}
Socket-->>Store: FRAME 수신
Store->>Store: appendMessage(validatedMessage)
User->>UI: 최상단 스크롤(이전 메시지 로드)
UI->>Store: loadOlderMessages()
Store->>API: fetchGroupChatMessages(beforeMessageId=nextCursor)
API->>Server: GET older page
Server-->>API: older page
API-->>Store: Older page
Store->>Store: mergeOlderMessages(중복 제거 및 상태 갱신)
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Possibly related PRs
Suggested labels
Suggested reviewers
Poem
🚥 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 docstrings
🧪 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: 12
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/features/chat/model/global-chat-store.ts`:
- Around line 120-138: loadOlderMessages can apply a late/stale response after
resetConnectionState; before calling fetchGlobalChatMessages(nextCursor) capture
a current session/version/token (the same value resetConnectionState updates in
use-global-chat-boot.ts) or create an AbortController, pass it into
fetchGlobalChatMessages, and after the await verify the session/version/token
(or that the request was not aborted) before calling set to update
messages/nextCursor/hasNext; if it doesn't match (stale) simply ignore the
response so logout/unmount won’t resurrect old session state.
- Around line 68-73: setInitialPage currently overwrites the whole messages
array and can drop messages appended by appendMessage during parallel boot;
change setInitialPage to merge page.messages into the existing state.messages
instead of replacing them: inside setInitialPage (the setter passed to set) read
the current messages from state, combine/merge page.messages with current
messages while deduplicating by message id (or timestamp if id absent) and
preserving chronological order, and then update nextCursor and hasNext as
before; ensure appendMessage and any consumers still work with the merged array.
- Around line 4-8: The import path is incorrect and should point to the module
that exports fetchGlobalChatMessages, GlobalChatMessage and
GlobalChatMessagesPage; update the import in global-chat-store.ts to remove the
stray "global" segment so it imports from the fetch-global-chat-messages module
in the chat api (i.e., reference the module that actually exports
fetchGlobalChatMessages, GlobalChatMessage, GlobalChatMessagesPage), then run
the TypeScript build to verify TS2307 is resolved.
In `@src/features/chat/model/group/use-group-chat-socket.ts`:
- Line 8: The import path for useGlobalChatStore is wrong; update the import in
use-group-chat-socket.ts to the correct module
"@/features/chat/model/global-chat-store" (so the symbol useGlobalChatStore
resolves) to restore the selector usage in useGroupChatSocket (references at the
useGlobalChatStore call around lines 20–21) and to ensure the client variable
used later in the socket handlers (client usages around lines ~87 and ~105) is
imported correctly.
- Around line 73-139: The effect that creates subscriptions should depend on
globalConnected so it re-runs after connection completes; update the guard from
checking client?.connected to checking globalConnected (e.g., use if
(!globalConnected || !client || !groupMeta?.groupId) to early-return) and add
globalConnected to the useEffect dependency array along with appendMessage,
client, groupMeta?.groupId, setErrorMessage so that subscriptions to
`/topic/chat/group/${groupMeta.groupId}` and `/user/queue/errors` are created
when client.onConnect flips globalConnected to true; keep existing unsubscribes
using messageSubscriptionRef and errorSubscriptionRef and retain errorTimeoutRef
cleanup.
In `@src/features/chat/model/use-global-chat-boot.ts`:
- Around line 37-41: The effect reads accessToken from localStorage once and
only depends on userId, causing STOMP client to keep using an expired token;
update the useEffect(s) in use-global-chat-boot.ts (the blocks that call
localStorage.getItem('accessToken') and check hasBootstrappedRef.current/userId)
to either include accessToken in the dependency array or subscribe to the
'auth-changed' event so the effect re-runs on token refresh; ensure the code
paths that create/configure the STOMP client and set the Authorization header
(referenced in the same effect and the other occurrences around lines 68-71 and
157-166) read the updated token when the effect re-triggers and clean up the
previous client via the existing teardown logic.
In `@src/features/chat/ui/global-chat-room.tsx`:
- Around line 55-70: The restore logic in useLayoutEffect relies on whether
older messages were actually prepended, but loadOlderMessages() swallows
failures/no-ops so the refs (shouldRestoreScrollRef, previousHeightRef) can
remain set and cause a jump when later messages render; update either the store
method loadOlderMessages() to return a boolean indicating success (true when
messages were prepended) and use that return value to decide whether to set
shouldRestoreScrollRef/previousHeightRef, or immediately clear
shouldRestoreScrollRef.current and previousHeightRef.current when
loadOlderMessages() fails/no-ops (ensure you update the callers of
loadOlderMessages() and the useLayoutEffect condition around
shouldRestoreScrollRef/previousHeightRef accordingly).
- Line 4: Import for useGlobalChatStore is using an extra "global/" segment;
update the import to point to the actual module by replacing
"@/features/chat/model/global/global-chat-store" with
"@/features/chat/model/global-chat-store" where useGlobalChatStore is imported
(also check and fix the analogous wrong imports in files that import the group
store and use-group-chat-socket so they reference
"@/features/chat/model/group-chat-store" or
"@/features/chat/model/use-group-chat-socket" as appropriate).
In `@src/features/chat/ui/group-chat-room.tsx`:
- Around line 232-236: The onKeyDown handler in the GroupChatRoom input sends
messages even when IME composition is active; update the handler (the input in
the GroupChatRoom component that currently calls handleSendMessage on Enter) to
first check event.nativeEvent.isComposing and return/skip if true, then only
call handleSendMessage when !isComposing and event.key === 'Enter' so Enter
during Korean/Japanese/Chinese composition does not prematurely submit.
- Around line 148-159: The list item keys use `${msg.messageId}-${index}`, which
causes key churn when older messages are prepended (see loadOlderMessages) and
forces unnecessary remounts; update the rendering in the GroupChatRoom message
map to use only the stable `msg.messageId` as the React key (remove `-
${index}`) for both the centered system message branch and the regular message
branch so keys remain stable after prepend operations.
- Line 4: The import uses a wrong module path
('@/features/chat/model/global/global-chat-store') which doesn't exist; update
the import in the three affected files to reference the actual module
('@/features/chat/model/global-chat-store') so that the useGlobalChatStore
import resolves correctly (search for useGlobalChatStore or the bad import
string in group-chat-room.tsx, global-chat-room.tsx, and
use-group-chat-socket.ts and replace the path).
- Around line 240-244: The button's disabled logic must match the actual send
condition used in group-chat-store.sendMessage (which checks client?.connected);
update the UI to disable the send button when the STOMP client isn't actually
connected by referencing the store's real client connection flag (e.g., expose
or derive clientConnected from the group chat store or read
store.client?.connected) and use that in the button's disabled prop (alongside
!message.trim()), ensuring handleSendMessage and sendMessage will only be
callable when both message.trim() and the actual clientConnected are true.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 34e4a6ca-5bea-42c1-86e4-842d9bab722c
📒 Files selected for processing (11)
src/features/chat/api/fetch-global-chat-messages.tssrc/features/chat/api/group/fetch-group-chat-messages.tssrc/features/chat/api/group/fetch-group-chat-meta.tssrc/features/chat/model/global-chat-store.tssrc/features/chat/model/global/use-demo-user-id.tssrc/features/chat/model/group/group-chat-store.tssrc/features/chat/model/group/use-group-chat-socket.tssrc/features/chat/model/use-global-chat-boot.tssrc/features/chat/model/use-global-chat-messages-query.tssrc/features/chat/ui/global-chat-room.tsxsrc/features/chat/ui/group-chat-room.tsx
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (3)
src/features/chat/ui/group-chat-room.tsx (1)
137-139: 상태 표시와 실제 전송 조건 간 불일치 가능성
global-chat-room.tsx와 동일하게, 연결 상태 표시는 group store의connected를 사용하지만 전송 버튼은client?.connected를 사용합니다. 일관성을 위해 표시도client?.connected를 사용하는 것을 고려해 보세요.♻️ 수정 예시
<div className="text-sm font-medium text-gray-500"> - {connected ? 'Connected' : 'Connecting...'} + {client?.connected ? 'Connected' : 'Connecting...'} </div>🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/features/chat/ui/group-chat-room.tsx` around lines 137 - 139, The UI shows connection status using the group store's connected variable but the send button checks client?.connected, creating a potential mismatch; update the status display in group-chat-room.tsx to use client?.connected (the same signal used by the send action) or vice versa so both the div with {connected ? 'Connected' : 'Connecting...'} and the send button reference the same source (client?.connected or connected) to keep display and send eligibility consistent.src/features/chat/ui/global-chat-room.tsx (1)
128-131: 상태 표시와 실제 전송 조건 간 불일치 가능성Line 129의 연결 상태 표시는 store의
connected상태를 사용하지만, Line 87과 217의 실제 전송 로직은client?.connected를 사용합니다. 동기화 타이밍 차이로 인해 "Connected"가 표시되지만 전송 버튼은 비활성화된 상태가 잠시 발생할 수 있습니다.일관성을 위해 표시도
client?.connected를 사용하는 것을 고려해 보세요.♻️ 수정 예시
<div className="text-sm font-medium text-gray-500"> - {connected ? 'Connected' : 'Connecting...'} + {client?.connected ? 'Connected' : 'Connecting...'} </div>🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/features/chat/ui/global-chat-room.tsx` around lines 128 - 131, The displayed connection status uses the store value connected while send logic checks client?.connected, causing a transient mismatch; update the UI render to use client?.connected (or a unified getter that returns client?.connected) instead of the store connected so the status text ("Connected"/"Connecting...") matches the actual send-enabled condition used in sendMessage/send handlers (look for client?.connected checks near the send logic).src/features/chat/model/group/use-group-chat-socket.ts (1)
103-124: 에러 메시지 파싱에 스키마 검증 추가를 고려하세요.메시지 구독(Line 88)에서는
groupChatMessageSchema.safeParse()를 사용하지만, 에러 구독(Line 105)에서는 타입 단언(as ChatErrorMessage)만 사용합니다. 서버가 예상치 못한 형식을 보내면 런타임에서 undefined 접근 오류가 발생할 수 있습니다.♻️ 스키마 검증 적용 예시
import { z } from 'zod' const chatErrorMessageSchema = z.object({ code: z.string(), message: z.string(), remainingSeconds: z.number().optional() }) // Line 103-105 변경 const parsed = chatErrorMessageSchema.safeParse(JSON.parse(frame.body)) if (!parsed.success) { console.error('채팅 에러 메시지 파싱 실패') return } const error = parsed.data🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/features/chat/model/group/use-group-chat-socket.ts` around lines 103 - 124, The error subscription currently type-asserts the parsed frame body to ChatErrorMessage (errorSubscriptionRef handler) which can crash if the server sends unexpected shape; add a zod schema (e.g., chatErrorMessageSchema) and replace the direct cast with chatErrorMessageSchema.safeParse(JSON.parse(frame.body)), check parsed.success and bail with a console.error if it fails, then use parsed.data for code/message/remainingSeconds and keep the existing timeout/clear logic in the errorSubscriptionRef callback inside useGroupChatSocket.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/features/chat/model/group/group-chat-store.ts`:
- Around line 110-136: The loadOlderMessages function can overwrite newer
session state because it lacks a sessionVersion guard; before calling
fetchGroupChatMessages capture const { sessionVersion: sv } = get(), then after
the await verify get().sessionVersion === sv and if not, abort (return false)
without calling set; apply the same check in the catch path to avoid clearing
loadingOlder for a different session; reference loadOlderMessages,
fetchGroupChatMessages, get, set, sessionVersion, and mergeMessages when making
this change.
---
Nitpick comments:
In `@src/features/chat/model/group/use-group-chat-socket.ts`:
- Around line 103-124: The error subscription currently type-asserts the parsed
frame body to ChatErrorMessage (errorSubscriptionRef handler) which can crash if
the server sends unexpected shape; add a zod schema (e.g.,
chatErrorMessageSchema) and replace the direct cast with
chatErrorMessageSchema.safeParse(JSON.parse(frame.body)), check parsed.success
and bail with a console.error if it fails, then use parsed.data for
code/message/remainingSeconds and keep the existing timeout/clear logic in the
errorSubscriptionRef callback inside useGroupChatSocket.
In `@src/features/chat/ui/global-chat-room.tsx`:
- Around line 128-131: The displayed connection status uses the store value
connected while send logic checks client?.connected, causing a transient
mismatch; update the UI render to use client?.connected (or a unified getter
that returns client?.connected) instead of the store connected so the status
text ("Connected"/"Connecting...") matches the actual send-enabled condition
used in sendMessage/send handlers (look for client?.connected checks near the
send logic).
In `@src/features/chat/ui/group-chat-room.tsx`:
- Around line 137-139: The UI shows connection status using the group store's
connected variable but the send button checks client?.connected, creating a
potential mismatch; update the status display in group-chat-room.tsx to use
client?.connected (the same signal used by the send action) or vice versa so
both the div with {connected ? 'Connected' : 'Connecting...'} and the send
button reference the same source (client?.connected or connected) to keep
display and send eligibility consistent.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 3c9eb95c-fc2d-41a4-a08a-d0881b24d946
📒 Files selected for processing (6)
src/features/chat/model/global/global-chat-store.tssrc/features/chat/model/global/use-global-chat-boot.tssrc/features/chat/model/group/group-chat-store.tssrc/features/chat/model/group/use-group-chat-socket.tssrc/features/chat/ui/global-chat-room.tsxsrc/features/chat/ui/group-chat-room.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
- src/features/chat/model/global/use-global-chat-boot.ts
There was a problem hiding this comment.
🧹 Nitpick comments (1)
src/features/chat/model/group/use-group-chat-socket.ts (1)
105-126: 에러 메시지 파싱에 타입 검증이 누락되었습니다.메시지 구독(Line 90)에서는
groupChatMessageSchema.safeParse로 검증하지만, 에러 구독에서는as ChatErrorMessage타입 단언을 사용합니다. 서버에서 예상치 못한 형태의 응답이 올 경우 런타임 오류가 발생할 수 있습니다.zod 스키마를 사용한 검증 권장
+import { z } from 'zod' +const chatErrorMessageSchema = z.object({ + code: z.string(), + message: z.string(), + remainingSeconds: z.number().optional() +}) errorSubscriptionRef.current = client.subscribe('/user/queue/errors', (frame) => { try { - const error = JSON.parse(frame.body) as ChatErrorMessage + const parsed = chatErrorMessageSchema.safeParse(JSON.parse(frame.body)) + if (!parsed.success) { + console.error('에러 메시지 스키마 검증 실패') + return + } + const error = parsed.data const timeoutMs = (error.remainingSeconds ?? 3) * 1000🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/features/chat/model/group/use-group-chat-socket.ts` around lines 105 - 126, Replace the unchecked type assertion in the '/user/queue/errors' subscription by validating the parsed payload with the same zod schema approach used for group messages: parse JSON from frame.body, run ChatErrorMessageSchema.safeParse (or a properly named zod schema for ChatErrorMessage) and only use error.remainingSeconds, error.message, errorTimeoutRef, setErrorMessage and FALLBACK_ERROR_TIMEOUT_MS when validation succeeds; on validation failure log a clear parsing/validation error and bail out without touching errorTimeoutRef or setErrorMessage.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@src/features/chat/model/group/use-group-chat-socket.ts`:
- Around line 105-126: Replace the unchecked type assertion in the
'/user/queue/errors' subscription by validating the parsed payload with the same
zod schema approach used for group messages: parse JSON from frame.body, run
ChatErrorMessageSchema.safeParse (or a properly named zod schema for
ChatErrorMessage) and only use error.remainingSeconds, error.message,
errorTimeoutRef, setErrorMessage and FALLBACK_ERROR_TIMEOUT_MS when validation
succeeds; on validation failure log a clear parsing/validation error and bail
out without touching errorTimeoutRef or setErrorMessage.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: c83a8a40-74df-4426-a41b-f3e2209c2345
📒 Files selected for processing (8)
src/features/chat/api/global/fetch-global-chat-messages.tssrc/features/chat/api/group/fetch-group-chat-messages.tssrc/features/chat/model/global/global-chat-store.tssrc/features/chat/model/global/use-global-chat-boot.tssrc/features/chat/model/group/group-chat-store.tssrc/features/chat/model/group/use-group-chat-socket.tssrc/features/chat/ui/global-chat-room.tsxsrc/features/chat/ui/group-chat-room.tsx
✅ Files skipped from review due to trivial changes (1)
- src/features/chat/model/global/use-global-chat-boot.ts
🚧 Files skipped from review as they are similar to previous changes (3)
- src/features/chat/api/global/fetch-global-chat-messages.ts
- src/features/chat/api/group/fetch-group-chat-messages.ts
- src/features/chat/model/global/global-chat-store.ts
📌 PR 설명
그룹 채팅 UI를 구현하고, 전체/그룹 채팅에 과거 메시지 조회 페이지네이션을 적용했습니다.
또한 로그인 기능이 추가된 이후 채팅 기능이 실제 인증 사용자 기준으로 동작하도록 REST/STOMP 연결을 모두 토큰 기반으로 정리했습니다.
기존에는 데모용
userId와 임시 헤더 흐름이 남아 있었고, 채팅 조회도 첫 페이지 로딩까지만 동작하던 상태였습니다.이번 작업에서는 그룹 채팅 화면 구성, 답장 기능 UI, 로그인 기반 사용자 식별, 이전 메시지 불러오기, 도배 감지 배너 처리까지 함께 정리했습니다.
✅ 완료한 기능 명세
Authorization: Bearer <token>기반으로 수정💭 고민과 해결과정
이번 작업에서 가장 크게 신경 쓴 부분은 채팅 기능을 “임시 데모 흐름”에서 “로그인 사용자 기준 실제 동작 흐름”으로 바꾸는 것이었습니다.
기존에는 데모용
userId를 따로 받아오거나X-User-Id를 직접 넣는 코드가 남아 있었는데, 로그인 기능이 붙은 이후에는 채팅도 다른 API와 동일하게accessToken기준으로 동작해야 했습니다. 그래서 REST는 공통 API 클라이언트의Authorization헤더를 그대로 사용하고, STOMP CONNECT 역시Authorization: Bearer <token>방식으로 맞춰 사용자 식별 기준을 통일했습니다.페이지네이션은 백엔드에서 이미
messages,nextCursor,hasNext구조로 내려주도록 바뀌어 있었기 때문에, 프론트도 그 구조를 그대로 받아 초기 메시지와 이전 메시지 로딩 흐름을 분리해서 처리했습니다.처음 진입 시에는 최신 메시지 묶음을 가져오고, 스크롤이 상단에 닿으면
beforeMessageId=nextCursor기준으로 이전 메시지를 다시 요청하도록 구현했습니다.여기서 UX적으로 가장 중요했던 부분은 “이전 메시지를 불러올 때 화면이 갑자기 맨 아래로 내려가지 않게 하는 것”이었습니다.
메시지를 앞쪽에 붙이면 DOM 높이가 늘어나기 때문에, 별도 처리 없이 렌더링하면 사용자가 읽던 위치가 흔들리거나 다시 아래로 이동하게 됩니다. 이를 막기 위해 이전 메시지를 불러오기 전 스크롤 높이를 저장해두고, prepend 이후 증가한 높이만큼
scrollTop을 보정해 현재 읽던 위치를 유지하도록 처리했습니다.또 하나의 고민은 도배 감지 에러 UX였습니다.
처음에는 에러 메시지가 들어오면 채팅 화면 전체를 에러 화면으로 바꾸는 구조였는데, 실제 사용성 측면에서는 채팅창이 유지된 상태에서 배너만 잠깐 보여주는 편이 더 자연스럽다고 판단했습니다. 그래서 전역/그룹 채팅 모두 에러를 상단 배너로만 노출하고,
remainingSeconds가 있으면 그 시간 이후, 없으면 기본 시간 이후 자동으로 사라지도록 수정했습니다.이번 작업은 단순히 그룹 채팅 화면을 만드는 것보다,
그룹 채팅 UI 구현 + 로그인 기반 인증 정리 + 채팅 히스토리 페이지네이션 UX 정리를 함께 묶어 사용자 경험을 실제 서비스 흐름에 가깝게 다듬은 작업이었습니다.
Summary by CodeRabbit
릴리스 노트
New Features
Refactor
Chores