SSE 알림이 "되다 멈춘다"를 끝낸 방법
요약: SSE 알림이 "잠깐 되다가 멈추는" 현상을 추적한 트러블슈팅 기록입니다. 단일 원인이 아니라 프록시 버퍼링, 클라이언트 연결 정책, 재연결 누락, 환경 정합성 문제가 복합적으로 얽혀 있었고, 이를 하나씩 풀어간 과정을 공유합니다.
시작하며
안녕하세요, 개인 블로그를 개발하고 있는 아퀼라 입니다.
여러분은 실시간 알림을 구현해 놓고, 어느 순간 알림 벨이 조용히 멈춰버린 경험이 있으신가요?
저도 그랬습니다. 댓글이 달렸는데 알림이 안 오고, 새로고침하면 그제야 반영되는 상황. "실시간이라며?"라는 의문이 들기 시작하면, 그때부터 서비스 신뢰도가 흔들리기 시작하죠.
이번 글에서는 /member/api/v1/notifications/stream 엔드포인트에서 발생한 SSE 장애를 추적하고 해결한 과정을 공유하려고 합니다. 실제 운영 로그, 커밋 기록, 대화 내용을 기반으로 정리했어요.
한 가지 미리 말씀드리면, 이 문제는 SSE 하나만 고쳐서 해결된 게 아닙니다. 오히려 "진짜 원인"과 "동시에 터진 노이즈"를 분리하는 과정이 더 중요했습니다.
| 항목 | 내용 |
|---|---|
| 증상 | 알림 벨이 실시간 갱신되지 않거나, 잠깐 동작 후 멈춤 |
| 단일 원인? | 아님. 프록시 flush + 클라이언트 연결 정책 + 재연결 누락 + 도메인/CORS 정합성의 조합 |
| 최종 해결 | SSE 전용 프록시 핸들러 + heartbeat + backoff + Last-Event-ID replay + same-site 우선 SSE + 환경 정합성 진단 |
| 관련 커밋 | 237cefe, 43794d3, b31f14c |
뭐가 문제였을까? — 장애가 보였던 방식
처음 장애를 인지한 건 단순한 체감이었습니다.
"댓글 알림이 안 와요."
좀 더 자세히 살펴보니 이런 패턴이 보였어요.
- 댓글이나 답글을 달아도 상대방의 알림 벨에 즉시 반영되지 않음
- 잠깐 동작하다가 몇 분 뒤 알림 벨이 멈춘 것처럼 보임
- 네트워크가 흔들린 뒤 복구되어도, 그 사이에 발생한 이벤트가 누락됨
www/api가 분리된 환경에서는 SSE 대신 polling으로만 동작
flowchart LR
U["사용자 체감: 알림이 멈춤"] --> A["새로고침하면 최신 상태 반영"]
A --> B["'실시간'이 아닌 서비스로 인식"]
B --> C["신뢰도 하락 + 운영 문의 증가"]
새로고침하면 최신 상태가 보이니까, 데이터 자체는 문제가 없었습니다. "실시간 전달 경로"에 문제가 있다는 건 분명했어요. 하지만 원인을 찾는 건 생각보다 훨씬 복잡했습니다.
에러 로그 속 미로 — 진짜 원인 찾기
로그를 열어보니, 에러가 한두 개가 아니었습니다. 여러 종류의 에러가 같은 타임라인에 뒤섞여 있었어요. 처음엔 이것들이 전부 SSE 장애와 관련된 줄 알았습니다.
좋아요 토글 500 — "이게 원인인가?"
가장 먼저 눈에 띈 건 좋아요 API의 500 에러였습니다.
POST <https://api.aquilaxk.site/post/api/v1/posts/402/like> 500 (Internal Server Error)SSE 알림이 멈춘 시점과 비슷해서, 처음엔 이 에러가 SSE 스트림을 죽이는 건 아닌지 의심했어요. 하지만 확인해 보니 좋아요 API와 SSE 스트림은 완전히 별개의 요청이었습니다. 동시에 터졌을 뿐, 서로 영향을 주지 않았어요.
라우팅 취소 — "2차 증상일 뿐"
Error: Loading initial props cancelled이건 Next.js에서 페이지 전환 중 이전 요청이 취소될 때 나오는 에러입니다. SSE와는 관계없는 클라이언트 사이드 이슈였어요.
SMTP 오타 — "전혀 다른 문제"
SSLHandshakeException: No subject alternative DNS name matching smtp.gamil.com found.gmail.com이 아니라 gamil.com으로 오타가 나 있었습니다. 이메일 발송 장애인데, 같은 시간대에 터지다 보니 SSE 문제와 혼동됐어요.
배포/헬스체크 노이즈 — "배경 소음"
The host [back_blue:8080] is not validhealthcheck pending: back_blue (status=400)Blue/Green 배포 과정에서 발생하는 일시적인 헬스체크 실패였습니다. 이것도 같은 타임라인에 로그가 쌓이면서 상황을 더 복잡하게 만들었어요.
flowchart TD
E["에러 로그 다발"] --> N1["SSE 원인"]
E --> N2["Like 500"]
E --> N3["SMTP 오타"]
E --> N4["헬스체크/배포 노이즈"]
N1 --> D["원인 분리 관측이 핵심"]
N2 --> D
N3 --> D
N4 --> D좋아요 500, SMTP 오타, 헬스체크 에러가 같은 타임라인에 섞여서 SSE 장애처럼 보이는 착시를 만들고 있었습니다. 그래서 가장 먼저 해야 할 일은 "SSE 스트림 자체의 문제"와 "동시에 발생한 별도 장애"를 분리하는 것이었어요.
노이즈를 걷어내고 나니, SSE 스트림 자체에 집중할 수 있었습니다. 그리고 근본 원인이 하나가 아니라 네 개라는 걸 알게 됐어요.
근본 원인 4개
1) Proxy 계층 — flush/버퍼링 불안정
Caddy가 SSE 응답을 버퍼링하고 있었습니다. 이벤트가 발생해도 즉시 클라이언트에 전달되지 않고, 모아서 한꺼번에 보내거나 아예 끊기는 현상이 발생했어요.
2) Client 연결 정책 — 과보수적 판단
클라이언트가 api 도메인과 www 도메인이 다르면 무조건 polling을 선택하도록 되어 있었습니다. same-site 환경에서도 SSE의 장점을 전혀 못 쓰고 있었어요.
3) Resume 부재 — 재연결 시 누락 복구 없음
네트워크가 끊겼다가 다시 연결되면, 그 사이에 발생한 이벤트를 복구하는 로직이 없었습니다. 잠깐 끊긴 구간의 알림은 영구적으로 사라졌어요.
4) Env 정합성 — URL/쿠키/CORS 불일치
FRONTURL, BACKURL, COOKIEDOMAIN, API_DOMAIN 등 환경 변수들이 환경별로 미묘하게 달랐습니다. 이 때문에 CORS나 쿠키 전달이 환경에 따라 되기도 하고 안 되기도 했어요.
flowchart TD
ROOT["SSE 알림 먹통"]
ROOT --> R1["Proxy flush/buffering"]
ROOT --> R2["Client 연결 정책 과보수"]
ROOT --> R3["Last-Event-ID replay 부재"]
ROOT --> R4["도메인/CORS 설정 불일치"]
원인을 알았으니, 이제 어떻게 해결할지 고민할 차례였습니다.
해결 방안 탐색 — 뭘 시도했고, 뭘 선택했나
원인별로 가능한 방안을 리스트업하고, 하나씩 검토해 봤습니다.
Proxy 문제: 어떻게 flush를 보장할까?
방안 A — Caddy SSE 전용 핸들러 + flush_interval -1 ( 👍 )
현재 스택이 이미 Caddy이니, SSE 경로만 별도 핸들러로 분리하고 flush_interval을 -1(즉시 flush)로 설정하는 방법입니다. 기존 인프라를 그대로 쓸 수 있어 즉시 적용이 가능했어요.
방안 B — Nginx로 전환 후 X-Accel-Buffering: no ( ❌ )
Nginx에서도 SSE 버퍼링을 끌 수 있지만, 프록시 자체를 Caddy에서 Nginx로 전환해야 합니다. 전환 비용이 너무 컸어요.
방안 C — 프록시 없이 백엔드 직접 노출 ( ❌ )
가장 단순하지만, SSL 인증서 관리와 보안을 포기해야 합니다. 운영 환경에서는 불가능한 선택이었어요.
→ 방안 A를 채택했습니다. 현재 스택과 호환되고, 변경 범위가 가장 작았어요.
Client 정책: SSE를 언제 쓸까?
방안 A — same-site 감지 → SSE 우선, cross-site → polling 폴백 ( 👍 )
same-site 환경에서는 SSE의 실시간성을 최대로 살리고, cross-site 환경에서만 polling으로 안전하게 폴백하는 전략입니다.
방안 B — 항상 SSE 강제 (CORS 전면 허용) ( ❌ )
모든 origin을 허용하면 보안 리스크가 커집니다. 인증 쿠키가 오가는 엔드포인트에서 이건 위험했어요.
방안 C — 항상 polling (SSE 포기) ( ❌ )
실시간성을 완전히 포기하는 건 서비스 가치를 훼손합니다. polling 주기를 아무리 줄여도 SSE만큼의 즉시성은 나오지 않죠.
→ 방안 A를 채택했습니다. 실시간성과 안전성의 균형점이었어요.
Resume: 끊긴 이벤트를 어떻게 복구할까?
방안 A — 서버 인메모리 버퍼 + Last-Event-ID replay ( 👍 )
서버가 최근 이벤트 100개를 인메모리에 보관하고, 클라이언트가 재연결할 때 Last-Event-ID를 보내면 누락분을 replay해 주는 방식입니다.
방안 B — Redis Streams 기반 중앙 replay ( ❌ )
Redis를 도입하면 멀티 인스턴스에서도 replay가 가능하지만, 현재 단일 인스턴스 운영 중이라 과도한 인프라 비용이었어요.
방안 C — 재연결 시 전체 목록 재조회 ( ❌ )
가장 단순하지만, 알림이 수백 개일 때 매번 전체를 가져오면 트래픽 낭비에 UX 지연까지 발생합니다.
→ 방안 A를 채택했습니다. 단일 인스턴스 규모에서 인메모리 버퍼 100개면 충분했어요.
Env 정합성: 불일치를 어떻게 잡을까?
방안 A — doctor.sh 자동 진단 스크립트 ( 👍 )
배포 직후 한 번 실행하면 FRONTURL, BACKURL, COOKIEDOMAIN, API_DOMAIN의 정합성을 자동으로 점검해 주는 셸 스크립트입니다.
방안 B — CI 파이프라인 내 env 검증 스텝 ( ❌ )
좋은 방법이지만, 현재 배포 파이프라인(GitHub Actions + GHCR + Blue/Green) 수정 범위가 커져서 일단 보류했어요.
방안 C — 수동 체크리스트 ( ❌ )
사람이 매번 확인하는 건 결국 실수를 유발합니다. 이미 한 번 겪었으니까요.
→ 방안 A를 채택했습니다. 지금 당장 쓸 수 있고, 사람 실수를 줄여줍니다.
현재 스택 호환성 — 새 인프라 도입 없이 Caddy + Spring + Next.js 안에서 해결
- 구현 복잡도 대비 효과 — 단일 인스턴스 규모에 맞는 적정 수준의 솔루션
- 안전망 확보 — SSE가 실패해도 polling으로 기능이 유지되는 이중 경로
- 운영 자동화 — 사람 실수에 의존하지 않는 진단 도구 내장
최종 아키텍처 — 이렇게 동작합니다
선택한 방안들을 조합하면, 전체 흐름은 다음과 같습니다.
sequenceDiagram
participant B as Browser(NotificationBell)
participant C as Caddy(SSE route)
participant A as Spring SSE Controller
participant S as SseService(buffer/replay)
B->>C: GET /notifications/stream?lastEventId=...
C->>A: Proxy pass (flush_interval=-1)
A->>S: subscribe(memberId, lastEventId)
S-->>A: missed events replay + live stream
A-->>B: event:id/retry/data + heartbeat(20s)
Note over B: 연결 실패 누적 시 backoff 재연결
Note over B: 반복 실패면 polling fallback
핵심 포인트를 정리하면 이래요.
- 서버가 모든 이벤트에
id를 부여하고, 20초마다heartbeat를 보냄 - 클라이언트가 마지막으로 받은
lastEventId를 추적하고, 재연결 시 서버에 전달 - 프록시(Caddy)가 SSE 스트림을 즉시 flush해서 버퍼링 지연 제거
- same-site면 SSE를 우선 사용하고, cross-site면 polling으로 안전하게 폴백
커밋별로 어떻게 바뀌었나
실제 적용은 세 번의 커밋에 걸쳐 이루어졌습니다.
237cefe — SSE 최초 도입
SSE 엔드포인트, 알림 벨 컴포넌트, 알림 도메인을 처음 구성한 커밋입니다.
back/.../ApiV1MemberNotificationController.ktback/.../MemberNotificationSseService.ktfront/src/layouts/RootLayout/Header/NotificationBell.tsx
이 시점에서는 네트워크 변동이나 재연결, 누락 복구 시나리오를 고려하지 않았습니다. "일단 동작하게 만들기"에 집중한 단계였어요.
43794d3 — 1차 안정화
멈추는 현상을 본격적으로 잡기 시작한 커밋입니다.
- heartbeat 추가 — 20초마다 빈 이벤트를 보내 연결이 살아 있음을 확인
- 클라이언트 backoff 재연결 — 실패 시 점진적으로 간격을 늘려 재시도
- Caddy SSE 전용 핸들러 —
flush_interval -1로 즉시 flush 보장
관련 파일:
deploy/homeserver/Caddyfileback/.../MemberNotificationSseService.ktfront/.../NotificationBell.tsx
b31f14c — 근본 보강
나머지 원인들을 모두 해결한 커밋입니다.
Last-Event-IDreplay — 서버가 최근 100개 이벤트를 버퍼에 보관하고, 재연결 시 누락분을 replayevent.lastEventId추적 — 클라이언트가 마지막 이벤트 ID를 기억하고 재연결 시 전달- stream 헤더 명시 —
Cache-Control,Connection,X-Accel-Buffering설정 - CORS 허용 origin 강화 —
frontUrl + cookieDomain기반 - same-site 우선 SSE — 도메인 비교 로직 개선
doctor.sh— 도메인 정합성 자동 점검 스크립트 추가
관련 파일:
back/.../ApiV1MemberNotificationController.ktback/.../MemberNotificationSseService.ktfront/.../NotificationBell.tsxback/.../SecurityConfig.ktdeploy/homeserver/doctor.sh
gantt
title "SSE 안정화 타임라인"
dateFormat X
axisFormat %s
section "초기"
"237cefe: SSE 최초 도입" :a1, 0, 1
section "1차 안정화"
"43794d3: heartbeat/backoff" :a2, 1, 2
"43794d3: Caddy flush_interval -1" :a3, 2, 3
section "근본 보강"
"b31f14c: Last-Event-ID replay" :a4, 3, 4
"b31f14c: same-site 우선 SSE" :a5, 4, 5
"b31f14c: doctor 정합성 진단" :a6, 5, 6
"그거 임시방편 아니야?" — 에 대한 답
polling 폴백만 넣고 끝냈다면 맞습니다. 임시방편이에요.
하지만 이번 수정은 폴백만 넣은 게 아니라, 근본 경로를 먼저 안정화한 위에 폴백을 얹은 구조입니다.
- SSE 전용 프록시 경로 + flush 보장
- heartbeat + backoff +
Last-Event-IDreplay - same-site에서 SSE 우선 사용
- 운영 환경 정합성 진단 자동화(
doctor.sh)
즉, 근본 경로(SSE) 안정화 + 실패 시 안전망(polling) 구조로 바꾼 겁니다. 임시방편이 아니라, 이중 안전장치에 가깝습니다.
적용 결과 — Before vs After
| 검증 항목 | Before | After | 판정 |
|---|---|---|---|
| 댓글/답글 알림 | 수 분 지연 또는 미반영. 새로고침 필요 | 1~2초 내 벨 카운트 즉시 반영 | ✅ |
| 네트워크 순간 단절 | 끊긴 구간 이벤트 영구 누락 | Last-Event-ID 기반 재연결로 누락 알림 replay | ✅ |
www/api 분리 환경 | SSE 대신 polling으로만 동작 | same-site 감지 시 SSE 우선 연결 | ✅ |
| SSE 반복 실패 | 알림 기능 완전 중단 | backoff 재시도 후 polling 폴백으로 기능 지속 | ✅ |
| heartbeat | 장시간 무응답 → 프록시/브라우저가 연결 종료 | 20초 간격 heartbeat로 연결 유지 | ✅ |
| 환경 진단 | 도메인/CORS 불일치를 수동 확인 → 누락 잦음 | doctor.sh 1회 실행으로 즉시 탐지 | ✅ |
📊 6개 검증 항목 모두 통과. 수정 이후 SSE 관련 운영 문의 0건을 유지하고 있습니다.
장애가 다시 터지면? — Runbook
만약 비슷한 증상이 다시 발생하면, 아래 플로우를 따라가면 됩니다.
flowchart TD
A["1) /notifications/stream 응답 확인"] --> B{"200 + text/event-stream + pending?"}
B -- "No" --> P["프록시/CORS/쿠키 설정 점검"]
B -- "Yes" --> C{"20초 내 heartbeat 수신?"}
C -- "No" --> P
C -- "Yes" --> D{"재연결 후 lastEventId replay 동작?"}
D -- "No" --> R["SSE 서비스 replay 로직 점검"]
D -- "Yes" --> E["SSE 정상. 동시 장애 분리 진단"]
환경 정합성은 아래 명령어 한 줄로 점검할 수 있어요.
bash deploy/homeserver/doctor.sh내용을 입력하세요.
FRONTURL/BACKURL are cross-siteCOOKIEDOMAIN does not match ...API_DOMAIN does not match BACKURL ...
남은 과제
해결했다고 끝은 아닙니다. 현재 구조에서 아직 개선할 부분이 있어요.
- replay 버퍼가 인메모리(100개) — 인스턴스가 재시작되면 유실됩니다. 장기적으로는 Redis나 Event log 기반 중앙 replay가 필요해요.
- 멀티 인스턴스 확장 — 지금은 단일 인스턴스 가정에 최적화되어 있습니다. 스케일 아웃 시 Pub/Sub + consumer group 구조로 전환해야 합니다.
- 터널/프록시 정책 변경 — Cloudflare Tunnel 정책이 바뀌면 장기 연결에 영향을 줄 수 있습니다. SSE synthetic check + 알림이 필요해요.
- 동시 장애 혼입 — 이번에 겪었듯이, 다른 장애가 섞이면 진단 시간이 늘어납니다. 대시보드에서 도메인별 에러를 분리 표시하는 게 다음 목표입니다.
마치며
이번 트러블슈팅에서 가장 크게 배운 건, SSE 구현 자체보다 "운영에서 원인을 어떻게 분리해서 보느냐"였습니다.
- SSE를 쓰려면 프록시 · 재연결 · replay · 환경 정합성을 세트로 설계해야 합니다.
- 에러가 많을수록 "진짜 원인"과 "동시 노이즈"를 분리해야 해결 속도가 올라갑니다.
이 글이 비슷한 문제를 겪고 계신 분들에게 도움이 되면 좋겠습니다. 궁금한 점이 있으시면 편하게 댓글 남겨 주세요!
