# Snap 백엔드 연결 설계 > snap 목업을 걷어내고 base-backend(000)의 세션 기반 chat 계약에 real 연결. > 이전 spec(`2026-07-16-snap-mate-react-client-design.md`)의 "2차로 미룸 — real 백엔드 부착"을 실행. > 최종 목적지: .NET 윈도우 애플리케이션의 웹뷰(WebView2). 작성일: 2026-07-18 --- ## 1. 목표 & 범위 snap 은 이미 **교체 seam 이 다 잡혀 있다.** 새 구조를 만드는 게 아니라, 기존 seam 에 real 구현을 끼우고 목업을 제거하는 작업이다. UI/store/컴포넌트는 **건드리지 않는다** (반환 타입만 유지하면 화면 코드는 안 바뀐다). ### 이번 범위 (세로 슬라이스) | # | 대상 | 붙일 곳 | |---|---|---| | 1 | `useSessionList` | `GET /chat/sessions` (`apiList`) | | 2 | `useSessionMessages` | `GET /chat/sessions/{id}/messages` (`apiGet`) | | 3 | `useCreateSession` | `POST /chat/sessions` (`apiPost`) | | 4 | `snap.stream` (`USE_MOCK=false`) | `POST /chat/stream` SSE (token/done/error/**title**) | | 5 | 목업 3파일 제거 | `mock/sessions.ts` · `mock/conversations.ts` · `mock/stream.ts` + import 정리 | | 6 | Bearer 토큰 provider seam | `client.ts` 요청 인터셉터 + `sse.ts` 헤더 | ### 2차로 미룸 (범위 밖) - rename(PATCH) / delete / search 엔드포인트 - `subagent_start` / `subagent_done` 진행 UI - `usage` 토큰 카운터 - Bearer 모드 refresh(호스트 위임) 실제 배선 — .NET 호스트 생기면 --- ## 2. 핵심 결정 (확정됨) | 항목 | 결정 | |---|---| | 방침 | 기존 seam 에 real 구현 주입 + 목업 제거. **UI/store/컴포넌트 0 변경** | | 범위 | **세로 슬라이스** — list/get/create/stream 까지. rename/delete/search 는 2차 | | envelope | 비스트림 응답은 `Envelope[T]` → `apiGet`/`apiPost`/`apiList` 헬퍼가 `.data` 자동 언랩 | | title 이벤트 | **살린다** — 첫 메시지 후 백엔드가 쏘는 `title{title}` 로 세션 제목 자동 갱신 | | subagent/usage | **무시** — `streamLLM` 이 unknown 이벤트를 흘려서 안 살려도 안 깨짐. 2차 | | 409 | `isGenerating` 이면 send 막고, stream open 이 409(`CHAT_GENERATION_IN_PROGRESS`)면 toast | | 인증 | **쿠키 now + Bearer 주입 seam.** 토큰 소스를 `getAccessToken()` 한 곳으로. 있으면 `Authorization: Bearer`, 없으면(=오늘) 쿠키 폴백 | | refresh | 쿠키 모드는 지금대로. Bearer 모드 refresh 는 .NET 호스트 책임 → 지금은 TODO 만 | | dev 환경 | Vite proxy(`/api`→`localhost:8001`)로 same-origin → 쿠키/CORS 이슈 없음. 건드릴 것 없음 | --- ## 3. 백엔드 계약 (base-backend `modules/chat`) 베이스 URL: `http://localhost:8001` · 프리픽스 `/api/v1` · dev 는 Vite proxy 로 `/api/v1/...`. ### 3.1 비스트림 엔드포인트 (Envelope 래핑, camelCase) | Method + Path | 용도 | 반환 `data` | |---|---|---| | `POST /chat/sessions` | 세션 생성 | `ChatSessionResponse` | | `GET /chat/sessions` | 세션 목록(페이지네이션) | `ChatSessionResponse[]` | | `GET /chat/sessions/{id}/messages` | 세션 + 메시지 | `ChatSessionDetailResponse` | - `ChatSessionResponse`: `id, title, titleLlm, isGenerating, createdAt, updatedAt` - `ChatMessageResponse`: `sessionId, role, content, createdAt` - `ChatSessionDetailResponse`: 세션 필드 + `messages: ChatMessageResponse[]` 프론트 `contract/types.ts` 의 `SnapSession`/`SnapMessage`/`SnapSessionDetail` 와 1:1 (단, `SnapSession` 의 UI-only 필드 `tag?`/`snippet?`/`tokens?` 는 백엔드에 없음 — 옵셔널이라 없어도 안 깨지고, 화면에서 안 쓰면 그만). ### 3.2 스트림 엔드포인트 `POST /chat/stream` — `sse_starlette.EventSourceResponse` (POST + JSON body 라 브라우저 네이티브 `EventSource` 불가 → `@microsoft/fetch-event-source` 사용, 이미 `sse.ts` 가 그럼). 요청 바디 `ChatStreamRequest`: `{ sessionId, content, forcedSkill? }`. 이벤트 (named `event:` + JSON `data:`): | event | data | 이번 처리 | |---|---|---| | `token` | `{delta}` | `appendChunk(delta)` | | `title` | `{title}` | **세션 제목 갱신** (react-query 캐시 + 필요 시 store) | | `done` | `{}` / `{traceId?}` | 스트림 종료 | | `error` | `{message, code, detail?}` | toast + 마지막 assistant 메시지 frozen | | `result` | `{items}` | (이번엔 pass — snap 은 안 씀) | | `subagent_start`/`subagent_done` | `{name,...}` | 무시 (2차) | | `usage` | `{used,limit,ratio,elapsed_ms}` | 무시 (2차) | 순서: (optional `title`/`result`/`subagent_*`) → 다수 `token` → `usage` → `done`, 또는 `error` 종결. SSE 는 200 으로 열려서 중간에 상태코드 못 바꿈 → LLM 설정/생성 실패는 HTTP 에러가 아니라 `error` **이벤트**로 옴. code: `LLM_NOT_CONFIGURED` / `LLM_RATE_LIMITED` / `LLM_ERROR` / `UNKNOWN_SKILL`. --- ## 4. 유닛별 변경 ### 4.1 `features/snap/api/snap.api.ts` — 목업 바디 → real 호출 세 함수의 `queryFn`/`mutationFn` 바디만 교체. 시그니처·반환타입·queryKey 유지. - `useSessionList` → `apiList("/chat/sessions")` 의 `items` - `useSessionMessages(id)` → `apiGet(\`/chat/sessions/${id}/messages\`)` - `useCreateSession` → `apiPost("/chat/sessions")` - `MOCK_SESSIONS`/`MOCK_CONVERSATIONS` import 제거. ### 4.2 `features/snap/api/snap.stream.ts` — 플래그 + title 핸들러 - `USE_MOCK = false`. - `SnapStreamHandlers` 에 `onTitle?(title: string)` 추가. - real 경로: `streamLLM({ path: "/chat/stream", body: req, signal, handlers })` — `streamLLM` 에도 `onTitle` 배선 필요(4.4). - `mockStream` import 제거. ### 4.3 `features/snap/hooks/useSnapChat.ts` — title 반영 + 409 - `onTitle` → react-query 세션 캐시(`["snap","sessions"]`, `["snap","session",id]`)의 `title` 패치(`queryClient.setQueryData`). - send 진입 가드: 스토어 `isStreaming` 이거나 세션 `isGenerating` 이면 막음. - stream open 이 409 면 `onError` 로 흘러오니 code 보고 "이미 생성 중" toast. ### 4.4 `lib/streaming/streamLLM.ts` — title 이벤트 파싱 - 이벤트 스위치에 `title` → `handlers.onTitle?.(payload.title)` 추가. - `LLMStreamHandlers` 타입에 `onTitle?` 추가. 기존 소비자(다른 chat)는 옵셔널이라 영향 없음. ### 4.5 인증 seam — `getAccessToken()` provider - 새 파일 `lib/auth/tokenProvider.ts`: `getAccessToken(): string | null` (기본 `null`) + `setAccessToken(t: string | null)`. .NET 호스트가 나중에 `window.chrome.webview` 로 주입하면 여기만 채움. - `lib/api/client.ts`: **요청 인터셉터** 추가 — 토큰 있으면 `Authorization: Bearer` 세팅 (없으면 아무것도 안 함 → 쿠키 그대로). 응답 인터셉터는 유지. - `lib/streaming/sse.ts`: `open()` 헤더에 토큰 있으면 `Authorization` 추가. - refresh: 쿠키 모드 유지. Bearer 모드는 `// TODO(host): .NET 호스트 refresh 위임` 주석만. ### 4.6 목업 제거 `mock/sessions.ts`·`mock/conversations.ts`·`mock/stream.ts` 삭제. 남은 import 없는지 확인 (4.1/4.2 에서 이미 끊음). --- ## 5. 검증 1. **백엔드 로컬 8001 기동 + LLM(NVIDIA NIM) 설정** → 프론트 로그인 → `/snap` 세션 목록이 실제 DB 세션으로 뜸. 2. **새 대화** → 첫 메시지 전송 → 실제 LLM 토큰이 타이핑되듯 스트리밍 → 완료 후 사이드바 제목이 LLM 이 지은 제목으로 자동 변경(title 이벤트). 3. **기존 세션 재진입** → 과거 메시지 렌더 정상. 4. **생성 중 재전송** → 막힘 + toast. 5. 기존 프론트 테스트(`snap.api`/`snap.stream`/`sse`/`streamLLM`) 통과 + real 경로 유닛 테스트 추가(핸들러 매핑·title·409·Bearer 헤더 주입). --- ## 6. 리스크 / 열린 항목 - **title 갱신 위치**: react-query 캐시만 패치할지, snap 스토어에도 반영할지 — 사이드바가 `useSessionList` 캐시를 보면 캐시 패치로 충분. 구현 때 확정. - **Bearer refresh**: .NET 호스트 부재로 이번엔 TODO. 호스트 붙을 때 별도 phase. - **usage/subagent**: 지금 무시. 진행 UI 는 후속 spec 에서.