기본을 넘어: LLM 토큰 스트림을 재개 가능하고 다중 디바이스 지원으로 만들기
많은 개발자에게 동기식 챗봇에서 비동기 AI 에이전트(사용자가 작업하는 동안 백그라운드에서 실행되는 도구)로 전환하는 것은 단순한 전송 방식 변경처럼 보입니다. 일반적인 조언은 Last-Event-ID 헤더와 함께 서버 전송 이벤트 (SSE)를 사용해 내구성 있는 스트림을 만드는 것입니다. 이론적으로는 쉬워 보이지만, 실제로 대규모로 구현하면 상당한 아키텍처 마찰이 발생합니다.
간단한 데모를 넘어 프로덕션 수준의 에이전시 경험으로 이동할 때, 세 가지 핵심 요구사항이 등장합니다: 재개 가능한 스트림, 신뢰할 수 있는 취소, 그리고 다중 디바이스 동기화. SSE가 기술적으로 이를 처리할 수는 있지만, 구현 세부 사항은 "가능함"과 "쉬움" 사이의 격차를 드러냅니다.
토큰 스트리밍의 오버헤드
아키텍처적 도전에 들어가기 전에 LLM 응답의 특성을 이해하는 것이 중요합니다. Vercel AI SDK, OpenAI, Anthropic 중 어느 것을 사용하든 반환되는 데이터는 단순한 텍스트 스트림이 아닙니다. 각 "토큰"은 상당한 메타데이터와 함께 제공됩니다.
예를 들어, Anthropic API의 단일 이벤트는 실제 텍스트 델타 5자를 전달하기 위해 125자의 JSON 메타데이터를 포함할 수 있습니다. 이러한 메타데이터 오버헤드는 스트림을 데이터베이스에 저장해 내구성을 확보하려는 순간에 중요한 병목이 됩니다.
재개 가능한 스트림의 도전 과제
스트림을 재개 가능하게 만들려면, 클라이언트가 연결이 끊긴 후 재연결하고 누락된 토큰을 요청할 수 있어야 합니다. SSE 사양은 이를 위해 Last-Event-ID 헤더를 제공합니다. 모든 이벤트에 고유 ID가 있다면(예: response_id:token_index), 클라이언트는 서버에 정확히 어디서 중단됐는지 알릴 수 있습니다.
하지만 상태가 없는 서버 복제본을 갖춘 현대적인 수평 확장 아키텍처에서는 이는 대규모 쓰기 증폭 문제를 야기합니다. 어느 복제본이든 재연결 요청을 처리할 수 있기 때문에, 모든 토큰을 실시간으로 공유 데이터베이스나 캐시에 기록해야 합니다.
이로 인해 역설적인 상황이 발생합니다: 텍스트 몇 글자마다 데이터베이스에 쓰기를 수행하게 되며, 실제로 끊기지 않을 요청에 대해서도 마찬가지입니다. LLM이 응답을 완료하면 개별 토큰 레코드는 쓸모 없게 되고 최종 "전체 응답" 레코드로 대체하기 위해 정리되어야 합니다. 결과적으로 소수의 사용자에게만 혜택을 주는 기능에 대해 높은 비용의 인프라 부담이 발생합니다.
내구성 있는 환경에서 취소 처리
기본 SSE 설정에서는 연결이 끊기면 서버가 LLM 요청을 취소하는 신호로 해석됩니다. 그러나 재개 가능한 스트림을 구현하면, 연결이 끊겼다고 해서 사용자가 중단을 원한다는 의미는 아니며, 단순히 터널에 들어가거나 브라우저를 새로 고친 것일 수 있습니다.
취소는 이제 전용 오프밴드 메커니즘이 필요합니다. POST /cancel/{response_id}와 같은 별도 엔드포인트를 구현해 "취소 마커"를 공유 데이터베이스에 기록해야 합니다. LLM 추론을 담당하는 서버 복제본은 토큰을 생성하는 사이마다 이 공유 저장소를 지속적으로 폴링하여 상위 호출을 중단해야 하는지 확인해야 합니다. 이는 추론 루프에 지연과 복잡성을 추가합니다.
다중 디바이스 동기화 격차
다중 디바이스를 지원하면 두 가지 뚜렷한 문제가 발생합니다:
- 상태 복구: 토큰이 재개 가능성을 위해 이미 데이터베이스에 저장되고 있기 때문에, 두 번째 디바이스는 기록을 가져와 현재 스트림을 이어받을 수 있습니다.
- 실시간 알림: 디바이스 B는 디바이스 A가 프롬프트를 전송했고 응답이 현재 스트리밍 중임을 어떻게 알 수 있을까요?
지속적인 양방향 연결이 없으면 디바이스 B는 폴링에 의존해야 합니다. 원본 자료에서 언급했듯이, 폴링은 양쪽 다 손해 보는 선택입니다: 드물게 폴링하면 높은 지연을 겪고, 자주 폴링하면 불필요한 트래픽으로 서버에 부담을 줍니다.
대안 아키텍처: Pub/Sub
이러한 마찰을 고려할 때, 일부는 HTTP가 비동기 에이전시 애플리케이션에 근본적으로 잘못된 전송 방식이라고 주장합니다. 퍼블리시/서브스크라이브(pub/sub) 패턴은 연결 수명을 에이전트 수명 주기와 분리함으로써 보다 우아한 해결책을 제공합니다.
In a pub/sub model:
- Persistence: 채널은 클라이언트와 독립적으로 존재합니다. 토큰은 채널에 퍼블리시되며, 클라이언트가 재연결 시 "되감기"하여 수집할 수 있도록 유지됩니다.
- Multi-device: 여러 클라이언트가 동일한 채널을 구독해 폴링 없이 실시간으로 동일한 스트림을 받을 수 있습니다.
- Efficiency: 전송 계층이 토큰 델타를 전체 응답으로 압축 처리할 수 있어, 클라이언트가 따라잡을 때 처리해야 할 메시지 수를 줄입니다.
- Routing: 취소 및 인터럽트는 채널에 메시지로 퍼블리시되어 서버 프로세스가 즉시 소비하므로, 취소 마커를 데이터베이스에서 폴링할 필요가 없습니다.
커뮤니티 관점
SSE의 도전 과제가 크지만, 일부 개발자는 "난이도"가 구현 문제라고 제안합니다. 예를 들어, 한 기여자는 Redis Streams 기반 SSE 구현이 SSE 프로토콜을 완전히 포기하지 않고도 상태와 내구성 문제를 많이 해결할 수 있다고 제안했습니다. 다른 사람들은 토큰 이벤트와 소켓 종료를 자동으로 처리하도록 설계된 Durable Streams와 같은 신흥 도구나 특화된 API를 지목했습니다.
궁극적으로 선택은 애플리케이션 규모에 달려 있습니다. 간단한 봇에는 SSE가 충분합니다. 그러나 높은 신뢰성과 낮은 지연을 요구하는 복잡한 다중 디바이스 에이전트의 경우, 상태 관리를 데이터베이스에서 전송 계층으로 옮기는 것이 강력한 아키텍처 변화가 됩니다.