블로그로 돌아가기

POST 기반 SSE 스트리밍과 Zustand 스토어 분리로 설계한 실시간 채팅 아키텍처

SSEZustandReactNext.jsTanStack QueryStreaming

도입

AI 캐릭터와 대화하는 채팅 플랫폼에서 가장 큰 UX 병목은 응답 지연이다. LLM이 전체 응답을 생성하는 데 2~5초가 걸리는데, 사용자가 그 시간을 빈 화면 앞에서 기다리게 할 수는 없다. 스트리밍이 답이다. 나레이션과 대사를 생성되는 즉시 화면에 띄우면, 체감 대기 시간은 수백 밀리초로 줄어든다.

문제는 브라우저의 표준 스트리밍 API인 EventSourceGET 요청만 지원한다는 것이다. 채팅 메시지를 보내려면 사용자 입력을 request body에 담아야 하고, 그러려면 POST가 필요하다. URL 쿼리 파라미터에 메시지를 넣는 건 길이 제한과 보안 양쪽에서 허용되지 않는다.

Plitfetch() + ReadableStream으로 POST 기반 SSE 클라이언트를 직접 구현했다. 이 글에서는 그 구현과 함께, Zustand 스토어 분리 전략, TanStack Query와의 통합, 그리고 에러 처리 패턴을 실제 코드 기반으로 기록한다.


핵심 1: POST 기반 SSE 클라이언트

EventSource의 한계

EventSource는 서버에서 클라이언트로의 단방향 스트리밍을 위한 표준 API다. 그러나 치명적인 제약이 하나 있다.

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

채팅 플랫폼에서는 이 두 가지가 모두 필요하다. 메시지 본문을 body로 전달해야 하고, Clerk 인증 토큰을 Authorization 헤더에 실어야 한다.

fetch() + ReadableStream 구현

Plit의 SSE 클라이언트는 65줄짜리 단일 함수다. fetch()로 POST 요청을 보내고, 응답의 ReadableStream을 수동으로 읽으며, SSE 프로토콜(event: / data: 라인)을 직접 파싱한다.

// lib/sse.ts — POST 기반 SSE 클라이언트
export function sendMessageSSE(
  sessionId: string,
  content: string,
  onEvent: SSECallback,
  mentionedCharacter?: string,
): AbortController {
  const controller = new AbortController();
  const apiUrl = process.env.NEXT_PUBLIC_API_URL;

  (async () => {
    // Clerk 토큰을 헤더에 주입
    const headers: Record<string, string> = { 'Content-Type': 'application/json' };
    const tokenGetter = getTokenGetter();
    if (tokenGetter) {
      const token = await tokenGetter();
      if (token) headers['Authorization'] = `Bearer ${token}`;
    }

    // POST 요청 — body에 메시지를 담는다
    const res = await fetch(`${apiUrl}/sessions/${sessionId}/messages`, {
      method: 'POST',
      headers,
      body: JSON.stringify({ content, mentionedCharacter }),
      signal: controller.signal, // 취소 지원
    });

    if (!res.ok || !res.body) return;

    // ReadableStream을 수동으로 읽는다
    const reader = res.body.getReader();
    const decoder = new TextDecoder();
    let buffer = '';

    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() || ''; // 마지막 불완전한 라인은 버퍼에 보관

      // SSE 프로토콜 수동 파싱
      let currentEvent = 'message';
      for (const line of lines) {
        if (line.startsWith('event: ')) {
          currentEvent = line.slice(7).trim();
        } else if (line.startsWith('data: ')) {
          try {
            const data = JSON.parse(line.slice(6));
            onEvent(currentEvent, data);
          } catch { /* 형식이 잘못된 데이터 건너뜀 */ }
        }
      }
    }
  })();

  return controller; // 호출자가 스트림을 취소할 수 있도록 반환
}

핵심 설계 포인트를 짚어 보면 다음과 같다.

비동기 즉시 실행 함수(IIFE)와 동기 반환. sendMessageSSE는 동기 함수다. 내부에서 async IIFE를 실행하되, AbortController는 즉시 반환한다. 호출자가 스트림 시작 전에도 취소 핸들을 가질 수 있다.

버퍼 기반 라인 파싱. TextDecoder가 반환하는 청크는 SSE 라인 경계와 일치하지 않는다. buffer에 누적하고 \n으로 분리하되, 마지막 불완전한 라인은 다음 청크와 합쳐지도록 버퍼에 남겨 둔다.

인증 토큰 브릿지. Clerk의 getToken()은 React 훅이지만, SSE 함수는 React 바깥에서 실행된다. auth-token.tssetTokenGetter()로 함수 레퍼런스를 등록하고, SSE 함수에서 getTokenGetter()()로 호출하는 브릿지 패턴으로 해결했다.


핵심 2: SSE 이벤트 타입 설계

백엔드의 AI 파이프라인은 하나의 사용자 메시지에 대해 여러 종류의 SSE 이벤트를 순차적으로 보낸다. 프론트엔드는 이벤트 타입에 따라 UI를 다르게 처리한다.

// 프론트엔드·백엔드 공통 이벤트 상수
export const SSE_EVENTS = {
  NARRATION: 'narration',   // 나레이션 텍스트
  DIALOGUE: 'dialogue',     // 캐릭터 대사 + 화자 이름
  EMOTION: 'emotion',       // 캐릭터 감정 상태 (happy, shy, angry 등)
  DONE: 'done',             // 스트림 종료 + 메타데이터 일괄 전달
  ERROR: 'error',           // 에러 (코드 + 환불 정보)
} as const;

POST 기반 SSE 이벤트 흐름 — 사용자 POST 요청에서 narration, dialogue, emotion, done 이벤트까지

각 이벤트의 역할과 프론트엔드 처리 방식은 다음과 같다.

이벤트데이터프론트엔드 처리
narration{ text }나레이션 메시지 버블 추가
dialogue{ character, text }캐릭터별 대사 버블 추가 + 응답 중인 캐릭터 표시
emotion{ character, emotion }마지막 대사 버블에 감정 배지 역방향 부착
done{ userMessageId, aiMessageIds, affinities, energy, ... }임시 ID → DB ID 교체, 호감도 업데이트, 캐시 무효화
error{ code, message, energyRefunded, gemRefunded }에러 코드별 분기 처리 (모달, 토스트, 메시지 제거)

emotion 이벤트의 처리가 흥미롭다. 감정 분석은 대사 생성 이후에 별도 LLM 호출로 수행되므로, dialogue 이벤트보다 늦게 도착한다. 프론트엔드에서는 메시지 배열을 역순으로 탐색하여 해당 캐릭터의 마지막 대사를 찾고, 거기에 감정 정보를 부착한다.

// chatStore.ts — emotion 이벤트 핸들러
} else if (event === SSE_EVENTS.EMOTION) {
  const e = data as { character: string; emotion: string };
  set((state) => {
    const msgs = [...state.messages];
    // 역순 탐색으로 해당 캐릭터의 마지막 대사를 찾는다
    for (let i = msgs.length - 1; i >= 0; i--) {
      if (msgs[i].type === 'dialogue' && msgs[i].character === e.character) {
        msgs[i] = { ...msgs[i], emotion: e.emotion };
        break;
      }
    }
    return { messages: msgs };
  });
}

done 이벤트는 스트림의 마지막에 한 번 전송되며, 세션 상태의 최종 스냅샷을 담는다. 임시 ID를 DB가 부여한 실제 ID로 교체하고, 호감도 변동을 계산하며, TanStack Query 캐시를 무효화한다. 하나의 이벤트에 많은 후처리를 담은 이유는, 스트리밍 중에는 DB 저장이 완료되지 않았기 때문이다.


핵심 3: Zustand 스토어 분리

Plit의 채팅 상태는 두 개의 Zustand 스토어로 분리되어 있다.

chatStore       — 비즈니스 로직: 메시지 배열, SSE 생명주기, 호감도, 에피소드 상태
chatUIStore     — 순수 UI: 사이드바 토글, 스크롤 위치, 되감기 타겟, 설정 모달

왜 분리하는가

Zustand의 useChatStore((s) => s.messages)처럼 셀렉터로 구독하면, 해당 슬라이스가 변경될 때만 컴포넌트가 리렌더링된다. 그런데 단일 스토어에 비즈니스 상태와 UI 상태를 함께 넣으면, UI 토글 하나가 메시지 렌더링을 유발하거나, 새 메시지 수신이 사이드바 컴포넌트를 리렌더링하는 상황이 생긴다.

chatStore는 복잡한 비동기 로직을 담는다. sendMessage가 SSE를 시작하고, 콜백에서 이벤트별로 상태를 갱신하며, _processDone에서 호감도 계산과 캐시 무효화를 수행한다. rewindadvanceEpisode도 API 호출 후 상태를 동기화한다.

// chatStore.ts — 비즈니스 상태 인터페이스 (핵심 부분)
interface ChatState {
  sessionId: string;
  messages: DisplayMessage[];
  isLoading: boolean;
  affinities: AffinityScore[];
  affinityDeltas: Record<string, number>;
  respondingCharacter: string | null;
  isEpisodeEnded: boolean;

  // 내부 레퍼런스 — React 구독 대상이 아님
  _abortController: AbortController | null;
  _currentUserMsgId: string | null;
  _deltaClearTimer: ReturnType<typeof setTimeout> | null;

  sendMessage: (content: string) => void;
  rewind: (messageId: string) => Promise<string>;
  cancel: () => void;
}

chatUIStore는 순수 setter만 있다. 비동기 로직이 없고, 각 필드가 독립적이다.

// chatUIStore.ts — UI 전용 상태 (순수 setter)
interface ChatUIState {
  sidebarOpen: boolean;
  rewindTargetId: string | null;
  selectedCharacter: Character | null;
  displayMode: 'messenger' | 'visual-novel';
  insufficientGemsOpen: boolean;

  openSidebar: () => void;
  closeSidebar: () => void;
  setRewindTargetId: (id: string | null) => void;
  // ... 모두 단순 set() 호출
}

모듈 레벨 변수 패턴

chatStore에는 _abortController, _currentUserMsgId, _deltaClearTimer 같은 언더스코어 접두어 필드가 있다. 이것들은 스토어 안에 선언되어 있지만 UI가 구독하지 않는 내부 레퍼런스다. AbortController나 타이머 ID는 React 리렌더링과 무관하지만, sendMessage 콜백 안에서 접근해야 하므로 스토어에 넣었다. useRef를 쓸 수 없는 이유는, SSE 콜백이 React 컴포넌트 바깥에서 실행되기 때문이다.

ID 생성도 모듈 레벨 카운터로 처리한다.

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

SSE 스트리밍 중에 추가되는 메시지는 narr-1711234567890-1 같은 임시 ID를 받는다. done 이벤트가 도착하면 DB가 부여한 실제 ID로 교체된다.


핵심 4: TanStack Query 통합

Plit에서 TanStack Query와 Zustand는 명확히 역할이 구분된다.

TanStack QueryZustand
역할서버 상태 캐싱 + 동기화클라이언트 실시간 상태
데이터세션 정보, 메시지 히스토리, 호감도현재 스트리밍 메시지, 로딩 상태
갱신자동 refetch + 캐시 무효화SSE 이벤트 콜백에서 직접 set

핵심은 Zustand에서 TanStack Query 캐시를 직접 조작하는 패턴이다. chatStore는 React 바깥에서 실행되므로 훅을 쓸 수 없다. 대신 queryClient를 직접 import하여 캐시를 무효화한다.

// chatStore.ts — _processDone에서 캐시 무효화
import { queryClient } from '@/lib/queryClient';
import { queryKeys } from '@/lib/queries';

// done 이벤트 수신 시:
queryClient.invalidateQueries({ queryKey: queryKeys.history(sessionId) });
queryClient.invalidateQueries({ queryKey: queryKeys.affinity(sessionId) });
if (doneData.memoryCreated) {
  queryClient.invalidateQueries({ queryKey: queryKeys.memories(sessionId) });
}

queryKeys는 팩토리 패턴으로 관리된다. 세션 ID 기반으로 키를 생성하므로, 특정 세션의 캐시만 정확히 무효화할 수 있다.

// lib/queries.ts — 쿼리 키 팩토리
export const queryKeys = {
  history: (sessionId: string) => ['history', sessionId] as const,
  affinity: (sessionId: string) => ['affinity', sessionId] as const,
  memories: (sessionId: string) => ['memories', sessionId] as const,
  energy: { balance: ['energy', 'balance'] as const },
  gems: { balance: ['gems', 'balance'] as const },
  // ...
};

행동력 잔액처럼 즉시 반영이 필요한 값은 setQueryData로 낙관적 업데이트한다.

// done 이벤트에서 행동력 잔액을 즉시 갱신
if (doneData.energy != null) {
  queryClient.setQueryData(queryKeys.energy.balance, {
    balance: doneData.energy,
    nextResetAt: queryClient.getQueryData<{ nextResetAt?: string }>(
      queryKeys.energy.balance,
    )?.nextResetAt,
  });
}

무효화와 낙관적 업데이트를 구분하는 기준은 단순하다. 서버에서 정확한 값을 이미 받았으면 setQueryData, 서버에 다시 물어봐야 하면 invalidateQueries다.


핵심 5: 에러 처리와 취소

AbortController로 스트림 취소

sendMessageSSE가 반환하는 AbortControllerchatStore._abortController에 저장해 둔다. 사용자가 취소 버튼을 누르거나, 세션을 전환하거나, 컴포넌트가 언마운트될 때 cancel()을 호출하면 스트림이 즉시 중단된다.

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

reset: () => {
  const { _deltaClearTimer, _abortController } = get();
  if (_deltaClearTimer) clearTimeout(_deltaClearTimer);
  _abortController?.abort(); // 진행 중인 스트림 정리
  set({ /* 모든 상태 초기화 */ });
},

에러 코드별 분기 처리

error SSE 이벤트는 단순 에러 메시지가 아니라, 에러 코드와 환불 정보를 함께 전달한다. 프론트엔드는 코드별로 다른 UI를 보여 준다.

// chatStore.ts — 에러 코드별 처리 분기
if (errorData.code === 'INSUFFICIENT_GEMS') {
  // 젬 부족 → 전용 모달 표시
  import('@/stores/chatUIStore').then(({ useChatUIStore }) => {
    useChatUIStore.getState().showInsufficientGems({
      required: errorData.required ?? 0,
      current: errorData.current ?? 0,
    });
  });
  // 사용자 메시지를 채팅에서 제거 (전송되지 않은 것처럼)
  set((state) => ({
    messages: state.messages.filter((m) => m.id !== state._currentUserMsgId),
  }));
  return;
}

if (errorData.code === 'INPUT_FILTER_BLOCKED') {
  // 입력 필터 차단 → 토스트 + 행동력 환불 알림
  import('sonner').then(({ toast }) => {
    toast.error('부적절한 내용이 포함되어 있어 전송할 수 없습니다.');
  });
  return;
}

if (errorData.code === 'AI_GENERATION_FAILED') {
  // AI 생성 실패 → 사용자 메시지 + 부분 응답 모두 제거 + 환불 알림
  set((state) => ({
    messages: state.messages.filter((m) => {
      if (m.id === state._currentUserMsgId) return false;
      if (m.id.startsWith('narr-') || m.id.startsWith('dial-')) return false;
      return true;
    }),
  }));
  return;
}

여기서 주목할 점은 chatStore에서 chatUIStore를 동적 import하는 패턴이다. 두 스토어 사이에 정적 의존성을 만들지 않고, 에러가 발생한 시점에만 모달 스토어를 불러온다. 순환 참조를 피하면서도 크로스 스토어 통신을 가능하게 하는 방법이다.

에러 발생 시 환불된 재화(행동력, 젬)가 있으면 해당 TanStack Query 캐시를 무효화하여 잔액 표시를 갱신한다. 사용자 입장에서는 "메시지가 사라지고 행동력이 돌아왔다"는 일관된 경험을 받는다.


정리

POST 기반 SSE 스트리밍과 Zustand 상태 관리를 구현하면서 확인한 엔지니어링 포인트를 정리한다.

  1. EventSource의 GET 제약은 fetch + ReadableStream으로 우회한다. 65줄이면 충분하다. SSE 프로토콜 파싱은 event:data: 두 줄만 처리하면 된다.

  2. 버퍼 기반 파싱이 필수다. 네트워크 청크는 SSE 라인 경계와 일치하지 않는다. 불완전한 마지막 라인을 버퍼에 남겨 두는 한 줄이 데이터 유실을 방지한다.

  3. Zustand 스토어는 관심사별로 분리한다. 비즈니스 로직(chatStore)과 UI 토글(chatUIStore)을 나누면, 불필요한 리렌더링을 구조적으로 차단할 수 있다.

  4. React 바깥의 비동기 로직에는 모듈 레벨 변수를 쓴다. SSE 콜백은 React 컴포넌트 바깥에서 실행되므로, useRef 대신 스토어 내부의 언더스코어 필드나 모듈 레벨 카운터로 상태를 추적한다.

  5. TanStack Query와 Zustand는 경쟁하지 않는다. 서버 상태(히스토리, 호감도)는 TanStack Query, 실시간 스트리밍 상태(현재 메시지, 로딩)는 Zustand. queryClient를 직접 import하면 스토어에서도 캐시를 조작할 수 있다.

  6. 에러 이벤트는 코드와 환불 정보를 함께 전달한다. 프론트엔드가 에러 유형별로 적절한 UI(모달, 토스트, 메시지 제거)를 선택하고, 재화 캐시를 즉시 무효화하여 일관된 사용자 경험을 제공한다.

  7. 동적 import로 크로스 스토어 순환 참조를 피한다. chatStore에서 chatUIStore가 필요한 시점은 에러 발생 시뿐이다. import()를 에러 핸들러 안에서 호출하면 정적 의존 그래프를 깨끗하게 유지할 수 있다.