이 글은 누구를 위한 것인가
- 라이브 방송 중 재고 오버셀링과 서버 다운을 경험한 팀
- 라이브 커머스 기능을 기존 이커머스 플랫폼에 추가하려는 엔지니어
- 동시 접속 급증(트래픽 스파이크)을 안정적으로 처리해야 하는 아키텍트
들어가며
라이브 커머스는 일반 이커머스와 근본적으로 다른 트래픽 패턴을 갖는다. 방송 중 인기 상품이 노출되는 순간 수만 명이 동시에 주문을 시도한다. 일반 피크 대비 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 히트율
마무리
라이브 커머스 백엔드의 핵심은 세 가지다.
- 재고는 Redis에서 원자적으로: DB에서 SELECT→UPDATE하면 오버셀링 필연
- 주문 접수와 처리를 분리: 급증 트래픽은 큐로 완충
- 실시간은 WebSocket + Redis Pub/Sub: 채팅, 재고, 좋아요 모두 구독 기반으로
방송 시작 전 부하 테스트를 반드시 진행하라. 실제 방송 중 다운되면 브랜드 이미지 타격이 크다.