32 KiB
Snap 백엔드 연결 Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: snap 목업(sessions/conversations/stream)을 걷어내고 base-backend(000)의 세션 기반 chat 계약(/api/v1/chat)에 real 연결한다 — 세션 목록/조회/생성 + SSE 토큰 스트리밍 + LLM 자동제목, 그리고 .NET 웹뷰용 Bearer 토큰 주입 seam.
Architecture: 기존 seam 에 real 구현을 끼운다. snap.api.ts 3함수는 mock 반환 → apiGet/apiList/apiPost 호출로 교체, snap.stream.ts 는 mockStream 제거하고 streamLLM("/chat/stream") 로 직결. streamLLM 에 title 이벤트 파싱 추가, 인증은 getAccessToken() provider 한 곳을 만들어 client.ts(axios 요청 인터셉터)와 sse.ts(헤더)가 참조 — 토큰 있으면 Authorization: Bearer, 없으면(=오늘) 쿠키 폴백. UI/store/컴포넌트는 안 건드린다.
Tech Stack: React 18 + TS + Vite + Zustand + TanStack Query + axios + @microsoft/fetch-event-source + vitest + @testing-library/react.
Global Constraints
- 작업 디렉토리:
D:\project\021.code-assistant-v2\2_frontend(모든 경로 이 기준 상대). - git: 브랜치
feat/snap-backend-connect(이미 생성됨) → 태스크마다 자기 파일만 add 후 커밋, push 금지. - 게이트: 자기 파일
npx eslint <파일>클린 + 해당 테스트 통과. Wave 경계에서npm run build. - baseline lint 주의: 프로젝트 전체
npm run lint에 이번 작업 무관 기존 에러/경고 있음 — 스코프는 항상npx eslint <자기 파일>. - 응답·주석 톤: 한글 반말 (CLAUDE.md 0번). 백엔드 계약은 camelCase(CamelModel 정렬).
- import 별칭:
@/→src/. - dev 서버:
npm run dev→ http://localhost:15173. 백엔드: base-backend 로컬 8001, Vite proxy/api→8001. - 테스트: vitest. 네트워크/스트림은 mock (실제 백엔드 호출 금지). 실제 백엔드 연동은 §검증에서 육안.
- 건드리지 말 것:
features/chat/*,chatStore,DashboardLayout, snap 의 store/components/pages. 이번 수정 파일은 아래 8개 태스크에 명시된 것만.
파일 구조 (수정/신규/삭제)
신규:
src/lib/auth/tokenProvider.ts # accessToken 단일 소스 (get/set)
src/lib/auth/tokenProvider.test.ts
src/lib/streaming/streamLLM.test.ts # title 이벤트 테스트 (신규)
src/features/snap/hooks/useSnapChat.test.tsx
수정:
src/lib/streaming/streamLLM.ts # title 이벤트 + onTitle 핸들러
src/lib/api/client.ts # 요청 인터셉터 Bearer 주입
src/lib/api/client.test.ts # Bearer 테스트 추가
src/lib/streaming/sse.ts # Authorization 헤더 (토큰 있을 때)
src/lib/streaming/sse.test.ts # Bearer 테스트 추가
src/features/snap/api/snap.api.ts # mock → apiGet/apiList/apiPost
src/features/snap/api/snap.api.test.tsx # mock client 로 재작성
src/features/snap/api/snap.stream.ts # USE_MOCK 제거, streamLLM 직결 + onTitle
src/features/snap/api/snap.stream.test.ts# streamLLM mock 으로 재작성
src/features/snap/hooks/useSnapChat.ts # title 캐시 패치 + 409 처리 + 중복 가드
삭제:
src/features/snap/mock/sessions.ts
src/features/snap/mock/conversations.ts
src/features/snap/mock/stream.ts
src/features/snap/mock/mock.test.ts
Wave 실행 맵
| Wave | Task | 병렬성 | 파일(disjoint) | 의존 |
|---|---|---|---|---|
| W1 | T1 tokenProvider | 병렬 | lib/auth/tokenProvider.ts(+test) |
— |
| W1 | T2 streamLLM title | 병렬 | lib/streaming/streamLLM.ts(+test) |
— |
| W1 | T3 snap.api real | 병렬 | features/snap/api/snap.api.ts(+test) |
— |
| W2 | T4 client Bearer | 병렬 | lib/api/client.ts(+test) |
T1 |
| W2 | T5 sse Bearer | 병렬 | lib/streaming/sse.ts(+test) |
T1 |
| W2 | T6 snap.stream real+title | 병렬 | features/snap/api/snap.stream.ts(+test) |
T2 |
| W3 | T7 useSnapChat title+409 | 병렬 | features/snap/hooks/useSnapChat.ts(+test) |
T3,T6 |
| W3 | T8 목업 제거 | 병렬 | mock/*.ts 삭제 |
T3,T6 |
같은 wave = 파일 disjoint + 시그니처 import 없음. W1 셋은 서로 안 엮임. W2 셋은 각각 W1 산출(getAccessToken/onTitle)만 소비하고 서로 안 엮임. W3 둘은 파일 disjoint.
Task T1: tokenProvider (accessToken 단일 소스)
Files:
- Create:
src/lib/auth/tokenProvider.ts - Test:
src/lib/auth/tokenProvider.test.ts
Interfaces:
-
Consumes: 없음
-
Produces:
getAccessToken(): string | null,setAccessToken(token: string | null): void -
Step 1: 실패 테스트 작성 —
src/lib/auth/tokenProvider.test.ts
import { describe, it, expect, afterEach } from "vitest"
import { getAccessToken, setAccessToken } from "./tokenProvider"
afterEach(() => setAccessToken(null))
describe("tokenProvider", () => {
it("기본값은 null (쿠키 모드)", () => {
expect(getAccessToken()).toBeNull()
})
it("set 하면 그 토큰을 돌려준다", () => {
setAccessToken("abc")
expect(getAccessToken()).toBe("abc")
})
it("null 로 다시 초기화 가능", () => {
setAccessToken("abc")
setAccessToken(null)
expect(getAccessToken()).toBeNull()
})
})
- Step 2: 실패 확인
Run: npx vitest run src/lib/auth/tokenProvider.test.ts
Expected: FAIL — Failed to resolve import "./tokenProvider"
- Step 3: 구현 —
src/lib/auth/tokenProvider.ts
// .NET 웹뷰 호스트가 주입할 accessToken 의 단일 소스.
// 브라우저(오늘)에선 기본 null → 쿠키 인증. 나중에 .NET 이 setAccessToken 으로 채우면
// client.ts / sse.ts 가 Authorization: Bearer 로 전환한다.
let accessToken: string | null = null
export function getAccessToken(): string | null {
return accessToken
}
export function setAccessToken(token: string | null): void {
accessToken = token
}
- Step 4: 통과 확인
Run: npx vitest run src/lib/auth/tokenProvider.test.ts
Expected: PASS (3 tests)
- Step 5: lint + 커밋
npx eslint src/lib/auth/tokenProvider.ts src/lib/auth/tokenProvider.test.ts
git add src/lib/auth/tokenProvider.ts src/lib/auth/tokenProvider.test.ts
git commit -m "feat(auth): add accessToken provider seam for .NET webview"
Task T2: streamLLM 에 title 이벤트 추가
Files:
- Modify:
src/lib/streaming/streamLLM.ts - Test:
src/lib/streaming/streamLLM.test.ts(신규)
Interfaces:
-
Consumes: 없음 (기존
streamSSE) -
Produces:
LLMStreamHandlers.onTitle?: (title: string) => void— T6 이 소비 -
Step 1: 실패 테스트 작성 —
src/lib/streaming/streamLLM.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest"
import { streamLLM } from "./streamLLM"
import * as sse from "./sse"
vi.mock("./sse", () => ({ streamSSE: vi.fn() }))
describe("streamLLM title 이벤트", () => {
beforeEach(() => vi.clearAllMocks())
it("title 이벤트를 onTitle 로 전달한다", async () => {
const streamSSE = sse.streamSSE as ReturnType<typeof vi.fn>
streamSSE.mockImplementation(async (opts: { onEvent: (e: { event: string; data: string }) => void }) => {
opts.onEvent({ event: "title", data: JSON.stringify({ title: "판매문서 조인" }) })
})
const onTitle = vi.fn()
await streamLLM({
path: "/chat/stream",
body: {},
handlers: { onToken: vi.fn(), onDone: vi.fn(), onTitle },
})
expect(onTitle).toHaveBeenCalledWith("판매문서 조인")
})
it("malformed title 은 조용히 무시(throw 안 함)", async () => {
const streamSSE = sse.streamSSE as ReturnType<typeof vi.fn>
streamSSE.mockImplementation(async (opts: { onEvent: (e: { event: string; data: string }) => void }) => {
opts.onEvent({ event: "title", data: "not-json" })
})
const onTitle = vi.fn()
await expect(
streamLLM({ path: "/x", body: {}, handlers: { onToken: vi.fn(), onDone: vi.fn(), onTitle } }),
).resolves.toBeUndefined()
expect(onTitle).not.toHaveBeenCalled()
})
})
- Step 2: 실패 확인
Run: npx vitest run src/lib/streaming/streamLLM.test.ts
Expected: FAIL — onTitle 이 호출 안 됨 (title 분기 없음)
- Step 3: 구현 —
src/lib/streaming/streamLLM.ts
3-1. LLMStreamHandlers 인터페이스에 onTitle 추가 (기존 onToken 위, 라인 61 근처):
/** `title` 이벤트 — 첫 메시지 후 LLM 이 지은 세션 제목 (선택) */
onTitle?: (title: string) => void
/** `token` 이벤트 — 토큰 조각 누적해서 표시 */
onToken: (delta: string) => void
3-2. ErrorPayload 인터페이스 아래(라인 87 근처)에 payload 타입 추가:
interface TitlePayload {
title: string
}
3-3. onEvent 스위치에서 result 분기 다음에 title 분기 추가 (라인 126 else if (e.event === "clarify") 앞):
} else if (e.event === "title") {
try {
const payload = JSON.parse(e.data) as TitlePayload
if (typeof payload.title === "string") handlers.onTitle?.(payload.title)
} catch {
// 제목 갱신은 부가정보 — 실패해도 토큰 흐름엔 영향 없음
}
- Step 4: 통과 확인
Run: npx vitest run src/lib/streaming/streamLLM.test.ts
Expected: PASS (2 tests)
- Step 5: lint + 커밋
npx eslint src/lib/streaming/streamLLM.ts src/lib/streaming/streamLLM.test.ts
git add src/lib/streaming/streamLLM.ts src/lib/streaming/streamLLM.test.ts
git commit -m "feat(streaming): parse SSE title event into onTitle handler"
Task T3: snap.api 목업 → real HTTP
Files:
- Modify:
src/features/snap/api/snap.api.ts - Test:
src/features/snap/api/snap.api.test.tsx(재작성)
Interfaces:
-
Consumes:
apiGet<T>(path),apiPost<T>(path, body?),apiList<T>(path)from@/lib/api/client -
Produces:
useSessionList()→SnapSession[](queryKey["snap","sessions"]),useSessionMessages(id)→SnapSessionDetail(queryKey["snap","session",id]),useCreateSession()→ mutationSnapSession -
Step 1: 테스트 재작성 (실패) —
src/features/snap/api/snap.api.test.tsx전체 교체
import { describe, it, expect, vi, beforeEach } from "vitest"
import { renderHook, waitFor } from "@testing-library/react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { useSessionList, useSessionMessages } from "./snap.api"
import * as client from "@/lib/api/client"
vi.mock("@/lib/api/client")
function wrapper({ children }: { children: React.ReactNode }) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>
}
const SESSION = {
id: "s1",
title: "t",
titleLlm: null,
isGenerating: false,
createdAt: "2026-07-18T00:00:00Z",
updatedAt: "2026-07-18T00:00:00Z",
}
describe("snap.api (real)", () => {
beforeEach(() => vi.clearAllMocks())
it("useSessionList 는 GET /chat/sessions 의 items 를 반환", async () => {
vi.mocked(client.apiList).mockResolvedValue({ items: [SESSION], meta: null, counts: 1 })
const { result } = renderHook(() => useSessionList(), { wrapper })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(client.apiList).toHaveBeenCalledWith("/chat/sessions")
expect(result.current.data![0].id).toBe("s1")
})
it("useSessionMessages 는 GET /chat/sessions/{id}/messages 를 호출", async () => {
vi.mocked(client.apiGet).mockResolvedValue({ ...SESSION, messages: [] })
const { result } = renderHook(() => useSessionMessages("s1"), { wrapper })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(client.apiGet).toHaveBeenCalledWith("/chat/sessions/s1/messages")
expect(result.current.data!.messages).toEqual([])
})
})
- Step 2: 실패 확인
Run: npx vitest run src/features/snap/api/snap.api.test.tsx
Expected: FAIL — 현재 snap.api.ts 가 mock 반환이라 client.apiList 미호출
- Step 3: 구현 —
src/features/snap/api/snap.api.ts전체 교체
import { useQuery, useMutation } from "@tanstack/react-query"
import { apiGet, apiList, apiPost } from "@/lib/api/client"
import type { SnapSession, SnapSessionDetail } from "../contract/types"
export function useSessionList() {
return useQuery({
queryKey: ["snap", "sessions"],
queryFn: async (): Promise<SnapSession[]> => {
const { items } = await apiList<SnapSession>("/chat/sessions")
return items
},
})
}
export function useSessionMessages(id: string) {
return useQuery({
queryKey: ["snap", "session", id],
enabled: !!id,
queryFn: (): Promise<SnapSessionDetail> =>
apiGet<SnapSessionDetail>(`/chat/sessions/${id}/messages`),
})
}
export function useCreateSession() {
return useMutation({
mutationFn: (): Promise<SnapSession> => apiPost<SnapSession>("/chat/sessions", {}),
})
}
- Step 4: 통과 확인
Run: npx vitest run src/features/snap/api/snap.api.test.tsx
Expected: PASS (2 tests)
- Step 5: lint + 커밋
npx eslint src/features/snap/api/snap.api.ts src/features/snap/api/snap.api.test.tsx
git add src/features/snap/api/snap.api.ts src/features/snap/api/snap.api.test.tsx
git commit -m "feat(snap): wire session list/detail/create to real backend"
Task T4: client.ts 요청 인터셉터 Bearer 주입
Files:
- Modify:
src/lib/api/client.ts - Test:
src/lib/api/client.test.ts(describe 블록 추가)
Interfaces:
-
Consumes:
getAccessToken()from@/lib/auth/tokenProvider(T1) -
Produces:
apiClient가 토큰 있으면Authorization: Bearer <t>헤더 부착 -
Step 1: 실패 테스트 추가 —
src/lib/api/client.test.ts파일 끝에 아래 추가. 파일 상단 import 에setAccessToken,apiClient,afterEach가 없으면 추가.
import { afterEach } from "vitest"
import { apiClient } from "./client"
import { setAccessToken } from "@/lib/auth/tokenProvider"
describe("apiClient Bearer 주입", () => {
afterEach(() => {
setAccessToken(null)
delete apiClient.defaults.adapter
})
it("토큰 있으면 Authorization: Bearer 헤더를 붙인다", async () => {
setAccessToken("tok123")
let seen: unknown
apiClient.defaults.adapter = async (config) => {
seen = config.headers.Authorization
return { data: { success: true, data: null }, status: 200, statusText: "OK", headers: {}, config } as never
}
await apiClient.get("/ping")
expect(seen).toBe("Bearer tok123")
})
it("토큰 없으면 Authorization 를 안 붙인다(쿠키 모드)", async () => {
setAccessToken(null)
let seen: unknown = "sentinel"
apiClient.defaults.adapter = async (config) => {
seen = config.headers.Authorization
return { data: { success: true, data: null }, status: 200, statusText: "OK", headers: {}, config } as never
}
await apiClient.get("/ping")
expect(seen).toBeUndefined()
})
})
- Step 2: 실패 확인
Run: npx vitest run src/lib/api/client.test.ts
Expected: FAIL — 토큰 넣어도 Authorization 이 undefined (인터셉터 없음)
- Step 3: 구현 —
src/lib/api/client.ts
3-1. import 추가 (파일 상단 import 그룹):
import { getAccessToken } from "@/lib/auth/tokenProvider"
3-2. apiClient 생성 직후(라인 26 }) 다음), 응답 인터셉터 위에 요청 인터셉터 추가:
// .NET 웹뷰 호스트가 토큰을 주입하면 Bearer 로, 아니면(=오늘) 쿠키로.
apiClient.interceptors.request.use((config) => {
const token = getAccessToken()
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
- Step 4: 통과 확인
Run: npx vitest run src/lib/api/client.test.ts
Expected: PASS (기존 테스트 + 새 2개)
- Step 5: lint + 커밋
npx eslint src/lib/api/client.ts src/lib/api/client.test.ts
git add src/lib/api/client.ts src/lib/api/client.test.ts
git commit -m "feat(api): inject Bearer token from provider when present"
Task T5: sse.ts Authorization 헤더
Files:
- Modify:
src/lib/streaming/sse.ts - Test:
src/lib/streaming/sse.test.ts(테스트 추가)
Interfaces:
-
Consumes:
getAccessToken()from@/lib/auth/tokenProvider(T1) -
Produces: SSE 요청이 토큰 있으면
Authorization: Bearer헤더 포함 -
Step 1: 실패 테스트 추가 —
src/lib/streaming/sse.test.ts
1-1. 상단 import 에 추가:
import { afterEach } from "vitest"
import { setAccessToken } from "@/lib/auth/tokenProvider"
1-2. describe("streamSSE", ...) 안, 기존 beforeEach 아래에 추가:
afterEach(() => setAccessToken(null))
it("토큰 있으면 Authorization: Bearer 헤더를 추가", async () => {
setAccessToken("tok")
const fetchEventSource = fes.fetchEventSource as ReturnType<typeof vi.fn>
fetchEventSource.mockResolvedValue(undefined)
await streamSSE({ path: "/chat/stream", body: { message: "hi" }, onEvent: vi.fn() })
const [, opts] = fetchEventSource.mock.calls[0]
expect(opts.headers.Authorization).toBe("Bearer tok")
})
- Step 2: 실패 확인
Run: npx vitest run src/lib/streaming/sse.test.ts
Expected: FAIL — 새 테스트에서 Authorization 이 undefined
- Step 3: 구현 —
src/lib/streaming/sse.ts
3-1. import 추가 (라인 2 env import 아래):
import { getAccessToken } from "@/lib/auth/tokenProvider"
3-2. open() 함수 시작부에서 헤더를 토큰 유무로 구성 (기존 await fetchEventSource(...) 의 인라인 headers 를 교체):
async function open(opts: StreamSSEOptions, retried: boolean): Promise<void> {
const token = getAccessToken()
const headers: Record<string, string> = { "Content-Type": "application/json" }
if (token) headers.Authorization = `Bearer ${token}`
await fetchEventSource(`${env.apiBaseUrl}${opts.path}`, {
method: "POST",
credentials: "include",
headers,
body: JSON.stringify(opts.body),
signal: opts.signal,
openWhenHidden: true,
onopen: async (res) => {
if (res.ok) return
if (res.status === 401 && !retried) {
throw new RetryableUnauthorized()
}
throw new Error(`SSE open failed: ${res.status}`)
},
onmessage: (msg) => {
opts.onEvent({ event: msg.event || "message", data: msg.data, id: msg.id })
},
onerror: (err) => {
if (err instanceof RetryableUnauthorized) throw err
// 자동 reconnect 방지: throw하면 종료
opts.onError?.(err)
throw err
},
})
}
- Step 4: 통과 확인
Run: npx vitest run src/lib/streaming/sse.test.ts
Expected: PASS (기존 3개 + 새 1개). 기존 "쿠키 기반" 테스트의 not.toHaveProperty("Authorization") 는 토큰 null 이라 그대로 통과.
- Step 5: lint + 커밋
npx eslint src/lib/streaming/sse.ts src/lib/streaming/sse.test.ts
git add src/lib/streaming/sse.ts src/lib/streaming/sse.test.ts
git commit -m "feat(streaming): add Authorization header to SSE when token present"
Task T6: snap.stream USE_MOCK 제거 + streamLLM 직결 + onTitle
Files:
- Modify:
src/features/snap/api/snap.stream.ts - Test:
src/features/snap/api/snap.stream.test.ts(재작성)
Interfaces:
-
Consumes:
streamLLM(T2 의onTitle포함) -
Produces:
SnapStreamHandlers { onToken, onDone, onTitle?, onError? },snapStream(req, handlers, opts?)— T7 이 소비 -
Step 1: 테스트 재작성 (실패) —
src/features/snap/api/snap.stream.test.ts전체 교체
import { describe, it, expect, vi, beforeEach } from "vitest"
import { snapStream } from "./snap.stream"
import * as streaming from "@/lib/streaming"
vi.mock("@/lib/streaming", () => ({ streamLLM: vi.fn() }))
describe("snapStream (real)", () => {
beforeEach(() => vi.clearAllMocks())
it("streamLLM 을 /chat/stream 계약으로 호출한다", async () => {
const streamLLM = streaming.streamLLM as ReturnType<typeof vi.fn>
streamLLM.mockResolvedValue(undefined)
await snapStream({ sessionId: "s1", content: "hi" }, { onToken: vi.fn(), onDone: vi.fn() })
expect(streamLLM).toHaveBeenCalledTimes(1)
const arg = streamLLM.mock.calls[0][0]
expect(arg.path).toBe("/chat/stream")
expect(arg.body).toEqual({ sessionId: "s1", content: "hi" })
})
it("onToken/onTitle/onDone/onError 를 그대로 배선한다", async () => {
const streamLLM = streaming.streamLLM as ReturnType<typeof vi.fn>
streamLLM.mockImplementation(
async (opts: { handlers: { onToken: (d: string) => void; onTitle?: (t: string) => void; onDone: (p: object) => void } }) => {
opts.handlers.onToken("a")
opts.handlers.onTitle?.("제목")
opts.handlers.onDone({})
},
)
const onToken = vi.fn()
const onTitle = vi.fn()
const onDone = vi.fn()
await snapStream({ sessionId: "s1", content: "x" }, { onToken, onDone, onTitle })
expect(onToken).toHaveBeenCalledWith("a")
expect(onTitle).toHaveBeenCalledWith("제목")
expect(onDone).toHaveBeenCalledTimes(1)
})
})
- Step 2: 실패 확인
Run: npx vitest run src/features/snap/api/snap.stream.test.ts
Expected: FAIL — 현재 USE_MOCK=true 라 streamLLM 미호출
- Step 3: 구현 —
src/features/snap/api/snap.stream.ts전체 교체
import { streamLLM } from "@/lib/streaming"
import type { SnapStreamRequest } from "../contract/types"
export interface SnapStreamHandlers {
onToken: (delta: string) => void
onDone: () => void
onTitle?: (title: string) => void
onError?: (e: Error) => void
}
// base-backend POST /chat/stream (SSE) 직결. token/done/error/title 이벤트 소비.
export function snapStream(
req: SnapStreamRequest,
handlers: SnapStreamHandlers,
opts?: { signal?: AbortSignal },
): Promise<void> {
return streamLLM({
path: "/chat/stream",
body: req,
signal: opts?.signal,
handlers: {
onToken: handlers.onToken,
onDone: () => handlers.onDone(),
onTitle: handlers.onTitle,
onError: handlers.onError,
},
})
}
- Step 4: 통과 확인
Run: npx vitest run src/features/snap/api/snap.stream.test.ts
Expected: PASS (2 tests)
- Step 5: lint + 커밋
npx eslint src/features/snap/api/snap.stream.ts src/features/snap/api/snap.stream.test.ts
git add src/features/snap/api/snap.stream.ts src/features/snap/api/snap.stream.test.ts
git commit -m "feat(snap): stream via real /chat/stream and forward title event"
Task T7: useSnapChat — title 캐시 패치 + 409 처리 + 중복 가드
Files:
- Modify:
src/features/snap/hooks/useSnapChat.ts - Test:
src/features/snap/hooks/useSnapChat.test.tsx(신규)
Interfaces:
-
Consumes:
snapStream+SnapStreamHandlers.onTitle(T6), queryKeys["snap","sessions"]/["snap","session",id](T3),useSnapChatStore -
Produces:
useSnapChat(sessionId)→{ send, stop }(시그니처 불변) -
Step 1: 실패 테스트 작성 —
src/features/snap/hooks/useSnapChat.test.tsx
import { describe, it, expect, vi, beforeEach } from "vitest"
import { renderHook } from "@testing-library/react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { toast } from "sonner"
import { useSnapChat } from "./useSnapChat"
import * as stream from "../api/snap.stream"
import { useSnapChatStore } from "../store/snapChatStore"
vi.mock("../api/snap.stream")
vi.mock("sonner", () => ({ toast: { error: vi.fn() } }))
let qc: QueryClient
function wrapper({ children }: { children: React.ReactNode }) {
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>
}
beforeEach(() => {
qc = new QueryClient()
useSnapChatStore.getState().reset()
vi.clearAllMocks()
})
describe("useSnapChat", () => {
it("onTitle 이 세션 목록 캐시의 title 을 갱신한다", async () => {
qc.setQueryData(
["snap", "sessions"],
[{ id: "s1", title: null, titleLlm: null, isGenerating: false, createdAt: "", updatedAt: "" }],
)
vi.mocked(stream.snapStream).mockImplementation(async (_req, handlers) => {
handlers.onTitle?.("새 제목")
})
const { result } = renderHook(() => useSnapChat("s1"), { wrapper })
await result.current.send("hi")
const list = qc.getQueryData(["snap", "sessions"]) as { id: string; title: string | null }[]
expect(list[0].title).toBe("새 제목")
})
it("409 에러면 '이미 생성 중' 안내 toast", async () => {
vi.mocked(stream.snapStream).mockImplementation(async (_req, handlers) => {
handlers.onError?.(new Error("SSE open failed: 409"))
})
const { result } = renderHook(() => useSnapChat("s1"), { wrapper })
await result.current.send("hi")
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining("이미 생성 중"))
})
it("open 실패로 snapStream 이 reject 해도 send 는 throw 하지 않는다", async () => {
// sse.ts 는 open 실패 시 onError 를 부른 뒤 promise 도 reject 한다(이중 신호).
// send 는 그 rejection 을 삼켜 unhandled rejection 을 막아야 한다.
vi.mocked(stream.snapStream).mockRejectedValue(new Error("SSE open failed: 500"))
const { result } = renderHook(() => useSnapChat("s1"), { wrapper })
await expect(result.current.send("hi")).resolves.toBeUndefined()
})
it("이미 스트리밍 중이면 두 번째 send 는 무시(중복 가드)", async () => {
useSnapChatStore.getState().setStreaming(true)
const { result } = renderHook(() => useSnapChat("s1"), { wrapper })
await result.current.send("hi")
expect(stream.snapStream).not.toHaveBeenCalled()
})
})
- Step 2: 실패 확인
Run: npx vitest run src/features/snap/hooks/useSnapChat.test.tsx
Expected: FAIL — title 미갱신 / 409 분기 없음 / 가드 없음
- Step 3: 구현 —
src/features/snap/hooks/useSnapChat.ts전체 교체
import { useCallback } from "react"
import { toast } from "sonner"
import { useQueryClient, type QueryClient } from "@tanstack/react-query"
import { useSnapChatStore } from "../store/snapChatStore"
import { snapStream } from "../api/snap.stream"
import type { SnapSession, SnapSessionDetail } from "../contract/types"
// title 이벤트 → react-query 세션 캐시(목록 + 상세)의 title 갱신.
function patchSessionTitle(
queryClient: QueryClient,
sessionId: string,
title: string,
): void {
queryClient.setQueryData<SnapSession[]>(["snap", "sessions"], (prev) =>
prev?.map((s) => (s.id === sessionId ? { ...s, title } : s)),
)
queryClient.setQueryData<SnapSessionDetail>(["snap", "session", sessionId], (prev) =>
prev ? { ...prev, title } : prev,
)
}
/** store + snapStream 배선 — 전송/중단. sessionId 는 현재 열린 세션. */
export function useSnapChat(sessionId: string) {
const queryClient = useQueryClient()
const send = useCallback(
async (text: string) => {
const store = useSnapChatStore.getState()
if (store.isStreaming) return // 이 클라이언트가 이미 생성 중 — 중복 전송 차단
store.currentController?.abort()
store.addUserMessage(text)
store.startAssistantMessage()
store.setStreaming(true)
const ctrl = new AbortController()
store.setController(ctrl)
try {
await snapStream(
{ sessionId, content: text },
{
onToken: (d) => useSnapChatStore.getState().appendChunk(d),
onDone: () => {},
onTitle: (title) => patchSessionTitle(queryClient, sessionId, title),
onError: (e) => {
if (e.message.includes("409")) {
toast.error("이미 생성 중인 세션이야. 잠깐 기다렸다 다시 보내.")
} else {
toast.error(`스트림 오류: ${e.message}`)
}
},
},
{ signal: ctrl.signal },
)
} catch {
// sse.ts 는 open 실패(409/5xx) 시 onError 를 부른 뒤 promise 도 reject 한다.
// 오류 표시는 위 onError 에서 이미 함 → 여기선 rejection 만 삼켜 unhandled 방지.
// abort 는 라이브러리가 resolve 처리하므로 여기로 안 옴.
} finally {
const s = useSnapChatStore.getState()
if (s.currentController === ctrl) {
s.setController(null)
s.setStreaming(false)
}
}
},
[sessionId, queryClient],
)
const stop = useCallback(() => useSnapChatStore.getState().stop(), [])
return { send, stop }
}
- Step 4: 통과 확인
Run: npx vitest run src/features/snap/hooks/useSnapChat.test.tsx
Expected: PASS (3 tests)
- Step 5: lint + 커밋
npx eslint src/features/snap/hooks/useSnapChat.ts src/features/snap/hooks/useSnapChat.test.tsx
git add src/features/snap/hooks/useSnapChat.ts src/features/snap/hooks/useSnapChat.test.tsx
git commit -m "feat(snap): patch session title on title event, handle 409 and dup send"
Task T8: 목업 제거
Files:
- Delete:
src/features/snap/mock/sessions.ts,src/features/snap/mock/conversations.ts,src/features/snap/mock/stream.ts,src/features/snap/mock/mock.test.ts
Interfaces:
-
Consumes: 없음 (T3 에서
snap.api가, T6 에서snap.stream이 이미 mock import 제거) -
Produces: 없음
-
Step 1: 잔여 import 확인 (없어야 함)
Run: git grep -n "mock/sessions\|mock/conversations\|mock/stream\|MOCK_SESSIONS\|MOCK_CONVERSATIONS\|mockStream" src/
Expected: 매치 없음(삭제할 mock 파일 자신 제외). 매치 나오면 그 파일부터 정리.
- Step 2: 삭제
git rm src/features/snap/mock/sessions.ts src/features/snap/mock/conversations.ts src/features/snap/mock/stream.ts src/features/snap/mock/mock.test.ts
- Step 3: 타입/빌드 확인
Run: npx tsc --noEmit
Expected: 에러 없음 (dangling import 없음)
- Step 4: 커밋
git commit -m "chore(snap): remove mock session/conversation/stream data"
Wave 경계 게이트
각 Wave 끝에서:
- W1 후:
npx vitest run src/lib/auth src/lib/streaming/streamLLM.test.ts src/features/snap/api/snap.api.test.tsx→ 전부 PASS - W2 후:
npx vitest run src/lib/api/client.test.ts src/lib/streaming/sse.test.ts src/features/snap/api/snap.stream.test.ts→ 전부 PASS - W3 후:
npm run build→ 성공 +npx vitest run src/features/snap→ PASS
CLAUDE.md 5·8번: 각 Wave 완료 후 docs/working/snap-backend-connect-wave-N.md 떨어뜨리고, 디버깅 사건 있었으면 docs/troubleshootings/ 기록.
검증 (수동 · 실제 백엔드)
- base-backend 로컬 8001 기동 + LLM(NVIDIA NIM) 설정 확인.
npm run dev→ http://localhost:15173 → 로그인./snap→ 세션 목록이 실제 DB 세션으로 뜸 (mock 아님)./snap/new→ 첫 메시지 전송 → 실제 LLM 토큰이 타이핑되듯 스트리밍 → 완료 후 사이드바 제목이 LLM 이 지은 제목으로 자동 변경 (title 이벤트).- 기존 세션 재진입 → 과거 메시지 렌더 정상.
- 생성 중 같은 세션에 재전송 → 중복 가드 또는 409 toast.
- Network 탭:
POST /api/v1/chat/stream이text/event-stream으로 열리고 쿠키 전송됨.
미룸 (범위 밖 — 후속 spec)
- rename(PATCH)/delete/search 엔드포인트
subagent_start/subagent_done진행 UI,usage토큰 카운터- Bearer 모드 refresh(.NET 호스트 위임) 실제 배선 — 호스트 생기면