커머스/F&B 백엔드에서 도메인 모델링 질문이 들어오면 대부분 답변이 주문과 결제 축에 쏠린다. 그러나 실제 운영에서 가장 자주 사고가 나는 곳은 그 옆에 붙은 두 축, 재고(Inventory)와 노출(Display/Catalog)이다. - "결제는 됐는데 매장에 재료가 없다고 거절당했다." → Inventory 축의 race condition. - "장바...
커머스/F&B 백엔드에서 도메인 모델링 질문이 들어오면 대부분 답변이 주문과 결제 축에 쏠린다. 그러나 실제 운영에서 가장 자주 사고가 나는 곳은 그 옆에 붙은 두 축, 재고(Inventory)와 노출(Display/Catalog)이다.
commerce-order-state-consistency-fundamentals.md, ecommerce-order-payment-domain-modeling.md, fnb-order-store-pickup-state-machine.md이 주문·결제·상태머신을 다룬다면, 이 문서는 그 옆에 빠져 있던 재고와 노출을 채운다. 면접에서 "주문 시스템 어떻게 설계하시겠어요"에 결제·상태머신만 답하면 50점이고, 재고와 노출까지 자르면 70점, 셋 사이의 동기화·캐시 전략까지 말하면 90점이다.
Product라는 단어 하나로 모든 컨텍스트가 같은 테이블을 바라보는 순간 설계는 무너진다. 같은 햄버거 상품이라도 다음 세 컨텍스트에서 의미가 다르다.
| 컨텍스트 | 같은 햄버거의 의미 | 변경 빈도 | 일관성 요구 |
|---|---|---|---|
| Catalog (마스터) | SKU, 영양정보, 알러지, 기본 가격 | 낮음(주 단위) | 강한 일관성 |
| Display (노출) | 매장 노출 여부, 시간대 메뉴, 정렬 순위, 품절 표시 | 중간(시간 단위) | 결과적 일관성 + 짧은 지연 허용 |
| Inventory (재고) | 매장별 잔여 수량, 예약/확정/취소 | 매우 높음(초 단위) | 강한 일관성 (트랜잭션) |
| Order (주문) | 주문 시점의 가격·옵션 스냅샷 | 한 번만 쓰임 | 불변(스냅샷) |
각 컨텍스트는 자체 Aggregate Root를 갖고, 컨텍스트 간 참조는 객체가 아니라 ID와 도메인 이벤트로만 한다. 이 원칙은 ddd-domain-modeling.md의 Bounded Context를 커머스 도메인에 그대로 적용한 결과다.
Catalog는 상품의 불변에 가까운 본질만 담는다.
CREATE TABLE catalog_item (
item_id BIGINT PRIMARY KEY,
brand_id BIGINT NOT NULL,
sku VARCHAR(64) NOT NULL UNIQUE,
name_ko VARCHAR(200) NOT NULL,
default_price INT NOT NULL,
nutrition_json JSON,
allergen_json JSON,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at DATETIME(6) NOT NULL,
updated_at DATETIME(6) NOT NULL,
KEY idx_brand_active (brand_id, is_active)
) ENGINE=InnoDB;default_price는 기본값이고, 실제 매장·시간대 가격은 Display 쪽 정책으로 덮어쓴다. Catalog는 마스터 데이터에 가까워서 어드민 변경 빈도가 낮고, 변경되면 결과적으로 모든 매장이 따라간다.
면접 포인트: "기본 가격을 Catalog에 두느냐 Display에 두느냐"는 흔한 질문이다. 답은 둘 다다. Catalog의 default_price는 가격 정책이 비어 있을 때의 안전망이고, 실제 노출/주문 가격은 Display의 정책 테이블에서 결정한다. 정책이 통째로 비어 있어도 가격은 노출돼야 하기 때문이다.
Display 컨텍스트는 "이 매장에서, 지금 시각에, 이 상품을 어떤 모습으로 보여줄 것인가"를 다룬다. 운영자가 가장 자주 만지는 영역이지만 모델링이 가장 자주 망가지는 영역이기도 하다.
핵심 분리 원칙: "안 보임"의 이유가 무엇인지 코드가 답할 수 있어야 한다.
다음 다섯 가지 "안 보임" 사유는 절대 같은 컬럼으로 표현하면 안 된다.
is_visible=false) — 의도된 비노출hour_window에 포함 안 됨) — 자동 노출 제어Inventory.qty_available <= 0) — 재고 컨텍스트 사실catalog_item.is_active=false) — 상품 전체 단종이걸 한 컬럼(예: display_status)에 우겨넣으면 운영 알림이 "이거 왜 안 보여요?"로 가득 찬다. 사유가 분리돼야 어드민이 "운영자가 내림"으로 표시할지 "품절"로 표시할지를 결정할 수 있다.
CREATE TABLE display_policy (
policy_id BIGINT PRIMARY KEY AUTO_INCREMENT,
store_id BIGINT NOT NULL,
item_id BIGINT NOT NULL,
is_visible BOOLEAN NOT NULL DEFAULT TRUE,
price_override INT NULL,
sort_priority INT NOT NULL DEFAULT 0,
start_at DATETIME(6) NULL,
end_at DATETIME(6) NULL,
hour_window JSON NULL, -- [[11,15],[17,21]] 등
updated_at DATETIME(6) NOT NULL,
UNIQUE KEY uk_store_item (store_id, item_id),
KEY idx_store_visible (store_id, is_visible)
) ENGINE=InnoDB;
CREATE TABLE display_visibility_log (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
store_id BIGINT NOT NULL,
item_id BIGINT NOT NULL,
from_state VARCHAR(32) NOT NULL,
to_state VARCHAR(32) NOT NULL,
reason_code VARCHAR(32) NOT NULL, -- ADMIN_HIDE, HOUR_WINDOW, OUT_OF_STOCK, POLICY_MISSING, CATALOG_INACTIVE
changed_at DATETIME(6) NOT NULL,
KEY idx_store_item (store_id, item_id, changed_at)
);reason_code가 사유 분리의 핵심이다. 같은 "안 보임"이라도 사유가 운영 알림 단계에서 갈린다.
PLP(상품 목록) 응답은 매장당 수십~수백 상품을 한 번에 본다. Catalog + Display + Inventory를 JOIN해서 매번 계산하면 매장 트래픽이 몰릴 때 DB가 죽는다. 그래서 읽기 전용 read model을 별도로 둔다.
public record DisplayItem(
long itemId,
String nameKo,
int price,
int sortPriority,
boolean soldOut,
String hiddenReason // null이면 노출
) {}이 read model을 채우는 방식은 세 가지가 있고 트래픽 크기와 정합성 요구에 따라 선택한다.
display:store:{storeId} 전체 목록 캐시. 도메인 이벤트로 무효화. F&B/커머스 대부분이 여기.후보자 경험과 연결하면, RabbitMQ Fanout으로 다중 서버 인메모리 캐시를 무효화한 사례가 이 패턴의 변형이다. "정적 설정 데이터 갱신 시 전 서버 동시 무효화 + StampedLock으로 갱신 구간 보호"를 매장 메뉴 캐시로 옮기면 동일한 구조가 된다.
재고는 모든 컨텍스트 중 가장 짧은 트랜잭션을 요구한다. 재고 차감이 5초 걸리면 동시 결제가 줄을 서고, 결제 PG에 영향이 간다.
F&B/매장 픽업 도메인에서 재고는 거의 항상 매장별이다. 중앙 창고 모델(전자상거래)과 다르다.
CREATE TABLE inventory (
store_id BIGINT NOT NULL,
item_id BIGINT NOT NULL,
qty_on_hand INT NOT NULL, -- 매장 실재 수량
qty_reserved INT NOT NULL DEFAULT 0, -- 결제 진행 중 예약분
version INT NOT NULL DEFAULT 0,
updated_at DATETIME(6) NOT NULL,
PRIMARY KEY (store_id, item_id)
) ENGINE=InnoDB;qty_available = qty_on_hand - qty_reserved가 사용자에게 노출되는 잔여수량이다.
결제 직전에 재고를 잠시 예약하고, 결제 승인 후 확정한다. 결제 실패/취소 시 예약을 푼다.
qty_reserved += qty, 단 qty_on_hand - qty_reserved >= qty 조건이 동시에 성립해야 함qty_on_hand -= qty, qty_reserved -= qtyqty_reserved -= qty핵심은 1번 단계의 조건부 UPDATE다. SELECT 후 UPDATE를 분리하면 동시 차감이 음수 재고를 만든다.
-- 예약: 재고 충분할 때만 성공
UPDATE inventory
SET qty_reserved = qty_reserved + :qty,
version = version + 1,
updated_at = NOW(6)
WHERE store_id = :storeId
AND item_id = :itemId
AND qty_on_hand - qty_reserved >= :qty;affected rows = 1이면 예약 성공, 0이면 실패. 락 없이도 InnoDB row-level lock + WHERE 조건 평가가 동시성을 막아준다. 분산 락(Redisson 등)을 매번 끼우면 결제 PG 호출 시간까지 락이 끼어 운영 사고가 난다.
UPDATE inventory
SET qty_on_hand = qty_on_hand - :qty,
qty_reserved = qty_reserved - :qty,
version = version + 1,
updated_at = NOW(6)
WHERE store_id = :storeId
AND item_id = :itemId
AND qty_reserved >= :qty;qty_reserved >= :qty 조건이 멱등성을 보장한다. 같은 결제 승인 이벤트가 중복 도착해도 두 번째는 affected rows 0으로 끝난다(inbox 테이블과 함께 쓰면 더 안전).
UPDATE inventory i
JOIN order_reservation r ON r.store_id = i.store_id AND r.item_id = i.item_id
SET i.qty_reserved = i.qty_reserved - r.qty
WHERE r.status = 'RESERVED'
AND r.reserved_at < NOW(6) - INTERVAL 5 MINUTE;예약 테이블을 따로 두고(order_reservation) 어떤 주문이 어느 매장의 어느 상품을 얼마나 예약했는지 추적해야 자동 해제가 가능하다. 이 테이블 없이 qty_reserved만 운영하면 "누가 점유 중인지" 알 수 없어 운영 장애가 난다.
주문 생성 트랜잭션은 세 컨텍스트와 어떻게 상호작용해야 하는가. 다음 흐름이 기본이다.
@Transactional
public OrderResult placeOrder(PlaceOrderCommand cmd) {
// 1. Display에서 노출 가능 여부 + 가격 스냅샷
DisplayItem item = displayQuery.snapshot(cmd.storeId(), cmd.itemId(), cmd.requestedAt());
if (item.hiddenReason() != null) {
throw new ItemNotAvailableException(item.hiddenReason());
}
// 2. Inventory 예약 (조건부 UPDATE)
int reserved = inventoryRepo.tryReserve(
cmd.storeId(), cmd.itemId(), cmd.qty()
);
if (reserved == 0) {
throw new OutOfStockException(cmd.storeId(), cmd.itemId());
}
// 3. Order Aggregate 생성. Display 스냅샷을 그대로 동결.
Order order = Order.place(
cmd, OrderPriceSnapshot.from(item)
);
orderRepo.save(order);
// 4. 같은 트랜잭션에 outbox 적재
outboxPublisher.append(new OrderPlacedEvent(order.id()));
// 5. order_reservation에 예약 추적 row 적재
reservationRepo.save(OrderReservation.of(order.id(), cmd, NOW));
return OrderResult.of(order);
}이 흐름이 명시적으로 분리하는 것:
결제 승인 이후의 흐름은 비동기로 풀린다.
OrderPaymentApprovedEventconfirm() 호출 (조건부 UPDATE로 멱등)qty_available 캐시 갱신Inventory.cancelConfirm() → Payment.cancel() → Order.markCanceledByStore()핵심은 각 consumer가 자기 컨텍스트의 사실만 책임진다는 것. Inventory consumer는 Display 캐시를 직접 무효화하지 않고, Display consumer가 별도로 OrderPaymentApprovedEvent를 구독해 자기 캐시를 갱신한다. 의존 방향을 컨텍스트별로 분리해야 한 컨텍스트의 장애가 다른 컨텍스트를 막지 않는다.
@Transactional
public OrderResult placeOrder(PlaceOrderCommand cmd) {
Product p = productRepo.findById(cmd.itemId()).orElseThrow();
if (!p.isVisibleAt(cmd.storeId())) throw new NotVisibleException();
int stock = stockRepo.findStock(cmd.storeId(), cmd.itemId());
if (stock < cmd.qty()) throw new OutOfStockException();
stockRepo.decrease(cmd.storeId(), cmd.itemId(), cmd.qty()); // SELECT 후 UPDATE
Order order = Order.create(cmd, p.getPrice()); // 가격 직접 참조
orderRepo.save(order);
kafkaTemplate.send("order.placed", order); // 트랜잭션 밖 발행
posClient.notify(cmd.storeId(), order); // 외부 호출이 트랜잭션 안
return OrderResult.of(order);
}문제 6가지:
Product)에 섞임 — Aggregate 경계 붕괴@Transactional
public OrderResult placeOrder(PlaceOrderCommand cmd) {
DisplayItem snapshot = displayQuery.snapshot(cmd.storeId(), cmd.itemId(), NOW);
if (snapshot.hiddenReason() != null) throw new ItemNotAvailableException(snapshot.hiddenReason());
int reserved = inventoryRepo.tryReserve(cmd.storeId(), cmd.itemId(), cmd.qty());
if (reserved == 0) throw new OutOfStockException();
Order order = Order.place(cmd, snapshot);
orderRepo.save(order);
reservationRepo.save(OrderReservation.of(order.id(), cmd, NOW));
outboxPublisher.append(new OrderPlacedEvent(order.id()));
return OrderResult.of(order);
}차이: 세 컨텍스트가 분리되고, 재고는 조건부 UPDATE, 가격은 스냅샷, 외부 호출은 outbox, 예약 추적까지 보장.
services:
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: commerce
ports: ["3306:3306"]
redis:
image: redis:7-alpine
ports: ["6379:6379"]세 컨텍스트 스키마(요약):
CREATE TABLE inventory (
store_id BIGINT, item_id BIGINT,
qty_on_hand INT NOT NULL, qty_reserved INT NOT NULL DEFAULT 0,
version INT NOT NULL DEFAULT 0, updated_at DATETIME(6) NOT NULL,
PRIMARY KEY (store_id, item_id)
);
CREATE TABLE display_policy (
store_id BIGINT, item_id BIGINT,
is_visible BOOLEAN NOT NULL DEFAULT TRUE,
price_override INT NULL, hour_window JSON NULL,
updated_at DATETIME(6) NOT NULL,
PRIMARY KEY (store_id, item_id)
);
CREATE TABLE order_reservation (
order_id BIGINT, store_id BIGINT, item_id BIGINT,
qty INT NOT NULL, status VARCHAR(16) NOT NULL,
reserved_at DATETIME(6) NOT NULL, confirmed_at DATETIME(6) NULL,
PRIMARY KEY (order_id, item_id)
);qty_on_hand=1로 두고 두 세션에서 동시에 UPDATE inventory SET qty_on_hand = qty_on_hand - 1 WHERE store_id=? AND item_id=?만 실행. 둘 다 성공해서 qty_on_hand=-1이 되는 것을 확인. 그 다음 AND qty_on_hand >= 1을 추가한 조건부 UPDATE로 한 쪽만 성공함을 확인.display_policy.is_visible=false로 두고 PLP 응답이 "운영자가 내림"으로 표시되는지, qty_on_hand=0인 경우 "품절"로 표시되는지 별 사유 코드를 검증.hour_window=[[11,15]]로 설정하고 11시 정각에 노출이 켜지는지, 15시 정각에 꺼지는지 검증. 캐시 무효화가 정시 ±1초 안에 일어나는지 확인.OrderPaymentApprovedEvent를 컨슈머에 두 번 흘려서 재고가 한 번만 차감되는지 확인.F&B나 매장 픽업 도메인에서는 재고가 매장 단위라 자연 키가 (매장ID, 상품ID)가 됩니다. 재고 차감은 결제 직전에 일시 예약하고 결제 승인 후 확정하는 Reserve-then-Confirm 패턴을 기본으로 둡니다. 핵심은 예약 단계의 조건부 UPDATE 한 줄로
qty_on_hand - qty_reserved >= 요청수량을 평가하는 거고, 이게 InnoDB row lock과 결합해 분산 락 없이도 음수 재고를 막아줍니다. 분산 락을 결제 PG 호출 시간까지 끼면 락 점유가 길어져 운영 장애가 나기 쉽습니다. 예약은 일정 시간 후 janitor가 자동 해제하고, 어느 주문이 점유 중인지는 별도order_reservation테이블로 추적합니다.
'왜 안 보이는가'를 코드가 다섯 가지 사유로 답할 수 있어야 운영 알림이 분리됩니다. 운영자가 내린 것, 시간대 메뉴가 아닌 것, 품절인 것, 정책 미설정인 것, 단종된 것은 운영자가 봐야 할 대시보드가 다릅니다. 한 컬럼으로 합치면 어드민이 사유별 액션을 못 합니다. 그래서 Display는 노출 정책만 책임지고, 품절 여부는 Inventory가 진실의 원천을 갖고, PLP 응답을 만드는 read model이 둘을 합쳐
soldOut/hiddenReason을 채워줍니다.
운영자 변경, 시간대 전환, 재고 변경 세 가지가 트리거입니다. 도메인 이벤트를 outbox로 발행하고, Display consumer가 매장 단위로 캐시를 무효화합니다. 캐시 키는 매장 ID 단위가 자연스럽고, 상품 단건이 아니라 매장 전체 목록을 통째로 다시 만드는 편이 PLP 응답 시간 면에서 안정적입니다. 시간대 전환은 cron 기반 정시 발행 + TTL 안전망을 같이 둡니다. 이전 업무에서 정적 설정 데이터를 다중 서버 인메모리 캐시로 운영할 때 RabbitMQ Fanout으로 전 서버 무효화하고 StampedLock writeLock으로 갱신 구간을 보호한 경험이 있어서, 매장 메뉴 캐시도 같은 구조로 풀 수 있습니다.
장바구니는 Display 가격을 그때그때 다시 조회해서 보여줍니다. 사용자가 결제 버튼을 누르는 순간 Display 스냅샷을 Order Aggregate에 동결하고, 그 이후의 정책 변경은 이미 생성된 주문에 영향이 없습니다. 만약 장바구니 진입 시 가격과 결제 시점 가격이 다르면 사용자에게 변경 사실을 한 번 확인받는 UX가 안전합니다. 핵심은 'Order는 과거의 사실을 불변으로 보존한다'는 원칙입니다.
운영 정책에 따라 다르지만, 기본은 품절 표시 + 노출 유지가 더 좋습니다. 안 보이면 사용자가 "내가 잘못 봤나" 혼동하고, 매장에서는 "그 메뉴 있는 줄 알고 왔는데" 컴플레인이 옵니다. Display read model에
soldOut=true플래그로 노출하고, 정렬 우선순위만 뒤로 미루는 게 일반적입니다. 단, 시즌 메뉴처럼 "끝났음"을 명확히 알려야 하는 경우는 운영자가 명시적으로 내리도록 합니다.
정적 설정 데이터를 다중 서버 인메모리 캐시로 운영할 때 갱신 빈도는 낮고 조회가 압도적이라
StampedLock+ optimistic read로 reader가 락 없이 흐르게 만들고, writer 진입 시점에만tryWriteLock타임아웃을 박았습니다. 커머스로 옮기면 매장 메뉴 노출 캐시가 정확히 같은 패턴입니다 — 운영자 변경 빈도는 낮고 PLP 조회는 매장 트래픽 그대로 받는 영역.
어드민 변경 시 다중 서버 정합성이 깨져 일시적 NPE가 났던 사고를 Hibernate
PostCommitUpdateEventListener→ RabbitMQ Fanout으로 전 서버 동시 무효화하면서 해소했습니다. Display 컨텍스트의 매장별 메뉴 노출 정책 변경도 같은 구조로 무효화합니다 — 변경은 한 곳에서, 무효화 신호는 fanout으로.
주문 생성 트랜잭션 안에서
outbox_message에OrderPlacedEvent를 같이 INSERT하고, 별도 publisher가 polling/CDC로 Kafka에 발행하는 구조를 운영했습니다. 커머스에서는 Order/Inventory/Display/Payment 네 컨텍스트가 각자 자기 consumer로 자기 사실만 책임지는 구조가 자연스럽게 따라옵니다. 매장 거절 같은 보상 흐름도 Saga로 풀립니다.
qty_reserved 누수(예약 추적 테이블 vs 합계 불일치) — 일 단위 reconciliationdisplay_visibility_log 사유별 분포 — 운영자 내림 vs 품절 vs 시간대 비율commerce-order-state-consistency-fundamentals.md — 주문 상태머신과 정합성 기본기 허브ecommerce-order-payment-domain-modeling.md — Order/Payment/Coupon/Promotion 도메인 경계fnb-order-store-pickup-state-machine.md — F&B 픽업·배달 상태머신 운영coupon-promotion-concurrency-basics.md — 쿠폰/프로모션 동시성ddd-domain-modeling.md — Bounded Context와 Aggregate 일반 원칙distributed-transaction-outbox-pattern.md — Outbox 패턴 심화outbox-inbox-pattern.md — Inbox 측 멱등성 보장