fos-blog/study
01 / 홈02 / 카테고리03 / 시리즈
01 / 홈02 / 카테고리03 / 시리즈

카테고리

  • AI 페이지로 이동
    • RAG 페이지로 이동
    • langgraph 페이지로 이동
    • agents.md
    • BMAD Method — AI 에이전트로 애자일 개발하는 방법론
    • Claude Code의 Skill 시스템 - 개발자를 위한 AI 자동화의 새로운 차원
    • Claude Code를 5주 더 쓴 결과 — 스킬·CLAUDE.md를 키워가는 방식
    • Claude Code를 11일 동안 쓴 결과 — 데이터로 본 나의 사용 패턴
    • Claude Code 멀티 에이전트 — Teams
    • AI 에이전트와 디자인의 새 컨벤션 — DESIGN.md, Google Stitch, Claude Design
    • Docling — IBM Research 의 문서 파싱 toolkit 상세 정리
    • 하네스 엔지니어링 실전 — 4인 에이전트 팀으로 코딩 파이프라인 구축하기
    • 하네스 엔지니어링 — 오래 실행되는 AI 에이전트를 위한 설계
    • 멀티모달 LLM (Multimodal Large Language Model)
    • AI 에이전트와 함께 MVP 만들기 — dooray-cli 사례
  • ai 페이지로 이동
    • agent 페이지로 이동
  • algorithm 페이지로 이동
    • live-coding 페이지로 이동
    • 분산 계산을 위한 알고리즘
  • apartment 페이지로 이동
    • 구리 럭키아파트 24평 인테리어 레퍼런스 모음
  • architecture 페이지로 이동
    • [초안] 시니어 백엔드를 위한 API 설계 실전 스터디 팩 — REST · 멱등성 · 페이지네이션 · 버전 전략
    • [초안] API Versioning과 Backward Compatibility: 시니어 백엔드 관점 정리
    • 캐시 설계 전략 총정리
    • [초안] CJ푸드빌 디지털 채널 면접: 슬롯 도메인 경험을 커머스 도메인 설계 능력으로 번역하기
    • [초안] 커머스 Spring 서비스에 Clean/Hexagonal Architecture를 실용적으로 적용하기
    • [초안] 커머스 도메인 모델링: 주문·재고·노출의 세 축을 분리해서 설계하기
    • 커머스 주문 상태와 데이터 정합성 기본기 — CJ푸드빌 면접 대비
    • [초안] 쿠폰/프로모션 동시성과 정합성 기본기 — 선착순·중복 사용 방지·발급/사용/복구
    • [초안] DDD와 도메인 모델링: 시니어 백엔드 관점의 전술/전략 패턴 실전 가이드
    • [초안] Decorator & Chain of Responsibility — 행동을 체인으로 조립하는 두 가지 방식
    • 디자인 패턴
    • [초안] 분산 아키텍처 완전 정복: Java 백엔드 시니어 인터뷰 대비 실전 가이드
    • [초안] 분산 트랜잭션과 Outbox 패턴 — 왜 2PC를 피하고 어떻게 대신할 것인가
    • 분산 트랜잭션
    • [초안] e-Commerce 주문·결제 도메인 모델링: 상태머신, 멱등성, Outbox/Saga 실전 정리
    • [초안] F&B 쿠폰·프로모션·멤버십·포인트 설계
    • [초안] F&B · e-Commerce 디지털 채널 도메인 한 장 정리 — CJ푸드빌 디지털 채널 백엔드 면접 대비
    • [초안] F&B 주문/매장/픽업 상태머신 설계 — CJ푸드빌 디지털 채널 백엔드 관점
    • [초안] F&B 이커머스 결제·환불·정산 운영 가이드
    • [초안] Hexagonal / Clean Architecture를 Spring 백엔드에 적용하기
    • [초안] 대규모 커머스 트래픽 처리 패턴 — 1,600만 고객과 올영세일을 버티는 설계
    • [초안] 레거시 JSP/jQuery 화면과 신규 API가 공존하는 백엔드 운영 전략
    • [초안] MSA 서비스 간 통신: Redis [Cache-Aside](../database/redis/cache-aside.md) × Kafka 이벤트 하이브리드 설계
    • [초안] Observability 입문: 시니어 백엔드가 장애를 탐지하고 대응하는 방식
    • [초안] Outbox / Inbox Pattern 심화 — 분산 메시징의 정합성 문제를 DB 트랜잭션으로 풀어내기
    • [초안] 결제 도메인 멱등성과 트랜잭션 재시도 기본기
    • [초안] 시니어 백엔드를 위한 Resilience 패턴 실전 가이드 — Timeout, Retry, Circuit Breaker, Bulkhead, Backpressure
    • [초안] REST API 버저닝과 모바일 앱 하위 호환성 — CJ푸드빌 디지털 채널 백엔드 관점
    • [초안] Spring Batch vs Event-Driven — 같은 비동기처럼 보이지만 전혀 다른 두 패러다임
    • [초안] Strategy Pattern — 분기문을 없애는 설계, 시니어 백엔드 인터뷰 핵심 패턴
    • [초안] 시니어 백엔드를 위한 시스템 설계 입문 스터디 팩
    • [초안] 템플릿 메서드 패턴 - 백엔드 처리 골격을 강제하는 가장 오래되고 가장 위험한 패턴
    • [초안] 대규모 트래픽 중 무중단 마이그레이션 — Feature Flag + Shadow Mode 실전
  • database 페이지로 이동
    • mysql 페이지로 이동
    • opensearch 페이지로 이동
    • redis 페이지로 이동
    • 김영한의-실전-데이터베이스-설계 페이지로 이동
    • [초안] DB Connection Pool Saturation과 Thread Pool 격리
    • 커넥션 풀 크기는 얼마나 조정해야 할까?
    • 인덱스 - DB 성능 최적화의 핵심
    • [초안] JPA N+1과 커머스 조회 모델: 주문/메뉴/쿠폰 도메인에서 살아남기
    • [초안] MyBatis 기본기 — XML Mapper, resultMap, 동적 SQL, 운영 패턴 정리
    • [초안] MyBatis와 JPA/Hibernate 트레이드오프 — 레거시 백엔드를 다루는 시니어 관점
    • 역정규화 (Denormalization)
    • 데이터 베이스 정규화
  • devops 페이지로 이동
    • docker 페이지로 이동
    • k8s 페이지로 이동
    • k8s-in-action 페이지로 이동
    • observability 페이지로 이동
    • [초안] 커머스/F&B 채널 장애 첫 5분과 관측성 기본기
    • Envoy Proxy
    • [초안] F&B / e-Commerce 운영 장애 대응과 모니터링 — 백엔드 관점 정리
    • Graceful Shutdown
    • [초안] 시니어 백엔드를 위한 SLO와 Error Budget 기반 장애 대응
  • finance 페이지로 이동
    • industry-cycle 페이지로 이동
    • investing 페이지로 이동
  • http 페이지로 이동
    • HTTP Connection Pool
  • interview 페이지로 이동
    • [초안] AI 서비스 팀 경험 기반 시니어 백엔드 면접 질문 뱅크 — Spring Batch RAG / gRPC graceful shutdown / 전략 패턴 / 12일 AI 웹툰 MVP
    • [초안] 커머스/F&B 면접 답변집 — 슬롯 도메인 경험을 주문·결제·쿠폰·매장 설계로 매핑하기
    • [초안] F&B / e-Commerce 운영 모니터링과 장애 대응 인터뷰 정리
    • Observability — 면접 답변 프레임
    • [초안] 시니어 Java 백엔드 면접 마스터 플레이북 — 김병태
    • [초안] NSC 슬롯팀 경험 기반 질문 은행 — 도메인 모델링·동시성·성능·AI 협업
  • java 페이지로 이동
    • concurrency 페이지로 이동
    • jdbc 페이지로 이동
    • opentelemetry 페이지로 이동
    • spring 페이지로 이동
    • spring-batch 페이지로 이동
    • 더_자바_코드를_조작하는_다양한_방법 페이지로 이동
    • [초안] Java 동시성 락 정리 — 커머스 메뉴/프로모션 정책 캐시 갱신 관점
    • [초안] JVM 튜닝 실전: 메모리 구조부터 Virtual Threads, GC 튜닝, 프로파일링까지
    • Java의 로깅 환경
    • MDC (Mapped Diagnostic Context)
    • Java StampedLock — 읽기 폭주에도 쓰기가 밀리지 않는 락
    • Virtual Thread와 Project Loom
  • javascript 페이지로 이동
    • typescript 페이지로 이동
    • AbortController
    • Async Iterator와 제너레이터
    • CommonJS와 ECMAScript Modules
    • 제너레이터(Generator)
    • Http Client
    • Node 백엔드 운영 패턴 — Streams 백프레셔, pipe/pipeline, 멱등성 vs 분산 락
    • Node.js
    • npm vs pnpm — 어떤 기준으로 선택했나
    • `setImmediate()`
  • kafka 페이지로 이동
    • [초안] Kafka 기본 개념 — 토픽, 파티션, 오프셋, 복제
    • Kafka를 사용하여 **데이터 정합성**은 어떻게 유지해야 할까?
    • [초안] Kafka 실전 설계: 파티션 전략, 컨슈머 그룹, 전달 보장, 재시도, 순서 보장 트레이드오프
    • 메시지 전송 신뢰성
  • linux 페이지로 이동
    • fsync — 리눅스 파일 동기화 시스템 콜
    • tmux — Terminal Multiplexer
  • network 페이지로 이동
    • L2(스위치)와 L3(라우터)의 역할 차이
    • L4와 VIP(Virtual IP Address)
    • IP Subnet
  • python 페이지로 이동
    • Python async/await — CompletableFuture·Reactor 와 다른 점, 그리고 blocking I/O 함정
    • Python 의존성 관리 — Java Maven/Gradle 사용자가 만나는 첫 충격
    • FastAPI 기초 — Spring Boot 사용자가 빠르게 익히는 법
    • GPU·CUDA·MPS 기초 — 자바 백엔드 개발자가 처음 만나는 그림
    • Multi-process GPU 워크로드 — 자바 ThreadPool 사용자가 만나는 모델 차이
    • Java 개발자를 위한 Python 심화 — OOP·데코레이터·컨텍스트 매니저
    • PyTorch 기초 — 텐서, 디바이스, 그리고 모델 로딩이 무거운 이유
    • Java 개발자를 위한 Python 문법 핵심
    • ML 서비스 성능 분석 워크플로 — 자바 백엔드 트러블슈팅과 다른 점
    • OCR 동작 원리 — Layout · Text · Post-process 3단계
    • Python 서버의 RSS 가 안 줄어드는 이유 — gc.collect 의 한계와 malloc_trim
  • rabbitmq 페이지로 이동
    • [초안] RabbitMQ Basics — 실전 백엔드 관점에서 정리하는 메시지 브로커 기본기
    • [초안] RabbitMQ vs Kafka — 백엔드 메시징 선택 기준과 실전 운영 관점
  • security 페이지로 이동
    • [초안] 시니어 백엔드를 위한 보안 / 인증 스터디 팩 — Spring Security, JWT, OAuth2, OWASP Top 10
    • [초안] Spring Security 6.x OAuth2 + JWT 상용 인증 설계 — Grant 선택, Resource Server, Refresh Rotation, 로그아웃
  • task 페이지로 이동
    • ai-service-team 페이지로 이동
    • nsc-slot 페이지로 이동
    • sb-dev-team 페이지로 이동
    • the-future-company 페이지로 이동
  • testing 페이지로 이동
    • [초안] 시니어 Java 백엔드를 위한 테스트 전략 완전 정리 — 피라미드부터 TestContainers, 마이크로벤치, Contract까지
  • travel 페이지로 이동
    • 오사카 3박 4일 일정표: 우메다 쇼핑, USJ, 난바·도톤보리, 오사카성
  • web 페이지로 이동
    • [초안] HTTP / Cookie / Session / Token 인증 기본기 — 레거시 JSP와 모바일 API가 공존하는 백엔드 관점
FOS-BLOG · FOOTERall systems normal·v0.1 · 2026.04.27·seoul, kr
Ffos-blog/study

개발 학습 기록을 정리하는 블로그입니다. 공부하면서 기록하고, 기록하면서 다시 배웁니다.

visitors
01site
  • Home↗
  • Posts↗
  • Categories↗
  • About↗
02policy
  • 소개/about
  • 개인정보처리방침/privacy
  • 연락처/contact
03categories
  • AI↗
  • Algorithm↗
  • DB↗
  • DevOps↗
  • Java/Spring↗
  • JS/TS↗
  • React↗
  • Next.js↗
  • System↗
04connect
  • GitHub@jon890↗
  • Source repositoryjon890/fos-study↗
  • RSS feed/rss.xml↗
  • Newsletter매주 1 회 · 한 편의 글→
© 2026 FOS Study. All posts MIT-licensed.
built with·Next.js·Tailwind v4·Geist·Pretendard·oklch
fos-blog/architecture/[초안] 커머스 도메인 모델링: 주문·재고·…
system

[초안] 커머스 도메인 모델링: 주문·재고·노출의 세 축을 분리해서 설계하기

커머스/F&B 백엔드에서 도메인 모델링 질문이 들어오면 대부분 답변이 주문과 결제 축에 쏠린다. 그러나 실제 운영에서 가장 자주 사고가 나는 곳은 그 옆에 붙은 두 축, 재고(Inventory)와 노출(Display/Catalog)이다. - "결제는 됐는데 매장에 재료가 없다고 거절당했다." → Inventory 축의 race condition. - "장바...

2026.05.16·14 min read·28 views

왜 이 주제가 중요한가

커머스/F&B 백엔드에서 도메인 모델링 질문이 들어오면 대부분 답변이 주문과 결제 축에 쏠린다. 그러나 실제 운영에서 가장 자주 사고가 나는 곳은 그 옆에 붙은 두 축, 재고(Inventory)와 노출(Display/Catalog)이다.

  • "결제는 됐는데 매장에 재료가 없다고 거절당했다." → Inventory 축의 race condition.
  • "장바구니에 담았는데 결제 직전 품절로 막혔다." → 재고 차감 시점 설계 실패.
  • "행사 시작 시각이 됐는데 상품이 안 보인다." → Display 축의 노출 정책과 캐시 갱신 실패.
  • "어드민에서 메뉴를 내렸는데 일부 매장에서 5분간 더 보였다." → Display 캐시 정합성.
  • "재고는 0인데 상품이 검색에 떠 있다." → 재고와 노출의 데이터 출처 분리 실패.

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: 변하지 않아야 할 진실의 원천

Catalog는 상품의 불변에 가까운 본질만 담는다.

sql
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: 노출은 재고와 다르다

Display 컨텍스트는 "이 매장에서, 지금 시각에, 이 상품을 어떤 모습으로 보여줄 것인가"를 다룬다. 운영자가 가장 자주 만지는 영역이지만 모델링이 가장 자주 망가지는 영역이기도 하다.

핵심 분리 원칙: "안 보임"의 이유가 무엇인지 코드가 답할 수 있어야 한다.

다음 다섯 가지 "안 보임" 사유는 절대 같은 컬럼으로 표현하면 안 된다.

  1. 운영자가 내렸다 (is_visible=false) — 의도된 비노출
  2. 시간대 메뉴가 아니다 (hour_window에 포함 안 됨) — 자동 노출 제어
  3. 품절이다 (Inventory.qty_available <= 0) — 재고 컨텍스트 사실
  4. 노출 정책이 없다 — 매장별 정책 미설정
  5. Catalog가 비활성이다 (catalog_item.is_active=false) — 상품 전체 단종

이걸 한 컬럼(예: display_status)에 우겨넣으면 운영 알림이 "이거 왜 안 보여요?"로 가득 찬다. 사유가 분리돼야 어드민이 "운영자가 내림"으로 표시할지 "품절"로 표시할지를 결정할 수 있다.

Display 모델 예시

sql
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가 사유 분리의 핵심이다. 같은 "안 보임"이라도 사유가 운영 알림 단계에서 갈린다.

Display 조회는 read-optimized

PLP(상품 목록) 응답은 매장당 수십~수백 상품을 한 번에 본다. Catalog + Display + Inventory를 JOIN해서 매번 계산하면 매장 트래픽이 몰릴 때 DB가 죽는다. 그래서 읽기 전용 read model을 별도로 둔다.

java
public record DisplayItem(
    long itemId,
    String nameKo,
    int price,
    int sortPriority,
    boolean soldOut,
    String hiddenReason   // null이면 노출
) {}

이 read model을 채우는 방식은 세 가지가 있고 트래픽 크기와 정합성 요구에 따라 선택한다.

  • 즉시 계산: 매 요청마다 Catalog/Display/Inventory join. 정합성 최고, 성능 최악. 소규모 매장 운영.
  • 매장 단위 캐시 + 무효화: Redis에 display:store:{storeId} 전체 목록 캐시. 도메인 이벤트로 무효화. F&B/커머스 대부분이 여기.
  • CDC 기반 비동기 read model: Catalog/Display/Inventory 변경을 CDC로 받아 별도 검색 인덱스(OpenSearch)에 투영. 검색·랭킹 요구가 강할 때.

후보자 경험과 연결하면, RabbitMQ Fanout으로 다중 서버 인메모리 캐시를 무효화한 사례가 이 패턴의 변형이다. "정적 설정 데이터 갱신 시 전 서버 동시 무효화 + StampedLock으로 갱신 구간 보호"를 매장 메뉴 캐시로 옮기면 동일한 구조가 된다.

Inventory: 트랜잭션이 가장 짧아야 하는 곳

재고는 모든 컨텍스트 중 가장 짧은 트랜잭션을 요구한다. 재고 차감이 5초 걸리면 동시 결제가 줄을 서고, 결제 PG에 영향이 간다.

매장 단위 재고가 자연 키다

F&B/매장 픽업 도메인에서 재고는 거의 항상 매장별이다. 중앙 창고 모델(전자상거래)과 다르다.

sql
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가 사용자에게 노출되는 잔여수량이다.

Reserve-then-Confirm 패턴

결제 직전에 재고를 잠시 예약하고, 결제 승인 후 확정한다. 결제 실패/취소 시 예약을 푼다.

  1. 주문 생성 시: qty_reserved += qty, 단 qty_on_hand - qty_reserved >= qty 조건이 동시에 성립해야 함
  2. 결제 승인 시: qty_on_hand -= qty, qty_reserved -= qty
  3. 결제 실패/취소 시: qty_reserved -= qty
  4. 예약 후 일정 시간(예: 5분) 경과 시 자동 해제(janitor)

핵심은 1번 단계의 조건부 UPDATE다. SELECT 후 UPDATE를 분리하면 동시 차감이 음수 재고를 만든다.

sql
-- 예약: 재고 충분할 때만 성공
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 호출 시간까지 락이 끼어 운영 사고가 난다.

확정 단계

sql
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 테이블과 함께 쓰면 더 안전).

예약 만료 janitor

sql
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만 운영하면 "누가 점유 중인지" 알 수 없어 운영 장애가 난다.

세 축이 만나는 결정점: 주문 생성

주문 생성 트랜잭션은 세 컨텍스트와 어떻게 상호작용해야 하는가. 다음 흐름이 기본이다.

java
@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);
}

이 흐름이 명시적으로 분리하는 것:

  • Display 조회는 읽기(query model)이고 변경하지 않는다.
  • Inventory는 조건부 UPDATE 한 줄로 예약. 락이나 분산 락이 끼지 않는다.
  • Order는 스냅샷을 동결한다. 이후 Display 가격이 바뀌어도 주문 금액은 변하지 않는다.
  • 이벤트는 outbox로 같이 커밋된다. 트랜잭션 밖에서 Kafka 호출하지 않는다.

결제 승인 ↔ 재고 확정 ↔ 노출 갱신

결제 승인 이후의 흐름은 비동기로 풀린다.

  1. PG 승인 응답 → Order.markPaid + outbox에 OrderPaymentApprovedEvent
  2. Inventory consumer가 받아 confirm() 호출 (조건부 UPDATE로 멱등)
  3. Display read model이 받아 qty_available 캐시 갱신
  4. 매장 알림 consumer가 받아 POS에 전달
  5. 매장 거절 시 Saga로 보상: Inventory.cancelConfirm() → Payment.cancel() → Order.markCanceledByStore()

핵심은 각 consumer가 자기 컨텍스트의 사실만 책임진다는 것. Inventory consumer는 Display 캐시를 직접 무효화하지 않고, Display consumer가 별도로 OrderPaymentApprovedEvent를 구독해 자기 캐시를 갱신한다. 의존 방향을 컨텍스트별로 분리해야 한 컨텍스트의 장애가 다른 컨텍스트를 막지 않는다.

Bad vs Improved

나쁜 패턴: 한 트랜잭션에 다 우겨넣기

java
@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가지:

  1. Display/Inventory/Catalog가 한 객체(Product)에 섞임 — Aggregate 경계 붕괴
  2. 재고 SELECT 후 UPDATE — 동시성 깨짐 → 음수 재고
  3. 가격을 Catalog에서 직접 참조 — 정책 변경 시 과거 주문 금액 흔들림
  4. 가격 스냅샷 미보관
  5. Kafka 발행이 트랜잭션 밖 — 커밋 후 실패 시 메시지 유실
  6. 외부 POS 호출이 트랜잭션 안 — 락 시간 폭증, 외부 장애가 DB로 전파

개선 패턴

java
@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, 예약 추적까지 보장.

로컬 실습 환경

yaml
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"]

세 컨텍스트 스키마(요약):

sql
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)
);

실습 시나리오

  1. 음수 재고 재현: 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로 한 쪽만 성공함을 확인.
  2. 예약 만료 janitor: 예약을 만들고 5분이 지나면 자동 해제되는 잡을 cron으로 돌려보고, 그 사이에 결제 승인이 도착하면 어떻게 처리할지 시나리오를 그린다(이미 해제된 예약을 다시 살릴지, 새 예약을 시도할지).
  3. 노출 사유 분리: display_policy.is_visible=false로 두고 PLP 응답이 "운영자가 내림"으로 표시되는지, qty_on_hand=0인 경우 "품절"로 표시되는지 별 사유 코드를 검증.
  4. 시간대 메뉴 전환: hour_window=[[11,15]]로 설정하고 11시 정각에 노출이 켜지는지, 15시 정각에 꺼지는지 검증. 캐시 무효화가 정시 ±1초 안에 일어나는지 확인.
  5. 결제 승인 후 재고 확정 멱등성: 같은 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을 채워줍니다.

"Display 캐시 정합성은 어떻게 잡으시겠어요?"

운영자 변경, 시간대 전환, 재고 변경 세 가지가 트리거입니다. 도메인 이벤트를 outbox로 발행하고, Display consumer가 매장 단위로 캐시를 무효화합니다. 캐시 키는 매장 ID 단위가 자연스럽고, 상품 단건이 아니라 매장 전체 목록을 통째로 다시 만드는 편이 PLP 응답 시간 면에서 안정적입니다. 시간대 전환은 cron 기반 정시 발행 + TTL 안전망을 같이 둡니다. 이전 업무에서 정적 설정 데이터를 다중 서버 인메모리 캐시로 운영할 때 RabbitMQ Fanout으로 전 서버 무효화하고 StampedLock writeLock으로 갱신 구간을 보호한 경험이 있어서, 매장 메뉴 캐시도 같은 구조로 풀 수 있습니다.

"장바구니에 담아둔 동안 가격이 바뀌면 어떻게 처리하나요?"

장바구니는 Display 가격을 그때그때 다시 조회해서 보여줍니다. 사용자가 결제 버튼을 누르는 순간 Display 스냅샷을 Order Aggregate에 동결하고, 그 이후의 정책 변경은 이미 생성된 주문에 영향이 없습니다. 만약 장바구니 진입 시 가격과 결제 시점 가격이 다르면 사용자에게 변경 사실을 한 번 확인받는 UX가 안전합니다. 핵심은 'Order는 과거의 사실을 불변으로 보존한다'는 원칙입니다.

"재고가 0이면 PLP에 안 보이게 하시나요, 품절 표시하시나요?"

운영 정책에 따라 다르지만, 기본은 품절 표시 + 노출 유지가 더 좋습니다. 안 보이면 사용자가 "내가 잘못 봤나" 혼동하고, 매장에서는 "그 메뉴 있는 줄 알고 왔는데" 컴플레인이 옵니다. Display read model에 soldOut=true 플래그로 노출하고, 정렬 우선순위만 뒤로 미루는 게 일반적입니다. 단, 시즌 메뉴처럼 "끝났음"을 명확히 알려야 하는 경우는 운영자가 명시적으로 내리도록 합니다.

후보자 경험을 세 축으로 번역하기

StampedLock 기반 정적 데이터 캐시 경험

정적 설정 데이터를 다중 서버 인메모리 캐시로 운영할 때 갱신 빈도는 낮고 조회가 압도적이라 StampedLock + optimistic read로 reader가 락 없이 흐르게 만들고, writer 진입 시점에만 tryWriteLock 타임아웃을 박았습니다. 커머스로 옮기면 매장 메뉴 노출 캐시가 정확히 같은 패턴입니다 — 운영자 변경 빈도는 낮고 PLP 조회는 매장 트래픽 그대로 받는 영역.

RabbitMQ Fanout 캐시 정합성 경험

어드민 변경 시 다중 서버 정합성이 깨져 일시적 NPE가 났던 사고를 Hibernate PostCommitUpdateEventListener → RabbitMQ Fanout으로 전 서버 동시 무효화하면서 해소했습니다. Display 컨텍스트의 매장별 메뉴 노출 정책 변경도 같은 구조로 무효화합니다 — 변경은 한 곳에서, 무효화 신호는 fanout으로.

Kafka Transactional Outbox 경험

주문 생성 트랜잭션 안에서 outbox_message에 OrderPlacedEvent를 같이 INSERT하고, 별도 publisher가 polling/CDC로 Kafka에 발행하는 구조를 운영했습니다. 커머스에서는 Order/Inventory/Display/Payment 네 컨텍스트가 각자 자기 consumer로 자기 사실만 책임지는 구조가 자연스럽게 따라옵니다. 매장 거절 같은 보상 흐름도 Saga로 풀립니다.

운영 모니터링 체크리스트

  • 재고 예약 만료 누적 건수(janitor 처리 lag) — 1분 이상 적체 시 알람
  • qty_reserved 누수(예약 추적 테이블 vs 합계 불일치) — 일 단위 reconciliation
  • Display 캐시 무효화 lag — outbox publisher와 consumer 사이 지연
  • 품절 자동 표시 정확도 — Inventory 사실과 Display 응답 일치율
  • display_visibility_log 사유별 분포 — 운영자 내림 vs 품절 vs 시간대 비율
  • 음수 재고 발생 카운트 — 0이어야 함, 1건이라도 발생 시 즉시 알림

학습 체크리스트

  • Catalog/Display/Inventory/Order 네 컨텍스트의 책임과 변경 빈도를 표로 그릴 수 있다
  • "안 보임" 사유 다섯 가지를 사유 코드로 분리해 설계할 수 있다
  • 재고 예약을 조건부 UPDATE 한 줄로 동시성 안전하게 만들 수 있다
  • Reserve-then-Confirm 패턴의 만료 janitor와 멱등 확정을 설명할 수 있다
  • Display 가격 스냅샷을 Order Aggregate에 동결하는 이유를 답변할 수 있다
  • PLP 응답을 위한 read model 패턴 세 가지(즉시 계산/매장 캐시/CDC 인덱스)를 트레이드오프와 함께 비교할 수 있다
  • 결제 승인 후 Inventory 확정과 Display 캐시 갱신이 분리된 consumer 책임임을 설명할 수 있다
  • 매장 거절 시 Inventory → Payment → Order 순의 Saga 보상을 멱등하게 설계할 수 있다
  • StampedLock·RabbitMQ Fanout·Kafka Outbox 경험을 세 컨텍스트 언어로 30초 내 번역할 수 있다
  • 면접에서 "재고 동시성 어떻게 막나"에 분산 락 없이 조건부 UPDATE로 답할 수 있다

관련 문서

  • 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 측 멱등성 보장
on this page
  • 01왜 이 주제가 중요한가
  • 02핵심 통찰: 같은 "상품"이 컨텍스트마다 다른 모델이다
  • 03Catalog: 변하지 않아야 할 진실의 원천
  • 04Display: 노출은 재고와 다르다
  • Display 모델 예시
  • Display 조회는 read-optimized
  • 05Inventory: 트랜잭션이 가장 짧아야 하는 곳
  • 매장 단위 재고가 자연 키다
  • Reserve-then-Confirm 패턴
  • 확정 단계
  • 예약 만료 janitor
  • 06세 축이 만나는 결정점: 주문 생성
  • 07결제 승인 ↔ 재고 확정 ↔ 노출 갱신
  • 08Bad vs Improved
  • 나쁜 패턴: 한 트랜잭션에 다 우겨넣기
  • 개선 패턴
  • 09로컬 실습 환경
  • 10실습 시나리오
  • 11면접 답변 프레이밍
  • "주문 시스템에서 재고는 어떻게 다루시겠어요?"
  • "노출 정책과 재고를 한 테이블에 넣으면 안 되나요?"
  • "Display 캐시 정합성은 어떻게 잡으시겠어요?"
  • "장바구니에 담아둔 동안 가격이 바뀌면 어떻게 처리하나요?"
  • "재고가 0이면 PLP에 안 보이게 하시나요, 품절 표시하시나요?"
  • 12후보자 경험을 세 축으로 번역하기
  • StampedLock 기반 정적 데이터 캐시 경험
  • RabbitMQ Fanout 캐시 정합성 경험
  • Kafka Transactional Outbox 경험
  • 13운영 모니터링 체크리스트
  • 14학습 체크리스트
  • 15관련 문서

이런 글도

  • [초안] Spring Batch vs Event-Driven — 같은 비동기처럼 보이지만 전혀 다른 두 패러다임
    > 관련 문서: Outbox / Inbox Pattern 심화, 분산 트랜잭션과 Outbox 패턴. 본 문서는 두 처리 패러다임의 선택 기준과 trade-off에 집중하고, 위 두 문서는 이벤트 발행의 정합성 메커니즘에 집중한다. 백엔드를 4\5년차 이상 다루다 보면 어느 시점에서 "이건 동기로 처리하기 어렵다"는 결론에 도달한다. 사용자가 결제 버튼을 누...
    🏗️ system
    system
    2026.05.16
  • [초안] REST API 버저닝과 모바일 앱 하위 호환성 — CJ푸드빌 디지털 채널 백엔드 관점
    CJ푸드빌처럼 매장·키오스크·모바일 앱·웹·파트너사 연동을 동시에 운영하는 도메인에서 REST API는 여러 세대의 클라이언트가 동시에 살아 있는 상태를 전제로 한다. 백엔드는 일주일에 두세 번 배포할 수 있지만, iOS/Android 앱은 그렇지 않다. 앱스토어 심사, 사용자 강제 업데이트 동의, 구버전 OS 잔존, 사내 매장 단말의 펌웨어 라이프사이클까...
    🏗️ system
    system
    2026.05.09
  • [초안] 커머스 Spring 서비스에 Clean/Hexagonal Architecture를 실용적으로 적용하기
    CJ푸드빌 같은 외식·커머스 도메인은 표면적으로는 "메뉴 CRUD에 결제 붙이기"처럼 보이지만, 실제로는 매장 운영, 재고, 프로모션, 결제, 주문 상태 머신이 얽힌다. 이때 모든 로직을 @Service 한 클래스에 몰면 처음 6개월은 빠르지만, 1년 차부터는 결제 PG 교체, 재고 정책 변경, 주문 상태 추가가 수십 개의 if-else 가지를 수정해야 끝...
    🏗️ system
    system
    2026.05.09
  • [초안] 쿠폰/프로모션 동시성과 정합성 기본기 — 선착순·중복 사용 방지·발급/사용/복구
    쿠폰과 프로모션은 F&B 커머스에서 매출을 만드는 동시에 정합성 이슈가 가장 자주 터지는 영역이다. "1만 개 한정 50% 할인 쿠폰"이라는 한 줄 기획은 백엔드 입장에서 보면 동시성, 멱등성, 락 전략, 캐시 일관성, 보상 트랜잭션이 한꺼번에 등장하는 종합 문제다. 매장 오픈 이벤트, 앱 푸시 후 1분, 신메뉴 런칭 같은 짧고 강한 트래픽 스파이크에서 한...
    🏗️ system
    system
    2026.05.08

댓글 (0)