플래시 세일 고트래픽 시스템 설계: 수십만 동시 접속 처리 전략

이커머스

플래시 세일고트래픽시스템 설계Redis대기열

이 글은 누구를 위한 것인가

  • 블랙프라이데이, 11.11 같은 대규모 이벤트를 준비하는 이커머스 팀
  • 트래픽 스파이크로 서버가 다운된 경험이 있는 개발자
  • 재고 처리 race condition을 해결하고 싶은 엔지니어

들어가며

플래시 세일 오픈 순간, 평소 100배의 트래픽이 몰린다. DB는 죽고, 재고는 -1이 되고, 결제는 중복 처리된다. 이 문제들은 아키텍처로 해결해야 한다.

이 글은 bluefoxdev.kr의 고트래픽 이커머스 아키텍처 를 참고하여 작성했습니다.


1. 플래시 세일 아키텍처

[3단계 방어 아키텍처]

단계 1: 진입 제어 (CDN + 대기열)
  사용자 → CDN (정적 대기 페이지)
          → 대기열 서버 (순서표 발급)
          → 토큰 검증 후 실제 서버 진입

단계 2: 재고 처리 (Redis Lua)
  토큰 → Redis Lua 스크립트 (원자적 재고 차감)
       → 성공: 주문 생성 큐 → Kafka
       → 실패: 품절 응답

단계 3: 주문 처리 (비동기)
  Kafka Consumer → 주문 DB 저장
                 → 결제 처리
                 → 이메일 발송

2. Redis 대기열 구현

import redis
import uuid
import time

r = redis.Redis()

def enter_waiting_queue(user_id: str, sale_id: str) -> dict:
    """대기열 진입 - 순서표 발급"""
    queue_key = f"sale:queue:{sale_id}"
    token = str(uuid.uuid4())
    
    # Sorted Set: score = 진입 시간 (먼저 온 순서대로)
    score = time.time()
    r.zadd(queue_key, {f"{user_id}:{token}": score})
    
    rank = r.zrank(queue_key, f"{user_id}:{token}")
    
    return {
        "token": token,
        "position": rank + 1,
        "estimated_wait_seconds": rank * 0.5  # 2명/초 처리 가정
    }

def process_queue(sale_id: str, batch_size: int = 100):
    """대기열에서 배치 단위로 처리 허용"""
    queue_key = f"sale:queue:{sale_id}"
    allowed_key = f"sale:allowed:{sale_id}"
    
    # 앞에서 batch_size명 꺼내기
    members = r.zrange(queue_key, 0, batch_size - 1, withscores=False)
    
    for member in members:
        r.zadd(allowed_key, {member: time.time()})
        r.zrem(queue_key, member)
        r.expire(allowed_key, 300)  # 5분 내 구매 완료해야 함

# Lua 스크립트: 원자적 재고 차감
RESERVE_STOCK_LUA = """
local stock_key = KEYS[1]
local reserve_key = KEYS[2]
local user_id = ARGV[1]
local quantity = tonumber(ARGV[2])

local current = tonumber(redis.call('GET', stock_key) or '0')

if current < quantity then
    return {0, current}  -- 재고 부족
end

redis.call('DECRBY', stock_key, quantity)
redis.call('HSET', reserve_key, user_id, quantity)
redis.call('EXPIRE', reserve_key, 600)  -- 10분 내 결제

return {1, current - quantity}  -- 성공, 남은 재고
"""

def reserve_stock(sale_id: str, product_id: str, user_id: str, quantity: int = 1):
    stock_key = f"sale:stock:{sale_id}:{product_id}"
    reserve_key = f"sale:reserve:{sale_id}:{product_id}"
    
    result = r.eval(RESERVE_STOCK_LUA, 2, stock_key, reserve_key, user_id, quantity)
    return {"success": bool(result[0]), "remaining": result[1]}

3. 서킷 브레이커

import time
from enum import Enum

class CircuitState(Enum):
    CLOSED = "closed"      # 정상
    OPEN = "open"          # 차단
    HALF_OPEN = "half_open"  # 복구 시도

class CircuitBreaker:
    def __init__(self, failure_threshold=5, recovery_timeout=30):
        self.failure_threshold = failure_threshold
        self.recovery_timeout = recovery_timeout
        self.failure_count = 0
        self.last_failure_time = None
        self.state = CircuitState.CLOSED
    
    def call(self, func, *args, **kwargs):
        if self.state == CircuitState.OPEN:
            if time.time() - self.last_failure_time > self.recovery_timeout:
                self.state = CircuitState.HALF_OPEN
            else:
                raise Exception("서킷 브레이커 OPEN — 서비스 일시 차단")
        
        try:
            result = func(*args, **kwargs)
            if self.state == CircuitState.HALF_OPEN:
                self.state = CircuitState.CLOSED
                self.failure_count = 0
            return result
        except Exception as e:
            self.failure_count += 1
            self.last_failure_time = time.time()
            
            if self.failure_count >= self.failure_threshold:
                self.state = CircuitState.OPEN
            raise e

# 사용
db_circuit = CircuitBreaker(failure_threshold=5, recovery_timeout=30)

def save_order_safe(order_data):
    return db_circuit.call(save_order_to_db, order_data)

마무리

플래시 세일의 핵심은 "DB에 닿기 전에 걸러내는 것"이다. CDN으로 정적 콘텐츠를 처리하고, Redis 대기열로 유량을 제어하고, Lua 스크립트로 재고를 원자적으로 처리하면 DB는 실제 주문만 처리하면 된다. 부하를 계층별로 분산시키는 것이 핵심 전략이다.