docs(10_Wiki): Topic_Business/General/Graphic/Programming을 Topics/ 하위로 이동
최상위 10_Wiki/Topic_*였던 4개 카테고리 폴더를 10_Wiki/Topics/Topic_* 로 재배치. 콘텐츠 변경 없음(순수 폴더 이동) — Topics/ 하위 나머지 폴더는 이미 지난 커밋에서 전부 정리된 상태(잔존 항목은 에이전트 운영 상태 및 사용자가 보존을 요청한 업데이트0615/무제 3.canvas 뿐).
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
---
|
||||
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]]
|
||||
- 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 추가 |
|
||||
Reference in New Issue
Block a user