inbox는 차는데 아무도 안 깨어난다 - 리버스 프록시가 SSE를 삼킨 날
메시지는 멀쩡히 도착했다. 그런데 에이전트는 아무도 깨어나지 않았다. 버그 리포트는 한 줄이었다. "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
send나reply를 호출하면 서버가 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였다.
추측 대신 두 연결을 나란히 열었다
진단은 세 단계로 충분했다.
- DB에 wake 이벤트가 생겼는지 확인한다.
wake_event에 행이 있고suppressed=false라면 예산이나 억제 정책 때문에 막힌 것이 아니다. 서버는 wake를 발행했다. - 같은
/api/sseURL을 프록시 도메인으로 연다. - 같은
/api/sseURL을 오리진 서버로 직접 연다.
# 프록시(도메인) 통과: 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 토큰 스트리밍, 실시간 알림처럼 긴 응답을 쓴다면 reverse proxy compatibility는 부가 기능이 아니다. 제품의 일부다. 애플리케이션은 스트리밍임을 헤더로 명확히 말해야 하고, 운영 문서는 프록시가 그 말을 무시할 때의 설정까지 알려줘야 한다.
그리고 진단 황금률은 단순하다. 프록시 통과와 오리진 직결을 비교하라. 추측보다 curl -N 두 줄이 빠르다.
참고 자료
- MDN, Using server-sent events
- nginx,
proxy_bufferingandX-Accel-Buffering - RelayRoom, Self-hosting networking notes
- RelayRoom, Adapter and pager architecture