알림 시스템, 120초 폴링에서 SSE 실시간으로 전환한 이야기


2분은 사용자에게 너무 길었다
FullStackFamily 알림은 원래 120초 폴링이었습니다.
- 댓글이 달려도 최대 2분 뒤에 뱃지 증가
- 답변 채택/좋아요도 최대 2분 지연
- 알림이 적을 때도 주기적으로 API 호출 발생
그래서 다시 SSE(Server-Sent Events)로 전환했습니다.
왜 SSE였나
알림은 본질적으로 서버 -> 클라이언트 단방향 이벤트입니다.
- 채팅처럼 양방향이 필요하지 않음
- HTTP 기반으로 운영/프록시(Nginx)와 궁합이 좋음
- 이벤트를 즉시 전달하기에 적합
다만 인증이 Authorization: Bearer {JWT}라서 브라우저 EventSource 대신 fetch + ReadableStream으로 구현했습니다.
const response = await fetch(`${apiUrl}/api/notifications/sse`, { headers: { Authorization: `Bearer ${token}` }, signal: controller.signal, })
SSE 동작 원리: 왜 스레드보다 연결 수가 중요할까
SSE를 처음 도입할 때 가장 많이 나오는 질문이 이겁니다.
사용자가 늘면 Tomcat 스레드가 SSE 연결 수만큼 묶이지 않나요?
Spring SseEmitter는 Servlet 비동기 + NIO 기반이라 동작 방식이 다릅니다.
- 클라이언트가
/sse를 호출하면, 요청 스레드가SseEmitter를 생성 - 생성 직후 요청 스레드는 풀로 반환 (지속 점유하지 않음)
- 연결 유지 자체는 NIO 소켓/selector가 담당
- 실제 스레드는 이벤트를
send()할 때 짧게 사용
즉, SSE에서 먼저 보는 지표는 thread보다 connection입니다.
- Tomcat:
maxConnections, keep-alive, FD(파일 디스크립터) - Nginx:
worker_connections, upstream timeout, buffering/gzip 설정 - OS:
ulimit -n, 네트워크 소켓 자원
정리하면, SSE는 “스레드 고갈형 문제”보다 “연결/소켓 자원 관리 문제”가 훨씬 현실적인 병목입니다.
전환보다 어려웠던 건 안정화였다
초기 전환은 빨랐지만, 실서비스에서 아래 문제가 연쇄적으로 나타났습니다.
- 같은 사용자에게 SSE 연결이 여러 개 생김
- 연결이 너무 자주 끊겨 재연결 반복
- 끊긴 emitter가 서버에 남아 Broken pipe 반복
- 재연결 복구용
Last-Event-ID가 사실상 동작하지 않음 - 알림 이벤트
@Async가 기본 fallback executor로 실행됨
이번 글은 “SSE로 바꿨다”보다, 이 안정화 과정을 중심으로 정리합니다.
1) 클라이언트 SSE를 단일 연결로 강제
문제의 출발점은 useSSE()를 일반 훅으로 사용한 구조였습니다.
NotificationDropdown에서도 호출/notifications페이지에서도 호출- 결과: 같은 계정에서 독립 SSE 연결이 동시 생성
해결은 Context Provider로 단일화였습니다.
SSEProvider를 앱 전역(providers.tsx)에 1회만 주입- 모든 컴포넌트는
useSSE()로 같은 연결 상태를 공유
<AuthProvider> <SSEProvider> <AppShell>{children}</AppShell> </SSEProvider> </AuthProvider>
이 변경이 체감상 가장 큰 안정성 개선이었습니다.
2) 탭 숨김 시 강제 disconnect를 제거
초기 구현은 document.hidden === true가 되면 즉시 disconnect() 했습니다.
겉으로는 리소스 절약처럼 보였지만 실제로는:
- 탭 전환이 잦은 사용자에서 연결 churn 급증
- 재연결 타이밍 경쟁으로 다중 연결/끊김 악화
현재는 정책을 바꿨습니다.
- 숨김 전환 시에는 끊지 않음
- visible로 돌아왔을 때 상태가
disconnected/fallback이면 재연결
즉, "항상 끊기"가 아니라 "필요할 때만 복구"로 전환했습니다.
3) Last-Event-ID 복구를 실제로 동작하게 수정
재연결 복구가 되려면 마지막 이벤트 ID가 클라이언트에 남아 있어야 합니다.
문제는 connected 이벤트의 id가 고정 0이면, 알림을 받기 전에 끊긴 세션에서 기준점이 남지 않는다는 점이었습니다.
그래서 두 군데를 같이 바꿨습니다.
서버
connected 이벤트 id를 0이 아니라 “최근 알림 목록의 최대 ID”로 전송
String connectedEventId = recentDtos.stream() .map(NotificationResponse::id) .max(Long::compareTo) .map(String::valueOf) .orElse("0");
클라이언트
- 이벤트 id가
0이면 무시 connectedpayload의recentNotifications최대 id를lastEventId로 저장
이제 재연결 시 Last-Event-ID 헤더가 실질적으로 채워집니다.
4) Zombie Emitter 정리와 안전 순회
연결이 자주 흔들리면 서버 메모리의 emitter set에 죽은 emitter가 섞입니다.
그 상태에서 전송하면 Broken pipe가 반복됩니다.
두 가지를 적용했습니다.
connect 시 사전 정리
새 연결 직전에 해당 user emitter를 health-check하고 실패한 객체를 제거:
private void cleanupDeadEmitters(Long userId) { // health-check comment 전송 실패 emitter 제거 }
순회 중 remove 안정화
send, sendUnreadCount, sendHeartbeat 모두
원본 Set이 아니라 snapshot(new ArrayList<>(userEmitters))으로 순회하도록 변경했습니다.
이렇게 해서 순회 중 제거 부작용 가능성을 줄였습니다.
5) 알림 이벤트 @Async executor를 명시
로그상 @Async가 명시 executor 없이 fallback되어 실행되고 있었습니다.
해결:
AsyncConfig에notificationExecutor추가- 리스너에
@Async("notificationExecutor")지정
@Bean("notificationExecutor") public Executor notificationExecutor() { ... } @Async("notificationExecutor") @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handleNotificationEvent(NotificationEvent event) { ... }
중요 포인트는 이벤트 리스너의 트랜잭션 phase는 기존처럼 AFTER_COMMIT을 유지했다는 점입니다.
문제는 실행 시점보다 executor 선택이었습니다.
6) Nginx 뒤에서 SSE가 멈추는 문제
로컬에서는 잘 되던 SSE가 프로덕션에서는 EventStream에 아무 데이터도 안 오는 현상이 발생했습니다.
브라우저 Network 탭에서 /api/notifications/sse가 200으로 연결은 되지만, 이벤트가 전혀 수신되지 않았습니다.
원인: Nginx 전역 gzip 압축
nginx.conf에 전역으로 gzip이 활성화되어 있었습니다.
# nginx.conf (http 블록) gzip on; gzip_proxied any;
SSE location에 proxy_buffering off는 설정해뒀지만, gzip 압축은 별개 레이어입니다. Nginx가 SSE 스트림을 gzip으로 감싸면 브라우저는 압축 청크가 충분히 쌓일 때까지 데이터를 받지 못합니다. heartbeat 코멘트(:heartbeat\n\n)는 수십 바이트라서 gzip 버퍼를 채우지 못하고, 결과적으로 클라이언트에서는 “연결됐는데 아무것도 안 온다”가 됩니다.
수정
SSE location에 3줄을 추가했습니다.
location /api/notifications/sse { proxy_pass http://backend:8080; proxy_http_version 1.1; proxy_set_header Connection ""; # SSE 스트리밍: 버퍼링/압축 완전 비활성화 proxy_buffering off; proxy_cache off; gzip off; # 전역 gzip 무효화 add_header X-Accel-Buffering no always; # Nginx 내부 버퍼링 레이어 비활성화 chunked_transfer_encoding on; # 청크 전송 명시 proxy_read_timeout 1800s; proxy_send_timeout 1800s; }
| 설정 | 역할 |
|---|---|
gzip off | 전역 gzip on + gzip_proxied any를 이 location에서만 비활성화 |
X-Accel-Buffering no | proxy_buffering off로 커버되지 않는 내부 버퍼링까지 차단 |
chunked_transfer_encoding on | HTTP/1.1 청크 전송 명시 (기본값이지만 안전장치) |
nginx -t로 문법 검증 후 nginx -s reload로 무중단 반영했고, 즉시 connected 이벤트와 30초 heartbeat가 수신되기 시작했습니다.
7) OS/Docker FD(파일 디스크립터) 설정
SSE는 연결 하나당 소켓 하나, 소켓 하나당 FD 하나를 점유합니다. 코드가 아무리 안정적이어도 OS나 컨테이너의 FD 상한이 낮으면 동시접속이 늘었을 때 Too many open files로 서비스가 멈출 수 있습니다.
점검해보니
프로덕션 서버(GCE VM, Docker Compose 구성)를 점검했더니 이런 상태였습니다.
| 레이어 | Soft Limit | Hard Limit | 문제 |
|---|---|---|---|
| ff-nginx 컨테이너 | 1,024 | 524,288 | Nginx가 가장 먼저 FD 고갈 |
| ff-backend 컨테이너 | 524,288 | 524,288 | Docker 기본값으로 괜찮음 |
| Docker daemon (systemd) | 1,024 | 524,288 | Soft가 낮음 |
| Host limits.conf | 미설정 | 미설정 | 기본값 의존 |
Nginx 설정 파일에 worker_rlimit_nofile 65535를 선언해뒀지만, 컨테이너 프로세스의 soft limit이 1,024라서 실제로는 적용되지 않고 있었습니다. worker_rlimit_nofile은 프로세스 limit 이하에서만 동작합니다.
SSE 동시접속 1,000명만 넘어도 Nginx worker가 FD 부족으로 새 연결을 받지 못하는 상황이었습니다.
수정
세 군데를 같이 건드려야 합니다.
1. docker-compose.yml — 모든 컨테이너에 ulimits 추가
services: nginx: image: nginx:1.25-alpine ulimits: nofile: soft: 65535 hard: 65535 # ... backend: ulimits: nofile: soft: 65535 hard: 65535 # ... frontend: ulimits: nofile: soft: 65535 hard: 65535 mysql: ulimits: nofile: soft: 65535 hard: 65535
2. Docker daemon systemd override
# /etc/systemd/system/docker.service.d/override-fd.conf [Service] LimitNOFILE=65535
sudo systemctl daemon-reload sudo systemctl restart docker
3. Host /etc/security/limits.conf
* soft nofile 65535 * hard nofile 65535 root soft nofile 65535 root hard nofile 65535
왜 무제한(unlimited)이 아니라 65,535인가
"서버 FD는 무제한으로"라는 관행이 있지만, 이 서버(e2-highmem-2, 2vCPU/16GB)에서는 65,535가 적절합니다.
- SSE 동시접속 수만 개는 충분히 커버
- 무제한 설정은 FD leak 버그 발생 시 시스템 전체가 멈추는 위험이 있음
- 실제 사용량(현재 57개)과 상한 사이에 충분한 여유
적용 후 확인
# 컨테이너별 FD limit 확인 docker exec ff-nginx sh -c 'cat /proc/1/limits | grep "Max open files"' # Max open files 65535 65535 files # Docker daemon systemctl show docker | grep -i limitnofile # LimitNOFILE=65535 # LimitNOFILESoft=65535
4개 컨테이너 모두 65535/65535로 통일되었고, Docker daemon도 동일하게 적용됐습니다.
8) OSIV가 SSE와 만나면 DB 커넥션 풀이 죽는다
배포 후 며칠 뒤, 사이트 전체가 500 에러를 반환하기 시작했습니다.
HikariPool-1 - Connection is not available, request timed out after 5001ms.
DB 커넥션 풀(max 12개)이 전부 고갈된 상태였습니다. SSE 연결이 2~3개뿐인데 왜?
원인: Spring OSIV (Open Session In View)
spring.jpa.open-in-view는 Spring Boot에서 기본값이 true입니다. 기동 시 이런 경고가 찍히지만, 대부분 무시하고 넘어갑니다.
WARN JpaBaseConfiguration: spring.jpa.open-in-view is enabled by default.
OSIV가 켜져 있으면 Hibernate Session(= DB Connection)이 HTTP 요청의 전체 수명 동안 유지됩니다. 일반 API에서는 밀리초 단위라 문제가 없지만, SSE에서는 이야기가 다릅니다.
SseEmitter를 반환하는 컨트롤러 메서드는 HTTP 요청이 최대 30분 동안 열려 있습니다. OSIV는 이 요청에 바인딩된 DB 커넥션을 30분 동안 반환하지 않습니다.
SSE 연결 1개 = DB 커넥션 1개 × 30분 점유
HikariCP 풀 크기가 12개이므로, 동시 SSE 연결이 12개만 쌓이면 일반 API 요청이 전부 Connection is not available로 실패합니다. 실제로 사용자 2명이 탭을 몇 개 열고 재연결이 반복되면서 12개가 채워졌습니다.
왜 바로 발견하지 못했나
- 로컬 개발 환경에서는 SSE 연결이 1~2개여서 풀이 고갈되지 않음
- Connection leak detection 로그(
WARN ProxyLeakTask)가 찍히고 있었지만, 서비스가 멈추기 전까지는 눈에 띄지 않음 - SSE 스레드 모델 문서에서 "요청 스레드는 풀로 반환된다"고 나오니, DB 커넥션도 당연히 반환될 거라고 생각함
하지만 스레드 반환과 DB 커넥션 반환은 별개입니다. OSIV는 요청 스레드가 아니라 요청 수명에 커넥션을 묶습니다.
수정
application.yml에 한 줄 추가:
spring: jpa: open-in-view: false
이 프로젝트에서 OSIV가 필요한 곳은 없습니다. 모든 데이터 조회는 Service 레이어의 @Transactional 안에서 완료되고, 컨트롤러에서 Lazy Loading을 하는 패턴도 없습니다.
SSE에서 DB 조회가 필요하다면
SseEmitterService.connect()는 연결 직후 notificationRepository로 미읽음 카운트와 최근 알림을 조회합니다. OSIV를 끄면 이 조회가 별도 트랜잭션으로 실행되고, 조회가 끝나면 DB 커넥션이 즉시 반환됩니다.
// connect()에서 DB 조회 → 커넥션 반환 → SseEmitter 반환 (커넥션 없이 유지) int unreadCount = notificationRepository.countByRecipientIdAndIsReadFalse(userId); // 이 시점에서 DB 커넥션 반환됨 (OSIV off이므로) return emitter; // 30분 동안 열려 있지만 DB 커넥션은 이미 없음
결과적으로 OSIV를 끄는 것만으로, SSE 연결 수와 DB 커넥션 풀이 완전히 분리되었습니다.
현재 최종 구조
[Frontend] SSEProvider (single connection) ├─ fetch /api/notifications/sse (Authorization header) ├─ connected / notification / unreadCount 이벤트 처리 ├─ reconnect (backoff) └─ fallback polling (120초) [Backend] SseEmitterService ├─ user별 emitter set ├─ connect 시 dead emitter cleanup ├─ heartbeat 30초 └─ send/sendUnreadCount snapshot 순회 NotificationEventListener └─ @Async("notificationExecutor") + AFTER_COMMIT JPA └─ spring.jpa.open-in-view=false (SSE 연결이 DB 커넥션을 점유하지 않음) [Nginx] └─ /api/notifications/sse ├─ proxy_buffering off, proxy_cache off ├─ gzip off, X-Accel-Buffering no └─ 30분 timeout [OS/Docker] ├─ Container ulimits: nofile 65535 (nginx/backend/frontend/mysql) ├─ Docker daemon: LimitNOFILE=65535 (systemd override) └─ Host limits.conf: nofile 65535
여러 대 서버에서 SSE를 보내려면 무엇이 더 필요할까
서버가 1대일 때는 userId -> emitter Set 메모리 맵만으로 충분합니다.
하지만 서버가 2대 이상이면 문제가 생깁니다.
- 사용자 A의 SSE 연결은 서버 1에 붙어 있음
- 알림 이벤트는 서버 2에서 발생함
- 서버 2는 서버 1 메모리의 emitter를 모름
- 결과: DB에는 알림이 저장됐는데 실시간 push는 누락될 수 있음
그래서 멀티 서버에서는 브로커 기반 fan-out 아키텍처가 필요합니다.
[App-1] --\ [App-2] ----> [Message Broker: Redis Pub/Sub, Kafka, NATS] ---> [All App nodes subscribe] [App-3] --/ └─ 각 노드가 "내 로컬 연결"에만 전송
권장 구성은 보통 이렇게 갑니다.
- 알림 생성 트랜잭션 커밋 후 이벤트 발행 (
notification.created) - 브로커가 모든 앱 노드로 이벤트 전달
- 각 노드는 자기 노드에 연결된 사용자에게만 SSE 전송
- 미수신/재연결 대비는
Last-Event-ID + DB replay로 보완
여기서 핵심은 두 가지입니다.
- 실시간 전달 채널: Redis Pub/Sub, Kafka 같은 브로커
- 복구 채널: DB(또는 outbox/event store) 기반 재전송
참고로 sticky session만으로는 부족합니다.
스케일 아웃/롤링 배포/장애 전환 시 연결이 다른 노드로 이동하기 때문에, 결국 브로커 + replay 전략이 있어야 안정적입니다.
검증
수정 후 아래를 직접 실행해 확인했습니다.
cd frontend && npm run buildcd backend && ./gradlew test./scripts/ci-local.sh
모두 통과했습니다. (기존 lint warning들은 남아 있지만 실패 원인은 아님)
마무리
이번 작업에서 가장 큰 교훈은 이거였습니다.
실시간 알림의 핵심은 “연결 생성”이 아니라 “연결 생명주기 관리”다.
SSE 자체는 단순합니다.
하지만 실서비스에서는 아래가 품질을 좌우합니다.
- 연결을 하나로 강제할 것
- 재연결 기준점을 잃지 않을 것
- 죽은 연결을 빨리 정리할 것
- 비동기 실행기를 명시해 예측 가능성을 높일 것
- 프록시 계층(Nginx)의 gzip/버퍼링을 SSE에선 반드시 꺼둘 것
- OS/컨테이너의 FD 상한을 코드와 별개로 점검할 것
- OSIV를 반드시 꺼서 SSE가 DB 커넥션을 장기 점유하지 않도록 할 것
특히 OSIV 문제는 배포 후 실제 장애가 발생하고 나서야 발견됐습니다. Spring Boot의 기본값(open-in-view=true)이 일반 요청에서는 무해하지만, 장시간 열려 있는 SSE 요청에서는 DB 커넥션 풀 고갈이라는 치명적 결과로 이어집니다. SSE를 도입하는 Spring Boot 프로젝트라면 OSIV 비활성화를 첫 번째 체크리스트로 넣어야 합니다.
120초 폴링에서 SSE로 바꾼 뒤, 이제는 알림이 실제로 “실시간”처럼 동작하는 상태에 훨씬 가까워졌습니다.






댓글
댓글을 작성하려면 이 필요합니다.