F&B 디지털 채널(자사앱, 키오스크, 카카오톡 채널, 배달 플랫폼)에서 주문은 단순한 CRUD가 아니다. 주문이라는 한 건의 트랜잭션은 사용자 단말, 결제 PG, 매장 POS, 주방 디스플레이(KDS), 픽업/배달 운영, 재고/할인 시스템을 가로지르는 분산 워크플로다. 이 워크플로는 거의 항상 부분 실패(partial failure), 재시도(retry)...
F&B 디지털 채널(자사앱, 키오스크, 카카오톡 채널, 배달 플랫폼)에서 주문은 단순한 CRUD가 아니다. 주문이라는 한 건의 트랜잭션은 사용자 단말, 결제 PG, 매장 POS, 주방 디스플레이(KDS), 픽업/배달 운영, 재고/할인 시스템을 가로지르는 분산 워크플로다. 이 워크플로는 거의 항상 부분 실패(partial failure), 재시도(retry), 지연(latency spike)과 함께 살아간다. 모바일은 끊기고, POS는 점심 피크에 응답이 느려지고, 매장 직원은 실수로 주문을 두 번 접수한다.
이걸 if/else와 status 컬럼 한 개로 풀면 다음과 같은 사고가 누적된다.
이 문서는 4년차 이상 백엔드 엔지니어가 F&B 도메인 면접에서 "주문 상태 모델을 어떻게 설계하시겠습니까"라는 질문을 받았을 때, 상태머신 + 불변조건 + 멱등성 + Outbox + 운영 가시성을 한 호흡에 설명할 수 있는 수준을 목표로 한다. 모델은 매장 픽업(Store Pickup) 시나리오를 중심에 두되, 부분적으로 배달/포장 시나리오도 포함한다.
관련 인프라 개념이 더 필요하면 별도 문서로 분리한다. 본 문서는 상태 모델링과 그 운영에 집중한다. 분산 트랜잭션 일반론은 distributed-transaction.md 참조.
상태머신을 도입하는 이유는 단순하다. 어떤 상태에서 어떤 이벤트가 들어오면 어떤 상태로만 갈 수 있는지를 코드/DB/문서가 모두 똑같이 이해해야 하기 때문이다. 그렇지 않으면 다음과 같은 비대칭이 발생한다.
상태머신은 이 인식을 하나의 진실로 강제한다.
PENDING_PAYMENT, ACCEPTED, IN_PREPARATION, READY_FOR_PICKUP, COMPLETED, CANCELED, NO_SHOW, FAILED.PaymentApproved, StoreAccepted, KdsStartedCooking, PickupReady, CustomerPickedUp, CustomerNoShow, StoreRejected, Timeout.orders.status 컬럼 하나로 결정. KDS, POS, 모바일 앱은 모두 이 값을 참조해 자기 표현을 그릴 뿐 자기 상태를 따로 보유하지 않는다.| 상태 | 설명 |
|---|---|
DRAFT | 카트 단계, 사용자가 메뉴/옵션 선택 중 |
PENDING_PAYMENT | 결제 진행 중, PG로부터 결과 대기 |
PAYMENT_FAILED | 결제 실패 (단말 취소, 카드 거절, 한도 초과 등) |
PENDING_STORE_ACCEPT | 결제 성공, 매장 POS 접수 대기 |
ACCEPTED | 매장이 주문 접수, KDS로 분배되기 직전 |
IN_PREPARATION | 제조 시작 |
READY_FOR_PICKUP | 픽업대 비치 완료, 고객 알림 발송됨 |
COMPLETED | 고객이 수령 완료 |
CANCELED | 정상 취소(사용자 요청/매장 거절/시스템 이유) |
NO_SHOW | 일정 시간 동안 미수령으로 자동 종결 |
REFUND_IN_PROGRESS | 환불 진행 중(취소/노쇼 후속) |
REFUNDED | 환불 완료 |
FAILED | 회복 불가능한 시스템 실패(드물게 사용) |
다음은 매장 픽업 기준 핵심 전이만 추린 것이다. 정의되지 않은 조합은 모두 거부.
| 현재 상태 | 이벤트 | 다음 상태 | Guard / 비고 |
|---|---|---|---|
DRAFT | Checkout | PENDING_PAYMENT | 메뉴 가용/영업시간/최소주문 검증 |
PENDING_PAYMENT | PaymentApproved | PENDING_STORE_ACCEPT | PG 결제 승인 콜백 |
PENDING_PAYMENT | PaymentDeclined | PAYMENT_FAILED | |
PENDING_PAYMENT | Timeout(60s) | PAYMENT_FAILED | PG 응답 미수신 시 |
PENDING_STORE_ACCEPT | StoreAccepted | ACCEPTED | POS 접수 응답 |
PENDING_STORE_ACCEPT | StoreRejected | REFUND_IN_PROGRESS | 품절/마감/장애 등 |
PENDING_STORE_ACCEPT | Timeout(180s) | REFUND_IN_PROGRESS | 매장 응답 없음 |
ACCEPTED | KdsStartedCooking | IN_PREPARATION | |
IN_PREPARATION | PickupReady | READY_FOR_PICKUP | KDS 완료 신호 |
READY_FOR_PICKUP | CustomerPickedUp | COMPLETED | 매장에서 픽업 확인 |
READY_FOR_PICKUP | Timeout(20m) | NO_SHOW | 정책에 따라 가변 |
NO_SHOW | RefundDecided | REFUND_IN_PROGRESS | 정책 따라 부분환불 가능 |
REFUND_IN_PROGRESS | RefundCompleted | REFUNDED | |
ACCEPTED, IN_PREPARATION | UserCanceled | REFUND_IN_PROGRESS | 정책: 제조 시작 후엔 거부할 수도 있음 |
DRAFT ──Checkout──> PENDING_PAYMENT ──Approved──> PENDING_STORE_ACCEPT ──Accepted──> ACCEPTED
│ │
Declined/Timeout Rejected/Timeout
▼ ▼
PAYMENT_FAILED REFUND_IN_PROGRESS ──Completed──> REFUNDED
ACCEPTED ──KdsStarted──> IN_PREPARATION ──PickupReady──> READY_FOR_PICKUP
│
┌───────────────────┼────────────────┐
▼ ▼ ▼
PickedUp Timeout(20m) UserCanceled
│ │ │
COMPLETED NO_SHOW REFUND_IN_PROGRESS상태머신만 그린다고 끝이 아니다. 다음은 데이터 레벨에서 항상 참이어야 하는 불변조건이다. 면접에서 "이 모델의 무결성을 어떻게 지키냐"는 질문이 들어오면 이 목록을 답하면 된다.
status >= ACCEPTED 인 주문은 반드시 대응되는 결제 승인 레코드가 존재한다.refunds 엔티티를 통해 처리.가장 흔한 사고: 결제 화면에서 사용자가 "결제하기"를 두 번 탭한다. 또는 네트워크가 끊긴 상태에서 앱이 자동 재시도한다. 그 결과 같은 주문이 두 번 만들어지거나 같은 결제가 두 번 승인된다.
해결 방법은 두 축이다.
(a) 클라이언트 측 멱등키. 앱이 주문 시도마다 UUID(예: idempotency-key: 2c3a...e4b9)를 발급하고, 같은 키로 재시도. 서버는 idempotency_keys(key, request_hash, response_snapshot, created_at) 테이블을 둔다.
(b) 서버 측 dedupe. 같은 idempotency-key가 들어오면 새 주문을 만들지 않고 이전 응답을 그대로 돌려준다. 단 request_hash가 다르면 충돌로 처리해야 한다(키 재사용 사고 방지).
-- MySQL 8 예시
CREATE TABLE idempotency_keys (
key_value VARCHAR(64) NOT NULL PRIMARY KEY,
request_hash CHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL, -- IN_PROGRESS / DONE / FAILED
response_body JSON NULL,
created_at DATETIME(6) NOT NULL,
expires_at DATETIME(6) NOT NULL
);매장 직원이 "접수" 버튼을 누르는 동시에 자동 타임아웃 작업이 "거절"로 전이를 시도한다. 둘 중 하나만 이겨야 한다. 두 가지 패턴 중 선택.
낙관적 락(권장 기본): orders 테이블에 version 컬럼을 둔다.
UPDATE orders
SET status = 'ACCEPTED',
version = version + 1,
accepted_at = NOW(6)
WHERE order_id = ?
AND status = 'PENDING_STORE_ACCEPT'
AND version = ?;영향 행 수가 0이면 누군가 먼저 전이했다는 뜻이고, 이쪽은 멱등 응답을 만들거나 비즈니스 에러를 반환한다.
비관적 락(필요 시): 한 주문에 대해 매우 짧은 트랜잭션 안에서 SELECT ... FOR UPDATE로 잠그고 상태 전이 + Outbox 기록까지 마친다. 트랜잭션 길이를 짧게 유지하는 것이 핵심.
PENDING_STORE_ACCEPT로 가기 전에 매장 영업 여부, 메뉴 품절 여부, 옵션 가용성을 체크해야 한다. 이때 절대 매장 POS 호출을 동기적으로 트랜잭션 안에서 하지 않는다. 다음 패턴을 사용한다.
// BAD
@Transactional
public void acceptOrder(Long orderId) {
Order order = orderRepository.findById(orderId).orElseThrow();
if (!order.getStatus().equals("PENDING_STORE_ACCEPT")) {
throw new IllegalStateException("이미 처리됨");
}
posClient.send(order); // 외부 호출이 트랜잭션 안에 있음
kdsClient.print(order); // 두 번 보내질 위험
notificationClient.push(order.getUserId(), "접수 완료"); // 실패하면 롤백되며 사용자에게 일관성 깨짐
order.setStatus("ACCEPTED");
orderRepository.save(order);
}문제점:
PENDING_STORE_ACCEPT 체크를 통과해 KDS에 두 번 출력될 수 있음(read-then-write race).// GOOD
@Transactional
public AcceptResult acceptOrder(AcceptOrderCommand cmd) {
Order order = orderRepository.findById(cmd.orderId()).orElseThrow();
Transition t = stateMachine.resolve(order.getStatus(), Event.STORE_ACCEPTED);
// 정의되지 않은 전이는 여기서 즉시 거부
int updated = orderRepository.transitionWithVersion(
cmd.orderId(),
order.getStatus(), // expected
t.next(), // ACCEPTED
order.getVersion()
);
if (updated == 0) {
return AcceptResult.alreadyTransitioned(); // 멱등 응답
}
outboxRepository.append(
OutboxEvent.of("OrderAccepted", cmd.orderId(), payload(order))
);
return AcceptResult.ok();
}여기에서 외부 호출은 트랜잭션 밖이다. 별도의 OutboxPublisher가 outbox 행을 폴링/CDC로 읽어 KDS, 알림, 정산 시스템으로 이벤트를 비동기 발송한다.
상태 전이 + 외부 알림을 한 트랜잭션 안에 묶으려는 시도는 거의 항상 분산 트랜잭션 함정으로 끝난다. Outbox는 이걸 우회한다.
CREATE TABLE outbox_events (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
aggregate_id VARCHAR(64) NOT NULL,
type VARCHAR(64) NOT NULL,
payload JSON NOT NULL,
created_at DATETIME(6) NOT NULL,
published_at DATETIME(6) NULL,
attempts INT NOT NULL DEFAULT 0,
INDEX idx_unpublished (published_at, id)
);핵심 규칙:
orders UPDATE + outbox_events INSERT 까지만 수행.published_at IS NULL 행을 폴링하거나, Debezium 등의 CDC가 binlog에서 읽어 Kafka에 발행.event_id 기준 멱등 처리. 같은 이벤트가 두 번 와도 KDS에 두 번 출력되지 않게 컨슈머 측에서도 dedupe.상태머신 자체보다 운영자가 무엇을 보고 있는가가 사고 시 회복 시간을 결정한다. 다음 지표를 갖춘다.
PENDING_STORE_ACCEPT, IN_PREPARATION, READY_FOR_PICKUP.created_at과 published_at의 차이. 1분 이상 lag 시 알람.운영 화면에서는 단일 주문에 대한 state transition timeline을 무조건 노출한다. 사고 분석 시 가장 먼저 보는 화면이다.
MySQL 8 + Spring Boot 기준의 미니 실습 셋을 그려둔다. 면접 직전 점검용으로 충분하다.
CREATE TABLE orders (
order_id BIGINT NOT NULL PRIMARY KEY,
store_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
status VARCHAR(32) NOT NULL,
total_amount INT NOT NULL,
version INT NOT NULL DEFAULT 0,
created_at DATETIME(6) NOT NULL,
updated_at DATETIME(6) NOT NULL,
INDEX idx_store_status (store_id, status, updated_at)
);
CREATE TABLE order_state_logs (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
order_id BIGINT NOT NULL,
from_status VARCHAR(32),
to_status VARCHAR(32) NOT NULL,
event_type VARCHAR(32) NOT NULL,
actor VARCHAR(64),
occurred_at DATETIME(6) NOT NULL,
INDEX idx_order (order_id, occurred_at)
);order_state_logs는 audit log이자 상태머신 디버깅 도구다. 운영자가 "왜 이 상태로 갔는가"를 답할 수 있게 만든다.
public enum OrderStatus {
DRAFT, PENDING_PAYMENT, PAYMENT_FAILED,
PENDING_STORE_ACCEPT, ACCEPTED, IN_PREPARATION,
READY_FOR_PICKUP, COMPLETED, CANCELED, NO_SHOW,
REFUND_IN_PROGRESS, REFUNDED, FAILED
}
public enum OrderEvent {
CHECKOUT, PAYMENT_APPROVED, PAYMENT_DECLINED,
STORE_ACCEPTED, STORE_REJECTED, KDS_STARTED,
PICKUP_READY, CUSTOMER_PICKED_UP, CUSTOMER_NO_SHOW,
USER_CANCELED, REFUND_DECIDED, REFUND_COMPLETED, TIMEOUT
}
@Component
public class OrderStateMachine {
private final Map<Key, OrderStatus> transitions = Map.ofEntries(
Map.entry(Key.of(DRAFT, CHECKOUT), PENDING_PAYMENT),
Map.entry(Key.of(PENDING_PAYMENT, PAYMENT_APPROVED), PENDING_STORE_ACCEPT),
Map.entry(Key.of(PENDING_PAYMENT, PAYMENT_DECLINED), PAYMENT_FAILED),
Map.entry(Key.of(PENDING_STORE_ACCEPT, STORE_ACCEPTED), ACCEPTED),
Map.entry(Key.of(PENDING_STORE_ACCEPT, STORE_REJECTED), REFUND_IN_PROGRESS),
Map.entry(Key.of(ACCEPTED, KDS_STARTED), IN_PREPARATION),
Map.entry(Key.of(IN_PREPARATION, PICKUP_READY), READY_FOR_PICKUP),
Map.entry(Key.of(READY_FOR_PICKUP, CUSTOMER_PICKED_UP), COMPLETED),
Map.entry(Key.of(READY_FOR_PICKUP, CUSTOMER_NO_SHOW), NO_SHOW),
Map.entry(Key.of(NO_SHOW, REFUND_DECIDED), REFUND_IN_PROGRESS),
Map.entry(Key.of(REFUND_IN_PROGRESS, REFUND_COMPLETED), REFUNDED)
// 나머지 전이는 명시적으로 금지
);
public OrderStatus next(OrderStatus current, OrderEvent event) {
OrderStatus next = transitions.get(Key.of(current, event));
if (next == null) {
throw new IllegalStateTransitionException(current, event);
}
return next;
}
private record Key(OrderStatus s, OrderEvent e) {
static Key of(OrderStatus s, OrderEvent e) { return new Key(s, e); }
}
}STORE_ACCEPTED, 절반은 타임아웃을 발생시킨다.order_state_logs를 조회하여 모든 주문이 정의된 경로만 따랐는지 확인.outbox_events를 조회해 모든 OrderAccepted/OrderRejected 이벤트가 정확히 1번씩 발행되었는지 확인.이 실습을 통과하면 동시성, 중복, 멱등성이 한 번에 검증된다.
orders.status와 order_payments.status가 따로 살아 있고 둘이 어긋남. → 결제는 별도 엔티티지만 주문 상태의 진실은 orders에 있어야 한다.if (status == X) status = Y가 여러 서비스에 분산되어 있음. → 단일 StateMachine 컴포넌트로 모은다.면접관이 "F&B 매장 픽업 주문 시스템을 설계해 보세요"라고 하면 다음 흐름으로 답하면 좋다.
여기서 단골 follow-up 질문과 답변 포인트를 미리 정리해 둔다.
refunds aggregate. 주문 상태를 환불완료로 직접 바꾸지 않고 환불 라인을 추가, 주문 상태는 REFUND_IN_PROGRESS → REFUNDED로 천이.PENDING_STORE_ACCEPT에서 타임아웃 정책 발동. 일정 횟수 재시도 후 자동 거절 + 환불. 운영자에게 알림. 신규 주문은 매장 단위 서킷 브레이커로 차단.order_state_logs에서 transition 단위로 통계, 또는 outbox에서 분석 파이프라인으로 stream. orders 테이블을 OLAP 용도로 직접 긁지 않는다.order_state_logs로 모든 전이를 audit