라이브 커머스 백엔드 아키텍처: 동시 접속 10만 명을 견디는 실시간 쇼핑 시스템

이커머스

라이브 커머스실시간 아키텍처RedisWebSocket이커머스

이 글은 누구를 위한 것인가

  • 라이브 방송 중 재고 오버셀링과 서버 다운을 경험한 팀
  • 라이브 커머스 기능을 기존 이커머스 플랫폼에 추가하려는 엔지니어
  • 동시 접속 급증(트래픽 스파이크)을 안정적으로 처리해야 하는 아키텍트

들어가며

라이브 커머스는 일반 이커머스와 근본적으로 다른 트래픽 패턴을 갖는다. 방송 중 인기 상품이 노출되는 순간 수만 명이 동시에 주문을 시도한다. 일반 피크 대비 10~100배의 순간 부하가 발생한다.

"그냥 서버 늘리면 되지"라는 접근은 통하지 않는다. 재고 오버셀링, 중복 주문, 채팅 메시지 유실 등 실시간 데이터 정합성 문제가 핵심이다.

이 글은 bluefoxdev.kr의 실시간 이커머스 아키텍처 가이드 를 참고하고, 라이브 커머스 실전 구현 관점에서 확장하여 작성했습니다. 라이브 커머스 전략은 bluebutton.kr 에서도 다루고 있습니다.


1. 라이브 커머스 시스템 구조

[전체 아키텍처]

방송사 RTMP 스트림
        ↓
  CDN (영상 배포)     ←────────────────────────────┐
        ↓                                          │
  [시청자 클라이언트]                               │
   ├── 영상 스트림 (HLS)                           │
   ├── WebSocket (채팅/좋아요/재고)    →  [WS 서버]  │
   └── REST API (주문)              →  [주문 서버]  │
                                                   │
[백엔드 레이어]                                     │
   ├── Redis Pub/Sub (실시간 이벤트)                │
   ├── Redis 재고 카운터 (원자적 감소)               │
   ├── Kafka (주문 이벤트 큐)                       │
   └── PostgreSQL (영구 저장)                       │
                                                   │
[방송 관리]                                         │
   └── 방송사 대시보드 ──── 상품 노출 API ───────────┘

2. 실시간 재고 관리 (오버셀링 방지)

2.1 Redis 원자적 재고 차감

# 라이브 방송 중 재고는 Redis에서 관리
import redis
import json
from datetime import datetime

class LiveInventoryManager:
    def __init__(self):
        self.redis = redis.Redis(host='localhost', decode_responses=True)
    
    def init_live_inventory(self, broadcast_id: str, product_id: str, quantity: int):
        """방송 시작 시 Redis에 재고 초기화"""
        key = f"live:{broadcast_id}:inventory:{product_id}"
        pipe = self.redis.pipeline()
        pipe.set(key, quantity)
        pipe.expire(key, 86400)  # 24시간 TTL
        pipe.execute()
    
    def try_reserve(self, broadcast_id: str, product_id: str, quantity: int = 1) -> bool:
        """원자적 재고 차감 (Lua 스크립트로 race condition 방지)"""
        key = f"live:{broadcast_id}:inventory:{product_id}"
        
        # Lua 스크립트: 재고 확인 + 차감을 원자적으로 실행
        lua_script = """
        local current = tonumber(redis.call('GET', KEYS[1]))
        if current == nil then
            return -1  -- 재고 정보 없음
        end
        if current < tonumber(ARGV[1]) then
            return 0   -- 재고 부족
        end
        redis.call('DECRBY', KEYS[1], tonumber(ARGV[1]))
        return current - tonumber(ARGV[1])  -- 남은 재고 반환
        """
        
        result = self.redis.eval(lua_script, 1, key, quantity)
        
        if result == -1:
            raise ValueError("재고 정보를 찾을 수 없습니다")
        
        return result >= 0  # 0 이상이면 예약 성공
    
    def release_reservation(self, broadcast_id: str, product_id: str, quantity: int = 1):
        """결제 실패 시 재고 복구"""
        key = f"live:{broadcast_id}:inventory:{product_id}"
        self.redis.incrby(key, quantity)
    
    def get_remaining(self, broadcast_id: str, product_id: str) -> int:
        """현재 남은 재고 조회"""
        key = f"live:{broadcast_id}:inventory:{product_id}"
        value = self.redis.get(key)
        return int(value) if value else 0
    
    def broadcast_inventory_update(self, broadcast_id: str, product_id: str):
        """재고 변경 시 구독자에게 실시간 푸시"""
        remaining = self.get_remaining(broadcast_id, product_id)
        
        message = json.dumps({
            'type': 'inventory_update',
            'product_id': product_id,
            'remaining': remaining,
            'timestamp': datetime.utcnow().isoformat(),
        })
        
        channel = f"live:{broadcast_id}:events"
        self.redis.publish(channel, message)

2.2 주문 처리 플로우

from enum import Enum
import asyncio

class OrderStatus(Enum):
    PENDING = "pending"
    RESERVED = "reserved"   # 재고 차감 완료
    CONFIRMED = "confirmed"  # 결제 완료
    FAILED = "failed"

async def process_live_order(
    broadcast_id: str,
    product_id: str,
    user_id: str,
    quantity: int,
    inventory_manager: LiveInventoryManager
) -> dict:
    
    # 1. 재고 예약 (Redis 원자적 차감)
    reserved = inventory_manager.try_reserve(broadcast_id, product_id, quantity)
    if not reserved:
        return {'success': False, 'error': '재고가 소진되었습니다'}
    
    try:
        # 2. 주문 생성 (DB)
        order = await create_order_db(user_id, product_id, quantity)
        
        # 3. 재고 변경 실시간 브로드캐스트
        inventory_manager.broadcast_inventory_update(broadcast_id, product_id)
        
        # 4. 결제 처리 (비동기)
        await payment_queue.send({
            'order_id': order['id'],
            'user_id': user_id,
            'broadcast_id': broadcast_id,
            'product_id': product_id,
            'amount': order['total'],
        })
        
        return {'success': True, 'order_id': order['id']}
    
    except Exception as e:
        # 실패 시 재고 복구
        inventory_manager.release_reservation(broadcast_id, product_id, quantity)
        return {'success': False, 'error': str(e)}

3. 채팅 및 실시간 이벤트 처리

3.1 WebSocket 서버 (FastAPI)

from fastapi import FastAPI, WebSocket, WebSocketDisconnect
import json, asyncio

app = FastAPI()

class BroadcastRoom:
    def __init__(self, broadcast_id: str):
        self.broadcast_id = broadcast_id
        self.connections: set[WebSocket] = set()
        self.chat_count = 0
        self.like_count = 0
    
    async def connect(self, ws: WebSocket):
        await ws.accept()
        self.connections.add(ws)
        # 현재 상태 전송
        await ws.send_json({
            'type': 'init',
            'chat_count': self.chat_count,
            'like_count': self.like_count,
            'viewer_count': len(self.connections),
        })
    
    def disconnect(self, ws: WebSocket):
        self.connections.discard(ws)
    
    async def broadcast(self, message: dict):
        if not self.connections:
            return
        
        data = json.dumps(message)
        # 모든 연결에 동시 전송
        await asyncio.gather(
            *[ws.send_text(data) for ws in self.connections],
            return_exceptions=True  # 개별 실패가 전체를 막지 않음
        )

rooms: dict[str, BroadcastRoom] = {}

@app.websocket("/ws/live/{broadcast_id}")
async def live_websocket(ws: WebSocket, broadcast_id: str, user_id: str):
    room = rooms.setdefault(broadcast_id, BroadcastRoom(broadcast_id))
    await room.connect(ws)
    
    try:
        while True:
            data = await ws.receive_json()
            
            if data['type'] == 'chat':
                # 채팅 메시지 검증 및 전파
                if len(data.get('message', '')) > 200:
                    continue  # 너무 긴 메시지 무시
                
                room.chat_count += 1
                await room.broadcast({
                    'type': 'chat',
                    'user_id': user_id,
                    'message': data['message'],
                    'timestamp': data.get('timestamp'),
                })
            
            elif data['type'] == 'like':
                room.like_count += 1
                await room.broadcast({
                    'type': 'like_count',
                    'count': room.like_count,
                })
    
    except WebSocketDisconnect:
        room.disconnect(ws)
        await room.broadcast({
            'type': 'viewer_count',
            'count': len(room.connections),
        })

4. 트래픽 스파이크 대응

4.1 상품 노출 시 주문 급증 처리

[주문 급증 대응 전략]

1. 주문 접수 레이어 (수평 확장)
   - 재고 예약만 처리 (Redis, 빠름)
   - 주문 번호 발급 후 큐에 적재
   - 응답: "주문 접수됨, 처리 중"

2. 주문 처리 레이어 (Kafka Consumer)
   - DB 저장, 결제 처리
   - 처리 속도에 맞춰 소비
   - 실패 시 DLQ(Dead Letter Queue)로 이동

3. 결과 알림 (WebSocket/푸시)
   - 처리 완료 시 사용자에게 알림
# 주문 접수 (빠른 응답)
async def quick_order_accept(order_data: dict) -> dict:
    # 1. 재고 예약 (Redis, ~1ms)
    reserved = inventory_manager.try_reserve(
        order_data['broadcast_id'],
        order_data['product_id'],
        order_data['quantity']
    )
    
    if not reserved:
        return {'status': 'sold_out'}
    
    # 2. 주문 ID 발급
    order_id = generate_order_id()
    
    # 3. Kafka에 적재 (비동기, ~2ms)
    await kafka_producer.send('live-orders', {
        'order_id': order_id,
        **order_data,
        'reserved_at': datetime.utcnow().isoformat(),
    })
    
    # 4. 즉시 응답 (총 ~5ms 이내)
    return {
        'status': 'accepted',
        'order_id': order_id,
        'message': '주문이 접수되었습니다. 결제 처리 중...',
    }

5. 방송사 연동 API

# 방송 관리자 대시보드 API
@router.post("/broadcasts/{broadcast_id}/products/spotlight")
async def spotlight_product(
    broadcast_id: str,
    product_id: str,
    spotlight_price: float,  # 방송 특가
    limited_quantity: int,   # 한정 수량
):
    """상품 노출 시작 - 재고 초기화 + 시청자에게 알림"""
    
    # 재고 초기화
    inventory_manager.init_live_inventory(
        broadcast_id, product_id, limited_quantity
    )
    
    # 상품 정보 조회
    product = await product_service.get(product_id)
    
    # 전체 시청자에게 상품 노출 이벤트 전송
    room = rooms.get(broadcast_id)
    if room:
        await room.broadcast({
            'type': 'product_spotlight',
            'product': {
                'id': product_id,
                'name': product.name,
                'original_price': product.price,
                'live_price': spotlight_price,
                'limited_quantity': limited_quantity,
                'image': product.thumbnail_url,
            },
        })
    
    return {'status': 'spotlighted', 'remaining': limited_quantity}

6. 모니터링 지표

[라이브 커머스 핵심 지표]

실시간:
- 동시 시청자 수 (Viewer Count)
- 초당 주문 수 (Orders per Second)
- 재고 소진율 (%)
- WebSocket 연결 수

성과:
- CVR (주문 / 시청자)
- 평균 주문 금액
- 재고 소진 시간
- 방송 중 이탈률

기술:
- WebSocket 메시지 레이턴시 (p99 < 100ms 목표)
- 재고 예약 성공률
- 결제 완료율
- Redis 히트율

마무리

라이브 커머스 백엔드의 핵심은 세 가지다.

  1. 재고는 Redis에서 원자적으로: DB에서 SELECT→UPDATE하면 오버셀링 필연
  2. 주문 접수와 처리를 분리: 급증 트래픽은 큐로 완충
  3. 실시간은 WebSocket + Redis Pub/Sub: 채팅, 재고, 좋아요 모두 구독 기반으로

방송 시작 전 부하 테스트를 반드시 진행하라. 실제 방송 중 다운되면 브랜드 이미지 타격이 크다.