SSE 이벤트 프로토콜로 AI 채팅 스트리밍 설계하기
안녕하세요, LLM플랫폼팀 프론트엔드 개발자 변수미입니다.
저희 팀은 eXemble이라는 LLMOps 플랫폼을 만들고 있습니다. eXemble은 기업이 자체 데이터를 기반으로 AI 에이전트를 구성하고, 워크플로우로 연결하여 운영할 수 있는 서비스입니다.
저는 이 서비스에서 사용자가 AI 에이전트에게 질문을 보내고 답변을 실시간으로 받아보는 채팅 프론트엔드 전반을 담당하고 있습니다.
사용자가 채팅으로 질문을 보내면, 에이전트는 워크플로우에 정의된 노드들을 순차적으로 실행하며 답변을 생성합니다. 이 과정에 보통 10초에서 30초가 걸리는데, 사용자에게 그동안 빈 화면을 보여줄 수는 없으니 스트리밍은 당연한 전제였습니다.
하지만 저희에게 스트리밍은 단순히 텍스트를 한 글자씩 보여주는 것이 아닙니다.
하나의 스트림으로 세 가지를 동시에 전달해야 합니다. > 답변 텍스트 + 참고 문서 목록 + 워크플로우 실행 흐름

사용자가 채팅에서 답변을 읽는 동안, 옆의 워크플로우 캔버스에서는 어떤 노드가 실행 중인지 실시간으로 표시되어야 합니다.
이 복잡성이 이후의 모든 기술적 의사결정에 영향을 미쳤고, 그중에서도 가장 결정적이었던 것은 서버와 프론트엔드 사이의 메시지 이벤트 프로토콜을 어떻게 설계하느냐였습니다.
이 글은 이벤트 프로토콜을 설계한 과정과, 그 설계가 실제 기능 추가에서 검증된 경험을 다룹니다.
1. AI 채팅의 스트리밍, 왜 필요하고 어떻게 동작하는가
1-1. 스트리밍이 필요한 이유
AI 채팅이 보편화된 만큼, ChatGPT나 Claude에서 답변이 한 글자씩 흘러나오는 장면은 이제 익숙합니다.

해당 이미지와 같이 텍스트가 타이핑하듯이 나오는 것을 스트리밍이라고 합니다.
LLM은 토큰을 하나씩 생성하기 때문에, 전체 응답이 완성될 때까지 기다리면 사용자는 수십 초 동안 빈 화면을 보게 됩니다.
LLM 응답이 10~30초인 상황에서, 스트리밍은 단순한 UX 개선이 아니라 서비스 이탈을 막는 핵심 기법입니다.
Nielsen Norman Group의 연구에 따르면, 1초 이상의 지연은 사용자의 사고 흐름을 끊고, 10초 이상이면 주의를 완전히 잃게 만듭니다.
1-2. 스트리밍을 구현하는 기술들
AI 채팅에서 스트리밍을 구현하는 방식은 크게 세 가지입니다.

Polling
- 클라이언트가 일정 간격으로 "새 데이터 있어?"라고 반복 요청합니다.
- 구현은 단순하지만 불필요한 요청이 많고, 실시간성이 떨어집니다.
- LLM이 토큰을 생성하는 속도(수십 ms 단위)에 맞추려면 요청 간격을 극도로 짧게 잡아야 하는데, 그러면 서버 부하가 급격히 늘어납니다.
- 응답이 완료되었는지 매번 확인해야 하므로, 토큰이 생성되지 않는 순간에도 요청이 계속 발생합니다.
WebSocket
- 클라이언트와 서버가 양방향 연결을 맺고 자유롭게 데이터를 주고 받습니다.
- 다만, AI 채팅은 "사용자가 질문 → 서버가 답변을 흘려보냄"이라는 단방향 흐름이 대부분입니다.
- 양방향이 필요하지 않은 상황에서 WebSocket을 쓰면 서버가 연결 상태를 관리해야 하는 부담이 늘어납니다.
- 실시간 채팅이나 게임에 적합하지만, 서버가 연결 상태를 관리해야 하고, 기존 HTTP 인프라와의 호환에 추가 설정이 필요합니다.
SSE (Server-Sent Events)
- 서버가 클라이언트에게 단방향으로 데이터를 전송합니다.
- HTTP 위에서 동작하므로 기존 인프라를 그대로 활용할 수 있고, 연결이 끊기면 자동 재연결을 지원합니다.
"서버가 클라이언트에게 일방적으로 데이터를 전송하는" AI 채팅의 흐름과 정확히 일치하기 때문에,
ChatGPT, Claude, Gemini 등 대부분의 주요 AI 채팅 서비스가 SSE를 채택하고 있습니다.
1-3. 우리의 선택: SSE + delta 방식
저희도 SSE를 선택했습니다. 이유는 세 가지였습니다.
- 저희 인프라는 Nginx(OpenResty) 기반 Gateway를 사용하고 있어서, HTTP 위에서 동작하는 SSE가 별도 설정 없이 바로 적용 가능했습니다.
- AI 채팅은 "사용자가 질문 → 서버가 답변을 흘려보냄"이라는 단방향 흐름이므로, 양방향이 필요한 WebSocket은 과한 선택이었습니다.
- 서버를 stateless로 유지하고 싶었습니다.
저희는 직전 이벤트 이후 새로 생성된 조각만 보내는 delta 방식을 채택하여 서버가 상태를 갖지 않도록 하고, 전송 리소스를 줄였습니다.
delta 방식은 중간 이벤트가 유실되면 텍스트가 깨질 수 있다는 트레이드오프가 있지만, 스트리밍 종료 시 전체 답변을 포함하는
final_answer 메시지로 최종 보정이 가능하기 때문에 감수할 수 있는 선택이었습니다.다만 SSE를 사용함에 있어 한 가지 제약이 있었습니다.
브라우저 표준
MDN Web DocsServer-Sent Events 사용하기 - Web API | MDN
EventSource API는 GET 요청만 지원하고, 커스텀 헤더를 설정할 수 없습니다.
MDN 참고 - 
Server-Sent Events 사용하기 - Web API | MDN
server-sent events를 사용하는 웹 애플리케이션을 개발하는 것은 간단합니다. 서버 측에서는 프론트엔드로 이벤트를 스트리밍하는 약간의 코드가 필요하지만, 클라이언트 측 코드는 들어오는 이벤트를 처리하는 부분에서 웹소켓과 거의 동일하게 작동합니다. 이는 단방향 연결이기 때문에 클라이언트에서 서버로 이벤트를 보낼 수는 없습니다.
저희 서비스는 JWT 인증 구조에서 모든 요청이 POST 본문과 Authorization 헤더를 필요로 했기 때문에,
@microsoft/fetch-event-source 라이브러리를 선택했습니다.이 라이브러리는 fetch API 위에 SSE 파싱 로직을 구현한 것으로,
POST 요청과 커스텀 헤더를 지원하면서도 기존 fetch 기반 코드와 자연스럽게 통합할 수 있었습니다.
여기까지가 일반적인 AI 채팅 스트리밍의 구현입니다.
텍스트 조각(delta)을 받아 화면에 누적하고, 완료(done) 신호가 오면 스트리밍을 종료합니다.
하지만 저희 서비스에서는 이것만으로 충분하지 않았습니다.
2. 왜 이벤트 프로토콜이 필요한가
2-1. 단순 delta만으로는 부족하다
일반적인 AI 채팅이라면 텍스트 조각(delta)과 완료(done) 신호, 두 가지 메시지면 됩니다.
하지만 저희 서비스는 워크플로우 엔진이 여러 노드를 순차 실행합니다.
프론트엔드가 동시에 처리해야 하는 것이 세 가지입니다.
- 채팅 영역에 답변 텍스트를 토큰 단위로 누적
- 캔버스에서 현재 실행 중인 노드를 하이라이트
- 참고 문서를 최종 답변과 함께 표시
문제는 SSE가 하나의 HTTP 연결 위에 하나의 스트림만 제공한다는 것입니다.
텍스트 조각, 노드 상태 변경, 참고 문서 정보와 같이 성격이 완전히 다른 데이터가 모두 같은 파이프라인을 통해 섞여 들어옵니다.
별도의 규약 없이 데이터를 보내면, 프론트엔드는 도착한 메시지의 내용을 파싱해서 종류를 추측해야 합니다.
각 메시지가 자기 자신이 무엇인지를 스스로 선언하는 구조
서버와 프론트엔드 사이에 "어떤 메시지가, 어떤 데이터를 담아 올 것인지"에 대한 약속이 필요합니다. 이 약속을 저희는 이벤트 프로토콜이라 부릅니다.
3. 메시지 이벤트 프로토콜 설계: 서버와의 약속
어떤 메시지가, 어떤 순서로, 어떤 데이터를 담아 올 것인지를 프론트엔드와 서버가 명확히 합의해야 합니다.
설계 원칙은 두 가지였습니다.
- For Speed — 빠르게 구현할 수 있는 단순한 구조. delta 방식을 핵심으로 하고, 서버는 상태를 관리하지 않습니다.
- For Future — 모든 메시지를
type필드로 구분합니다. 새 메시지가 필요하면 기존 코드를 수정하지 않고 핸들러만 추가합니다.
3-1. 메시지 시퀀스: 하나의 질문이 만드는 흐름
사용자가 질문을 보내면, 서버는 다음과 같은 이벤트 순서로 메시지를 보냅니다.

SSE 스트림에서 오는 각각의 메시지에는
type 필드가 존재합니다.프론트엔드는 이 값을 보고 어떤 처리를 할지 결정합니다.
plan이 오면 화면에 실행 계획을 표시하고, node_start가 오면 해당 노드를 하이라이트하고, delta가 오면 텍스트를 화면에 누적해 표시합니다.실제로 SSE 응답 형태는 아래와 같습니다.
3-2. 설계 판단: 왜 이렇게 쪼갰는가
설계의 핵심 원칙은 각 메시지가 하나의 명확한 의미만 갖는 것이었습니다.
하나의 타입이 두 가지 의미를 갖게 되면, 핸들러 안에서 "이번에는 어떤 의미인가?"를 다시 분기해야 하고, 이것은 타입을 나눈 의미가 사라지는 것과 같습니다.
이 원칙이 구체적으로 적용된 몇 가지 판단이 있습니다.
node_start와 node_done을 분리한 이유- 하나의
node_status메시지에 시작/종료를 필드로 구분하는 방법도 검토했습니다. 하지만 두 이벤트는 발생 시점이 완전히 다르고,프론트엔드가 해야 할 처리도 다릅니다.
node_start가 오면 해당 노드를 하이라이트하고 스피너를 표시해야 하고,node_done이 오면 스피너를 제거하고 결과를 표시해야 합니다.
- 같은 타입으로 묶으면 핸들러 안에서 다시 "시작인가 종료인가"를 분기해야 하므로, 타입 자체를 나누는 것이 원칙에 맞았습니다.
final_answer를 별도 메시지 타입으로 분리한 이유- 저희 서비스는 최종 답변과 함께 참고 문서 목록(출처, 페이지, 관련도)을 제공합니다.
- 이 메타데이터를
delta에 섞으면 "텍스트 조각"이라는 의미가 오염되고,done에 실으면 "종료 신호"라는 의미가 희석됩니다.
- 최종 답변은 그 자체로 독립적인 의미를 가지므로 별도 타입으로 분리했습니다.
heartbeat를 비즈니스 로직에서 분리한 이유- SSE는 장기 연결이라 네트워크가 조용히 끊어질 수 있습니다.
- 이를 감지하기 위해 서버가 주기적으로 보내는 heartbeat는, "워크플로우에서 무슨 일이 일어났는가"와는 무관한 인프라 레벨의 신호입니다.
- SSE 연결을 담당하는 레이어에서 필터링하여 비즈니스 로직(Reducer)에는 전달하지 않도록 했고, 이 분리 덕분에 연결 감지 로직을 비즈니스 로직과 독립적으로 개선할 수 있었습니다. (이것이 5-2에서 실제로 효과를 발휘합니다.)
이렇게 각 메시지가 하나의 의미만 가지면,
새로운 의미가 필요할 때 기존 타입을 변형하는 대신 새 타입을 추가하면 됩니다.
이 구조가 4장에서 실제로 검증됩니다.
3-3. 이벤트 프로토콜이 상태 관리 구조를 결정한다
하나의 메시지가 여러 UI 상태를 동시에 바꿔야 하는 상황이 있습니다.
예를 들어
node_start 메시지가 도착하면,- 캔버스에서 노드 상태를
RUNNING으로 바꾸고
- 동시에 채팅 상태도
STREAMING으로 변경해야 합니다.
"채팅은 WAITING인데 노드는 RUNNING"
— 이런 상태 조합이 화면에 노출되면 안 됩니다.
React의 상태 배치 처리(batching) 덕분에, 같은 핸들러 안에서 여러 상태를 변경해도 중간 상태가 실제로 화면에 노출되지는 않습니다.
하지만 이것은 React 런타임의 내부 동작에 암묵적으로 의존하는 것이지, 코드 구조 자체가 보장하는 것은 아닙니다. 노드 상태와 채팅 상태가 별도의 훅이나 store에 분산되어 있다면, 어떤 상태 조합이 가능하고 어떤 조합이 불가능한지를 파악하려면 흩어진 코드를 모두 추적해야 합니다.

저희가 원한 것은 하나의 이벤트에 대한 모든 상태 변화를 한 곳에서 정의하여, 불가능한 상태 조합을 구조적으로 차단하는 것이었습니다.
3-2에서 "하나의 타입, 하나의 의미"를 원칙으로 세웠는데, 상태 관리에도 같은 원칙을 적용했습니다. 하나의 이벤트가 바꿔야 하는 상태를 Reducer 한 곳에 모아두면, 런타임 동작이 아니라 코드 구조가 일관성을 보장합니다.
이 구조를
useReducer로 구현했습니다.useSSEStream.ts — SSE 핸들러
chatReducer.ts — Reducer
결과적으로 SSE 메시지 타입이 Reducer 액션과 1:1로 매핑되는 깔끔한 구조가 만들어졌습니다.
새 메시지 타입이 추가되면 SSE 핸들러에 case 하나, Reducer에 액션 하나를 추가하면 됩니다.

4. 확장성 검증: if/else 분기 노드가 추가되었을 때
"For Future" 원칙이 실제로 유효한지는 예상보다 빠르게 검증되었습니다.
4-1. 문제: 실행 계획이 런타임에 바뀐다
저희 워크플로우에 if/else 분기 노드가 추가되었습니다.
분기 노드란, 워크플로우 실행 중 조건에 따라 다음에 실행할 경로가 달라지는 노드입니다.

기존에는 실행 계획이 질문 시점에 확정되었습니다.
plan 메시지로 "A → B → C" 경로를 받으면,
캔버스에 이 경로를 표시하고 순서대로 하이라이트하면 되었습니다.하지만 분기 노드가 도입되면서 실행 경로가 런타임에 결정되는 상황이 생겼습니다.
캔버스에는 "A → B → C or D" 경로가 표시되어 있는데,
분기 조건에 따라 실제로는 "A → B → D"가 실행될 수 있는 것입니다.
사용자 입장에서는 화면에 보이는 실행 경로에 따라 예상한 결과와 실제 진행 상황이 어긋나는 혼란을 겪을 수 있습니다.
중간에 계획이 바뀌었다는 사실을 프론트엔드에서 알아야 캔버스의 실행 경로를 올바르게 업데이트하고, 사용자가 혼란을 겪지 않도록 할 수 있습니다.
그렇다면 서버는 이 변경을 어떤 메시지로 알려줘야 할까요?
4-2. 설계 선택: 기존 타입 재사용 vs 새 타입 추가
3장에서 세운 이벤트 프로토콜 구조 안에서, 이 문제를 해결하는 방법은 두 가지였습니다.
방법 A: 기존
plan 메시지를 다시 보내기구현은 간단하지만,
plan이 "초기 계획"과 "계획 변경"을 동시에 의미하게 됩니다.
이것은 3-2에서 세운 "하나의 타입, 하나의 의미" 원칙에 정면으로 어긋납니다.운영 관점에서도 문제가 됩니다. 로그에
plan이 두 번 찍혔을 때,
이게 초기 계획인지 경로 변경인지 구분하려면 타임스탬프와 전후 메시지를 모두 대조해야 합니다.처리 로직 또한 하나의 이벤트 타입 안에서 "이게 첫 번째 plan인가, 업데이트인가?"를 분기해야 합니다.
방법 B:
plan_updated라는 새 메시지 타입 추가각 메시지가 "서버에서 무슨 일이 일어났는가"를 명확히 표현합니다.
plan은 항상 초기 계획이고, plan_updated는 항상 계획 변경입니다.이중 저희는 방법 B를 택했습니다.
"For Future" 원칙을 준수하고, 디버깅과 확장 모두 수월하기 때문입니다.
plan_updated는 변경된 경로 전체를 포함하며, 프론트엔드는 기존 경로를 새 경로로 교체하기만 하면 됩니다.
diff 방식도 검토했지만, 실행 계획의 크기가 작아 전체 교체가 구현과 디버깅 모두에서 단순했습니다.4-3. 실제 변경 범위
3-3에서 본 SSE → Reducer 매핑 테이블에 행 하나가 추가된 것이 이때의 변경 전부입니다.

- SSE 핸들러에
plan_updated→dispatch({ type: 'UPDATE_PLAN' })case 1개 추가
- Reducer에 기존 노드 상태를 유지하면서 경로만 업데이트하는 액션 1개 추가
- 캔버스 컴포넌트에서 경로 재렌더링
기존의
plan, delta, node_start, node_done, final_answer, done 처리 로직은 한 줄도 수정하지 않았습니다.이 경험이 의미 있는 것은 같은 패턴이 앞으로도 반복 가능하다는 점입니다.
새 메시지 타입이 필요할 때마다 SSE 핸들러에 case 하나, Reducer에 액션 하나, 컴포넌트에 UI 하나를 추가하면 됩니다.
기존 코드는 건드리지 않습니다.
소프트웨어 설계에서 말하는 Open-Closed Principle (확장에는 열려 있고, 수정에는 닫혀 있다)이 이벤트 프로토콜 레벨에서 자연스럽게 적용된 것입니다.
5. Troubleshooting: 운영에서 배운 것
설계가 끝나고 배포를 하면, 설계에서는 보이지 않던 문제들이 나타납니다.
운영 과정에서 마주친 세가지 문제와 해결방법을 공유합니다.
5-1. 비활성 탭에서 답변이 끊기다
배포 후 가장 먼저 마주친 문제였습니다.
사용자가 답변을 기다리며 다른 동작을 하거나 브라우저의 다른 탭으로 이동하면, 돌아왔을 때 답변이 중간에 끊기는 상황이 발생했습니다.

원인은 SSE 구현 시 사용하는 라이브러리
@microsoft/fetch-event-source의 기본 동작이었습니다.이 라이브러리는 기본적으로 Page Visibility API를 활용해, 탭이 비활성화되면 SSE 연결을 종료하고, 다시 활성화되면 재연결을 시도합니다. 하지만 AI 채팅에서는 이미 생성된 토큰을 다시 받을 수 없기 때문에, 재연결되더라도 중간에 유실된 답변은 복구되지 않습니다.
불필요한 리소스 소비를 방지하기 위한 합리적인 기본값이지만,AI 채팅처럼 답변 생성에 10~30초가 걸리는 서비스에서는 "잠깐 다른 탭을 보고 돌아왔더니 답변이 끊겨 있다"는 치명적인 UX 문제로 이어집니다.
openWhenHidden: true 옵션으로 변경하여 해결했습니다.백그라운드에서 연결을 유지하면 리소스 비용이 우려될 수 있지만, SSE 연결은 응답 생성이 완료되면 종료되므로 무한히 열린 채로 유지되지 않습니다. 사용자가 탭을 떠나 있는 동안에만 연결이 유지되고, 답변이 끝나면 자연스럽게 정리되기 때문에 실질적인 추가 비용은 무시할 수 있는 수준이었습니다.
5-2. 연결이 조용히 끊어지다
SSE는 장기 연결이기 때문에, 네트워크 문제로 연결이 끊어져도 클라이언트가 이를 즉시 알아차리지 못하는 경우가 있습니다.

3장에서 설계한 heartbeat가 여기서 해결책이 되었습니다.
서버는 N초 간격으로 heartbeat를 보냅니다.
클라이언트의 커넥션 매니저는 마지막 heartbeat 수신 시각을 기록하고, M초 이상 heartbeat가 도착하지 않으면 연결이 끊어진 것으로 판단합니다.
연결 끊김을 감지하면 지수 백오프(exponential backoff) 전략으로 재연결을 시도합니다.
첫 번째 재시도는 1초 후, 두 번째는 2초 후, 세 번째는 4초 후, 네트워크가 일시적으로 불안정한 상황에서 서버에 과부하를 주지 않기 위해서 사용합니다.
heartbeat를 설계 시점에 비즈니스 로직과 분리해둔 것이 이때 빛을 발했습니다.
이 재연결 로직 전체가 커넥션 매니저 안에서 완결되고, 채팅이나 캔버스의 비즈니스 로직은 전혀 건드리지 않았습니다.
5-3. 인프라 협업: TTFT 최적화
스트리밍을 구현했는데도, 사용자가 질문을 보낸 뒤 첫 글자가 나타나기까지 체감상 느린 현상이 있었습니다.
네트워크 탭을 확인해보니, 서버는 토큰을 즉시 보내고 있는데 브라우저에 도착하는 시점이 늦었습니다.
몇 초 동안 아무런 데이터도 오지 않다가 여러 토큰이 한꺼번에 몰려오는 패턴이었습니다.

원인은 Nginx의 기본 버퍼링이었습니다.
Nginx는 upstream 응답을 일정 크기까지 버퍼에 모았다가 클라이언트에 전달합니다.
일반적인 API 응답에서는 네트워크 효율을 높이는 합리적인 동작이지만, 토큰 하나(수 바이트)를 생성 즉시 전달해야 하는 SSE에서는 이 버퍼링이 지연의 원인이 됩니다.
같은 팀 백엔드 엔지니어와 함께 스트리밍 전용 설정(
proxy_buffering off 등)을 적용하여
TTFT(Time To First Token)를 0.5초 미만으로 단축했습니다.이 과정의 상세한 내용은 같은 팀 심정수님의 Nginx, 어디까지 써보셨나요?(feat. OpenResty)에서 다루고 있습니다.
프론트엔드만으로는 완전한 스트리밍 경험을 만들 수 없습니다. 서버, 인프라, 프론트엔드가 각자의 레이어에서 최적화해야 하고, 그 접점에서의 협업이 중요합니다.
마치며
이 프로젝트에서 가장 영향력이 컸던 결정은 특정 기술의 선택이 아니라 메시지 이벤트 프로토콜을 어떻게 설계하느냐였습니다.
모든 메시지를
type 필드로 구분하는 단순한 규칙 하나가, Reducer의 switch/case와 맞물리면서 새 기능 추가 시 기존 코드를 건드리지 않는 확장 패턴을 만들어냈습니다.if/else 분기 노드 추가가 이 패턴의 첫 번째 검증이었고, 앞으로의 기능 확장에서도 같은 패턴이 반복될 것이라고 기대하고 있습니다. 물론 이 설계가 모든 상황에서 최적인 것은 아닙니다.
현재는 하나의 스트림으로 모든 데이터를 보내고 있지만, 워크플로우가 더 복잡해지고 동시에 실행되는 에이전트가 늘어나면 채널을 분리하거나 메시지 간의 순서 보장을 별도로 다뤄야 할 수도 있습니다.
범용적으로 "좋은" 기술보다, 우리 환경에서 동작하는 기술이 중요했습니다.
그 설계가 실제 요구사항에서 검증되었을 때, 비로소 초기 원칙이 전략으로 전환되는 경험을 했습니다.
이 글이 AI 채팅 서비스를 설계하는 분들에게 참고가 되길 바랍니다.
함께 보면 좋은 아티클
