F&B 이커머스(예: 빵집, 카페, 외식 브랜드의 온라인 주문/예약/선물하기/모바일 상품권)는 일반 상품 커머스와 다른 결제 운영 특성을 가진다. 단가가 작은 주문이 단시간에 폭증하고(점심·저녁 피크), 매장 단위로 정산이 분기되며, 모바일 상품권·선불충전·카카오페이 머니 같은 "현금이 아닌 결제수단"이 섞이고, 매장 사정으로 인한 부분취소가 매우 잦다....
F&B 이커머스(예: 빵집, 카페, 외식 브랜드의 온라인 주문/예약/선물하기/모바일 상품권)는 일반 상품 커머스와 다른 결제 운영 특성을 가진다. 단가가 작은 주문이 단시간에 폭증하고(점심·저녁 피크), 매장 단위로 정산이 분기되며, 모바일 상품권·선불충전·카카오페이 머니 같은 "현금이 아닌 결제수단"이 섞이고, 매장 사정으로 인한 부분취소가 매우 잦다. "주문은 됐는데 카드 승인이 안 되었다", "결제는 됐는데 매장에서 거절했다", "모바일 쿠폰을 환불해 달라"는 문의가 매일 들어온다.
이 영역의 면접 질문은 보통 "결제는 됐는데 주문은 실패한 케이스를 어떻게 처리하느냐", "PG 망 장애 시 결제 상태를 어떻게 복구하느냐", "환불 정합성을 어떻게 보장하느냐"로 좁혀진다. 이는 단순한 결제 SDK 호출 이야기가 아니라, 분산 트랜잭션의 일관성 모델, 멱등성, 상태기계 설계, 정산/대사 운영에 대한 질문이다. 후보자가 카프카 Outbox Pattern으로 트랜잭션 경계를 다룬 경험이 있다면, 그 경험을 결제 도메인 언어로 정확히 번역할 수 있어야 한다.
이 문서는 PG 연동의 표면적 흐름을 넘어, 실제 운영에서 마주치는 정합성 깨짐 시나리오, 재시도와 멱등키 설계, 정산·대사 운영, 장애 복구·감사로그까지 한 번에 정리한다.
결제는 본질적으로 우리 시스템(주문/결제 도메인)과 외부 PG(또는 간편결제사) 사이의 분산 합의 문제다. 두 시스템이 항상 같은 상태를 보고 있다고 가정하면 사고가 난다.
신용카드 결제 흐름은 보통 두 단계로 나뉜다.
PG에 따라 승인/매입을 한 번에 처리하는 모드가 기본이지만, F&B에서 "주문 수령(매장 픽업/배달 출발) 시점에 매입"하는 형태를 쓰는 경우도 있다. 이 경우 취소 비용·정산 흐름이 달라진다.
취소도 두 종류다.
| 수단 | 즉시 환불 가능성 | 부분 환불 | 운영 함정 |
|---|---|---|---|
| 신용/체크카드 | 승인 당일 Void는 즉시, 매입 후 Refund는 영업일 기준 며칠 | 가능 | Void 마감시간(보통 23:30) 넘기면 자동으로 Refund로 전환 |
| 계좌이체(가상계좌) | 다음 영업일 환불계좌 입금 | 가능하나 PG에 따라 제한 | 환불계좌 검증 필요(예금주 일치) |
| 카카오페이/네이버페이 머니 | 즉시 | 가능 | 포인트 적립분 회수 정책 따로 |
| 모바일 상품권/금액권 | 잔액 복원 또는 결제 취소 | 사용 분 차감 후 환불 | 부분 사용 후 만료된 케이스 |
| 선불충전 잔액 | 잔액 복원 | 가능 | 잔액 → 카드 환불 변환 시 회계 분리 |
이 표는 정책이 아니라 상태기계 설계의 입력이다. 결제수단별로 가능한 전이가 다르기 때문이다.
가장 흔한 안티패턴은 주문 테이블 하나에 결제 상태까지 우겨넣는 것이다. F&B에서는 다음과 같은 상태가 동시에 존재한다.
RECEIVED → ACCEPTED → PREPARING → READY → COMPLETED (or REJECTED, CANCELLED)PENDING → AUTHORIZED → CAPTURED → REFUNDED(부분/전체) (or FAILED, VOIDED)핵심 원칙은 주문 상태와 결제 상태를 독립 상태기계로 두고, 둘을 합성한 뷰를 화면/CS에 노출하는 것이다. 그래야 "결제는 성공했는데 매장에서 거절"이 단순한 케이스가 된다. 결제 상태기계에서는 CAPTURED → REFUNDED만 신경 쓰면 되고, 주문 상태기계에서는 RECEIVED → REJECTED만 정의하면 된다. 둘을 묶는 책임은 별도 컴포넌트(주문 조정자)가 진다.
운영에서 반드시 마주치는 4가지 사고 패턴이다.
가장 비싼 사고다. PG로부터 승인 응답을 받았는데, 그 직후 주문 영속화에 실패하는 경우.
원인:
처리:
PENDING. 멱등키와 외부 주문번호를 함께 부여한다.AUTHORIZED/CAPTURED로 갱신한다.PG 호출 자체가 타임아웃이 나서 응답을 받지 못한 경우. 이때 절대 안 되는 것이 "타임아웃이니까 실패로 판단해서 같은 멱등키로 다시 호출"하는 것이다. PG 쪽에서는 첫 호출이 살아 승인되어 있을 수 있다.
처리:
paymentIntentId로 충분하다.F&B 특유의 케이스다. "주문 들어왔는데 재료가 떨어졌다", "마감 직전이라 못 만든다" 같은 사유.
처리:
REJECTED로 옮기는 것과 동시에 결제 도메인에 환불 명령을 발행한다. 이때 직접 호출이 아니라 Outbox 메시지로 발행한다.피자 5판 주문 중 1판만 못 만들었다. 1판분만 환불해야 한다.
처리:
CREATE TABLE payment_intent (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
intent_key CHAR(36) NOT NULL,
order_id BIGINT NOT NULL,
amount DECIMAL(13,2) NOT NULL,
currency CHAR(3) NOT NULL DEFAULT 'KRW',
status VARCHAR(16) NOT NULL,
pg_provider VARCHAR(32) NOT NULL,
pg_tid VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL,
updated_at DATETIME(3) NOT NULL,
UNIQUE KEY uk_intent_key (intent_key),
KEY idx_order (order_id),
KEY idx_status_created (status, created_at)
) ENGINE=InnoDB;멱등키 intent_key는 사용자 액션(주문 결제 버튼 누름) 단위로 발급한다. 같은 결제 버튼을 여러 번 눌러도 같은 키가 재사용되도록 클라이언트가 보관한다. 서버는 같은 키가 들어오면 기존 intent를 반환한다.
PG 호출 시 헤더 또는 바디에 이 키를 넣는다. 대부분의 국내 PG는 Idempotency-Key 또는 자체 필드명을 지원한다. 지원하지 않더라도 우리 쪽에서 단일 호출만 보장하면 충분하다.
후보자가 가지고 있는 카프카 Outbox 경험은 결제 도메인에서 다음과 같이 매핑된다.
기존 도메인: "주문이 저장되었으니 검색 색인 업데이트 메시지를 보낸다" 결제 도메인: "결제가 CAPTURED 됐으니 정산 도메인과 알림 도메인에 사실을 알린다"
@Transactional
public void confirmCapture(PaymentIntent intent, PgCaptureResponse res) {
intent.markCaptured(res.getTid(), res.getApprovedAt());
paymentIntentRepository.save(intent);
OutboxEvent event = OutboxEvent.of(
"payment.captured.v1",
intent.getId(),
PaymentCapturedPayload.from(intent, res)
);
outboxRepository.save(event);
}핵심은 결제 상태 변경과 메시지 발행이 동일 트랜잭션 안에서 같은 DB에 기록된다는 점이다. 별도 발행기(Outbox Poller 또는 Debezium)는 이 테이블을 읽어 카프카로 흘려 보낸다. 정산·알림·CS 사이드의 어떤 컨슈머가 죽어도 결제 본 트랜잭션은 안전하다.
면접에서 이 흐름을 설명할 때는, 후보자가 이전에 "트랜잭션 안에서 외부 호출 시도하다 롤백 시점이 어긋나서 정합성이 깨졌다"는 경험을 어떻게 outbox로 정리했는지를 결제 시나리오에 그대로 옮겨 말하면 된다. 결제는 그 패턴이 가장 강하게 요구되는 도메인이다.
부분 결제(카드 + 쿠폰 + 포인트)를 환불할 때, 각 사이드 효과를 보상 가능한 단위 트랜잭션으로 쪼갠다.
오케스트레이션형 Saga로 가는 경우가 운영상 추적이 쉽다. "환불 작업"이라는 단일 엔티티가 진행 상태를 들고 있어 CS가 한 화면에서 추적할 수 있다.
F&B에서 정산은 일반 셀러 정산과 다르다.
이 흐름은 두 가지 방식으로 구현된다.
운영 관점에서 매일 해야 하는 일은 대사(reconciliation)다.
PG 정산내역(파일 또는 API) ─┐
├─ 대사 엔진 ─→ 정산 확정 / 차이 리포트
우리 결제 도메인 거래내역 ──┘대사 엔진의 책임:
payment_intent와 매칭-- PG가 알려준 일별 매출과 우리 쪽 CAPTURED 합계 비교
SELECT
d.settle_date,
d.pg_amount,
COALESCE(p.our_amount, 0) AS our_amount,
d.pg_amount - COALESCE(p.our_amount, 0) AS diff
FROM (
SELECT settle_date, SUM(amount) AS pg_amount
FROM pg_settlement_daily
WHERE settle_date BETWEEN '2026-05-01' AND '2026-05-07'
GROUP BY settle_date
) d
LEFT JOIN (
SELECT DATE(captured_at) AS settle_date, SUM(amount) AS our_amount
FROM payment_intent
WHERE status IN ('CAPTURED', 'PARTIALLY_REFUNDED', 'REFUNDED')
AND captured_at BETWEEN '2026-05-01 00:00:00' AND '2026-05-08 00:00:00'
GROUP BY DATE(captured_at)
) p ON p.settle_date = d.settle_date
ORDER BY d.settle_date;이 쿼리에서 diff가 0이 아닌 행이 운영 알림으로 떠야 한다.
@Transactional
public Order placeOrder(OrderCommand cmd) {
Order order = Order.from(cmd);
orderRepository.save(order);
// 위험: 외부 호출이 트랜잭션 안에 있음
PgResponse res = pgClient.approve(order.toPgRequest());
if (!res.isSuccess()) throw new PaymentFailed(res);
order.markPaid(res.getTid());
return order;
}문제:
public PaymentIntent prepare(OrderCommand cmd, String idempotencyKey) {
return paymentIntentRepository
.findByIntentKey(idempotencyKey)
.orElseGet(() -> paymentIntentRepository.save(
PaymentIntent.pending(cmd, idempotencyKey)
));
}
public PaymentIntent capture(PaymentIntent intent) {
PgResponse res;
try {
res = pgClient.approve(intent.toPgRequest()); // 트랜잭션 밖
} catch (PgTimeoutException e) {
res = pgClient.inquiry(intent.getIntentKey()); // 같은 키로 조회
}
return updateAfterPg(intent.getId(), res);
}
@Transactional
protected PaymentIntent updateAfterPg(Long intentId, PgResponse res) {
PaymentIntent intent = paymentIntentRepository.findById(intentId).orElseThrow();
if (res.isApproved()) intent.markCaptured(res.getTid(), res.getApprovedAt());
else intent.markFailed(res.getCode(), res.getMessage());
outboxRepository.save(OutboxEvent.from(intent));
return intent;
}핵심 차이:
MySQL 8과 도커로 충분히 시뮬레이션할 수 있다.
services:
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: rootpw
MYSQL_DATABASE: payments
ports: ["3306:3306"]
command:
- --character-set-server=utf8mb4
- --default-time-zone=+09:00
redis:
image: redis:7
ports: ["6379:6379"]
kafka:
image: bitnami/kafka:3.7
ports: ["9092:9092"]
environment:
KAFKA_CFG_NODE_ID: "1"
KAFKA_CFG_PROCESS_ROLES: "broker,controller"
KAFKA_CFG_LISTENERS: "PLAINTEXT://:9092,CONTROLLER://:9093"
KAFKA_CFG_ADVERTISED_LISTENERS: "PLAINTEXT://localhost:9092"
KAFKA_CFG_CONTROLLER_LISTENER_NAMES: "CONTROLLER"
KAFKA_CFG_CONTROLLER_QUORUM_VOTERS: "1@localhost:9093"
KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP: "CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT"PG는 실제 연동 없이 가짜 서버(WireMock 또는 간단 Spring Boot 앱)로 돌린다. 시나리오 시뮬레이터를 만들어 두면 실습 효과가 크다.
// FakePg: 시나리오별 응답
@PostMapping("/approve")
public PgResponse approve(@RequestBody PgRequest req) {
String scenario = req.getScenario();
return switch (scenario) {
case "OK" -> PgResponse.approved("TID-" + UUID.randomUUID());
case "TIMEOUT" -> sleepThen(95_000, () -> PgResponse.approved("TID-LATE"));
case "DECLINED" -> PgResponse.declined("LIMIT_EXCEEDED");
case "DUPLICATE" -> PgResponse.duplicate(); // 같은 멱등키 두 번째 호출
default -> PgResponse.approved("TID-" + UUID.randomUUID());
};
}다음 5개 시나리오를 직접 실행해 보면 운영 감각이 빠르게 잡힌다.
intent_key로 동시에 두 번 호출. 두 번째 호출이 새 거래를 만들지 않고 기존 의도를 반환하는지 확인.핵심을 세 단계로 답한다.
이어서 후보자 경험을 연결한다. "기존 프로젝트에서 트랜잭션 안에서 카프카 발행을 묶다가 발행은 됐는데 DB는 롤백된 적이 있어 outbox로 옮겼는데, 그 패턴을 결제에 그대로 적용했다고 보면 됩니다."
actor, actor_type(USER/STAFF/SYSTEM), before_status, after_status, reason_code, pg_raw_response, created_at. 회계 감사 대응에 직결된다.