1. HTTP의 한계: 왜 WebSocket이 필요한가
HTTP는 요청-응답 모델이다
HTTP는 클라이언트가 요청하면, 서버가 응답하는 단방향 구조다. 서버가 먼저 클라이언트에게 메시지를 보낼 수 없다.
[HTTP 통신]
브라우저: "내 순번 알려줘" ──→ 서버: "3,421번째입니다"
브라우저: "내 순번 알려줘" ──→ 서버: "3,200번째입니다"
브라우저: "내 순번 알려줘" ──→ 서버: "2,980번째입니다"
...반복...
서버: "지금 입장 가능합니다!" ──→ ???
서버가 먼저 말할 수 없다!
CGV 대기열에서 10만 명이 "내 순번 알려줘"를 2초마다 보내면:
10만 명 × 2초마다 1번 = 초당 50,000 요청
→ HTTP 헤더만 해도 요청당 ~500바이트
→ 초당 25MB의 순수 오버헤드
→ 서버 CPU의 대부분을 "변화 없음" 응답에 소비
HTTP로 실시간을 흉내내는 3가지 방법
[1. Short Polling — 주기적으로 물어보기]
브라우저 서버
│── GET /api/rank ──────────→│
│←── "3,421번째" ───────────│
│ (2초 대기) │
│── GET /api/rank ──────────→│
│←── "3,421번째" ───────────│ ← 변화 없어도 응답
│ (2초 대기) │
│── GET /api/rank ──────────→│
│←── "3,200번째" ───────────│ ← 드디어 변화
문제: 변화 없어도 계속 요청. 10만 명이면 서버 과부하.
[2. Long Polling — 변화 있을 때까지 기다리기]
브라우저 서버
│── GET /api/rank ──────────→│
│ (서버가 변화 생길 때까지 응답 보류... 10초... 20초...)
│←── "3,200번째" ───────────│ ← 변화 생기면 응답
│── GET /api/rank ──────────→│ ← 즉시 다시 요청
│ (또 대기...) │
문제: 연결을 계속 점유. 10만 연결 = 10만 스레드(전통 모델).
타임아웃 관리 복잡.
[3. SSE (Server-Sent Events) — 서버→클라이언트 단방향 스트림]
브라우저 서버
│── GET /api/events ────────→│
│←── "data: rank=3421\n\n" ─│ ← 서버가 계속 push
│←── "data: rank=3200\n\n" ─│
│←── "data: rank=2980\n\n" ─│
│ │
│── (클라이언트→서버 메시지?) ──→│ ← 불가능! 단방향임
문제: 서버→클라이언트만 가능. 양방향 필요하면 별도 HTTP 연결 필요.
HTTP/1.1에서 브라우저당 6개 연결 제한.
이 세 가지 모두 HTTP의 한계 안에서 실시간을 흉내내는 것이다. WebSocket은 이 한계 자체를 깬다.
전체 비교
| 특성 | Short Polling | Long Polling | SSE | WebSocket |
|---|---|---|---|---|
| 방향 | 단방향 (C→S) | 단방향 (C→S) | 단방향 (S→C) | 양방향 |
| 연결 방식 | 매번 새 연결 | 대기 후 응답 | 지속 연결 | 지속 연결 |
| 프로토콜 | HTTP | HTTP | HTTP | WS (TCP) |
| 서버 Push | 불가 | 간접적 | 가능 | 가능 |
| 오버헤드 | 매우 높음 | 높음 | 낮음 | 매우 낮음 |
| 실시간성 | 폴링 간격 의존 | 양호 | 좋음 | 매우 좋음 |
| 10만 동접 | 초당 5만 요청 | 10만 커넥션 점유 | 10만 커넥션 | 10만 커넥션 |
| 브라우저 제한 | 없음 | 없음 | 6개/도메인 | 없음 |
| CGV 사용 | Fallback | - | - | Primary |
왜 CGV는 WebSocket을 선택했나
- 양방향 필요: 서버→클라이언트(순번 업데이트) + 클라이언트→서버(대기열 등록) 모두 필요
- 10만 동접: HTTP 폴링으로는 초당 5만 요청 → 서버 과부하
- 실시간성: 대기열 순번은 2초마다 바뀜 → 지연 없는 push 필요
- Spring Boot 기본 지원:
spring-websocket+spring-messaging의존성만 추가하면 됨
2. 네트워크 기초: TCP, HTTP, 그리고 WebSocket
이 문서에서 "TCP 위에서 동작", "HTTP Upgrade" 같은 표현이 반복된다.
WebSocket의 연결 과정(섹션 4)을 정확히 이해하려면 이 관계를 먼저 알아야 한다.
네트워크는 계층 구조다
인터넷 통신은 4개 계층으로 나뉜다. 각 계층은 아래 계층 위에서 동작한다.
[인터넷 4계층 모델]
4. 애플리케이션 계층(응용,표현,세션) — HTTP, WebSocket, STOMP ← 우리가 다루는 영역
3. 전송 계층 — TCP, UDP ← "신뢰할 수 있는 파이프"
2. 인터넷 계층 — IP ← 목적지 주소 (IP 주소)
1. 네트워크 접근 계층(데이터링크, 물리) — 이더넷, Wi-Fi ← 물리적 전송
비유: 택배 시스템
1계층 = 도로/트럭 (물리적 이동 수단)
2계층 = 주소 체계 (어디로 보낼지)
3계층 = 택배 회사 (분실 방지, 순서 보장, 재전송)
4계층 = 편지 내용과 형식 (비즈니스 레터? 초대장?)
WebSocket을 이해하는 데 중요한 건 3계층(TCP)과 4계층(HTTP/WebSocket)이다.
TCP: 신뢰할 수 있는 파이프
TCP(Transmission Control Protocol)는 두 컴퓨터 사이에 신뢰할 수 있는 파이프를 만드는 프로토콜이다.
[TCP가 보장하는 것]
1. 순서 보장: 보낸 순서대로 도착
서버 → "대기 3421번" → "대기 3200번" → "입장 허가"
받는 쪽도 이 순서 그대로 수신
2. 유실 방지: 데이터가 도중에 사라지면 자동 재전송
서버 → "입장 허가" → (네트워크에서 사라짐) → 일정 시간 후 자동 재전송
3. 중복 제거: 같은 데이터가 두 번 도착하면 하나 버림
4. 흐름 제어: 받는 쪽이 느리면 보내는 속도를 줄임
[TCP vs UDP]
TCP: "전화 통화" — 연결 수립 → 대화 → 연결 종료
✅ 순서 보장, 유실 방지, 재전송
❌ 연결 수립 비용 (3-way handshake)
→ HTTP, WebSocket, STOMP가 사용
UDP: "편지" — 그냥 보냄. 도착 보장 없음
✅ 빠름, 오버헤드 적음
❌ 순서 보장 ❌, 유실되면 끝
→ 실시간 게임, 영상 통화 (약간의 유실 허용)
CGV 대기열은?
→ "입장 허가" 메시지가 유실되면 사용자가 영원히 대기
→ 순서가 바뀌면 대기열 순번이 엉망
→ 반드시 TCP
TCP 연결 수립: 3-Way Handshake
TCP 연결은 3단계로 수립된다. HTTP도, WebSocket도 이 과정을 먼저 거친다.
[TCP 3-Way Handshake]
클라이언트 서버
│ │
│── SYN (연결하고 싶어요) ────────→│ ①
│ │
│←── SYN-ACK (좋아요, 나도) ──────│ ②
│ │
│── ACK (확인, 시작하자) ─────────→│ ③
│ │
│ TCP 파이프 완성! │
│ 이제 이 파이프로 데이터 전송 │
이 TCP 파이프가 만들어진 후, 그 위에서 어떤 "언어"로 대화할지가 프로토콜이다.
HTTP와 WebSocket: 같은 TCP 파이프, 다른 대화 규칙
[같은 TCP 파이프 위에서]
HTTP:
TCP 파이프 → "GET /api/rank" → "200 OK {rank: 3421}" → (응답 끝)
→ 요청-응답 1쌍. 서버가 먼저 말할 수 없음.
WebSocket:
TCP 파이프 → 자유롭게 양방향 메시지
→ 서버도 클라이언트도 아무 때나 메시지 가능.
핵심: HTTP도 WebSocket도 TCP 위에서 동작한다. 차이는 TCP 파이프 위에서 대화하는 규칙(프로토콜)이 다른 것이다.
"HTTP Upgrade"란 — 프로토콜 전환
WebSocket 연결은 HTTP로 시작해서 WebSocket으로 전환된다. 이걸 "Upgrade"라고 한다.
[HTTP Upgrade = 프로토콜 전환]
1. TCP 파이프 수립 (3-Way Handshake)
2. HTTP로 대화 시작: "Upgrade: websocket" (WebSocket으로 바꾸자)
3. 서버: "101 Switching Protocols" (좋아, 바꾸자)
4. 같은 TCP 파이프에서 WebSocket으로 대화 시작
비유:
한국어로 전화 연결 → "영어로 대화할까요?" → "OK" → 영어로 대화
전화선(TCP)은 그대로. 대화 언어(프로토콜)만 바뀜.
왜 처음부터 WebSocket으로 연결하지 않는가?
방화벽은 보통 80/443 포트만 허용한다 (HTTP/HTTPS 포트). WebSocket이 별도 포트를 쓰면 기업 네트워크에서 차단된다. HTTP와 같은 포트에서 HTTP로 시작하면 방화벽과 프록시를 자연스럽게 통과한다. 이게 바로 다음 섹션(Handshake)에서 보게 될 101 Switching Protocols의 본질이다.
3. WebSocket이란
소켓(Socket)의 개념
소켓(Socket)이란 네트워크상에서 동작하는 프로그램 간 통신의 종착점(Endpoint)이다. 1대1 통신의 경우 양쪽 모두 소켓이 존재해야 통신이 가능하다. 현재 대부분의 네트워크 통신은 인터넷 프로토콜(TCP, UDP)에 기반하고 있으므로, 대부분의 네트워크 소켓은 곧 인터넷 소켓이다.
OSI 7계층 기준으로 보면, 인터넷 소켓은 TCP/UDP에 기반하므로 4계층(전송 계층)에 위치한다. 이때 TCP 소켓 통신은 단순한 바이트 스트림을 통해 데이터를 전송한다. 즉, TCP 소켓은 데이터의 구조나 의미를 알지 못하고, 연속된 바이트 흐름만을 전달하는 것이다.
HTTP의 한계: 왜 WebSocket이 필요한가
TCP 소켓 위에서 동작하는 대표적인 프로토콜이 HTTP다. HTTP는 요청-응답(Request-Response) 구조로, 클라이언트가 요청해야만 서버가 응답할 수 있다. 이 구조에는 몇 가지 근본적인 한계가 있다.
- 매 요청마다 HTTP 헤더를 반복 전송해야 한다(~500바이트).
- 요청과 응답이 1:1로 매핑되므로, 서버가 먼저 데이터를 보낼 수 없다.
- Keep-Alive 옵션으로 TCP 연결을 일정 기간 재사용할 수는 있지만, 이는 연결을 맺고 끊는 비용을 줄이는 것일 뿐 통신 모델 자체는 여전히 요청-응답 방식이다.
실시간 대기열 순번 알림, 채팅, 주식 시세처럼 서버가 능동적으로 클라이언트에 데이터를 푸시해야 하는 상황에서는 HTTP만으로는 효율적인 처리가 어렵다. 이러한 한계를 해결하기 위해 등장한 것이 WebSocket이다.
WebSocket의 정의
WebSocket은 HTTP 레이어 위에서 작동하는 양방향(Full-Duplex) 통신 프로토콜이다.
내부적으로는 TCP 연결에 의존하지만, HTTP 프로토콜을 기반으로 핸드셰이크를 수행하므로 OSI 7계층(애플리케이션 계층)에 위치한다.
여기서 핵심은, WebSocket은 TCP 소켓과 별개의 것이 아니라 TCP 소켓의 추상화된 형태라는 점이다. 4계층의 TCP 소켓이 단순한 바이트 스트림을 다루는 반면, 7계층에 위치한 WebSocket은 구조화된 메시지 형식의 데이터를 다룬다.
즉, 동일한 TCP 파이프 위에서 더 높은 수준의 데이터 단위를 제공하는 것이다.
WebSocket의 동작 방식
섹션 2에서 살펴본 것처럼, WebSocket 연결은 다음 순서로 수립된다.
TCP 파이프 생성 → HTTP Handshake → WebSocket으로 프로토콜 전환(Upgrade)
한 번 연결이 수립되면, 서버와 클라이언트가 자유롭게 메시지를 주고받을 수 있다. HTTP처럼 요청-응답 구조가 아니라, 양방향으로 아무 때나 추가 연결 없이 통신한다.
[WebSocket 통신]
브라우저 ←──── TCP 연결 (한 번만) ────→ 서버
연결 수립 후:
서버: "3,421번째입니다" ──→ 브라우저 (서버가 먼저 보냄)
서버: "3,200번째입니다" ──→ 브라우저 (서버가 먼저 보냄)
서버: "입장 가능합니다!" ──→ 브라우저 (서버가 먼저 보냄)
브라우저: "대기열 등록 요청" ──→ 서버 (클라이언트도 보냄)
양방향. 아무 때나. 추가 연결 없이.
HTTP vs WebSocket 핵심 비교
[HTTP]
- 매 요청마다 TCP 연결 (또는 Keep-Alive로 재사용)
- 매 요청마다 HTTP 헤더 반복 전송 (~500바이트)
- 요청-응답 1:1 매핑
- 서버가 먼저 보낼 수 없음
- URL: http:// 또는 https://
[WebSocket]
- TCP 연결 1번, 이후 계속 유지
- 프레임 헤더 2~14바이트 (HTTP 헤더 대비 약 1/50)
- 자유로운 양방향 메시지
- 서버가 먼저 보낼 수 있음
- URL: ws:// 또는 wss:// (TLS)
왜 HTTP와 같은 포트를 쓰는가
WebSocket은 HTTP 80번(ws://) 또는 443번(wss://) 포트를 그대로 사용하며, 새로운 포트를 열지 않는다.
그 이유는 현실적인 네트워크 환경에 있다. 기업 방화벽은 보통 80/443 포트만 허용하기 때문에, WebSocket이 별도 포트를 사용하면 대부분의 기업 네트워크에서 차단된다. WebSocket은 HTTP와 같은 포트를 쓰면서 핸드셰이크 자체를 HTTP로 시작하기 때문에, 방화벽과 프록시를 자연스럽게 통과할 수 있다.
4. WebSocket 연결 과정 (Handshake)
WebSocket 연결은 HTTP 요청으로 시작해서, 프로토콜을 전환(Upgrade)하는 방식이다.
섹션 2에서 배운 "같은 TCP 파이프에서 대화 언어만 바꾸는 것"이 바로 이 과정이다.
실제 Handshake 과정
[Step 1: 클라이언트 → 서버 (HTTP Upgrade 요청)]
GET /ws-stomp HTTP/1.1
Host: dev.api.peacemaker.kr
Upgrade: websocket ← "WebSocket으로 전환해주세요"
Connection: Upgrade ← "연결을 업그레이드하겠습니다"
Sec-WebSocket-Key: dGhlIHNhbXBsZQ== ← 랜덤 Base64 키 (검증용)
Sec-WebSocket-Version: 13 ← WebSocket 프로토콜 버전
Sec-WebSocket-Protocol: v10.stomp ← 서브 프로토콜 (STOMP)
Origin: https://dev.peacemaker.kr ← CORS 출처
[Step 2: 서버 → 클라이언트 (101 Switching Protocols)]
HTTP/1.1 101 Switching Protocols ← 101 = "프로토콜 전환 승인"
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9k... ← Key 검증 응답
Sec-WebSocket-Protocol: v10.stomp
[Step 3: 이후 모든 통신은 WebSocket 프레임으로]
이 시점부터 HTTP가 아니다.
TCP 연결은 유지된 채로, WebSocket 프레임이 오간다.
Sec-WebSocket-Key → Accept 검증
이 과정은 프록시/캐시가 WebSocket 연결을 일반 HTTP로 착각하는 것을 방지한다.
클라이언트가 보낸 Key: dGhlIHNhbXBsZQ==
서버 계산:
Base64(SHA-1(Key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"))
= s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
서버가 이 값을 Sec-WebSocket-Accept로 응답
→ 클라이언트가 검증: "이 서버는 진짜 WebSocket을 이해하는구나"
"258EAFA5..."는 RFC 6455에 정의된 고정 매직 GUID다. 모든 WebSocket 구현이 동일한 값을 사용한다.
CGV 프로젝트의 실제 엔드포인트
// WebSocketConfig.java:66-75
registry.addEndpoint("/ws-stomp") // 이 경로로 Handshake
.setAllowedOriginPatterns("*") // CORS 허용
.withSockJS() // SockJS fallback 지원
.setStreamBytesLimit(512 * 1024)
.setHttpMessageCacheSize(1000)
.setDisconnectDelay(30 * 1000)
.setHeartbeatTime(20 * 1000);
// 프론트엔드 .env.production
VITE_WEBSOCKET_URL=wss://dev.api.peacemaker.kr/ws-stomp
SockJS: WebSocket을 지원하지 않는 브라우저/환경에서 자동으로 Long Polling 등으로 fallback하는 라이브러리. 우리 프로젝트에서는 withSockJS()로 활성화했지만, 프론트엔드는 @stomp/stompjs의 네이티브 WebSocket을 사용하므로 SockJS fallback은 거의 사용되지 않는다.
5. WebSocket 프레임 구조
Handshake 이후, 데이터는 WebSocket 프레임 단위로 전송된다. HTTP 헤더 없이, 최소 2바이트 헤더로 통신한다.
[WebSocket 프레임 구조]
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16 or 64) |
|N|V|V|V| |S| | |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+-------------------------------+
| Masking-key (0 or 4 bytes) |
+-------------------------------+-------------------------------+
| Payload Data |
+---------------------------------------------------------------+
핵심 필드
| 필드 | 크기 | 설명 |
|---|---|---|
| FIN | 1bit | 이 프레임이 메시지의 마지막 조각인지 |
| opcode | 4bit | 프레임 종류 |
| MASK | 1bit | 페이로드가 마스킹되었는지 |
| Payload len | 7bit | 페이로드 길이 (125 이하면 그대로, 126이면 2바이트 확장, 127이면 8바이트 확장) |
opcode 종류
0x0 = Continuation (분할된 메시지의 이어지는 조각)
0x1 = Text Frame (UTF-8 텍스트 — JSON 메시지가 여기)
0x2 = Binary Frame (바이너리 데이터)
0x8 = Close (연결 종료)
0x9 = Ping (생존 확인 요청)
0xA = Pong (Ping에 대한 응답)
Ping/Pong: 연결 생존 확인
[Ping/Pong 메커니즘]
서버: Ping(opcode=0x9) ──→ 클라이언트
클라이언트: Pong(opcode=0xA) ──→ 서버
일정 시간 내에 Pong이 안 오면 → 연결 끊긴 것으로 판단
WebSocket Ping/Pong과 STOMP heartbeat은 목적은 같지만(연결 생존 확인) 별개 메커니즘이다.
Ping/Pong은 opcode 0x9/0xA를 사용하고,
STOMP heartbeat은 줄바꿈 문자("\n")를 일반 텍스트 프레임(opcode 0x1)으로 보낸다.
CGV 프로젝트에서 20초마다 보내는 것은 STOMP heartbeat이며, 둘 다 TCP 프레임을 생성하므로 ALB idle timeout 방지에 효과적이다.
HTTP vs WebSocket 오버헤드 비교
CGV에서 대기열 통계 브로드캐스트 메시지: {"totalWaiting":95000,"totalActive":4800} (약 45바이트)
[HTTP 폴링으로 보낼 때]
10만 명 × 2초마다 GET /api/queue/stats = 초당 50,000 요청
요청 헤더: ~500바이트 (Cookie, User-Agent, Accept 등)
응답 헤더: ~300바이트 (Content-Type, Cache-Control 등)
본문: ~45바이트
──────────────────
합계: ~845바이트 × 50,000 = 초당 42MB
[WebSocket 브로드캐스트로 보낼 때]
서버: 2초마다 /topic/stats/movie/{movieId}에 1번 전송
프레임 헤더: 2~6바이트
본문: ~45바이트
──────────────────
합계: ~51바이트 × 10만 연결 = 초당 2.5MB
→ WebSocket이 약 17배 효율적
→ 서버 로직: HTTP는 초당 5만 요청 처리 vs WebSocket은 토픽 1번 전송
6. STOMP: WebSocket 위의 메시징 프로토콜
왜 STOMP가 필요한가
Raw WebSocket은 바이트스트림만 주고받는 파이프다. "이 메시지가 누구한테 가야 하는지", "어떤 종류의 메시지인지" 같은 규칙이 없다.
[Raw WebSocket만 쓸 때]
서버가 보낸 메시지: {"totalWaiting":95000,"totalActive":4800}
문제:
- 이 메시지는 누구한테 보내는 건가? (영화 A 대기자? 영화 B 대기자?)
- 대기열 통계인가? 입장 허가인가? 타임아웃인가? 매진인가?
- 클라이언트가 특정 종류의 메시지만 구독할 수 있나?
→ 전부 직접 구현해야 한다 (메시지 라우팅, 토픽, 구독 관리...)
[STOMP를 얹으면]
WebSocket 위에서 토픽(topic) 기반 구독/발행이 자동으로 된다.
클라이언트: "SUBSCRIBE /topic/admission/req-12345"
→ 내 입장 알림만 받겠다
클라이언트: "SUBSCRIBE /topic/stats/movie/korea-series-g5"
→ 한국시리즈 5차전 전체 대기열 통계만 받겠다
서버: template.convertAndSend("/topic/admission/req-12345", payload)
→ req-12345를 구독한 클라이언트에게만 전송
WebSocket이 "파이프"라면, STOMP는 "파이프 안에서 메시지를 주고받는 규칙"이다.
STOMP 프레임 구조
STOMP(Simple Text Oriented Messaging Protocol)는 텍스트 기반 프로토콜이다. HTTP처럼 읽을 수 있다.
[STOMP 프레임 형식]
COMMAND ← 명령어 (CONNECT, SUBSCRIBE, SEND, MESSAGE 등)
header1:value1 ← 헤더들 (key:value)
header2:value2
← 빈 줄
Body^@ ← 본문 + NULL 문자로 끝남
주요 STOMP 명령
[1. CONNECT — 클라이언트가 서버에 STOMP 연결]
CONNECT
accept-version:1.2
heart-beat:20000,20000 ← 20초마다 heartbeat 보내겠다/받겠다
^@
[2. CONNECTED — 서버가 연결 승인]
CONNECTED
version:1.2
heart-beat:20000,20000 ← 서버도 20초 heartbeat 동의
session:session-12345
^@
[3. SUBSCRIBE — 토픽 구독]
SUBSCRIBE
id:sub-0
destination:/topic/admission/req-12345 ← 이 토픽의 메시지를 받겠다
^@
[4. MESSAGE — 서버가 구독자에게 메시지 전송]
MESSAGE
subscription:sub-0
destination:/topic/admission/req-12345
content-type:application/json
{"status":"ADMITTED","action":"REDIRECT_TO_SEATS"}^@
[5. SEND — 클라이언트가 서버에 메시지 전송]
SEND
destination:/app/chat ← 서버의 @MessageMapping에 매핑
{"message":"Hello"}^@
STOMP Heartbeat
STOMP 프로토콜에는 heartbeat 기능이 내장되어 있다. WebSocket Ping/Pong과 별도로, STOMP 레벨에서도 연결 생존을 확인한다.
CONNECT 시 협상:
클라이언트: heart-beat:20000,20000
↑ ↑
보내는 간격 받기 원하는 간격 (밀리초)
서버: heart-beat:20000,20000
협상 결과:
클라이언트→서버: 20초마다 heartbeat 전송
서버→클라이언트: 20초마다 heartbeat 전송
실제 heartbeat 내용: 그냥 줄바꿈 문자 1개 ("\n")
→ 이 1바이트가 TCP 프레임을 생성
→ ALB가 "이 연결은 살아있구나" 판단
→ idle timeout 리셋
7. Spring Boot WebSocket/STOMP 구현
의존성
<!-- pom.xml -->
<!-- WebSocket -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-messaging</artifactId>
</dependency>
spring-boot-starter-websocket을 쓸 수도 있지만, CGV 프로젝트에서는 spring-websocket + spring-messaging을 직접 추가했다. 결과는 동일하다.
WebSocketConfig: 3가지 핵심 설정
Spring Boot에서 WebSocket+STOMP를 쓰려면 WebSocketMessageBrokerConfigurer를 구현한다. 핵심은 3개 메서드다.
// WebSocketConfig.java
@Configuration
@EnableWebSocketMessageBroker // ← WebSocket 메시지 브로커 활성화
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
// ① 메시지 브로커 설정
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/topic") // 구독 prefix
.setHeartbeatValue(new long[]{20000, 20000}) // 20초 heartbeat
.setTaskScheduler(taskScheduler()); // heartbeat용 스케줄러
config.setApplicationDestinationPrefixes("/app"); // 클라이언트→서버 prefix
config.setPreservePublishOrder(true); // 메시지 순서 보장
}
// ② STOMP 엔드포인트 등록
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws-stomp") // WebSocket Handshake URL
.setAllowedOriginPatterns("*") // CORS 허용
.withSockJS() // SockJS fallback
.setDisconnectDelay(30 * 1000) // 30초 연결 유지
.setHeartbeatTime(20 * 1000); // 20초 heartbeat
}
// ③ WebSocket 전송 계층 튜닝
@Override
public void configureWebSocketTransport(WebSocketTransportRegistration registry) {
registry
.setMessageSizeLimit(256 * 1024) // 256KB 메시지 제한
.setSendBufferSizeLimit(2 * 1024 * 1024) // 2MB 전송 버퍼
.setSendTimeLimit(30000) // 30초 전송 제한
.setTimeToFirstMessage(60000); // 60초 첫 메시지 대기
}
}
각 설정이 하는 일
[① configureMessageBroker — 메시지가 어디로 가는지 규칙 설정]
"/topic" (SimpleBroker):
서버→클라이언트 브로드캐스트 경로.
template.convertAndSend("/topic/admission/req-123", payload)
→ /topic/admission/req-123을 SUBSCRIBE한 모든 클라이언트에게 전송
"/app" (ApplicationDestinationPrefix):
클라이언트→서버 메시지 경로.
클라이언트가 SEND destination:/app/chat 보내면
→ 서버의 @MessageMapping("/chat") 메서드가 처리
메시지 흐름:
클라이언트 → /app/xxx → @MessageMapping → 비즈니스 로직
서버 → /topic/xxx → 구독한 클라이언트들에게 전달
[② registerStompEndpoints — WebSocket 연결 진입점]
"/ws-stomp":
클라이언트가 WebSocket Handshake하는 URL
wss://dev.api.peacemaker.kr/ws-stomp
withSockJS():
WebSocket 불가능한 환경에서 HTTP Streaming/Long Polling으로 자동 전환
→ IE 10 이하, 일부 기업 프록시 환경 대응
[③ configureWebSocketTransport — 대량 트래픽 대응 튜닝]
기본값 CGV 설정 왜?
messageSizeLimit: 16KB → 256KB 대기열 통계 메시지가 클 수 있음
sendBufferSizeLimit: 512KB → 2MB 10만 명에게 동시 전송 시 버퍼 필요
sendTimeLimit: 10초 → 30초 동시 전송 시 시간 더 필요
timeToFirstMessage: 10초 → 60초 느린 클라이언트/네트워크 대응
SimpMessagingTemplate: 메시지 전송
서버에서 WebSocket 메시지를 보내는 핵심 클래스다.
// WebSocketUpdateService.java
@Service
public class WebSocketUpdateService {
private final SimpMessagingTemplate template;
// 입장 허가 알림 — 특정 사용자에게 (Redis Pub/Sub 경유)
public void notifyAdmission(String requestId, String movieId) {
String destination = "/topic/admission/" + requestId;
Map<String, Object> payload = Map.of(
"status", "ADMITTED",
"action", "REDIRECT_TO_SEATS",
"movieId", movieId
);
template.convertAndSend(destination, payload);
// → /topic/admission/req-12345를 구독한 클라이언트만 받음
}
// 대기열 통계 — 해당 영화 대기자 전체에게 (broadcast-only)
public void broadcastQueueStats(String movieId, long totalWaiting, long totalActive) {
String destination = "/topic/stats/movie/" + movieId;
template.convertAndSend(destination, Map.of(
"totalWaiting", totalWaiting,
"totalActive", totalActive
));
// → /topic/stats/movie/{movieId}를 구독한 모든 클라이언트가 받음
// 개별 순위(ZRANK)는 보내지 않음 — 95,000명 × ZRANK = Redis 과부하
// 사용자 순위는 입장 시 1회 조회 + 클라이언트가 totalWaiting 변화로 추정
}
}
프론트엔드 STOMP 클라이언트
// WaitPage.jsx (React)
import { Client } from '@stomp/stompjs';
// 입장 시 1회만 서버에 내 순위 요청
const [myRank, setMyRank] = useState(null);
const [initialWaiting, setInitialWaiting] = useState(null);
useEffect(() => {
fetch(`/api/queue/rank?requestId=${requestId}`)
.then(res => res.json())
.then(data => {
setMyRank(data.rank); // 예: 3200
setInitialWaiting(data.total); // 예: 95000
});
}, []);
const client = new Client({
brokerURL: import.meta.env.VITE_WEBSOCKET_URL, // wss://dev.api.peacemaker.kr/ws-stomp
reconnectDelay: 2000, // 끊기면 2초 후 재연결
maxReconnectAttempts: 30, // 최대 30번 재시도
heartbeatIncoming: 20000, // 서버→클라이언트 heartbeat 20초
heartbeatOutgoing: 20000, // 클라이언트→서버 heartbeat 20초
onConnect: () => {
// 대기열 통계 구독 (broadcast-only: 서버가 2초마다 전송)
client.subscribe(`/topic/stats/movie/${movieId}`, (message) => {
const stats = JSON.parse(message.body);
setTotalWaiting(stats.totalWaiting);
setTotalActive(stats.totalActive);
// 클라이언트가 내 순위 추정 (개별 ZRANK 요청 없음)
const decreased = initialWaiting - stats.totalWaiting;
setEstimatedRank(Math.max(0, myRank - decreased));
});
// 내 입장 알림 구독 (승격 시 서버가 전송)
client.subscribe(`/topic/admission/${requestId}`, (message) => {
const data = JSON.parse(message.body);
if (data.status === 'ADMITTED') {
redirectToSeats(); // 좌석 선택 페이지로 이동
}
});
}
});
client.activate(); // WebSocket 연결 시작
broadcast-only 설계: 서버는
totalWaiting과totalActive숫자 2개만 2초마다 브로드캐스트한다. 개별 사용자 순위(ZRANK)를 보내지 않는 이유는 95,000명 × ZRANK = 초당 ~160만 Redis 연산이 발생하기 때문이다. 대신 사용자는 입장 시 1회/api/queue/rank를 호출하고, 이후totalWaiting감소분으로 자신의 순위를 추정한다.
스레드 모델: NIO 기반
[10만 명 연결 = 10만 스레드?]
아니다. Spring WebSocket은 NIO(Non-blocking I/O)를 사용한다.
연결 관리: NIO
→ 1개 스레드가 수천 개 연결을 관리
→ 10만 연결 ≠ 10만 스레드
메시지 처리: 스레드 풀 (corePoolSize=20, maxPoolSize=100)
→ 10만 개 메시지가 큐에 들어감
→ 100개 워커 스레드가 분배 처리
→ NIO 기반이라 1개 워커가 빠르게 여러 연결에 전송
실제 스레드 사용:
NIO Selector 스레드: 1~4개
메시지 처리 워커: 20~100개
heartbeat 스케줄러: 30개
────────────────────────
합계: ~134개 스레드로 10만 연결 처리
참고: 이 NIO 기반 동시 연결 처리 능력은 k6 부하테스트로 검증한다. 10만 WebSocket 연결을 시뮬레이션하여 스레드 수, 응답 시간, 메시지 전달률을 측정한다 (-> 2.4 모니터링 참고).
8. 전체 아키텍처: CGV 대기열의 5계층 실시간 통신
1~7장에서 각 기술을 개별적으로 배웠다. 이 장에서는 5개가 왜 전부 필요하고, 어떻게 연결되는지 전체 그림을 잡는다.
8.1 각 계층의 역할 — 한 줄 정리
[브라우저] ←──WebSocket──→ [Spring Boot Pod] ←──Redis──→ [다른 Pod들]
브라우저 쪽: 서버 쪽:
───────── ──────
WebSocket: 서버↔클라이언트 상시 연결 Lua Script: Redis 데이터 원자적 변경
STOMP: 메시지 형식 + 토픽 라우팅 Redis Pub/Sub: Pod 간 메시지 전파
SimpleBroker: 로컬 Pod 내 구독자 찾아 전달
| 계층 | 비유 | 하는 일 | 없으면? |
|---|---|---|---|
| WebSocket | 전화선 | 서버↔클라이언트 상시 연결 유지 | 10만 명이 2초마다 HTTP 요청 = 초당 5만 요청 폭탄 |
| STOMP | 전화 규칙 | "이 메시지는 입장허가야, /topic/admission/req-123으로 보내" | 메시지가 순위인지 입장허가인지 매진인지 구분 불가 |
| SimpleBroker | 교환원 | "req-123을 구독한 사람이 이 Pod에 있나?" → 찾아서 전달 | convertAndSend() 호출해도 아무에게도 안 감 |
| Redis Pub/Sub | 사내 방송 | Pod 3에서 보낸 메시지를 Pod 1~10 전부에 전달 | Pod 3에서 처리한 입장허가가 Pod 1의 사용자에게 안 감 (전달률 10%) |
| Lua Script | 금고 자물쇠 | ZCARD + ZADD를 하나의 연산으로 묶어 원자적 실행 | 10 Pod이 동시에 ZADD → 5,000명 제한 초과 (데이터 깨짐) |
8.2 5개 계층의 관계도
┌─────────────────────────────────────────────────────────────────┐
│ Redis (단일 Primary) │
│ │
│ [데이터] [메시징] │
│ Sorted Set: waiting, active Pub/Sub: queue:notifications │
│ String: seat lock │
│ Set: booked seats ┌──────────────┐ │
│ │ PUBLISH 메시지 │ │
│ ← Lua Script로 원자적 변경 → │ 모든 Pod에 │ │
│ │ 동시 전달 │ │
│ └───────┬──────┘ │
└────────────────────────────────────────────┼─────────────────────┘
↑ Lua 실행 │ SUBSCRIBE
│ ↓
┌─────────┴───────────────────────────────────────────────────────┐
│ Spring Boot Pod (10개) │
│ │
│ QueueProcessor (2초마다) │
│ │ │
│ ├─ ① Lua로 Redis 데이터 변경 (승격: ZREM waiting + ZADD active)│
│ │ │
│ ├─ ② Redis Pub/Sub로 메시지 발행 │
│ │ PUBLISH queue:notifications │
│ │ → {event: "ADMISSION", requestId: "req-123"} │
│ │ → {event: "STATS", totalWaiting: 94000, totalActive: 5000}│
│ │ │
│ │ ┌──── 모든 Pod가 SUBSCRIBE로 수신 ────┐ │
│ │ ↓ ↓ │
│ │ [Pod 1] [Pod 7] │
│ │ │
│ ├─ ③ SimpleBroker에게 전달 │
│ │ "req-123 구독한 사람 이 Pod에 있어?" │
│ │ → Pod 1: "있다!" → 전송 │
│ │ → Pod 7: "없다" → 무시 │
│ │ │
│ └─ ④ STOMP 프레임으로 포장 → WebSocket으로 전송 │
│ MESSAGE │
│ destination:/topic/admission/req-123 │
│ {"status":"ADMITTED","action":"REDIRECT_TO_SEATS"} │
│ │
└──────────────────────────┬──────────────────────────────────────┘
│ WebSocket (TCP 상시 연결)
↓
┌──────────────────────────────────────────────────────────────────┐
│ 사용자 브라우저 │
│ │
│ STOMP 클라이언트가 메시지 수신 │
│ → destination이 /topic/admission/req-123이네? │
│ → 내가 구독한 토픽이다! → onMessage 콜백 실행 │
│ → "입장 허가됨" → 좌석 선택 페이지로 이동 │
└──────────────────────────────────────────────────────────────────┘
8.3 실제 흐름으로 이해하기 — 사용자 A가 대기열에 들어가서 입장할 때까지
시간순으로 따라가 보자. 사용자 A가 "어벤져스 예매하기"를 클릭한다.
Step 1: WebSocket 연결 수립
브라우저 → ALB → Pod 1에 WebSocket Handshake (HTTP → 101 Switching Protocols)
→ STOMP CONNECT (heart-beat:20000,20000)
→ STOMP SUBSCRIBE /topic/admission/req-A (내 입장 알림용)
→ STOMP SUBSCRIBE /topic/stats/movie/avengers (전체 통계용)
이 시점에서:
Pod 1의 SimpleBroker 메모리:
/topic/admission/req-A → [사용자 A의 WebSocket 세션]
/topic/stats/movie/avengers → [사용자 A의 WebSocket 세션, 사용자 B, C, ...]
Step 2: 대기열 입장 (REST API + Lua)
브라우저 → POST /api/admission/enter {movieId: "avengers", requestId: "req-A"}
→ (이건 WebSocket이 아니라 일반 HTTP 요청)
서버 (어떤 Pod이든):
→ admission.lua 실행 (Redis에서 원자적으로):
local active = redis.call('ZCARD', 'sessions:avengers:active')
if active < 5000 then
redis.call('ZADD', 'sessions:avengers:active', timestamp, 'req-A')
return 'ADMITTED' -- 바로 입장
else
redis.call('ZADD', 'sessions:avengers:waiting', timestamp, 'req-A')
return 'WAITING' -- 대기열에 들어감
end
왜 Lua인가?
ZCARD와 ZADD 사이에 다른 Pod의 요청이 끼어들면 5,000명 초과 가능
Lua = 싱글 스레드에서 한 덩어리로 실행 → 끼어들기 불가능
Step 3: 대기 중 — 통계 브로드캐스트 (2초마다)
QueueProcessor (모든 Pod에서 실행):
→ ZCARD sessions:avengers:waiting = 94,000
→ ZCARD sessions:avengers:active = 5,000
→ Redis Pub/Sub: PUBLISH queue:notifications
{event: "STATS", movieId: "avengers", totalWaiting: 94000, totalActive: 5000}
→ 모든 Pod가 SUBSCRIBE로 수신
→ 각 Pod의 SimpleBroker:
/topic/stats/movie/avengers 구독자가 있나?
→ Pod 1: 사용자 A, B, C 있음 → STOMP MESSAGE로 포장 → WebSocket 전송
→ Pod 5: 사용자 X, Y 있음 → 전송
→ Pod 8: 이 토픽 구독자 없음 → 무시
→ 사용자 A의 브라우저:
{totalWaiting: 94000, totalActive: 5000} 수신
→ "현재 대기 중: 94,000명" 표시
→ 내 추정 순위: 초기 순위 - (초기 대기자 - 현재 대기자) 계산
Step 4: 승격 — 입장 허가 (Lua + Pub/Sub + SimpleBroker + STOMP + WebSocket)
QueueProcessor (Pod 7에서 실행 중):
→ Lua로 승격 처리:
ZRANGEBYSCORE sessions:avengers:waiting 0 +inf LIMIT 0 100
→ 대기열 앞쪽 100명 조회 (req-A 포함)
→ ZREM sessions:avengers:waiting req-A
→ ZADD sessions:avengers:active timestamp req-A
(이 전체가 Lua로 원자적 실행)
→ Redis Pub/Sub: PUBLISH queue:notifications
{event: "ADMISSION", requestId: "req-A", movieId: "avengers"}
→ 모든 10개 Pod가 수신
→ Pod 1의 SimpleBroker:
"/topic/admission/req-A 구독한 사람?" → 사용자 A 발견!
→ STOMP MESSAGE 프레임 생성:
MESSAGE
destination:/topic/admission/req-A
content-type:application/json
{"status":"ADMITTED","action":"REDIRECT_TO_SEATS"}
→ WebSocket으로 전송
→ Pod 2~10의 SimpleBroker:
"/topic/admission/req-A 구독한 사람?" → 없음 → 무시
→ 사용자 A의 브라우저:
onMessage 콜백 → status === 'ADMITTED' → 좌석 선택 페이지로 이동!
8.4 각 계층을 빼면 어떻게 되는가
이해를 확실히 하기 위해, 각 계층을 하나씩 빼본다.
[WebSocket을 빼면]
브라우저가 2초마다 GET /api/queue/stats 요청
10만 명 × 2초 = 초당 50,000 HTTP 요청
매 요청마다 500바이트 헤더 = 초당 25MB 순수 오버헤드
서버 CPU 대부분을 "변화 없음" 응답에 소비
→ WebSocket: 토픽 1번 전송으로 10만 명에게 동시 전달 (초당 2.5MB)
[STOMP를 빼면]
서버: webSocket.send('{"status":"ADMITTED","requestId":"req-A"}')
클라이언트: 이 메시지를 받았는데...
- 입장 허가야? 순위 통계야? 타임아웃이야? 매진이야?
- req-A한테 보내는 거야? 전체 브로드캐스트야?
→ 직접 if/else로 분기 처리해야 함
→ 토픽이 5종류 × 분기 처리 = 스파게티 코드
STOMP가 있으면:
client.subscribe('/topic/admission/req-A', onAdmission) ← 입장허가만
client.subscribe('/topic/stats/movie/avengers', onStats) ← 통계만
→ 토픽별로 콜백이 분리됨. 라우팅은 STOMP가 알아서 처리.
[SimpleBroker를 빼면]
서버: template.convertAndSend("/topic/admission/req-A", payload)
↓
"req-A를 구독한 WebSocket 세션이 어디 있지?"
→ 구독자 목록이 없음
→ 메시지를 보낼 대상을 찾을 수 없음
→ 아무에게도 안 감
SimpleBroker = 구독자 목록을 메모리에 관리하는 엔진
SUBSCRIBE 받으면 → Map<토픽, List<세션>>에 추가
convertAndSend() 호출하면 → Map에서 해당 토픽의 세션 목록 조회 → 전송
[Redis Pub/Sub를 빼면]
Pod 7의 QueueProcessor가 req-A를 승격함
→ Pod 7: template.convertAndSend("/topic/admission/req-A", payload)
→ Pod 7의 SimpleBroker: "req-A? 내 로컬에 없는데..." → 전송 실패
req-A는 Pod 1에 WebSocket 연결되어 있음
→ Pod 1은 승격 사실을 모름
→ 사용자 A는 영원히 대기 화면
10 Pod이면 → 처리한 Pod = 연결된 Pod일 확률 = 10%
→ 전달 성공률 10%
Redis Pub/Sub가 있으면:
Pod 7 → PUBLISH → Redis → 모든 Pod에 전달
→ Pod 1이 수신 → SimpleBroker에서 req-A 찾음 → 전송 성공
→ 전달 성공률 100%
[Lua Script를 빼면]
Pod 3과 Pod 7이 동시에 QueueProcessor 실행:
Pod 3: ZCARD active = 4,999 Pod 7: ZCARD active = 4,999
Pod 3: "1명 더 넣을 수 있다!" Pod 7: "1명 더 넣을 수 있다!"
Pod 3: ZADD active req-A Pod 7: ZADD active req-B
→ active = 5,001명! (제한 초과)
Lua가 있으면:
ZCARD + 비교 + ZADD가 하나의 원자적 연산
→ Pod 3이 실행하는 동안 Pod 7은 대기
→ 절대 5,000 초과 불가
8.5 WebSocket으로 전송하는 5가지 메시지
| 메시지 종류 | 토픽 | 대상 | 보내는 곳 | 설명 |
|---|---|---|---|---|
| 입장 허가 | /topic/admission/{requestId} |
개별 | QueueProcessor | 대기→활성 승격 시 "좌석 선택으로 이동" |
| 대기열 통계 | /topic/stats/movie/{movieId} |
전체 | QueueProcessor | {totalWaiting, totalActive} 2초마다 브로드캐스트 |
| 세션 타임아웃 | /topic/timeout/{requestId} |
개별 | SessionTimeoutProcessor | "타임아웃 초과, 자동 퇴장" (기본 30초, prod 600초) |
| 좌석 선점 결과 | /topic/booking/{requestId} |
개별 | SeatService | 다른 사용자의 선점으로 인한 잔여석 변화 알림 |
| 매진 알림 | /topic/soldout/movie/{movieId} |
전체 | BookingService | "전체 매진, 예매 불가" |
개별 순위 업데이트(
/topic/rank/{requestId})는 의도적으로 제거했다. 95,000명에게 각각 ZRANK를 호출하면 2초마다 ~160만 Redis 연산이 발생한다.
대신 전체 통계만 브로드캐스트하고,
사용자 순위는 입장 시 1회 REST API(/api/queue/rank)로 조회 + 클라이언트가totalWaiting감소분으로 추정한다.
8.6 좌석 선점 WebSocket 플로우
Active Session 사용자가 좌석을 선택할 때의 WebSocket 메시지 흐름:
[좌석 선점 성공]
1. 프론트엔드: POST /api/seats/select {movieId, theaterId, seatIds, requestId}
2. 백엔드: seat_lock.lua 실행 → SET NX EX 300 (원자적 선점)
3. 성공 시: WebSocket 전송
template.convertAndSend(
"/topic/booking/" + requestId,
{status: "LOCKED", seatIds: ["A-10"], lockedUntil: "20:05:50"}
)
→ 프론트엔드: 좌석을 "내 것"으로 표시 + 5분 카운트다운 시작
[좌석 선점 실패 (충돌)]
1. 프론트엔드: POST /api/seats/select (같은 좌석)
2. 백엔드: SET NX 실패 (이미 다른 사용자가 점유)
3. 실패 시: HTTP 409 Conflict 응답
{status: "CONFLICT", conflictSeats: ["A-10"]}
→ 프론트엔드: "이미 선점된 좌석입니다" 안내 + 다른 좌석 선택 유도
[매진 브로드캐스트]
1. booking:completed:{movieId} == 6,000 도달
2. Redis Pub/Sub: PUBLISH queue:notifications {event: "SOLD_OUT", movieId: "..."}
3. 모든 Pod가 SUBSCRIBE로 수신
4. 각 Pod: WebSocket 브로드캐스트
template.convertAndSend(
"/topic/soldout/movie/" + movieId,
{status: "SOLD_OUT", totalSold: 6000}
)
→ 대기열 95,000명에게 매진 알림
SET NX는 분산 환경에서 동시에 하나의 프로세스만 자원에 접근하도록 보장하는 패턴이다. 여러 Pod/서버가 동시에 같은 작업을 수행하려 할 때, 하나만 성공하고 나머지는 실패시키는 "분산 락"에 핵심적으로 쓰인다.
# 분산 락 예시: 여러 서버가 동시에 같은 리소스에 접근 시도
# SET NX: 키가 없을 때만 저장. 이미 있으면 실패.
Pub/Sub 유실 대비:
sold-out:{movieId}Redis 키도 함께 설정된다 (TTL 1시간).
WebSocket 연결이 끊긴 사용자는/api/admission/status폴링 시 이 키를 확인하여 매진 여부를 판단한다.
설계 의도: 좌석 선점 결과(200/409)는 POST 응답으로 즉시 확인한다.
/topic/booking/{requestId}는
다른 사용자의 선점으로 인한 실시간 좌석 상태 업데이트(잔여석 감소 등)를 브로드캐스트하기 위한 것이다.
즉, 내 선점 결과는 HTTP, 다른 사람의 선점 알림은 WebSocket으로 분리된다.
8.7 QueueProcessor에서의 WebSocket 전송
// QueueProcessor.java — 이전 실행 완료 후 2초 대기, 다시 실행
@Scheduled(fixedDelay = 2000)
public void processAllQueues() {
// ... 대기자 승격 로직 ...
// 승격된 사용자에게 입장 허가 알림 (Redis Pub/Sub 경유)
messagingTemplate.convertAndSend(
"/topic/admission/" + requestId,
Map.of("status", "ADMITTED", "action", "REDIRECT_TO_SEATS")
);
// 해당 영화 대기열 통계 브로드캐스트 (broadcast-only)
long totalWaiting = redisTemplate.opsForZSet().size("sessions:" + movieId + ":waiting");
long totalActive = redisTemplate.opsForZSet().size("sessions:" + movieId + ":active");
messagingTemplate.convertAndSend(
"/topic/stats/movie/" + movieId,
Map.of("totalWaiting", totalWaiting, "totalActive", totalActive)
);
// Redis 호출: ZCARD 2번뿐 (개별 ZRANK 없음)
}
8.8 2-Layer Safety: WebSocket이 끊기면?
실시간 알림은 대기열 시스템의 핵심이다. WebSocket이 끊기면 사용자는 자기 순번을 모른다. 이를 위해 2단계 안전장치를 설계했다.
[2-Layer Safety]
Layer 1 (Primary): QueueProcessor → Redis Pub/Sub → 모든 Pod 수신 → WebSocket 전송
Layer 2 (Fallback): API 폴링 — GET /api/admission/status (3초 간격)
각 레이어가 실패해도 다음 레이어가 동작:
WebSocket 끊김 → 프론트엔드가 감지하여 API 폴링으로 자동 전환
Pod 장애 → 다른 Pod의 API 폴링으로 확인
'프론트엔드에서의 자동 전환 (WaitPage.jsx):
// WebSocket 연결 끊김 감지
onWebSocketClose: (event) => {
setIsConnected(false);
// 즉시 API 폴링으로 전환
statusCheckIntervalRef.current = setInterval(checkStatusAndNavigate, 3000);
setUsingApiPolling(true);
}
// Heartbeat 타임아웃 감지 (35초 이상 무응답)
if (timeSinceLastHeartbeat > 35000 && isConnected) {
setIsConnected(false);
// API 폴링으로 전환
}
// WebSocket 재연결 성공 시 → API 폴링 중지
onConnect: () => {
if (statusCheckIntervalRef.current) {
clearInterval(statusCheckIntervalRef.current);
}
setUsingApiPolling(false);
}
9. ALB와 WebSocket
ALB는 WebSocket을 어떻게 처리하는가
AWS ALB(Application Load Balancer)는 HTTP Upgrade 요청을 인식하고, WebSocket 연결을 해당 백엔드로 패스스루한다.
[ALB의 WebSocket 처리]
1. 클라이언트 → ALB: HTTP Upgrade 요청
2. ALB → 백엔드: Upgrade 요청 전달 (Target Group 선택)
3. 백엔드 → ALB → 클라이언트: 101 Switching Protocols
4. 이후: ALB는 TCP 레벨에서 양방향 프레임을 그대로 전달
ALB는 WebSocket을 "이해"하지 않는다.
Handshake 이후에는 TCP 프레임을 그냥 통과시킬 뿐이다.
idle timeout 문제
ALB의 기본 idle timeout은 60초다. "idle"이란 TCP 레벨에서 데이터가 흐르지 않는 시간을 의미한다.
[문제 시나리오]
1. 10만 명 중 뒤쪽 대기자: 순번 변화가 거의 없음
2. QueueProcessor가 2초마다 통계를 보내지만, 변화 없으면 프론트엔드가 메시지 무시
3. ALB 기준으로 60초간 TCP 프레임 없음
4. ALB: "이 연결 idle이네" → 일방적으로 TCP 연결 종료
5. 사용자 화면: "연결 끊김" → 재연결 → 또 끊김 → 반복
결과: 대기열 뒤쪽 사용자들 WebSocket 연결 불안정
해결: STOMP heartbeat + ALB timeout 확장
[해결 1: ALB idle timeout을 300초로 확장]
# Kubernetes Ingress annotation
alb.ingress.kubernetes.io/load-balancer-attributes: idle_timeout.timeout_seconds=300
# 또는 Terraform
resource "aws_lb" "main" {
idle_timeout = 300
}
왜 300초?
60초(기본): WebSocket 서비스에 너무 짧음
3600초(1시간): 좀비 연결이 1시간 동안 리소스 점유
300초: heartbeat(20초) × 15회 버퍼. 일시적 네트워크 불안정 허용.
[해결 2: STOMP heartbeat으로 idle 방지]
// WebSocketConfig.java
config.enableSimpleBroker("/topic")
.setHeartbeatValue(new long[]{20000, 20000}); // 서버↔클라이언트 20초
// 프론트엔드 (WaitPage.jsx)
const client = new Client({
heartbeatIncoming: 20000, // 서버→클라이언트 heartbeat 기대 주기
heartbeatOutgoing: 20000, // 클라이언트→서버 heartbeat 전송 주기
});
20초마다 STOMP heartbeat("\n") 전송
→ 1바이트 TCP 프레임 생성
→ ALB: "데이터 흐르고 있으니 idle 아님"
→ idle timeout 리셋
→ 300초 내에 최소 15번 heartbeat → 연결 유지
Multi-Pod 문제
convertAndSend()는 로컬 Pod의 세션에만 전송한다. 10 Pod이면 전달 성공률 10%. 이를 Redis Pub/Sub로 해결하여 100% 전달을 보장한다. 상세한 문제 분석과 해결 흐름은 섹션 8.4 "Redis Pub/Sub를 빼면"에서 다루며, 구현 상세는 Redis lua와 Pub/Sub 섹션 2를 참고한다.
10. 메시징 시스템에서의 위치
위 섹션들에서 Redis Pub/Sub과 SimpleBroker가 왜 필요한지는 이해했다. 그런데 이것들이 메시징 시스템 전체에서 어디에 위치하는지, RabbitMQ/Kafka/Kinesis와 뭐가 다른지, 왜 우리는 그것들을 안 쓰는지는 → 메시징_시스템에서 상세히 다룬다.
메시징 시스템 3가지 패턴:
├── Pub/Sub (라이브 방송) ← Redis Pub/Sub, SimpleBroker = 여기
├── Message Queue (택배) ← RabbitMQ, SQS
└── Event Streaming (유튜브) ← Kafka, Kinesis
CGV는 Pub/Sub 패턴만 사용.
→ 2초 갱신 데이터 = 보관 불필요 → Kafka 탈락
→ 유실 허용 (2-Layer Safety로 보완) → RabbitMQ 불필요
→ 이미 Redis 사용 중 → Pub/Sub 추가 비용 $0
나머지를 안 쓰는 상세 이유는 메시징 문서 참고.
11. 면접 대비 정리
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ 브라우저1 │ │ 브라우저2 │ │ 브라우저3 │ ... │브라우저 N │
│ (React) │ │ (React) │ │ (React) │ │ (React) │
└────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │ │
│ ① HTTP 요청: GET /ws (Upgrade: websocket) │
│ Connection: Upgrade │
│ │ │ │
════╪══════════════╪══════════════╪══════════════════╪═══════ 인터넷
│ │ │ │
▼ ▼ ▼ ▼
┌─────────────────────────────────────────────────────────┐
│ ALB (Application Load Balancer) │
│ │
│ 역할: │
│ 1. HTTP Upgrade 요청 인식 → WebSocket으로 전환 │
│ 2. Target Group 중 Pod 하나 선택 (Round Robin) │
│ 3. Sticky Connection — 한번 연결되면 같은 Pod 유지 │
│ 4. TCP 연결 2개를 중계 (브라우저↔ALB, ALB↔Pod) │
│ │
│ ┌───────────────────────────────────────────────────┐ │
│ │ 브라우저1 ──TCP──► ALB ──TCP──► Pod 1 │ │
│ │ 브라우저2 ──TCP──► ALB ──TCP──► Pod 1 │ │
│ │ 브라우저3 ──TCP──► ALB ──TCP──► Pod 2 │ │
│ │ 브라우저N ──TCP──► ALB ──TCP──► Pod 2 │ │
│ │ │ │
│ │ ※ WebSocket 프레임을 그대로 통과시킴 (L7 투명 중계) │ │
│ │ ※ ALB는 STOMP 내용을 모름 — 그냥 바이너리 전달 │ │
│ └───────────────────────────────────────────────────┘ │
│ │
│ ② HTTP 101 Switching Protocols 응답 ← Pod │
│ → 이후부터 이 TCP 연결은 WebSocket 전용 │
│ │
└───────────┬─────────────────────────────┬───────────────┘
│ │
TCP ①-B │ TCP ①-B │
(ALB↔Pod)│ (ALB↔Pod)│
▼ ▼
┌───────────────────────┐ ┌───────────────────────┐
│ Pod 1 │ │ Pod 2 │
│ │ │ │
│ ┌─────────────────┐ │ │ ┌─────────────────┐ │
│ │ SimpleBroker │ │ │ │ SimpleBroker │ │
│ │ /topic/adm/A→A │ │ │ │ /topic/adm/C→C │ │
│ │ /topic/adm/B→B │ │ │ │ /topic/adm/D→D │ │
│ │ /topic/stats→AB│ │ │ │ /topic/stats→CD│ │
│ └────────┬────────┘ │ │ └────────┬────────┘ │
│ │ │ │ │ │
│ ┌────────▼────────┐ │ │ ┌────────▼────────┐ │
│ │ Spring Boot │ │ │ │ Spring Boot │ │
│ │ 비즈니스 로직 │ │ │ │ 비즈니스 로직 │ │
│ └────────┬────────┘ │ │ └────────┬────────┘ │
│ │ │ │ │ │
│ ┌────────▼────────┐ │ │ ┌────────▼────────┐ │
│ │ Redis Listener │ │ │ │ Redis Listener │ │
│ │ (Pub/Sub 구독) │ │ │ │ (Pub/Sub 구독) │ │
│ └────────┬────────┘ │ │ └────────┬────────┘ │
└───────────┼────────────┘ └───────────┼────────────┘
│ │
TCP ② │ TCP ② │
(Pod↔Redis) (Pod↔Redis)
│ │
▼ ▼
┌─────────────────────────────────────────────────────┐
│ Redis (ElastiCache) │
│ │
│ Pub/Sub: queue:notifications │
│ Data: Sorted Set, Set, Hash, String │
│ Lua: enter_queue / process_queue / seat_lock / ... │
│ │
│ Primary ◄──TCP ③──► Replica │
└─────────────────────────────────────────────────────┘
핵심 포인트 — ALB와 WebSocket Upgrade 과정:
브라우저 ALB Pod
│ │ │
│── GET /ws HTTP/1.1 ──►│ │
│ Upgrade: websocket │── GET /ws HTTP/1.1 ──►│
│ Connection: Upgrade │ Upgrade: websocket │
│ │ │
│ │◄─ 101 Switching ───────│
│◄─ 101 Switching ──────│ Protocols │
│ Protocols │ │
│ │ │
│◄══ WebSocket 프레임 (STOMP) 양방향 통과 ════════►│
│ ALB는 중간에서 투명하게 중계만 함 │
Q. 실시간 통신 전체 아키텍처를 설명해주세요
5개 계층으로 구성됩니다.
Lua 스크립트가 Redis 데이터를 원자적으로 변경하고,
QueueProcessor가 변경 결과를 Redis Pub/Sub로 발행하면 모든 Pod가 수신합니다.
각 Pod의 SimpleBroker가 자기 로컬 메모리에서 해당 토픽의 구독자를 찾고,
STOMP 프레임으로 포장하여
WebSocket 연결을 통해 브라우저에 전달합니다.
Lua가 데이터 정합성, Pub/Sub가 Pod 간 전파, SimpleBroker가 구독자 매칭, STOMP가 토픽 라우팅, WebSocket이 상시 연결을 담당하며, 하나라도 빠지면 시스템이 동작하지 않습니다.
Q. SimpleBroker가 뭔가요? 왜 외부 브로커(RabbitMQ)를 안 쓰나요?
SimpleBroker는 Spring Boot에 내장된 메시지 브로커로, Pod 메모리에 구독자 목록(Map<토픽, List<세션>>)을 관리합니다.
convertAndSend("/topic/admission/req-A")를 호출하면 SimpleBroker가 이 토픽을 구독한 WebSocket 세션을 찾아 전달합니다. 외부 브로커 없이도 Redis Pub/Sub로 Pod 간 전파를 해결하고 있으므로, RabbitMQ를 별도 운영할 필요가 없습니다. Pod당 1만 세션(10만/10 Pod) 정도는 SimpleBroker로 충분히 처리 가능합니다.
Q. WebSocket이 뭔가요?
TCP 위에서 동작하는 양방향 전이중 통신 프로토콜입니다. HTTP는 클라이언트가 요청해야만 서버가 응답할 수 있지만,
WebSocket은 한 번 연결하면 서버와 클라이언트가 자유롭게 메시지를 주고받을 수 있습니다.
CGV 프로젝트에서 대기열 순번을 실시간으로 사용자에게 push하기 위해 사용했습니다.
Q. WebSocket 연결은 어떻게 수립되나요?
HTTP 요청으로 시작합니다.
먼저 TCP 3-Way Handshake로 파이프를 만들고,
클라이언트가Upgrade: websocket헤더를 포함한 GET 요청을 보내면,
서버가101 Switching Protocols로 응답하면서
같은 TCP 파이프 위에서 프로토콜이 전환됩니다.
이후 통신은 HTTP가 아닌 WebSocket 프레임으로 이루어지며, 프레임 헤더가 26 바이트로 HTTP 헤더(500바이트) 대비 매우 가볍습니다.
Q. HTTP 폴링 대신 WebSocket을 쓴 이유는?
10만 명이 2초마다 폴링하면 초당 5만 요청이 발생합니다. 대부분 "변화 없음" 응답이므로 서버 리소스 낭비입니다. WebSocket을 쓰면 서버가
/topic/stats/movie/{movieId}토픽에 1번만 보내도 구독한 10만 명 모두에게 전달되므로, 서버 로직은 2초마다 1번 실행에 불과합니다. 또한 대기열 등록 등 클라이언트→서버 통신도 필요하기 때문에 단방향인 SSE가 아닌 양방향 WebSocket을 선택했습니다.
Q. STOMP는 왜 쓰나요?
Raw WebSocket은 바이트스트림만 전달하는 파이프입니다. "어떤 메시지를 누구에게"라는 라우팅 규칙이 없습니다. STOMP를 얹으면 토픽 기반 구독/발행이 가능해져,
/topic/admission/{requestId}를 구독한 사용자에게만 입장 허가를 보낼 수 있습니다. Spring Boot가 STOMP를 기본 지원하므로@EnableWebSocketMessageBroker만 붙이면 됩니다.
Q. WebSocket 연결이 끊기면 어떻게 되나요?
2-Layer Safety를 설계했습니다.
Primary는 Redis Pub/Sub를 통한 WebSocket 전송(QueueProcessor → Redis Pub/Sub → 모든 Pod 수신 → WebSocket 전송)이고,
Fallback은 3초 간격 API 폴링입니다.프론트엔드에서 heartbeat 타임아웃(35초 무응답)을 감지하면 자동으로 API 폴링으로 전환하고, WebSocket 재연결에 성공하면 폴링을 중지합니다. 2초마다 재연결을 시도하며 최대 30번까지 재시도합니다.
Q. 멀티 Pod 환경에서 WebSocket 메시지 전달은?
convertAndSend()는 해당 Pod의 로컬 세션에만 전송합니다. 10개 Pod이면 메시지 전달 성공률이 10%입니다. 이를 해결하기 위해 Redis Pub/Sub를 설계했습니다.
메시지를 Redis 채널로 발행하면 모든 Pod가 수신하고,
각 Pod가 자기 로컬에 해당 사용자 세션이 있는지 확인 후 전송합니다. 전달 성공률 100%가 됩니다.
Q. ALB에서 WebSocket 사용 시 주의할 점은?
ALB의 기본 idle timeout이 60초입니다. 대기열 뒤쪽 사용자는 순번 변화가 없어 TCP 레벨에서 데이터가 안 흐를 수 있고, 60초 후 ALB가 연결을 끊습니다. 해결책으로 ALB idle timeout을 300초로 늘리고, STOMP heartbeat를 20초로 설정하여 주기적으로 TCP 프레임을 생성합니다.
'Cloudwave > cgv-project' 카테고리의 다른 글
| CI/CD (0) | 2026.02.18 |
|---|---|
| 메시징 시스템 (0) | 2026.02.16 |
| 3. CI/CD 파이프라인 구축과 자동화 (0) | 2026.02.02 |
| 2. CGV 대기열 시스템 (0) | 2026.02.01 |
| 1. 인프라 설계 (0) | 2026.01.31 |