← 뒤로
프로젝트 요약
- 한 줄 요약: REST 폴링을 제거하기 위해 WebSocket과 SSE를 POC·부하 테스트로 비교했고, SSE가 250개 연결에서 502 에러를 79.6% 쏟아낸 원인을 SSE 1연결이 Nginx 커넥션 2개를 소모하는 구조까지 추적한 뒤, 프로토콜 특성상 남는 좀비 커넥션·재연결 손실까지 고려해 WebSocket(STOMP)을 채택했습니다.
- 진행/소속: 개인 프로젝트
- 키워드:
WebSocket, STOMP, SSE, 실시간통신, 성능테스트, Redis Pub/Sub, 수평확장
- 기간: 3일
- 역할: 백엔드
문제(AS-IS)

- 메시지 도메인은 REST API만 제공 (POST/GET /messages)
- 클라이언트가 새 메시지를 받으려면 폴링 또는 수동 새로고침 필요
- 메시지 서비스 핵심 가치인 즉시 전달 미달성
목표(TO-BE)
- 실시간 메시지 수신: 발송 즉시 수신자에게 푸시 (폴링 제거)
- 기술 선택 근거 마련: WebSocket vs SSE 비교 후, 서비스 요구사항에 맞는 방식 선정
설계/선택(Key decisions)

WebSocket (STOMP) 방식 최종 선택
- 비교 검증: WebSocket/SSE POC 구현 및 성능 테스트
- 수평 확장 지원:
Redis Pub/Sub+ Sticky Session으로 수평 확장 지원
- 연결 안정성: Heartbeat 설정 (10초 간격), Rolling Update & Graceful Shutdown
- 프로토콜 문서화:
AsyncAPI 3.0 기반으로 테스트해볼 수 있는 Web 문서 생성
- 깔끔한 코드 구조 채택:
AOP 어노테이션(@WebsocketSend)으로 메시지 전송부 보일러플레이트 제거
Port-Adapter 패턴으로 인프라에 의존하지 않는 도메인 로직 작성
결과(Impact)
- 선택 근거를 인프라 레이어까지 규명 (부하 테스트: 동시 연결 50~300개, 네트워크 지연 50ms 시뮬레이션)
- SSE는 250개 연결부터 502 에러 79.6% 발생. 원인은 SSE 1연결이 Nginx 커넥션을 2개(클라이언트↔Nginx, Nginx↔백엔드) 소모해
worker_connections 기본값 1024를 초과한 것 → worker_connections 10000 상향으로 502 자체는 해소
- 다만 좀비 커넥션(HTTP 스트리밍 특성상 서버가 클라이언트 종료를 즉시 감지 못 함)과 재연결 시 메시지 손실 1~4% 는 설정으로 못 없애는 프로토콜 특성이라 판단 → WebSocket 채택
- WebSocket 측정치 (같은 조건): 에러율 0%, 메시지 손실 0건, P99 101~263ms — 연결 수가 늘어도 레이턴시 변화가 크지 않음
- 정성 성과:
- 서버 리소스 감소: 폴링 제거로 불필요한 HTTP 요청 감소
- 사용자 경험 개선: 메시지 송신부터 실제 수신까지 딜레이 감소
- 문서화: 웹 기반 테스트가 가능한 문서 제작으로 프론트와 소통 효율화
아래 링크에서 실제 구현 확인 가능
message.rahoon.site/websocket-docs/index.html
목차
구현 상세
1) 기술 비교 및 선택
배경: 실시간 통신을 위해 WebSocket/SSE 각각의 기술을 이론 비교 및 POC 구현 후 비교
이론 비교
| 특징 |
SSE |
웹소켓 |
| 데이터 전송 방향 |
단방향 |
양방향 |
| 프론트 개발 난이도 |
(웹)간단, (모바일) 보통 |
보통 |
| 연결 감지 |
서버에서 클라이언트 종료 감지 지연 |
즉각적인 연결 상태 변화 감지 |
| 데이터 유형 |
텍스트만 지원 |
텍스트 및 바이너리 지원 |
| 프로토콜 |
HTTP 기반 |
TCP 기반 |
| 사용 사례 |
주식 시세, SNS 알림 등.. |
채팅, 멀티플레이게임, 협업도구 등.. |
참고:
- KakaoTalk: SSE -> LOCO(자체 TCP 프로토콜)
- Whats App: XMPP -> Noise(자체 TCP 프로토콜)
- Instagram, Messenger -> MQTT(앱), Websocket(웹)
- Discord -> Websocket
-> 양방향성이 중요한 메시지 서비스에서는 Websocket이 유리해보임
POC 구현 방법
- WebSocket (STOMP):
- 서버: Spring WebSocket + STOMP 메시징 프로토콜
- 클라이언트: Nodejs, Sockjs + WebStomp-client
- SSE
- 서버:
SseEmitter 기반 Server-Sent Events
- 클라이언트: Nodejs, EventSource 라이브러리
테스트 1: 연속 3개 메시지 수신 테스트
방법) 10명 구독자, 메시지 3개 연속 전송(50ms 간격), 200회 반복
결과)
- WebSocket: 성공률 100% (P99 381ms)
- SSE: 성공률 100% (P99 380ms)
결론) 큰 차이 없음
테스트 2: 동시 연결 부하 테스트
방법) 동시 연결을 50개부터 300개까지 단계별로 증가하며 메시지 수신 확인
결과)
- WebSocket: 메시지 손실 0%, P50 91~257ms
- SSE: 메시지 손실 1~4%, P50 92ms→581ms
- 재연결 타이밍에 메시지 손실 발생한 것으로 확인됨
- Websocket 수준의 안정성을 위해서는 LastEventId 구현 필요
- EventSource에서 각 세션이 시작될 때마다 서버에서 Zombie Connection 생성됨
- HTTP 기반 스트리밍 특정상 서버에서 클라이언트 종료를 즉각 감지하기 어렵기 때문
- 메시지서비스처럼 연결 밀도가 높은 경우 리스크가 될 수 있다고 생각됨
결론) Websocket을 선택할 이유가 늘어남
최종 결론: WebSocket 채택
근거)
- 검증된 산업 표준: Discord, Instagram, Messenger 등 대규모 서비스에서 WebSocket 기반이거나 비슷한 TCP 방식 통신 채택, 검증된 기술 스택
- 부하 테스트 결과: 50~300개 연결 구간에서 WebSocket은 메시지 손실 0%, SSE는 손실 1~4% + 250개부터 502 에러. SSE로 동일한 신뢰성을 확보하려면 LastEventId 기반 재전송 등 추가 구현이 필요
- 양방향 통신 기반 확장성: 읽음 처리, 타이핑 인디케이터 등 클라이언트→서버 실시간 피드백 구현 용이
- 전송 신뢰성: Heartbeat 및 ACK/Receipt 프레임으로 클라이언트 수신 여부와 세션 생존 상태를 프로토콜 수준에서 확인 가능
- 리소스 관리: 장기 연결 지향 프로토콜로 SSE 대비 안정적이며, Close Frame을 통한 좀비 커넥션 즉시 정리 가능
- 운영 안정성: SSE의 재연결 타이밍 메시지 손실 및 Zombie Connection 문제를 프로토콜 수준에서 해결
2) 깔끔한 코드 구조: @WebsocketSend AOP
목적:
- 웹소켓 전송부를 쉽게 파악하여 AsyncAPI 문서로 작성하기 위함
- 반복되는 보일러 플레이트 코드 제거하기 위함
변화
// MessageWebsocketController class -- Before
@Async
@EventListener
fun sendCreatedMessage(message: MessageEvent.Created){
simpleMessagingTemplate.convertAndSend(
"/topic/chat-rooms/${message.chatRoomId}/messages",
MessageWsSend.Detail.from(message)
)
}
// MessageWebsocketController class -- After
@Async
@EventListener
@WebsocketSend("/topic/chat-rooms/{chatRoomId}/messages")
fun sendCreatedMessage(event: MessageEvent.Created): MessageWsSend.Detail {
return MessageWsSend.Detail.from(event)
}
// WebsocketSend의 chatRoomId는 응답값 MessageWsSend.Detail을 SPEL로 조회하여 조합됨
3) 수평 확장 지원: Redis Pub/Sub
배경:
- 서버-A와 서버-B로 수평 확장된 상황에서
- 유저가 서버-A에 WebSocket을 Connect 한 상황일때
- 서버-B에서 메시지 생성 요청을 처리한 경우 MessageEvent.Created발급됨
- 이렇게 발급된 MessageEvent.Created는 서버-A까지 전달되지 않음
해결:
- 서버-A와 서버-B로 수평 확장된 상황에서
- 유저가 서버-A에 WebSocket을 Connect 한 상황일때
- 서버-B에서 메시지 생성 요청을 처리한 경우 MessageEvent.Created발급됨
- 서버-B에서는 MessageEvent.Created를 수신하여 MessageCommandEvent.Send로 변환한 뒤 Redis를 통해 적절한 서버로 전송
- 각 서버는 MessageCommandEvent.Send를 구독했다가 내부 이벤트로 발행
- MessageCommandEvent.Send를 수신하여 Websocket 전송
선택 근거:
- Kafka 미도입: 실시간 브로드캐스팅에는 Redis Pub/Sub이 레이턴시·구조 면에서 더 적합. Kafka는 이벤트 스트리밍/로그 저장에 특화되어 이 용도에는 오버헤드
- Port-Adapter로 추상화: Redis Pub/Sub을 Port 뒤에 두어, 추후 이벤트 스트리밍이 필요해지면 Kafka로 교체 가능하도록 여지를 남김 (다만 Kafka를 효율적으로 쓰려면 이용자별 서버 라우팅이 선행되어야 함)
- DTO 기반 이벤트 분리:
MessageEvent.Created가 인프라 레이어에 의존하지 않도록 분리
- Sticky Session: Nginx IP 기반 세션 고정 (향후 Cookie 기반 개선 고려)
- 테스트 환경:
docker-compose.test.yml로 앱 2개 + Nginx Sticky Session 구성
4) 프로토콜 문서화: AsyncAPI 3.0
배경: WebSocket 프로토콜 문서화 부재로 클라이언트 개발자와의 협업 어려움이 예상됨
접근:
AsyncAPI 3.0 스펙 기반 자동 문서 생성 (AsyncApiFullGenerator)
- Generic/List 타입 스키마 처리 및 canonical name 기반 중복 방지
- 웹 UI에서 스키마 기반 예제 자동 생성
포인트:
- WebSocket 엔드포인트와 메시지 스키마를 자동으로 문서화하여 API 변경 시 실시간 반영
@WebsocketSend 어노테이션 파싱하여 RECEIVE 액션으로 문서화
- 기존 AsyncAPI 문서와 다르게 WebSocket을 직접 테스트해볼 수 있도록 프론트엔드 구성
5) 연결 안정성: Heartbeat + Graceful Shutdown
배경: 네트워크가 끊겨도 클라이언트가 즉시 감지하지 못하고, 죽은 연결이 서버에 남아 리소스를 낭비함
접근:
- Heartbeat:
WebSocketConfig.kt에서 setHeartbeatValue() + ThreadPoolTaskScheduler 설정
- 10초마다 heartbeat 교환, 최대 30초 내 연결 끊김 감지
- Graceful Shutdown:
application.properties: server.shutdown=graceful + Actuator Probes 활성화
- K8s:
terminationGracePeriodSeconds: 30, Liveness/Readiness Probe 설정
- 배포 전략:
RollingUpdate, maxSurge=1, maxUnavailable=0
포인트:
- 진행 중 요청 완료 대기(20초) → 새 Pod 준비 완료 후 기존 Pod 종료 → 무중단 배포 보장
- 좀비 커넥션으로 인한 리소스 낭비 방지
트러블슈팅
1) SSE 502 에러 (250개 이상 연결)
증상: SSE 연결 250개 이상에서 HTTP 502 에러 폭증 (에러율 79.6%)
원인:
- Nginx
worker_connections 제한 (기본값 1024개)
- SSE 연결 1개 = Nginx 연결 2개 (클라이언트↔Nginx, Nginx↔백엔드)
- 250개 SSE 연결 = 500개 Nginx 연결 + 좀비 커넥션 + 기타 HTTP 요청 → 1024개 초과
- Nginx가 새 연결을 받지 못해 “no live upstreams” → 502 에러
해결:
worker_connections 10000으로 상향 → 502 에러 제거
- 단, WebSocket은 좀비 커넥션이 없어 동일 연결 수에서도 애초에 문제가 없었음 (NIO 기반으로 커넥션당 리소스 효율이 높음). 즉 502는 설정으로 막을 수 있었지만, SSE가 커넥션을 2배로 먹는 구조 자체는 남는다는 점이 WebSocket 채택의 근거가 됨