CGV 대기열 시스템: 대량 트래픽 백엔드 분석
도입
2.1에서 CGV 개발계 인프라를 Terraform으로 구축했다. VPC, Subnet, VPC Endpoint, Client VPN까지 네트워크 기반을 완성했다.
이번 섹션에서는 그 인프라 위에서 동작하는 비즈니스 로직을 분석한다. CGV 프로젝트의 목표는 "인터파크 티켓팅처럼 순간적으로 트래픽이 폭증하는 상황"을 처리하는 것이다. 영화 개봉일에 수만 명이 동시에 예매 버튼을 누르면, 서버가 버티면서도 순번을 공정하게 배정해야 한다.
담당 범위는 Spring Boot 백엔드 개발이다. 대기열 시스템, Kinesis 연동, WebSocket 로직이 dev_backend 레포지토리에 있다. 코드 스택은 Java 17 + Spring Boot 3.3.13이다.
1. 프로젝트 구조
dev_backend 레포지토리 구조다.
dev_backend/
├── src/main/java/com/example/
│ ├── admission/ # 대기열 핵심 로직
│ │ ├── QueueProcessor.java
│ │ ├── KinesisAdmissionProducer.java
│ │ ├── KinesisAdmissionConsumer.java
│ │ ├── SessionTimeoutProcessor.java
│ │ ├── RealtimeStatsBroadcaster.java
│ │ ├── LoadBalancingOptimizer.java
│ │ └── service/
│ │ ├── AdmissionService.java
│ │ ├── DynamicSessionCalculator.java
│ │ └── AdmissionMetricsService.java
│ ├── config/
│ │ ├── AwsConfig.java
│ │ ├── RedisConfig.java
│ │ ├── DataSourceConfig.java
│ │ └── WebSocketConfig.java
│ ├── movie/
│ ├── session/
│ └── websockets/
├── src/main/resources/
│ ├── application.yml
│ └── db/migration/
├── docker-compose.yml # 로컬 개발용 (MySQL, Redis, LocalStack)
├── Dockerfile
├── test.sh # 부하 테스트 스크립트
└── test2.sh
대기열 관련 로직은 admission 패키지에 모여 있다. SQS + Lambda 조합이었다면 같은 로직이 Lambda 함수 여러 개와 콘솔 설정으로 분산됐을 것이다.
2. 왜 대기열이 필요한가
동시 접속자가 폭증하면 세 가지 문제가 발생한다.
첫째, DB 커넥션 고갈이다. 1만 명이 동시에 좌석 조회 API를 호출하면 DB가 버티지 못한다. RDS MySQL의 max_connections는 보통 수백 개 수준이다.
- RDS Proxy를 도입하면 커넥션 풀링/다중화로 이 문제를 완화할 수 있다. 1,000개 애플리케이션 커넥션을 100개 DB 커넥션으로 재사용하는 방식이다. 다만 티켓팅 같은 극단적 트래픽에서는 한계가 있다. 커넥션이 풀링되어도 1만 개 쿼리가 동시에 들어오면 DB CPU/IOPS 자체가 병목이 된다. 대기열은 트래픽 유입 자체를 조절해서 이 문제를 근본에서 다룬다.
둘째, 불공정한 처리다. 누가 먼저 클릭했는지 기록하지 않으면 네트워크 상태가 좋은 사람이 유리해진다. 선착순이 아니라 "운 좋은 순"이 된다.
셋째, 즉각적인 피드백이 필요하다. 10만 명이 동시에 요청을 보낼 때 동기 방식으로 처리하면 대부분의 사용자가 타임아웃을 경험한다. 서버 응답에 수십 초가 걸리면 사용자는 "버튼이 안 눌린다"고 판단하고 새로고침을 반복한다. 이로 인해 트래픽이 더 증가하는 악순환이 발생한다.
대기열은 이 세 문제를 다룬다. 요청을 동기로 처리하지 않고 즉시 대기열에 등록한 뒤 "현재 몇 번째입니다"라는 피드백을 반환한다. 실제 입장 처리는 백그라운드에서 비동기로 진행된다. 사용자는 200ms 이내에 응답을 받고, 시스템은 감당 가능한 속도로 순차 처리한다.
10만 명 트래픽 시 시스템 동작
10만 명 동시 접속 시:
├─ 즉시 처리: 600명 (3 Pod × 200 세션)
│ └─ 좌석 선택 페이지로 바로 진입
└─ 대기열 진입: 99,400명
└─ WebSocket으로 "현재 1,234번째, 전체 99,400명 대기 중" 실시간 표시
처리 프로세스:
1. 좌석 선택 완료 또는 30초 무응답 → 세션 만료
2. 빈자리 발생 → QueueProcessor가 대기열에서 다음 사용자 승격
3. Kinesis → WebSocket → 사용자에게 입장 허가 알림
3. 기술 선택: 왜 Kinesis인가
대기열 시스템의 메시지 브로커로 세 가지 옵션을 검토했다: SQS, Kinesis, MSK(Kafka). Kinesis를 선택했다.
SQS vs Kinesis
처음에는 AWS SQS FIFO + Lambda로 서버리스 아키텍처를 구상했다. 구현을 시도하면서 세 가지 한계에 부딪혔다.
첫째, 실시간 순위 조회가 불가능하다. SQS는 메시지를 저장만 할 뿐 "내가 몇 번째인지"를 알 수 없다. 순위를 확인하려면 별도로 DynamoDB나 Redis를 추가해야 한다. Redis에는 ZRANK라는 O(log N) 순위 조회 명령어가 있지만, SQS 단독으로는 불가능하다.
둘째, 아키텍처가 파편화된다. SQS + Lambda 조합은 이벤트 종류별로 큐와 함수가 늘어난다.
SQS + Lambda 아키텍처
Producer → SQS Queue 1 (입장) → Lambda 1 → WebSocket
Producer → SQS Queue 2 (순위) → Lambda 2 → WebSocket
Producer → SQS Queue 3 (통계) → Lambda 3 → WebSocket
관리 대상: 큐 3개, Lambda 3개, DLQ 3개, IAM 역할 9개, CloudWatch 로그 그룹 3개
셋째, Lambda 15분 제한이 있다. 사용자 대기 시간이 15분을 넘으면 Step Functions을 추가해야 한다. 복잡도가 증가한다.
성능도 비교했다.
| 항목 | SQS | Kinesis |
|---|---|---|
| 순위 조회 | 불가능 (별도 DB 필요) | Redis ZRANK로 O(log N) |
| 데이터 보존 | 최대 14일 | 최대 365일 |
| 재처리 | 불가 (한 번 읽으면 삭제) | 가능 (offset 이동) |
| Fan-out | 복잡 (SNS 필요) | 네이티브 지원 |
| 지연 시간 | 최대 20초 (Long Polling) | 200ms 이내 (GetRecords) |
Fan-out이란? 하나의 메시지를 여러 Consumer가 독립적으로 처리하는 패턴이다. "입장 허가" 이벤트를 WebSocket 알림용, 통계 집계용, 이메일 발송용 Consumer가 각각 처리할 수 있다. SQS는 한 Consumer가 메시지를 가져가면 삭제되지만, Kinesis는 여러 Consumer가 같은 데이터를 독립적으로 읽는다.
Kinesis를 선택하면 순위 조회(Redis)와 이벤트 스트림(Kinesis)을 역할별로 분리할 수 있고, 큐/함수가 이벤트별로 늘어나지 않는다.
MSK vs Kinesis
| 항목 | Amazon MSK | Kinesis Data Streams |
|---|---|---|
| 본질 | Apache Kafka 완전 관리형 | AWS 독자 스트리밍 서비스 |
| 프로토콜 | Kafka 프로토콜 (오픈소스) | AWS SDK 전용 |
| 최소 브로커 | 2개 (다중 AZ) | 샤드 1개부터 가능 |
| 최소 비용 | 월 $200+ (kafka.t3.small × 2) | 월 $13 (샤드 1개) |
| 확장 단위 | 브로커 추가 (수동) | 샤드 추가 (API 한 줄) |
| Consumer 라이브러리 | @KafkaListener (Spring 네이티브) | SDK 직접 구현 또는 KCL |
| 데이터 보존 | 무제한 (설정에 따라) | 최대 365일 |
| 처리량 | 브로커당 수십만 TPS | 샤드당 1,000 TPS (쓰기) |
MSK를 선택했다면 달라졌을 점
// MSK (Kafka) - Spring 네이티브 지원
@KafkaListener(topics = "admissions", groupId = "cgv-consumer")
public void onAdmission(AdmissionEvent event) {
webSocketService.notifyAdmission(event);
}
// Kinesis - SDK 직접 구현 필요
@Scheduled(fixedDelay = 1500)
public void pollKinesis() {
GetRecordsResponse response = kinesisClient.getRecords(...);
for (Record record : response.records()) {
processRecord(record);
}
}
Spring Kafka는 @KafkaListener 하나로 Consumer를 구현할 수 있다. Kinesis는 SDK로 polling 로직을 직접 구현해야 한다. 이 편의성 차이는 MSK 선택을 정당화할 만큼 크지 않았다.
Kinesis를 선택한 근거
- 비용: 프로젝트 초기 월 $13 vs $200+, 약 15배 차이.
- 운영 부담: MSK는 브로커 패치, 디스크 관리, Zookeeper 모니터링이 필요하다. Kinesis는 샤드 수만 관리한다.
- 확장성: MSK 브로커 추가는 수 시간, Kinesis 샤드 추가는 수 분.
- 프로젝트 기간: 3주 안에 완성해야 했고, MSK 학습 + 구축에 약 1주가 필요했다.
MSK가 유리한 조건
- 기존 Kafka 코드/인프라가 있을 때
- 멀티 클라우드/온프레미스 호환이 필요할 때
- 샤드당 1,000 TPS 이상 처리량이 필요할 때
- Kafka Connect, Kafka Streams 생태계가 필요할 때
CGV 대기열은 이 조건에 해당하지 않았다.
4. 전체 아키텍처
CGV 대기열 시스템은 네 가지 컴포넌트로 구성된다. Redis가 상태를 저장하고, QueueProcessor가 주기적으로 빈자리를 채우고, Kinesis가 이벤트를 비동기로 전달하고, WebSocket이 사용자에게 실시간으로 알린다.
flowchart TD
U["사용자 브라우저"] -->|"HTTP: enter/status"| API["Backend API Pod"]
API -->|"상태 저장·조회"| R[("Redis Cluster")]
R --- RW["sessions:{movieId}:waiting (대기열 ZSet)"]
R --- RA["sessions:{movieId}:active (활성 세션 ZSet)"]
QP["QueueProcessor (2초 스케줄)"] -->|"빈자리 확인·승격"| R
QP -->|"입장 이벤트"| KP["KinesisAdmissionProducer"]
KP --> KS[["Kinesis Data Stream"]]
KS --> KC["KinesisAdmissionConsumer (1.5초 polling)"]
KC -->|"알림"| WS["WebSocket (STOMP)"]
WS -->|"실시간 알림"| U
STOMP란? Simple Text Oriented Messaging Protocol의 약자로, WebSocket 위에서 동작하는 메시징 프로토콜이다. 일반 WebSocket은 바이트 스트림만 주고받지만, STOMP는 "어떤 토픽을 구독할지", "어떤 목적지로 메시지를 보낼지" 같은 구조화된 통신이 가능하다. Spring에서 @MessageMapping, @SendTo 같은 어노테이션으로 구현한다.
역할 분리가 핵심 설계다.
- Redis: 상태 저장 (대기열 순위, 활성 세션)
- QueueProcessor: 주기적 처리 (빈자리 체크, 승격)
- Kinesis: 비동기 이벤트 전달 (입장 허가, 순위 업데이트)
- WebSocket: 실시간 사용자 알림
동기/비동기 처리 분리
섹션 2에서 언급한 "즉각적인 피드백" 문제를 이 구조가 어떻게 다루는지 정리한다. 사용자 요청은 Redis 등록 후 즉시 반환되고(동기), 실제 입장 처리는 QueueProcessor가 백그라운드에서 수행한다(비동기).
[동기 처리 - 사용자 요청 시점]
사용자 → /api/admission/enter → Redis에 등록 → 200ms 이내 응답
"1,234번째입니다"
[비동기 처리 - 백그라운드]
QueueProcessor (2초마다)
↓
빈자리 확인 → 승격 대상 선정
↓
KinesisProducer → Kinesis Stream
↓
KinesisConsumer (1.5초마다 polling)
↓
WebSocket → 사용자에게 "입장하세요" 알림
이 분리 덕분에 10만 명이 동시에 요청해도 각 사용자는 200ms 이내에 응답을 받는다. 실제 입장 처리 속도는 시스템 수용량과 무관하게 사용자 응답 지연에 영향을 주지 않는다.
대량 트래픽 처리 패턴
백엔드 코드는 대량 트래픽을 처리하기 위해 네 가지 패턴을 사용한다.
- parallelStream(): 영화별 병렬 처리. 영화 A 대기열과 영화 B 대기열이 독립적으로 처리된다.
- 배치 처리 (PROCESSING_BATCH_SIZE = 100): 한 번에 100명씩 승격시켜 Kinesis 호출 횟수를 줄인다. 1명씩 처리하면 Kinesis
putRecords를 100번 호출해야 하지만, 배치로 묶으면 1번이면 된다. - CompletableFuture.runAsync(): WebSocket 알림을 비동기로 전송한다. 100명에게 알릴 때 동기 방식이면 순차로 100번 전송하지만, 비동기로 병렬 전송한다.
- 동적 세션 계산: Pod 수에 따라 최대 동시 세션 수가 자동으로 조절된다. HPA로 Pod가 늘어나면 수용량도 함께 증가한다.
5. API 엔드포인트
대기열 시스템의 API는 세 영역으로 나뉜다.
사용자용 API
| 메서드 | 경로 | 설명 | 응답 |
|---|---|---|---|
| POST | /api/admission/enter | 대기열 진입 요청 | 200: 즉시 입장, 202: 대기열 진입 |
| GET | /api/admission/status | 현재 상태 조회 | 순위, 대기자 수, 예상 대기 시간 |
| POST | /api/admission/leave | 대기열/세션 이탈 | 200: 이탈 완료 |
@PostMapping("/enter")
public ResponseEntity<?> enter(@RequestBody EnterRequest request) {
AdmissionResult result = admissionService.enter(
"movie", request.getMovieId(), request.getRequestId());
if (result.isAdmitted()) {
return ResponseEntity.ok(new EnterResponse("ADMITTED", 0, 0));
} else {
return ResponseEntity.status(HttpStatus.ACCEPTED)
.body(new EnterResponse("WAITING", result.getRank(), result.getTotalWaiting()));
}
}
입장 결과에 따라 상태 코드를 나눈다. 즉시 입장은 200, 대기열 진입은 202(Accepted)로 응답해 프론트가 두 흐름을 구분한다.
관리자용 API
| 메서드 | 경로 | 설명 |
|---|---|---|
| GET | /api/admin/admission/config | 세션 설정 조회 |
| GET | /api/admin/admission/status | 전체 시스템 상태 |
| GET | /api/admin/admission/status/{movieId} | 영화별 세션 상태 |
모니터링 API
| 메서드 | 경로 | 설명 |
|---|---|---|
| GET | /api/admin/monitoring/metrics | 실시간 메트릭 |
| GET | /api/admin/monitoring/analysis | 성능 분석 및 스케일 권장 |
| GET | /api/admin/monitoring/dashboard | 종합 대시보드 |
API가 호출되면 상태가 Redis에 저장된다. 다음으로 Redis 데이터 구조를 본다.
6. Redis 데이터 구조
대기열 시스템에서 Redis는 두 가지 개념(Sorted Set, Hash Tag)을 사용한다.
Sorted Set (ZSet)이란?
각 멤버에 Score(점수)가 붙는 자료구조다. Score 기준으로 자동 정렬되며, 순위 조회(ZRANK)가 O(log N)이다. 대기열처럼 "누가 몇 번째인지" 조회해야 하는 시스템에 적합하다.
ZADD waiting 1000 "user1" # user1을 score 1000으로 추가
ZADD waiting 2000 "user2" # user2를 score 2000으로 추가
ZRANK waiting "user2" # user2의 순위 → 1 (0부터 시작)
- sessions:{movieId}:waiting (ZSet): 대기열. Score는 타임스탬프로, 먼저 들어온 사람이 낮은 Score를 가진다.
ZRANK로 O(log N) 시간에 순위를 조회한다. - sessions:{movieId}:active (ZSet): 활성 세션. 현재 좌석 선택 페이지에 있는 사용자들이다. Score는 입장 시간으로, 타임아웃 체크에 사용한다.
// AdmissionService.java - 실제 키 생성 메서드
private String waitingQueueKey(String type, String id) {
return "sessions:{" + id + "}:waiting";
}
private String activeSessionsKey(String type, String id) {
return "sessions:{" + id + "}:active";
}
Hash Tag란?
Redis Cluster는 키를 16,384개 슬롯에 분산 저장한다. 기본적으로 키 전체를 해시해서 슬롯을 결정하는데, 중괄호 {}로 감싼 부분만 해시하도록 지정할 수 있다. 이것이 Hash Tag다.
sessions:{topgun2}:waiting → "topgun2"만 해시 → 슬롯 A
sessions:{topgun2}:active → "topgun2"만 해시 → 슬롯 A (같은 슬롯)
같은 영화(id)의 대기열과 활성 세션이 같은 슬롯에 저장된다. 두 키를 동시에 조작하는 Lua Script는 키들이 같은 슬롯에 있어야만 실행되므로, Hash Tag가 이 전제를 만든다. (섹션 11 CROSSSLOT 트러블슈팅 참고)
Redis Cluster 설정
운영 환경 Redis Cluster 설정이다.
- Connection Pool: 20개 (동시 연결 제한)
- SSL: 활성화 (전송 암호화)
- Timeout: 5초 (연결 타임아웃)
- Hash Tag: 적용 (CROSSSLOT 해결)
7. 핵심 컴포넌트 분석
컴포넌트들이 사용자 요청부터 입장까지 어떻게 협력하는지 흐름을 먼저 정리한다.
[사용자 요청 → 입장까지 전체 흐름]
1. 사용자가 예매 요청
→ AdmissionService.enter()
→ Redis Lua Script로 빈자리 확인
→ 빈자리 있으면 즉시 입장 (active에 추가)
→ 빈자리 없으면 대기열 등록 (waiting에 추가)
2. DynamicSessionCalculator가 Pod 상태 계산
→ 현재 Pod 수 × Pod당 세션 수 = 최대 동시 세션
→ AdmissionService에 전달
3. QueueProcessor가 2초마다 실행
→ 빈자리 확인 (active < 최대)
→ 대기열에서 다음 사용자 승격
→ KinesisAdmissionProducer로 이벤트 발행
4. KinesisAdmissionConsumer가 1.5초마다 polling
→ Kinesis에서 이벤트 수신
→ WebSocketUpdateService로 알림 전송
5. SessionTimeoutProcessor가 10초마다 실행
→ 타임아웃된 세션 정리
→ 빈자리 발생 → 3번으로
6. RealtimeStatsBroadcaster가 주기적 업데이트
→ 1초: 전체 통계 브로드캐스트
→ 2초: 변경된 순위만 업데이트
→ 5초: 강제 동기화
7. LoadBalancingOptimizer가 Pod 간 작업 분배
→ 어떤 Pod가 어떤 영화 대기열을 담당할지 결정
같은 흐름을 시퀀스로 보면 동기 응답과 비동기 승격이 분리된 구조가 드러난다.
sequenceDiagram
participant U as 사용자
participant API as AdmissionService
participant R as Redis
participant QP as QueueProcessor
participant K as Kinesis
participant C as KinesisConsumer
participant WS as WebSocket
U->>API: enter(movieId, requestId)
API->>R: Lua 조회→판단→저장 (원자적)
alt 빈자리 있음
R-->>API: active 추가 (ADMITTED)
API-->>U: 200 ADMITTED (좌석 페이지)
else 빈자리 없음
R-->>API: waiting 추가 + ZRANK
API-->>U: 202 WAITING (순위 N)
end
Note over QP: 2초마다 (비동기)
QP->>R: 빈자리 확인 + admitNextUsers (Lua)
R-->>QP: 승격된 사용자 목록
QP->>K: ADMIT 이벤트 발행 (배치 100)
C->>K: getRecords (1.5초)
K-->>C: ADMIT 레코드
C->>WS: notifyAdmission(requestId)
WS-->>U: "입장하세요"
AdmissionService: 입장 처리
사용자가 예매 요청을 보내면 이 메서드가 호출된다. 활성 세션(active)에 빈자리가 있으면 즉시 입장, 없으면 대기열(waiting)에 등록한다.
public EnterResponse enter(String type, String id, String sessionId, String requestId) {
String activeKey = activeSessionsKey(type, id); // sessions:{movieId}:active
String waitingKey = waitingQueueKey(type, id); // sessions:{movieId}:waiting
long maxSessions = sessionCalculator.calculateMaxActiveSessions();
String luaScript = """
local activeCount = redis.call('ZCARD', KEYS[1]) -- 1. 조회: 현재 몇 명?
if activeCount < tonumber(ARGV[1]) then -- 2. 판단: 빈자리 있나?
redis.call('ZADD', KEYS[1], ARGV[3], ARGV[2]) -- 3. 저장: 활성 세션에 추가
return {1, 'SUCCESS', activeCount + 1}
else
redis.call('ZADD', KEYS[2], ARGV[3], ARGV[2]) -- 3. 저장: 대기열에 추가
local rank = redis.call('ZRANK', KEYS[2], ARGV[2])
return {2, 'QUEUED', rank + 1, redis.call('ZCARD', KEYS[2])}
end
""";
}
왜 Lua Script인가?
Redis는 데이터 저장소다. 문제는 "조회 → 판단 → 저장"을 따로 실행하면 중간에 다른 요청이 끼어든다는 것이다.
[문제: 최대 100명, 현재 99명 상황]
요청 A: ZCARD → 99명 (조회)
요청 B: ZCARD → 99명 (조회) ← A가 저장하기 전에 끼어듦
요청 A: ZADD → 100명 (저장)
요청 B: ZADD → 101명 (저장) ← 둘 다 "빈자리 있다"고 봤으니 초과!
[해결: Lua Script]
요청 A: 조회→판단→저장 (한 번에 실행, 중간에 끼어들 수 없음)
요청 B: (A 완료 후 실행) → 100명 확인 → 대기열로
Redis는 Lua Script를 단일 스레드에서 하나의 원자적 연산으로 실행한다. 스크립트가 끝나기 전에는 다른 명령이 끼어들 수 없으므로, 동시에 1,000명이 요청해도 한 명씩 순차 판정된다. 위 코드에서 maxSessions는 DynamicSessionCalculator가 계산한다.
admitNextUsers: 원자적 승격
QueueProcessor가 빈자리를 채울 때 호출하는 승격 로직도 같은 이유로 Lua Script다. 대기열 상위 N명을 조회(ZRANGE)한 뒤 대기열에서 제거(ZREM)하고 활성 세션에 추가(ZADD)하는 세 단계를 한 스크립트로 묶는다.
local waitingKey = KEYS[1]
local activeKey = KEYS[2]
local count = tonumber(ARGV[1])
local now = tonumber(ARGV[2])
local waitingUsers = redis.call('ZRANGE', waitingKey, 0, count - 1) -- 1. 상위 N명 조회
local admitted = {}
for i = 1, #waitingUsers do
local user = waitingUsers[i]
redis.call('ZREM', waitingKey, user) -- 2. 대기열에서 제거
redis.call('ZADD', activeKey, now, user) -- 3. 활성 세션으로 이동
table.insert(admitted, user)
end
return admitted
원자성이 중요한 이유는 조회와 이동 사이에 틈이 생기면 안 되기 때문이다. ZRANGE로 상위 N명을 읽고 ZREM으로 지우기까지가 원자적이지 않으면, 같은 사용자가 두 번 승격되거나(중복 입장) 대기열과 활성 세션 어디에도 없는 순간이 생길 수 있다. 하나의 Lua Script로 묶으면 승격된 사용자는 waiting에서 제거되는 동시에 active에 추가되어 상태 불일치 구간이 존재하지 않는다. 빈자리 계산은 다음처럼 최대 세션에서 현재 활성 수를 뺀 값이다.
public long getVacantSlots(String type, String id) {
long maxSessions = sessionCalculator.calculateMaxActiveSessions();
long currentSessions = getTotalActiveCount(type, id);
return Math.max(0, maxSessions - currentSessions);
}
DynamicSessionCalculator: 동적 세션 계산
최대 활성 세션 수는 고정값이 아니라 Pod 수에 따라 동적으로 계산한다.
@Value("${BASE_SESSIONS_PER_POD:200}")
private int baseSessionsPerPod;
@Value("${MAX_TOTAL_SESSIONS:1000}")
private int maxTotalSessionsLimit;
public long calculateMaxActiveSessions() {
int podCount = getCurrentPodCount(); // K8s API로 조회
long calculated = (long) podCount * baseSessionsPerPod;
return Math.min(calculated, maxTotalSessionsLimit); // 상한선 적용
}
Pod 2개 × 200세션 = 400명이 동시에 좌석 선택 페이지에 있을 수 있다. 상한선(1000)을 두어 Pod가 급격히 늘어나도 시스템 과부하를 방지한다. HPA로 Pod가 늘어나면 수용량도 자동으로 증가한다.
처리량 계산 공식
세션 타임아웃(30초)을 기준으로 처리량을 계산한다.
Pod당 분당 처리량 = 세션 수 × (60초 / 타임아웃)
= 200 × (60 / 30)
= 분당 400명
전체 시스템 처리량:
├─ 최소 (2 Pods): 2 × 400 = 분당 800명
├─ 기본 (3 Pods): 3 × 400 = 분당 1,200명
└─ 최대 (5 Pods): 5 × 400 = 분당 2,000명
HPA 확장 시작점을 200으로 설정하면, 대기열 200번째 사용자의 예상 대기 시간은 약 30초다. "대기 시간이 30초를 넘으면 시스템을 확장한다"는 규칙이 된다.
PodDiscoveryService: Kubernetes API로 Pod 수 조회
DynamicSessionCalculator가 사용하는 getCurrentPodCount()는 PodDiscoveryService가 Kubernetes API를 호출해 구한다.
@Service
public class PodDiscoveryService {
private CoreV1Api coreV1Api;
private boolean isKubernetesClientAvailable = false;
@PostConstruct
public void init() {
try {
ApiClient client = ClientBuilder.standard().build();
Configuration.setDefaultApiClient(client);
this.coreV1Api = new CoreV1Api();
this.isKubernetesClientAvailable = true;
} catch (IOException e) {
// Kubernetes 외부(로컬)에서는 Fallback 모드
this.isKubernetesClientAvailable = false;
}
}
public int getPodCount() {
if (!isKubernetesClientAvailable) {
return 0; // DynamicSessionCalculator가 fallbackPodCount 사용
}
V1PodList list = coreV1Api.listNamespacedPod(namespace)
.labelSelector(appLabel)
.execute();
return list.getItems().size();
}
}
EKS 클러스터 내부에서 실행되면 ServiceAccount 토큰을 자동으로 읽어 Kubernetes API에 접근한다. 로컬 개발 환경에서는 fallbackPodCount(기본값 2)를 사용한다.
RBAC: Pod 조회 권한이 필요한 이유
PodDiscoveryService가 Kubernetes API를 호출하려면 RBAC 권한이 필요하다. ServiceAccount 토큰만으로는 "인증"만 되고 "인가"는 별도다.
[인증 vs 인가]
ServiceAccount 토큰 → "나는 cgv-api다" (인증)
RBAC Role/RoleBinding → "cgv-api는 pods 조회 가능" (인가)
RBAC 없이 API를 호출하면 403 Forbidden이 발생한다.
io.kubernetes.client.openapi.ApiException: Forbidden
User "system:serviceaccount:cgv-api:cgv-api" cannot list resource "pods" in namespace "cgv-api"
IRSA와 RBAC의 차이:
| 구분 | IRSA | RBAC |
|---|---|---|
| 대상 | AWS 서비스 (Kinesis, ECR, RDS) | Kubernetes API (Pod, Service 등) |
| 설정 위치 | IAM Role + ServiceAccount 어노테이션 | Role + RoleBinding |
| 용도 | Pod → AWS 리소스 접근 | Pod → K8s API 접근 |
CGV 백엔드는 둘 다 필요하다. Kinesis 접근에는 IRSA, Pod 개수 조회에는 RBAC. RBAC의 Helm Chart 구현은 "3. CI/CD 파이프라인 구축과 자동화"에서 다룬다.
QueueProcessor: 대기열 처리 (2초마다 100명)
2초마다 실행되는 스케줄러다. 빈자리가 생기면 대기열에서 다음 사용자를 승격시킨다.
왜 2초 간격인가? 너무 짧으면 Redis 부하가 증가하고, 너무 길면 사용자 대기 시간이 늘어난다. 2초는 "빈자리 발생 후 최대 2초 내 승격"을 보장하면서 Redis 부하를 적정 수준으로 유지하는 균형점이다.
왜 100명 배치인가? 1명씩 처리하면 Kinesis 호출이 너무 많고, 1000명씩 처리하면 승격 알림이 한꺼번에 몰린다. 100명은 Kinesis putRecords 한 번 호출로 처리 가능한 최대치(500개)보다 작으면서 WebSocket 알림 부하를 분산하는 크기다.
@Component
public class QueueProcessor {
private static final int PROCESSING_BATCH_SIZE = 100; // 한 번에 최대 100명 처리
private static final long PROCESSING_INTERVAL = 2000; // 2초마다 실행
@Scheduled(fixedDelay = PROCESSING_INTERVAL)
public void processAllQueues() {
List<String> activeMovieIds = getActiveMovieIds();
activeMovieIds.parallelStream() // 영화별 병렬 처리
.forEach(movieId -> processMovieQueue("movie", movieId));
}
private void processMovieQueue(String type, String movieId) {
long vacantSlots = admissionService.getVacantSlots(type, movieId);
long waitingCount = admissionService.getTotalWaitingCount(type, movieId);
if (vacantSlots > 0 && waitingCount > 0) {
long admitCount = Math.min(vacantSlots,
Math.min(waitingCount, PROCESSING_BATCH_SIZE));
List<String> admittedUsers = admissionService.admitNextUsers(type, movieId, admitCount);
// Kinesis에 입장 이벤트 발행
}
}
}
admitCount는 빈자리 수, 대기자 수, 배치 크기 중 최솟값이다. 빈자리보다 많이 승격하거나 대기자보다 많이 꺼내는 일이 없다. parallelStream()으로 영화별 병렬 처리를 하며, 영화 A 대기열과 영화 B 대기열이 독립적으로 처리된다. 승격이 결정되면 KinesisAdmissionProducer를 통해 Kinesis에 이벤트를 발행한다.
KinesisAdmissionProducer: 이벤트 발행
QueueProcessor가 대기열(waiting)에서 사용자를 활성 세션(active)으로 승격시키면, Producer가 "입장 허가" 이벤트를 Kinesis에 발행한다.
private void sendToKinesis(List<PutRecordsRequestEntry> records, String eventType) {
int maxRetries = 3;
int retryCount = 0;
List<PutRecordsRequestEntry> recordsToSend = new ArrayList<>(records);
while (retryCount < maxRetries && !recordsToSend.isEmpty()) {
try {
PutRecordsResponse response = kinesisClient.putRecords(
PutRecordsRequest.builder()
.streamName(streamName)
.records(recordsToSend)
.build());
// 부분 실패 시 실패한 레코드만 재시도
if (response.failedRecordCount() > 0) {
recordsToSend = getFailedRecords(recordsToSend, response);
retryCount++;
// 지수 백오프: 100ms → 200ms → 400ms (최대 2초)
long waitTime = Math.min(100L * (1L << retryCount), 2000L);
Thread.sleep(waitTime);
continue;
}
return; // 전체 성공
} catch (ProvisionedThroughputExceededException e) {
// 처리량 초과 시 더 긴 대기 후 재시도
long waitTime = Math.min(500L * retryCount, 3000L);
Thread.sleep(waitTime);
retryCount++;
}
}
}
putRecords()는 부분 실패를 반환할 수 있다. 100개 중 5개만 실패하면 5개만 재시도한다. 지수 백오프로 Kinesis 과부하를 방지한다. Kinesis 내부 동작 원리(샤드, partitionKey, 처리량)는 섹션 8에서 다룬다.
KinesisAdmissionConsumer: 이벤트 수신
Producer가 발행한 이벤트를 Consumer가 polling해서 가져온다. 샤드별로 별도 스레드가 1.5초 간격으로 polling한다.
private void processRecord(Record record) {
String data = StandardCharsets.UTF_8.decode(record.data().asByteBuffer()).toString();
JsonNode eventNode = objectMapper.readTree(data);
String eventType = eventNode.path("action").asText();
switch (eventType) {
case "ADMIT":
webSocketService.notifyAdmission(
eventNode.path("requestId").asText(),
eventNode.path("movieId").asText());
break;
case "RANK_UPDATE":
webSocketService.notifyRankUpdate(
eventNode.path("requestId").asText(),
"WAITING",
eventNode.path("rank").asLong(),
eventNode.path("totalWaiting").asLong());
break;
case "STATS_UPDATE":
// 전체 대기열 통계 브로드캐스트
break;
}
}
단일 스트림에서 이벤트 타입별로 분기 처리한다. SQS였다면 큐가 3개 필요했을 로직이다. 이벤트를 수신한 Consumer는 WebSocket을 통해 프론트에 알림을 보낸다. 여러 Pod가 있을 때 각 Pod가 담당할 샤드를 분배하는 로직도 구현했다. 샤드 분배와 Hot Shard 트러블슈팅은 섹션 8에서 다룬다.
Kinesis 이벤트 흐름
QueueProcessor (빈자리 확인, 승격 결정)
↓
KinesisAdmissionProducer (Kinesis에 "입장 허가" 이벤트 발행)
↓
Kinesis (샤드에 저장, 기본 24시간 보존)
↓
KinesisAdmissionConsumer (1.5초마다 polling, 이벤트 수신)
↓
WebSocket (사용자에게 "입장하세요" 알림)
SessionTimeoutProcessor: 만료 세션 정리
10초마다 실행되어 타임아웃된 활성 세션을 정리한다.
@Scheduled(fixedDelayString = "${sessionCleanupInterval:10000}")
public void processExpiredSessions() {
Set<String> movieIds = admissionService.getActiveQueueMovieIds();
for (String movieId : movieIds) {
if (loadBalancer.shouldProcessMovie(movieId)) {
Set<String> expiredMembers = admissionService.findExpiredActiveSessions("movie", movieId);
if (!expiredMembers.isEmpty()) {
admissionService.removeActiveSessions("movie", movieId, expiredMembers);
for (String member : expiredMembers) {
String requestId = member.split(":")[0];
webSocketUpdateService.notifyTimeout(requestId);
metricsService.recordTimeout(movieId, 1);
}
}
}
}
}
좌석 선택 페이지에서 30초(설정값) 이상 아무 동작이 없으면 세션을 만료시킨다. 만료 판정은 활성 세션 ZSet에서 Score(입장 시간)가 임계값보다 오래된 멤버를 골라내는 방식이다.
public Set<String> findExpiredActiveSessions(String type, String id) {
String key = activeSessionsKey(type, id);
long expirationThreshold = System.currentTimeMillis() - (sessionTimeoutSeconds * 1000);
return zSetOps.rangeByScore(key, 0, expirationThreshold);
}
ZRANGEBYSCORE(0, now - timeout)로 임계 타임스탬프 이전의 멤버를 만료 대상으로 뽑는다. 빈자리가 생기면 다음 주기의 QueueProcessor가 대기열에서 다음 사용자를 승격시킨다.
RealtimeStatsBroadcaster: 실시간 순위 업데이트
세 가지 주기로 순위와 통계를 브로드캐스트한다.
@Scheduled(fixedRate = 1000) // 1초마다 전체 통계
public void broadcastRealtimeStats() {
for (String movieId : admissionService.getActiveQueueMovieIds()) {
long totalWaiting = admissionService.getTotalWaitingCount("movie", movieId);
if (totalWaiting > 0) {
webSocketUpdateService.broadcastQueueStats(movieId, totalWaiting);
}
}
}
@Scheduled(fixedRate = 2000) // 2초마다 개별 순위 (변경분만)
public void updateIndividualRanks() {
// 이전 순위와 비교해서 변경된 사용자만 업데이트
}
@Scheduled(fixedRate = 5000) // 5초마다 강제 전체 동기화
public void forceRankSync() {
// WebSocket 재연결 후에도 정확한 순위 보장
}
1초마다 전체 통계를 보내 "현재 대기자 수"가 실시간으로 보이게 한다. 2초마다 개별 순위를 보내되 변경된 사용자만 업데이트해서 트래픽을 줄인다. 5초마다 강제 동기화로 WebSocket 재연결 후에도 정확한 순위를 보장한다.
LoadBalancingOptimizer: Pod 간 부하 분산
여러 Pod가 같은 영화 대기열을 중복 처리하지 않도록 조율한다.
@Scheduled(fixedDelay = 30000) // 30초마다 생존 신호
public void registerPod() {
String key = "load_balancer:active_pods";
redisTemplate.opsForZSet().add(key, podId, System.currentTimeMillis());
}
@Scheduled(fixedDelay = 60000) // 1분마다 만료 Pod 정리
public void cleanupExpiredPods() {
long threeMinutesAgo = System.currentTimeMillis() - (3 * 60 * 1000);
redisTemplate.opsForZSet().removeRangeByScore(key, 0, threeMinutesAgo);
}
private boolean shouldProcessRoundRobin(String movieId) {
List<String> activePods = getActivePods();
int movieHash = Math.abs(movieId.hashCode());
int assignedPodIndex = movieHash % activePods.size();
return podId.equals(activePods.get(assignedPodIndex));
}
ROUND_ROBIN 전략에서 movieId 해시값으로 담당 Pod를 결정한다. Pod 3개, 영화 5개면 각 Pod가 1–2개 영화를 분담한다. Pod가 죽으면 3분 후 목록에서 제거되고 나머지 Pod가 작업을 인계받는다. 이 조율 덕분에 QueueProcessor의 영화별 처리가 Pod 간에 중복되지 않는다.
8. Kinesis 상세 동작
섹션 3에서 Kinesis 선택 이유를, 섹션 7에서 Producer/Consumer 코드를 다뤘다. 여기서는 Kinesis의 내부 동작 원리와 대량 트래픽 처리 방식을 본다.
샤드(Shard)란?
샤드는 Kinesis 스트림의 기본 처리 단위다. 고속도로의 차선에 비유할 수 있다.
Kinesis 스트림 = 고속도로
샤드 = 차선 (샤드 2개면 2차선)
레코드 = 차량
partitionKey = 차량 번호판 → 어느 차선으로 갈지 결정
스트림의 총 용량은 샤드 용량의 합이다. 샤드 2개면 쓰기 2,000건/초, 읽기 10 TPS가 된다.
샤드 처리량 제한
| 항목 | 제한 | 설명 |
|---|---|---|
| 쓰기 (Producer) | 1MB/초 또는 1,000건/초 | 둘 중 먼저 도달하는 쪽 |
| 읽기 (Consumer) | 2MB/초 또는 5 TPS | GetRecords API 호출 횟수 |
| 레코드 크기 | 최대 1MB | Base64 인코딩 포함 |
| 보존 기간 | 기본 24시간, 최대 365일 | 프로비저닝 모드에서 설정 가능 |
읽기 "5 TPS"는 레코드 5개가 아니라 API 호출 5회다. 한 번 호출에 최대 10,000개 레코드를 가져올 수 있다. 문제는 여러 Pod가 같은 샤드에 호출할 때 발생한다.
Shard Iterator
Consumer는 Shard Iterator로 "어디부터 읽을지"를 추적한다.
┌─────────────────────────────────────────┐
│ Shard (시간순 레코드) │
│ [A] [B] [C] [D] [E] [F] [G] ... │
│ ▲ │
│ 현재 Iterator │
└─────────────────────────────────────────┘
getRecords(iterator) → [C, D, E] 반환 + nextIterator (F 위치)
Iterator 타입:
- TRIM_HORIZON: 가장 오래된 데이터부터 (재처리용)
- LATEST: 지금부터 새 데이터만 (실시간용) — CGV 사용
- AT_TIMESTAMP: 특정 시점부터 (장애 복구용)
SDK / KPL / KCL
- SDK: 기본 AWS SDK. 직접
putRecords/getRecords호출. CGV에서 사용. - KPL: Producer용. 배치 처리, 재시도 자동화.
- KCL: Consumer용. 샤드 분배, 체크포인트를 DynamoDB에 저장.
CGV에서는 KCL 대신 직접 샤드 분배 로직을 구현했다. Pod가 빈번하게 스케일링되는 EKS 환경에서 DynamoDB 체크포인트 오버헤드를 피하기 위해서다. (이 선택의 한계와 KCL 전환 방향은 섹션 14.1에서 다룬다.)
샤드 분배 로직
Consumer는 시작 시 DescribeStreamRequest로 스트림의 활성 샤드 목록을 조회한 뒤, LoadBalancingOptimizer가 관리하는 활성 Pod 목록에서 자기 순번(myIndex)을 구해 라운드로빈 stride로 샤드를 나눠 가진다.
// 자기 순번부터 Pod 수만큼 건너뛰며 샤드 할당
for (int i = myIndex; i < allShards.size(); i += activePods.size()) {
assignShard(allShards.get(i));
}
[Pod 3개, 샤드 9개일 때 stride 분배]
Pod 0 (myIndex=0): 샤드 0, 3, 6
Pod 1 (myIndex=1): 샤드 1, 4, 7
Pod 2 (myIndex=2): 샤드 2, 5, 8
이렇게 하면 각 샤드를 정확히 한 Pod가 담당해 중복 소비를 막는다. Pod가 활성 목록에 없으면 첫 샤드를 담당하는 fallback으로 동작한다. 단, 이 방식은 Pod가 재시작되면 할당 상태가 메모리에서 사라지므로, 처리 위치를 저장하는 체크포인트가 없다는 한계가 있다(섹션 14.1).
트러블슈팅: Hot Shard (읽기 TPS 초과)
문제: 초기에 1개 샤드로 운영했더니 ProvisionedThroughputExceededException이 발생했다.
[원인 분석]
KinesisAdmissionConsumer.java:
long pollInterval = 1500; // 1.5초마다 getRecords() 호출
→ Pod 1개당 초당 0.67회 (1 ÷ 1.5 = 0.67)
[샤드 1개 - 병목]
[샤드 0] ← Pod 1~10 (각 0.67 TPS)
합계: 10 × 0.67 = 6.7 TPS > 5 TPS (제한)
→ ProvisionedThroughputExceededException!
[샤드 2개 - 해결]
[샤드 0] ← Pod 1~5 → 3.35 TPS < 5 TPS
[샤드 1] ← Pod 6~10 → 3.35 TPS < 5 TPS
샤드 계산 공식:
필요 샤드 = Pod 수 × 0.67 / 5
= 10 × 0.67 / 5 = 1.3 → 2개
해결: 샤드 1개 → 2개 증설.
Consumer 쪽에는 예외 처리도 함께 두었다. ProvisionedThroughputExceededException 발생 시 지수 백오프(최대 5초)로 재시도하고, Iterator 만료 시 LATEST로 자동 갱신하며, nextShardIterator가 null이면 샤드가 닫힌 것으로 판단해 해당 스레드를 정리한다. 참고로 Kinesis는 한 번에 2배까지만 증설 가능하다. 1→4는 안 되고 1→2→4로 단계별로 해야 한다.
온디맨드 vs 프로비저닝
| 항목 | 온디맨드 | 프로비저닝 |
|---|---|---|
| 샤드 관리 | AWS 자동 (15분 지연) | 직접 설정 |
| 비용 예측 | 어려움 | 쉬움 ($0.015/샤드/시간) |
| 적합한 경우 | 트래픽 예측 불가 | 실시간 서비스 (권장) |
대기열처럼 예측 가능한 성능이 필요하면 프로비저닝이 적합하다. 온디맨드는 트래픽 급증 시 15분 지연이 있다.
9. 실시간 알림 시스템
WebSocket 토픽 구조
사용자별, 영화별로 토픽을 분리했다.
/topic/admission/{requestId} - 입장 허가 알림 (개인)
/topic/rank/{requestId} - 순위 업데이트 (개인)
/topic/stats/movie/{movieId} - 전체 대기열 통계 (브로드캐스트)
/topic/timeout/{requestId} - 세션 타임아웃 알림 (개인)
토픽을 분리한 이유는 부하다. 10만 명에게 전부 브로드캐스트하면 네트워크 부하가 커진다. requestId별로 토픽을 나누면 해당 사용자에게만 메시지가 전달된다. 반면 대기자 수 같은 공용 정보는 movieId 토픽으로 한 번에 브로드캐스트한다.
WebSocketUpdateService: 알림 전송
// 입장 허가 알림
template.convertAndSend("/topic/admission/" + requestId, Map.of(
"status", "ADMITTED",
"action", "REDIRECT_TO_SEATS"
));
// 순위 업데이트 알림
template.convertAndSend("/topic/rank/" + requestId, Map.of(
"rank", rank,
"totalWaiting", totalWaiting
));
SimpMessagingTemplate.convertAndSend()로 특정 토픽에 JSON 메시지를 전송한다. 프론트엔드는 해당 토픽을 구독하고 있다가 메시지를 받으면 UI를 업데이트한다.
WebSocketConfig: 고부하 대응 설정
10,000명 동시 접속을 위해 WebSocket 설정을 조정했다.
| 설정 | 값 | 이유 |
|---|---|---|
| heartbeat | 20초 | 연결 상태 확인, 너무 짧으면 부하 증가 |
| corePoolSize(스레드 풀) | 20 | 동시 메시지 처리 스레드 |
| maxPoolSize(스레드 풀) | 100 | 피크 시 확장 가능한 최대 스레드 |
| sendBufferSizeLimit | 2MB | 대량 메시지 전송 버퍼 |
| disconnectDelay | 30초 | 일시적 끊김 시 재연결 여유 |
스레드 풀의 역할을 구분해야 한다. 연결 유지와 메시지 처리는 다른 자원이다.
[WebSocket 연결과 스레드 풀은 다르다]
연결 관리: NIO (Non-blocking I/O)
- 1개 스레드가 수천 개 연결을 유지할 수 있음
- 10,000명 연결 ≠ 10,000개 스레드 필요
스레드 풀 (corePoolSize/maxPoolSize): 메시지 처리 워커
- 인바운드 메시지를 큐에서 꺼내 처리하는 워커 수
- 워커 20개 → 동시에 20개 메시지 처리 가능
[알림 전송 흐름]
1. convertAndSend() 호출 → 메시지가 큐에 쌓임
2. 워커 스레드가 큐에서 메시지를 꺼내 전송
3. 전송은 비동기 + NIO → 워커 1개가 빠르게 여러 연결에 전송
예: 10,000명에게 순위 업데이트 전송
- 메시지 10,000개가 큐에 쌓임
- 워커 100개가 동시에 처리 → 각 워커가 100개씩 처리
- NIO 덕분에 빠르게 완료 (수백 ms 이내)
기본값(CPU 코어 수 = 2–4개)으로는 피크 시 큐에 메시지가 쌓여 지연이 발생한다. maxPoolSize 100으로 설정해서 대량 알림 시 병목을 줄였다.
RealtimeStatsBroadcaster: 주기적 업데이트
섹션 7의 RealtimeStatsBroadcaster가 세 스케줄러로 실시간 정보를 갱신한다.
- 1초마다: 전체 통계 브로드캐스트
- 2초마다: 변경된 순위만 개인 알림 (캐시 활용)
- 5초마다: 강제 동기화 (누락된 사용자 대비)
캐시(previousRanks)를 활용해 변경되지 않은 사용자에게는 메시지를 보내지 않아 네트워크 부하를 줄인다.
AdmissionMetricsService: 시스템 모니터링
10초마다 메트릭을 수집하고 1분마다 처리량을 기록한다. 히스토리 데이터(최근 100개)를 유지해 추세를 분석한다.
- utilization > 80% + 대기열 증가 → 스케일업 권장
- utilization < 30% + 대기열 감소 → 스케일다운 권장
이 정보는 /api/admission/metrics API로 조회할 수 있어 운영자가 시스템 상태를 확인한다.
3중 안전장치: WebSocket 장애 대응
WebSocket이 끊기면 사용자는 자신의 순위를 알 수 없다. 알림 경로를 3중으로 두어 한 경로가 실패해도 다른 경로로 전달되게 했다.
flowchart TD
A["AdmissionService"] -->|"Primary: WebSocket 직접 전송 (즉시)"| WS["사용자 브라우저"]
A -->|"Secondary"| K["Kinesis 발행"]
K --> C["Consumer"] -->|"WebSocket 전송 (안정성)"| WS
WS -. "WebSocket 끊김 (35초 무응답)" .-> P["API 폴링 /api/admission/status (3초 간격)"]
P -->|"Fallback"| WS
Primary: AdmissionService → WebSocket 직접 전송 (즉시)
Secondary: Kinesis 발행 → Consumer → WebSocket 전송 (안정성)
Fallback: API 폴링 (/api/admission/status, 3초 간격)
WebSocket Heartbeat는 20초 간격으로 체크한다. 35초 이상 응답이 없으면 자동으로 API 폴링으로 전환한다. STOMP Client는 reconnectDelay: 2000ms, maxReconnectAttempts: 30으로 자동 재연결을 시도한다. 이 구조에서 Kinesis Consumer 장애 시에도 Direct WebSocket으로 알림이 전달되고, WebSocket 자체가 끊겨도 API 폴링으로 순위를 확인할 수 있다.
10. AWS 설정: IRSA와 LocalStack
IRSA란?
IAM Roles for Service Accounts의 약자다. EKS에서 Pod가 AWS 서비스(Kinesis, S3 등)에 접근할 때 인증하는 방법이다.
[기존 EC2 방식]
EC2 인스턴스 1개 = IAM Role 1개
→ 같은 EC2의 모든 컨테이너가 같은 권한
[IRSA 방식]
Pod별로 ServiceAccount 지정 → IAM Role 연결
→ Pod별로 다른 AWS 권한 부여 가능
AwsConfig: 환경별 Kinesis 클라이언트
// 운영환경: IRSA로 자동 인증
@Bean
@Profile("!local")
public KinesisClient kinesisClient() {
return KinesisClient.builder()
.credentialsProvider(DefaultCredentialsProvider.create()) // IRSA 자동 감지
.build();
}
// 로컬환경: LocalStack 에뮬레이션
@Bean
@Profile("local")
public KinesisClient localKinesisClient() {
return KinesisClient.builder()
.endpointOverride(URI.create("http://localstack:4566"))
.credentialsProvider(StaticCredentialsProvider.create(
AwsBasicCredentials.create("test", "test")))
.build();
}
IRSA 동작 원리: EKS Pod가 시작되면 AWS가 AWS_ROLE_ARN, AWS_WEB_IDENTITY_TOKEN_FILE 환경 변수를 주입한다. DefaultCredentialsProvider가 이를 감지해 IAM 역할을 Assume한다. 주의: spring-cloud-aws-starter 의존성이 없으면 IRSA 환경 변수를 인식하지 못해 AccessDeniedException이 발생한다.
DataSourceConfig: Aurora Writer/Reader 분리
읽기 트래픽을 Reader로 분산해 Writer 부담을 줄인다.
// 라우팅 로직
// @Transactional(readOnly = true) → Reader 엔드포인트
// @Transactional → Writer 엔드포인트
RoutingDataSource.determineCurrentLookupKey():
return TransactionSynchronizationManager.isCurrentTransactionReadOnly()
? "read" : "write";
영화 목록 조회 같은 읽기 API는 Reader를, 예약 생성 같은 쓰기 API는 Writer를 사용한다.
HealthCheckService: Kubernetes Probe
[Liveness Probe]
역할: Pod가 살아있는지 확인 (데드락 감지)
실패 시: Pod 재시작
경로: /health/live
[Readiness Probe]
역할: DB 연결까지 확인 (트래픽 받을 준비 확인)
실패 시: 트래픽 차단 (Service에서 제외)
경로: /health/ready
검사: Writer DB + Reader DB 둘 다 연결 확인
Aurora Failover 시 Reader만 살아있으면 Readiness가 DOWN이 되어 트래픽이 차단된다.
11. 트러블슈팅: Redis CROSSSLOT 오류
문제 상황
QueueProcessor가 admitNextUsers()를 호출할 때 CROSSSLOT 오류가 발생했다.
CROSSSLOT Keys in request don't hash to the same slot
원인 분석
Redis Cluster는 키를 16,384개 슬롯에 분산 저장한다. 여러 키를 동시에 조작하는 Lua Script는 해당 키들이 같은 슬롯에 있어야만 실행된다.
기존 키 설계는 세 키가 서로 다른 슬롯에 저장됐다.
waiting_queue:movie:topgun2 → 슬롯 A (대기열)
active_sessions:movie:topgun2 → 슬롯 B (활성 세션)
active_user_ttl:movie:topgun2:user123 → 슬롯 C (타임아웃 키)
세 키가 다른 슬롯에 있어서 Lua Script로 동시에 조작할 수 없었다.
해결: Hash Tag
Redis는 중괄호 {} 안의 문자열만 해시한다. {topgun2} 부분이 같으면 같은 슬롯에 저장된다.
// 기존 (CROSSSLOT 오류 발생)
private String activeSessionsKey(String id) {
return "active_sessions:movie:" + id;
}
// 수정 (해결)
private String activeSessionsKey(String type, String id) {
return "sessions:{" + id + "}:active";
}
private String waitingQueueKey(String type, String id) {
return "sessions:{" + id + "}:waiting";
}
이제 같은 영화의 대기열과 활성 세션이 같은 슬롯에 저장되어 Lua Script로 원자적 처리가 가능하다. 기존 키 정리를 위해 redis_cleanup.sh를 작성했다.
# 기존 CROSSSLOT 문제 키 삭제
redis6-cli $REDIS_OPTS --scan --pattern "active_sessions:movie:*" | xargs -r -I '{}' redis6-cli $REDIS_OPTS del '{}'
redis6-cli $REDIS_OPTS --scan --pattern "waiting_queue:movie:*" | xargs -r -I '{}' redis6-cli $REDIS_OPTS del '{}'
12. 트러블슈팅: Redis WRONGTYPE 오류
문제 상황
운영 중 간헐적으로 WRONGTYPE 오류가 발생했다.
WRONGTYPE Operation against a key holding the wrong kind of value
원인 분석
Redis 키 타입 불일치 문제였다. 예를 들어 sessions:{topgun2}:active가 Set으로 생성됐는데 코드에서 ZSET 명령어(ZADD, ZCARD)를 실행하면 오류가 발생한다. 배포 과정에서 이전 버전 코드가 다른 타입으로 키를 생성했을 때 발생한다.
해결: 키 타입 사전 검증
// AdmissionService.java - 키 타입 검증 메서드
private void ensureKeyType(String key, String expectedType) {
try {
DataType actualType = redisTemplate.type(key);
if (actualType != DataType.NONE && !actualType.code().equalsIgnoreCase(expectedType)) {
logger.warn("키 타입 불일치 감지: {} (예상: {}, 실제: {})", key, expectedType, actualType);
redisTemplate.delete(key); // 잘못된 키 삭제 후 재생성
}
} catch (Exception e) {
logger.error("키 타입 검증 중 오류", e);
}
}
// 오류 판별 유틸리티
private boolean isWrongTypeError(Exception e) {
if (e instanceof RedisSystemException) {
Throwable cause = e.getCause();
if (cause instanceof RedisCommandExecutionException) {
String message = ((RedisCommandExecutionException) cause).getMessage();
return message.startsWith("WRONGTYPE") || message.contains("CROSSSLOT");
}
}
return false;
}
Redis 작업 전에 ensureKeyType()으로 키 타입을 검증한다. 타입이 다르면 키를 삭제하고 재생성한다. WRONGTYPE 발생 시에는 키를 정리하고 재시도한다.
13. 부하 테스트
dev_backend 레포지토리에 test.sh와 test2.sh를 작성해 점진적으로 검증했다.
테스트 스크립트 핵심 로직
# test.sh - 기본 테스트 (100~1000명)
# 쿠키 유지 + 50개씩 배치 병렬 실행
curl -c cookies.txt -X POST "${API_URL}/api/sessions/issue" # 세션 발급
curl -X POST -d '{"movieId":"movie-topgun2","requestId":"$uuid"}' \
-b cookies.txt "${API_URL}/api/admission/enter" & # 백그라운드 실행
# 50개씩 배치로 나눠 wait → 서버 과부하 방지 + 동시 요청 시뮬레이션
# test2.sh - 대규모 테스트 (10,000명)
# 1,000개씩 배치, 배치 간 1초 대기
for batch in 1..100; do
for i in 1..100; do
send_request & # 100개 병렬
done
wait
sleep 1 # 서버 처리 시간
done
전체를 한 번에 보내지 않고 배치 단위로 나눠 실행한다. 서버에 과부하를 주지 않으면서 동시 요청을 시뮬레이션한다.
테스트 결과
| 단계 | 동시 사용자 | 설정 | 결과 |
|---|---|---|---|
| 1단계 | 100명 | Pod 2개, 세션 20개/Pod | 정상 동작 확인 |
| 2단계 | 1,000명 | Pod 4개, 세션 50개/Pod | HPA 스케일아웃 확인 |
| 3단계 | 10,000명 | Pod 10개, 세션 500개/Pod | CPU 60%, Memory 75% 안정 |
10,000명 테스트 시 지표:
| 지표 | 측정값 |
|---|---|
| 응답 시간 | 평균 200ms 이하 |
| 대기열 → 입장 전환 | 평균 10초 |
| WebSocket 순위 업데이트 | 4-5초 간격 |
| Redis 응답 시간 | 평균 5ms 이하 |
| Kinesis 이벤트 전달 | 평균 100ms 이하 |
왜 10,000명까지만 테스트했는가?
담당은 개발계다. 개발계는 기능 검증이 목적이고 운영계보다 낮은 스펙으로 구성했다.
| 환경 | 노드 | DB | 목적 |
|---|---|---|---|
| 개발계 | t3.medium (2 vCPU) | db.t3.medium | 기능 검증 |
| 운영계 | t3.xlarge (4 vCPU) | db.r6g.2xlarge | 실서비스 |
10만 명 트래픽은 개발계 리소스로는 무리다. 대기열 시스템의 정상 동작 확인이 목적이었고, 10,000명으로 검증했다.
14. 심화: 10만 트래픽 대기열 아키텍처
개발계에서 부하 테스트를 완료했다(13절, 최대 10,000명). 실제 10만 트래픽을 처리하려면 현재 코드의 한계를 파악해야 한다. 이 섹션에서는 현재 구현의 한계점과 운영 환경에서 필요한 개선 방향을 분석한다.
[확인필요] 원문 14절 도입부는 "5,000명 부하 테스트"로 기재되어 있었으나, 13절의 3단계 결과(10,000명)와 불일치한다. 실제 최대 테스트 규모를 확정해 통일할 것.
인프라 개선(NAT Multi-AZ, VPC Endpoint, WAF)은 2.1 심화에서 다뤘다. 여기서는 백엔드 코드와 프론트엔드 연계에 집중한다.
이 섹션의 "해결 과정"은 구현 완료가 아닌 개선 방향이다. 시간 제약으로 개발계에는 적용하지 못했지만, 운영 환경에서 필요한 내용과 구현 코드를 정리한다.
섹션 간 의존 관계
각 섹션은 독립적이지 않다. 적용 순서가 있다.
[의존 관계 다이어그램]
┌─────────────────────────────────────────────────────────────┐
│ 1순위: 필수 선행 │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 14.1 KCL │ │ 14.2 Pub/Sub│ │ 14.3 DLQ │ │
│ │ 체크포인트 │ │ WebSocket │ │ 에러 처리 │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │ │
│ └────────────────┼────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ 2순위: 1순위 완료 후 │ │
│ │ ┌──────────────┐ ┌──────────────┐ │ │
│ │ │ 14.6 KEDA │ │ 배치 500 │ │ │
│ │ │ 오토스케일링│ │ (14.3 전제) │ │ │
│ │ └──────────────┘ └──────────────┘ │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Thunder Herd 방지 │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ 14.7 Graceful │ │ 14.4 지수 백오프 │ │
│ │ Shutdown │ │ + 지터 │ │
│ │ (정상 종료) │ │ (비정상 종료) │ │
│ └────────┬─────────┘ └────────┬─────────┘ │
│ │ │ │
│ └──────────┬───────────────┘ │
│ ▼ │
│ Thunder Herd 방지 │
└─────────────────────────────────────────────────────────────┘
[왜 이 순서인가?]
14.1~14.3 없이 14.6 KEDA만 적용하면?
→ Pod 2개 → 10개로 스케일 아웃
→ 체크포인트 없음: Pod 10개 중 하나 죽으면 이벤트 유실 (더 자주 발생)
→ Pub/Sub 없음: 10개 Pod에 세션 분산 → 알림 누락 확률 10배
→ DLQ 없음: 처리량 증가 → 에러 발생 시 데이터 유실량 증가
결론: KEDA로 Pod 늘려봤자, 각 Pod의 문제가 해결 안 되면 오히려 악화
14.1 Kinesis Consumer: 체크포인트와 리밸런싱
역할 상기
섹션 7-8에서 분석한 KinesisAdmissionConsumer의 역할을 다시 정리한다.
[Kinesis Consumer의 역할]
사용자 입장 요청 → API Server → Kinesis에 이벤트 발행
│
▼
KinesisAdmissionConsumer
│
┌──────────────────┼──────────────────┐
▼ ▼ ▼
Redis 대기열 좌석 가용 확인 WebSocket 알림
순위 업데이트 입장 처리 "입장하세요" 전송
핵심: Kinesis에서 이벤트를 읽어서 → 대기열 처리 → 실시간 알림
Consumer가 제대로 동작하지 않으면 사용자는 "입장하세요" 알림을 받지 못한다. 10만 트래픽에서 Consumer 안정성은 서비스 품질에 직결된다.
현재 동작 방식 분석
[현재 코드의 동작]
1. Pod 시작 시:
- Kinesis 샤드 목록 조회
- 각 샤드의 LATEST Iterator 획득
- shardIterators (ConcurrentHashMap)에 저장
2. 1.5초마다:
- shardIterators에서 Iterator 조회
- getRecords() 호출
- 레코드 처리
- nextShardIterator를 shardIterators에 업데이트
[핵심 문제: 모든 상태가 메모리에만 존재]
- shardIterators: Pod 재시작 시 사라짐
- 처리 위치: 어디에도 저장 안 됨
- 샤드 할당: Pod 간 조율 없음
문제 확인
[문제 1: 체크포인트 유실]
시나리오:
1. Pod 1이 shard-0의 seq-1000까지 처리
2. Pod 1 재배포 (Rolling Update)
3. 새 Pod 1 시작 → LATEST부터 읽음 (현재 seq-2000)
4. seq-1001 ~ seq-1999 이벤트 영원히 처리 안 됨
결과:
- 해당 구간 사용자들 "입장하세요" 알림 못 받음
- 대기 화면에서 무한 대기
- 수동 새로고침해야 입장 가능
[문제 2: 샤드 리밸런싱 미지원]
시나리오:
1. 초기: 샤드 2개 (shard-0, shard-1)
2. 트래픽 증가 → AWS 콘솔에서 샤드 4개로 분할
3. 현재 코드: @PostConstruct에서만 샤드 조회
4. 새 샤드 (shard-2, shard-3) 인식 못 함
결과:
- 새 샤드로 간 이벤트 처리 안 됨
- 약 50% 사용자 알림 누락
- Pod 재시작해야 새 샤드 인식
[문제 3: Pod 장애 복구 불가]
시나리오:
1. Pod 1이 shard-0 담당
2. Pod 2가 shard-1 담당
3. Pod 1 OOMKilled
4. shard-0 담당 Pod 없음
결과:
- shard-0의 모든 이벤트 처리 중단
- Partition Key로 shard-0에 할당된 모든 사용자 영향
- 수동 개입 없이는 복구 불가
기본 개념: KCL(Kinesis Client Library)이란?
KCL은 AWS가 제공하는 Kinesis Consumer 라이브러리다. 직접 구현해야 하는 로직을 자동화한다.
[KCL이 자동으로 처리하는 것]
1. 체크포인트 관리
- DynamoDB에 "어디까지 읽었는지" 자동 저장
- Pod 재시작해도 마지막 위치부터 재개
- 중복/누락 방지
2. 샤드 리밸런싱
- 샤드 분할/병합 자동 감지
- 새 샤드 발견 시 자동으로 Consumer 할당
- 수동 개입 불필요
3. Worker 분배
- 여러 Pod(Worker) 간 샤드 자동 분배
- Pod 죽으면 다른 Pod가 샤드 인계
- Lease 기반 분산 처리
[KCL 아키텍처]
┌─────────────────────────────────────────────────────┐
│ DynamoDB │
│ ┌─────────────────────────────────────────────┐ │
│ │ Lease Table (체크포인트 + Worker 할당) │ │
│ │ ┌─────────┬─────────┬──────────┬─────────┐ │ │
│ │ │ ShardId │ Worker │ Checkpoint│ LeaseKey│ │ │
│ │ ├─────────┼─────────┼──────────┼─────────┤ │ │
│ │ │ shard-0 │ pod-1 │ seq-12345│ lease-1 │ │ │
│ │ │ shard-1 │ pod-2 │ seq-67890│ lease-2 │ │ │
│ │ └─────────┴─────────┴──────────┴─────────┘ │ │
│ └─────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
↑ ↑
│ 체크포인트 저장 │ Lease 갱신
│ │
┌────────┴────────┐ ┌────────┴────────┐
│ Pod 1 │ │ Pod 2 │
│ ┌───────────┐ │ │ ┌───────────┐ │
│ │ KCL Worker│ │ │ │ KCL Worker│ │
│ │ (shard-0) │ │ │ │ (shard-1) │ │
│ └─────┬─────┘ │ │ └─────┬─────┘ │
│ │ │ │ │ │
│ ▼ │ │ ▼ │
│ Kinesis │ │ Kinesis │
│ getRecords() │ │ getRecords() │
└─────────────────┘ └─────────────────┘
해결 과정: KCL 적용
// 1. KCL RecordProcessor 구현
public class AdmissionRecordProcessor implements ShardRecordProcessor {
private String shardId;
private final WebSocketBroadcastService broadcastService;
private final QueueService queueService;
@Override
public void initialize(InitializationInput input) {
// KCL이 샤드 할당해줌 - 수동 분배 로직 불필요
this.shardId = input.shardId();
log.info("Initialized processor for shard: {}", shardId);
}
@Override
public void processRecords(ProcessRecordsInput input) {
for (KinesisClientRecord record : input.records()) {
try {
// 비즈니스 로직만 구현하면 됨
AdmissionEvent event = deserialize(record.data());
processAdmissionEvent(event);
} catch (Exception e) {
log.error("Failed to process record", e);
// 에러 시에도 계속 진행 (DLQ로 보내는 로직 추가 필요)
}
}
// 체크포인트 저장 (DynamoDB에 자동 저장)
// Pod 재시작해도 여기부터 재개
try {
input.checkpointer().checkpoint();
} catch (Exception e) {
log.error("Checkpoint failed", e);
}
}
private void processAdmissionEvent(AdmissionEvent event) {
switch (event.getAction()) {
case "ADMIT":
broadcastService.notifyAdmission(
event.getRequestId(),
event.getMovieId()
);
break;
case "RANK_UPDATE":
broadcastService.notifyRankUpdate(
event.getRequestId(),
event.getRank(),
event.getTotalWaiting()
);
break;
}
}
@Override
public void leaseLost(LeaseLostInput input) {
// 다른 Worker가 이 샤드를 인계받음
log.info("Lease lost for shard: {}", shardId);
}
@Override
public void shardEnded(ShardEndedInput input) {
// 샤드 분할됨 → 새 샤드로 자동 전환
try {
input.checkpointer().checkpoint();
} catch (Exception e) {
log.error("Final checkpoint failed", e);
}
}
}
// 2. ProcessorFactory 구현
@Component
@RequiredArgsConstructor
public class AdmissionProcessorFactory implements ShardRecordProcessorFactory {
private final WebSocketBroadcastService broadcastService;
private final QueueService queueService;
@Override
public ShardRecordProcessor shardRecordProcessor() {
return new AdmissionRecordProcessor(broadcastService, queueService);
}
}
// 3. KCL Scheduler 설정
@Configuration
public class KclConfig {
@Value("${kinesis.stream.name}")
private String streamName;
@Value("${HOSTNAME:worker-unknown}")
private String workerId; // Pod 이름으로 식별
@Bean
public Scheduler kclScheduler(
KinesisAsyncClient kinesisClient,
DynamoDbAsyncClient dynamoDbClient,
AdmissionProcessorFactory processorFactory) {
ConfigsBuilder configsBuilder = new ConfigsBuilder(
streamName,
"cgv-admission-consumer", // DynamoDB 테이블명 (자동 생성)
kinesisClient,
dynamoDbClient,
CloudWatchAsyncClient.create(),
workerId,
processorFactory
);
return new Scheduler(
configsBuilder.checkpointConfig(),
configsBuilder.coordinatorConfig(),
configsBuilder.leaseManagementConfig(),
configsBuilder.lifecycleConfig(),
configsBuilder.metricsConfig(),
configsBuilder.processorConfig(),
configsBuilder.retrievalConfig()
);
}
@Bean
public CommandLineRunner startKcl(Scheduler scheduler) {
return args -> {
// 별도 스레드에서 KCL 실행
new Thread(scheduler, "kcl-scheduler").start();
};
}
}
결과
[Before: SDK 직접 구현 - 현재 상태]
- 체크포인트: 없음 → Pod 재시작 시 이벤트 누락
- 리밸런싱: 없음 → 샤드 분할 시 절반 이벤트 누락
- 장애 복구: 없음 → Pod 죽으면 해당 샤드 처리 중단
[After: KCL 사용 - 개선 방향]
- 체크포인트: DynamoDB에 자동 저장 → 재시작해도 연속 처리
- 리밸런싱: 자동 감지 → 샤드 분할해도 모든 이벤트 처리
- 장애 복구: Lease 만료(30초) → 다른 Pod가 자동 인계
[트레이드오프]
- DynamoDB 비용: 약 $5/월 (Lease 테이블)
- 지연 시간: 체크포인트 주기만큼 중복 처리 가능성
→ batchSize 단위로 체크포인트하면 중복 최소화
[미적용 이유]
- 개발 일정 부족
- 개발계에서는 이벤트 유실이 치명적이지 않음
→ 운영 환경에서는 필수 적용 대상
연결: 14.6 KEDA의 전제 조건
KCL이 없으면 KEDA로 Pod를 늘려도 의미가 없다. Pod가 10개로 늘어나면 각 Pod가 샤드를 나눠 처리하는데, 체크포인트 없이 Pod 하나가 죽으면 해당 샤드의 이벤트가 유실된다. Pod가 많아질수록 장애 확률도 높아지므로, KCL 없는 KEDA는 위험하다.
14.2 WebSocket 스케일링: Pod 간 세션 공유
현재 동작 방식 분석
먼저 현재 코드가 WebSocket 메시지를 어떻게 전송하는지 분석한다.
[현재 WebSocket 메시지 흐름]
1. QueueProcessor가 입장 처리
│
▼
2. KinesisProducer가 Kinesis에 ADMIT 이벤트 발행
│
▼
3. KinesisAdmissionConsumer가 이벤트 수신
│
▼
4. WebSocketUpdateService.notifyAdmission() 호출
│
▼
5. template.convertAndSend("/topic/admission/{requestId}", payload)
│
▼
6. 이 Pod에 연결된 WebSocket 세션에만 전송
[핵심 문제]
template.convertAndSend()는 해당 Pod의 메모리에 있는 세션에만 전송한다.
다른 Pod에 연결된 사용자에게는 메시지가 도달하지 않는다.
문제 재현 시나리오
[Pod 2개 환경에서 문제 발생]
1. 사용자 A → ALB → Pod 1에 WebSocket 연결
2. 사용자 B → ALB → Pod 2에 WebSocket 연결
3. KinesisAdmissionConsumer (Pod 2에서 실행)
→ "A 입장" 이벤트 수신
4. Pod 2의 WebSocketUpdateService.notifyAdmission("A의 requestId", ...)
→ template.convertAndSend("/topic/admission/A의requestId", ...)
5. Pod 2의 세션 목록에서 A 검색
→ A의 세션 없음! (A는 Pod 1에 연결됨)
6. 결과: A에게 "입장하세요" 알림 도달 실패
→ A는 대기 화면에서 무한 대기
→ 새로고침해야 입장 가능
[로그에서 확인되는 현상]
Pod 2 로그: "입장 알림 전송 성공: A → /topic/admission/A"
하지만 A는 알림을 받지 못함!
기본 개념: Redis Pub/Sub
Redis Pub/Sub는 메시지 브로커 패턴이다. Publisher가 채널에 메시지를 보내면 해당 채널을 Subscribe하는 모든 클라이언트가 메시지를 받는다.
[Pub/Sub 동작 원리]
Redis Server
┌─────────────────────┐
│ Channel: "notify" │
│ │
Publisher ──► PUBLISH ──────────► Subscriber 1 (Pod 1)
│ │
│ ──► Subscriber 2 (Pod 2)
│ │
│ ──► Subscriber 3 (Pod 3)
└─────────────────────┘
[CGV에 적용하면]
Kinesis Consumer (Pod 2)
│
▼ PUBLISH "websocket:admit:A"
│
┌────┴─────────────────────────┐
│ Redis Server │
└────┬─────────────────────────┘
│
┌────┼────────────────┐
▼ ▼ ▼
Pod 1 Pod 2 Pod 3
(A세션) (B세션) (C세션)
│
▼
A에게 WebSocket 전송!
해결 과정: Redis Pub/Sub 적용
// 1. Redis Pub/Sub 설정
@Configuration
public class RedisPubSubConfig {
@Bean
public RedisMessageListenerContainer redisContainer(
RedisConnectionFactory connectionFactory,
WebSocketBroadcastListener listener) {
RedisMessageListenerContainer container =
new RedisMessageListenerContainer();
container.setConnectionFactory(connectionFactory);
// 패턴 구독: websocket:broadcast:* 채널 전체
container.addMessageListener(
listener,
new PatternTopic("websocket:broadcast:*")
);
return container;
}
}
// 2. 메시지 발행 서비스 (기존 WebSocketUpdateService 대체)
@Service
@RequiredArgsConstructor
@Slf4j
public class WebSocketBroadcastService {
private final StringRedisTemplate redisTemplate;
private final ObjectMapper objectMapper;
/**
* 기존: template.convertAndSend() 직접 호출
* 변경: Redis PUBLISH로 모든 Pod에 브로드캐스트
*/
public void notifyAdmission(String requestId, String movieId) {
try {
Map<String, Object> payload = Map.of(
"type", "admission",
"status", "ADMITTED",
"action", "REDIRECT_TO_SEATS",
"requestId", requestId,
"movieId", movieId,
"message", "입장이 허가되었습니다!",
"timestamp", System.currentTimeMillis()
);
String channel = "websocket:broadcast:admission:" + requestId;
String message = objectMapper.writeValueAsString(payload);
// Redis PUBLISH → 모든 Pod가 수신
redisTemplate.convertAndSend(channel, message);
log.info("Redis PUBLISH: {} → {}", channel, requestId);
} catch (Exception e) {
log.error("Redis 발행 실패: {}", requestId, e);
}
}
public void notifyRankUpdate(String requestId, int rank, int totalWaiting) {
try {
Map<String, Object> payload = Map.of(
"type", "rank",
"status", "WAITING",
"requestId", requestId,
"rank", rank,
"totalWaiting", totalWaiting,
"timestamp", System.currentTimeMillis()
);
String channel = "websocket:broadcast:rank:" + requestId;
redisTemplate.convertAndSend(channel,
objectMapper.writeValueAsString(payload));
} catch (Exception e) {
log.error("순위 업데이트 발행 실패: {}", requestId, e);
}
}
public void broadcastQueueStats(String movieId, int totalWaiting) {
try {
Map<String, Object> payload = Map.of(
"type", "stats",
"movieId", movieId,
"totalWaiting", totalWaiting,
"timestamp", System.currentTimeMillis()
);
String channel = "websocket:broadcast:stats:" + movieId;
redisTemplate.convertAndSend(channel,
objectMapper.writeValueAsString(payload));
} catch (Exception e) {
log.error("통계 브로드캐스트 실패: {}", movieId, e);
}
}
}
// 3. 메시지 수신 리스너 (모든 Pod에서 실행)
@Component
@RequiredArgsConstructor
@Slf4j
public class WebSocketBroadcastListener implements MessageListener {
private final SimpMessagingTemplate messagingTemplate;
@Override
public void onMessage(Message message, byte[] pattern) {
try {
String channel = new String(message.getChannel());
String body = new String(message.getBody());
// 채널명: websocket:broadcast:admission:req-12345
// 또는: websocket:broadcast:rank:req-12345
// 또는: websocket:broadcast:stats:movie-avatar3
String[] parts = channel.split(":");
String messageType = parts[2]; // admission, rank, stats
String identifier = parts[3]; // requestId 또는 movieId
// WebSocket 토픽으로 전송
String destination = "/topic/" + messageType + "/" + identifier;
messagingTemplate.convertAndSend(destination, body);
log.debug("WebSocket 전송: {} → {}", channel, destination);
} catch (Exception e) {
log.error("메시지 처리 실패", e);
}
}
}
// 4. KinesisAdmissionConsumer 수정
@Component
public class KinesisAdmissionConsumer {
// 기존: private final WebSocketUpdateService webSocketService;
// 변경: Redis Pub/Sub 사용
private final WebSocketBroadcastService broadcastService;
private void processRecord(Record record) {
JsonNode eventNode = objectMapper.readTree(data);
String eventType = eventNode.path("action").asText();
switch (eventType) {
case "ADMIT":
// 기존: webSocketService.notifyAdmission(...)
// 변경: Redis Pub/Sub로 브로드캐스트
broadcastService.notifyAdmission(
eventNode.path("requestId").asText(),
eventNode.path("movieId").asText()
);
break;
case "RANK_UPDATE":
broadcastService.notifyRankUpdate(
eventNode.path("requestId").asText(),
eventNode.path("rank").asInt(),
eventNode.path("totalWaiting").asInt()
);
break;
case "STATS_UPDATE":
broadcastService.broadcastQueueStats(
eventNode.path("movieId").asText(),
eventNode.path("totalWaiting").asInt()
);
break;
}
}
}
결과
[Before: Pod 로컬 세션만 - 현재 상태]
사용자 A → Pod 1 연결
Kinesis Consumer → Pod 2에서 "A 입장" 이벤트 처리
Pod 2 → notifyAdmission("A") → template.convertAndSend()
→ Pod 2 세션에 A 없음 → 전송 실패!
A는 알림 못 받음 → 무한 대기 → 새로고침해야 입장
[After: Redis Pub/Sub - 개선 방향]
사용자 A → Pod 1 연결
Kinesis Consumer → Pod 2에서 "A 입장" 이벤트 처리
Pod 2 → broadcastService.notifyAdmission("A")
→ Redis PUBLISH "websocket:broadcast:admission:A"
Redis → Pod 1, Pod 2, Pod 3 모두 수신
Pod 1 → onMessage() → "/topic/admission/A"로 전송
→ A 세션 있음 → WebSocket 전송 성공!
Pod 2, Pod 3 → A 세션 없음 → 전송해도 수신자 없음 (정상)
A는 즉시 "입장하세요" 알림 수신 → 좌석 선택 페이지로 이동
[변경 요약]
- WebSocketUpdateService → WebSocketBroadcastService로 교체
- template.convertAndSend() 직접 호출 → Redis PUBLISH 후 각 Pod에서 전송
- 추가 인프라: Redis Pub/Sub 설정 (기존 Redis 활용, 추가 비용 없음)
[미적용 이유]
- 시간 부족 (구현 난이도는 낮음)
→ 운영 환경에서 필수 적용 대상
연결: 14.6 KEDA의 전제 조건
Redis Pub/Sub가 없으면 KEDA로 Pod를 늘려도 알림 누락이 더 심해진다. Pod 2개일 때 50% 확률로 다른 Pod에 세션이 있으면, Pod 10개일 때는 90% 확률로 다른 Pod에 세션이 있다. Pub/Sub 없는 KEDA는 알림 누락을 크게 늘린다.
14.3 배치 크기: 트레이드오프와 DLQ
현재 설정
[QueueProcessor의 역할]
대기열 (Redis Sorted Set)
┌─────────────────────────────────────┐
│ 순위 1: user-A (10:00:01 입장) │
│ 순위 2: user-B (10:00:02 입장) │
│ 순위 3: user-C (10:00:03 입장) │
│ ... │
│ 순위 100,000: user-Z (10:05:00 입장)│
└─────────────────────────────────────┘
│
▼ 2초마다 배치 크기만큼 꺼냄
┌───────────────────┐
│ QueueProcessor │
│ BATCH_SIZE = 100 │
└─────────┬─────────┘
│
┌─────────────┼─────────────┐
▼ ▼ ▼
user-A user-B ... user-100
"입장하세요" "입장하세요" "입장하세요"
[배치 크기(BATCH_SIZE)]
= 한 번에 대기열에서 꺼내서 "입장 가능" 처리하는 사용자 수
= 2초마다 100명씩 처리하면 BATCH_SIZE = 100
문제 확인
현재 설정으로 10만 명을 처리하면 33분이 걸린다.
// 현재 설정
private static final int PROCESSING_BATCH_SIZE = 100;
private static final long PROCESSING_INTERVAL = 2000; // 2초
// 처리량 계산
// 100명 / 2초 = 50명/초
// 10만 명 처리 시간: 100,000 / 50 = 2,000초 = 33분
33분 대기는 티켓팅 시스템에서 허용하기 어렵다.
왜 처음부터 배치 크기를 500으로 크게 하지 않았나?
배치 크기 증가는 단순한 설정 변경이 아니라 트레이드오프가 있다.
[배치 크기 증가의 위험 요소]
1. 장애 영향 범위 증가
┌────────────────────────────────────────────┐
│ 배치 100 처리 중 Pod OOMKilled │
│ → 최대 100명 영향 │
│ → 100명만 재처리 필요 │
│ │
│ 배치 500 처리 중 Pod OOMKilled │
│ → 최대 500명 영향 │
│ → 500명 재처리 필요 (5배 복구 부담) │
└────────────────────────────────────────────┘
2. 메모리 사용량 증가
- 100명 처리: 약 1MB (사용자 정보 + 메시지)
- 500명 처리: 약 5MB
- Pod 메모리 256MB → OOM 위험 증가
3. Redis 락 시간 증가
- ZRANGEBYSCORE + ZREM 100건: 약 5ms
- ZRANGEBYSCORE + ZREM 500건: 약 20ms
- 락 시간 길어짐 → 다른 요청 blocking
4. WebSocket 순간 부하
- 100명 동시 알림: 100 connections에 메시지 전송
- 500명 동시 알림: 500 connections에 메시지 전송
- 네트워크 버퍼 overflow 가능
5. 에러 복구 복잡도
- 배치 100 중 50번째에서 에러: 49명 성공, 51명 미처리
- 배치 500 중 250번째에서 에러: 249명 성공, 251명 미처리
- 재처리 로직 복잡
배치 크기 증가 전 체크리스트
□ Pod 메모리 512Mi 이상
□ DLQ(Dead Letter Queue) 구현 ← 현재 미구현!
□ WebSocket 비동기 전송
□ Redis 성능 여유
[현재 상태]
✓ Pod 메모리: 512Mi (충분)
✗ DLQ: 미구현 → 에러 시 이벤트 유실!
✓ WebSocket: SimpMessagingTemplate (비동기)
✓ Redis: ElastiCache r6g.large (충분)
→ DLQ 미구현으로 배치 500 적용 위험
현재 한계: DLQ 미구현
// 현재 코드: 재시도 3회 후 그냥 종료
private void pollRecordsForShard(String shardId) {
try {
// ... 레코드 처리
} catch (Exception e) {
retryCount++;
if (retryCount >= maxRetries) {
logger.error("최종 실패: {}", shardId, e);
return; // 이벤트 유실! DLQ 전송 없음!
}
}
}
[DLQ 없이 배치 크기 500 적용 시 위험]
1. 배치 500명 처리 시작
2. 250번째 사용자에서 Redis 연결 오류
3. 재시도 3회 실패
4. 현재 코드: logger.error() 후 return
5. 250명 이벤트 유실 → 알림 못 받음
[DLQ가 있으면]
1. 배치 500명 처리 시작
2. 250번째 사용자에서 Redis 연결 오류
3. 재시도 3회 실패
4. DLQ로 실패 이벤트 전송
5. 나중에 DLQ 처리 → 250명 알림 재시도
개선 방향: DLQ 구현 후 배치 크기 증가 (미구현)
// 2. DLQ 전송 로직 추가
@Service
@RequiredArgsConstructor
public class DeadLetterQueueService {
private final SqsClient sqsClient;
@Value("${aws.sqs.dlq-url}")
private String dlqUrl;
public void sendToDeadLetterQueue(String payload, String errorMessage) {
try {
sqsClient.sendMessage(SendMessageRequest.builder()
.queueUrl(dlqUrl)
.messageBody(payload)
.messageAttributes(Map.of(
"error", MessageAttributeValue.builder()
.dataType("String")
.stringValue(errorMessage)
.build(),
"timestamp", MessageAttributeValue.builder()
.dataType("Number")
.stringValue(String.valueOf(System.currentTimeMillis()))
.build()
))
.build());
log.warn("DLQ 전송 완료: {}", payload);
} catch (Exception e) {
log.error("DLQ 전송 실패! 이벤트 유실: {}", payload, e);
}
}
}
// 3. Consumer에서 DLQ 사용
private void processRecord(Record record) {
try {
// 비즈니스 로직
processAdmissionEvent(event);
} catch (Exception e) {
retryCount++;
if (retryCount >= maxRetries) {
// 기존: logger.error() 후 return
// 변경: DLQ로 전송
dlqService.sendToDeadLetterQueue(
record.data().asUtf8String(),
e.getMessage()
);
}
}
}
# 1. SQS DLQ 설정 (Terraform)
resource "aws_sqs_queue" "admission_dlq" {
name = "cgv-admission-dlq"
message_retention_seconds = 1209600 # 14일 보관
}
# 4. DLQ 모니터링 알람
resource "aws_cloudwatch_metric_alarm" "dlq_not_empty" {
alarm_name = "cgv-dlq-not-empty"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 1
metric_name = "ApproximateNumberOfMessagesVisible"
namespace = "AWS/SQS"
period = 300
statistic = "Sum"
threshold = 0
alarm_description = "DLQ에 실패 메시지 존재 - 즉시 확인 필요"
alarm_actions = [aws_sns_topic.alerts.arn]
}
배치 크기 변경 후 처리량
[Before: 배치 100 - 현재 상태]
처리량: 100명 / 2초 = 50명/초
10만 명 처리: 33분
[After: 배치 500 (DLQ 구현 후)]
처리량: 500명 / 2초 = 250명/초
10만 명 처리: 6.7분 (79% 단축)
[변경 조건]
1. DLQ 구현 완료
2. Pod 메모리 512Mi 확인
3. 부하 테스트로 안정성 검증
연결: 배치 500과 14.6 KEDA의 전제 조건
DLQ가 없으면 배치 500으로 늘릴 수 없다(에러 시 500명 이벤트 유실). KEDA로 Pod를 늘려도 위험하다(처리량 증가 = 에러 발생 시 유실량 증가). DLQ는 처리량을 안전하게 늘리기 위한 안전망이다.
14.4 Thunder Herd: 즉시 폴링 전환
현재 코드 분석
[현재 동작 흐름]
WebSocket 끊김 감지
│
▼ 즉시 (지연 없음!)
setInterval(checkStatusAndNavigate, 3000)
│
▼
3초마다 GET /api/admission/status 요청
문제 재현: Thunder Herd
[Thunder Herd Problem - 실제 시나리오]
1. 상황: Pod 스케일 다운 또는 배포
- Pod 1 종료 → 5,000개 WebSocket 강제 끊김
2. 현재 코드 동작:
- 5,000명 모두 onWebSocketClose 이벤트 수신
- 5,000명 모두 "즉시" setInterval 시작
- 3초 후: 5,000개 GET /api/admission/status 동시 요청
3. 결과:
- ALB 순간 부하: 5,000 req/sec (3초마다 반복)
- 나머지 Pod들도 부하로 응답 지연
- 응답 지연 → 프론트엔드 타임아웃 → 더 많은 재시도
- 악순환 발생 → 전체 서비스 장애
[로그에서 확인되는 현상]
[10:00:00.000] Pod 1 종료 (SIGTERM)
[10:00:00.001] user-A: WebSocket 끊김 → 폴링 전환
[10:00:00.002] user-B: WebSocket 끊김 → 폴링 전환
...
[10:00:00.100] user-5000: WebSocket 끊김 → 폴링 전환
[10:00:03.000] 5,000개 동시 요청 → ALB 503 에러 발생
기본 개념: 지수 백오프 + 지터
지수 백오프는 재시도 간격을 점점 늘리는 전략이다. 지터는 랜덤 지연을 추가해 동시 재시도를 분산한다.
[지수 백오프만 적용]
재시도 1회: 1초 후 → 5,000명 동시 (타이밍 동일!)
재시도 2회: 2초 후 → 5,000명 동시
재시도 3회: 4초 후 → 5,000명 동시
→ 부하는 분산되지 않음!
[지수 백오프 + 지터]
재시도 1회: 1초 + random(0~1초) = 1.0초 ~ 2.0초 사이에 분산
재시도 2회: 2초 + random(0~1초) = 2.0초 ~ 3.0초 사이에 분산
재시도 3회: 4초 + random(0~1초) = 4.0초 ~ 5.0초 사이에 분산
[효과 계산]
5,000명 × 1초 분산 = 평균 5,000명/초 → 5명/ms
기존 동시 요청 대비 순간 부하가 크게 감소
개선 방향: 지수 백오프 + 지터 (미구현)
// WaitPage.jsx - 개선 코드
// 지수 백오프 + 지터 계산 함수
const calculateReconnectDelay = (attempts) => {
// 지수 백오프: 1초, 2초, 4초, 8초, 16초...
const base = Math.min(
1000 * Math.pow(2, attempts),
30000 // 최대 30초
);
// 지터: 0~1초 랜덤 추가
const jitter = Math.random() * 1000;
return base + jitter;
};
onWebSocketClose: (event) => {
console.warn('🔌 WebSocket 연결 종료:', event?.reason || 'Unknown');
setIsConnected(false);
// 기존: 즉시 폴링 전환
// 변경: 지수 백오프로 재연결 시도
const delay = calculateReconnectDelay(reconnectAttempts);
console.log(`🔄 ${delay}ms 후 재연결 시도 (${reconnectAttempts + 1}회차)`);
reconnectTimeoutRef.current = setTimeout(() => {
setReconnectAttempts(prev => prev + 1);
// WebSocket 재연결 시도
if (reconnectAttempts < 3) {
console.log('🔗 WebSocket 재연결 시도...');
client.activate(); // 재연결
} else {
// 3회 실패 후에만 폴링 폴백
console.log('🔗 [재연결 실패] API 폴링으로 전환');
setUsingApiPolling(true);
statusCheckIntervalRef.current = setInterval(
checkStatusAndNavigate,
5000 // 5초 간격 (3초보다 여유있게)
);
}
}, delay);
},
// 컴포넌트 언마운트 시 정리
useEffect(() => {
return () => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
if (statusCheckIntervalRef.current) {
clearInterval(statusCheckIntervalRef.current);
}
};
}, []);
결과
[Before: 즉시 전환 - 현재 상태]
Pod 종료 → 5,000명 WebSocket 끊김
→ 5,000명 "즉시" 폴링 전환
→ 3초 후 5,000개 동시 요청
→ ALB/Pod 과부하 → 추가 장애
[After: 지수 백오프 + 지터 - 개선 방향]
Pod 종료 → 5,000명 WebSocket 끊김
→ 각자 1~2초 사이에 분산되어 재연결 시도
→ 대부분 재연결 성공
→ 3회 실패한 일부만 폴링 전환
→ 순간 부하가 크게 감소
[미적용 이유]
- 프론트엔드 변경 필요
- 테스트 환경에서 검증 부족
→ 운영 환경에서 적용 권장
연결: 14.7 Graceful Shutdown과 함께 사용
Thunder Herd 방지에는 두 접근이 있다.
- 정상 종료 (배포, 스케일다운): 14.7 Graceful Shutdown → 미리 알림 → 분산 재연결
- 비정상 종료 (OOM, 크래시): 14.4 지수 백오프 → 즉시 끊김 → 분산 재연결
14.7만 있으면 비정상 종료 시 무방비다. 14.4만 있으면 정상 종료에도 지연이 생긴다. 둘 다 적용해야 완전한 Thunder Herd 방지가 된다.
14.5 동적 폴링: 순위 기반 간격 조절
현재 코드
// WaitPage.jsx - 현재 코드
// 모든 사용자 동일한 3초 간격
if (!statusCheckIntervalRef.current && !isAdmitted) {
statusCheckIntervalRef.current = setInterval(checkStatusAndNavigate, 3000);
}
문제 분석
[부하 계산 - 10만 명 폴링 시]
현재 설정:
- 모든 사용자: 3초 간격
- 요청/초 = 100,000명 / 3초 = 33,333 req/sec
[왜 문제인가?]
순위 1번 사용자:
- 곧 입장 → 빠른 업데이트 필요 (2초 OK)
- 입장 알림 놓치면 안 됨
순위 99,999번 사용자:
- 입장까지 약 30분 대기
- 1분에 한 번 업데이트해도 충분
- 3초마다 요청 → 불필요한 서버 부하
[비효율성]
하위 50%가 전체 요청의 50% 생성
→ 대부분 "순위 변화 없음" 응답
→ 서버 자원 낭비
개선 방향: 순위 기반 동적 폴링 (미구현)
// 백엔드: AdmissionController.java - 권장 간격 추가
@GetMapping("/api/admission/status")
public ResponseEntity<StatusResponse> getStatus(
@RequestParam String movieId,
@RequestParam String sessionId,
@RequestParam String requestId) {
int rank = queueService.getRank(requestId);
int totalWaiting = queueService.getTotalWaiting(movieId);
// 순위 기반 권장 폴링 간격 계산
int recommendedInterval = calculatePollingInterval(rank, totalWaiting);
return ResponseEntity.ok(new StatusResponse(
"WAITING",
rank,
totalWaiting,
recommendedInterval // 응답에 포함
));
}
/**
* 순위 기반 폴링 간격 계산
* 원칙: 입장 가능성이 높을수록 자주 확인
*/
private int calculatePollingInterval(int rank, int total) {
if (total == 0) return 5000; // 기본값
double position = (double) rank / total;
// 상위 1% (곧 입장): 2초
if (position < 0.01) return 2000;
// 상위 10% (조금 기다림): 5초
if (position < 0.1) return 5000;
// 상위 50% (중간): 10초
if (position < 0.5) return 10000;
// 하위 50% (한참 대기): 15초
return 15000;
}
// 프론트엔드: WaitPage.jsx - 동적 간격 적용
const [currentPollingInterval, setCurrentPollingInterval] = useState(3000);
const checkStatusAndNavigate = useCallback(async () => {
try {
const response = await axios.get('/api/admission/status', {
params: { movieId, sessionId, requestId }
});
const data = response.data;
if (data.status === 'ADMITTED') {
redirectToSeats();
return;
}
setMyRank(data.rank);
setTotalWaiting(data.totalWaiting);
// 서버 권장 간격으로 동적 조절
const newInterval = data.recommendedPollingInterval || 5000;
if (newInterval !== currentPollingInterval) {
console.log(`📊 폴링 간격 변경: ${currentPollingInterval}ms → ${newInterval}ms`);
console.log(` (순위: ${data.rank}/${data.totalWaiting})`);
// 기존 인터벌 정리
if (statusCheckIntervalRef.current) {
clearInterval(statusCheckIntervalRef.current);
}
// 새 간격으로 인터벌 재설정
statusCheckIntervalRef.current = setInterval(
checkStatusAndNavigate,
newInterval
);
setCurrentPollingInterval(newInterval);
}
} catch (error) {
console.error('상태 확인 실패:', error);
}
}, [movieId, sessionId, requestId, currentPollingInterval, redirectToSeats]);
결과
[Before: 고정 3초 - 현재 상태]
모든 사용자 동일: 3초 간격
총 요청: 100,000명 / 3초 = 33,333 req/sec
[After: 순위 기반 동적 - 개선 방향]
상위 1% (1,000명): 간격 2초 → 1,000 / 2 = 500 req/sec
상위 10% (9,000명): 간격 5초 → 9,000 / 5 = 1,800 req/sec
상위 50% (40,000명): 간격 10초 → 40,000 / 10 = 4,000 req/sec
하위 50% (50,000명): 간격 15초 → 50,000 / 15 = 3,333 req/sec
총 요청: 500 + 1,800 + 4,000 + 3,333 = 9,633 req/sec
감소율: (33,333 - 9,633) / 33,333 = 71% 감소
[사용자 체감]
순위 1번 → 2초마다 업데이트 → "곧 입장" 피드백
순위 99,999번 → 15초마다 업데이트 → 어차피 한참 대기, 영향 없음
14.6 오토스케일링: HPA의 한계와 KEDA
전제 조건: 14.1–14.3 완료 필요
KEDA를 적용하기 전에 다음이 완료되어야 한다.
[KEDA 적용 전제 조건]
□ 14.1 KCL 체크포인트
- 없으면: Pod 늘어날수록 장애 시 이벤트 유실 확률 증가
□ 14.2 Redis Pub/Sub
- 없으면: Pod 10개일 때 90% 확률로 알림 누락
□ 14.3 DLQ
- 없으면: 처리량 증가 = 에러 시 유실량 증가
[왜 순서가 중요한가?]
KEDA로 Pod 2개 → 10개로 늘리면?
- KCL 없음: 10개 중 하나 죽으면 이벤트 유실 (확률 증가)
- Pub/Sub 없음: 알림 누락 90% (2개일 때 50%)
- DLQ 없음: 에러 시 유실량 증가
결론: 14.1~14.3 없이 KEDA만 적용하면 오히려 악화
문제: I/O bound 워크로드에서 CPU 기반 스케일링
[왜 HPA가 한계인가?]
Kinesis Consumer 동작:
1. getRecords() 호출 → 네트워크 I/O 대기 (CPU idle)
2. 레코드 처리 → 잠깐 CPU 사용
3. Redis 호출 → 네트워크 I/O 대기 (CPU idle)
4. sleep(1500ms) → CPU idle
결과:
- 대기열에 10만 건 쌓임
- CPU 사용률: 30% (임계값 70% 미달)
- HPA 판단: "스케일링 불필요"
- 하지만 처리량은 한계에 도달!
[문제의 본질]
HPA는 CPU/Memory 기반 → I/O bound 워크로드에 부적합
대기열 시스템은 "큐 길이"로 스케일링해야 함
해결 방향: KEDA (Kubernetes Event-Driven Autoscaling)
KEDA는 외부 메트릭(Redis 큐 길이, Kafka lag 등)을 기반으로 스케일링한다.
[KEDA가 모니터링하는 대상]
Redis Sorted Set: sessions:{movieId}:waiting
↑ 대기 중인 사용자 (입장 전)
NOT: sessions:{movieId}:active
↑ 이미 입장한 사용자 (스케일링과 무관)
[KEDA vs HPA]
HPA: CPU 30% → "여유 있음" → 스케일링 안 함
KEDA: waiting 큐 5,000건 → "1,000건당 Pod 1개" → 5개로 스케일 아웃
waiting 큐가 비면 → Pod 최소 개수로 스케일 인
상세 설정은 2.3 CI/CD에서 다룬다. HPA/KEDA는 Kubernetes 리소스이므로 Helm values.yaml, ScaledObject YAML 설정은 2.3에서 분석한다. 현재 KEDA는 DevOps 팀원 담당으로 개발계에 미적용 상태다.
14.7 Graceful Shutdown: WebSocket 연결 정리
현재 코드
// KinesisAdmissionConsumer.java - 현재 코드
@PreDestroy
public void shutdown() {
log.info("Shutting down Kinesis Consumer...");
this.isRunning = false;
// WebSocket 세션 정리 로직 없음
// 그냥 종료
}
문제점
[Pod 종료 시]
1. SIGTERM 수신
2. @PreDestroy 실행 → isRunning = false
3. Spring 컨텍스트 종료 시작
4. WebSocket 연결 강제 끊김 (정상 종료 아님)
5. 클라이언트: 1006 (Abnormal Closure) 에러 수신
6. 모든 사용자 동시에 onWebSocketClose 이벤트
7. Thunder Herd 발생!
개선 방향: Graceful Shutdown (미구현)
# Pod spec에 preStop 추가
spec:
containers:
- name: cgv-backend
lifecycle:
preStop:
exec:
# 종료 전 10초 대기 (연결 정리 시간)
command: ["/bin/sh", "-c", "sleep 10"]
# 전체 종료 대기 시간
terminationGracePeriodSeconds: 30
// WebSocketSessionManager.java - 세션 관리 추가
@Component
@Slf4j
public class WebSocketSessionManager {
private final Set<WebSocketSession> sessions =
ConcurrentHashMap.newKeySet();
private volatile boolean acceptingConnections = true;
public void registerSession(WebSocketSession session) {
if (acceptingConnections) {
sessions.add(session);
} else {
// 종료 중이면 연결 거부
try {
session.close(CloseStatus.SERVICE_RESTARTING);
} catch (IOException e) {
log.warn("세션 거부 실패", e);
}
}
}
public void unregisterSession(WebSocketSession session) {
sessions.remove(session);
}
/**
* Graceful Shutdown 시 호출
*/
@PreDestroy
public void prepareForShutdown() {
log.info("Graceful shutdown 시작. 활성 세션: {}", sessions.size());
// 1. 새 연결 거부
acceptingConnections = false;
// 2. 기존 연결에 "곧 재연결 필요" 알림
String message = "{\"type\":\"RECONNECT_SOON\",\"reason\":\"SERVER_SHUTDOWN\"}";
sessions.forEach(session -> {
try {
if (session.isOpen()) {
session.sendMessage(new TextMessage(message));
log.debug("재연결 알림 전송: {}", session.getId());
}
} catch (Exception e) {
log.warn("알림 전송 실패: {}", session.getId(), e);
}
});
// 3. 클라이언트가 재연결할 시간 대기 (preStop과 함께 10초)
log.info("클라이언트 재연결 대기 중...");
// 4. 남은 세션 정상 종료
sessions.forEach(session -> {
try {
if (session.isOpen()) {
session.close(CloseStatus.SERVICE_RESTARTING);
}
} catch (Exception e) {
log.warn("세션 종료 실패: {}", session.getId(), e);
}
});
log.info("Graceful shutdown 완료");
}
}
// WaitPage.jsx - RECONNECT_SOON 처리
onWebSocketMessage: (message) => {
const data = JSON.parse(message.body);
if (data.type === 'RECONNECT_SOON') {
console.log('🔄 서버 종료 예정. 다른 서버로 재연결 중...');
// 즉시 재연결 시도 (Thunder Herd 방지를 위해 지터 추가)
const jitter = Math.random() * 2000; // 0~2초 랜덤
setTimeout(() => {
client.deactivate();
client.activate(); // 다른 Pod로 연결
}, jitter);
}
}
결과
[Before: 즉시 종료 - 현재 상태]
Pod 종료 → WebSocket 강제 끊김 (1006 에러)
→ 5,000명 동시에 onWebSocketClose
→ Thunder Herd → 서비스 장애
[After: Graceful Shutdown - 개선 방향]
Pod 종료 예정 → RECONNECT_SOON 메시지 전송
→ 5,000명이 0~2초 사이에 분산되어 재연결
→ 다른 Pod로 정상 연결
→ Thunder Herd 방지
연결: 14.4 지수 백오프와 함께 사용
Graceful Shutdown은 정상 종료(배포, 스케일다운)에서만 동작한다. 비정상 종료(OOM, 크래시)는 RECONNECT_SOON을 보낼 시간이 없다.
| 종료 유형 | 해결책 |
|---|---|
| 정상 종료 | 14.7 Graceful Shutdown |
| 비정상 종료 | 14.4 지수 백오프 + 지터 |
둘 다 적용해야 모든 상황에서 Thunder Herd를 방지한다.
14.8 정리: 현재 vs 운영 환경
적용 순서 (의존 관계 반영)
[1순위: 필수 선행 - 독립적으로 적용 가능]
14.1 KCL 체크포인트
14.2 Redis Pub/Sub
14.3 DLQ
[2순위: 1순위 완료 후]
14.6 KEDA (14.1~14.3 전제)
배치 500 (14.3 전제)
[3순위: 독립적 - 언제든 적용 가능]
14.4 지수 백오프 + 지터
14.5 동적 폴링
14.7 Graceful Shutdown
※ 14.4와 14.7은 둘 다 적용해야 Thunder Herd 완전 방지
| 항목 | 현재 구현 | 운영 필요 | 우선순위 | 의존성 |
|---|---|---|---|---|
| Kinesis 체크포인트 | 메모리 저장 | KCL + DynamoDB | 상 | 없음 |
| WebSocket 브로드캐스트 | Pod 로컬만 | Redis Pub/Sub | 상 | 없음 |
| 에러 처리 | 재시도 후 폐기 | DLQ 구현 | 상 | 없음 |
| 배치 크기 | 100 | 500 | 중 | DLQ 필요 |
| 오토스케일링 | HPA (CPU) | KEDA (waiting 큐 길이) | 중 | 14.1–14.3 필요 |
| 재연결 전략 | 즉시 폴링 | 지수 백오프 + 지터 | 중 | 없음 |
| 폴링 간격 | 고정 3초 | 순위 기반 동적 | 중 | 없음 |
| 종료 처리 | 즉시 종료 | Graceful Shutdown | 중 | 14.4와 함께 |
15. 마무리
CGV 대기열 시스템의 핵심은 역할 분리다. Redis는 상태 저장(순위, 활성 세션), QueueProcessor는 주기적 처리(빈자리 체크), Kinesis는 비동기 이벤트 전달, WebSocket은 실시간 알림을 담당한다.
SQS 대신 Kinesis를 선택한 이유는 실시간 순위 조회(200ms vs 최대 20초), 단일 스트림 아키텍처, 데이터 재처리 가능성이다. MSK도 고려했지만 초기 비용(월 $200+ vs $13)과 팀 러닝커브를 감안해 Kinesis를 선택했다. Redis CROSSSLOT 오류는 Hash Tag로 해결했다. 이는 Redis Cluster에서 Lua Script를 사용할 때 고려해야 할 제약이다.
심화에서는 현재 구현의 한계점과 10만 트래픽 대응을 위한 개선 방향을 분석했다. Kinesis 체크포인트 미구현, WebSocket Pod 간 공유 불가, DLQ 부재 등 운영 환경에서 필요한 개선 사항을 파악했다. 개발계에는 적용하지 못했지만, KCL, Redis Pub/Sub, 지수 백오프, KEDA의 개념과 필요성을 정리했고 운영 전환 시 구현할 계획이다.
정리하면 다음을 다뤘다.
- Redis Sorted Set을 활용한 실시간 순위 조회 구현
- Kinesis를 통한 이벤트 기반 시스템 설계
- Redis Cluster의 Hash Tag로 CROSSSLOT 해결
- IRSA를 통한 EKS Pod IAM 권한 관리
- MSK vs Kinesis 선택 기준 (비용, 생태계, 팀 역량)
- 현재 구현 한계 파악 및 운영 환경 개선 방향 분석
다음 섹션에서는 CI/CD 파이프라인을 분석한다. GitLab에서 Push하면 EKS에 배포되기까지의 전체 흐름을 검증한다.
'Cloudwave > cgv-project' 카테고리의 다른 글
| CI/CD (0) | 2026.02.18 |
|---|---|
| 메시징 시스템 (0) | 2026.02.16 |
| WebSocket (0) | 2026.02.12 |
| 3. CI/CD 파이프라인 구축과 자동화 (0) | 2026.02.02 |
| 1. 인프라 설계 (0) | 2026.01.31 |