1. Redis 배포 아키텍처 — Non-Cluster vs Cluster
Redis를 프로덕션에 배포할 때 가장 먼저 결정해야 하는 것이 배포 모드다.
ElastiCache에서는 Cluster Mode Disabled(Non-Cluster)와 Cluster Mode Enabled(Cluster) 두 가지를 제공한다.
이 선택이 데이터 분산, Lua 스크립트 동작, 고가용성 전략 전체를 결정한다.
Node, Shard, Slot 기본 개념
Redis 배포 구조를 이해하려면 세 가지 핵심 개념을 먼저 알아야 한다.
[Node, Shard, Slot의 관계]
Node = ElastiCache 인스턴스 1개. 독립된 Redis 프로세스가 실행되는 단위.
cache.t3.small 1대 = Node 1개.
Shard = Primary 노드 + Replica 노드(들)의 묶음. 하나의 데이터 파티션.
Shard 안에서 Primary는 쓰기/읽기, Replica는 읽기 전용.
Slot = Redis Cluster의 데이터 분배 단위. 총 16,384개.
키의 CRC16 해시값을 16,384로 나눈 나머지가 슬롯 번호.
Non-Cluster에서는 슬롯 개념이 존재하지 않는다.
각 개념이 주는 이점:
[Shard를 써서 얻는 이점 — Primary + Replica 분리]
왜 Primary와 Replica로 나누는가?
1. 고가용성(HA): Primary 장애 → Replica가 자동 승격 → 서비스 중단 최소화
2. 읽기 분산: 읽기 요청을 Replica로 보내서 Primary 부하 감소
3. 데이터 안전: 비동기 복제로 데이터 사본 유지
→ Shard = "장애에 대비한 데이터 복제 단위"
[Slot을 써서 얻는 이점 — Cluster Mode Only]
왜 16,384개 슬롯으로 나누는가?
1. 데이터 분산: 키를 여러 Shard에 자동 배분
2. 수평 확장: Shard 추가 시 슬롯 일부를 새 Shard로 이동 (리밸런싱)
3. 클라이언트 라우팅: 키 → CRC16 해시 → 슬롯 번호 → 어느 Shard에 있는지 즉시 결정
→ Slot = "데이터를 고르게 나누는 우편번호 시스템"
→ Non-Cluster에서는 슬롯이 없다. 모든 데이터가 1 Shard에 있으므로 분배할 이유가 없다.
[Cluster (다중 Shard)를 써서 얻는 이점]
1. 메모리 확장: 1 Primary에 64GB 한계 → 3 Shard면 192GB
2. 처리량 확장: 1 Primary에 ~25K ops/sec → 3 Primary면 ~75K ops/sec
3. 장애 격리: Shard 1이 죽어도 Shard 2, 3은 정상
단, 전제조건:
- 데이터가 여러 Shard에 고르게 분산되어야 이점 존재
- 같은 Lua 스크립트에서 다른 슬롯의 키 접근 불가 (CROSSSLOT)
- CGV처럼 한 영화에 10만 명 집중 → Hash Tag로 모든 키가 같은 슬롯
→ 1 Shard에 모든 트래픽 집중 → Cluster의 분산 이점 = 0
[Non-Cluster: 1 Shard]
┌── Shard ──────────────────────┐
│ Primary ──→ Replica │ 슬롯 없음. 모든 데이터가 여기.
└───────────────────────────────┘
[Cluster: 3 Shards]
┌── Shard 0 ────────┐ ┌── Shard 1 ────────┐ ┌── Shard 2 ────────┐
│ Primary → Replica │ │ Primary → Replica │ │ Primary → Replica │
│ Slot 0~5460 │ │ Slot 5461~10922 │ │ Slot 10923~16383 │
└────────────────────┘ └────────────────────┘ └────────────────────┘
Non-Cluster Mode (Cluster Mode Disabled)
ElastiCache에서 "Cluster Mode Disabled"를 선택하면 1개 Shard로 구성된다.
[Non-Cluster Mode 구조]
┌── 1개의 Shard ────────────────────────────┐
│ │
│ ┌──────────┐ ┌──────────┐ │
│ │ Primary │───→│ Replica │ (0~5개) │
│ │ 읽기/쓰기│ │ 읽기전용 │ │
│ └──────────┘ └──────────┘ │
│ │
│ 모든 데이터가 Primary 한 대에 저장 │
│ Replica는 Primary의 완전한 복사본 │
└────────────────────────────────────────────┘
특징:
- 1개 Shard: Primary 노드 1대 + Replica 노드 0~5대
- 모든 데이터가 Primary 한 대에 저장된다
- Replica는 Primary의 완전한 복사본 (읽기 전용)
- Replica의 용도: HA(고가용성) + 읽기 분산
- 슬롯 개념 없음 → CROSSSLOT 에러가 물리적으로 불가능
- Lua 스크립트에서 아무 키나 자유롭게 조합 가능
- 우리 프로젝트의 선택
Cluster Mode (Cluster Mode Enabled)
ElastiCache에서 "Cluster Mode Enabled"를 선택하면 여러 Shard로 구성된다.
[Cluster Mode 구조 -- 3 Shard 예시]
Shard 0 (Slot 0~5460) Shard 1 (Slot 5461~10922) Shard 2 (Slot 10923~16383)
┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐
│ Primary → Replica │ │ Primary → Replica │ │ Primary → Replica │
│ │ │ │ │ │
│ sessions:{avatar}:* │ │ sessions:{matrix}:* │ │ sessions:{topgun}:* │
│ (CRC16("avatar") │ │ (CRC16("matrix") │ │ (CRC16("topgun") │
│ → Slot 범위 내) │ │ → Slot 범위 내) │ │ → Slot 범위 내) │
└─────────────────────┘ └─────────────────────┘ └─────────────────────┘
특징:
- 여러 Shard: 각 Shard가 16,384개 슬롯 중 일부를 담당
- 데이터가 슬롯 기반으로 분산 → 메모리와 처리량의 수평 확장
- 제약: 한 Lua 스크립트에서 다른 슬롯의 키에 접근하면 CROSSSLOT 에러
- Hash Tag로 같은 슬롯에 배치할 수 있지만, 분산 효과를 상실
Cluster Mode 사용 전제조건:
- CROSSSLOT 해결: 모든 Lua 스크립트의 키에 Hash Tag 적용, 또는 단일 키 Lua로 분리
- 클라이언트 지원: Lettuce ClusterClientOptions, MOVED/ASK 리다이렉션 자동 처리
- 워크로드 분산 가능: 여러 키가 고르게 분산되어야 이점 존재 (Hot Key면 무의미)
- 운영 복잡성 감수: 슬롯 리밸런싱, 노드 추가/제거, Shard별 모니터링
Replica vs Cluster — 흔한 혼동 정리
이 두 개념은 완전히 다른 차원의 이야기다.
[Replica와 Cluster의 차이]
Replica = 같은 데이터의 복사본
┌──────────┐ ┌──────────┐
│ Primary │───→│ Replica │ 둘 다 "같은" 데이터를 갖고 있음
│ {A,B,C} │ │ {A,B,C} │ 목적: HA(고가용성), 읽기 분산
└──────────┘ └──────────┘
Cluster = 다른 데이터의 분할
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Shard 0 │ │ Shard 1 │ │ Shard 2 │
│ {A} │ │ {B} │ │ {C} │ 각각 "다른" 데이터를 분담
└──────────┘ └──────────┘ └──────────┘ 목적: 수평 확장
핵심:
- Non-Cluster에서 Replica 추가 ≠ Cluster Mode
- Replica를 3대 붙여도 여전히 Non-Cluster (1 Shard)
- 우리 구성: Non-Cluster + Multi-AZ Replica = HA 확보, 분산은 불필요
Multi-AZ와 Replica의 관계
Multi-AZ는 "Replica를 다른 AZ에 배치하는 설정"이다. Replica가 없으면 Multi-AZ도 없다.
[Multi-AZ 이해]
Replica 없음 (num_cache_clusters=1):
Primary (AZ-a) 만 존재
→ Multi-AZ 설정 불가
→ 장애 = 서비스 중단, 데이터 유실
Replica 1개, 같은 AZ (multi_az_enabled=false):
Primary (AZ-a) → Replica (AZ-a)
→ AZ-a 전체 장애 시 둘 다 죽음
→ failover 불가
Replica 1개, 다른 AZ (multi_az_enabled=true): ← 우리 선택
Primary (AZ-a) → Replica (AZ-c)
→ AZ-a 장애 → Replica(AZ-c) 자동 승격 → 서비스 계속
→ RTO ~30초, RPO ~0
흔한 혼동 정리:
- "Replica 여러 개 = Multi-AZ"가 아니다. Replica 3개가 전부 AZ-a에 있으면 Multi-AZ가 아님.
- "Multi-AZ = AZ를 분리 배치하는 설정"이다. Replica 1개여도 다른 AZ에 있으면 Multi-AZ.
- ElastiCache의
multi_az_enabled=true는 "Replica를 Primary와 다른 AZ에 자동 배치"한다.
왜 Replica 1개인가 (비용 vs 효과):
Replica 0개: $0.82/day → HA 없음. 장애 = 서비스 중단.
Replica 1개: $1.63/day → HA 확보. failover ~30초. 읽기 분산 83%. ← 최적
Replica 2개: $2.45/day → HA는 1개로 이미 충분. 추가 읽기 분산 효과 미미.
Replica 5개: $4.90/day → 과잉. 읽기 TPS가 ~6K인 CGV에서 불필요.
포트폴리오 규모에서는 Replica 1개가 비용 대비 효과가 가장 높다. 프로덕션 대규모 서비스에서는 Replica 2~3개로 읽기 분산과 failover 후보를 추가할 수 있다.
RTO / RPO
RTO (Recovery Time Objective) — 장애 발생 후 서비스 복구까지 걸리는 시간:
ElastiCache Auto-Failover:
장애 감지: ~10초
Replica 승격: ~20초
DNS 전환: 자동 (같은 endpoint)
──────────────────────
RTO = ~30초
RPO (Recovery Point Objective) — 장애 시 유실될 수 있는 데이터량:
비동기 복제 지연: ~1ms
→ 최악의 경우 장애 직전 ~1ms 분량의 쓰기 유실
→ 대기열은 수명 3~5분인 임시 데이터
→ 유실 시 "다시 접속해주세요" 안내 → 대기열 재등록
→ 예매 완료(RDS) 데이터는 Redis 장애와 무관
──────────────────────
RPO ≈ 0 (실질적 영향 없음)
면접에서 말할 때: "RTO 30초, RPO 사실상 0입니다. 대기열 데이터는 임시성이라 유실되어도 재등록으로 복구되고, 결제 완료 데이터는 RDS에 저장되어 Redis 장애와 무관합니다."
핵심 관계 체인: Lua → Hash Tag → Cluster → 우리의 선택
이 프로젝트에서 Non-Cluster를 선택한 이유는 하나의 논리 체인으로 설명된다.
[왜 Non-Cluster인가 — 의사결정 체인]
1. 대기열 원자성을 위해 Lua 스크립트가 필수
→ ZCARD active + ZADD (active or waiting) 을 원자적으로 실행해야 함
→ "빈자리 확인 → 입장/대기 등록"이 하나의 원자적 연산이어야 함
2. Lua 스크립트는 여러 키(waiting, active)를 조작
→ Cluster Mode에서는 모든 키가 같은 슬롯에 있어야 함
→ 다른 슬롯의 키를 Lua에서 조작하면 CROSSSLOT 에러
3. Hash Tag {movieId}로 같은 슬롯에 배치
→ sessions:{topgun}:waiting과 sessions:{topgun}:active가 같은 슬롯
→ Lua 스크립트에서 함께 조작 가능
4. 같은 영화의 모든 키가 같은 노드에 집중
→ 10만 명이 topgun에 집중 → 모든 ops가 한 Shard에 집중
→ Cluster Mode의 장점(데이터 분산)을 사용할 수 없음
5. 결론: Cluster Mode의 이점 없이 복잡성만 증가
→ Non-Cluster + Multi-AZ Replica가 최적
→ HA는 Replica failover로 확보
→ 읽기 분산은 read-from: replica-preferred로 확보
CROSSSLOT 에러란 무엇인가
[CROSSSLOT 에러]
발생 조건:
Cluster Mode에서 Lua 스크립트 또는 MULTI/EXEC 트랜잭션이
서로 다른 슬롯에 속한 키를 동시에 조작할 때 발생
에러 메시지:
(error) CROSSSLOT Keys in request don't hash to the same slot
예시 (Cluster Mode에서):
EVAL "redis.call('ZCARD', KEYS[1]); redis.call('ZADD', KEYS[2], ...)"
2 "sessions:topgun:waiting" "sessions:topgun:active"
↓ ↓
Slot 8923 Slot 12047
↓ ↓
다른 슬롯! → CROSSSLOT 에러 발생
해결 방법 (Cluster Mode를 써야 한다면):
Hash Tag 사용: sessions:{topgun}:waiting, sessions:{topgun}:active
→ 둘 다 CRC16("topgun") = Slot 5791 → 같은 슬롯 → Lua 동작 가능
우리 프로젝트에서는:
- Non-Cluster Mode이므로 슬롯 개념 자체가 없다
- 따라서 CROSSSLOT 에러가 물리적으로 불가능하다
- 키 이름에 Hash Tag
{movieId}를 유지하는 이유: 미래 Cluster Mode 전환 시 Lua 스크립트 무변경 (단, Lettuce ReadFrom 설정과 클라이언트 리다이렉션 설정은 변경 필요)
ElastiCache Replication Group 구조
우리 프로젝트의 실제 Prod 구성이다.
[Prod 환경 — ElastiCache Replication Group]
┌────────────────────────────────────────────┐
│ Replication Group (cgv-redis) │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Primary │ │ Replica │ │
│ │ cache.t3.small│ │ cache.t3.small│ │
│ │ AZ-a │ │ AZ-c │ │
│ │ 읽기/쓰기 │───→│ 읽기 전용 │ │
│ └──────────────┘ └──────────────┘ │
│ ↑ ↑ │
│ Primary Endpoint Reader Endpoint │
│ (xxx.ng.0001.apn2. (xxx-ro.ng.0001. │
│ cache.amazonaws.com) cache.amazonaws.com)│
└────────────────────────────────────────────┘
비용: ~$1.63/day (2 × cache.t3.small × $0.034/hr)
Endpoint 사용:
- Primary Endpoint: 쓰기 + 읽기 (기본 연결 대상). Spring Boot의
spring.data.redis.host에 설정. - Reader Endpoint: 읽기 전용.
read-from: replica-preferred사용 시 Lettuce가 자동으로 활용. - Auto Failover: Primary 장애 시 → Replica가 Primary로 승격 (~30초)
- DNS가 자동 전환되므로 애플리케이션의 endpoint 설정 변경이 불필요하다
Hot Key 분석
"인기 영화 1편에 10만 명 집중" 시나리오에서 Redis 부하가 특정 키에 집중된다.
[Hot Key 문제]
10만 명이 "topgun"에 집중
→ sessions:{topgun}:waiting에 모든 ZADD/ZRANK/ZRANGE 집중
→ sessions:{topgun}:active에 모든 ZCARD/ZADD/ZREM 집중
→ 두 키가 모든 ops를 받는 "Hot Key"
현재 부하 (피크 기준):
데이터 크기: ~15MB (waiting) + ~0.75MB (active) = ~16MB (Hot Key 대상)
전체 Redis 사용: ~26MB (session String 등 포함, 아래 §3 메모리 계산 참고)
초당 명령: ~6,000 ops/sec (피크)
Redis 처리 용량: cache.t3.small = ~25,000 ops/sec
사용률: 6,000 / 25,000 = 24% → 충분한 여유
Cluster Mode로 바꿔도 해결되지 않는 이유:
Hash Tag {topgun} 때문에 waiting과 active가 같은 슬롯
→ 같은 Shard의 같은 Primary에 모든 ops 집중
→ Shard를 3개로 늘려도 topgun 트래픽은 1개 Shard에 집중
→ Cluster Mode의 분산 효과를 사용할 수 없음
결론: Hot Key 문제는 수직 스케일링으로 대응
cache.t3.small (~25K ops) → cache.r6g.large (~100K+ ops)
→ 현재 24%이므로 당장은 불필요. 모니터링하다가 70% 넘으면 스케일업.
모니터링과 대응:
| 메트릭 | 임계값 | 대응 |
|---|---|---|
redis_commands_processed_total (rate) |
> 17,500 ops/sec (70%) | 스케일업 검토 |
redis_memory_used_bytes |
> 820 MB (80%) | 스케일업 실행 |
redis_connected_clients |
> 200 | 연결 풀 점검 |
스케일업 경로: cache.t3.small (25K) -> cache.t3.medium -> cache.r6g.large(100K)
25,000 ops/sec 근거: AWS ElastiCache 벤치마크 기준 cache.t3.small의 처리량. 실제 성능은 명령어 복잡도와 데이터 크기에 따라 변동하며, k6 부하테스트 중
redis_commands_processed_total메트릭으로 실측한다
Lua 블로킹 시간:
QueueProcessor가 Lua 스크립트로 최대 5,000명을 한 번에 이동한다. Lua 실행 중 Redis는 다른 명령을 처리하지 못한다 (single-threaded).
Lua 내부: 5,000 × (ZREM + ZADD) = 10,000 Redis 명령
실행 시간: ~50-100ms (Redis 벤치마크 기준)
QueueProcessor 주기: 2,000ms (2초)
차지 비율: 100ms / 2,000ms = 5% → 나머지 95%는 다른 명령 처리 가능
→ 허용 가능한 수준. 블로킹이 200ms를 넘으면 스크립트 분할 필요.
read-from: replica-preferred 최적화
Non-Cluster + Replica 구성에서 읽기 요청을 Replica로 분산하여 Primary 부하를 줄인다.
// Lettuce 설정으로 읽기 요청을 Replica로 분산
@Bean
public LettuceClientConfigurationBuilderCustomizer lettuceCustomizer() {
return builder -> builder.readFrom(ReadFrom.REPLICA_PREFERRED);
}
[read-from: replica-preferred 동작]
쓰기 명령 (ZADD, ZREM, SET, DEL) → Primary로 전송
읽기 명령 (ZCARD, ZRANK, ZSCORE, GET, EXISTS) → Replica로 전송 (우선)
Replica 불가 시 Primary fallback
[Primary 부하 감소 효과]
대기열 시스템의 Redis 명령 비율:
읽기: ZCARD(빈자리 확인), ZRANK(순번 조회), EXISTS(세션 검증) → ~83%
쓰기: ZADD(등록), ZREM(제거), SET(세션 생성) → ~17%
read-from 적용 전: Primary가 100% 처리
read-from 적용 후: Primary ~17%, Replica ~83%
→ Primary 부하 ~83% 감소
주의사항:
- Replica 복제 지연: ~1ms (비동기 복제)
- 최신 데이터가 아닐 수 있음: 방금 ZADD한 데이터가 Replica에 아직 없을 수 있음
- 대기열 순번 조회에는 1ms 지연이 문제되지 않음 (사용자는 "3,422번째" vs "3,423번째" 차이를 인지하지 못함)
- 입장 여부 판단(Lua 스크립트)은 Primary에서 실행되므로 정확성에 영향 없음
2. CGV 대기열 시스템의 Redis 아키텍처
Redis 키 구조 전체 맵
CGV 프로젝트에서 사용하는 모든 Redis 키와 그 관계를 정리한다.
[Redis 키 구조 -- 영화 "topgun"의 예시]
sessions:{topgun}:waiting (Sorted Set)
├── score: 1707120000000, member: "req-aaa:sess-111" ← 첫 번째 대기자
├── score: 1707120000500, member: "req-bbb:sess-222" ← 두 번째 대기자
├── score: 1707120001000, member: "req-ccc:sess-333"
└── ... (최대 100,000명)
sessions:{topgun}:active (Sorted Set)
├── score: 1707120002000, member: "req-xxx:sess-444" ← 입장한 사용자
├── score: 1707120003000, member: "req-yyy:sess-555"
└── ... (최대 5,000명)
session:sess-111 (String, TTL 1시간)
└── value: "1"
session:sess-222 (String, TTL 1시간)
└── value: "1"
active_movies (Set)
└── members: {"topgun"}
→ QueueProcessor가 SMEMBERS로 조회해 각 영화의 대기열을 독립 처리.
동시 다중 영화 예매 시 자동 확장.
waiting_movies (Set)
└── members: {"topgun"}
── 좌석 선점 키 (Active Session 사용자가 좌석을 선택할 때 사용) ──
seat:{topgun}:{imax-yongsan}:{A-10} (String, NX EX 300)
└── value: "req-aaa:sess-111" ← 선점한 사용자의 requestId
→ SET NX로 원자적 획득. 5분 TTL. 만료 시 자동 해제.
booked:{topgun}:{imax-yongsan} (Set)
└── members: {"A-01", "A-02", "B-05", ...}
→ 예매 확정된 좌석 ID들. SADD로 추가. TTL 없음.
booking:completed:{topgun} (String, INCR)
└── value: "1200"
→ 매진 감지 카운터. 값 == 6,000이면 SOLD_OUT.
theater:{topgun}:info (Hash)
└── fields: {"capacity": "6000", "theaterCount": "20"}
→ 상영관 메타정보.
키 네이밍에 {movieId}를 쓰는 이유 -- Redis Cluster Hash Tag:
우리는 ElastiCache Non-Cluster Mode(Cluster Mode Disabled)이므로 Hash Tag가 필수는 아니다. 하지만 키 네이밍에
{movieId}를 넣어두면 향후 Cluster Mode 전환 시 Lua 스크립트가 그대로 동작한다. 좌석 선점 키(seat, booked, booking:completed)도 동일한{movieId}Hash Tag를 사용하여 booking_complete.lua에서 Active 제거 + 좌석 확정을 원자적으로 처리할 수 있다.
sessions:{topgun}:waiting
sessions:{topgun}:active
^^^^^^^
Hash Tag: 중괄호 안의 문자열로 해시 슬롯이 결정됨
Redis Cluster는 키를 16,384개 슬롯에 분산한다. 같은 슬롯에 있는 키끼리만 Lua 스크립트에서 함께 조작할 수 있다. {topgun}이라는 Hash Tag를 사용하면 sessions:{topgun}:waiting과 sessions:{topgun}:active가 반드시 같은 슬롯에 배치된다. 이것이 Lua 스크립트에서 두 키를 원자적으로 조작할 수 있는 전제 조건이다.
Hash Tag가 없으면 sessions:topgun:waiting과 sessions:topgun:active가 다른 슬롯에 배치될 수 있고, Lua 스크립트 실행 시 CROSSSLOT 에러가 발생한다.
슬롯 결정 원리 — CRC16 해시:
Redis Cluster는 키 이름에 CRC16 해시 함수를 적용하여 16,384개 슬롯 중 하나에 배치한다.
[Hash Tag 없이]
slot = CRC16("sessions:topgun:waiting") % 16384 = 8923
slot = CRC16("sessions:topgun:active") % 16384 = 12047
→ 다른 슬롯! → 서로 다른 Redis 노드에 저장될 수 있음
→ Lua 스크립트에서 두 키를 동시에 조작하면 CROSSSLOT 에러
[Hash Tag 적용]
키에 중괄호 {}가 있으면, 중괄호 안의 문자열만으로 해시를 계산한다:
> **핵심**: Redis Cluster의 Hash Tag는 키 이름에서 **첫 번째 `{}`** 안의 문자열만 사용한다.
> `seat:{topgun}:{imax-yongsan}:{A-10}`에서 `{topgun}`만 Hash Tag이고, `{imax-yongsan}`과 `{A-10}`은 단순 키 이름이다.
slot = CRC16("topgun") % 16384 = 5791 ← {} 안의 문자열만 해싱
sessions:{topgun}:waiting → CRC16("topgun") % 16384 = 5791
sessions:{topgun}:active → CRC16("topgun") % 16384 = 5791
→ 전부 슬롯 5791! → 반드시 같은 Redis 노드에 저장
→ Lua 스크립트에서 함께 조작 가능
주의사항:
- Hash Tag가 없는 키(예:
active_movies)는 Lua 스크립트에서 Hash Tag 키와 함께 사용할 수 없다 - 같은
{movieId}를 쓰는 키끼리만 하나의 Lua 스크립트에서 조작할 수 있다 - 영화가 다르면(
{topgun}vs{avatar}) 다른 슬롯이므로, 한 Lua에서 두 영화의 키를 동시에 조작할 수 없다 — CGV에서는 영화별로 독립 처리하므로 문제없다
대기열 진입 흐름
[Redis 키 관점의 진입 흐름]
POST /api/admission/enter → Lua 스크립트 (원자적):
1. ZCARD sessions:{topgun}:active → 현재 Active 수 확인
2. Active < 5,000?
YES → ZADD sessions:{topgun}:active {timestamp} user → ADMITTED
NO → ZADD sessions:{topgun}:waiting {timestamp} user
ZRANK sessions:{topgun}:waiting user → rank (0-based)
→ QUEUED (rank+1을 사용자에게 반환)
핵심: ZCARD와 ZADD가 하나의 Lua 스크립트 안에서 실행된다. Redis 싱글 스레드이므로 Lua 실행 중 다른 명령 불가 → 정원 초과가 물리적으로 불가능. (3편 Lua 참고)
QueueProcessor 흐름
@Scheduled(fixedDelay = 2000) — 매 2초 실행
1. SMEMBERS active_movies → 처리할 영화 목록
2. ZCARD sessions:{movie}:active → 현재 Active 수
3. vacant = maxSessions - active → 빈자리 계산
4. ZRANGE sessions:{movie}:waiting 0 (vacant-1) → 대기열 앞에서 N명 추출
5. Lua: ZREM waiting + ZADD active → 원자적 이동
6. WebSocket /topic/admission/{requestId} → 입장 알림
7. WebSocket /topic/stats/movie/{movieId} → 대기열 현황 브로드캐스트
처리량: vacant에 의해 결정. BATCH_SIZE=5,000은 상한일 뿐이다.
입장 후 흐름과 세션 관리
Active 상태의 사용자는 좌석 선택 페이지로 이동한다. 좌석 선점/결제는 대기열 시스템 범위 밖이므로 여기서 다루지 않는다. Active 사용자가 예매를 완료하거나 세션이 타임아웃되면 Active에서 제거되어 다음 대기자가 입장한다.
세션 타임아웃:
[Active 세션 타임아웃 -- 코드 기본값 30초 / Prod 설계 600초(10분)]
Active에 진입한 시간 = ZSCORE sessions:{topgun}:active "req-abc:sess-123"
→ 1707120002000 (입장 시간)
현재 시간 = 1707120032000 (30초 경과)
ZRANGEBYSCORE sessions:{topgun}:active 0 {현재-타임아웃}
→ 타임아웃 초과 세션들 반환
ZREMRANGEBYSCORE sessions:{topgun}:active 0 {현재-타임아웃}
→ 타임아웃 초과 세션 일괄 제거
→ 빈자리 생김 → QueueProcessor가 다음 대기자 입장
> 코드의 @Value("${SESSION_TIMEOUT_SECONDS:30}")가 기본값 30초.
> Prod에서는 values-prod.yaml로 SESSION_TIMEOUT_SECONDS=600(10분)을 주입.
> 아래 "좌석 선점 타임아웃 관리" 섹션에서 600초(10분) 기준으로 상세 설명.
전체 아키텍처
[CGV Redis 전체 아키텍처]
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ 사용자 A │ │ 사용자 B │ │ 사용자 10만 │
│ (브라우저) │ │ (브라우저) │ │ (브라우저) │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ WebSocket │ WebSocket │ WebSocket
│ + REST API │ + REST API │ + REST API
▼ ▼ ▼
┌──────────────────────────────────────────────────────────┐
│ ALB (로드밸런서) │
│ idle_timeout=300s │
└──────────────────────────┬───────────────────────────────┘
│
┌───────────────┼───────────────┐
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Pod 1 │ │ Pod 2 │ │ Pod 10 │
│ Spring │ │ Spring │ │ Spring │
│ Boot │ │ Boot │ │ Boot │
│ │ │ │ │ │
│ Queue │ │ Queue │ │ Queue │
│Processor│ │Processor│ │Processor│
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
└──────────────┼──────────────┘
│
▼
┌───────────────────────┐
│ Redis │
│ │
│ Sorted Set (대기열) │
│ ┌─ waiting:topgun │
│ └─ active:topgun │
│ │
│ String (세션) │
│ └─ session:{id} │
│ │
│ Set (영화 목록) │
│ └─ active_movies │
│ │
│ Pub/Sub (미구현) │
│ └─ queue:notifications│
└───────────────────────┘
3. Redis 운영과 성능
메모리 사용 계산
CGV 10만 명 대기열의 Redis 메모리 사용량을 계산한다.
결론: 전체 약 30MB -> cache.t3.small(1 GiB) 기준 ~3% 사용. 메모리 부족은 고려 대상이 아니다.
[Sorted Set 멤버 1개의 메모리]
member: "req-abc-123-def:sess-xyz-789-ghi" → ~70 bytes (UUID 2개)
score: 1707120000000 (8 bytes)
Redis 내부 오버헤드: ~80 bytes (skiplist 노드, 포인터 등)
───────────────────────────────────────────
총: ~150 bytes / 멤버
[10만 명 대기열]
waiting Sorted Set: 100,000 x 150 bytes = ~15 MB
active Sorted Set: 5,000 x 150 bytes = ~0.75 MB
session String: 100,000 x 100 bytes = ~10 MB (key + value + overhead)
───────────────────────────────────────────
총: ~26 MB (영화 1편, 10만 대기 + 5천 Active 기준)
[좌석 선점 데이터의 메모리 (영화 1편, 극장 20개, 총 6,000석)]
seat:{movieId}:{theaterId}:{seatId} (String):
키: ~60 bytes, 값: ~30 bytes = ~90 bytes/좌석
최대 6,000좌석 동시 선점: 6,000 × 90 = ~540 KB
booked:{movieId}:{theaterId} (Set):
멤버당 ~60 bytes × 6,000석 = ~360 KB
booking:completed:{movieId} (String): ~50 bytes
theater:{movieId}:info (Hash): ~200 bytes × 20극장 = ~4 KB
───────────────────────────────────────────
좌석 소계: ~900 KB (영화 1편)
동시 5개 영화: ~4.5 MB
[전체 합계]
대기열: ~26 MB + 좌석: ~4.5 MB = ~30.5 MB
→ ElastiCache cache.t3.small (~1.37 GiB, maxmemory ~1 GiB) 기준 ~3% 사용. 여유 충분.
→ 이 메모리 사용량은 k6 부하테스트 중 Grafana의 redis_memory_used_bytes
메트릭으로 실시간 확인한다 (→ 4. 모니터링 파트 참고).
TTL과 만료 전략
Redis의 TTL(Time To Live)은 키가 자동으로 삭제되는 시간이다.
[CGV의 TTL 설정]
session:{id} EX 3600 (1시간 후 삭제, 슬라이딩 갱신)
수동 TTL이 없는 키:
sessions:{movie}:waiting → TTL 없음. QueueProcessor가 ZREM으로 관리.
sessions:{movie}:active → TTL 없음. 타임아웃은 ZRANGEBYSCORE로 직접 감지.
active_movies → TTL 없음. 대기열 종료 시 SREM으로 제거.
Sorted Set에는 개별 멤버에 TTL을 설정할 수 없다 (Redis 한계).
대신 score(타임스탬프)를 이용한 수동 만료 처리를 한다.
[수동 만료 처리]
score = 입장 시간(타임스탬프)
현재 시간 = now
타임아웃 확인:
ZRANGEBYSCORE sessions:{movie}:active 0 (now - 30000)
→ 30초 무응답 사용자들 = 타임아웃 대상
타임아웃 처리:
ZREMRANGEBYSCORE sessions:{movie}:active 0 (now - 30000)
→ 일괄 제거
타임아웃 값 참고: 위 예시의
30000(30초)은 코드의 기본값이다.
Prod 환경에서는 values-prod.yaml을 통해SESSION_TIMEOUT=600(10분)을 주입한다.
아래 섹션 "좌석 선점 타임아웃 관리"에서 설명하는 600초(10분)가 Prod 설계값이다.
연결 풀 (Connection Pool)
Spring Boot에서 Redis 연결을 관리하는 방식이다.
# application.yml
spring:
data:
redis:
lettuce:
pool:
max-active: 8 # local (동시 연결 최대 8개)
# max-active: 20 # dev
# max-active: 50 # prod
| 설정 | local | dev | prod | 설명 |
|---|---|---|---|---|
| max-active | 8 | 20 | 50 | 동시 Redis 연결 최대 수 |
| timeout | 2s | 3s | 5s | 연결 대기 시간 |
| SSL | false | false | true | 암호화 |
| host | localhost | redis.cgv-dev.svc | xxx.cache.amazonaws.com | 주소 |
왜 prod에서 max-active가 50인가:
Pod 1개당:
- QueueProcessor: 2초마다 여러 Redis 명령어 실행
- WebSocket 알림: 입장 처리된 사용자들에게 동시 발송
- 세션 검증: REST API 요청마다 EXISTS 호출
- Pub/Sub: 구독 리스너 전용 연결 (구현 시)
Peak 시 동시 Redis 호출: ~30~40개
→ max-active: 50 (약간의 여유)
→ 부족하면 "Could not get a resource from the pool" 에러 발생
→ 부하테스트 시 redis_pool_active_connections 메트릭으로 풀 포화 여부를 모니터링한다.
Dev vs Prod 구성
[Dev 환경 -- Redis Pod]
cgv-dev namespace에 Redis Pod 직접 배포
bitnami/redis Helm Chart 사용
┌─────────────┐ ┌──────────────┐
│ cgv-api │ ───→ │ Redis Pod │
│ (dev Pod) │ │ (6379) │
│ 1개 │ │ 비밀번호 없음 │
└─────────────┘ └──────────────┘
장점: AWS 비용 없음, 즉시 시작
단점: Pod 죽으면 데이터 소멸, 고가용성 없음
[Prod 환경 -- ElastiCache]
AWS ElastiCache (Redis 호환)
┌──────────────┐
│ cgv-api │ ┌──────────────────────────┐
│ (prod Pods) │ ───→ │ ElastiCache │
│ 2~10개 │ │ cache.t3.small │
└──────────────┘ │ Primary + Replica │
│ Multi-AZ │
│ SSL + AUTH │
│ 자동 failover │
└──────────────────────────┘
장점: 고가용성, 자동 백업, 관리 불필요
비용: ~$0.034/hr × 2 = ~$1.63/day
Redis 성능 특성
| 연산 | 시간복잡도 | 10만 멤버에서 | 설명 |
|---|---|---|---|
| ZADD | O(log N) | ~0.01ms | 대기열 등록 |
| ZCARD | O(1) | ~0.001ms | 대기열 크기 |
| ZRANK | O(log N) | ~0.01ms | 순번 조회 |
| ZRANGE 0 99 | O(log N + M) | ~0.02ms | 상위 100명 추출 |
| ZREM | O(log N) | ~0.01ms | 멤버 제거 |
| SET NX | O(1) | ~0.001ms | 분산 락 |
Redis는 single-threaded지만 초당 10만~30만 명령어를 처리할 수 있다. CGV의 피크 시 Redis 부하는 초당 ~5,000 명령어 수준이므로 여유가 충분하다. k6로 10만 트래픽을 발생시켜 실제 Redis 처리량을 검증한다 (→ 4. 모니터링 참고).
WRONGTYPE 에러 방어
Redis에서 키의 데이터 타입이 맞지 않으면 WRONGTYPE 에러가 발생한다.
# String 키에 Sorted Set 명령어를 실행하면:
SET mykey "hello"
ZADD mykey 100 "member"
→ (error) WRONGTYPE Operation against a key holding the wrong kind of value
# 원인: 이전에 같은 키 이름으로 다른 타입이 저장됨
# 해결: TYPE 명령어로 확인 후 처리
CGV 코드에서의 방어:
// AdmissionService.java -- WRONGTYPE 방어 패턴
String actualType = redisTemplate.type(activeKey).name();
if (!"NONE".equals(actualType) && !"ZSET".equals(actualType)) {
// 잘못된 타입의 키가 존재 → 삭제 후 재생성
redisTemplate.delete(activeKey);
log.warn("WRONGTYPE 감지: {} 키를 재생성합니다", activeKey);
}
4. Redis 장애 대응과 운영
프로덕션에서 Redis에 문제가 생겼을 때 대기열 시스템이 어떻게 동작하는지 정리한다.
연결 실패 시 동작
[Redis 연결 실패 시 동작 흐름]
Spring Boot (Lettuce)
│
├─ Lettuce auto-reconnect: 기본 활성화
│ → 연결이 끊어지면 자동으로 재연결 시도
│ → 지수 백오프(exponential backoff)로 재시도 간격 증가
│
├─ 재연결 중 Redis 명령 실행 시:
│ → RedisConnectionFailureException 발생
│ → Spring 레벨에서 catch 가능
│
├─ QueueProcessor:
│ → @Scheduled(fixedDelay = 2000)이므로 한 주기 실패해도
│ → 다음 주기(2초 후)에 자동 재시도
│ → 대기열 처리가 2초 지연될 뿐, 데이터 유실 없음
│
└─ WebSocket 알림:
→ Redis 명령 실패로 알림 유실 가능
→ 다음 QueueProcessor 주기에서 재발행
→ 사용자는 최대 2~4초 지연 후 알림 수신
Failover 중 중단 (~30초)
[ElastiCache Auto Failover 흐름]
정상 상태:
Primary (AZ-a) ─── 비동기 복제 ──→ Replica (AZ-c)
↑ 모든 쓰기/읽기
Primary 장애 발생:
Primary (AZ-a) ✕ Replica (AZ-c) ← ElastiCache가 감지 (~10초)
Failover 시작:
Primary (AZ-a) ✕ Replica (AZ-c) → 새 Primary로 승격 (~20초)
Failover 완료:
(구 Primary) 새 Primary (AZ-c)
↑ DNS 자동 전환 — 같은 endpoint로 연결됨
전환 중 (~30초):
- 쓰기 명령: 실패 (RedisConnectionFailureException)
- 읽기 명령: Replica가 승격 중이므로 실패 가능
- 대기열 진입: 실패 → 사용자에게 "잠시 후 다시 시도" 응답
- QueueProcessor: 2~3 주기 실패 후 정상 재개
대기열 데이터 유실 가능성:
- Redis 데이터는 메모리에만 존재한다 (RDB/AOF 사용하지 않음)
- Primary 장애 시 비동기 복제 지연(~1ms) 분량의 데이터가 유실될 수 있다
- 대응: 대기열은 수명 3~5분의 임시 데이터다
- 사용자에게 "다시 접속해주세요" 안내 → 대기열 재등록
- 예매 완료 데이터(RDS에 저장)는 영향 없음
- 대기열 유실이 곧 금전적 손실로 이어지지 않음
maxmemory 도달 시
[maxmemory 도달 시 동작]
ElastiCache cache.t3.small: maxmemory ≈ 1 GiB (총 1.37 GiB, reserved 제외)
우리 데이터: ~26 MB (10만 대기 + 5천 Active)
사용률: ~26 MB / ~1 GiB ≈ 2.5% → 도달 가능성 극히 낮음
만약 도달하면:
정책: noeviction (기본값으로 사용)
→ 기존 키를 임의로 삭제하지 않음 (대기열 데이터 보호)
→ 대신 쓰기 명령이 실패함
ZADD sessions:{topgun}:waiting ...
→ (error) OOM command not allowed when used memory > 'maxmemory'
읽기 명령(ZCARD, ZRANK)은 정상 동작
모니터링:
Grafana에서 redis_memory_used_bytes 메트릭으로 추적
알림 설정: 메모리 사용량 > 80% (~820 MB) 시 경고
대응: cache.t3.small → cache.t3.medium (~3.09 GiB)로 스케일업
좌석 선점 타임아웃 관리
좌석 선점은 Redis TTL(EX 300초)로 자동 관리된다. 대기열 타임아웃(SessionTimeoutProcessor)과는 별개의 메커니즘이다.
[타임아웃 2종류 비교]
좌석 선점 TTL (300초 = 5분):
메커니즘: SET NX EX 300 → Redis가 자동 만료
만료 시: 좌석만 해제, Active 세션은 유지 (다른 좌석 재선점 가능)
모니터링: redis_keys_expired_total (Prometheus)
Active 세션 타임아웃 (600초 = 10분):
메커니즘: SessionTimeoutProcessor가 10초마다 체크 → ZRANGEBYSCORE로 만료 감지
만료 시: 전체 세션 종료, Active 슬롯 반환 → 대기열 다음 사용자 승격
모니터링: cgv_active_sessions_count (Prometheus)
[시나리오]
사용자가 좌석 A-10을 선점 → 3분 경과 → 결제 완료
→ 좌석 TTL 잔여 2분이지만, /api/admission/complete 호출로 즉시 정리
→ Active 슬롯 반환 + 좌석 확정 (booking_complete.lua)
사용자가 좌석 A-10을 선점 → 5분 경과 → 결제 미완료
→ Redis TTL 만료 → 좌석 자동 해제 → 다른 사용자가 선점 가능
→ Active 세션은 유지 (나머지 5분 동안 다른 좌석 선택 가능)
사용자가 좌석 선점 후 완전 이탈 → 10분 경과
→ 5분: 좌석 TTL 만료 (좌석 해제)
→ 10분: Active 타임아웃 (세션 종료, 슬롯 반환)
⚠ DevOps 고려사항: 결제 지연 시 TTL 연장 (미구현)
결제 게이트웨이가 느린 경우(PG사 장애 등) 5분 내 결제 완료가 불가능할 수 있다.
향후 구현 시: EXPIRE 명령으로 TTL 연장 (최대 1회, +3분) +cgv_seat_ttl_extended_total메트릭 추가.
현재는 TTL 만료 → 좌석 자동 해제 → 사용자가 재선점하는 흐름으로 동작한다.
'Redis' 카테고리의 다른 글
| Redis vs LevelDB (0) | 2026.02.12 |
|---|---|
| Redis Lua와 Pub/Sub (0) | 2026.02.12 |
| Redis 기본 개념 — 왜 메모리이고, 왜 Sorted Set인가 (0) | 2026.02.12 |