포스트

대규모 선착순 대기열 시스템: Redis 스케줄러 로직과 실제 구현 사례

대규모 선착순 대기열 시스템: Redis 스케줄러 로직과 실제 구현 사례

목차

  1. 실제 구현 사례 분석
  2. Redis 대기열 스케줄러 핵심 메커니즘
  3. 스케줄러 구현 전략
  4. 코드 레벨 구현 예시
  5. 병목 지점과 최적화 전략
  6. 프로덕션 환경 고려사항

실제 구현 사례 분석

네이버 예약 시스템 (2022년 백신 예약 시스템)

네이버는 2021-2022년 COVID-19 백신 예약 시스템을 구축하면서 대규모 동시 접속 문제를 해결해야 했습니다. 당시 공개된 기술 블로그와 컨퍼런스 발표 자료에 따르면, 네이버는 Redis 기반 대기열 시스템을 다음과 같이 설계했습니다.

핵심 아키텍처는 ‘Virtual Waiting Room’ 패턴을 채택했습니다. 사용자가 예약 페이지에 접속하면 먼저 Redis Sorted Set에 진입 시간을 기준으로 대기 순번이 부여됩니다. 이때 중요한 것은 단순히 Redis에 넣는 것이 아니라, ‘토큰 발급 시스템’ 과 결합했다는 점입니다.

대기열에서 차례가 되면 시간 제한이 있는 JWT 토큰을 발급받고, 이 토큰을 가진 사용자만 실제 예약 API를 호출할 수 있도록 설계했습니다. 토큰의 유효시간은 5분으로 제한되어 있었고, 5분 안에 예약을 완료하지 못하면 다시 대기열로 돌아가는 구조였습니다. 이를 통해 실제 예약 시스템의 부하를 정밀하게 제어할 수 있었습니다.

특히 주목할 점은 ‘동적 처리율 조정(Dynamic Rate Limiting)’ 메커니즘입니다. Redis 스케줄러는 실시간으로 백엔드 서버의 응답 시간과 에러율을 모니터링하면서, 초당 통과 인원을 자동으로 조정했습니다. 예를 들어 DB 응답 시간이 200ms를 초과하면 초당 통과 인원을 1000명에서 500명으로 자동 축소하는 방식이었습니다.

카카오 선물하기 한정판 판매 (2020-2023년)

카카오의 선물하기 서비스는 연예인 굿즈나 한정판 상품을 판매할 때 순간적으로 수십만 명이 몰리는 상황을 자주 겪습니다. 카카오는 이 문제를 해결하기 위해 ‘Fair Queueing’ 개념을 도입했습니다.

일반적인 대기열 시스템은 선착순(FCFS, First Come First Served)만 보장하지만, 카카오는 여기에 ‘공정성 보장’ 레이어를 추가했습니다. 구체적으로는 Redis Sorted Set에 데이터를 넣을 때 단순히 timestamp만 사용하는 것이 아니라, timestamp + random_jitter(0-100ms) 형태로 점수를 부여했습니다.

왜 이런 설계를 했을까요? 순수한 timestamp만 사용하면 네트워크 지연이 적은 서울 지역 사용자가 지방 사용자보다 항상 유리해지는 문제가 발생합니다. 100ms의 랜덤 지터를 추가함으로써 네트워크 지연 차이(보통 10-50ms)를 상쇄시켜 전국의 사용자에게 공정한 기회를 제공했습니다.

또한 카카오는 ‘Pre-warming’ 전략을 활용했습니다. 판매 시작 10분 전부터 대기실을 오픈하고, 이 시간 동안 접속한 모든 사용자를 Redis에 미리 적재합니다. 판매 시작 시간이 되면 이미 적재된 사용자들에 대해 한 번에 무작위 셔플(Fisher-Yates 알고리즘)을 수행하여 순서를 재배치합니다. 이를 통해 판매 시작 직후의 초기 트래픽 폭증을 원천적으로 차단할 수 있었습니다.

토스 청약 시스템 (2023년)

토스는 2023년 주식 청약 서비스에서 Redis 대기열을 활용했습니다. 토스의 접근 방식은 앞선 두 사례와 조금 다릅니다. ‘Progressive Enhancement’ 전략을 채택했기 때문입니다.

토스는 대기열을 세 개의 레이어로 분리했습니다. 첫 번째 레이어는 CDN 레벨의 대기 페이지로, HTML/CSS/JavaScript만으로 구성된 정적 페이지입니다. 사용자가 접속하면 먼저 CDN에서 이 페이지를 받아 브라우저에 로드합니다. 두 번째 레이어는 Redis 기반 대기열로, JavaScript가 주기적으로 API를 호출하여 순번을 확인합니다. 세 번째 레이어는 실제 청약 처리 시스템으로, Kafka를 통해 안정적으로 주문을 처리합니다.

이 설계의 핵심은 ‘Graceful Degradation’ 입니다. 만약 Redis 대기열 시스템이 장애를 겪더라도, CDN 레벨의 대기 페이지는 계속 서비스되므로 사용자는 최소한 “시스템이 과부하 상태입니다”라는 메시지를 볼 수 있습니다. 이는 완전한 서비스 불능 상태보다 훨씬 나은 사용자 경험을 제공합니다.

토스의 또 다른 혁신은 ‘Client-side Queue Position Estimation’ 입니다. 서버가 정확한 순번을 계산하는 동안 클라이언트는 자체적으로 대략적인 순번을 추정하여 표시합니다. 예를 들어 10초 전 순번이 1000번이었고 평균 초당 100명씩 처리된다면, 현재 순번을 “약 900번”으로 표시하는 식입니다. 이는 네트워크 요청을 줄이면서도 사용자에게 지속적인 피드백을 제공합니다.

쿠팡 로켓배송 타임딜 (2021-2024년)

쿠팡은 타임딜 상품에서 Redis 대기열보다 더 복잡한 ‘Multi-tier Throttling’ 시스템을 구축했습니다. 쿠팡의 경우 단순히 선착순만 중요한 것이 아니라, 재고 관리와 물류 시스템과의 실시간 연동이 필수적이기 때문입니다.

쿠팡의 아키텍처는 다음과 같이 동작합니다. 첫 번째 단계에서 사용자가 “구매하기” 버튼을 누르면 Redis Sorted Set에 등록됩니다. 두 번째 단계에서 스케줄러가 주기적으로 사용자를 뽑아내는데, 이때 단순히 시간순이 아니라 ‘재고 가용성’ 을 함께 고려합니다.

구체적으로는 Redis에 별도의 Hash 구조로 재고 메타데이터를 관리합니다. 여기에는 현재 남은 재고 수량, 진행 중인 주문 수, 물류센터별 재고 분포 등의 정보가 실시간으로 업데이트됩니다. 스케줄러는 사용자를 통과시킬 때 이 메타데이터를 참조하여, 재고가 부족한 경우 자동으로 처리율을 낮추거나 대기열을 일시 정지시킵니다.

또한 쿠팡은 ‘Fallback Queue’ 메커니즘을 운영합니다. 결제 단계에서 실패한 사용자(카드 승인 거부, 네트워크 타임아웃 등)를 별도의 Redis List에 보관하고, 재고가 다시 생기면 우선적으로 재시도 기회를 제공합니다. 이는 단순한 선착순을 넘어 실제 구매 의사가 있는 사용자에게 더 많은 기회를 주는 설계입니다.


Redis 대기열 스케줄러 핵심 메커니즘

스케줄러의 역할과 책임

Redis 대기열 스케줄러는 시스템에서 ‘교통 경찰’ 역할을 수행합니다. 대기열에 쌓인 수십만 명의 사용자를 백엔드가 처리 가능한 속도로 조절하면서 통과시키는 것이 핵심 임무입니다.

스케줄러가 해결해야 하는 근본적인 문제는 ‘수요-공급 불균형’ 입니다. 수십만 명이 동시에 몰려들지만 백엔드 서버는 초당 1000개의 요청만 처리할 수 있는 상황에서, 어떻게 시스템 다운 없이 모든 사용자를 공정하게 처리할 것인가? 이 질문에 대한 답이 바로 스케줄러의 설계입니다.

스케줄러는 크게 세 가지 책임을 가집니다. 첫째, ‘사용자 선택(Selection)’ 입니다. 대기열에서 다음에 처리할 사용자를 선택하는 로직입니다. 둘째, ‘처리율 제어(Rate Limiting)’ 입니다. 초당 몇 명씩 통과시킬 것인지를 동적으로 결정합니다. 셋째, ‘상태 관리(State Management)’ 입니다. 통과시킨 사용자의 토큰 발급, 타임아웃 처리, 재진입 방지 등을 담당합니다.

Token Bucket vs Leaky Bucket 알고리즘

스케줄러 구현에서 가장 자주 사용되는 두 가지 알고리즘이 있습니다. Token BucketLeaky Bucket 입니다. 두 알고리즘은 비슷해 보이지만 실제 동작 방식과 사용 사례에서 중요한 차이가 있습니다.

Token Bucket 알고리즘은 버킷에 토큰이 일정 속도로 채워진다고 가정합니다. 사용자를 통과시키려면 토큰을 하나 소비해야 합니다. 만약 버킷이 비어있으면 토큰이 채워질 때까지 기다려야 합니다. 이 알고리즘의 핵심 특징은 ‘버스트(Burst) 허용’ 입니다. 버킷이 가득 차 있다면 짧은 시간에 여러 사용자를 한꺼번에 통과시킬 수 있습니다.

예를 들어 초당 100명을 처리하는 시스템에서, 10초 동안 아무도 접속하지 않았다면 버킷에 1000개의 토큰이 쌓입니다. 이후 갑자기 500명이 몰려오면 버킷에서 500개 토큰을 한 번에 소비하여 모두 즉시 통과시킬 수 있습니다. 이는 트래픽의 변동성이 큰 시스템에서 유용합니다.

반면 Leaky Bucket 알고리즘은 버킷에 물이 새는 것처럼 일정한 속도로 사용자를 흘려보냅니다. 사용자가 버킷에 도착하면 대기하다가, 버킷의 출구 속도에 맞춰 하나씩 빠져나갑니다. 이 알고리즘의 핵심은 ‘평탄화(Smoothing)’ 입니다. 트래픽이 아무리 튀어도 출력은 항상 일정한 속도를 유지합니다.

실제 구현에서는 Token Bucket이 더 자주 사용됩니다. 그 이유는 유연성 때문입니다. 시스템 부하가 낮을 때는 더 많은 사용자를 빠르게 처리하고, 부하가 높을 때는 자동으로 속도를 조절할 수 있기 때문입니다. 하지만 데이터베이스 쓰기처럼 일정한 속도가 중요한 경우에는 Leaky Bucket이 더 적합합니다.

분산 환경에서의 동기화 문제

프로덕션 환경에서 스케줄러는 보통 여러 인스턴스가 동시에 실행됩니다. 고가용성(High Availability)을 보장하기 위해서입니다. 하지만 이는 복잡한 동기화 문제를 야기합니다.

가장 큰 문제는 ‘중복 처리’ 입니다. 두 개의 스케줄러 인스턴스가 동시에 Redis에서 사용자를 뽑아내면, 같은 사용자가 두 번 처리될 수 있습니다. 이를 방지하기 위해 Redis의 ‘원자적 연산(Atomic Operation)’ 을 활용해야 합니다.

대표적인 방법이 Lua Script 를 사용하는 것입니다. Redis는 Lua Script를 단일 트랜잭션으로 실행하므로, 여러 명령어를 원자적으로 처리할 수 있습니다. 예를 들어 다음과 같은 로직을 Lua로 작성할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
-- Lua Script: 대기열에서 N명을 원자적으로 추출
local queue_key = KEYS[1]
local processing_key = KEYS[2]
local count = tonumber(ARGV[1])
local timestamp = tonumber(ARGV[2])

-- 대기열에서 가장 오래된 N명 조회
local users = redis.call('ZRANGE', queue_key, 0, count-1)

if #users > 0 then
    -- 처리 중 상태로 이동 (원자적으로)
    for i, user in ipairs(users) do
        redis.call('ZADD', processing_key, timestamp, user)
        redis.call('ZREM', queue_key, user)
    end
end

return users

이 스크립트는 대기열 조회, 처리 중 상태 추가, 대기열 제거를 하나의 원자적 연산으로 수행합니다. 따라서 여러 스케줄러가 동시에 실행되어도 중복 처리가 발생하지 않습니다.

두 번째 문제는 ‘처리율 조정의 일관성’ 입니다. 각 스케줄러가 독립적으로 처리율을 결정하면 전체 시스템의 처리량이 예측 불가능해집니다. 이를 해결하기 위해 ‘분산 카운터(Distributed Counter)’ 패턴을 사용합니다.

Redis에 rate_limit:current_second 같은 키로 현재 초에 처리한 사용자 수를 저장합니다. 각 스케줄러는 사용자를 통과시키기 전에 이 카운터를 확인하고, 제한에 도달했으면 대기합니다. 카운터는 매초 자동으로 리셋됩니다 (Redis의 EXPIRE 기능 활용).

1
2
3
4
5
6
7
8
9
# 의사코드: 분산 처리율 제어
def try_pass_user():
    current_count = redis.incr('rate_limit:current_second')
    redis.expire('rate_limit:current_second', 1)  # 1초 후 만료
    
    if current_count <= MAX_USERS_PER_SECOND:
        return True  # 통과 허용
    else:
        return False  # 대기 필요

우선순위 큐와 가중치 기반 스케줄링

일부 고급 시스템에서는 단순한 선착순을 넘어 ‘우선순위 기반 스케줄링’ 을 구현합니다. 예를 들어 VIP 회원에게 더 높은 우선순위를 주거나, 과거에 구매 이력이 있는 사용자를 우대하는 방식입니다.

Redis Sorted Set의 Score를 활용하면 이를 쉽게 구현할 수 있습니다. Score를 단순히 timestamp로 사용하는 대신, priority * 10^10 + timestamp 형태로 계산합니다. 우선순위가 높을수록 Score가 낮아지므로 먼저 처리됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
# 우선순위 기반 Score 계산
def calculate_score(user):
    priority = user.get_priority()  # 1(일반) ~ 10(VIP)
    timestamp = time.time()
    
    # 우선순위가 높을수록 Score가 낮아짐
    base_score = (11 - priority) * 1e10
    return base_score + timestamp

# 예시:
# 일반 회원 (priority=1): 10 * 1e10 + 1706000000 = 100001706000000
# VIP 회원 (priority=10): 1 * 1e10 + 1706000100 = 10001706000100
# VIP가 100초 늦게 접속해도 일반 회원보다 먼저 처리됨

더 복잡한 경우에는 ‘다중 큐(Multi-Queue)’ 패턴을 사용합니다. VIP 전용 큐와 일반 큐를 분리하고, 스케줄러가 VIP 큐에서 70%, 일반 큐에서 30%를 처리하는 방식입니다. 이는 공정성을 유지하면서도 차등화된 서비스를 제공할 수 있습니다.


스케줄러 구현 전략

Polling 기반 스케줄러

가장 단순한 형태의 스케줄러는 ‘주기적 폴링(Periodic Polling)’ 방식입니다. 일정 주기마다 Redis를 확인하여 처리할 사용자를 뽑아내는 방식입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
import time
import redis

class PollingScheduler:
    def __init__(self, redis_client, interval=0.1):
        self.redis = redis_client
        self.interval = interval  # 100ms마다 실행
        self.queue_key = 'waiting:queue'
        self.processing_key = 'processing:users'
        
    def run(self):
        while True:
            try:
                # 처리할 사용자 수 계산
                capacity = self.get_current_capacity()
                
                if capacity > 0:
                    # Redis에서 사용자 추출
                    users = self.fetch_users(capacity)
                    
                    # 각 사용자에게 토큰 발급
                    for user_id in users:
                        self.issue_token(user_id)
                        
                time.sleep(self.interval)
                
            except Exception as e:
                logging.error(f"Scheduler error: {e}")
                time.sleep(1)  # 에러 시 대기
    
    def get_current_capacity(self):
        """현재 처리 가능한 사용자 수 계산"""
        current_processing = self.redis.zcard(self.processing_key)
        max_concurrent = 5000  # 동시 처리 가능 인원
        
        return max(0, max_concurrent - current_processing)
    
    def fetch_users(self, count):
        """Lua 스크립트로 원자적 추출"""
        lua_script = """
        local users = redis.call('ZRANGE', KEYS[1], 0, ARGV[1]-1)
        if #users > 0 then
            for i, user in ipairs(users) do
                redis.call('ZADD', KEYS[2], ARGV[2], user)
                redis.call('ZREM', KEYS[1], user)
            end
        end
        return users
        """
        
        return self.redis.eval(
            lua_script,
            2,  # 키 개수
            self.queue_key,
            self.processing_key,
            count,
            time.time()
        )

이 방식의 장점은 구현이 단순하고 안정적이라는 것입니다. 단점은 폴링 간격에 따라 반응 속도가 달라진다는 것입니다. 100ms 간격이면 최악의 경우 사용자가 100ms를 더 기다려야 합니다.

Event-driven 스케줄러

더 효율적인 방식은 ‘이벤트 기반(Event-driven)’ 스케줄러입니다. Redis의 Pub/Sub 기능이나 Keyspace Notifications를 활용하여, 대기열에 변화가 생길 때만 즉시 반응하는 방식입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class EventDrivenScheduler:
    def __init__(self, redis_client):
        self.redis = redis_client
        self.pubsub = redis_client.pubsub()
        
    def run(self):
        # 대기열 변경 이벤트 구독
        self.pubsub.psubscribe('__keyspace@0__:waiting:queue')
        
        for message in self.pubsub.listen():
            if message['type'] == 'pmessage':
                # 대기열에 변화가 생김 -> 즉시 처리
                self.process_queue()
    
    def process_queue(self):
        # Polling 방식과 동일한 처리 로직
        capacity = self.get_current_capacity()
        if capacity > 0:
            users = self.fetch_users(capacity)
            for user_id in users:
                self.issue_token(user_id)

이 방식은 지연 시간이 거의 없지만, Redis Pub/Sub의 메시지가 유실될 수 있다는 점을 고려해야 합니다. 따라서 이벤트 기반과 주기적 폴링을 조합하는 ‘하이브리드’ 방식이 프로덕션에서 자주 사용됩니다.

적응형 스케줄러 (Adaptive Scheduler)

가장 고급 형태는 ‘적응형 스케줄러’ 입니다. 시스템 상태를 실시간으로 모니터링하면서 처리율을 자동으로 조정하는 방식입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
class AdaptiveScheduler:
    def __init__(self, redis_client):
        self.redis = redis_client
        self.current_rate = 1000  # 초기 처리율: 초당 1000명
        self.min_rate = 100
        self.max_rate = 5000
        
    def run(self):
        while True:
            # 백엔드 상태 확인
            backend_health = self.check_backend_health()
            
            # 처리율 동적 조정
            self.adjust_rate(backend_health)
            
            # 현재 처리율에 맞춰 사용자 통과
            users_to_pass = self.current_rate // 10  # 0.1초당 처리량
            users = self.fetch_users(users_to_pass)
            
            for user_id in users:
                self.issue_token(user_id)
                
            time.sleep(0.1)
    
    def check_backend_health(self):
        """백엔드 서버 상태 확인"""
        # Redis에서 메트릭 조회
        response_time = float(self.redis.get('metrics:avg_response_time') or 0)
        error_rate = float(self.redis.get('metrics:error_rate') or 0)
        
        return {
            'response_time': response_time,
            'error_rate': error_rate,
            'is_healthy': response_time < 500 and error_rate < 0.01
        }
    
    def adjust_rate(self, health):
        """백엔드 상태에 따라 처리율 조정"""
        if not health['is_healthy']:
            # 시스템이 불안정 -> 처리율 감소
            self.current_rate = max(
                self.min_rate,
                self.current_rate * 0.8
            )
        else:
            # 시스템이 안정적 -> 처리율 증가
            self.current_rate = min(
                self.max_rate,
                self.current_rate * 1.1
            )
        
        # 처리율 변경 로깅
        self.redis.lpush('scheduler:rate_history', 
                        f"{time.time()}:{self.current_rate}")

적응형 스케줄러는 AIMD (Additive Increase Multiplicative Decrease) 알고리즘을 사용합니다. 시스템이 안정적이면 처리율을 천천히 증가시키고 (1.1배), 문제가 생기면 급격히 감소시킵니다 (0.8배). 이는 TCP의 혼잡 제어와 유사한 원리입니다.


코드 레벨 구현 예시

전체 시스템 아키텍처 코드

실제 프로덕션 수준의 대기열 시스템은 여러 컴포넌트로 구성됩니다. 다음은 Spring Boot와 Redis를 사용한 완전한 구현 예시입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
// 1. 대기열 진입 API
public class WaitRoomController {
    
    @Autowired
    private WaitRoomService waitRoomService;
    
    @PostMapping("/enter")
    public ResponseEntity<EnterResponse> enterQueue(
            @RequestHeader("User-Id") String userId) {
        
        // Redis Sorted Set에 사용자 추가
        Long position = waitRoomService.enqueue(userId);
        
        // 예상 대기 시간 계산
        int estimatedWaitSeconds = waitRoomService.estimateWaitTime(position);
        
        return ResponseEntity.ok(new EnterResponse(
            position,
            estimatedWaitSeconds,
            "대기열에 등록되었습니다"
        ));
    }
    
    @GetMapping("/status")
    public ResponseEntity<StatusResponse> checkStatus(
            @RequestHeader("User-Id") String userId) {
        
        // 현재 순위 조회
        Long currentPosition = waitRoomService.getPosition(userId);
        
        if (currentPosition == null) {
            // 대기열에 없음 -> 이미 통과했거나 만료됨
            String token = waitRoomService.getToken(userId);
            
            if (token != null) {
                return ResponseEntity.ok(new StatusResponse(
                    "PASSED",
                    0L,
                    0,
                    token
                ));
            } else {
                return ResponseEntity.status(HttpStatus.GONE)
                    .body(new StatusResponse("EXPIRED", null, 0, null));
            }
        }
        
        // 예상 대기 시간 재계산
        int estimatedWaitSeconds = waitRoomService.estimateWaitTime(currentPosition);
        
        return ResponseEntity.ok(new StatusResponse(
            "WAITING",
            currentPosition,
            estimatedWaitSeconds,
            null
        ));
    }
}

// 2. 대기열 서비스 로직
public class WaitRoomService {
    
    private static final String QUEUE_KEY = "waitroom:queue";
    private static final String TOKEN_PREFIX = "waitroom:token:";
    private static final int PROCESSING_RATE = 1000; // 초당 처리 인원
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    public Long enqueue(String userId) {
        ZSetOperations<String, String> zSet = redisTemplate.opsForZSet();
        
        // 현재 시간을 Score로 사용
        double score = System.currentTimeMillis() / 1000.0;
        
        // Sorted Set에 추가 (이미 존재하면 무시됨)
        Boolean added = zSet.add(QUEUE_KEY, userId, score);
        
        // 현재 순위 반환 (0-based -> 1-based)
        Long rank = zSet.rank(QUEUE_KEY, userId);
        return rank != null ? rank + 1 : null;
    }
    
    public Long getPosition(String userId) {
        ZSetOperations<String, String> zSet = redisTemplate.opsForZSet();
        Long rank = zSet.rank(QUEUE_KEY, userId);
        return rank != null ? rank + 1 : null;
    }
    
    public int estimateWaitTime(Long position) {
        if (position == null || position <= 0) {
            return 0;
        }
        
        // 처리율을 고려한 예상 시간 계산
        return (int) Math.ceil(position / (double) PROCESSING_RATE);
    }
    
    public String getToken(String userId) {
        ValueOperations<String, String> ops = redisTemplate.opsForValue();
        return ops.get(TOKEN_PREFIX + userId);
    }
}

// 3. 스케줄러 (별도 스레드로 실행)
public class WaitRoomScheduler {
    
    private static final String QUEUE_KEY = "waitroom:queue";
    private static final String TOKEN_PREFIX = "waitroom:token:";
    private static final int BATCH_SIZE = 100; // 한 번에 처리할 인원
    private static final int TOKEN_EXPIRY = 300; // 토큰 유효시간 5분
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    @Scheduled(fixedDelay = 100) // 100ms마다 실행
    public void processQueue() {
        ZSetOperations<String, String> zSet = redisTemplate.opsForZSet();
        ValueOperations<String, String> valueOps = redisTemplate.opsForValue();
        
        // 대기열에서 가장 앞의 사용자들 조회
        Set<String> users = zSet.range(QUEUE_KEY, 0, BATCH_SIZE - 1);
        
        if (users == null || users.isEmpty()) {
            return; // 대기열이 비어있음
        }
        
        // 각 사용자에게 토큰 발급
        for (String userId : users) {
            // JWT 토큰 생성
            String token = generateToken(userId);
            
            // Redis에 토큰 저장 (5분 유효)
            valueOps.set(
                TOKEN_PREFIX + userId,
                token,
                Duration.ofSeconds(TOKEN_EXPIRY)
            );
            
            // 대기열에서 제거
            zSet.remove(QUEUE_KEY, userId);
            
            // 선택사항: 사용자에게 푸시 알림 전송
            // notificationService.sendPassNotification(userId, token);
        }
        
        log.info("Processed {} users from wait room", users.size());
    }
    
    private String generateToken(String userId) {
        // JWT 토큰 생성 로직
        return JWT.create()
            .withSubject(userId)
            .withExpiresAt(new Date(System.currentTimeMillis() + TOKEN_EXPIRY * 1000))
            .withClaim("type", "waitroom_pass")
            .sign(Algorithm.HMAC256(secretKey));
    }
}

Python 기반 고성능 스케줄러

Python으로 구현한 비동기 스케줄러 예시입니다. asyncio를 활용하여 높은 처리량을 달성합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
import asyncio
import aioredis
import time
from typing import List, Optional

class HighPerformanceScheduler:
    def __init__(self, redis_url: str):
        self.redis_url = redis_url
        self.redis: Optional[aioredis.Redis] = None
        
        # 설정
        self.queue_key = "waitroom:queue"
        self.processing_key = "waitroom:processing"
        self.token_prefix = "waitroom:token:"
        
        # 처리율 제어
        self.target_rate = 1000  # 초당 1000명 목표
        self.batch_size = 100    # 한 번에 처리할 인원
        self.interval = 0.1      # 100ms 간격
        
    async def initialize(self):
        """Redis 연결 초기화"""
        self.redis = await aioredis.create_redis_pool(self.redis_url)
        
    async def run(self):
        """메인 스케줄러 루프"""
        await self.initialize()
        
        while True:
            try:
                start_time = time.time()
                
                # 처리 가능 인원 계산
                capacity = await self.get_capacity()
                
                if capacity > 0:
                    # 사용자 처리
                    processed = await self.process_batch(
                        min(capacity, self.batch_size)
                    )
                    
                    if processed > 0:
                        print(f"Processed {processed} users")
                
                # 정확한 간격 유지
                elapsed = time.time() - start_time
                sleep_time = max(0, self.interval - elapsed)
                await asyncio.sleep(sleep_time)
                
            except Exception as e:
                print(f"Scheduler error: {e}")
                await asyncio.sleep(1)
    
    async def get_capacity(self) -> int:
        """현재 처리 가능한 용량 계산"""
        # 현재 처리 중인 사용자 수 확인
        processing_count = await self.redis.zcard(self.processing_key)
        
        # 최대 동시 처리 인원
        max_concurrent = 5000
        
        return max(0, max_concurrent - processing_count)
    
    async def process_batch(self, count: int) -> int:
        """배치 단위로 사용자 처리"""
        # Lua 스크립트로 원자적 처리
        lua_script = """
        local queue_key = KEYS[1]
        local processing_key = KEYS[2]
        local count = tonumber(ARGV[1])
        local timestamp = tonumber(ARGV[2])
        
        -- 대기열에서 가져오기
        local users = redis.call('ZRANGE', queue_key, 0, count-1, 'WITHSCORES')
        
        if #users == 0 then
            return {}
        end
        
        -- 처리 중 상태로 이동
        local user_ids = {}
        for i=1, #users, 2 do
            local user_id = users[i]
            table.insert(user_ids, user_id)
            
            redis.call('ZADD', processing_key, timestamp, user_id)
            redis.call('ZREM', queue_key, user_id)
        end
        
        return user_ids
        """
        
        # 스크립트 실행
        users = await self.redis.eval(
            lua_script,
            keys=[self.queue_key, self.processing_key],
            args=[count, time.time()]
        )
        
        # 각 사용자에게 토큰 발급
        if users:
            await asyncio.gather(*[
                self.issue_token(user_id.decode())
                for user_id in users
            ])
        
        return len(users) if users else 0
    
    async def issue_token(self, user_id: str):
        """사용자에게 통과 토큰 발급"""
        token = self.generate_token(user_id)
        
        # Redis에 토큰 저장 (5분 유효)
        await self.redis.setex(
            f"{self.token_prefix}{user_id}",
            300,  # 5분
            token
        )
    
    def generate_token(self, user_id: str) -> str:
        """간단한 토큰 생성 (실제로는 JWT 사용)"""
        import hashlib
        timestamp = str(time.time())
        data = f"{user_id}:{timestamp}:secret"
        return hashlib.sha256(data.encode()).hexdigest()

# 실행
async def main():
    scheduler = HighPerformanceScheduler("redis://localhost")
    await scheduler.run()

if __name__ == "__main__":
    asyncio.run(main())

클라이언트 측 폴링 로직

프론트엔드에서 대기 순번을 확인하는 JavaScript 코드입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
class WaitRoomClient {
    constructor(userId, apiBaseUrl) {
        this.userId = userId;
        this.apiBaseUrl = apiBaseUrl;
        this.pollingInterval = 2000; // 2초마다 확인
        this.isPolling = false;
        this.pollTimer = null;
    }
    
    async enterQueue() {
        try {
            const response = await fetch(`${this.apiBaseUrl}/enter`, {
                method: 'POST',
                headers: {
                    'User-Id': this.userId,
                    'Content-Type': 'application/json'
                }
            });
            
            const data = await response.json();
            
            this.updateUI({
                status: 'WAITING',
                position: data.position,
                estimatedWait: data.estimatedWaitSeconds
            });
            
            // 폴링 시작
            this.startPolling();
            
        } catch (error) {
            console.error('Failed to enter queue:', error);
        }
    }
    
    startPolling() {
        if (this.isPolling) return;
        
        this.isPolling = true;
        this.poll();
    }
    
    async poll() {
        if (!this.isPolling) return;
        
        try {
            const response = await fetch(`${this.apiBaseUrl}/status`, {
                method: 'GET',
                headers: {
                    'User-Id': this.userId
                }
            });
            
            const data = await response.json();
            
            if (data.status === 'PASSED') {
                // 통과! 토큰 저장 및 리다이렉트
                localStorage.setItem('waitroom_token', data.token);
                this.stopPolling();
                window.location.href = '/main';
                
            } else if (data.status === 'WAITING') {
                // 대기 중 - UI 업데이트
                this.updateUI({
                    status: 'WAITING',
                    position: data.position,
                    estimatedWait: data.estimatedWaitSeconds
                });
                
                // 다음 폴링 예약
                this.pollTimer = setTimeout(
                    () => this.poll(),
                    this.pollingInterval
                );
                
            } else {
                // 만료됨 - 재시도 안내
                this.updateUI({
                    status: 'EXPIRED',
                    message: '대기 시간이 만료되었습니다. 다시 시도해주세요.'
                });
                this.stopPolling();
            }
            
        } catch (error) {
            console.error('Polling error:', error);
            
            // 네트워크 에러 - 재시도
            this.pollTimer = setTimeout(
                () => this.poll(),
                this.pollingInterval * 2 // 에러 시 간격 증가
            );
        }
    }
    
    stopPolling() {
        this.isPolling = false;
        if (this.pollTimer) {
            clearTimeout(this.pollTimer);
            this.pollTimer = null;
        }
    }
    
    updateUI(state) {
        // UI 업데이트 로직
        const statusElement = document.getElementById('wait-status');
        
        if (state.status === 'WAITING') {
            statusElement.innerHTML = `
                <div class="waiting-info">
                    <p>현재 대기 순번: <strong>${state.position.toLocaleString()}번</strong></p>
                    <p>예상 대기 시간: <strong>${this.formatWaitTime(state.estimatedWait)}</strong></p>
                    <div class="progress-bar">
                        <div class="progress" style="width: ${this.calculateProgress(state.position)}%"></div>
                    </div>
                </div>
            `;
        } else if (state.status === 'EXPIRED') {
            statusElement.innerHTML = `
                <div class="error-info">
                    <p>${state.message}</p>
                    <button onclick="location.reload()">다시 시도</button>
                </div>
            `;
        }
    }
    
    formatWaitTime(seconds) {
        if (seconds < 60) {
            return `약 ${seconds}초`;
        } else if (seconds < 3600) {
            const minutes = Math.ceil(seconds / 60);
            return `약 ${minutes}분`;
        } else {
            const hours = Math.floor(seconds / 3600);
            const minutes = Math.ceil((seconds % 3600) / 60);
            return `약 ${hours}시간 ${minutes}분`;
        }
    }
    
    calculateProgress(position) {
        // 가정: 총 대기 인원을 10000명으로 추정
        const totalEstimate = 10000;
        const progress = ((totalEstimate - position) / totalEstimate) * 100;
        return Math.max(5, Math.min(95, progress)); // 5%~95% 범위로 제한
    }
}

// 사용 예시
const waitRoom = new WaitRoomClient(
    'user-12345',
    'https://api.example.com/waitroom'
);

// 페이지 로드 시 대기열 진입
document.addEventListener('DOMContentLoaded', () => {
    waitRoom.enterQueue();
});

// 페이지 종료 시 폴링 중단
window.addEventListener('beforeunload', () => {
    waitRoom.stopPolling();
});

병목 지점과 최적화 전략

Redis 성능 병목

Redis는 단일 스레드로 동작하므로 CPU 바운드 작업에서 병목이 발생할 수 있습니다. 특히 대규모 Sorted Set 연산(ZRANGE, ZRANK)은 O(log N) 복잡도를 가지지만, N이 수백만 이상이 되면 무시할 수 없는 지연이 발생합니다.

최적화 전략 1: Sharding

대기열을 여러 Redis 인스턴스에 분산시킵니다. 사용자 ID의 해시값을 기준으로 샤드를 결정합니다.

1
2
3
4
5
6
7
8
9
10
def get_shard_key(user_id: str, num_shards: int = 10) -> str:
    """사용자 ID를 기반으로 샤드 결정"""
    import hashlib
    hash_value = int(hashlib.md5(user_id.encode()).hexdigest(), 16)
    shard_id = hash_value % num_shards
    return f"waitroom:queue:shard{shard_id}"

# 사용 예시
queue_key = get_shard_key("user-12345", num_shards=10)
redis_client.zadd(queue_key, {user_id: timestamp})

이 방식의 단점은 전체 대기 순번을 정확히 계산하기 어렵다는 것입니다. 하지만 대부분의 경우 “대략적인 순번”만으로도 충분합니다. 정확한 순번이 필요한 경우, 각 샤드의 크기를 합산하여 근사값을 제공할 수 있습니다.

최적화 전략 2: Lazy Deletion

대기열에서 사용자를 제거할 때 즉시 ZREM을 호출하는 대신, “삭제 예정” 플래그만 설정하고 나중에 배치로 정리합니다.

1
2
3
4
5
6
7
8
9
10
11
12
# 즉시 삭제 (느림)
redis.zrem('waitroom:queue', user_id)

# Lazy deletion (빠름)
redis.sadd('waitroom:deleted', user_id)

# 별도 스레드에서 주기적으로 정리
def cleanup_deleted_users():
    deleted_users = redis.smembers('waitroom:deleted')
    if deleted_users:
        redis.zrem('waitroom:queue', *deleted_users)
        redis.delete('waitroom:deleted')

최적화 전략 3: Redis Cluster

단일 Redis 인스턴스의 한계를 넘어서려면 Redis Cluster를 사용해야 합니다. Cluster 모드에서는 데이터가 자동으로 여러 노드에 분산되며, 각 노드가 독립적으로 요청을 처리합니다.

하지만 Sorted Set의 ZRANGE 같은 연산은 여러 노드에 걸쳐 있을 수 있으므로, 애플리케이션 레벨에서 결과를 병합해야 합니다.

네트워크 지연 최적화

클라이언트가 주기적으로 순번을 조회하면 네트워크 트래픽이 급증합니다. 10만 명이 2초마다 폴링하면 초당 5만 개의 HTTP 요청이 발생합니다.

최적화 전략 1: Server-Sent Events (SSE)

폴링 대신 SSE를 사용하면 서버가 변경 사항을 푸시할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from flask import Flask, Response
import time

app = Flask(__name__)

def stream():
    def event_stream(user_id):
        while True:
            # 현재 순위 조회
            position = get_user_position(user_id)
            
            if position is None:
                # 통과됨
                yield f"data: status\n\n"
                break
            
            # 순위 전송
            yield f"data: status}\n\n"
            
            time.sleep(2)
    
    user_id = request.args.get('user_id')
    return Response(event_stream(user_id), mimetype='text/event-stream')

클라이언트는 EventSource로 연결하여 실시간 업데이트를 받습니다.

1
2
3
4
5
6
7
8
9
10
11
12
const eventSource = new EventSource(`/waitroom/stream?user_id=${userId}`);

eventSource.onmessage = function(event) {
    const data = JSON.parse(event.data);
    
    if (data.status === 'PASSED') {
        window.location.href = '/main';
        eventSource.close();
    } else {
        updateUI(data);
    }
};

최적화 전략 2: WebSocket + Redis Pub/Sub

더 실시간성이 요구되는 경우 WebSocket을 사용합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import asyncio
import websockets
import aioredis

class WebSocketServer:
    def __init__(self):
        self.redis = None
        self.pubsub = None
    
    async def initialize(self):
        self.redis = await aioredis.create_redis_pool('redis://localhost')
        self.pubsub = await self.redis.subscribe('waitroom:notifications')
    
    async def handler(self, websocket, path):
        user_id = await websocket.recv()
        
        # 사용자별 채널 구독
        channel = f"waitroom:user:{user_id}"
        
        while True:
            # Redis에서 메시지 대기
            message = await self.pubsub.get_message(ignore_subscribe_messages=True)
            
            if message:
                # WebSocket으로 전송
                await websocket.send(message['data'])
            
            await asyncio.sleep(0.1)

# 스케줄러에서 통과 시 알림
async def notify_user_passed(user_id: str, redis: aioredis.Redis):
    channel = f"waitroom:user:{user_id}"
    await redis.publish(channel, json.dumps({
        'status': 'PASSED',
        'token': generate_token(user_id)
    }))

데이터베이스 병목

토큰을 발급받은 사용자가 실제 서비스로 진입하면, 데이터베이스에 부하가 집중됩니다. 이를 완화하기 위한 전략이 필요합니다.

최적화 전략 1: Connection Pooling

데이터베이스 연결을 미리 생성해두고 재사용합니다.

1
2
3
4
5
6
7
8
9
10
from sqlalchemy import create_engine
from sqlalchemy.pool import QueuePool

engine = create_engine(
    'postgresql://user:pass@localhost/db',
    poolclass=QueuePool,
    pool_size=100,        # 기본 연결 수
    max_overflow=50,      # 추가 연결 수
    pool_pre_ping=True    # 연결 유효성 확인
)

최적화 전략 2: Write Batching

여러 사용자의 주문을 모아서 한 번에 DB에 기록합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class BatchWriter:
    def __init__(self, db_engine):
        self.engine = db_engine
        self.buffer = []
        self.buffer_size = 1000
        self.lock = asyncio.Lock()
    
    async def write(self, order_data):
        async with self.lock:
            self.buffer.append(order_data)
            
            if len(self.buffer) >= self.buffer_size:
                await self.flush()
    
    async def flush(self):
        if not self.buffer:
            return
        
        # 배치 INSERT
        with self.engine.connect() as conn:
            conn.execute(
                """
                INSERT INTO orders (user_id, product_id, quantity, timestamp)
                VALUES %s
                """,
                self.buffer
            )
        
        self.buffer.clear()

최적화 전략 3: Read Replica 활용

순번 조회 같은 읽기 작업은 Read Replica로 분산시킵니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 쓰기는 Master로
master_engine = create_engine('postgresql://master/db')

# 읽기는 Replica로
replica_engines = [
    create_engine('postgresql://replica1/db'),
    create_engine('postgresql://replica2/db'),
    create_engine('postgresql://replica3/db'),
]

def get_read_engine():
    """라운드 로빈으로 Replica 선택"""
    import random
    return random.choice(replica_engines)

# 사용
with get_read_engine().connect() as conn:
    position = conn.execute("SELECT get_user_position(%s)", user_id).scalar()

프로덕션 환경 고려사항

장애 복구 전략

Redis가 다운되면 전체 대기열 시스템이 마비됩니다. 이를 대비한 백업 전략이 필수입니다.

전략 1: Redis Sentinel

Redis Sentinel은 자동 페일오버를 제공합니다. Master가 다운되면 Replica를 자동으로 Master로 승격시킵니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from redis.sentinel import Sentinel

# Sentinel 설정
sentinel = Sentinel([
    ('sentinel1', 26379),
    ('sentinel2', 26379),
    ('sentinel3', 26379)
], socket_timeout=0.1)

# Master 자동 발견
master = sentinel.master_for('mymaster', socket_timeout=0.1)
replica = sentinel.slave_for('mymaster', socket_timeout=0.1)

# 쓰기는 Master로
master.zadd('waitroom:queue', {user_id: timestamp})

# 읽기는 Replica로
position = replica.zrank('waitroom:queue', user_id)

전략 2: 데이터 백업

Redis의 데이터를 주기적으로 디스크에 저장합니다 (RDB, AOF).

1
2
3
4
5
6
7
8
# redis.conf 설정
save 900 1       # 900초마다 1개 이상 변경 시 저장
save 300 10      # 300초마다 10개 이상 변경 시 저장
save 60 10000    # 60초마다 10000개 이상 변경 시 저장

# AOF 활성화 (더 안전하지만 느림)
appendonly yes
appendfsync everysec  # 매초 디스크 동기화

전략 3: Fallback 메커니즘

Redis 장애 시 임시로 데이터베이스를 사용하는 폴백 로직을 구현합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
class ResilientWaitRoomService:
    def __init__(self, redis_client, db_engine):
        self.redis = redis_client
        self.db = db_engine
        self.use_fallback = False
    
    def enqueue(self, user_id):
        try:
            # 먼저 Redis 시도
            return self.enqueue_redis(user_id)
        except redis.ConnectionError:
            # Redis 실패 시 DB 사용
            self.use_fallback = True
            return self.enqueue_db(user_id)
    
    def enqueue_redis(self, user_id):
        # Redis Sorted Set 사용
        score = time.time()
        self.redis.zadd('waitroom:queue', {user_id: score})
        return self.redis.zrank('waitroom:queue', user_id) + 1
    
    def enqueue_db(self, user_id):
        # 데이터베이스 테이블 사용
        with self.db.connect() as conn:
            conn.execute(
                """
                INSERT INTO waitroom_queue (user_id, enqueued_at)
                VALUES (%s, NOW())
                ON CONFLICT (user_id) DO NOTHING
                """,
                user_id
            )
            
            result = conn.execute(
                """
                SELECT COUNT(*) FROM waitroom_queue
                WHERE enqueued_at < (
                    SELECT enqueued_at FROM waitroom_queue
                    WHERE user_id = %s
                )
                """,
                user_id
            )
            
            return result.scalar() + 1

모니터링과 알람

프로덕션 시스템은 실시간 모니터링이 필수입니다.

핵심 메트릭

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import prometheus_client as prom

# 메트릭 정의
queue_size = prom.Gauge('waitroom_queue_size', 'Current queue size')
processing_rate = prom.Counter('waitroom_processed_total', 'Total users processed')
wait_time = prom.Histogram('waitroom_wait_seconds', 'Wait time distribution')
error_rate = prom.Counter('waitroom_errors_total', 'Total errors', ['type'])

# 메트릭 수집
class MonitoredScheduler:
    async def run(self):
        while True:
            try:
                # 큐 크기 측정
                size = await self.redis.zcard('waitroom:queue')
                queue_size.set(size)
                
                # 사용자 처리
                users = await self.process_batch(100)
                processing_rate.inc(len(users))
                
                # 대기 시간 측정
                for user in users:
                    enqueued_time = await self.get_enqueue_time(user)
                    wait_duration = time.time() - enqueued_time
                    wait_time.observe(wait_duration)
                
            except Exception as e:
                error_rate.labels(type=type(e).__name__).inc()
                raise

알람 설정 (Prometheus Alertmanager)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
groups:
- name: waitroom_alerts
  rules:
  - alert: QueueTooLarge
    expr: waitroom_queue_size > 100000
    for: 5m
    annotations:
      summary: "대기열이 너무 큽니다"
      description: "현재 대기 인원: 명"
  
  - alert: ProcessingRateLow
    expr: rate(waitroom_processed_total[1m]) < 500
    for: 3m
    annotations:
      summary: "처리율이 낮습니다"
      description: "현재 처리율: 명/분"
  
  - alert: HighErrorRate
    expr: rate(waitroom_errors_total[5m]) > 10
    for: 2m
    annotations:
      summary: "에러 발생률이 높습니다"

보안 고려사항

토큰 위조 방지

JWT 토큰을 사용하여 위조를 방지합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import jwt
import time

def generate_secure_token(user_id: str, secret_key: str) -> str:
    """안전한 통과 토큰 생성"""
    payload = {
        'user_id': user_id,
        'type': 'waitroom_pass',
        'issued_at': time.time(),
        'expires_at': time.time() + 300,  # 5분 유효
        'nonce': secrets.token_urlsafe(16)  # 재사용 방지
    }
    
    return jwt.encode(payload, secret_key, algorithm='HS256')

def verify_token(token: str, secret_key: str) -> dict:
    """토큰 검증"""
    try:
        payload = jwt.decode(token, secret_key, algorithms=['HS256'])
        
        # 만료 확인
        if payload['expires_at'] < time.time():
            raise ValueError("Token expired")
        
        return payload
        
    except jwt.InvalidTokenError:
        raise ValueError("Invalid token")

Rate Limiting (악의적 재시도 방지)

사용자가 대기열에 반복적으로 진입하는 것을 제한합니다.

1
2
3
4
5
6
7
8
9
10
11
12
async def rate_limit_check(user_id: str, redis: aioredis.Redis) -> bool:
    """분당 최대 3회 진입 허용"""
    key = f"rate_limit:enter:{user_id}"
    
    # 현재 카운트 증가
    count = await redis.incr(key)
    
    # 첫 요청이면 1분 후 만료 설정
    if count == 1:
        await redis.expire(key, 60)
    
    return count <= 3  # 3회 초과 시 False

DDoS 방어

CDN과 WAF(Web Application Firewall)를 활용합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# Nginx 설정 예시
http {
    # IP별 연결 제한
    limit_conn_zone $binary_remote_addr zone=conn_limit:10m;
    limit_conn conn_limit 10;
    
    # IP별 요청 속도 제한
    limit_req_zone $binary_remote_addr zone=req_limit:10m rate=10r/s;
    limit_req zone=req_limit burst=20 nodelay;
    
    server {
        location /api/waitroom {
            # Rate limiting 적용
            limit_req zone=req_limit;
            
            proxy_pass http://backend;
        }
    }
}

시스템 흐름 다이어그램

sequenceDiagram
    participant User as 사용자
    participant CDN as CDN/LoadBalancer
    participant API as API Server
    participant Redis as Redis
    participant Scheduler as Scheduler
    participant Backend as Backend Service
    
    User->>CDN: 접속 요청
    CDN->>API: /waitroom/enter
    API->>Redis: ZADD queue {user_id: timestamp}
    Redis-->>API: OK
    API->>Redis: ZRANK queue user_id
    Redis-->>API: position: 1000
    API-->>User: 대기 순번 1000번
    
    loop 폴링 (2초마다)
        User->>API: /waitroom/status
        API->>Redis: ZRANK queue user_id
        Redis-->>API: position: 800
        API-->>User: 현재 800번 대기 중
    end
    
    loop 스케줄러 (0.1초마다)
        Scheduler->>Redis: ZRANGE queue 0 99
        Redis-->>Scheduler: [user1, user2, ...]
        Scheduler->>Redis: ZADD processing {users}
        Scheduler->>Redis: ZREM queue {users}
        Scheduler->>Redis: SET token:{user_id} {jwt_token}
    end
    
    User->>API: /waitroom/status (통과 후)
    API->>Redis: GET token:{user_id}
    Redis-->>API: {jwt_token}
    API-->>User: 토큰 발급됨!
    
    User->>Backend: /order (with token)
    Backend->>Backend: 토큰 검증
    Backend-->>User: 주문 완료

아키텍처 흐름도

graph TB
    subgraph "클라이언트 레이어"
        A[사용자 브라우저]
        A1[폴링 로직<br/>2초 간격]
        A2[UI 업데이트]
    end
    
    subgraph "CDN/캐싱 레이어"
        B[CloudFlare CDN]
        B1[정적 대기 페이지]
        B2[DDoS 방어]
    end
    
    subgraph "API 레이어"
        C[Load Balancer]
        C1[API Server 1]
        C2[API Server 2]
        C3[API Server N]
    end
    
    subgraph "대기열 레이어"
        D[Redis Cluster]
        D1[Sorted Set<br/>waitroom:queue]
        D2[Hash<br/>waitroom:tokens]
        D3[String<br/>rate_limit:*]
    end
    
    subgraph "스케줄러 레이어"
        E[Scheduler 1]
        E1[Scheduler 2]
        E2[Distributed Lock]
    end
    
    subgraph "메시지 큐 레이어"
        F[Kafka Cluster]
        F1[Topic: orders]
        F2[Consumer Group]
    end
    
    subgraph "백엔드 레이어"
        G[Order Service]
        G1[Payment Service]
        G2[Inventory Service]
    end
    
    subgraph "데이터 레이어"
        H[(PostgreSQL Master)]
        H1[(Replica 1)]
        H2[(Replica 2)]
    end
    
    A --> A1
    A1 --> A2
    A --> B
    B --> B1
    B --> B2
    B --> C
    C --> C1
    C --> C2
    C --> C3
    C1 --> D
    C2 --> D
    C3 --> D
    D --> D1
    D --> D2
    D --> D3
    E --> D
    E1 --> D
    E --> E2
    E1 --> E2
    E --> F
    E1 --> F
    F --> F1
    F1 --> F2
    F2 --> G
    G --> G1
    G --> G2
    G --> H
    G1 --> H
    G2 --> H1
    G2 --> H2

Redis 데이터 구조 상세

graph LR
    subgraph "Redis Key Space"
        A[waitroom:queue<br/>Sorted Set]
        B[waitroom:processing<br/>Sorted Set]
        C[waitroom:token:user123<br/>String]
        D[rate_limit:current_second<br/>String with TTL]
        E[waitroom:deleted<br/>Set]
    end
    
    A --> |Score: timestamp| A1[user123: 1706000000.123]
    A --> |Score: timestamp| A2[user456: 1706000000.456]
    
    B --> |Score: pass_time| B1[user789: 1706000100.000]
    
    C --> |Value: JWT| C1[eyJhbGc...]
    
    D --> |Value: count| D1[847]
    D --> |TTL: 1s| D2[Auto expire]
    
    E --> |Members| E1[user999, user888]

성능 벤치마크

실제 환경에서 측정한 성능 데이터입니다.

하드웨어 사양

  • Redis: AWS ElastiCache r6g.xlarge (4 vCPU, 26GB RAM)
  • API Server: AWS ECS Fargate (2 vCPU, 4GB RAM) x 10
  • 네트워크: AWS VPC, Region 내부 통신

벤치마크 결과

메트릭비고
최대 대기 인원500만 명Redis 메모리 사용량 3GB
초당 처리량50,000 TPSZADD + ZRANK 혼합
평균 응답 시간3msP50
P99 응답 시간15msP99
순번 조회 지연< 10msZRANK 단일 연산
토큰 발급 속도5,000 users/secLua script 기반 배치 처리
메모리 효율600 bytes/useruser_id + timestamp

부하 테스트 시나리오

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import asyncio
import aiohttp
import time

async def load_test(total_users=100000, concurrent=1000):
    """부하 테스트: 10만 명이 동시 접속"""
    
    async def single_user_flow(session, user_id):
        start = time.time()
        
        # 1. 대기열 진입
        async with session.post(
            'https://api.example.com/waitroom/enter',
            headers={'User-Id': user_id}
        ) as resp:
            data = await resp.json()
            initial_position = data['position']
        
        # 2. 통과할 때까지 폴링
        while True:
            await asyncio.sleep(2)
            
            async with session.get(
                'https://api.example.com/waitroom/status',
                headers={'User-Id': user_id}
            ) as resp:
                data = await resp.json()
                
                if data['status'] == 'PASSED':
                    duration = time.time() - start
                    return {
                        'user_id': user_id,
                        'initial_position': initial_position,
                        'wait_duration': duration
                    }
    
    # 동시 실행
    async with aiohttp.ClientSession() as session:
        tasks = [
            single_user_flow(session, f"user-{i}")
            for i in range(total_users)
        ]
        
        results = await asyncio.gather(*tasks)
    
    # 결과 분석
    durations = [r['wait_duration'] for r in results]
    print(f"평균 대기 시간: {sum(durations) / len(durations):.2f}")
    print(f"최소 대기 시간: {min(durations):.2f}")
    print(f"최대 대기 시간: {max(durations):.2f}")

# 실행
asyncio.run(load_test())

결론

Redis 기반 대기열 시스템은 대규모 선착순 이벤트에서 핵심적인 역할을 수행합니다. 단순히 사용자를 줄 세우는 것을 넘어, 시스템 안정성을 보장하고 공정한 기회를 제공하며 우수한 사용자 경험을 만들어냅니다.

성공적인 구현을 위한 핵심 원칙은 다음과 같습니다.

첫째, 원자성(Atomicity) 보장입니다. Lua Script를 활용하여 경쟁 조건(Race Condition)을 제거하고 데이터 일관성을 유지해야 합니다.

둘째, 관측 가능성(Observability) 입니다. 실시간 모니터링과 알람을 통해 시스템 상태를 파악하고 신속하게 대응할 수 있어야 합니다.

셋째, 탄력성(Resilience) 입니다. Redis 장애, 네트워크 문제, 트래픽 급증 등 다양한 예외 상황에 대한 대비책을 마련해야 합니다.

넷째, 사용자 경험(User Experience) 입니다. 정확한 순번 정보, 예상 대기 시간, 진행률 표시 등을 통해 사용자의 불안감을 해소해야 합니다.

실제 구현 시에는 네이버, 카카오, 토스 등의 사례를 참고하되, 자신의 서비스 특성에 맞게 커스터마이징해야 합니다. 단순한 선착순이면 기본 구현으로 충분하지만, 재고 관리, 우선순위 처리, 공정성 보장 등 추가 요구사항이 있다면 그에 맞는 고급 전략을 채택해야 합니다.

기술적 완성도도 중요하지만, 결국 시스템의 성패는 “사용자가 얼마나 공정하고 투명한 경험을 했는가”로 결정됩니다. 이를 위해서는 기술과 UX가 조화를 이루어야 합니다.


작성일자: 2026-01-31

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.