블로그로 돌아가기

POST로 SSE를 받고 Zustand를 둘로 나눈 실시간 채팅 구조

SSEZustandReactNext.jsTanStack QueryStreaming

2026-10 개정: 지금의 Plit 코드에 맞춰 고쳐 썼다. emotion 이벤트와 응답 캐릭터 결정 단계가 빠지고 dialogue_cut·호감도 델타 이벤트가 들어왔으며, 에러 처리는 "이번 턴만 되돌리기"와 끊긴 스트림 복구 중심으로 바뀌었다.

도입

AI 캐릭터와 1:1로 대화하는 채팅 플랫폼에서 UX를 가장 크게 좀먹는 건 응답 지연이다. LLM이 답을 끝까지 만드는 동안 사용자를 빈 화면 앞에 세워 둘 수는 없다. 스트리밍이 답이다. 나레이션과 대사를 만들어지는 족족 화면에 올리면 첫 글자가 뜨는 시점이 답 전체가 끝나는 시점보다 훨씬 앞당겨진다.

그런데 브라우저 표준 스트리밍 API인 EventSource는 GET 요청만 보낸다. 채팅 메시지를 보내려면 사용자 입력을 request body에 실어야 하고, 그러려면 POST여야 한다. 메시지를 URL 쿼리 파라미터에 밀어 넣는 방법은 길이 제한에서도 보안에서도 답이 아니다.

Plit은 fetch() + ReadableStream으로 POST 기반 SSE 클라이언트를 직접 짰다. 그 구현과 함께 Zustand 스토어 분리 전략, TanStack Query 통합, 에러 처리 패턴을 실제 코드로 남긴다.


POST 기반 SSE 클라이언트

EventSource의 한계

EventSource는 서버에서 클라이언트로 한 방향으로 흘려보내는 표준 API다. 제약이 하나 있는데, 이게 꽤 아프다.

// 브라우저 표준 EventSource: GET만 가능
const es = new EventSource('/api/messages?content=hello');
// ❌ body를 보낼 수 없다
// ❌ 커스텀 헤더(Authorization)를 설정할 수 없다

채팅에서는 둘 다 필요하다. 메시지 본문은 body로 보내야 하고, Clerk 인증 토큰은 Authorization 헤더에 실어야 한다.

헤더 제약은 GET에도 걸린다. 같은 세션을 다른 기기에서 열어 두면 새 메시지를 받아 오는 동기화 구독(GET /sessions/:id/subscribe)이 있는데, 이것도 인증 헤더가 필요해서 EventSource를 쓰지 않고 같은 fetch() 방식으로 읽는다. 연결이 끊기면 3초 뒤 다시 붙고, 서버는 30초마다 :heartbeat 주석 줄을 보내 프록시가 유휴 연결을 끊지 못하게 한다.

fetch() + ReadableStream 구현

메시지 전송용 sendMessageSSE는 lib/sse.ts의 함수 하나, 80줄 남짓이다. fetch()로 POST 요청을 보내고, 응답의 ReadableStream을 손으로 읽고, SSE 프로토콜(event: / data: 라인)을 직접 파싱한다. 아래는 그 함수를 줄인 것이다.

// frontend/src/lib/sse.ts: POST 기반 SSE 클라이언트 (발췌)
export function sendMessageSSE(
  sessionId: string,
  content: string,
  onEvent: SSECallback,
  mentionedCharacter?: string,
  forceAdvanceEvent?: boolean,
  onStreamEnd?: (reason: SSEStreamEndReason, status?: number) => void,
): AbortController {
  const controller = new AbortController();
  const body: Record<string, string | boolean> = { content };
  // ... mentionedCharacter, forceAdvanceEvent 는 있을 때만 싣는다

  (async () => {
    const headers: Record<string, string> = { 'Content-Type': 'application/json', ...(await getClientHeaders()) };
    const tokenGetter = getTokenGetter();
    if (tokenGetter) {
      const token = await tokenGetter();
      if (token) headers['Authorization'] = `Bearer ${token}`;
    }
    const cid = getClientId();
    if (cid) headers['X-Client-Id'] = cid; // 동기화 방송에서 보낸 기기를 빼는 데 쓴다

    let res: Response;
    try {
      res = await fetch(`${apiUrl}/sessions/${sessionId}/messages`, {
        method: 'POST',
        headers,
        body: JSON.stringify(body),
        signal: controller.signal,
      });
    } catch (err) {
      onStreamEnd?.((err as Error).name === 'AbortError' ? 'aborted' : 'fetch_error');
      return;
    }

    if (!res.ok || !res.body) {
      // 스트림이 아예 열리지 않은 실패는 상태 코드가 유일한 단서다 (429 = 한도 소진)
      onStreamEnd?.('fetch_error', res.status);
      return;
    }

    const reader = res.body.getReader();
    const decoder = new TextDecoder();
    let buffer = '';

    try {
      while (true) {
        const { done, value } = await reader.read();
        if (done) break;

        buffer += decoder.decode(value, { stream: true });
        const lines = buffer.split('\n');
        buffer = lines.pop() || '';

        let currentEvent = 'message';
        for (const line of lines) {
          if (line.startsWith('event: ')) {
            currentEvent = line.slice(7).trim();
          } else if (line.startsWith('data: ')) {
            try {
              onEvent(currentEvent, JSON.parse(line.slice(6)));
            } catch {
              // 형식이 잘못된 데이터 건너뜀
            }
          }
        }
      }
      onStreamEnd?.('stream_closed');
    } catch (err) {
      onStreamEnd?.((err as Error).name === 'AbortError' ? 'aborted' : 'stream_closed');
    }
  })();

  return controller;
}

짚어 둘 설계 결정이 넷 있다.

비동기 즉시 실행 함수(IIFE)와 동기 반환. sendMessageSSE 자체는 동기 함수다. 안에서 async IIFE를 돌리되 AbortController는 곧바로 돌려준다. 그래서 호출자는 스트림이 시작되기도 전에 취소 핸들을 쥔다.

버퍼 기반 라인 파싱. TextDecoder가 뱉는 청크는 SSE 라인 경계와 맞아떨어지지 않는다. buffer에 쌓고 \n으로 자르되, 끝에 잘려 있는 라인 하나는 다음 청크와 이어 붙이라고 버퍼에 남겨 둔다. 다만 이 버퍼가 지키는 것은 줄 경계까지다. currentEvent는 청크마다 'message'로 다시 초기화되므로, 한 이벤트의 event: 줄과 data: 줄이 서로 다른 청크로 갈리면 그 데이터는 'message'로 분류되고 어떤 핸들러에도 걸리지 않는다. 서버가 프레임 하나(event: ...\ndata: ...\n\n)를 한 번의 write로 내보내서 보통은 같은 청크로 오지만, 보장은 아니다. 이벤트 경계(빈 줄)까지 지키려면 currentEvent를 루프 바깥으로 빼야 한다.

인증 토큰 브릿지. Clerk의 getToken()은 useAuth() 훅에서 나오는데 SSE 함수는 React 바깥에서 돈다. Providers.tsx의 TokenSync 컴포넌트가 setTokenGetter(() => getToken())로 함수 레퍼런스를 등록해 두고, SSE 함수는 getTokenGetter()로 꺼내 쓴다. 모듈 변수 하나에 함수를 넣어 두는 브릿지라 auth-token.ts는 열 줄이 안 된다.

스트림 종료 콜백. 마지막 인자 onStreamEnd는 스트림이 왜 끝났는지(aborted / fetch_error / stream_closed)와 상태 코드를 알려 준다. 정상 종료라면 done 이벤트가 이미 처리를 끝냈겠지만, done도 error도 없이 연결이 끊기는 경우가 있다. 그 복구는 아래 "에러 처리" 절에서 다룬다.

같은 파싱 루프는 sendIdleNudgeSSE(캐릭터가 먼저 말을 거는 요청)와 동기화 구독용 parseSSEStream에도 들어 있다. 한 파일 안에서 세 번 반복되는 셈이다.


SSE 이벤트 타입 설계

백엔드는 사용자 메시지 하나에 여러 종류의 SSE 이벤트를 차례로 흘린다. 이벤트 이름은 @plit/shared에 두고 백엔드(emit)와 프론트엔드(parse)가 같은 상수를 import한다. 메시지 전송 스트림에 실리는 것만 추리면 이렇다.

// packages/shared/src/sse-events.ts (메시지 전송 스트림에 쓰는 것만 발췌)
export const SSE_EVENTS = {
  NARRATION: 'narration',
  DIALOGUE: 'dialogue',
  SCENE_TRANSITION: 'scene_transition',
  AFFINITY_DELTA: 'intimacy_delta',
  LOCATION_CHANGE: 'location_change',
  CONVERSATION_PAUSE: 'conversation_pause',
  /** 턴이 끝난 뒤 캐릭터마다 고른 컷. done 앞에 온다 */
  DIALOGUE_CUT: 'dialogue_cut',
  DONE: 'done',
  ERROR: 'error',
  // ... 제작자 테스트 세션용 디버그, 기기 간 동기화(sync_*), 세션 공유(share_*) 이벤트
} as const;

AFFINITY_DELTA의 값이 'intimacy_delta'인 건 오타가 아니다. 호감도의 옛 이름이 친밀도였고, 심볼 이름은 바꿨지만 와이어 값은 프론트엔드와 앱이 새 이름을 받도록 배포된 뒤에 바꾸기로 했다. 백엔드(Render)와 프론트엔드(Vercel)가 따로 배포되니, 값을 먼저 바꾸면 그 시차 동안 델타가 조용히 사라진다. 그래서 chatStore는 상수를 거치지 않고 'intimacy_delta'와 'affinity_delta' 두 문자열을 모두 받는다.

POST 기반 SSE 이벤트 흐름: POST 요청 뒤 젬 선차감, narration·dialogue·scene_transition 스트리밍, 저장과 호감도 갱신, intimacy_delta·dialogue_cut·done 순서

이벤트별 역할과 처리 방식을 표로 정리했다.

이벤트데이터프론트엔드 처리
narration{ text }나레이션 메시지 추가
dialogue{ character, text }대사 메시지 추가, 응답 중인 캐릭터 표시
scene_transition{ text }장면 전환 메시지 추가
intimacy_delta{ score, delta, levelChanged?, newSkills? }호감도 점수 갱신, 레벨업 연출, 새로 열린 케미 스킬 알림
location_change장소 정보받지 않는다. 장소는 done의 currentLocation으로 갱신한다
conversation_pause{ pauseReason, resetMessages, currentLocation }캐릭터가 자리를 뜨는 식으로 대화가 끊기면, 백엔드가 이어서 만든 새 장면 메시지(resetMessages)를 붙인다
dialogue_cut{ character, lineIndex, image }이번 턴의 lineIndex번째 대사에 컷 이미지를 붙인다
done{ userMessageId, aiMessageIds, gems, gemSpent, memoryCreated, emotionCode, ... }사용자 메시지 ID 교체, AI 메시지에 DB ID 부착, 젬 잔액 반영, 캐시 무효화
error{ code, message?, gemRefunded?, ... }에러 코드별 분기 (모달, 토스트, 이번 턴 되돌리기)

순서에는 이유가 있다. 백엔드(backend/src/chat/chat-turn.service.ts)는 생성된 나레이션과 대사를 항목 단위로 바로 흘려보낸다. 생성이 끝나면 메시지를 저장하고 호감도를 갱신한 다음, 그 결과를 intimacy_delta, dialogue_cut, done 순서로 보낸다. 젬은 생성 전에 먼저 차감하고(선차감), 젬이 모자라면 생성 없이 error와 { blocked: true }인 done을 보내고 닫는다. 막힌 턴도 done으로 끝난다는 점이 프론트엔드 쪽 정리 코드를 단순하게 만든다.

늦게 오는 이벤트를 앞 메시지에 붙이기

dialogue_cut은 대사가 다 나간 뒤에 도착한다. 백엔드가 턴이 끝난 뒤 대사마다 캐릭터의 이미지 목록에서 컷을 한 장 고르기 때문이다. 고를 컷이 없으면 이 이벤트는 오지 않는다. 프론트엔드는 이미 화면에 있는 대사를 찾아 컷을 붙여야 한다.

// frontend/src/stores/chatStore.ts: dialogue_cut 핸들러
} else if (event === SSE_EVENTS.DIALOGUE_CUT) {
  // 턴이 끝난 뒤 도착하는 컷: 이번 턴의 lineIndex 번째 대사에 붙인다
  const d = data as { character: string; lineIndex: number; image: DialogueCut };
  set((state) => {
    const turnIds = new Set(state._currentTurnAiMsgIds);
    let seen = -1;
    const index = state.messages.findIndex(
      (m) => turnIds.has(m.id) && m.type === 'dialogue' && ++seen === d.lineIndex,
    );
    if (index === -1 || state.messages[index].character !== d.character) return {};
    const messages = [...state.messages];
    messages[index] = { ...messages[index], image: d.image, cutArrivedLive: true };
    return { messages };
  });
}

초기에는 감정 분석이 대사 생성 뒤 별도 LLM 호출로 돌아서 emotion 이벤트가 따로 왔고, 프론트엔드가 메시지 배열을 뒤에서부터 훑어 그 캐릭터의 마지막 대사에 감정을 붙였다. 지금은 감정 코드가 응답 생성 출력에 함께 실려(response-generation.service.ts의 emotionCode) 별도 이벤트 없이 done으로 온다. 남은 "늦게 오는 이벤트"인 dialogue_cut은 "마지막 대사"가 아니라 이번 턴에 붙인 메시지 ID 목록 안에서 몇 번째 대사인가로 자리를 찾는다. 대사가 여러 줄인 턴에서도, 그 사이 다른 이벤트가 끼어도 엉뚱한 줄에 붙지 않는다. 화자 이름까지 맞는지 한 번 더 확인하고, 어긋나면 아무것도 하지 않는다.

done은 스트림 끝에 한 번 오고, 이번 턴의 최종 결과를 담는다. 이걸 받으면 _processDone이 사용자 메시지의 임시 ID를 DB ID로 바꾸고, AI 메시지에는 DB ID를 dbId 필드로 따로 붙이고, 감정 코드와 속마음을 이번 턴의 메시지에 얹고, 젬 잔액과 캐시를 정리한다. 기억 생성 자체는 응답을 다 보낸 뒤 백엔드에서 돈다. done의 memoryCreated는 "이번 턴에 기억이 만들어질 것인가"를 미리 싸게 판정한 값이다.


Zustand 스토어 분리

Plit의 채팅 상태는 Zustand 스토어 두 개에 나눠 담았다.

chatStore       턴 생명주기 (메시지 배열, SSE 연결, 기기 간 동기화 구독, 유휴 넛지)
chatUIStore     화면 상태 (패널·모달 토글, 되감기 대상, 표시 모드, 호감도 점수와 레벨업 연출)

왜 분리하는가

Zustand는 useChatStore((s) => s.messages)처럼 셀렉터로 구독하면 그 슬라이스가 바뀔 때만 컴포넌트를 다시 그린다. 그래도 한 스토어에 메시지 스트림과 화면 토글을 같이 밀어 넣으면, 스토어를 통째로 구독하는 컴포넌트 하나만 있어도 사이드바 토글이 메시지 목록을 흔들고 새 메시지가 사이드바를 흔든다. 두 스토어로 나누면 그 실수가 구조적으로 막힌다.

chatStore가 비동기 로직을 떠안는다. sendMessage가 SSE를 열고, 콜백에서 이벤트별로 상태를 갱신하고, _processDone이 턴을 마무리한다. rewind는 API를 부른 뒤 메시지를 잘라 내고, init은 세션을 옮길 때 앞 세션의 스트림과 동기화 구독을 먼저 끊는다.

// frontend/src/stores/chatStore.ts: 상태 인터페이스 (발췌)
interface ChatState {
  sessionId: string;
  messages: DisplayMessage[];
  isLoading: boolean;
  suggestions: string[];
  respondingCharacter: string | null;
  /** CHILD_SEXUAL 확정으로 이 세션이 잠겼다. true 가 되면 새로고침 전까지 유지한다 */
  safetyLocked: boolean;

  // Internal refs (non-reactive, not subscribed by UI)
  _abortController: AbortController | null;
  _currentUserMsgId: string | null;
  _currentTurnAiMsgIds: string[];
  _syncCleanup: (() => void) | null;
  _embedded: boolean;

  sendMessage: (content: string, mentionedCharacter?: string, forceAdvanceEvent?: boolean) => void;
  rewind: (messageId: string) => Promise<string>;
  cancel: () => void;
  reset: () => void;
  // ...
}

chatUIStore는 이름만큼 순수하지는 않다. 패널·모달 토글 말고도 호감도 점수(affinityScore), 레벨업 연출 정보, 현재 장소처럼 채팅 화면 여러 곳이 읽는 값이 여기 있다. 그래도 비동기 로직은 없다. 액션은 전부 set() 한 번이고, 사용자 취향 세 가지(suggestionsEnabled, displayMode, sessionListOpen)만 persist 미들웨어로 저장한다.

// frontend/src/stores/chatUIStore.ts: 상태 인터페이스 (발췌)
interface ChatUIState {
  sidebarOpen: boolean;
  rewindTargetId: string | null;
  selectedCharacter: Character | null;
  displayMode: DisplayMode; // 'messenger' | 'visual-novel'
  insufficientGemsOpen: boolean;
  affinityScore: number;
  levelUpInfo: { prevLevel: number; newLevel: number; score: number; levelName: string } | null;

  openSidebar: () => void;
  setRewindTargetId: (id: string | null) => void;
  setAffinityScore: (score: number, delta?: number) => void;
  // ... 모두 단순 set() 호출
}

내부 레퍼런스와 모듈 레벨 변수

chatStore에는 _abortController, _currentUserMsgId, _currentTurnAiMsgIds처럼 언더스코어로 시작하는 필드가 있다. 스토어 안에 선언돼 있지만 UI는 구독하지 않는 내부 레퍼런스다. AbortController나 이번 턴의 메시지 ID 목록은 리렌더링과 상관이 없는데도 SSE 콜백에서 집어야 해서 스토어에 넣었다. useRef는 쓸 수 없다. SSE 콜백이 React 컴포넌트 바깥에서 돌기 때문이다.

스토어에 넣을 필요조차 없는 값은 모듈 레벨 변수로 둔다. 유휴 넛지 타이머(idleTimer)와 메시지 ID 카운터가 그렇다.

// frontend/src/stores/chatStore.ts: 모듈 레벨 카운터
let msgIdCounter = 0;
function nextId(prefix: string) {
  return `${prefix}-${Date.now()}-${++msgIdCounter}`;
}

스트리밍 중에 붙는 메시지는 narr-1711234567890-1 같은 임시 ID를 단다. done이 오면 사용자 메시지만 DB ID로 바뀌고, AI 메시지는 임시 ID를 그대로 쓰면서 DB ID를 dbId에 따로 든다. 피드백처럼 서버에 메시지를 가리켜야 하는 기능은 dbId를 쓴다.


TanStack Query 통합

Plit에서 TanStack Query와 Zustand는 하는 일이 겹치지 않는다.

TanStack QueryZustand
역할서버 상태 캐싱 + 동기화클라이언트 실시간 상태
데이터메시지 히스토리, 기억 목록, 젬 잔액·거래 내역현재 스트리밍 메시지, 로딩 상태, 호감도 점수
갱신자동 refetch + 캐시 무효화SSE 이벤트 콜백에서 직접 set

호감도 점수가 Zustand 쪽에 있는 건 intimacy_delta 이벤트가 이미 새 점수를 들고 오기 때문이다. 서버에 다시 물을 이유가 없으니 chatUIStore.setAffinityScore()로 바로 넣는다.

Zustand에서 TanStack Query 캐시를 직접 건드리는 대목이 재미있다. chatStore는 React 바깥에서 도니까 훅을 못 쓴다. 대신 브라우저용 queryClient 인스턴스를 그냥 import한다. queryClient.ts는 서버에서는 렌더마다 새 클라이언트를 만들고, 브라우저에서는 Providers와 같은 인스턴스 하나를 돌려준다.

// frontend/src/stores/chatStore.ts: _processDone 의 캐시 처리 (발췌)
import { queryClient } from '@/lib/queryClient';
import { queryKeys, invalidateGemDerived } from '@/lib/queries';

if (typeof doneData.gems === 'number') {
  queryClient.setQueryData(queryKeys.gems.balance, {
    balance: doneData.gems,
    updatedAt: new Date().toISOString(),
  });
  invalidateGemDerived(queryClient); // 만료 예정·거래 내역은 다시 읽는다
}

queryClient.invalidateQueries({ queryKey: queryKeys.history(state.sessionId) });
if (doneData.memoryCreated) {
  queryClient.invalidateQueries({ queryKey: queryKeys.memories(state.sessionId) });
}

queryKeys는 팩토리 패턴으로 관리한다. 키에 무엇을 넣느냐가 무효화 범위를 정한다.

// frontend/src/lib/queries.ts: 쿼리 키 팩토리 (발췌)
export const queryKeys = {
  affinity: (characterId: string) => ['affinity', characterId] as const,
  gems: {
    balance: ['gems', 'balance'] as const,
    transactionsAll: ['gems', 'transactions'] as const,
    // ...
  },
  history: (sessionId: string) => ['history', sessionId] as const,
  memories: (sessionId: string) => ['memories', sessionId] as const,
  // ...
};

히스토리는 세션 단위라 세션 ID로, 호감도는 사용자×캐릭터 관계라 캐릭터 ID로 키를 만든다. 젬은 계정 단위라 키에 ID가 없다. 젬 쪽에는 invalidateGemHoldings()(잔액 + 만료 예정 + 거래 내역)와 invalidateGemDerived()(잔액을 뺀 둘)라는 묶음 헬퍼를 둬서, ['gems'] 접두로 상품 목록·단가 정책까지 한꺼번에 날리는 일을 막는다.

둘을 가르는 기준은 간단하다. 서버가 정확한 값을 이미 줬으면 setQueryData, 서버에 다시 물어봐야 하면 invalidateQueries. done이 준 잔액은 그대로 쓰고, done에 없는 거래 내역은 다시 읽는다.

done 뒤에는 대사 추천도 한 번 요청한다(사용자가 자동완성을 켜 둔 경우만). done에 실린 traceparent를 그 요청에 넘겨서, 추천 생성이 방금 턴과 같은 Langfuse 트레이스에 묶인다.


에러 처리와 취소

AbortController로 스트림 취소

sendMessageSSE가 돌려준 AbortController는 chatStore._abortController에 넣어 둔다. cancel()은 스트림만 끊고, reset()과 세션을 옮길 때 부르는 init()은 동기화 구독과 유휴 타이머까지 정리한다.

// frontend/src/stores/chatStore.ts: 취소와 정리 (발췌)
cancel: () => {
  get()._abortController?.abort();
  set({ isLoading: false, _abortController: null });
},

reset: () => {
  const { _abortController, _syncCleanup } = get();
  _abortController?.abort(); // 진행 중인 스트림 정리
  _syncCleanup?.();          // 기기 간 동기화 구독 정리
  get().clearIdleTimer();
  set({ /* 모든 상태 초기화 */ });
},

에러 코드별 분기 처리

error 이벤트는 메시지 한 줄이 아니라 에러 코드(@plit/shared의 SSE_ERROR_CODES)와 환불 여부를 함께 들고 온다. 프론트엔드의 분기는 코드가 많아도 동작은 세 갈래로 모인다.

동작코드화면
이번 턴을 통째로 되돌린다INSUFFICIENT_GEMS, INPUT_FILTER_BLOCKED, OUTPUT_FILTER_REVERTED, AI_GENERATION_FAILED, AI_GENERATION_FAILED_REFUND_ERROR이번 턴 메시지를 지우고 입력창에 원문을 되돌린다. 젬 부족은 전용 모달
메시지를 두고 스트림을 계속 받는다SAFETY_SUBSTITUTED, OUTPUT_FILTER_BLOCKED, AI_GENERATION_PARTIAL, AI_RESPONSE_SAVE_FAILED토스트만 띄우고 뒤따르는 이벤트를 기다린다
세션·계정 상태가 바뀌었다USER_SUSPENDED, ACCOUNT_BANNED, STUDIO_BANNED, CHARACTER_UNAVAILABLE, SESSION_SAFETY_LOCKED보낸 메시지를 거두고, 필요하면 입력을 막거나 사용자 정보를 다시 읽는다

첫 갈래의 핵심은 "이번 턴만" 지우는 것이다.

// frontend/src/stores/chatStore.ts
// 현재 턴의 유저 메시지 + 이번 스트림에서 도착한 AI 메시지만 제거 (이전 턴은 건드리지 않음)
function removeCurrentTurnMessages(
  messages: DisplayMessage[],
  userMsgId: string | null,
  aiMsgIds: string[],
): DisplayMessage[] {
  const aiIdSet = new Set(aiMsgIds);
  return messages.filter((m) => m.id !== userMsgId && !aiIdSet.has(m.id));
}

// sendMessage 의 SSE 콜백 안
if (errorData.code === SSE_ERROR_CODES.AI_GENERATION_FAILED) {
  set((state) => ({
    messages: removeCurrentTurnMessages(state.messages, state._currentUserMsgId, state._currentTurnAiMsgIds),
  }));
  restoreInput(); // 보낸 문장을 입력창에 되돌린다
  // ... 토스트, 환불됐으면 invalidateGemHoldings(queryClient)
  return;
}

초기에는 narr-·dial- 같은 ID 접두사로 이번 턴의 AI 메시지를 골랐다. 그런데 AI 메시지는 done 뒤에도 임시 ID를 그대로 쓰기 때문에, 접두사로 고르면 지난 턴의 응답까지 함께 지워진다. 그래서 스트림이 메시지를 붙일 때마다 그 ID를 _currentTurnAiMsgIds에 쌓고, 지울 때는 그 목록만 본다.

둘째 갈래의 대표는 SAFETY_SUBSTITUTED다. 안전 판정이 이번 턴의 응답을 정형문으로 바꾸면 백엔드는 이 코드를 보낸 뒤 정형문을 평범한 dialogue로 이어 보낸다. 프론트엔드는 사용자 메시지는 남기고 이미 흘러온 AI 메시지만 버린 다음, eventHandled를 다시 false로 돌려 뒤따르는 이벤트를 받는다. 정형문은 모델이 쓰지 않고 @plit/shared에 고정된 문장이다.

셋째 갈래의 USER_SUSPENDED·ACCOUNT_BANNED는 어드민이 기간과 사유를 정해 걸어 둔 제재를 대화 진입 시 집행한 결과다. 코드가 스스로 계정을 정지하는 경로는 없다.

done도 error도 없이 끊긴 스트림

네트워크가 끊기거나 서버가 응답 도중 죽으면 done도 error도 오지 않는다. 그러면 isLoading이 true로 남아 입력창이 영원히 잠긴다. sendMessageSSE의 onStreamEnd가 이 구멍을 막는다.

// frontend/src/stores/chatStore.ts: sendMessage 의 onStreamEnd (발췌)
(reason, status) => {
  if (reason === 'aborted') return;
  if (!get().isLoading) return; // done 이나 error 가 이미 정리했다

  // DONE/ERROR 이벤트 없이 스트림이 끊김 → 이번 턴 메시지 제거 후 복구
  set((state) => ({
    messages: removeCurrentTurnMessages(state.messages, state._currentUserMsgId, state._currentTurnAiMsgIds),
    isLoading: false,
    respondingCharacter: null,
    _abortController: null,
    _currentUserMsgId: null,
    _currentTurnAiMsgIds: [],
  }));
  restoreInput();

  // 서버 상태와 동기화
  queryClient.invalidateQueries({ queryKey: queryKeys.history(sessionId) });
  invalidateGemHoldings(queryClient);
  // 429 는 스트림이 열리기 전에 끊긴 것이라 error 이벤트가 오지 않는다: 상태 코드로 문구를 고른다
}

화면에서는 이번 턴을 지우지만, 서버는 이미 메시지를 저장했을 수도 있다. 그래서 추측하지 않고 히스토리와 젬 캐시를 무효화해 서버가 가진 상태로 다시 맞춘다. 저장된 턴이었다면 다음 refetch 때 돌아온다.

동적 import로 스토어끼리 말 걸기

에러 핸들러는 chatUIStore를 정적으로 import하지 않는다.

// frontend/src/stores/chatStore.ts: INSUFFICIENT_GEMS 분기 (발췌)
import('@/stores/chatUIStore').then(({ useChatUIStore }) => {
  useChatUIStore.getState().showInsufficientGems(
    { required: errorData.required ?? getGemCost(GemTransactionReason.CHAT_MESSAGE) ?? 0, current: errorData.current ?? 0 },
    { sessionId, content, mentionedCharacter, forceAdvanceEvent }, // 출석 젬을 받은 뒤 한 번 다시 보낸다
  );
});

chatStore가 chatUIStore에서 가져다 쓰는 건 타입 하나(import type)뿐이고, 실제 스토어는 필요한 순간에 import()로 끌어온다. 입력창 복원(setPrefill), 호감도 갱신, 장소 갱신도 같은 방식이다. 두 스토어 사이에 정적 의존을 만들지 않으면서 말은 통하게 하는 방법이다.


정리

POST 기반 SSE 스트리밍과 Zustand 상태 관리를 붙이면서 확인한 것들을 정리한다.

  1. EventSource의 GET 제약은 fetch + ReadableStream으로 우회한다. 메시지 전송 함수는 80줄 남짓이고, 파싱은 event:와 data: 두 줄만 처리하면 된다. 인증 헤더가 필요한 GET 구독도 같은 방식으로 읽는다.

  2. 버퍼는 줄 경계를 지키지만 이벤트 경계까지 지키지는 않는다. 잘린 마지막 줄을 버퍼에 남기는 한 줄이 줄 단위 유실을 막는다. event:와 data:가 다른 청크로 갈리는 경우까지 막으려면 이벤트 이름도 청크를 넘어 들고 있어야 한다.

  3. 늦게 오는 이벤트는 "이번 턴의 몇 번째"로 자리를 찾는다. dialogue_cut은 이번 턴 메시지 ID 목록과 lineIndex로 붙는다. "마지막 대사"를 뒤에서 찾는 방식보다 어긋날 틈이 적다.

  4. Zustand 스토어는 생명주기와 화면 상태로 나눈다. 비동기 턴 처리(chatStore)와 set()뿐인 화면 상태(chatUIStore)를 나누면 불필요한 리렌더링이 구조적으로 막힌다.

  5. React 바깥의 비동기 로직에는 내부 필드와 모듈 레벨 변수를 쓴다. SSE 콜백은 컴포넌트 바깥에서 돌기 때문에 useRef 대신 스토어의 언더스코어 필드와 모듈 레벨 카운터·타이머로 상태를 추적한다.

  6. TanStack Query와 Zustand는 경쟁하지 않는다. 서버가 값을 준 것(done의 젬 잔액, intimacy_delta의 점수)은 바로 넣고, 주지 않은 것(히스토리, 거래 내역)만 무효화한다. 쿼리 키에 넣는 ID가 무효화 범위를 정한다.

  7. 에러는 코드가 아니라 동작으로 묶는다. 이번 턴 되돌리기, 계속 받기, 세션 상태 변경의 세 갈래다. 되돌릴 때는 이번 턴에 붙인 ID 목록만 지운다.

  8. done도 error도 없이 끊긴 스트림을 따로 처리한다. 종료 콜백에서 로딩을 풀고, 화면을 추측으로 고치지 말고 서버 상태로 다시 맞춘다.

  9. 동적 import로 크로스 스토어 의존을 끊는다. 필요한 순간에 import()로 상대 스토어를 끌어오면 정적 의존 그래프가 깨끗하게 남는다.

관련 프로젝트

플릿(Plit) - AI 캐릭터 채팅 플랫폼

여성향·남성향 캐릭터를 골라 1:1로 대화하는 AI 캐릭터 채팅 플랫폼. 용도별 모델 배정 AI 파이프라인과 pgvector 장기 기억, 젬 결제를 1인 풀스택으로 구축·운영.

같은 프로젝트의 다른 글