
글의 주제를 표현한 일러스트예요. 실제 서비스 화면은 아니에요.
쇼핑몰의 AI 상담 화면에는 질문을 받는 서버와 상담 세션을 관리하는 서버가 따로 있었어요. 프론트엔드에서는 두 서비스에 모두 연결해야 했고, 어느 쪽 백엔드도 수정할 수 없었어요.
- AI 챗 서비스: 질문을 보내면 AI 답을 토큰 단위로 스트리밍(SSE)해주는 백엔드.
- 세션 백엔드: 상담사 연결, 세션 관리, 대화 히스토리를 담당하는 또 다른 백엔드. 상담사 메시지와 상태 변화를 SSE로 내려줘요.
두 응답을 합쳐 달라고 맡길 곳도 없었어요. 애초에 별개 서비스라 한쪽에 다른 쪽 응답까지 받아 달라고 할 수는 없었거든요.
서버가 나눠서 보내는 메시지를 한 대화처럼 보이게 만드는 일은 클라이언트에 남았어요. 토큰을 직접 쌓던 첫 구현에서 패널을 나누고 다시 합쳤고, 상태 관리도 useReducer와 자체 store를 거쳐 @tanstack/ai로 옮겼어요.
순수 AI 토큰 스트리밍 → 상담사 버튼 패널 분리 → 키워드 통합 → useReducer 상태머신 → useSyncExternalStore 자체 store → @tanstack/ai 어댑터
처음에는 토큰을 받는 대로 보여 줬어요
src/modules/entities/chat/chatbot/
├─ api/chatbot.api.ts # AI 챗 서비스 /chat/stream 호출 + SSE 파싱
├─ model/use-chat-stream.hook.ts
├─ model/use-chat-session.hook.ts
└─ ui/ (MessageBubble, TypingIndicator, MarkdownContent ...)처음 만든 화면은 POST /chat/stream에 질문을 보내고 SSE 응답을 그리면 됐어요. 응답을 직접 파싱하면서 토큰이 올 때마다 메시지 끝에 붙였어요. API는 이미 정해져 있어서 프론트엔드가 받은 형식에 맞춰 처리했어요.
// 초기: AI 응답 스트림을 직접 파싱해 화면에 흘림
async function* parseSSEStream(stream: ReadableStream<Uint8Array>) {
const reader = stream.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() || '';
for (const line of lines) {
const t = line.trim();
if (t.startsWith('data: ')) yield JSON.parse(t.slice(6)) as StreamData;
}
}
}
// 토큰이 올 때마다 메시지 text 에 이어붙임 (= 진짜 스트리밍 UI)
onData: (data) =>
setMessages((prev) => updateMessage(prev, botMessageId, { text: current.text + data.content })),별도 스트리밍 라이브러리 없이 useMutation으로 요청하고, useState<Message[]>에 토큰을 이어 붙이는 방식이었어요.
서버 히스토리에는 이런 안내도 있었어요.
"무엇을 도와드릴까요? 상담사와 연결하려면 '상담원 연결'을 입력해 주세요."
AI 답변만 보여 주던 화면에 이 안내의 "상담원 연결"을 넣으려니, 다른 서비스의 메시지도 받아야 했어요.
상담사 연결 버튼을 추가했어요
상담사와 주고받는 메시지는 세션 백엔드의 SSE에서 왔어요. start로 세션을 열고 subscribe로 메시지와 상태를 구독하며, message와 end로 전송과 종료를 처리해요. AI가 답하던 대화를 이 세션으로 이어 줘야 했어요.
처음에는 서버가 나뉜 그대로 화면도 나눴어요. AiChatPanel은 AI 스트림을, CounselingPanel은 세션 SSE를 맡기고 상담사 연결 버튼으로 전환했어요.
// 상담사 연결이 '버튼'이던 시절: EventSource 로 세션 SSE 구독
const subscribeSse = useCallback((sid: string) => {
const es = fetchCounselingSubscribe(sid);
es.addEventListener('status', (e) => {
const { status } = safeParse(e.data);
if (status === 'counseling') append(createSystemMessage(GUIDE.accepted));
if (status === 'ended') append(createSystemMessage(GUIDE.ended));
});
es.addEventListener('message', (e) => {
const { message } = safeParse(e.data);
if (message?.trim()) append(createCounselorMessage(message));
});
}, []);패널을 바꾸자 메시지 목록과 스크롤, 세션도 함께 갈라졌어요. 사용자에게는 이어지는 대화인데 코드에서는 다른 화면으로 넘어가는 셈이었어요. createUserMessage, createCounselorMessage, createSystemMessage처럼 메시지를 만드는 함수와 타입도 출처마다 따로 생겼고요.
연결 키워드로 바꾸고 패널을 하나로 합쳤어요
패널을 하나로 합치면서 연결 버튼도 없앴어요. 서버의 안내대로 "상담원 연결"을 입력하면 상담을 요청하도록 바꿨어요.
훅도 하나로 모았어요. 상담 연결을 기다리거나 상담 중이면 세션 백엔드로 보내고, 나머지 상태에서는 AI 챗 서비스로 보내요. 최상위 컴포넌트에서 ai/counseling 패널을 고르는 분기는 없어졌어요.
이제 같은 목록에 두 종류의 답을 넣어야 했어요. AI 스트림과 세션 SSE를 둘 다 그리면 같은 답을 겹쳐 넣을 수 있어서, 어디서 온 메시지를 표시할지 먼저 정했어요.
AI 스트림은 연결만 유지하게 됐어요
세션 SSE에는 AI 답과 상담사 답이 모두 들어왔어요. AI 챗 서비스는 AI 답만 보냈고요. 그래서 화면에 넣을 메시지는 세션 SSE에서만 받기로 했어요.
AI 스트림은 연결을 열어 두고 응답을 끝까지 읽되 화면에는 넣지 않았어요. 답을 생성하게 하는 요청과 완성된 답을 받는 구독을 따로 쓰는 drain-only 방식이에요.
// 한때 화면에 흘리던 AI 스트림이 → drain-only(읽고 버림) 로 강등됨
const response = await chatClient.post('/chat/stream', body, {
adapter: 'fetch',
responseType: 'stream',
timeout: 30_000, // 30s 상한: 생성이 오래 걸릴 수 있어 넉넉히
});
const reader = (response.data as ReadableStream<Uint8Array>).getReader();
try {
while (!(await reader.read()).done) {
// 읽기만 하고 버린다. 표시는 세션 SSE 가 담당.
}
} finally {
reader.releaseLock();
}서버 내부를 볼 수는 없었지만, 관찰한 동작으로는 AI 스트림을 최대 30초 동안 유지해야 생성이 진행되고 결과가 세션 SSE로 전달됐어요. 그래서 응답을 버리더라도 연결은 유지했어요. 이 상태에서는 브라우저가 연결을 닫거나 타임아웃에 걸릴 때 생성도 영향을 받을 수 있어요.
인증 헤더 때문에 EventSource를 쓰지 못했어요
세션 SSE를 붙일 때는 인증 헤더에서 막혔어요. 서버가 Authorization: Bearer를 요구하는데 표준 EventSource는 커스텀 헤더를 보낼 수 없어요. fetch 기반인 @microsoft/fetch-event-source로 바꾸면 Bearer를 붙일 수 있어요. 재연결 정책과 openWhenHidden을 통한 백그라운드 탭 연결도 이 라이브러리에서 처리했어요.
export async function openSessionStream(sessionId, init) {
const accessToken = await getCookie('accessToken');
return fetchEventSource(`${SESSION_API_URL}/chat/sessions/${sessionId}/stream`, {
signal: init.signal,
openWhenHidden: true, // 백그라운드 탭에서도 상담 메시지 유지
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
onmessage: (e) => init.onMessage(e.event, e.data),
onerror: init.onError, // 지연(ms) 반환 시 재연결, throw 시 중단
});
}흩어진 상태 전이를 useReducer로 모았어요
상담 상태에 따라 전송할 서버와 구독 여부가 달라졌어요. 그런데 상태 자체도 클라이언트에서 만든 값과 서버에서 받은 값이 섞여 있었어요.
ai ──"상담원 연결"→ connecting ──(SSE: live)→ live ──(SSE: done)→ done
└─(SSE: hold)→ hold- ai : AI가 응답하는 상태 (클라이언트 기본값)
- connecting : AI 종료 호출 후 상담사 수락을 기다리는, 클라이언트만 아는 상태
- live/hold/done : 세션 SSE가 권위를 갖는 서버 상태
상담사 연결을 기다리는 connecting은 클라이언트가 정하는데, 그사이에 서버가 done을 보내기도 해요. useState로 나눠 둔 값을 각자 바꾸다 보니 이런 순서를 다루기 어려워져서, useReducer에 전이를 모았어요.
어떤 상태로 바뀔지는 읽기 쉬워졌지만 연결은 여전히 effect 여러 곳에서 열고 닫았어요. 상태가 live여도 SSE가 닫혀 있을 수 있었어요. 다음에는 연결을 여는 조건도 한곳으로 모았어요.
store가 SSE 연결을 관리하고 React는 스냅샷을 구독했어요
SSE 연결 여부를 상태에서 계산하도록 했어요. 연결 = (구독자 있음) ∧ (세션 있음) ∧ (종료 아님)
syncConnection()은 구독자와 세션, 종료 상태가 바뀔 때 필요한 연결을 맞춰요. React는 useSyncExternalStore로 결과 스냅샷만 구독하게 했어요. 재연결 중이거나 구독을 해제하는 중에 들어오는 이벤트는 이 조건만으로 해결되지는 않아요.
export class ChatStore {
private snapshot = ChatStore.initialSnapshot();
private readonly listeners = new Set<Listener>();
private readonly seen = new Set<number>(); // 메시지 id 로 history+SSE 이중표시 제거
readonly subscribe = (listener: Listener) => {
this.listeners.add(listener);
this.syncConnection(); // 구독 시작 → 필요하면 연결
return () => {
this.listeners.delete(listener);
this.syncConnection(); // 구독 종료 → 필요하면 해제
};
};
readonly getSnapshot = () => this.snapshot;
}AI 종료와 메시지 전송, AI 스트림 요청은 훅의 React Query에 두고, store에는 SSE 구독과 상태 전이를 남겼어요. 문서에 없는 메시지 형식도 store에서 받아 줬어요. 단일 항목과 배열, {messages:[]} 래퍼를 Zod .catch()로 읽어 한 항목이 잘못돼도 나머지는 남게 했어요.
자체 store로 동작은 연결했지만, 메시지 형식을 더 늘리려면 이 store도 다시 손봐야 했어요.
메시지 관리를 @tanstack/ai로 옮겼어요
ChatStore에는 메시지 누적과 seen을 통한 ID 중복 제거, 구독 관리, 불변 스냅샷 갱신이 들어 있었어요. AI 메시지 라이브러리가 제공하는 기능을 직접 들고 있는 부분이었어요.
이후 토큰 스트리밍과 tool call, 카드와 첨부가 있는 메시지도 지원할 계획이었어요. 텍스트 중심인 자체 모델을 바꿀 시점이라 @tanstack/ai의 메시지 모델로 옮겼어요.
세션 SSE의 응답 형식은 useChat이 받는 connection과 달랐어요. 서버 형식을 바꾸는 대신 SubscribeConnectionAdapter가 SSE를 읽어서 라이브러리에 넘기게 했어요.
export function makeSessionConnection({ sessionId, onStatus }): SubscribeConnectionAdapter {
return {
async *subscribe() {
const queue: StreamChunk[] = [];
let notify = () => {};
const controller = new AbortController();
const accessToken = await getCookie('accessToken');
fetchEventSource(`${SESSION_API_URL}/chat/sessions/${sessionId}/stream`, {
signal: controller.signal,
openWhenHidden: true,
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
onmessage: ({ event, data }) => {
if (event === SseEvent.StatusChange) {
const status = parseStatusEvent(safeParse(data));
if (status) onStatus(status); // 서버 상태 → 비즈니스 레이어 (ServerStatus 타입)
} else {
queue.push(...itemsToChunks(safeParse(data))); // 메시지 → 표준 StreamChunk
}
notify();
},
onerror: () => 3000, // 3s 후 재연결
}).catch(() => {});
try {
while (true) {
if (queue.length === 0) await new Promise<void>((r) => (notify = r));
yield* queue.splice(0); // 콜백(push) → async generator(pull) 브리지
}
} finally {
controller.abort(); // 소비 종료 시 SSE 닫기
}
},
send: asyncNoop, // ← 전송은 이 채널이 아니다
};
}이 어댑터의 send는 비어 있어요. useChat의 connection은 보통 전송 뒤 응답 스트림을 돌려주지만, 여기서는 전송할 서버를 따로 고르고 수신은 세션 SSE로만 해요. 전송까지 어댑터에 넣으면 두 서버 중 어디로 보낼지 다시 처리해야 하니, 어댑터에는 구독만 남겼어요.
useChat의 초기화와 구독 시점을 맞췄어요
connection 형식을 맞춰도 히스토리가 안 보이거나 실시간 메시지가 오지 않는 경우가 있었어요. useChat이 언제 값을 읽고 구독을 시작하는지도 맞춰야 했어요.
- useChat은 initialMessages를 mount 시 한 번만 읽어요. 히스토리는 React Query로 비동기로 오는데, 첫 렌더에는 비어 있어서 나중에 도착해도 표시되지 않았어요. useSuspenseQuery로 히스토리가 준비된 뒤에 대화 컴포넌트를 mount하도록 바꿨어요. 그러면 생성 시점의 initialMessages에 히스토리가 들어가요.
- live: true를 안 넘기면 구독 자체가 안 일어나요. 이걸 빠뜨려서 한동안 실시간 메시지가 안 들어왔어요. useChat({ connection, initialMessages, live: true })로 바꿨어요.
- 패널을 닫으면 컴포넌트가 unmount → 구독 해제 → 어댑터의 finally에서 controller.abort()로 SSE가 닫혀요.
클라이언트와 서버의 상태 타입을 나눴어요
connecting을 서버에서 온 상태와 구분하려고 처음에는 serverStatus와 isConnecting을 따로 뒀어요. 나중에는 상태 값은 하나로 두되, 서버에서 받을 수 있는 값의 타입을 ServerStatus로 좁혔어요.
type ServerStatus = 'ai' | 'live' | 'hold' | 'done'; // SSE/히스토리가 주는 권위 상태
type ChatStatus = ServerStatus | 'connecting'; // + 클라 전용 전이 상태
const [status, setStatus] = useState<ChatStatus>(/* 히스토리 마지막 status ?? 'ai' */);
// onStatus 가 ServerStatus 타입이라, SSE 는 connecting 을 타입상 절대 못 덮는다
const connection = useMemo(() => makeSessionConnection({ sessionId, onStatus: setStatus }), [sessionId]);키워드 핸들러가 connecting을 설정하고, 서버 이벤트를 받는 onStatus에는 ServerStatus만 넘길 수 있어요. 그래서 서버 경로에서 connecting을 넣는 호출은 타입 오류가 나요. 다만 예제 주석과 달리 live나 done이 현재 connecting을 덮는 것까지 막지는 않아요. 어떤 전이를 받아들일지는 별도로 정해야 해요.
메시지 중복 제거는 추가 검증이 필요했어요
자체 store의 seen은 옮기지 않고 useChat의 messageId 누적에 맡겼어요. 이 선택에서 아직 확인할 것은 히스토리와 SSE가 같은 ID를 보내는 경우예요. 각각에서 같은 메시지가 들어와도 하나로 합쳐지는지 백엔드 재전송을 포함해 확인해야 해요.
전송 실패를 대화 내용처럼 남기지 않았어요
전송이 실패했을 때 그리던 에러 버블은 엉뚱한 응답 아래에 붙고, 세션이 끝날 때까지 남았어요. 대화 메시지가 아닌 실패 안내는 공용 toast.warning으로 옮겼어요.
초기 status도 마지막 항목만 보면 안 됐어요. .at(-1)이 status 없는 메시지를 고를 수 있어서 .findLast(i => i.status)로 바꿨어요. AI 종료 중에는 connecting이어도 전송할 수 있던 문제는 그 호출을 isPending에 포함해 입력을 잠갔어요. 대화 맨 위의 클라이언트 인사말은 아직 서버 히스토리의 인사말과 겹칠 수 있어요. 히스토리가 비었을 때만 넣을지 확인할 부분이에요.
메시지 모델을 옮긴 뒤에도 화면은 아직 텍스트 한 줄짜리 Message를 받아요.
UIMessage를 그 모델로 바꾸면서 parts에 있는 카드와 tool, 첨부 정보는 렌더 전에 버려져요. 렌더러가 parts를 직접 그려야 라이브러리의 메시지 형식을 화면에서도 쓸 수 있어요. AI 서비스가 세션 SSE로 토큰도 보내게 된다면 토큰 단위 표시도 이 경로에서 다시 붙일 수 있고요.
구독을 닫는 처리도 어댑터의 finally만으로 끝나지는 않아요. 큐가 빈 채로 await 중이면 취소 신호가 와도 대기가 바로 풀리지 않을 수 있어요. 소비자의 취소를 SSE로 전달하고 기다리는 큐도 깨워야 연결이 끝나요. 위 예제는 두 채널을 연결하는 모양을 보여 주는 코드라 이 취소 처리와 재전송된 ID의 중복 처리는 더 확인해야 해요.


