agent 채팅은 SSE로 보내고 server가 유일한 public edge로 중계한다
맥락
토큰이 흐르는 화면을 만들려면 스트림이 필요하다. 그런데 그 스트림을 만드는 쪽(heymoa-ai의 LangGraph)과 소유해야 하는 것(인증·히스토리·외부 도구 자격증명)이 서로 다른 서비스에 있었다. 브라우저가 ai에 직접 붙으면 ai가 public edge가 된다. Linear·GitHub OAuth 토큰도 브라우저를 지나야 한다.
전사 구간에는 이미 STOMP 브로커가 서 있었으므로 "그걸 쓰면 되지 않나"가 기본 선택지였다. 하지만 채팅의 수명은 메시지 한 건이다. 회의 전체를 덮는 전사와 길이부터 다르다. 방향은 서버→클라이언트 단방향이며 구독 개념도 팬아웃도 없다.
결정
- server가 유일한 public edge다.
POST /v1/agent-chats/{chatId}/messages가text/event-stream으로 응답하고, 그 안에서AgentChatSseClient가 heymoa-ai의 SSE를 받아 그대로 흘려보낸다. 전사 구간의 판단을 여기에도 그대로 적용했다. 브라우저는 외부 LLM 공급자에 직접 붙지 않는다. 서버가 중계하며 인증·권한·키· 저장을 소유한다. - server는 passthrough다. 이벤트를 변환하지 않으므로 자기 DTO도 없다. 그 결과 SSE 이벤트 스키마의 단일 출처는 AsyncAPI 문서가 된다. server의 restdocs는 이 스키마를 모른다.
- 통과시키면서 히스토리를 tee한다. 사용자 메시지와 완성된 assistant 메시지는 DB에 남긴다. 스트림이 오류로 끝나면 아무것도 저장하지 않는다.
- 브라우저 인증은
access_token쿠키 하나로 전사 구간과 같다. 도구 자격증명은 server가 조립해 ai 호출에만 싣고 응답·로그에는 마스킹된 형태로만 남긴다(ADR-0011). - 스트림 수명 상한은 30분이다. keepalive는 comment 프레임으로 보낸다.
대안
대안 1 — STOMP 재사용. 이미 서 있는 브로커를 쓰니 프로토콜이 하나로 유지된다. 기각한 이유: 단방향·단일 수명 스트림에 양방향 프로토콜과 브로커 목적지를 붙이는 값이 없다. 채팅은 구독이 아니라 요청-응답이다.
대안 2 — 폴링. 분석 완료 알림은 실제로 이쪽으로 갔다(ADR-0010). 기각한 이유: 채팅은 토큰 단위로 그려야 해서 같은 선택이 원리적으로 불가능하다.
대안 3 — 브라우저 → heymoa-ai 직접 연결. 중계 계층이 사라져 가장 단순하고 지연도 가장 짧다. 기각한 이유: ai가 public edge가 된다. 인증·자격증명·히스토리도 전부 ai로 새어 나간다.
결과
- 인증·히스토리·외부 토큰이 전부 server에 남는다. ai는 public edge가 되지 않는다.
- 단방향 스트림에 브로커 목적지·구독 관리가 붙지 않는다.
POST+ SSE라 브라우저EventSource를 못 쓴다.EventSource는 GET 전용이다. 그래서 web이lib/api/sse.ts에 프레임 파서를 직접 들고 있고, 재연결·이벤트 ID 재개 같은 표준 기능도 함께 잃었다.- 실시간 경로가 결국 둘이 됐다 — 전사는 STOMP, 채팅은 SSE. 하트비트·재연결·백프레셔를 각각 푼다.
- 중계 스레드가 스트림이 끝날 때까지 붙잡혀 있다. 동시 스트림 상한이 곧 스레드 수다. 큐잉은 굶주림이라
초과 요청을 즉시 거부해야 한다(전용 executor
core=max=32,queue=0+ heartbeat 스케줄러 분리가 여기서 나왔다). - 중간 장비 문제를 우리가 떠안았다. 프록시 버퍼링과 유휴 연결 폐기는 곧바로 "답변이 뭉쳐 나온다"로
나타났다. 실제로 nginx
location /에proxy_buffering off가 없어 터졌다(APP-229). - ai가 죽으면 server가 죽은 것처럼 보인다. 스트림이 중간에 끊겼을 때 사용자 화면에서 두 서비스를 구분할 방법이 없다.
- passthrough라 계약 검증을 server 테스트가 못 한다. 스키마가 틀려도 server 빌드는 초록이다.