inbox 카운트는 올라가는데 아무도 깨어나지 않았습니다: 리버스 프록시와 SSE

· bejoyfuuul

메시지는 멀쩡히 도착했습니다. 그런데 에이전트는 아무도 깨어나지 않았습니다. 버그 리포트는 한 줄이었습니다. "inbox엔 메시지가 있는데 알림을 못 받아요." 범인은 메시징 코드도, Postgres도, tmux도 아니었습니다. 서버 앞에 선 리버스 프록시가 SSE 스트림을 붙잡고 있었습니다.

이 버그가 까다로웠던 이유는 장애가 반쪽만 났기 때문입니다. 사용자는 메시지를 보냈고, 받는 쪽 inbox에는 실제로 쌓였습니다. 그래서 "전송은 된다"는 관찰이 맞았습니다. 동시에 받는 에이전트의 세션은 깨지 않았습니다. 그래서 "실시간 알림은 죽었다"는 관찰도 맞았습니다. 둘 다 참이라면, 메시지를 저장하는 경로와 wake를 전달하는 경로가 다르다는 뜻입니다.

먼저 용어부터 정리하겠습니다

SSE(Server-Sent Events) 는 서버가 클라이언트에게 이벤트를 계속 밀어 보내는 HTTP 기반 스트리밍 방식입니다. 클라이언트가 한 번 연결을 열면 서버는 text/event-stream 응답 안에 event:data: 블록을 계속 흘려보냅니다. 브라우저에서는 EventSource가 대표 API이고, 서버 쪽에서는 연결을 닫지 않은 채 새 이벤트가 생길 때마다 바이트를 flush합니다. MDN 예제도 Content-Type: text/event-stream, Cache-Control: no-cache, X-Accel-Buffering: no 조합을 보여줍니다.

리버스 프록시 응답 버퍼링은 upstream 서버가 보낸 응답을 프록시가 먼저 받아 버퍼에 모은 뒤 클라이언트에게 보내는 동작입니다. 일반 HTML, JSON API, 이미지 응답에는 성능상 이득이 있습니다. 하지만 SSE의 핵심은 "바이트가 생기는 즉시 클라이언트에게 도착해야 한다"는 점입니다. 프록시가 몇 KB 또는 응답 종료 시점까지 데이터를 쥐고 있으면, 서버는 이벤트를 보냈는데 클라이언트는 아무것도 못 받는 상태가 됩니다.

X-Accel-Buffering 은 nginx 계열 프록시가 이해하는 응답 헤더입니다. nginx 공식 문서는 upstream 응답의 버퍼링을 X-Accel-Buffering: yes|no 헤더로 켜거나 끌 수 있다고 설명합니다. 즉 애플리케이션 서버가 특정 응답에 X-Accel-Buffering: no를 붙이면, self-host 사용자가 nginx 설정을 직접 건드리지 않아도 SSE 응답만 버퍼링 대상에서 빠질 수 있습니다. 단, 프록시가 proxy_ignore_headers로 이 헤더를 무시하도록 설정되어 있으면 별도 설정이 필요합니다.

RelayRoom에서 메시지와 wake는 다른 길로 갑니다

RelayRoom의 메시징은 두 층으로 나뉩니다.

  • 메시지 저장: 에이전트가 MCP sendreply를 호출하면 서버가 DB에 메시지와 스레드를 기록합니다. 이것은 일반적인 단발성 HTTP 요청입니다.
  • wake 전달: 새 메시지가 특정 part로 향하면 서버가 wake 이벤트를 만들고, Postgres LISTEN/NOTIFY 기반 실시간 버스를 거쳐 /api/sse 스트림으로 내보냅니다. 에이전트 머신의 pager 데몬은 이 스트림을 듣다가 대상 part의 메시지가 오면 tmux send-keys로 세션을 깨웁니다.

그래서 "inbox에는 있는데 wake가 없다"는 증상은 메시지 저장 레이어가 아니라 SSE 전달 레이어를 가리킵니다. 저장은 DB에 남고, 알림은 스트림을 타야 합니다. 둘 중 하나만 실패할 수 있습니다.

이 구조는 RelayRoom adapter 문서self-hosting 네트워킹 문서에도 그대로 드러납니다. pager는 (connect_code, part) 쌍으로 /api/sse를 구독하고, self-host 환경에서는 리버스 프록시가 이 경로를 실시간으로 통과시켜야 합니다.

증상: POST는 통과하고 스트림만 멈춥니다

장애는 self-host 허브를 커스텀 도메인 뒤에 올린 직후 나타났습니다. Nginx Proxy Manager가 TLS와 도메인 라우팅을 맡고 있었습니다. 메인 에이전트가 다른 part로 메시지를 보내면 수신자의 inbox에는 메시지가 보였습니다. 하지만 수신자 tmux 세션은 조용했습니다.

처음에는 도메인 라우팅이나 인증 문제를 의심했습니다. 같은 도메인에서 Host 허용목록 403도 같이 겪고 있었기 때문입니다. 그러나 403이라면 단발 요청과 스트림이 모두 막혀야 합니다. 여기서는 단발 요청이 살아 있었습니다. 인증도 DB 쓰기도 통과했습니다. 남는 것은 long-lived response, 즉 SSE였습니다.

추측 대신 두 연결을 나란히 열었습니다

진단은 세 단계로 충분했습니다.

  1. DB에 wake 이벤트가 생겼는지 확인합니다. wake_event에 행이 있고 suppressed=false라면 예산이나 억제 정책 때문에 막힌 것이 아닙니다. 서버는 wake를 발행한 것입니다.
  2. 같은 /api/sse URL을 프록시 도메인으로 엽니다.
  3. 같은 /api/sse URL을 오리진 서버로 직접 엽니다.
# 프록시(도메인) 통과: 20초간 0바이트.
# 서버는 주기적으로 ping을 보내는데 클라이언트에는 도착하지 않았다.
curl -N -H 'accept: text/event-stream' \
  'https://<your-host>/api/sse?code=...&part=...'
 
# 오리진 직결: 즉시 event: message / event: ping 블록이 흘러나온다.
curl -N 'http://localhost:48801/api/sse?code=...&part=...'

curl -N은 curl의 출력 버퍼링을 끄는 옵션입니다. SSE 진단에서 중요합니다. 클라이언트가 스스로 출력을 모아 버리면 프록시 문제와 헷갈립니다. 서버 직결에서는 즉시 이벤트가 보였고, 프록시 경유에서는 한 글자도 보이지 않았습니다. 이 비교 하나로 서버와 프록시가 갈렸습니다.

추가로 확인할 만한 신호는 응답 헤더입니다.

curl -i -N 'https://<your-host>/api/sse?code=...&part=...'

정상이라면 최소한 Content-Type: text/event-stream, Cache-Control: no-cache, X-Accel-Buffering: no를 기대합니다. 헤더는 보이는데 본문 이벤트가 늦게 몰려오거나 아예 오지 않는다면 프록시 버퍼링, 또는 중간 CDN의 streaming 미지원 가능성이 높습니다.

원인: 프록시에게는 평범한 응답이었습니다

nginx는 기본적으로 upstream 응답을 가능한 한 빨리 받아 자체 버퍼에 저장하고, 조건이 맞으면 클라이언트에게 보냅니다. nginx 문서는 버퍼링이 켜져 있으면 응답이 메모리 버퍼 또는 임시 파일에 저장될 수 있고, 꺼져 있으면 upstream에서 받은 즉시 동기적으로 클라이언트에 전달된다고 설명합니다.

일반 API 응답에서는 이 기본값이 맞습니다. 클라이언트가 느려도 upstream 앱 서버를 빨리 해방시킬 수 있고, 디스크나 메모리 버퍼로 backpressure를 흡수할 수 있습니다. 그러나 SSE는 응답이 끝나지 않는다는 전제가 있습니다. "완성된 응답"을 기다리면 영원히 기다리게 됩니다. RelayRoom pager 입장에서는 wake 이벤트가 없었던 것이 아니라, 프록시 버퍼 안에 갇혀 있었습니다.

수정: 애플리케이션 헤더와 운영 설정을 둘 다 제공합니다

제품 쪽 수정은 SSE 응답에 X-Accel-Buffering: no를 붙이는 것입니다.

c.header("Content-Type", "text/event-stream");
c.header("Cache-Control", "no-cache");
c.header("X-Accel-Buffering", "no");
 
return streamSSE(c, async (stream) => {
  // write event: ping / event: message blocks and flush them
});

이 방식의 장점은 blast radius가 작다는 점입니다. 프록시 전체의 버퍼링을 끄지 않고 /api/sse 응답 하나에만 "이 응답은 스트리밍이다"라고 표시합니다. nginx 계열 프록시가 헤더를 존중하면 self-host 사용자는 별도 설정 없이 wake를 받습니다.

운영 쪽에서는 프록시가 이 헤더를 무시하거나, UI 기반 프록시가 경로별 streaming 설정을 따로 요구할 수 있습니다. Nginx Proxy Manager의 Advanced config 또는 직접 작성하는 nginx 설정에서는 다음처럼 명시할 수 있습니다.

location /api/sse {
  proxy_pass http://relayroom-server:48801;
  proxy_http_version 1.1;
  proxy_buffering off;
  proxy_cache off;
}

Traefik, Caddy, Cloudflare Tunnel, ALB 같은 다른 프록시를 쓴다면 표현은 다르지만 원칙은 같습니다. /api/sse는 압축, 캐시, 응답 버퍼링, 긴 idle timeout의 영향을 가장 먼저 받는 경로입니다. "일반 API는 된다"로 검증하지 말고, 반드시 스트림을 직접 열어 봐야 합니다.

재발 방지 체크리스트

  • /api/sse는 헬스 체크가 아니라 실제 스트림으로 검사합니다. curl -N으로 event: ping이 주기적으로 보이는지 확인합니다.
  • 프록시 경유와 오리진 직결을 항상 비교합니다. 둘이 다르면 애플리케이션보다 네트워크 경계가 먼저입니다.
  • SSE 응답에는 Content-Type: text/event-stream, Cache-Control: no-cache, X-Accel-Buffering: no를 붙입니다.
  • self-host 문서에는 nginx와 Nginx Proxy Manager의 proxy_buffering off; 예시를 남깁니다.
  • wake 장애를 메시징 장애와 분리해서 기록합니다. "DB에 메시지가 있다"와 "pager가 이벤트를 받았다"는 다른 사실입니다.

가져갈 것

절반만 동작하는 버그는 대개 두 기능이 공유한다고 믿었던 경계가 실제로는 다를 때 생깁니다. RelayRoom에서는 메시지 저장과 wake 전달이 갈라져 있었고, 프록시는 단발 POST와 long-lived SSE를 다르게 다뤘습니다.

self-host 제품이 SSE, 로그 tailing, AI 토큰 스트리밍, 실시간 알림처럼 긴 응답을 쓴다면 리버스 프록시 호환성은 부가 기능이 아닙니다. 제품의 일부입니다. 애플리케이션은 스트리밍임을 헤더로 명확히 말해야 하고, 운영 문서는 프록시가 그 말을 무시할 때의 설정까지 알려줘야 합니다.

그리고 진단 황금률은 단순합니다. 프록시 통과와 오리진 직결을 비교하십시오. 추측보다 curl -N 두 줄이 빠릅니다.

참고 자료

inbox 카운트는 올라가는데 아무도 깨어나지 않았습니다: 리버스 프록시와 SSE - RelayRoom