OPEN HYPER STEP
← 목록으로 (stack-analysis)
STACK-ANALYSIS · 76 / 90
stack-analysis
CHAPTER 76 / 90
읽기 약 2
FUNCTION

재고 관리 시스템


핵심 개념

실재고·예약·warehouse·다중 채널·실시간 동기화 — 정확한 재고.

본문

재고 모델 (3계층)

📋 코드 (9줄)
1. 실제 재고 (physical_stock)
   - 창고에 있는 실제 물량

2. 가용 재고 (available)
   - 실제 - 예약 (장바구니·결제 중)

3. 표시 재고 (display)
   - 가용 + 안전 마진
   - 0이어도 "주문 가능" 표시 가능 (재입고 알림 유도)

데이터 모델

SQL📋 코드 (32줄)
CREATE TABLE warehouses (
  id UUID PRIMARY KEY,
  name VARCHAR NOT NULL,
  code VARCHAR UNIQUE,
  address JSONB
);

CREATE TABLE inventory (
  id UUID PRIMARY KEY,
  variant_id UUID NOT NULL REFERENCES product_variants(id),
  warehouse_id UUID NOT NULL REFERENCES warehouses(id),
  quantity INT NOT NULL DEFAULT 0,    -- 실재고
  reserved INT NOT NULL DEFAULT 0,    -- 예약 (15분 차감)
  reorder_point INT DEFAULT 10,
  reorder_quantity INT DEFAULT 100,
  updated_at TIMESTAMP DEFAULT NOW(),
  UNIQUE (variant_id, warehouse_id)
);

CREATE TABLE inventory_movements (
  id UUID PRIMARY KEY,
  inventory_id UUID NOT NULL REFERENCES inventory(id),
  type VARCHAR NOT NULL,  -- 'in', 'out', 'adjust', 'reserve', 'release'
  quantity INT NOT NULL,
  reference_type VARCHAR,  -- 'order', 'return', 'adjustment'
  reference_id UUID,
  note TEXT,
  created_by UUID,
  created_at TIMESTAMP DEFAULT NOW()
);

CREATE INDEX ON inventory_movements(inventory_id, created_at DESC);

입출고 트랜잭션

TYPESCRIPT📋 코드 (51줄)
async function moveInventory({
  variantId, warehouseId, type, quantity, referenceType, referenceId, userId,
}: MoveInput) {
  return db.$transaction(async (tx) => {
    const inv = await tx.inventory.findUnique({
      where: { variantId_warehouseId: { variantId, warehouseId } },
    });
    if (!inv) throw new NotFoundError('Inventory');

    let newQuantity = inv.quantity;
    let newReserved = inv.reserved;

    switch (type) {
      case 'in':
        newQuantity += quantity;
        break;
      case 'out':
        if (inv.quantity < quantity) throw new ConflictError('Insufficient stock');
        newQuantity -= quantity;
        break;
      case 'reserve':
        if (inv.quantity - inv.reserved < quantity) {
          throw new ConflictError('Insufficient available');
        }
        newReserved += quantity;
        break;
      case 'release':
        newReserved = Math.max(0, newReserved - quantity);
        break;
      case 'adjust':
        newQuantity = quantity;  // 절대값
        break;
    }

    await tx.inventory.update({
      where: { id: inv.id },
      data: { quantity: newQuantity, reserved: newReserved, updatedAt: new Date() },
    });

    await tx.inventoryMovement.create({
      data: {
        inventoryId: inv.id,
        type, quantity,
        referenceType, referenceId,
        createdBy: userId,
      },
    });

    return { quantity: newQuantity, reserved: newReserved };
  }, { isolationLevel: 'Serializable' });
}

다중 창고 — 가까운 창고 선택

TYPESCRIPT📋 코드 (28줄)
async function findFulfillmentWarehouse(
  variantId: string,
  quantity: number,
  customerLocation: { lat: number; lng: number }
) {
  const inventories = await db.inventory.findMany({
    where: {
      variantId,
      quantity: { gte: quantity },
    },
    include: { warehouse: true },
  });

  // 가용 재고 있는 창고 중 가장 가까운 곳
  const candidates = inventories
    .map(inv => ({
      ...inv,
      available: inv.quantity - inv.reserved,
      distance: haversine(
        customerLocation,
        { lat: inv.warehouse.lat, lng: inv.warehouse.lng }
      ),
    }))
    .filter(c => c.available >= quantity)
    .sort((a, b) => a.distance - b.distance);

  return candidates[0];
}

재입고 알림

TYPESCRIPT📋 코드 (29줄)
// 사용자가 품절 상품 알림 신청
CREATE TABLE restock_subscriptions (
  user_id UUID NOT NULL,
  variant_id UUID NOT NULL,
  email VARCHAR NOT NULL,
  notified_at TIMESTAMP,
  PRIMARY KEY (user_id, variant_id)
);


// 재고 입고 이벤트
eventBus.subscribe('inventory.restocked', async ({ variantId, quantity }) => {
  if (quantity < 5) return;  // 너무 적으면 스킵

  const subscribers = await db.restockSubscription.findMany({
    where: { variantId, notifiedAt: null },
  });

  for (const sub of subscribers) {
    await emailQueue.add('restock', {
      email: sub.email,
      variantId,
    });
    await db.restockSubscription.update({
      where: { userId_variantId: { userId: sub.userId, variantId } },
      data: { notifiedAt: new Date() },
    });
  }
});

자동 재주문 (reorder point)

TYPESCRIPT📋 코드 (20줄)
// 매시간 - 재고 부족 알림
const reorderCheck = new Worker('reorder-check', async () => {
  const lowStock = await db.inventory.findMany({
    where: {
      quantity: { lte: db.inventory.fields.reorderPoint },
    },
    include: { variant: { include: { product: true } } },
  });

  for (const inv of lowStock) {
    await emailQueue.add('low_stock_alert', {
      to: 'inventory@example.com',
      product: inv.variant.product.name,
      variant: inv.variant.name,
      current: inv.quantity,
      reorderPoint: inv.reorderPoint,
      suggested: inv.reorderQuantity,
    });
  }
});

재고 정확도 (Cycle Counting)

TYPESCRIPT📋 코드 (27줄)
// 주기적 실재고 vs DB 비교
async function reconcileInventory(warehouseId: string, counts: { variantId: string; actualQuantity: number }[]) {
  const adjustments = [];

  for (const count of counts) {
    const inv = await db.inventory.findUnique({
      where: { variantId_warehouseId: { variantId: count.variantId, warehouseId } },
    });
    if (!inv) continue;

    if (inv.quantity !== count.actualQuantity) {
      const diff = count.actualQuantity - inv.quantity;
      adjustments.push({ variantId: count.variantId, diff });

      await moveInventory({
        variantId: count.variantId,
        warehouseId,
        type: 'adjust',
        quantity: count.actualQuantity,
        referenceType: 'cycle_count',
        userId: 'system',
      });
    }
  }

  return adjustments;
}

다음 챕터

CH.77 "리뷰/평점 시스템".


AI 프롬프트
🤖 AI에게 잘 물어보는 법 — 모델·전략별 프롬프트
Claude

무료: Sonnet 4.6 / Pro $20/mo: Opus 4.6

내 프로젝트의 재고 관리 부분을 분석해서
실전 분석 + 개선 우선순위를 알려줘.
ChatGPT

무료: GPT-5.5 / Plus $20/mo: GPT-5.5 Pro

재고 관리 관련 실제 서비스 5개를
비교 분석해서 패턴 추출를 알려줘.
Gemini

무료: 2.5 Flash / Pro $19.99/mo: 3.1 Pro

내 코드베이스에서 재고 관리
최적화 가능 위치를 보고해줘.
Grok

무료: Grok 4.1 / SuperGrok $30/mo

2026년 한국 풀스택 시장의
재고 관리 트렌드를 솔직히 알려줘.

⭐ 이것만 기억하세요
재고 관리 시스템 이 3가지만 확실히 잡으세요
1.재고 = 실재고 - 예약 = 가용 재고
2.예약 패턴(15분 만료)으로 결제 중 재고 확보
3.입출고 movements 테이블로 모든 변화 추적 — 감사·디버깅


공유하기
진행도 76 / 90