CJ푸드빌·F&B 계열 e-Commerce 백엔드는 단순한 "장바구니 → 결제" 흐름이 아니다. 같은 주문 한 건이 매장 픽업, 배달, 예약, 쿠폰 결합, 멤버십 적립, 프로모션 가격 정책, 결제 승인/부분취소/환불, 재고 차감, 매장 운영시간 검증까지 동시에 만족해야 한다. 도메인이 잘못 잘리면 한 가지 실패 시나리오만 발생해도 "결제는 됐는데 주문은 안...
CJ푸드빌·F&B 계열 e-Commerce 백엔드는 단순한 "장바구니 → 결제" 흐름이 아니다. 같은 주문 한 건이 매장 픽업, 배달, 예약, 쿠폰 결합, 멤버십 적립, 프로모션 가격 정책, 결제 승인/부분취소/환불, 재고 차감, 매장 운영시간 검증까지 동시에 만족해야 한다. 도메인이 잘못 잘리면 한 가지 실패 시나리오만 발생해도 "결제는 됐는데 주문은 안 들어간다", "쿠폰은 차감됐는데 결제는 취소됐다", "재고는 빠졌는데 매장에서 거절했다" 같은 운영 사고가 그대로 사용자에게 노출된다.
면접에서 이 주제는 단일 모델 설계 문제가 아니라 분산 환경에서 정합성을 어떻게 지키는가, 장애가 났을 때 시스템이 어떻게 회복하는가, 운영 관점에서 무엇을 지표로 보는가까지 묻는다. 따라서 본 문서는 도메인을 어떻게 자르는지, 상태머신을 어떻게 정의하는지, 멱등성과 Outbox/Saga를 언제 어떻게 적용하는지, 그리고 그 의사결정을 어떻게 답변으로 번역하는지를 한 흐름으로 정리한다.
도메인을 자를 때는 "데이터 일관성이 같은 트랜잭션 안에 묶여야 하는가"를 기준으로 본다. 같은 트랜잭션이어야 한다면 같은 Aggregate, 다른 시점에 결과가 보장돼도 되면 다른 Bounded Context로 분리한다.
원칙: Order Aggregate 안에서 결제 상세, 매장 운영, 재고를 직접 변경하지 않는다. 도메인 이벤트로 전파하고 각자 책임지게 한다.
주문 상태는 명시적으로 정의된 유한 상태머신으로 다룬다. 임의 문자열 컬럼이 아니라 enum + 전이 규칙으로 강제한다.
CREATED → PAYMENT_PENDING
PAYMENT_PENDING → PAYMENT_APPROVED | PAYMENT_FAILED | CANCELED_BY_USER
PAYMENT_APPROVED → STORE_ACCEPTED | STORE_REJECTED | CANCELED_BY_USER
STORE_ACCEPTED → PREPARING → READY_FOR_PICKUP/OUT_FOR_DELIVERY → COMPLETED
어디서든 → REFUND_REQUESTED → REFUNDED (정책 검증 후)핵심 규칙:
cancel() 함수로 묶어버리면 운영 사고가 난다.상태 전이 함수는 도메인 객체 안에 두고, 잘못된 전이는 예외를 던진다.
public final class Order {
private OrderStatus status;
public void approvePayment(PaymentApproved event) {
if (this.status != OrderStatus.PAYMENT_PENDING) {
throw new IllegalOrderTransitionException(this.status, "approvePayment");
}
this.status = OrderStatus.PAYMENT_APPROVED;
registerEvent(new OrderPaymentApprovedEvent(this.id, event.paymentId()));
}
}결제는 PG 응답이 진실의 원천이지만, 자체 시스템에도 결제 트랜잭션 이력을 둔다. PG와 자체 DB가 어긋날 때 어느 쪽이 진실인가를 정해 둬야 한다.
권장 모델:
PaymentAttempt): 사용자가 결제 버튼을 누르면 만들어진다. 멱등키(idempotency key)는 (orderId, attemptSeq).PaymentApproval): PG 승인 응답이 도착하면 1건. 같은 PaymentAttempt에 대해 중복 도착해도 1건만 유효하게 만든다(unique 제약).PaymentRefund): 부분환불을 지원하려면 금액과 사유, 대상 주문 항목 식별자 보관.PG 호출 자체가 멱등이 아닌 경우가 많아서 요청 보내기 전에 자체 DB에 "승인 요청 중" 레코드를 먼저 commit하고, PG 응답이 와야 그 레코드를 갱신한다. 응답이 안 오면 별도 reconciliation job이 PG에 "이 거래 ID 어떻게 됐냐" 조회해서 맞춘다. 이 패턴이 빠지면 사용자가 결제창을 두 번 눌렀을 때 이중 청구가 발생한다.
쿠폰은 "정책"과 "발급 인스턴스"를 분리해야 멱등하게 다룰 수 있다.
(coupon_id PK, member_id, status, used_order_id, used_at, version)을 가진다.UPDATE issued_coupon SET status='USED', used_order_id=?, used_at=NOW() WHERE coupon_id=? AND status='ISSUED' 한 줄이 핵심이다.affected rows = 1이면 사용 성공, 0이면 이미 사용/만료된 것. SELECT 후 UPDATE를 따로 두지 말고 조건부 UPDATE 한 번으로 끝낸다.같은 사용자가 동시에 같은 쿠폰을 두 주문에 적용하려 해도 DB의 row lock + 조건부 update가 막아준다. 분산락으로 풀려고 하면 락 점유 시간이 결제 PG 호출까지 길어져서 운영 장애로 번진다.
-- 쿠폰 사용 시도. 멱등하다.
UPDATE issued_coupon
SET status = 'USED',
used_order_id = :orderId,
used_at = NOW(6),
version = version + 1
WHERE coupon_id = :couponId
AND member_id = :memberId
AND status = 'ISSUED'
AND valid_from <= NOW(6)
AND valid_until > NOW(6);결제 실패로 주문이 취소되면 쿠폰을 다시 ISSUED로 되돌린다. 이때도 WHERE status='USED' AND used_order_id=:orderId 조건을 명시해서 다른 사용자의 쿠폰을 건드리지 않게 한다.
프로모션은 시간에 따라 바뀌는 정책이다. "2026-04-21 18:00에 1+1 적용된 가격으로 주문이 들어왔다"는 사실은 그 순간 동결되어야 한다. 그래서 Order 항목에는 다음 스냅샷을 보관한다.
promotion_id, promotion_version)unit_price_after_promotion)정책 마스터 테이블을 직접 join하지 않고 스냅샷을 읽는다. 이렇게 해야 정책이 변경되거나 폐기되어도 과거 주문 금액이 흔들리지 않고, 정산/환불 시점에 동일한 금액으로 계산할 수 있다.
F&B 도메인의 재고는 e-Commerce 일반과 다르다. 매장 단위 한정 수량 + 운영시간이 변수다. 두 가지 패턴이 나뉜다.
reserved_qty++), 결제 승인 후 확정(reserved_qty--, sold_qty++), 실패 시 예약 해제. 동시성이 높은 인기 상품은 이 패턴이 안전하다.qty - sold_qty > 0 조건부 update가 필수다.매장 운영시간 검증은 주문 생성 시점과 매장 수락 시점에 두 번 한다. 사용자가 마감 1분 전에 주문을 넣고 결제 진행 중에 마감 시각이 지나면 매장 수락 단계에서 거절한다. 이 거절을 자연스러운 흐름으로 처리할 수 있어야 운영자가 야간에 호출당하지 않는다.
서비스 경계가 여러 개라면 분산 트랜잭션 대신 Outbox 패턴 + 보상 트랜잭션(Saga) 조합을 쓴다. 면접에서는 이 부분이 가장 자주 나온다.
도메인 변경과 메시지 발행을 같은 RDB 트랜잭션에 묶고, 별도 발행기(publisher)가 outbox 테이블을 폴링/CDC해서 Kafka로 보낸다. 메시지 발행 자체에는 멱등키(event_id)를 함께 싣는다.
CREATE TABLE outbox_message (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
aggregate VARCHAR(64) NOT NULL,
aggregate_id VARCHAR(64) NOT NULL,
event_type VARCHAR(64) NOT NULL,
payload JSON NOT NULL,
event_id CHAR(36) NOT NULL UNIQUE,
created_at DATETIME(6) NOT NULL,
published_at DATETIME(6) NULL,
KEY idx_published (published_at, id)
) ENGINE=InnoDB;Order의 approvePayment() 결과로 발생한 OrderPaymentApprovedEvent는 같은 트랜잭션에서 outbox_message에 INSERT된다. 매장 알림 서비스, 적립 서비스, 정산 서비스는 Kafka 컨슈머로 이 이벤트를 받아 자기 일을 처리한다.
매장 거절은 분산 환경의 정상 시나리오다.
OrderPaymentApprovedEvent 수신 → 매장 알림 서비스가 매장 POS에 주문 전달StoreRejectedEvent 발행PaymentCanceledEvent 발행CANCELED_BY_STORE로 전이, 쿠폰 복구 이벤트 발행각 단계는 멱등해야 한다. 같은 이벤트가 중복 도착해도 동일 결과여야 한다. 결제 취소 컨슈머는 (orderId, paymentId) 기준으로 이미 취소 이력이 있으면 no-op으로 끝낸다.
멱등키는 사용자 입력 단계부터 일관되게 흐르게 한다.
Idempotency-Key 헤더 부여(member_id, idempotency_key) 유니크 제약 테이블에 INSERT 시도processed_event(event_id PK) 테이블로 중복 차단멱등키 없이 "재시도하면 안 된다"는 안내로 해결하려는 설계는 모바일 네트워크 환경에서 무너진다.
// 안 좋음: 정책 join, 동시성 무방비, 이벤트 발행 따로
public OrderId createOrder(CreateOrderCommand cmd) {
Promotion promo = promotionRepository.findActive(cmd.productId());
int price = promo.applyTo(productRepository.priceOf(cmd.productId()));
Coupon coupon = couponRepository.find(cmd.couponId());
if (coupon.isUsed()) throw new IllegalStateException();
coupon.markUsed(); // 같은 트랜잭션 안에서만 안전
couponRepository.save(coupon);
Order order = Order.create(cmd, price);
orderRepository.save(order);
kafkaTemplate.send("order.created", order); // <-- 트랜잭션 밖
return order.id();
}문제: 쿠폰 사용 검사가 SELECT 후 UPDATE 분리, 가격 스냅샷 미보관, Kafka 발행이 트랜잭션 밖이라 커밋 후 발행 실패 시 사라진다.
@Transactional
public OrderId createOrder(CreateOrderCommand cmd) {
PriceSnapshot snapshot = pricingService.snapshotFor(cmd);
int affected = couponRepository.tryUse(cmd.couponId(), cmd.memberId(), cmd.orderId());
if (affected == 0) throw new CouponAlreadyUsedException(cmd.couponId());
Order order = Order.create(cmd, snapshot);
orderRepository.save(order);
outboxPublisher.append(new OrderCreatedEvent(order.id(), snapshot));
return order.id();
}차이: 쿠폰은 조건부 UPDATE 한 번으로 검사+사용 동시 처리, 가격은 스냅샷, 이벤트는 outbox에 같이 커밋.
MySQL 8 + Kafka(KRaft 모드) + Spring Boot 한 개 모듈로 작은 실습이 가능하다.
docker-compose.yml
services:
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: shop
ports: ["3306:3306"]
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_0900_ai_ci
kafka:
image: bitnami/kafka:3.7
environment:
KAFKA_CFG_NODE_ID: 1
KAFKA_CFG_PROCESS_ROLES: controller,broker
KAFKA_CFG_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093
KAFKA_CFG_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092
KAFKA_CFG_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093
KAFKA_CFG_CONTROLLER_LISTENER_NAMES: CONTROLLER
ports: ["9092:9092"]스키마 초기화
CREATE TABLE orders (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_no CHAR(20) NOT NULL UNIQUE,
member_id BIGINT NOT NULL,
status VARCHAR(32) NOT NULL,
total_amount INT NOT NULL,
created_at DATETIME(6) NOT NULL,
KEY idx_member_created (member_id, created_at)
) ENGINE=InnoDB;
CREATE TABLE issued_coupon (
coupon_id BIGINT PRIMARY KEY,
member_id BIGINT NOT NULL,
status VARCHAR(16) NOT NULL,
used_order_id BIGINT NULL,
used_at DATETIME(6) NULL,
valid_from DATETIME(6) NOT NULL,
valid_until DATETIME(6) NOT NULL,
version INT NOT NULL DEFAULT 0,
KEY idx_member_status (member_id, status)
) ENGINE=InnoDB;실습 시나리오
CANCELED_BY_STORE로 전이되고 쿠폰이 ISSUED로 복구되는지 확인.핵심은 분산 트랜잭션을 만들지 않는 거라고 봅니다. 도메인 변경과 메시지 발행을 같은 RDB 트랜잭션에 묶는 Outbox 패턴을 기본으로 두고, 그 다음 단계는 Saga로 보상합니다. 이전 업무에서 도메인 변경과 외부 시스템 알림이 분리된 상태에서 메시지가 누락되는 사고를 겪었고, 이후 Outbox로 표준화하면서 누락이 잡혔습니다. e-Commerce에서는 주문 생성·결제 승인·매장 통보·적립이 각자의 책임이라 이 패턴이 그대로 들어맞습니다. 매장 거절은 Saga의 보상 트랜잭션 트리거로 보고, 결제 취소·쿠폰 복구·재고 복구를 멱등 컨슈머로 잇습니다.
상품 단가, 매장 운영시간, 멤버십 등급 같은 읽기 비중이 큰 데이터를 캐시 대상으로 봅니다. 다만 주문 시점 가격은 캐시에서 읽되 주문 객체 안에 스냅샷으로 저장합니다. 정책이 바뀌어도 과거 주문이 흔들리면 안 되니까요. 캐시 갱신은 변경 시점에 publish-then-invalidate로 흘리고, TTL 안전장치를 함께 둬서 invalidate 누락 시에도 자동 회복되게 합니다. 과거에 캐시 stampede로 결제 직전 단가 조회가 폭주하면서 DB 부하가 튄 적이 있어서, 그 뒤로는 단건 캐시 + 짧은 TTL + jitter 패턴으로 정착시켰습니다.
가장 중요한 건 사용자에게 "결제됐는지 안 됐는지" 모호한 상태를 보여주지 않는 겁니다. 그래서 PG 호출 직전에 "승인 요청 중" 레코드를 자체 DB에 먼저 커밋하고, 응답을 못 받았을 때를 대비한 reconciliation job을 따로 운영합니다. 이 잡이 PG에 거래 상태를 다시 조회해서 자체 DB와 맞추고, 사용자에게는 "결제 확인 중" 상태로 노출합니다. 운영 모니터링은 PG 응답시간 p99, 승인 실패율, reconciliation 보정 건수, outbox lag 네 가지를 1분 단위로 봅니다. 이 지표 중 하나라도 임계 초과하면 결제 수단별로 트래픽을 일시 분산하거나 매장 알림 큐를 늦추는 식으로 대응합니다.
PAYMENT_PENDING이 1시간 이상 머무는 건수)