CJ푸드빌처럼 빕스/뚜레쥬르/제일제면소 같은 다브랜드 F&B 운영사의 디지털 채널 백엔드는 단순히 "주문 API"를 만드는 일이 아니다. 같은 회원이 어제는 매장에서 결제하고 오늘은 앱에서 픽업을 잡고 내일은 외부 배달사로 같은 메뉴를 시킨다. 가격은 매장별·시간대별로 달라지고, 쿠폰은 브랜드 단위와 멤버십 등급에 따라 중첩되며, 정산은 결제대행사·배달사·...
CJ푸드빌처럼 빕스/뚜레쥬르/제일제면소 같은 다브랜드 F&B 운영사의 디지털 채널 백엔드는 단순히 "주문 API"를 만드는 일이 아니다. 같은 회원이 어제는 매장에서 결제하고 오늘은 앱에서 픽업을 잡고 내일은 외부 배달사로 같은 메뉴를 시킨다. 가격은 매장별·시간대별로 달라지고, 쿠폰은 브랜드 단위와 멤버십 등급에 따라 중첩되며, 정산은 결제대행사·배달사·매장 가맹주에게 동시에 분배된다. 이 도메인을 면접에서 "주문 들어오면 결제하고 주방에 알려주는 시스템이요" 수준으로만 설명하면 4년차 이상 시니어가 받아야 할 도메인 깊이 점수를 받기 어렵다.
이 문서는 F&B 디지털 채널의 전체 그림을 bounded context 단위로 끊어서 정리한다. 각 컨텍스트에서 어떤 엔티티가 핵심이고, 데이터가 어떤 순서로 흐르며, 운영 중에 자주 깨지는 정합성·장애 패턴이 무엇인지까지 짚는다. 마지막으로 캐시 정합성·Kafka Outbox·운영 제약 대응 같은 후보자 본인의 실제 경험을 F&B 도메인 언어로 어떻게 번역해서 답할지 답변 예시까지 둔다.
F&B e-Commerce의 가치사슬은 다섯 단계로 정리할 수 있다.
겉에서 보면 "주문 → 결제 → 배달"이지만 운영 시스템 관점에서는 위 다섯 단계가 모두 별도 트랜잭션 경계, 별도 SLA, 별도 외부 연동 책임을 갖는다. 이게 그대로 bounded context의 후보가 된다.
DDD 관점에서 F&B 디지털 채널은 보통 다음 컨텍스트로 쪼개진다. 모놀리스로 시작해도 모듈 경계는 이 라인을 따르는 것이 안전하다.
면접에서 "어디서부터 어디까지 한 서비스가 책임지나요?"라는 질문이 들어오면 이 11개 컨텍스트 중 묶음을 어떻게 잡았는지로 답하면 된다. 예를 들어 모놀리스라면 "주문/결제/이행을 한 서비스에 두되 모듈로 쪼개고, Catalog와 Member는 사내 다른 채널과 공유 가능하도록 분리했습니다" 같은 식이다.
Member (id, ci/di, status, tier, joinedAt)Auth (member_id, provider, externalId)Consent (member_id, type, agreedAt, version)Address (member_id, label, addr1, addr2, lat/lng)Device (member_id, pushToken, platform)CI/DI 같은 본인확인값은 PII 등급 최상위라 컬럼 자체를 KMS 암호화 + 별도 테이블로 분리하는 게 일반적이다. 등급 변경은 이벤트로 발행해서 가격/쿠폰 컨텍스트가 구독한다.
Brand (id, code, name)Store (id, brand_id, code, type[direct/franchise], lat/lng, openHours)Menu (id, brand_id, name, baseImage, taxType, allergens[])MenuOptionGroup (id, menu_id, type[single/multi], min/max)MenuOption (id, group_id, name, extraPrice)StoreMenu (store_id, menu_id, isAvailable, soldOutUntil)Menu와 StoreMenu를 분리하는 게 핵심이다. 본사가 정의한 "메뉴 마스터"와 매장에서 실제 팔 수 있는지 여부는 별개이고, "오늘 떡갈비 떨어졌어요" 같은 운영 이벤트는 StoreMenu만 건드린다.
Price (menu_id, store_id?, channel?, validFrom, validTo, amount)Coupon (id, code, type[fixed/percent], min/max, target[brand/menu/store], stackable)IssuedCoupon (id, coupon_id, member_id, status, issuedAt, usedAt)Promotion (id, period, target, ruleJson)LoyaltyPoint (member_id, balance) + LoyaltyTxn가격 결정은 "가장 좁은 범위가 이긴다" 규칙(매장-채널-시간 구간이 우선, 없으면 브랜드 정상가)으로 풀어야 면접에서 설명이 깔끔하다.
Cart (id, member_id or guestKey, store_id, channel, expiresAt)CartItem (cart_id, menu_id, qty, optionsJson, unitPriceSnapshot)Order (id, orderNo, member_id, store_id, channel, status, totalAmount, payAmount, discountAmount, placedAt)OrderItem (order_id, menu_id, qty, optionsJson, unitPriceSnapshot, discountAllocated)OrderEvent (order_id, type, payloadJson, occurredAt) — 상태 머신 이력"옵션 스냅샷"이 중요하다. 메뉴/옵션이 나중에 바뀌어도 과거 주문 영수증·정산은 주문 시점 가격으로 굳어 있어야 한다. 마스터 테이블 조인으로만 영수증을 그리는 설계는 정산이 깨진다.
Payment (id, order_id, pg, method, amount, status, approvedAt, approvalNo)PaymentEvent (payment_id, type, rawPayloadJson)PaymentMethodToken (member_id, pg, billingKey)Refund (payment_id, amount, reason, refundedAt, status)PG 연동은 반드시 ACL(Anti-Corruption Layer)로 감싼다. 카드사 응답 코드, 부분 취소 가능 여부, 빌링키 발급 흐름이 PG마다 달라서 도메인 모델이 PG 어휘에 오염되면 다른 PG로 바꾸기 매우 어렵다.
OrderFulfillment (order_id, type[dineIn/pickup/delivery], status)KitchenTicket (id, order_id, station, status, startedAt, finishedAt)PickupSlot (store_id, slot, capacity)DeliveryDispatch (order_id, agency, externalId, riderInfo, etaAt, status)배달은 자체 배달과 배달중개사(배민/요기요/쿠팡이츠 등) 위탁의 두 가지 패턴이 공존하고, 같은 주문이라도 매장별·시간대별로 분기된다. agency 컬럼이 있고 ACL로 감싸야 추후 변동에 견딘다.
Claim (id, order_id, type[cancel/refund/compensation], reason, status)ClaimItem (claim_id, order_item_id, qty, refundAmount)CsTicket (id, member_id, order_id?, channel, status)부분 취소가 자주 깨진다. 결제 부분 취소 ↔ 정산 분개 ↔ 적립금 회수 ↔ 쿠폰 복원이 묶여 있어서 한쪽만 성공하면 즉시 데이터 정합성 오류가 된다.
SettlementBatch (id, period, status)SettlementEntry (batch_id, partyType[store/agency/pg], partyId, amount, basis)Invoice / TaxInvoice정산은 주문/결제와 다른 시간 축으로 일어난다. 주문은 실시간이지만 정산은 일/주/월 배치다. 그래서 주문 컨텍스트가 발행한 도메인 이벤트를 정산 컨텍스트가 별도 테이블에 누적하고, 배치가 그 누적분을 읽어 분개한다.
NotificationOutbox (id, type, target, payloadJson, status)PushAdapter, KakaoAlimtalkAdapter, LmsAdapter)알림은 도메인 이벤트의 가장 흔한 소비자다. 동기 송신은 절대 금지(외부 API 장애가 주문 체결을 막는다).
권한은 RBAC + ABAC(특정 매장만 보이는 점주) 복합이 일반적이다.
[앱/웹/키오스크/콜센터]
|
v
[BFF / API Gateway]
|
v
+----------------------+ +-----------+
| 디지털 채널 백엔드 |<----->| Catalog |
| (주문/결제/이행) | +-----------+
+----------------------+ +-----------+
| ^ ^ ^ | Member |
| | | | +-----------+
v | | | +-----------+
[PG] | | +----------> | Pricing |
| | +-----------+
| +-> [배달중개사 API]
+-> [매장 POS / KDS]핵심은 "프론트 → BFF → 도메인 백엔드"의 계층 분리다. 외부 채널(키오스크, 콜센터, 외부 배달앱)은 별도의 진입점을 갖지만 내부 도메인 백엔드의 API 계약은 단일하게 유지하는 게 운영상 유리하다.
Order가 PENDING_PAYMENT로 저장 + OrderItem에 가격 스냅샷.Payment APPROVED.Order가 PAID로 전이 → 도메인 이벤트 OrderPaid 발행.KitchenTicket 생성, 매장 KDS 푸시.이 흐름의 핵심 정합성 포인트는 "결제 승인과 주문 상태 전이의 원자성"이다. PG 콜백이 두 번 오거나, 결제 성공 후 주문 갱신이 실패하는 케이스가 가장 흔하다. Outbox + idempotency key가 표준 답이다.
Order 생성, payment 컨텍스트는 외부 정산 모드로 마킹(자사 PG 미사용).여기서 자주 깨지는 게 메뉴 매핑이다. 본사에서 메뉴를 리뉴얼했는데 외부앱의 매핑 테이블이 갱신 안 되면 주문은 들어오는데 매장에서 못 만든다.
PromotionEvaluator가 후보 쿠폰을 모아 적용 시뮬레이션.IssuedCoupon을 RESERVED 상태로 잡고 결제 성공 후 USED로 전이.쿠폰의 동시성 이슈는 "한 사람이 같은 쿠폰으로 여러 디바이스에서 동시 결제"다. IssuedCoupon에 (coupon_id, member_id) 유니크 + 상태 컬럼 CAS 갱신이 표준이다.
Claim 생성, 환불 금액 계산(할인 안분이 핵심).할인 안분은 면접 단골이다. "1만원짜리에 2천원 쿠폰 + A(7000)/B(3000) 2개 항목인데 A만 환불할 때 얼마 환불?"에 즉답할 수 있어야 한다. 결제 시점에 discountAllocated를 항목별로 미리 계산해 저장해 두는 설계가 운영적으로 가장 안전하다.
PriceChanged 이벤트.이 흐름이 후보자의 캐시 정합성 경험과 직접 연결된다.
OrderPaid/Refunded 이벤트 누적분을 스캔.정산은 "돈"이라 멱등성과 재처리 가능성이 절대 깨지면 안 된다. 입력은 이벤트 스트림이지만 출력은 항상 같은 분개가 나오는 결정론적 함수여야 한다.
마스터만 참조하다가 메뉴 가격이 바뀐 뒤 영수증/정산이 어긋난다. → 주문 시점에 unitPriceSnapshot, discountAllocated를 반드시 저장.
PG 승인은 났는데 주문 갱신 트랜잭션이 실패. → Outbox + 보상 트랜잭션 + 결제 콜백 idempotency key.
매장 메뉴 품절 처리가 일부 서버에만 반영. → DB 커밋 이후 이벤트 발행, Fanout으로 전 인스턴스 동시 무효화, 갱신 구간 lock.
본사 리뉴얼 vs 배달앱 매핑이 시간차. → 메뉴 변경 시 외부 채널 동기화 잡을 반드시 트리거하고, 매핑 미존재 메뉴 주문은 ACL에서 즉시 거부.
동시 결제로 한 쿠폰이 2건에 적용. → IssuedCoupon 상태를 CAS로 RESERVED 전이, 실패 시 즉시 결제 차단.
라운딩으로 1원 차이가 누적. → 안분은 정수 원 단위 + 잔여는 가장 큰 항목에 가산하는 결정론 규칙 고정.
PickupSlot.capacity를 단순 count + 1로 늘리면 동시성 시 초과. → DB 유니크/조건부 업데이트 또는 Redis 카운터 + 정합 검증 잡.
OrderPaid에 동기 푸시 발송 코드를 박으면 외부 알림톡 장애가 결제 흐름을 막는다. → 알림은 항상 Outbox 비동기.
앱 회원과 매장 멤버십 회원이 다른 ID로 존재. → CI 기반 통합 키 + 머지 잡 + 적립금 통합 정책.
매장 영업시간/슬롯/쿠폰 유효기간이 매장 로컬 시간 기준인데 서버는 UTC. → 모든 시간 컬럼 타임존 명시, 매장 단위 영업일 정의(예: 02:00까지가 전일자).
지원자는 슬롯/스포츠 베팅/AI 서비스 도메인 출신이라 F&B 어휘가 익숙하지 않을 수 있지만, 뼈대 문제는 동일하다. 면접에서 도메인 이해를 보여주려면 "내 경험 → 같은 구조의 F&B 문제 → 풀이"로 1분 안에 매핑해서 답해야 한다.
"이전 회사에서 정적 설정 데이터를 메모리에 캐싱하면서 다중 서버 정합성이 깨지는 문제를 풀어 본 적 있습니다. JPA
PostCommitUpdateEventListener로 커밋 이후에만 RabbitMQ Fanout 발행, 각 인스턴스가 자기 큐에서 받아 해당 키만 갱신, 갱신 구간은StampedLockwriteLock으로 막고 조회는tryReadLock(2.5s)타임아웃으로 보호했습니다. CJ푸드빌 디지털 채널로 옮기면 이게 매장 메뉴/가격 변경 전파에 그대로 적용됩니다. 본사에서 가격을 바꾸면 채널 백엔드 인스턴스마다 캐시가 살아 있는데, 트랜잭션 커밋 이전에 이벤트를 보내면 갱신 직후 조회가 옛 데이터를 다시 캐시해버립니다. AFTER_COMMIT + Fanout + 인스턴스별 lock 패턴이면 매장이 'X메뉴 품절' 토글했을 때 KDS와 앱 모두 일관된 상태로 수렴합니다."
"이전 도메인에서 핵심 API의 동기 처리(금액·레벨)와 비동기 후처리(미션·통계·알림)를 분리하면서 메시지 유실을 막기 위해 Transactional Outbox Pattern을 운영했습니다.
@TransactionalEventListener(AFTER_COMMIT)으로 커밋 이후 발행, 발행 실패 시Propagation.REQUIRES_NEW로 별도 트랜잭션에 실패 메시지를 저장하고 스케줄러가 재전송, traceId까지 같이 적재해 추적했습니다. F&B 디지털 채널에서는 이게 OrderPaid 이벤트 처리에 정확히 대응합니다. 결제 승인 직후 주방 KDS 푸시·알림톡·적립금 적립·정산 누적이 동시에 일어나는데, 이걸 동기로 묶으면 알림톡 장애가 결제 자체를 막아 매장 매출이 끊깁니다. Outbox로 분리하면 알림톡이 막혀도 주문은 살고, 알림은 재전송으로 따라잡습니다. 정산 누적도 같은 outbox 경로를 타기 때문에 일배치 정산이 결정론적으로 같은 분개를 만듭니다."
"이전에 NHN Cloud Container Service의
terminationGracePeriodSeconds30초 고정 제약 하에서 gRPC 서버 graceful shutdown 503을 잡아 본 경험이 있습니다. preStop sleep 15초로 트래픽 차단 전파, gRPC graceful 12초, 여유 3초로 예산을 쪼개 SIGTERM 핸들러와 supervisord stopwaitsecs를 맞췄습니다. CJ푸드빌 디지털 채널로 옮겨도 같은 클래스의 문제가 매번 발생합니다. 결제 승인 콜백이 막 들어오는 도중 배포가 시작되면 인스턴스가 내려가면서 콜백을 잃고 주문이 PENDING에 박힙니다. 콜백 idempotency 키 + 짧은 grace + 미수신 콜백을 PG에 재조회하는 reconciliation 잡 조합으로 풀어야 운영 안전합니다. 외부에 의존하는 흐름은 'grace 안에 끝낸다'가 아니라 '재조회로 따라잡는다'로 설계해야 한다는 게 직접 깨졌을 때 배운 점입니다."
"이전 도메인에서 RTP/지급률 계산 같은 결정론적 분개를 자주 다뤘습니다. 결정론적 계산은 '입력이 같으면 출력이 항상 같다'를 강제해야 재처리가 안전한데, 그래서 라운딩 규칙을 한 곳에 못 박고 잔여 단위는 가장 큰 단위 항목에 가산하는 식으로 1원 오차를 흡수했습니다. F&B 환불 안분도 같은 문제입니다. 1만원 주문에 2천원 쿠폰을 항목 A(7000)/B(3000)에 안분할 때 1400/600 또는 1399/601처럼 라운딩이 갈라지면 부분 환불 금액이 어긋나 정산이 깨집니다. 결제 시점에
discountAllocated를 정수 원 단위로 미리 굳히고, 잔여 1원은 단가가 큰 항목에 가산하는 결정론 규칙으로 고정해 두면 부분 환불·재환불·재정산이 항상 같은 결과를 냅니다."
도메인을 머리로만 이해하지 않으려면 작은 모형을 굴려보는 게 빠르다. MySQL 8 + Spring Boot로 다음 최소 스키마를 띄워 두고 시나리오를 흘려본다.
CREATE TABLE store (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
brand_code VARCHAR(20) NOT NULL,
code VARCHAR(40) NOT NULL,
name VARCHAR(100) NOT NULL,
UNIQUE KEY uk_store (brand_code, code)
);
CREATE TABLE menu (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
brand_code VARCHAR(20) NOT NULL,
name VARCHAR(100) NOT NULL,
base_price INT NOT NULL
);
CREATE TABLE store_menu (
store_id BIGINT NOT NULL,
menu_id BIGINT NOT NULL,
is_available TINYINT(1) NOT NULL DEFAULT 1,
sold_out_until DATETIME NULL,
PRIMARY KEY (store_id, menu_id)
);
CREATE TABLE orders (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_no VARCHAR(32) NOT NULL UNIQUE,
member_id BIGINT NULL,
store_id BIGINT NOT NULL,
channel VARCHAR(20) NOT NULL,
status VARCHAR(20) NOT NULL,
total_amount INT NOT NULL,
discount_amount INT NOT NULL,
pay_amount INT NOT NULL,
placed_at DATETIME NOT NULL,
KEY idx_orders_store_placed (store_id, placed_at)
);
CREATE TABLE order_item (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_id BIGINT NOT NULL,
menu_id BIGINT NOT NULL,
qty INT NOT NULL,
unit_price_snapshot INT NOT NULL,
options_json JSON NULL,
discount_allocated INT NOT NULL DEFAULT 0,
KEY idx_order_item_order (order_id)
);
CREATE TABLE outbox_event (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
aggregate_type VARCHAR(40) NOT NULL,
aggregate_id VARCHAR(40) NOT NULL,
type VARCHAR(60) NOT NULL,
payload JSON NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
created_at DATETIME NOT NULL,
KEY idx_outbox_status (status, created_at)
);이 위에서 다음을 직접 굴려본다.
unit_price_snapshot을 반드시 채우는 서비스 작성 → 메뉴 가격을 도중에 바꿔도 영수증이 변하지 않는지 확인.OrderPaid를 outbox로 적재하는 트랜잭션 작성 → outbox publisher 별도 스레드에서 발행.discount_allocated 기준 환불 금액 계산 함수 단위 테스트 작성.// Bad — 마스터를 그대로 참조해 영수증을 그린다
public BigDecimal receiptTotal(Order order) {
return order.getItems().stream()
.map(it -> menuRepo.findById(it.getMenuId())
.orElseThrow().getBasePrice()
.multiply(BigDecimal.valueOf(it.getQty())))
.reduce(BigDecimal.ZERO, BigDecimal::add);
}가격이 바뀌면 어제 손님 영수증이 바뀐다. 정산이 즉시 깨진다.
// Improved — 주문 시점 스냅샷만 사용
public long receiptTotal(Order order) {
return order.getItems().stream()
.mapToLong(it -> (long) it.getUnitPriceSnapshot() * it.getQty()
- it.getDiscountAllocated())
.sum();
}마스터는 신규 주문에만 영향을 주고, 기존 주문은 절대 변하지 않는다.
// Bad — 결제 승인 처리에서 알림톡을 동기로 발송
@Transactional
public void onPaymentApproved(Long orderId) {
Order o = orderRepo.findById(orderId).orElseThrow();
o.markPaid();
alimtalkClient.sendOrderPaid(o); // 외부 API. 장애 시 결제 처리가 막힘
kdsClient.push(o); // 매장 KDS API. 장애 시 결제 처리가 막힘
}// Improved — outbox로 분리, 트랜잭션은 DB만 책임
@Transactional
public void onPaymentApproved(Long orderId) {
Order o = orderRepo.findById(orderId).orElseThrow();
o.markPaid();
outboxRepo.save(OutboxEvent.of("ORDER", o.getId(), "OrderPaid", o.toEventPayload()));
}
// 별도 publisher가 outbox를 읽어 Kafka 토픽에 발행
// 알림 / KDS / 정산 누적 모두 같은 토픽을 구독OrderEvent 이력 + 동시성은 낙관적 락(@Version) 또는 상태 컬럼 CAS.discountAllocated를 결정론 규칙(원 단위, 잔여는 단가 큰 항목에 가산)으로 미리 굳혀 둔다.