1. Redis Lua 스크립팅
Lua란 무엇인가
정의: Lua는 가볍고 빠른 스크립팅 언어다. Redis에 내장되어 있어, 여러 Redis 명령어를 하나의 원자적 트랜잭션으로 실행할 수 있다.
핵심 포인트:
- Lua 스크립트가 실행되는 동안 Redis는 다른 명령어를 받지 않는다 (single-threaded)
- 여러 명령어를 네트워크 왕복 없이 서버 측에서 한 번에 실행한다
- 스크립트 전체가 성공하거나 전체가 실패한다 (원자성)
Redis가 싱글 스레드이기 때문에 Lua 원자성이 보장되는 근본 원리는 4편 LevelDB 비교의 "싱글 스레드 쓰기 모델" 섹션에서 상세히 다룬다.
왜 Lua를 쓰는가 -- 원자성 문제
Lua 없이 대기열 진입을 구현하면 다음과 같은 레이스 컨디션(race condition) 문제가 발생한다.
[Lua 없이 -- 위험한 코드]
Thread A (사용자 A) Thread B (사용자 B)
───────────────── ─────────────────
1. ZCARD active → 4,999
1. ZCARD active → 4,999
2. 4,999 < 5,000? YES!
2. 4,999 < 5,000? YES!
3. ZADD active "user-A"
3. ZADD active "user-B"
결과: Active가 5,001명!
→ 최대 5,000명 제한 위반!
→ 서버 과부하 가능
ZCARD와 ZADD 사이에 다른 요청이 끼어들 수 있다. 이것을 Check-then-Act 레이스 컨디션이라 한다.
[Lua로 -- 안전한 코드]
Thread A와 Thread B가 동시에 요청해도:
Redis는 Lua 스크립트를 순차 실행한다 (single-threaded):
스크립트 A 실행:
ZCARD active → 4,999
4,999 < 5,000? YES
ZADD active "user-A"
Active = 5,000
→ 완료
스크립트 B 실행 (A가 끝난 후):
ZCARD active → 5,000
5,000 < 5,000? NO
ZADD waiting "user-B"
→ 대기열로
결과: Active는 정확히 5,000명. 제한 지켜짐.
CGV에서의 Lua 스크립트
대기열 진입 스크립트
사용자가 대기열에 진입할 때 실행되는 Lua 스크립트다.
"Active가 가득 찼는지 확인 → 비어있으면 Active로, 가득 찼으면 Waiting으로" 전체를 원자적으로 처리한다.
-- 파일: AdmissionService.java에서 RedisScript로 실행
-- KEYS[1] = sessions:{movieId}:active
-- KEYS[2] = sessions:{movieId}:waiting
-- ARGV[1] = maxSessions (예: 5000)
-- ARGV[2] = member (예: "req-abc:sess-123")
-- ARGV[3] = now (타임스탬프)
local activeKey = KEYS[1]
local waitingKey = KEYS[2]
local maxSessions = tonumber(ARGV[1])
local member = ARGV[2]
local now = tonumber(ARGV[3])
-- 1단계: 현재 Active 수 확인
local currentActive = redis.call('ZCARD', activeKey)
-- 2단계: 빈자리 있는지 판단
if currentActive < maxSessions then
-- 빈자리 있음 → Active에 직접 추가
redis.call('ZADD', activeKey, now, member)
local activeCount = redis.call('ZCARD', activeKey)
return {1, 'SUCCESS', activeCount} -- status=1, 상태문자열, Active 수
else
-- 빈자리 없음 → Waiting에 추가
redis.call('ZADD', waitingKey, now, member)
local rank = redis.call('ZRANK', waitingKey, member)
local totalWaiting = redis.call('ZCARD', waitingKey)
return {2, 'QUEUED', rank + 1, totalWaiting} -- status=2, 상태, 순번(1-based), 총 대기
-- ZRANK는 0-based (첫 번째 = 0). 사용자에게 보여줄 1-based 순번으로 변환.
-- rank=0이면 "1번째", rank=3421이면 "3,422번째"
end
실행 결과 해석:
return {1, 'SUCCESS', 4801} → 즉시 입장. Active 4,801번째. 좌석 선택 페이지로 이동
return {2, 'QUEUED', 3422, 45000} → 대기열 등록. "당신은 3,422번째입니다" (총 대기 45,000명)
왜 Java에서 if-else를 하면 안 되는가:
[Java에서 처리하면 발생하는 문제]
Java Thread A: Java Thread B:
long count = ZCARD(active) long count = ZCARD(active)
→ 4,999 → 4,999
if (count < 5000) if (count < 5000)
ZADD(active, userA) ZADD(active, userB)
→ Active = 5,001명 (제한 위반!)
[Lua에서 처리하면]
Redis가 스크립트를 순차 실행하므로
Thread A의 ZCARD~ZADD가 끝난 후에야 Thread B의 스크립트가 시작됨
→ Active는 절대 5,000을 초과할 수 없음
Java에서 ZCARD → if → ZADD를 순서대로 호출하면, 각 Redis 명령어 사이에 네트워크 왕복(약 0.5ms)이 있고, 이 사이에 다른 스레드의 명령어가 끼어들 수 있다. Lua 스크립트는 Redis 서버 내부에서 실행되므로 네트워크 왕복이 없고, 스크립트 전체가 끝날 때까지 다른 명령어가 실행되지 않는다.
입장 처리 스크립트 (admitNextUsers)
QueueProcessor가 대기열에서 N명을 꺼내 Active로 이동시키는 Lua 스크립트다.
-- KEYS[1] = sessions:{movieId}:waiting
-- KEYS[2] = sessions:{movieId}:active
-- ARGV[1] = admitCount (예: 200, vacantSlots 기준)
-- ARGV[2] = score (현재 타임스탬프)
local waitingKey = KEYS[1]
local activeKey = KEYS[2]
local admitCount = tonumber(ARGV[1])
local score = tonumber(ARGV[2])
-- 1단계: Waiting에서 앞에서 N명 조회
local users = redis.call('ZRANGE', waitingKey, 0, admitCount - 1)
-- 2단계: 조회된 사용자들을 이동
local admitted = {}
for i, member in ipairs(users) do
-- Waiting에서 제거
redis.call('ZREM', waitingKey, member)
-- Active에 추가 (새 타임스탬프로)
redis.call('ZADD', activeKey, score, member)
-- 입장 처리된 사용자 목록에 추가
table.insert(admitted, member)
end
-- 3단계: 입장 처리된 사용자 목록 반환
return admitted
원자성 보장 범위: 스크립트 전체
위 코드에서 local waitingKey = KEYS[1] 첫 줄부터 return admitted 마지막 줄까지, 스크립트 전체가 하나의 원자적 단위다. Redis는 싱글 스레드이므로, 스크립트 실행 중에는 다른 어떤 클라이언트의 명령도 처리하지 않는다.
좌석 선점 스크립트 (seat_lock.lua)
대기열 승격과 마찬가지로, 좌석 선점도 Lua 스크립트로 원자적으로 처리한다. 여러 좌석을 한 번에 선점할 때 "확인 → 선점" 사이에 다른 요청이 끼어들면 안 되기 때문이다.
-- 파일: backend/src/main/resources/lua/seat_lock.lua
-- 다좌석 원자적 선점: 충돌이 있으면 전부 취소, 없으면 전부 선점
-- KEYS[1] = {movieId} (Hash Tag용)
-- ARGV[1] = movieId, ARGV[2] = theaterId
-- ARGV[3] = seatIds (JSON 배열), ARGV[4] = requestId, ARGV[5] = ttl
local movieId = ARGV[1]
local theaterId = ARGV[2]
local seatIds = cjson.decode(ARGV[3])
local requestId = ARGV[4]
local ttl = tonumber(ARGV[5])
-- 1단계: 충돌 확인
local conflicts = {}
for _, seatId in ipairs(seatIds) do
local key = "seat:{" .. movieId .. "}:{" .. theaterId .. "}:{" .. seatId .. "}"
if redis.call('EXISTS', key) == 1 then
table.insert(conflicts, seatId)
end
end
-- 2단계: 충돌 있으면 전부 취소
if #conflicts > 0 then
return cjson.encode({status = "CONFLICT", conflicts = conflicts})
end
-- 3단계: 충돌 없으면 전부 선점
for _, seatId in ipairs(seatIds) do
local key = "seat:{" .. movieId .. "}:{" .. theaterId .. "}:{" .. seatId .. "}"
redis.call('SET', key, requestId, 'EX', ttl)
end
return cjson.encode({status = "LOCKED", count = #seatIds})
왜 단순 SET NX가 아니라 Lua인가?
단일 좌석이면 SET key value NX EX 300 한 줄이면 된다. 하지만 다좌석 선점(A-01, A-02, A-03을 한 번에)에서는 "A-01은 성공, A-02는 실패" 같은 부분 성공이 발생한다. Lua 스크립트로 "전부 확인 → 충돌 없으면 전부 선점"을 원자적으로 처리하여 사용자 경험을 보호한다.
[일반 명령어 -- 끼어들기 가능]
Pod A (QueueProcessor) Pod B (QueueProcessor)
────────────────── ──────────────────
ZRANGE waiting 0 99 → 응답
ZRANGE waiting 0 99 → 응답 ← 같은 유저 목록 조회!
ZREM waiting user-1
ZREM waiting user-1 ← 이미 없는 유저 제거 시도
ZADD active user-1
ZADD active user-1 ← 중복 입장!
→ 각 명령이 독립적이므로 사이사이에 다른 Pod의 명령이 끼어든다
[Lua 스크립트 -- 끼어들기 불가]
Pod A: EVAL script → [Redis가 스크립트 전체를 한 번에 실행] → 응답
├── ZRANGE waiting 0 99 (내부)
├── ZREM waiting user-1 (내부)
├── ZADD active user-1 (내부) ← 이 사이에 아무도 못 끼어듦
├── ZREM waiting user-2 (내부)
├── ZADD active user-2 (내부)
└── return admitted
Pod B: EVAL script → [대기 중] → A 끝난 뒤에 실행
→ Pod B는 A가 이미 이동시킨 유저를 조회할 수 없다. 중복 입장 불가능.
핵심은 EVAL 명령 한 번 = 스크립트 전체 실행이라는 점이다. Java에서 Redis 명령을 개별 호출하면 각 명령 사이에 네트워크 왕복(~0.5ms)이 있고 그 틈에 다른 클라이언트가 끼어든다. Lua 스크립트는 Redis 서버 내부에서 실행되므로 네트워크 왕복이 없고, 스크립트가 끝날 때까지 Redis 전체가 이 스크립트만 처리한다.
Hash Tag와 Cluster Mode 관계: 위 Lua 스크립트에서
KEYS[1](waiting)과KEYS[2](active)가 반드시 같은 Redis 노드에 있어야 Lua가 동작한다. 현재 Non-Cluster Mode에서는 모든 키가 한 노드에 있으므로 문제없다. Cluster Mode에서는{movieId}Hash Tag로 같은 슬롯에 배치해야 한다.
[Java에서 Lua 스크립트를 실행하는 방법]
// ✅ 원자적: EVAL 한 번 호출 = 스크립트 전체가 원자적
redisTemplate.execute(
new DefaultRedisScript<>(luaScript, List.class),
List.of(waitingKey, activeKey), // KEYS
admitCount, score // ARGV
);
// ❌ 원자적이지 않음: Java에서 개별 명령 호출
List<String> users = redisTemplate.opsForZSet().range(waitingKey, 0, 99);
for (String user : users) {
redisTemplate.opsForZSet().remove(waitingKey, user); // ← 사이에 끼어들 수 있음
redisTemplate.opsForZSet().add(activeKey, user, score);
}
Lua 블로킹 시간 분석 — 5,000명 일괄 이동
Redis는 싱글 스레드이므로 Lua 스크립트 실행 중에는 모든 다른 명령이 대기한다. QueueProcessor가 최대 5,000명을 이동시키는 Lua의 실행 시간을 계산한다.
[admitNextUsers Lua — 최대 부하 시나리오]
ZRANGE sessions:{topgun}:waiting 0 4999 → ~0.1ms (인덱스 범위 조회)
for i = 1 to 5000:
ZREM sessions:{topgun}:waiting user[i] → ~0.01ms × 5000 = ~50ms
ZADD sessions:{topgun}:active ts user[i] → ~0.01ms × 5000 = ~50ms
총 Lua 실행 시간: ~100ms (최대 부하 시)
→ Redis가 100ms 동안 다른 명령어를 처리하지 못함
→ 그 사이 들어온 ZADD(대기열 진입), ZRANK(순번 조회) 등이 대기
→ 2초 주기에서 100ms = 5% 차지 (나머지 1.9초는 정상 처리)
[실제로는 5,000명이 한번에 이동하는 경우는 드물다]
vacantSlots = maxSessions - currentActive = 5000 - 4800 = 200
admitCount = min(vacantSlots=200, waiting=45000, BATCH_SIZE=5000) = 200
→ 실제: 200명 이동 → ~4ms Lua 실행
→ 전체 Active가 0인 초기 상황에서만 5,000명 일괄 이동 발생
→ 정상 운영 중에는 100~500명 수준 → ~2~10ms
위험 상황과 대응:
[만약 Lua가 5초 이상 걸리면?]
Redis 기본 설정: lua-time-limit = 5000 (5초)
→ 5초 초과 시 다른 클라이언트가 SCRIPT KILL 명령 가능
→ 하지만 쓰기 명령이 포함된 Lua는 KILL 불가 (데이터 정합성)
→ SHUTDOWN NOSAVE로만 중단 가능
우리 프로젝트:
→ 최대 5,000명 × 2 ops = 10,000 Redis 명령 = ~100ms
→ 5초 한도의 2%밖에 안 됨 → 안전
[좌석 선점 Lua 블로킹 분석]
seat_lock.lua (3좌석 동시 선점):
EXISTS × 3 (충돌 확인) → ~0.01ms × 3 = ~0.03ms
SET ... EX × 3 (선점) → ~0.01ms × 3 = ~0.03ms
총: ~0.06ms
booking_complete.lua (예매 확정):
ZREM (Active 제거) → ~0.01ms
SADD × 3 (좌석 확정) → ~0.01ms × 3 = ~0.03ms
INCRBY (매진 카운터) → ~0.001ms
PUBLISH (매진 알림) → ~0.01ms (매진 시에만)
총: ~0.05ms
→ QueueProcessor Lua (~4ms) 대비 1/80 수준
→ 초당 좌석 선점 요청 500건 × 0.06ms = 30ms → Redis 블로킹 우려 없음
→ 좌석 Lua와 대기열 Lua가 동시에 실행될 수 없지만 (싱글 스레드),
각각 너무 짧아서 서로 영향 없음
EVAL vs EVALSHA
EVAL: 매번 스크립트 전문을 전송 → 네트워크 오버헤드
EVALSHA: SHA1 해시로 캐시된 스크립트 호출 → 효율적
CGV에서는 Spring Boot의 RedisScript<T>가 자동으로 SCRIPT LOAD + EVALSHA를 처리한다.
개발자가 EVAL/EVALSHA를 직접 선택할 필요 없다.
// Spring이 알아서 EVALSHA로 호출하고, 캐시 미스 시 EVAL로 fallback
DefaultRedisScript<List> script = new DefaultRedisScript<>();
script.setScriptSource(new ResourceScriptSource(new ClassPathResource("lua/enter_queue.lua")));
redisTemplate.execute(script, keys, args);
MULTI/EXEC vs Lua 비교
MULTI/EXEC란 무엇인가
MULTI/EXEC는 Redis의 트랜잭션 메커니즘이다. 여러 명령어를 모아서 한 번에 원자적으로 실행한다.
MULTI
ZADD sessions:{topgun}:active 1707120000000 "req-abc:sess-123"
ZREM sessions:{topgun}:waiting "req-abc:sess-123"
EXEC
→ 두 명령이 원자적으로 실행됨
- MULTI: 트랜잭션 시작. 이후 명령어는 큐에 쌓임
- EXEC: 큐에 쌓인 명령어를 한 번에 원자적으로 실행
- DISCARD: 트랜잭션 취소
왜 MULTI/EXEC로는 부족한가
핵심 한계: MULTI/EXEC에는 조건 분기가 없다.
[MULTI/EXEC의 한계]
우리가 필요한 로직:
1. ZCARD active → 현재 수 확인
2. IF 현재 수 < 5,000 THEN
→ ZADD active (즉시 입장)
3. ELSE
→ ZADD waiting (대기열로)
MULTI/EXEC:
MULTI
ZCARD active ← 결과를 볼 수 없음! EXEC 전까지 QUEUED만 반환
... IF? → 불가능. MULTI 안에서 조건 분기가 없음
EXEC
→ MULTI/EXEC는 "미리 정해진 명령어 목록"을 실행하는 것
→ 중간 결과를 보고 분기하는 것은 불가능
→ 우리 대기열의 핵심인 "Check-then-Act"를 구현할 수 없다
Lua vs MULTI/EXEC vs 개별 명령 비교표
| 비교 | 개별 명령 | MULTI/EXEC | Lua 스크립트 |
|---|---|---|---|
| 원자성 | X (명령 사이 끼어들기) | O (EXEC 시 원자적) | O (스크립트 전체) |
| 조건 분기 | X (Java에서 처리, 레이스) | X (불가) | O (if/else 가능) |
| 중간 결과 참조 | X | X (QUEUED 반환) | O (변수로 사용) |
| 네트워크 왕복 | N회 (명령 수만큼) | 1회 (MULTI~EXEC) | 1회 (EVAL) |
| CGV 대기열 적합 | X | X | O |
→ CGV 대기열에서 Lua를 선택한 이유: 원자성 + 조건 분기 + 중간 결과 참조 세 가지가 모두 필요하기 때문
2. Redis Pub/Sub
Pub/Sub 기본 개념
정의: Pub/Sub(Publish/Subscribe)는 메시지 발행-구독 패턴이다. 발행자(Publisher)가 채널에 메시지를 보내면, 해당 채널을 구독(Subscribe)하고 있는 모든 수신자가 메시지를 받는다.
[Pub/Sub 기본 동작]
Publisher Redis Subscribers
(메시지 보내는 쪽) (중개자) (메시지 받는 쪽)
"경기 시작!" ──PUBLISH──→ [channel: sports] ──→ 구독자 A: "경기 시작!" 수신
──→ 구독자 B: "경기 시작!" 수신
──→ 구독자 C: "경기 시작!" 수신
핵심 포인트:
- Fire-and-Forget: 메시지를 보내고 끝. 수신 확인 없음.
- 실시간 전달: 메시지 저장 안 함. 구독 중인 클라이언트에게만 즉시 전달.
- 구독하지 않은 시점의 메시지는 받지 못함: 메시지 큐(Kafka, SQS)와의 차이점.
- 패턴 구독 가능:
PSUBSCRIBE queue:*로 와일드카드 매칭.
# 채널 구독
SUBSCRIBE news:sports
→ 이제부터 news:sports 채널의 메시지를 수신
# 패턴 구독 (와일드카드)
PSUBSCRIBE news:*
→ news:sports, news:politics, news:tech 등 모든 news: 채널 수신
# 메시지 발행
PUBLISH news:sports "Son Heung-min scored!"
→ 구독 중인 모든 클라이언트에게 전달
# 발행 명령어의 반환값 = 메시지를 받은 구독자 수
PUBLISH news:sports "goal!"
→ (integer) 3 (3명이 받음)
Pub/Sub vs Message Queue (Kafka/SQS)
| 비교 | Redis Pub/Sub | Kafka / SQS |
|---|---|---|
| 메시지 저장 | 안 함 (즉시 전달, 소멸) | 저장됨 (나중에 소비 가능) |
| 오프라인 수신 | 불가 (구독 중이어야 함) | 가능 (나중에 읽기) |
| 수신 보장 | 보장 안 됨 | 보장됨 (at-least-once) |
| 용도 | 실시간 브로드캐스트 | 이벤트 소싱, 로그 처리 |
| 성능 | 매우 빠름 (~0.01ms) | 빠름 (~1ms) |
| 복잡도 | 매우 단순 | 파티션, 오프셋, 컨슈머 그룹 |
CGV에서 Pub/Sub를 선택한 이유: WebSocket 메시지는 "지금 접속 중인 사용자"에게만 전달하면 된다. 오프라인 사용자에게 나중에 전달할 필요 없다. 메시지가 유실되어도 다음 2초 후 QueueProcessor가 다시 갱신하므로 치명적이지 않다. 이런 특성에서는 Kafka보다 Redis Pub/Sub가 적합하다.
왜 CGV에서 Pub/Sub가 필수인가
Pod가 1개일 때는 Pub/Sub가 필요 없다. 문제는 Pod가 여러 개일 때 발생한다.
[Pod 1개 -- 문제 없음]
QueueProcessor: "user-A 입장 허가!" (Waiting → Active 이동 완료)
│
▼
template.convertAndSend("/topic/admission/user-A", message)
│
▼
Pod 1의 WebSocket 세션에서 user-A를 찾아 전달
→ user-A는 Pod 1에 연결되어 있으므로 수신 성공!
[Pod 10개 -- 문제 발생!]
user-A는 Pod 3에 WebSocket 연결되어 있음
QueueProcessor가 Pod 7에서 "user-A 입장 허가!" 처리 (Waiting → Active 이동)
Pod 7의 QueueProcessor:
template.convertAndSend("/topic/admission/user-A", message)
│
▼
Pod 7의 WebSocket 세션에서 user-A를 찾음
→ 없음! (user-A는 Pod 3에 연결됨)
→ 메시지 유실!
결과: user-A는 입장 허가를 받았지만 알림을 못 받음
→ 대기 화면에서 계속 기다림
→ "언제 입장되는 거야?!" 불만
전달률 계산:
Pod 10개, 사용자가 무작위로 분산 연결:
- user-A가 Pod 3에 연결됨
- QueueProcessor가 Pod 7에서 입장 처리 실행
- Pod 7에서 convertAndSend → Pod 7의 로컬 세션만 검색
- user-A가 Pod 7에 있을 확률: 1/10 = 10%
- 메시지 유실 확률: 9/10 = 90%
→ Pub/Sub 없으면 메시지 전달률 ~10%
→ 시스템이 사실상 동작하지 않는 것과 같다
Pub/Sub로 해결하는 방법
[Pub/Sub 적용 후 -- 모든 Pod에 전달]
Pod 7의 QueueProcessor:
"user-A 입장 허가!" (Waiting → Active 이동 완료)
│
│ 더 이상 직접 convertAndSend 하지 않음
│ 대신 Redis Pub/Sub로 발행
│
▼
Redis PUBLISH "queue:notifications" {type: "ADMISSION", requestId: "user-A", ...}
│
│ Redis가 구독 중인 모든 Pod에 즉시 전달
│
├─→ Pod 1: 수신 → 로컬에 user-A 있나? → 없음 → 무시
├─→ Pod 2: 수신 → 로컬에 user-A 있나? → 없음 → 무시
├─→ Pod 3: 수신 → 로컬에 user-A 있나? → 있음! → convertAndSend 실행
├─→ Pod 4~9: 수신 → 없음 → 무시
└─→ Pod 10: 수신 → 없음 → 무시
결과: user-A가 어느 Pod에 연결되어 있든 메시지를 받는다
전달률: 100%
이 멀티 Pod 시나리오는 KEDA가 k6 부하테스트 트래픽에 반응하여 Pod를 2→10개로 스케일업한 상황이다 (→ 2.4 참고).
CGV Pub/Sub 구현 설계
⚠ 주의: 아래 Pub/Sub 코드는 전부 미구현 설계안이다.
현재 백엔드 코드에는 Redis Pub/Sub가 적용되어 있지 않다.
SimpMessagingTemplate.convertAndSend()로 단일 Pod 내에서만 WebSocket을 전송한다.
다중 Pod 환경에서 Pub/Sub 릴레이는 구현할 예정이다.
// 1. RedisConfig -- Pub/Sub 리스너 설정
@Configuration
public class RedisPubSubConfig {
@Bean
public RedisMessageListenerContainer redisMessageListenerContainer(
RedisConnectionFactory connectionFactory,
WebSocketBroadcastListener listener) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(connectionFactory);
// 통합 알림 채널 구독: 모든 알림이 queue:notifications 채널로 통합
container.addMessageListener(listener,
new ChannelTopic("queue:notifications"));
return container;
}
}
// 2. WebSocketBroadcastService -- 메시지 발행 (Publisher)
@Service
public class WebSocketBroadcastService {
private final StringRedisTemplate redisTemplate;
// 입장 알림 발행 — 통합 채널 queue:notifications 사용
public void publishAdmission(String requestId, Map<String, Object> payload) {
payload.put("event", "ADMISSION");
payload.put("requestId", requestId);
String message = objectMapper.writeValueAsString(payload);
// Redis Pub/Sub로 발행 → 모든 Pod가 수신
redisTemplate.convertAndSend("queue:notifications", message);
}
// 대기열 현황 발행
public void publishStats(String movieId, Map<String, Object> payload) {
payload.put("event", "STATS");
payload.put("movieId", movieId);
String message = objectMapper.writeValueAsString(payload);
redisTemplate.convertAndSend("queue:notifications", message);
}
// 매진 알림 발행
public void publishSoldOut(String movieId) {
Map<String, Object> payload = Map.of(
"event", "SOLD_OUT",
"movieId", movieId,
"totalSold", 6000
);
String message = objectMapper.writeValueAsString(payload);
redisTemplate.convertAndSend("queue:notifications", message);
}
}
// 3. WebSocketBroadcastListener -- 메시지 수신 (Subscriber, 모든 Pod)
@Component
public class WebSocketBroadcastListener implements MessageListener {
private final SimpMessagingTemplate template;
@Override
public void onMessage(Message message, byte[] pattern) {
String body = new String(message.getBody());
Map<String, Object> payload = objectMapper.readValue(body, Map.class);
String event = (String) payload.get("event");
// 이벤트 타입에 따라 WebSocket 목적지 결정
switch (event) {
case "ADMISSION":
String requestId = (String) payload.get("requestId");
template.convertAndSend("/topic/admission/" + requestId, body);
break;
case "STATS":
String movieId = (String) payload.get("movieId");
template.convertAndSend("/topic/stats/movie/" + movieId, body);
break;
case "SOLD_OUT":
String soldOutMovieId = (String) payload.get("movieId");
template.convertAndSend("/topic/soldout/movie/" + soldOutMovieId, body);
break;
}
}
}
전체 흐름:
[Pub/Sub 적용 후 전체 흐름]
QueueProcessor (Pod 7)
│ admitNextUsers Lua 실행 → user-A를 Waiting → Active 이동 완료
│
▼
WebSocketBroadcastService.publishAdmission("user-A", payload)
│
│ PUBLISH "queue:notifications" {event: "ADMISSION", requestId: "user-A", status: "ADMITTED"}
│
▼
Redis Server
│
│ queue:notifications 채널을 구독 중인 모든 Pod에 메시지 전달
│
├─→ Pod 1: WebSocketBroadcastListener.onMessage()
│ → event="ADMISSION" → template.convertAndSend("/topic/admission/user-A", payload)
│ → Pod 1 로컬 세션에 user-A 없음 → 전달할 대상 없음 → OK
│
├─→ Pod 3: WebSocketBroadcastListener.onMessage()
│ → event="ADMISSION" → template.convertAndSend("/topic/admission/user-A", payload)
│ → Pod 3 로컬 세션에 user-A 있음! → WebSocket으로 전달
│ → user-A 브라우저: "입장 허가! 좌석 선택 페이지로 이동"
│
└─→ Pod 10: 없음 → OK
Pub/Sub 채널 설계 — 통합 채널
[CGV Pub/Sub 채널 구조 — 통합 채널 방식]
모든 알림이 단일 채널 queue:notifications를 통해 전달된다.
메시지 본문의 event 필드로 이벤트 종류를 구분한다.
채널: queue:notifications
이벤트 타입 (event 필드) → WebSocket 목적지
─────────────────────────────────────────────────────────────────────
ADMISSION → /topic/admission/{requestId}
RANK → /topic/rank/{requestId}
STATS → /topic/stats/movie/{movieId}
SOLD_OUT → /topic/soldout/movie/{movieId}
왜 통합 채널인가: 채널을 이벤트 종류별로 분리하면(예:
queue:admission,queue:soldout) 각 Pod가 여러 채널을 구독해야 한다. 통합 채널 1개로 모든 알림을 전달하면 구독 관리가 단순하고, Listener가 event 필드로 라우팅하므로 확장도 용이하다.
Pub/Sub 주의사항
Pub/Sub 메시지 유실 대응 (현재 구현 수준):
Redis Pub/Sub는 fire-and-forget이다. 구독자가 없으면 메시지는 사라진다.
현재 대응 (Pub/Sub 미구현 상태):
- QueueProcessor가 2초마다 stats 브로드캐스트 → 1~2주기 유실은 자동 복구
- 입장 알림 유실 시: 사용자가
/api/admission/status폴링으로 상태 확인 가능 - 매진 알림:
sold-out:{movieId}키를 SET하여 폴링 fallback 제공
향후 Pub/Sub 구현 시 추가 대응:
- Pod 재시작 중 발행된 메시지는 유실됨
- 대응: 브로드캐스트가 1초 주기이므로 최대 1초 지연 후 자동 복구
- 입장 알림은
/topic/admission/{requestId}개별 전송이라 유실 시 폴링 필요
2. 메모리 사용:
Pub/Sub는 메시지를 저장하지 않으므로 메모리를 거의 사용하지 않는다.
10만 명 x 2초마다 stats 발행 = 초당 ~50KB 수준.
Redis 메모리에 영향을 주지 않는다.
3. 모든 Pod가 모든 메시지를 받는다:
10개 Pod가 모두 admission:{requestId} 메시지를 받지만,
실제로 해당 사용자가 연결된 Pod는 1개뿐이다.
나머지 9개 Pod는 "없음 → 무시"한다.
→ 약간의 CPU 낭비가 있지만, 10개 Pod 수준에서는 무시할 수 있다.
→ 100개 이상 Pod라면 채널 파티셔닝을 고려해야 한다.
'Redis' 카테고리의 다른 글
| Redis vs LevelDB (0) | 2026.02.12 |
|---|---|
| Redis In CGV 아키텍처 (0) | 2026.02.12 |
| Redis 기본 개념 — 왜 메모리이고, 왜 Sorted Set인가 (0) | 2026.02.12 |