채팅 시스템 고도화 FE 설계 (React Native / Expo — mobile/)

Background

근거 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

용어정의
STOMPSimple Text Oriented Messaging Protocol. WebSocket 위 pub/sub 서브프로토콜. FE는 @stomp/stompjs Client로 소비
ChatMessageFE 내부 정규화 메시지 타입. REST MessageResponse와 STOMP BroadcastMessage를 하나로 병합한 형태
Read Cursor참여자별 마지막으로 읽은 메시지 id. 안읽은 수·읽음 표시 계산 기준
Unread Badge방별·전체 안읽은 메시지 수 배지
BackfillWebSocket 재연결 후 끊긴 구간 메시지를 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-conventionno-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로 갱신 — 실시간 수신 없음.
  • 읽음/안읽은 수/타이핑 없음: api/types.ts:214-238RoomResponse{id,type,name}·MessageResponse{id,roomId,senderId,content,sentAt}에 unread·readCursor·typing 필드 없음. 방 목록(app/rooms/index.tsx)은 이름·타입만 표시.
  • 커뮤니티(동아리) 도메인 없음, 이름 충돌 존재: app/(tabs)/community.tsx·app/community/[id].tsx·app/community/new.tsxpost 게시판을 렌더(usePosts/usePost 사용). 동아리 개념이 아니며, api/community*·lib/useCommunit* 훅은 0건. → 신규 동아리 화면은 app/communities/(복수형)로 분리해 라우트·파일 충돌 회피.
  • 게스트 초대 없음: 관련 코드 0건.
  • 상품 상세에 채팅 진입 없음: app/product/[id]/index.tsx는 “장바구니 담기”·“장바구니 보기” CTA만 있고 “채팅하기” 없음.
  • 인증 흐름: api/be-client.tsAuthorization: Bearer <accessToken>를 모든 REST에 자동 부착(accessTokenlib/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단계 확인 → 방 생성/이동).

Architecture Benchmarking (토스 벤치마킹)

제품/패턴참고 패턴본 설계 반영미참고
토스 채팅/알림 UX (toss.im)단순 위계·한 화면 한 과업·명확한 단일 CTA·절제된 accent(화면당 accent 1곳)·넉넉한 여백. 목록은 카드/리스트 아이템에 안읽은 수를 우측 원형 배지로방 목록·커뮤니티 목록의 리스트 아이템 배지, 방 화면 하단 단일 입력 CTA, accent(toss blue) 화면당 1곳화려한 그라디언트·다중 accent 미사용
당근마켓 거래 채팅 (byline)상품 상세 → “채팅하기” 단일 진입으로 거래 채팅방 생성/이동상품 상세 “채팅하기” 2단계(확인 → 진입)별도 채팅 서비스 분리는 앱 FE 범위 밖
Slack 게스트게스트 초대 발송/수락, 발화 권한·만료 표시초대 발송 시 발화권한·만료일 설정 UI, 방 화면 게스트 만료 배너Multi-channel guest 미지원

Possible Solutions

방안 비교 — 실시간 상태를 어디에 둘 것인가

방안설명왜 채택 / 미채택
실시간 이벤트를 Query 캐시에 병합 (채택)useChatSocket이 수신한 BroadcastMessagequeryClient.setQueryData(messagesQueryKey)로 캐시에 append. 화면은 useMessages(Query)만 구독서버 상태 SSOT는 Query 캐시 하나. no-global-by-default(서버 데이터 스토어 복사 금지) 준수. 백필·낙관적 전송·재연결이 캐시 한 곳으로 수렴
소켓 메시지를 Zustand 스토어에 별도 보관실시간 메시지를 전역 스토어 배열로서버 데이터를 스토어에 복사 → no-global-by-default 위반, 캐시와 이중 소스 → 미채택
컴포넌트에서 직접 STOMP 구독방 화면이 stompjs Client 직접 사용컴포넌트에 소켓 로직 유입(no-logic-in-component) → 미채택. 훅으로 캡슐화

방안 비교 — 테마 전환 저장

방안설명왜 채택 / 미채택
useColorScheme 기본 + Zustand 오버라이드(MMKV 영속) (채택)시스템 테마를 기본값으로, 사용자가 라이트/다크/시스템 선택 시 themeStore에 저장하고 MMKV로 영속토스식 “시스템 따름 + 수동 선택” 표준. 테마는 정말 전역 → Zustand 정당
시스템 테마만 사용useColorScheme사용자 수동 전환 불가 → 접근성·선호 미충족. 최소 구현이나 오버라이드 확장점 필요 → 오버라이드 포함 채택

방안 비교 — 재연결/백필

방안설명왜 채택 / 미채택
지수 백오프 재연결 + 3회 실패 시 REST 폴링 + 재구독 시 backfill (채택)useChatSocketreconnectDelay를 지수 증가, 3회 실패 시 pollingFallback 플래그 노출 → 방 화면이 useMessages refetchInterval 활성화. 재연결 성공 시 마지막 수신 id 이후를 GET .../backfill로 채우고 dedupPRD FR-10·NFR 재연결 정책 그대로. netinfo로 오프라인 즉시 감지

Detail Design

화면 목록

#화면라우트(파일)주 API/훅신규/변경
S1채팅방 목록app/rooms/index.tsxuseRooms+useUnreadCounts변경(재작성)
S2채팅방(실시간)app/rooms/[id].tsxuseMessages+useChatSocket+useMarkRead변경(재작성)
S3커뮤니티 목록app/communities/index.tsxuseCommunities신규
S4커뮤니티 개설app/communities/new.tsxuseCreateCommunity신규
S5커뮤니티 상세(가입·멤버·역할)app/communities/[id].tsxuseCommunity+useCommunityMembers+멤버십 mutation신규
S6게스트 초대 발송app/invite/[roomId].tsxuseInviteGuest신규
S7초대 수신함(수락/거절)app/invitations/index.tsxuseMyInvitations+useAcceptInvitation/useRejectInvitation신규
S8상품 상세 “채팅하기”app/product/[id]/index.tsxuseStartGoodsChat변경(CTA 추가)

텍스트 와이어프레임 + 화면별 상태 표

토스 패턴은 화면마다 1줄 명시. 모든 화면은 라이트/다크 토큰으로 렌더.


S1. 채팅방 목록 (app/rooms/index.tsx) — 토스 패턴: 리스트 아이템 + 우측 원형 안읽은 배지, 헤더 우측 단일 액션.

┌───────────────────────────────┐
│ 채팅                    [초대함]│  ← 헤더(초대 수신함 진입, 대기 초대 있으면 점 배지)
├───────────────────────────────┤
│ ⦿ 주말 축구 모임        (3) 14:20│  ← 커뮤니티방: 이름, 안읽은 배지(3), 최근시각
│   김철수: 오늘 몇 시에 모여요?    │  ← 최근 메시지 미리보기(textSecondary)
├───────────────────────────────┤
│ ⦿ 이영희 (1:1)              12:05│  ← DIRECT, 안읽은 0이면 배지 없음
│   안녕하세요 상품 문의드려요       │
└───────────────────────────────┘
상태UI
loading리스트 스켈레톤 3개(surface 카드) + 헤더
empty”참여 중인 채팅방이 없어요” + 안내 문구(surface, textSecondary). PRD 빈 상태 시나리오
errorErrorView + “다시 시도” (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
errorErrorView + 재시도
success커뮤니티 카드(종목 이모지·이름·공개여부·멤버수·설명)

S4. 커뮤니티 개설 (app/communities/new.tsx) — 토스 패턴: 한 화면 한 과업 폼, 하단 고정 단일 CTA, 유효성 인라인.

┌───────────────────────────────┐
│ ‹  동아리 개설                  │
│  이름   [                     ] │
│  설명   [                     ] │
│  종목   [축구 ▾]                │  ← sportCategory 선택
│  공개   ( 공개 )  ( 비공개 )     │  ← visibility 세그먼트
├───────────────────────────────┤
│           [ 개설하기 ]          │  ← 하단 고정, 유효 시에만 활성
└───────────────────────────────┘
상태UI
loading(제출 중) CTA 스피너 + 폼 비활성
empty해당 없음(입력 폼)
error제출 실패 토스트/인라인 + CTA 복구
success개설 완료 → 상세(S5)로 이동, 방 목록 캐시 무효화(전용방 자동 생성 반영)

S5. 커뮤니티 상세(가입·멤버·역할) (app/communities/[id].tsx) — 토스 패턴: 상단 요약 + 명확한 단일 주요 CTA(가입/채팅 입장), 멤버는 하위 섹션.

┌───────────────────────────────┐
│ ‹  주말 축구 모임               │
│ ⚽ 공개 · 32명 · 방장 김철수     │
│ 동네에서 주말마다 축구해요         │
│           [ 가입하기 ]          │  ← 비멤버: 가입(공개=즉시/비공개=승인대기)
│           [ 채팅 입장 ]         │  ← 멤버: 전용방 입장
├───────────────────────────────┤
│ 멤버 (32)                       │
│  김철수  방장                    │
│  이영희  멤버        [강퇴][위임]│  ← 방장에게만 노출
│  …                              │
└───────────────────────────────┘
상태UI
loading상단 요약 스켈레톤 + 멤버 리스트 스켈레톤
empty멤버 0(이론상 방장 존재로 없음) — 방어적으로 “멤버 없음”
errorErrorView + 재시도
success요약 + 역할별 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”받은 초대가 없어요”
errorErrorView + 재시도
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#FFFFFFaccent 위 텍스트
bubbleMine#3182F6#3B5BDB내 말풍선
bubbleMineText#FFFFFF#F2F4F6내 말풍선 텍스트
bubbleOther#F2F4F6#2E2E36상대 말풍선
bubbleOtherText#191F28#F2F4F6상대 말풍선 텍스트
badge#F04452#F76A78안읽은 배지 배경
badgeText#FFFFFF#FFFFFF배지 텍스트
success#12B886#2AC29B읽음·온라인
danger#F04452#F76A78오류·강퇴·거절
overlayrgba(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 영속).

컴포넌트 트리 (컨테이너/프레젠테이션 분리)

theme/ThemeProvider (앱 루트, _layout.tsx에서 마운트)
 └─ ChatSocketProvider (선택: 전역 단일 Client 관리, roomId별 구독은 훅)
     └─ [screens] 컨테이너(훅 소비) → 프레젠테이션(순수 UI, 토큰 소비)

components/ui/ (프레젠테이션 프리미티브, 도메인 무관)
  ThemedText / ThemedView / Button / Card / Badge / Avatar
  EmptyState / ErrorView / LoadingView / SegmentedControl / ListItem

S2 채팅방:
  RoomChatScreen(컨테이너: useMessages·useChatSocket·useMarkRead·useMyProfile)
   ├─ ConnectionBanner(props: status)          ← 프레젠테이션
   ├─ GuestExpiryBanner(props: expiresAt)       ← 프레젠테이션
   ├─ MessageList(props: messages, myUserId, readCursors)
   │   └─ MessageBubble(props: message, isMine, isRead)
   ├─ TypingIndicator(props: typingUserIds)
   └─ MessageComposer(props: canSpeak, onSend, onTyping)
  • 컨테이너만 훅을 소비하고, 프레젠테이션 컴포넌트는 props + useTheme()만. 비즈니스 로직(정규화·읽음 계산·재연결)은 훅/유틸로.

상태관리 설계 (서버/클라이언트 구분 + 근거)

상태저장소근거
방 목록·메시지·안읽은 수·커뮤니티·멤버·초대TanStack Query 캐시서버 상태 SSOT. 실시간 이벤트도 setQueryData로 캐시에 병합(별도 복사 금지)
실시간 수신 메시지Query 캐시(messagesQueryKey)에 append서버 상태 스토어 복사 금지(no-global-by-default). 캐시 한 곳 수렴
타이핑 표시(상대)방 화면 useState(휘발성, TTL 3초)서버 영속 아님·화면 지역. 전역 불필요
소켓 연결 상태·재연결 카운트useChatSocket 내부 useState훅 스코프 지역
테마 모드(system/light/dark)Zustand themeStore + MMKV 영속정말 전역(앱 전체)·재기동 유지. 전역 정당
STOMP Client 인스턴스Zustand 또는 Context 싱글톤(ChatSocketProvider)앱 1개 소켓 재사용. 전역 정당
accessToken/userId기존 useAuthStore(메모리) / useMyProfile재사용
  • 안읽은 수 계산: 서버 GET /rooms/me/unread가 SSOT. 실시간 수신 시 낙관적으로 방별 unread +1(내가 보낸 것 제외), 방 진입·읽음 시 POST /rooms/{id}/read 후 서버 값으로 재동기화.

API 연동 표 (BE 계약과 필드 일치)

REST는 getBeClient()(Authorization Bearer 자동 부착) 경유. 컴포넌트 → query 훅 → api/ 함수 → 클라이언트. 필드는 BE TDD 계약과 일치.

화면Method · Path요청/응답(계약)에러 처리
S1GET /rooms/meuseRooms(기존 확장)RoomResponse[](+역제안 필드)error 상태 → ErrorView
S1GET /rooms/me/unreaduseUnreadCountsRoomUnreadResponse[]{roomId,unreadCount}실패 시 배지 0 폴백(비차단)
S2GET /rooms/{roomId}/messagesuseMessages(기존)ListMessagesResponse{messages,nextCursor}error → 재시도
S2POST /rooms/{roomId}/readuseMarkReadreq {lastReadMessageId}UnreadResponse실패 무음 재시도(비차단)
S2GET /rooms/{roomId}/messages/backfill?afterMessageId=useBackfill(재연결 시)MessageResponse[]실패 시 다음 수신에 재시도
S2STOMP SEND /app/rooms/{roomId}/senduseChatSocket().send{content}미연결 시 REST POST /rooms/{id}/messages 폴백
S2STOMP SUBSCRIBE /topic/rooms/{roomId}useChatSocketBroadcastMessage{messageId,userId,content,createdAt}ChatMessage 정규화구독 실패 → 재연결
S2STOMP typing/readuseChatSocket().sendTyping/sendReadTypingEvent{userId,typing} / ReadEvent{userId,lastReadMessageId}휘발성, 실패 무음
S3GET /communities?keyword=(역제안)useCommunitiesCommunityResponse[]error → ErrorView
S4POST /communitiesuseCreateCommunity{name,description,visibility,sportCategory}CommunityResponse실패 인라인
S5GET /communities/{id}(역제안)useCommunityCommunityResponseerror → ErrorView
S5GET /communities/{id}/members(역제안)useCommunityMembersCommunityMemberResponse[]error → ErrorView
S5POST /communities/{id}/joinuseJoinCommunityMembershipResponse{status:ACTIVE|PENDING_APPROVAL}실패 토스트
S5POST /communities/{id}/members/{userId}/approveuseApproveMemberMembershipResponse방장만
S5POST /communities/{id}/members/{userId}/kickuseKickMember→ 204방장만, 확인 다이얼로그
S5POST /communities/{id}/host/transferuseTransferHost{newHostUserId} → 200확인 다이얼로그
S5DELETE /communities/{id}/members/meuseLeaveCommunity→ 204확인 다이얼로그
S6POST /rooms/{roomId}/invitationsuseInviteGuest{inviteeUserId,canSpeak,expiresInDays}InvitationResponse멱등 응답·실패 인라인
S7GET /rooms/invitations/me(역제안)useMyInvitationsInvitationResponse[]error → ErrorView
S7POST /rooms/invitations/{id}/acceptuseAcceptInvitationInvitationResponse실패 토스트
S7POST /rooms/invitations/{id}/rejectuseRejectInvitationInvitationResponse실패 토스트
S2POST /rooms/{roomId}/guests/{userId}/evictuseEvictGuest(FR-15)→ 204방장만
S8POST /products/{productId}/chatuseStartGoodsChatRoomResponse(contextType=GOODS_PRODUCT)실패 토스트

STOMP↔REST 필드 정규화 (useChatSocket가 담당): BroadcastMessage.messageId → ChatMessage.id, .userId → .senderId, .createdAt → .sentAt, .content → .content, roomId는 구독 destination에서. REST MessageResponse는 이미 {id,roomId,senderId,content,sentAt}.

라우팅·내비게이션 흐름

flowchart LR
    Tabs[탭 네비게이터] --> Rooms[rooms/index S1]
    Tabs --> CommList[communities/index S3]
    Rooms --> RoomChat[rooms/id S2]
    Rooms --> Inbox[invitations/index S7]
    RoomChat --> Invite[invite/roomId S6]
    CommList --> CommNew[communities/new S4]
    CommList --> CommDetail[communities/id S5]
    CommDetail --> RoomChat
    Product[product/id S8] --> RoomChat
    Inbox --> RoomChat
  • 신규 라우트 등록·탭 진입·전역 안읽은 배지 와이어업은 통합 티켓(FE-15)에서 _layout.tsx·(tabs)/_layout.tsx 단독 수정.

기능 플래그 · 점진 공개

플래그값 소스게이트 대상
chat.realtime.enabledEXPO_PUBLIC_CHAT_REALTIME_ENABLEDuseChatSocket 연결 활성화. OFF면 방 화면은 기존 REST 폴링만
chat.community.enabledEXPO_PUBLIC_CHAT_COMMUNITY_ENABLED커뮤니티/초대 탭·화면 노출. OFF면 진입점 숨김
chat.goods.enabled(2단계)EXPO_PUBLIC_CHAT_GOODS_ENABLED상품 상세 “채팅하기” CTA 노출
  • BE Release Scenario(Phase 1 OFF → Phase 2 ON)와 정합. lib/feature-flags.ts가 플래그를 읽는 단일 함수 제공(FE-15가 소유).

Testing Plan (implementer TDD 입력)

대상유형케이스(최소)
theme 토큰/useTheme훅 단위시스템 다크 시 다크 토큰 반환 / 오버라이드 라이트 시 라이트 반환 / 토큰 키 누락 없음
components/ui/*컴포넌트Badge count 표시·0이면 미표시 / EmptyState·ErrorView 텍스트·재시도 콜백 / Button disabled 상태
useChatSocket훅 단위(모의 Client)수신 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에 역제안:

  1. 방 목록 미리보기 필드RoomResponselastMessagePreview: string|null, lastMessageAt: string|null, contextType/contextId, name(커뮤니티명) 추가. 없으면 미리보기 없이 이름·안읽은 수만 표시(degraded).
  2. 커뮤니티 조회 RESTGET /communities?keyword=(목록/검색, repo findPublicByKeyword 대응), GET /communities/{id}(상세), GET /communities/{id}/members(멤버 목록), GET /communities/me(내 커뮤니티). 현재 계약은 write-op만 존재.
  3. 초대 수신함 RESTGET /rooms/invitations/me(내가 받은 PENDING 초대 목록). accept/reject만 있고 목록 조회 없음.
  4. 인증 헤더 정합 — BE REST 계약이 X-User-Id 헤더(AUTH-04 TODO) 전제이나 앱 be-client.tsAuthorization: Bearer만 부착. 신규 엔드포인트가 서버에서 userId를 어떻게 식별하는지 확정 필요(Bearer로 통일 권장).
  5. 응답 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건.