Server-Sent Events로 구현하는 POST 기반 AI 채팅 - Part 1: SSE의 동작 원리와 기술 선택 과정

seo·2025년 2월 23일
0

최근 AI 기술의 발전으로 사용자의 데이터를 쉽게 활용할 수 있는 AI 에이전트 플랫폼들이 주목받고 있습니다. 저는 ChatGPT와 같은 대규모 언어 모델(LLM)을 활용해 사용자가 올린 파일을 학습하고, 이를 기반으로 지능적인 질의응답 서비스를 제공하는 플랫폼을 개발하고 있습니다.

이러한 AI 채팅 서비스에서 가장 중요한 것은 사용자와의 자연스러운 대화 경험입니다. 특히 AI가 답변을 생성하는 과정을 실시간으로 보여주는 것이 핵심 요구사항이었는데요. 지금부터 실시간 스트리밍 구현 과정에서 마주친 기술적 과제들과 그 해결 방법에 대해 3탄에 걸쳐 이야기해보고자 합니다.

AI 채팅의 사용자 경험

일반적인 HTTP 통신에서는 클라이언트의 요청이 있을 때마다 서버가 한 번의 응답을 보내고 연결이 종료됩니다. 예를 들어 "오늘 날씨 어때?"라는 질문에 AI가 오늘은 맑고 화창한 날씨입니다. 라고 응답한다고 해봅시다.

기존 HTTP 방식에서는 실제 대화와는 거리가 먼 경험을 제공합니다.

  • AI가 전체 문장을 완성한 후에야 사용자에게 응답을 보낼 수 있음
  • 응답 길이에 따라 생성 완료까지 불확실한 시간 동안 로딩 화면만 표시
  • 사용자는 AI가 언제 응답을 완료할지 알 수 없음

이런실제 대화처럼 자연스러운 경험을 제공하려면 AI가 응답을 생성하는 과정을 실시간으로 보여줄 필요가 있습니다.

실시간 통신 기술 비교

AI가 생성하는 응답을 순차적으로 UI에 표시하기 위해 두 가지 스트리밍 응답 통신 방식을 검토했습니다.

WebSocket vs SSE

WebSocket

  • 양방향 통신 지원 (서버 ↔ 클라이언트)
  • 상태 유지 및 지속적인 연결 필요 (메모리 사용 증가)
  • TCP 프로토콜 수준의 저수준 네트워크 처리 필요
  • 전용 서버와 추가적인 개발 리소스 필요

SSE (Server-Sent Events)

  • 단방향 통신 (서버 → 클라이언트)
  • HTTP 프로토콜 기반 (추가 설정 최소화)
  • 브라우저 기본 지원
  • 구현이 상대적으로 단순

SSE 선택 이유

일반적인 메신저와 같은 양방향 채팅 서비스와 달리, AI 채팅은 다음과 같은 특징이 있습니다.

  • 사용자가 질문을 전송하고 AI가 응답을 생성하는 단방향적 과정
  • 응답 생성 중에는 사용자 입력 불필요
  • 하나의 질문-답변 주기가 완료되면 자연스럽게 연결이 종료됨

이러한 특성을 고려할 때, 양방향 통신의 복잡성을 감수할 필요 없이 단방향 통신만을 지원하는 SSE로 충분하다고 판단했습니다.

SSE 동작 방식

HTTP와의 차이점

SSE는 기존 HTTP 프로토콜을 기반으로 하지만, 일반적인 HTTP 요청/응답 모델과는 근본적으로 다른 통신 패턴을 제공합니다.

기존 HTTP 통신 방식

  • 일시적 연결: 클라이언트가 요청하면 서버는 응답을 한 번 보내고 연결이 종료됨
  • 단일 응답: 하나의 요청에 대해 하나의 완성된 응답만 전송
  • 연결 종료: 데이터 전송이 완료되면 즉시 연결이 닫힘
  • 새로운 데이터 수신 방법: 새 데이터가 필요할 때마다 클라이언트가 새 요청을 해야 함

SSE 통신 방식

  • 지속적 연결: 한 번 연결이 수립되면 서버가 명시적으로 닫거나 오류가 발생할 때까지 유지됨
  • 다중 응답: 하나의 연결을 통해 서버가 여러 메시지를 순차적으로 전송 가능
  • 비동기 데이터 전송: 서버가 준비되는 대로 데이터를 전송할 수 있음
  • 자동 재연결: 연결이 끊어지면 클라이언트가 자동으로 재연결 시도

연결 수립

클라이언트 요청

GET /stream HTTP/1.1
Host: example.com
Accept: text/event-stream

클라이언트는 Accept: text/event-stream 헤더를 포함하여 SSE 연결을 요청합니다.

서버 응답

HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

서버는 Content-Type: text/event-stream으로 응답하며, 캐싱을 방지하기 위한 헤더들을 포함합니다.

SSE 메시지 형식과 표준

SSE는 W3C와 WHATWG에서 공동으로 관리하는 HTML Living Standard에 포함된 웹 표준 기술입니다. 표준 문서의 Server-Sent Events 섹션에서는 EventSource 인터페이스와 이벤트 스트림의 구문을 상세히 정의하고 있습니다.

SSE 표준은 크게 다음과 같은 핵심 영역을 다룹니다.

  • MIME 타입: SSE 연결을 위한 기본 설정
  • 메시지 필드와 파싱 규칙: 실제 데이터를 주고받는 방식
  • EventSource 인터페이스: 클라이언트에서 SSE를 사용하는 방법
  • 연결 관리와 재연결: 실제 운영환경에서 필요한 연결 처리

이러한 표준화 덕분에 Chrome, Firefox, Safari 등 모든 주요 브라우저에서 일관된 방식으로 SSE가 구현되어 있어, 별도의 호환성 처리 없이도 안정적으로 실시간 데이터 스트리밍을 구현할 수 있습니다.

메시지 필드와 파싱 규칙

SSE는 서버에서 클라이언트로 전송되는 이벤트 스트림의 형식을 명확하게 정의하고 있습니다. 각 이벤트는 여러 필드로 구성되며, 특정 규칙에 따라 파싱됩니다.

  • 각 라인은 필드명, 콜론, 값으로 구성
  • 콜론으로 시작하는 라인은 주석으로 처리
  • 빈 라인은 이벤트의 끝을 의미
  • 필드값의 시작이 공백이면 그 공백은 제거
  1. data 필드 (필수)
    • 메시지의 실제 내용을 포함하는 유일한 필수 필드
    • 텍스트, JSON 등 어떤 형식이든 사용 가능
    • 여러 줄의 데이터는 각 줄에 'data:' 접두사를 붙여 전송
  2. event 필드 (선택)
    • 이벤트의 타입을 지정
    • 생략 시 기본값은 'message'
    • 클라이언트에서 이벤트 타입별 핸들러 구현 가능
  3. id 필드 (선택)
    • 이벤트의 고유 식별자
    • Last-Event-ID 헤더와 함께 사용되어 재연결 시 메시지 복구에 활용
    • U+0000 문자(NULL)를 포함할 수 없음
  4. retry 필드 (선택)
    • 재연결 대기 시간을 밀리초 단위로 지정
    • 정수값만 허용
    • 잘못된 값은 무시됨

실제 사용 예시

AI 채팅에서 사용되는 이벤트 스트림의 예:

event: message
data: {"text": "안녕하세요! "}
id: 1

event: message
data: {"text": "안녕하세요! 오늘 날씨에 대해 "}
id: 2

event: message
data: {"text": "안녕하세요! 오늘 날씨에 대해 말씀드리겠습니다."}
id: 3

event: close
data: {"reason": "completion"}
id: 4

각 청크는 개행 문자(\n\n)로 구분되며, 이는 브라우저가 개별 이벤트를 인식하는 기준이 됩니다.

EventSource 인터페이스

브라우저는 SSE 통신을 위한 표준 인터페이스로 EventSource를 제공합니다. 이는 WHATWG 명세를 기반으로 각 브라우저 벤더(Chrome, Firefox, Safari 등)가 구현한 것입니다.

주요 기능

  1. 연결 관리
    • readyState 속성을 통해 연결 상태 확인 가능 (0: CONNECTING1: OPEN2: CLOSED)
    • 서버와의 단일 방향 연결(클라이언트가 요청하면 서버가 지속적으로 데이터를 전송)
    • 연결이 끊어지면 자동으로 재연결 시도
    • 네트워크 장애 발생 시 즉각적인 재연결 시도
    • EventSource 생성자의 두 번째 인자로 { withCredentials: true } 옵션을 주면 쿠키 및 인증 정보 포함 가능
    • 서버에서 제공하는 retry 값을 통한 동적 재연결 간격 조정
  2. 이벤트 처리
    • 기본적으로 message 이벤트를 자동으로 수신하고 핸들러 실행
    • event: 필드를 사용하여 특정 이벤트 타입 지정 가능 (addEventListener('customEvent', handler))
    • JSON 데이터를 수신하면 JSON.parse(event.data)로 변환하여 사용 가능
  3. 에러 처리
    • 네트워크 장애 또는 서버 오류 발생 시 error 이벤트 발생
    • CORS 정책 위반 시 에러 발생 가능 (서버에서 Access-Control-Allow-Origin 헤더 필요)
    • 서버가 올바른 SSE 형식을 따르지 않으면 클라이언트에서 에러 발생 가능
  4. 기타 기능
    • .close() 메서드를 호출하여 수동으로 연결 종료 가능
    • 브라우저가 자동으로 Last-Event-ID를 관리하여 클라이언트가 끊겼다가 다시 연결될 때 이어서 수신 가능 (event.lastEventId 활용)

사용예시

const eventSource = new EventSource('/stream');
eventSource.addEventListener("notice", (e) => {
  console.log(e.data);
});

한계

하지만 브라우저의 EventSource는 몇 가지 중요한 제약사항이 있습니다.

  1. GET 요청만 지원
  2. 요청 본문(body) 포함 불가
  3. 헤더 커스터마이징 제한

EventSource는 기본적으로 GET 요청만 지원하고, 요청 헤더도 제한적으로만 설정할 수 있습니다. 특히 우리 서비스에서는 음성 메모나 PDF와 같은 대용량 파일을 요청 본문에 포함해야 했는데 EventSource로는 구현이 불가능했습니다.

이런 대량의 데이터를 GET 요청의 쿼리 파라미터로 전송하는 것은 적절하지 않았고 POST 요청으로 긴 요청 본문(body)을 전송할 수 있는 새로운 해결책이 필요했습니다.

정리

지금까지 AI 채팅의 실시간 응답 구현을 위한 기술 선택 과정과 SSE의 기본 개념을 살펴봤습니다. 단방향 통신이 주요 패턴인 AI 채팅에서는 SSE가 구현 복잡도와 성능 측면에서 적절한 선택이었습니다. 표준 HTTP 프로토콜 위에서 동작하고 브라우저 기본 지원을 활용할 수 있어 개발 리소스도 절약할 수 있었습니다.

하지만 앞서 언급한 것처럼 브라우저의 EventSource는 POST 요청을 지원하지 않는 등의 한계가 있었습니다. 다음 글에서는 이런 한계를 극복하기 위한 커스텀 SSE 클라이언트 구현에 대해 다루겠습니다

  • Fetch API로 POST 요청 SSE 구현하기
  • ReadableStream으로 실시간 청크 처리하기
  • 불완전한 청크를 위한 버퍼 시스템 만들기
  • 에러 상황 대응과 타입 안전성 확보하기

이 글이 도움이 되었길 바랍니다. 질문이나 의견이 있으시면 댓글로 남겨주세요!

0개의 댓글