--- id: wiki-2026-0508-이커머스의-실시간-재고-관리 title: 이커머스의 실시간 재고 관리 category: 10_Wiki/Topics status: verified canonical_id: self aliases: [Real-time Inventory, Stock Management, Inventory Reservation] duplicate_of: none source_trust_level: A confidence_score: 0.9 verification_status: applied tags: [ecommerce, inventory, event-driven, distributed-systems] raw_sources: [] last_reinforced: 2026-05-10 github_commit: pending tech_stack: language: typescript framework: redis-kafka --- # 이커머스의 실시간 재고 관리 ## 매 한 줄 > **"매 stock = 단일 진실 + reservation log"**. 이커머스 재고는 oversell·undersell 사이의 trade-off — 동일 SKU에 동시 구매 요청이 몰리면 절대 수량은 atomic하게 감소해야 하고, 결제 실패 시 복구도 atomic해야 한다. 2026 표준은 Redis(또는 DB row lock)에서 reservation, Kafka로 event publish, eventual consistency로 warehouse·search·analytics 동기화. ## 매 핵심 ### 매 두 모델 - **Reserve-then-confirm** (most): cart 추가 시 hold (TTL 10-30분), 결제 완료 시 commit. Hold 만료 → 자동 release. - **Optimistic deduction**: 결제 시점에만 차감 — 빠르지만 oversell 위험. 재고 풍부 시 OK. ### 매 일관성 layer - **Source of truth**: 단일 store (Postgres row 또는 Redis HSET). Strict serializable. - **Read replica / search index**: eventual consistency. Kafka changelog로 동기화. - **CDN cached PDP**: stale 허용 (몇 초). "Add to cart" 시 source 재확인. ### 매 응용 1. **Flash sale**: pre-allocate per-shard inventory, hot-key 분산. 2. **Multi-warehouse**: SKU × warehouse 행렬, 배송 가능성으로 reservation routing. 3. **Marketplace**: 판매자 자체 stock + platform reservation. ## 💻 패턴 ### Redis Lua로 atomic reserve ```lua -- reserve.lua: KEYS[1]=stock:{sku}, ARGV[1]=qty, ARGV[2]=reservationId, ARGV[3]=ttlSec local available = tonumber(redis.call('GET', KEYS[1]) or '0') local qty = tonumber(ARGV[1]) if available < qty then return {0, available} end redis.call('DECRBY', KEYS[1], qty) redis.call('SET', 'reserve:' .. ARGV[2], qty, 'EX', ARGV[3]) return {1, available - qty} ``` ```typescript import { createClient } from 'redis'; const redis = createClient(); await redis.connect(); const reserveScript = await redis.scriptLoad(LUA); export async function reserve(sku: string, qty: number, ttlSec = 1800) { const reservationId = crypto.randomUUID(); const [ok, remaining] = await redis.evalSha(reserveScript, { keys: [`stock:${sku}`], arguments: [String(qty), reservationId, String(ttlSec)], }) as [number, number]; if (!ok) throw new OutOfStockError(sku, qty, remaining); return reservationId; } ``` ### Postgres row-level lock 대안 ```sql -- single SKU, strong consistency, ~1k tx/s 까지 OK BEGIN; SELECT available FROM stock WHERE sku = $1 FOR UPDATE; -- application-side check UPDATE stock SET available = available - $2 WHERE sku = $1 AND available >= $2; INSERT INTO reservation (id, sku, qty, expires_at) VALUES ($3, $1, $2, now() + interval '30 minutes'); COMMIT; ``` ### Confirm / Release ```typescript export async function confirm(reservationId: string) { const r = await db.reservation.findUnique({ where: { id: reservationId } }); if (!r || r.status !== 'held') throw new Error('invalid reservation'); await db.$transaction([ db.reservation.update({ where: { id: reservationId }, data: { status: 'confirmed' } }), db.stockMovement.create({ data: { sku: r.sku, qty: -r.qty, kind: 'sale' } }), ]); await kafka.publish('inventory.events', { type: 'StockSold', sku: r.sku, qty: r.qty, reservationId, }); } export async function release(reservationId: string) { // Lua에서 stock:{sku} INCRBY qty + DEL reserve:{id} } ``` ### TTL 만료 자동 release (Redis keyspace notifications) ```typescript // redis CONFIG SET notify-keyspace-events Ex const sub = redis.duplicate(); await sub.connect(); await sub.pSubscribe('__keyevent@0__:expired', async (key) => { if (key.startsWith('reserve:')) { const id = key.slice('reserve:'.length); await release(id); // idempotent } }); ``` ### Kafka changelog → search/analytics ```typescript // consumer: inventory.events → ElasticSearch const consumer = kafka.consumer({ groupId: 'search-indexer' }); await consumer.subscribe({ topic: 'inventory.events' }); await consumer.run({ eachMessage: async ({ message }) => { const e = JSON.parse(message.value!.toString()); if (e.type === 'StockSold' || e.type === 'StockAdded') { const remaining = await redis.get(`stock:${e.sku}`); await es.update({ index: 'products', id: e.sku, doc: { in_stock: Number(remaining) > 0, available: Number(remaining) }, }); } }, }); ``` ### Hot-key 분산 (flash sale) ```typescript // SKU 'AIR-MAX' 10000개 → 10 shard 각 1000개 const shards = 10; async function reserveSharded(sku: string, qty: number) { const shardOrder = shuffle([...Array(shards).keys()]); for (const i of shardOrder) { try { return await reserve(`${sku}:shard:${i}`, qty); } catch (e) { if (!(e instanceof OutOfStockError)) throw e; } } throw new OutOfStockError(sku, qty, 0); } ``` ## 매 결정 기준 | 상황 | Approach | |---|---| | 재고 풍부, 동시성 낮음 | Postgres row lock | | 동시성 높음, 단일 SKU | Redis Lua + reservation TTL | | Flash sale, 동일 SKU 폭주 | Sharded inventory | | Multi-warehouse | SKU × WH 매트릭스 + routing | **기본값**: Redis reservation + Kafka changelog. Postgres는 audit·reconciliation용 source of truth. ## 🔗 Graph - 부모: [[Event-Driven Architecture]] · [[Distributed Transactions]] - 변형: [[Saga Pattern]] · [[Two-Phase Commit]] - 응용: [[Flash Sale Architecture]] · [[Marketplace Architecture]] - Adjacent: [[CQRS]] · [[Eventual Consistency]] · [[Idempotency]] ## 🤖 LLM 활용 **언제**: reservation 흐름 설계, oversell incident 분석, hot-key 식별. **언제 X**: 재고 추적이 필요 없는 digital good (license key 무한) — 단순 카운터로 충분. ## ❌ 안티패턴 - **READ + 그 다음 UPDATE** (lock 없이): TOCTOU race → oversell. - **Reservation 만료 처리 누락**: hold이 영원히 남아 가용 재고 잠식. - **Search index를 source of truth로 사용**: ES eventual consistency → 결제 시점 negative stock. - **재고 0에서 cart 추가 허용**: UX는 좋지만 결제 실패 폭주. ## 🧪 검증 / 중복 - Verified (Shopify Engineering, "Building Resilient Payment Systems", 2024; AWS retail patterns). - 신뢰도 A. ## 🕓 Changelog | 날짜 | 변경 | |---|---| | 2026-05-08 | Phase 1 | | 2026-05-10 | Manual cleanup — reservation 모델·Lua·sharding 추가 |