근거 PRD: /Users/biuea/Desktop/dpdpdndn/프로젝트/채팅 시스템/20260704-채팅시스템고도화-prd.md
근거 BE TDD: /Users/biuea/Desktop/dpdpdndn/프로젝트/채팅 시스템/20260704-채팅시스템고도화-tdd.md (REST API 계약·STOMP 계약·인터페이스 시그니처를 API 소비 근거로 사용)
mobile/는 Expo Router 기반 React Native 앱입니다. 현재 채팅은 REST 폴링 기반 방 목록·메시지 조회만 있고 실시간·읽음·안읽은 수·타이핑·커뮤니티(동아리)·게스트 초대가 전부 없습니다. 본 문서는 이 5개 축을 앱에 추가하는 FE 설계를 확정합니다.
Overview
무엇을: ① STOMP 실시간 송수신 훅, ② 읽음 표시·안읽은 수 배지·타이핑, ③ 커뮤니티(동아리) 개설·가입·멤버·역할 화면, ④ 게스트 초대(발송/수락/거절/만료 방출 UX), ⑤ goods 상품 상세 “채팅하기” 진입.
왜: 폴링의 지연·배터리 문제 해소, 동아리 단위 소통, 임시 참여 UX, 채팅 인프라 도메인 재사용.
어떻게: 실시간은 useChatSocket 훅으로 캡슐화(컴포넌트는 소켓을 직접 다루지 않음), 서버 상태는 TanStack Query, 실시간 이벤트는 Query 캐시에 병합. 테마 토큰 시스템을 신설해 라이트/다크 전 화면 지원. 신규 커뮤니티(동아리) 도메인은 기존 post 게시판(app/community/*)과의 라우트 충돌을 피해 app/communities/(복수형)로 배치.
Terminology
용어
정의
STOMP
Simple Text Oriented Messaging Protocol. WebSocket 위 pub/sub 서브프로토콜. FE는 @stomp/stompjs Client로 소비
ChatMessage
FE 내부 정규화 메시지 타입. REST MessageResponse와 STOMP BroadcastMessage를 하나로 병합한 형태
Read Cursor
참여자별 마지막으로 읽은 메시지 id. 안읽은 수·읽음 표시 계산 기준
Unread Badge
방별·전체 안읽은 메시지 수 배지
Backfill
WebSocket 재연결 후 끊긴 구간 메시지를 REST로 채우는 보정
Guest 방출
게스트 만료·수동 방출 시 방 접근 차단(REST 403). FE는 방출 화면으로 폴백
테마 토큰
시맨틱 색 토큰(background/text-primary 등). 라이트/다크 값 매핑이 SSOT
Community(동아리)
신규 커뮤니티 도메인. 기존 post 게시판(커뮤니티 탭)과 다른 개념
Define Problem
AS-IS (실제 코드 근거)
테마·다크모드 인프라 0건: theme/ 디렉토리 없음. useColorScheme/useTheme 사용처 0건. 모든 화면이 색을 하드코딩 — app/rooms/[id].tsx:135-224는 #fff·#007AFF·#F2F2F7·#1C1C1E·#8E8E93를 StyleSheet에 직접 박아 라이트 모드만 존재. private-fe-convention의 no-hardcoded-color·no-single-mode 전면 위반 상태. → 테마 토큰 시스템 신설이 모든 화면 티켓의 선행 병목.
실시간 계층 0건: package.json에 @stomp/stompjs·sockjs·WebSocket 관련 의존성 없음(@tanstack/react-query·axios·zustand·react-native-mmkv·@react-native-community/netinfo는 존재). 채팅은 api/room.ts#listMessages(REST 커서)로만 조회. lib/useRooms.ts#useSendMessage는 REST POST 후 invalidateQueries로 갱신 — 실시간 수신 없음.
커뮤니티(동아리) 도메인 없음, 이름 충돌 존재: app/(tabs)/community.tsx·app/community/[id].tsx·app/community/new.tsx는 post 게시판을 렌더(usePosts/usePost 사용). 동아리 개념이 아니며, api/community*·lib/useCommunit* 훅은 0건. → 신규 동아리 화면은 app/communities/(복수형)로 분리해 라우트·파일 충돌 회피.
게스트 초대 없음: 관련 코드 0건.
상품 상세에 채팅 진입 없음: app/product/[id]/index.tsx는 “장바구니 담기”·“장바구니 보기” CTA만 있고 “채팅하기” 없음.
인증 흐름: api/be-client.ts가 Authorization: Bearer <accessToken>를 모든 REST에 자동 부착(accessToken은 lib/auth.ts Zustand 메모리). 401 시 refresh 재발급 후 1회 재시도. 현재 userId는 useMyProfile(GET /users/me, id) 또는 api/goods.ts#useCurrentUserId로 획득 가능.
네트워크 상태: @react-native-community/netinfo + components/OfflineBanner.tsx 존재 — 재연결 폴백 판단에 재사용 가능.
테스트: Jest + @testing-library/react-native (jest.config.js). 컴포넌트/훅 단위 테스트 컨벤션 존재(components/__tests__, api/__tests__, lib/__tests__).
TO-BE
theme/ 신설: 시맨틱 토큰 light/dark 매핑 + useTheme() 훅 + useColorScheme 기본 + 사용자 오버라이드(Zustand themeStore).
lib/useChatSocket.ts 신설: @stomp/stompjs Client를 훅으로 캡슐화. 구독/발화/타이핑/읽음 + 지수 백오프 재연결 + 3회 실패 시 REST 폴백 신호.
방 목록·방 화면 재작성: 실시간 수신·안읽은 수·읽음·타이핑·재연결/백필·게스트 방출 처리 + 테마 토큰.
app/communities/* 신설: 동아리 개설/목록/상세(가입·멤버·역할).
게스트 초대 화면 신설: 발송(app/invite/[roomId].tsx) + 수신함 수락/거절(app/invitations/index.tsx).
app/product/[id]/index.tsx에 “채팅하기” CTA 추가(2단계 확인 → 방 생성/이동).
시스템 테마를 기본값으로, 사용자가 라이트/다크/시스템 선택 시 themeStore에 저장하고 MMKV로 영속
토스식 “시스템 따름 + 수동 선택” 표준. 테마는 정말 전역 → Zustand 정당
시스템 테마만 사용
useColorScheme만
사용자 수동 전환 불가 → 접근성·선호 미충족. 최소 구현이나 오버라이드 확장점 필요 → 오버라이드 포함 채택
방안 비교 — 재연결/백필
방안
설명
왜 채택 / 미채택
지수 백오프 재연결 + 3회 실패 시 REST 폴링 + 재구독 시 backfill (채택)
useChatSocket이 reconnectDelay를 지수 증가, 3회 실패 시 pollingFallback 플래그 노출 → 방 화면이 useMessages refetchInterval 활성화. 재연결 성공 시 마지막 수신 id 이후를 GET .../backfill로 채우고 dedup
S1. 채팅방 목록 (app/rooms/index.tsx) — 토스 패턴: 리스트 아이템 + 우측 원형 안읽은 배지, 헤더 우측 단일 액션.
┌───────────────────────────────┐
│ 채팅 [초대함]│ ← 헤더(초대 수신함 진입, 대기 초대 있으면 점 배지)
├───────────────────────────────┤
│ ⦿ 주말 축구 모임 (3) 14:20│ ← 커뮤니티방: 이름, 안읽은 배지(3), 최근시각
│ 김철수: 오늘 몇 시에 모여요? │ ← 최근 메시지 미리보기(textSecondary)
├───────────────────────────────┤
│ ⦿ 이영희 (1:1) 12:05│ ← DIRECT, 안읽은 0이면 배지 없음
│ 안녕하세요 상품 문의드려요 │
└───────────────────────────────┘
상태
UI
loading
리스트 스켈레톤 3개(surface 카드) + 헤더
empty
”참여 중인 채팅방이 없어요” + 안내 문구(surface, textSecondary). PRD 빈 상태 시나리오
error
ErrorView + “다시 시도” (accent 텍스트 버튼) → refetch
success
방 카드 리스트(이름·미리보기·최근시각·안읽은 배지). pull-to-refresh
S2. 채팅방(실시간) (app/rooms/[id].tsx) — 토스 패턴: 한 화면 한 과업(대화), 하단 단일 입력 CTA, 내/상대 말풍선 색 대비 최소.
┌───────────────────────────────┐
│ ‹ 주말 축구 모임 [초대] │ ← 헤더(뒤로, 방 이름, 방장이면 게스트 초대 진입)
│ ⚠ 연결 끊김 — 재연결 중… │ ← (조건) 재연결 배너 / 폴링 폴백 배너
│ ⏳ 게스트 참여 만료: D-2 │ ← (조건, 본인 게스트) 만료 안내 배너
├───────────────────────────────┤
│ 오늘 14:20 │
│ [상대] 오늘 몇 시에 모여요? │ ← bubbleOther
│ │
│ 내 메시지입니다 [읽음] 14:21 │ ← bubbleMine, 우측정렬, 읽음 표시
│ 상대가 입력 중… │ ← (조건) 타이핑 인디케이터
├───────────────────────────────┤
│ [메시지 입력…] [전송] │ ← 단일 입력 + 전송(발화권한 없으면 비활성+안내)
└───────────────────────────────┘
상태
UI
loading
메시지 스켈레톤 + 소켓 연결 중 표시(비차단)
empty
”첫 메시지를 보내보세요” (textSecondary)
error
메시지 로드 실패 ErrorView + 재시도. 소켓 실패는 배너로 별도 표시(화면은 REST로 동작)
success
메시지 리스트(내/상대 말풍선, 읽음, 시각), 실시간 append, 타이핑/읽음 이벤트 반영
게스트 방출(403)
“참여 기간이 만료되어 대화를 볼 수 없어요” 전체 화면 + “목록으로”
읽기전용 게스트
입력창 비활성 + “읽기 전용으로 초대되었어요”
S3. 커뮤니티 목록 (app/communities/index.tsx) — 토스 패턴: 검색 + 카드 리스트, 하단 플로팅 단일 CTA(개설).
┌───────────────────────────────┐
│ 동아리 │
│ [🔍 종목·이름 검색 ] │
├───────────────────────────────┤
│ ⚽ 주말 축구 모임 공개 32명 │
│ 동네 축구 같이 해요 │
│ 🏀 새벽 농구 비공개 승인제 │
├───────────────────────────────┤
│ ( + 개설 ) │ ← 플로팅 단일 CTA
└───────────────────────────────┘
상태
UI
loading
카드 스켈레톤 3개
empty
”아직 동아리가 없어요. 첫 동아리를 만들어보세요” + 개설 CTA
error
ErrorView + 재시도
success
커뮤니티 카드(종목 이모지·이름·공개여부·멤버수·설명)
S4. 커뮤니티 개설 (app/communities/new.tsx) — 토스 패턴: 한 화면 한 과업 폼, 하단 고정 단일 CTA, 유효성 인라인.
┌───────────────────────────────┐
│ ‹ 동아리 개설 │
│ 이름 [ ] │
│ 설명 [ ] │
│ 종목 [축구 ▾] │ ← sportCategory 선택
│ 공개 ( 공개 ) ( 비공개 ) │ ← visibility 세그먼트
├───────────────────────────────┤
│ [ 개설하기 ] │ ← 하단 고정, 유효 시에만 활성
└───────────────────────────────┘
상태
UI
loading
(제출 중) CTA 스피너 + 폼 비활성
empty
해당 없음(입력 폼)
error
제출 실패 토스트/인라인 + CTA 복구
success
개설 완료 → 상세(S5)로 이동, 방 목록 캐시 무효화(전용방 자동 생성 반영)
S5. 커뮤니티 상세(가입·멤버·역할) (app/communities/[id].tsx) — 토스 패턴: 상단 요약 + 명확한 단일 주요 CTA(가입/채팅 입장), 멤버는 하위 섹션.
방장: 강퇴/위임 노출. 멤버: 탈퇴. 게스트/비멤버: 멤버 목록 접근 제한(FR-13) 안내
S6. 게스트 초대 발송 (app/invite/[roomId].tsx) — 토스 패턴: 단일 과업(초대), 하단 단일 CTA.
┌───────────────────────────────┐
│ ‹ 게스트 초대 │
│ 대상 사용자 [사용자 ID 입력 ] │ ← inviteeUserId (정회원 userId)
│ 발화 권한 ( 발화 가능 )( 읽기전용 )│ ← canSpeak
│ 참여 기간 [7]일 │ ← expiresInDays
├───────────────────────────────┤
│ [ 초대 보내기 ] │
└───────────────────────────────┘
상태
UI
loading
(제출) CTA 스피너
empty
해당 없음
error
”이미 대기 중인 초대가 있어요” 등 인라인(멱등 응답 처리)
success
”초대를 보냈어요” → 방으로 복귀
권한 없음
방장 아님 → 화면 진입 차단/안내(초대 진입 버튼은 방장에게만 노출)
S7. 초대 수신함(수락/거절) (app/invitations/index.tsx) — 토스 패턴: 리스트 아이템당 명확한 2선택(수락/거절).
┌───────────────────────────────┐
│ ‹ 받은 초대 │
│ 주말 축구 모임 · 발화 가능 · D-7 │
│ 초대자: 김철수 │
│ [거절] [수락] │
├───────────────────────────────┤
│ (다른 초대…) │
└───────────────────────────────┘
상태
UI
loading
리스트 스켈레톤
empty
”받은 초대가 없어요”
error
ErrorView + 재시도
success
초대 카드 리스트. 수락 → 방 목록·해당 방 갱신 후 방 이동, 거절 → 카드 제거
S8. 상품 상세 “채팅하기” (app/product/[id]/index.tsx) — 토스 패턴: 기존 화면에 accent 보조 CTA 1개 추가(주요 CTA는 장바구니 유지).
│ … 상품 정보 … │
│ [ 판매자와 채팅하기 ] │ ← 신규 보조 CTA (본인 상품이면 숨김)
│ [ 장바구니 담기 ] [ 장바구니 ] │ ← 기존 유지
2단계: “채팅하기” 탭 → 확인 시트(“판매자와 1:1 채팅을 시작할까요?”) → POST /products/{id}/chat → 방(S2)으로 이동(기존 방 있으면 이동).
상태
UI
loading
(생성 중) CTA 스피너
empty
해당 없음
error
”채팅을 시작하지 못했어요” 토스트
success
방 화면(S2) 이동
본인 상품
CTA 숨김(Product.ownerId == 내 userId)
테마 토큰 정의 표 (시맨틱 토큰 → 라이트/다크 값)
theme/tokens.ts의 SSOT. 하드코딩 금지, 전 화면 이 토큰만 사용.
시맨틱 토큰
라이트
다크
용도
background
#FFFFFF
#17171C
화면 배경
surface
#F9FAFB
#202027
카드·리스트 아이템 배경
surfaceElevated
#FFFFFF
#26262E
입력창·시트
textPrimary
#191F28
#F2F4F6
본문·제목
textSecondary
#4E5968
#B0B8C1
미리보기·메타
textTertiary
#8B95A1
#6B7684
시각·placeholder
border
#E5E8EB
#2E2E36
구분선·테두리
accent
#3182F6
#4E93FB
주요 CTA·활성 탭(화면당 1곳)
accentText
#FFFFFF
#FFFFFF
accent 위 텍스트
bubbleMine
#3182F6
#3B5BDB
내 말풍선
bubbleMineText
#FFFFFF
#F2F4F6
내 말풍선 텍스트
bubbleOther
#F2F4F6
#2E2E36
상대 말풍선
bubbleOtherText
#191F28
#F2F4F6
상대 말풍선 텍스트
badge
#F04452
#F76A78
안읽은 배지 배경
badgeText
#FFFFFF
#FFFFFF
배지 텍스트
success
#12B886
#2AC29B
읽음·온라인
danger
#F04452
#F76A78
오류·강퇴·거절
overlay
rgba(0,0,0,0.4)
rgba(0,0,0,0.6)
시트 딤
typing
#8B95A1
#6B7684
타이핑 인디케이터
웹(web/)은 Tailwind + CSS 변수 관례가 있으나, 본 문서는 앱 전용. 앱은 useTheme()가 위 토큰 객체(light/dark)를 반환, StyleSheet를 함수형으로 생성(createStyles(theme)).
전환: useColorScheme() 기본 + themeStore.mode('system'|'light'|'dark') 오버라이드(MMKV 영속).
수신 BroadcastMessage를 ChatMessage로 정규화해 캐시 append / 미연결 시 send가 REST 폴백 / 3회 실패 시 pollingFallback true / 재연결 시 backfill 호출
useUnreadCounts/useMarkRead
훅 단위
안읽은 수 매핑 / read 후 해당 방 배지 0 / 내가 보낸 메시지는 미증가
useCommunity*/useInvitations
훅 단위
목록 매핑 / 가입 공개=ACTIVE·비공개=PENDING / 초대 수락 후 방 캐시 무효화
S1 방목록
컴포넌트
안읽은 배지 렌더 / empty·error 렌더 / 아이템 탭 시 이동
S2 방화면
컴포넌트
내/상대 말풍선 정렬 / 실시간 수신 append / 타이핑 인디케이터 표시·사라짐 / 읽기전용 게스트 입력 비활성 / 게스트 방출(403) 폴백 / 재연결 배너
S5 커뮤니티 상세
컴포넌트
방장 강퇴/위임 노출·멤버 미노출 / 비공개 가입 승인대기 표시
S8 상품 채팅
컴포넌트
CTA 탭 → 확인 → 방 이동 / 본인 상품 CTA 숨김
Testing Library 사용자 관점 검증. snapshot 동작검증 금지. 해피·실패·엣지 각 화면 최소 3케이스.
확인 필요 (역제안 API 계약 — BE TDD에 미정의)
FE가 소비해야 하나 BE REST 계약에 없는 항목. private-senior-be에 역제안:
방 목록 미리보기 필드 — RoomResponse에 lastMessagePreview: string|null, lastMessageAt: string|null, contextType/contextId, name(커뮤니티명) 추가. 없으면 미리보기 없이 이름·안읽은 수만 표시(degraded).
커뮤니티 조회 REST — GET /communities?keyword=(목록/검색, repo findPublicByKeyword 대응), GET /communities/{id}(상세), GET /communities/{id}/members(멤버 목록), GET /communities/me(내 커뮤니티). 현재 계약은 write-op만 존재.
초대 수신함 REST — GET /rooms/invitations/me(내가 받은 PENDING 초대 목록). accept/reject만 있고 목록 조회 없음.
인증 헤더 정합 — BE REST 계약이 X-User-Id 헤더(AUTH-04 TODO) 전제이나 앱 be-client.ts는 Authorization: Bearer만 부착. 신규 엔드포인트가 서버에서 userId를 어떻게 식별하는지 확정 필요(Bearer로 통일 권장).
응답 DTO 스키마 상세 — CommunityResponse/CommunityMemberResponse/InvitationResponse/MembershipResponse/UnreadResponse의 필드명·타입 표. FE api/*-types.ts가 이 표와 1:1 일치해야 함(현재 계약은 이름만 명시).
Document History
날짜
변경 내용
2026-07-04
최초 작성 — mobile AS-IS(테마 부재·실시간 부재·community 라우트 충돌) 근거. 화면 8개·테마 토큰·상태관리·API 연동(BE 계약)·라우팅·기능 플래그·역제안 5건.