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

카테고리

  • 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
    • 하네스 엔지니어링 실전 — 4인 에이전트 팀으로 코딩 파이프라인 구축하기
    • 하네스 엔지니어링 — 오래 실행되는 AI 에이전트를 위한 설계
    • 멀티모달 LLM (Multimodal Large Language Model)
    • AI 에이전트와 함께 MVP 만들기 — dooray-cli 사례
  • ai 페이지로 이동
    • agent 페이지로 이동
  • algorithm 페이지로 이동
    • live-coding 페이지로 이동
    • 분산 계산을 위한 알고리즘
  • architecture 페이지로 이동
    • [초안] 시니어 백엔드를 위한 API 설계 실전 스터디 팩 — REST · 멱등성 · 페이지네이션 · 버전 전략
    • [초안] API Versioning과 Backward Compatibility: 시니어 백엔드 관점 정리
    • 캐시 설계 전략 총정리
    • [초안] CJ푸드빌 커머스/F&B 도메인 설계 면접 대비 — 슬롯 경험을 주문·결제·쿠폰·매장 상태 설계로 번역하기
    • [초안] 커머스 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푸드빌 디지털 채널 백엔드 관점
    • [초안] Strategy Pattern — 분기문을 없애는 설계, 시니어 백엔드 인터뷰 핵심 패턴
    • [초안] 시니어 백엔드를 위한 시스템 설계 입문 스터디 팩
    • [초안] 템플릿 메서드 패턴 - 백엔드 처리 골격을 강제하는 가장 오래되고 가장 위험한 패턴
    • [초안] 대규모 트래픽 중 무중단 마이그레이션 — Feature Flag + Shadow Mode 실전
  • database 페이지로 이동
    • mysql 페이지로 이동
    • opensearch 페이지로 이동
    • redis 페이지로 이동
    • 김영한의-실전-데이터베이스-설계 페이지로 이동
    • 커넥션 풀 크기는 얼마나 조정해야 할까?
    • 인덱스 - 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
  • finance 페이지로 이동
    • industry-cycle 페이지로 이동
    • investing 페이지로 이동
    • stock-notes 페이지로 이동
  • http 페이지로 이동
    • HTTP Connection Pool
  • interview 페이지로 이동
    • [초안] AI 서비스 팀 경험 기반 시니어 백엔드 면접 질문 뱅크 — Spring Batch RAG / gRPC graceful shutdown / 전략 패턴 / 12일 AI 웹툰 MVP
    • [초안] CJ푸드빌 디지털 채널 Back-end 개발자 직무 분석
    • [초안] CJ푸드빌 디지털 채널 Back-end 면접 답변집 — 슬롯 도메인 경험을 커머스/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
  • rabbitmq 페이지로 이동
    • [초안] RabbitMQ Basics — 실전 백엔드 관점에서 정리하는 메시지 브로커 기본기
    • [초안] RabbitMQ vs Kafka — 백엔드 메시징 선택 기준과 실전 운영 관점
  • security 페이지로 이동
    • [초안] 시니어 백엔드를 위한 보안 / 인증 스터디 팩 — Spring Security, JWT, OAuth2, OWASP Top 10
  • 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/[초안] F&B 쿠폰·프로모션·멤버십·포인트…
system

[초안] F&B 쿠폰·프로모션·멤버십·포인트 설계

F&B 이커머스에서 쿠폰·프로모션·멤버십·포인트는 매출 직결 도메인이면서 동시에 가장 컴플레인이 많이 나오는 영역이다. 한 번의 결제에 "신규가입 쿠폰", "브랜드 쿠폰", "매장 쿠폰", "메뉴 쿠폰", "통신사 제휴 할인", "멤버십 등급 할인", "포인트 사용"이 동시에 얹히고, 발급·사용·취소·환불이 비동기로 흐른다. 정책이 잘못 설계되면 같은 쿠폰...

2026.05.07·10 min read·19 views

왜 중요한가

F&B 이커머스에서 쿠폰·프로모션·멤버십·포인트는 매출 직결 도메인이면서 동시에 가장 컴플레인이 많이 나오는 영역이다. 한 번의 결제에 "신규가입 쿠폰", "브랜드 쿠폰", "매장 쿠폰", "메뉴 쿠폰", "통신사 제휴 할인", "멤버십 등급 할인", "포인트 사용"이 동시에 얹히고, 발급·사용·취소·환불이 비동기로 흐른다. 정책이 잘못 설계되면 같은 쿠폰이 두 번 차감되거나, 환불 후 쿠폰이 살아나지 않거나, 선착순 이벤트가 정원을 초과 발급한다. 시니어 백엔드가 면접에서 이 도메인을 다룰 때는 "어떻게 만들겠다"가 아니라 "어떤 실패를 어떻게 막겠다"를 말할 수 있어야 한다.

CJ푸드빌/올리브영 같은 F&B·헬스앤뷰티 도메인은 멀티브랜드(빕스, 더플레이스, 뚜레쥬르 등) × 멀티채널(앱, 웹, 매장 POS, 키오스크) × 멀티 결제수단이 결합된다. 같은 쿠폰이 매장에서는 인쇄 바코드로, 앱에서는 푸시 카드로, 키오스크에서는 QR로 들어오는데 백엔드는 한 정책으로 검증해야 한다. 즉 "쿠폰 = 단순 할인 코드"가 아니라 "조건·우선순위·재고·발급정책·복구정책을 가진 정책 객체"로 다뤄야 한다.

도메인 모델 — 4개 축을 분리한다

쿠폰을 한 테이블로 만들면 6개월 안에 망가진다. 기능적으로 다음 4개 축을 분리한다.

  1. 쿠폰 정의(Coupon Definition) — 어떤 할인인가. 할인 종류(정액/정률/N+1/무료배송), 적용 대상(브랜드/매장/카테고리/메뉴), 사용 조건(최소 금액, 시간대, 요일, 채널), 중복 가능 정책.
  2. 쿠폰 발급(Coupon Issue) — 누구에게 몇 장, 언제까지. 1인 1매, 계정당 N매, 선착순 N명, 다운로드형, 자동 발급(생일/등급업).
  3. 쿠폰 사용(Coupon Redemption) — 한 결제에서 어느 쿠폰을 어떻게 적용했는가. 멱등성 보장이 핵심.
  4. 쿠폰 회수(Coupon Restore) — 결제 취소·환불·CS 복구 시 사용 이력을 되돌리는 흐름.

coupon_template (정의) → coupon_issue (사용자별 보유 쿠폰 인스턴스) → coupon_use_log (사용 이력) 3계층이 가장 확장이 잘 된다. coupon_issue는 (template_id, user_id, status, expire_at)로 인덱스를 잡고, status는 ISSUED / RESERVED / USED / EXPIRED / RESTORED 5상태 머신으로 둔다. RESERVED는 결제 진입 시점에 잠그는 중간 상태로, 이걸 빼면 결제 동시 진행 시 같은 쿠폰이 두 번 적용된다.

핵심 정합성 원칙 — 멱등키와 상태 전이

쿠폰 도메인은 분산 트랜잭션을 피하면서도 정합성을 보장해야 한다. 가장 안전한 패턴은 다음 두 가지다.

  • 멱등키(Idempotency Key): 결제 요청별로 order_id를 멱등키로 사용하고, coupon_use_log(order_id, coupon_issue_id) UNIQUE 제약을 건다. 같은 주문에 대한 재시도가 와도 두 번 차감되지 않는다.
  • CAS 기반 상태 전이: 쿠폰 사용은 UPDATE coupon_issue SET status='USED', used_at=NOW() WHERE id=? AND status='ISSUED' 형태의 조건부 업데이트로 한다. affected rows = 0이면 이미 누가 썼다는 뜻이고 즉시 실패시킨다.
sql
UPDATE coupon_issue
   SET status = 'USED',
       used_at = NOW(6),
       order_id = :orderId
 WHERE id = :couponIssueId
   AND user_id = :userId
   AND status = 'ISSUED'
   AND expire_at > NOW(6);

이 한 줄이면 분산락 없이도 단일 쿠폰의 이중 사용을 막는다. 비관적 락(SELECT ... FOR UPDATE)을 거는 코드도 자주 보이지만, 결제 트랜잭션이 길어지면 락 대기로 결제 큐가 막히기 때문에 단건 CAS가 더 낫다.

선착순 이벤트 — Redis가 정답인 이유

"오전 10시 쿠폰 1만장 선착순 발급" 같은 이벤트는 RDBMS만으로는 풀리지 않는다. RDBMS에 INSERT INTO coupon_issue ... WHERE (SELECT COUNT(*) FROM coupon_issue WHERE template_id=?) < 10000 같은 쿼리를 박으면 락 경합으로 DB가 죽는다. 패턴은 다음과 같다.

  1. 이벤트 시작 전, Redis에 event:{id}:stock = 10000을 세팅한다.
  2. 요청이 들어오면 DECR event:{id}:stock을 먼저 호출한다.
  3. 반환값이 0 이상이면 RabbitMQ/Kafka에 발급 메시지를 넣는다. 음수가 되면 즉시 "마감" 응답.
  4. 컨슈머가 coupon_issue INSERT를 비동기로 처리한다.
  5. 1인 1매 제약은 Redis SET NX(SET event:{id}:user:{userId} 1 NX EX 86400)으로 본다.

이 구조에서 핵심은 "재고 차감"과 "DB 발급"을 분리하는 것이다. 재고 차감은 Redis 단일 명령으로 원자성을 보장하고, DB 발급은 메시지 큐 컨슈머가 자기 페이스로 처리한다. 발급 메시지가 유실되면 안 되니 큐는 publisher confirm + persistent + at-least-once로 둔다. 멱등키는 (event_id, user_id)이면 충분하다.

면접에서 "왜 Redis를 캐시가 아닌 진실의 원천처럼 썼냐"는 질문이 들어오면, "선착순 카운터는 일시적 진실이고, 영구 진실은 컨슈머가 RDBMS에 쓰는 coupon_issue 행이다. Redis는 게이트키퍼 역할이고, 실패 시 발급 메시지를 다시 흘려서 RDBMS 기준으로 정합성을 맞춘다"고 답한다. 이 답이 캐시 정합성 경험이 있는 후보의 답이다.

정책 엔진 — 우선순위와 중복 가능 규칙

브랜드·매장·메뉴별 적용 조건은 정책 엔진으로 분리한다. 각 쿠폰은 다음 정보를 가진다.

  • scope: BRAND / STORE / CATEGORY / MENU / ORDER_TOTAL
  • target_ids: 적용 대상 식별자 리스트
  • discount_type: FIXED / PERCENT / BOGO / FREE_DELIVERY
  • priority: 적용 우선순위 (낮을수록 먼저)
  • stackable_with: 같이 쓸 수 있는 쿠폰 그룹 ID

결제 시 정책 엔진은 장바구니 → 적용 가능한 쿠폰 후보 추출 → 우선순위 정렬 → 중복 가능 규칙으로 필터링 → 최적 조합 선택의 파이프라인을 돈다. 일반적인 적용 순서는 메뉴 단위 할인 → 카테고리 할인 → 주문 총액 할인 → 멤버십 등급 할인 → 포인트 사용 → 결제 수단 할인이다. 이 순서가 바뀌면 같은 쿠폰이 다른 금액으로 찍힌다.

"최적 조합 선택"은 욕심껏 하면 NP 문제가 된다. 실무에서는 그리디(최대 할인 1장)와 정책상 허용된 조합만 시뮬레이션하는 형태로 컷한다. CJ푸드빌처럼 멀티브랜드면 "브랜드 쿠폰 1장 + 매장 쿠폰 1장 + 멤버십 할인 1개 + 포인트"로 슬롯을 정해두는 게 현실적이다.

포인트 — 가용/적립예정/만료 분리

포인트는 쿠폰보다 골치 아프다. 조회 시점의 잔액과 사용 가능 잔액이 다르고, 환불 시 적립 취소까지 따라온다. 모델은 다음과 같이 잡는다.

  • point_balance(user_id, available, pending, locked): 합산 캐시.
  • point_transaction(id, user_id, type, amount, source_order_id, expire_at, status): 모든 변동의 단일 진실원.

type은 EARN / USE / EXPIRE / CANCEL_EARN / CANCEL_USE. 잔액은 point_transaction의 합으로 계산되고, point_balance는 그 캐시일 뿐이다. 캐시 정합성이 깨지면 정기 배치로 재계산한다. 이 구조에서 읽기 빈도가 매우 높다는 점이 중요하다 — 결제 화면, 마이페이지, 주문 내역 모두 잔액을 부른다. 읽기 전용 잔액 조회는 Redis 캐시 + StampedLock의 tryOptimisticRead로 받쳐주면 락 경합 없이 처리량을 끌어올릴 수 있다. 잔액이 갱신될 때만 writeLock으로 캐시를 무효화한다.

차감은 항상 멱등키 기반이다. INSERT INTO point_transaction(order_id, type, ...) VALUES(...)에 UNIQUE(order_id, type)을 걸면 결제 재시도에도 이중 차감이 안 일어난다. 만료는 별도 스케줄러가 expire_at < NOW() AND status='ACTIVE'인 적립건을 EXPIRE로 마킹하고 잔액 캐시를 갱신한다.

나쁜 예 vs 개선 예

나쁜 예 — 쿠폰 사용을 SELECT 후 UPDATE로 처리

java
// 두 번 사용될 수 있다
Coupon coupon = couponRepo.findById(id);
if (coupon.getStatus() == ISSUED) {
    coupon.setStatus(USED);
    couponRepo.save(coupon);
}

동일 쿠폰을 두 탭에서 동시에 결제 시도하면 둘 다 ISSUED를 보고 둘 다 USED로 바꾼다. 두 주문 모두 할인이 들어간다.

개선 예 — 조건부 UPDATE + 멱등 로그

java
int updated = couponMapper.markUsedIfIssued(couponIssueId, userId, orderId);
if (updated == 0) {
    throw new CouponAlreadyUsedException(couponIssueId);
}
couponUseLogMapper.insertIgnore(orderId, couponIssueId, appliedAmount);

updated == 0이면 이미 누군가 썼거나 만료되었다는 뜻이고 즉시 실패다. INSERT IGNORE로 로그가 멱등하게 들어가서 재시도해도 같은 결과다.

나쁜 예 — 환불 시 쿠폰 단순 복구

java
coupon.setStatus(ISSUED); // 만료된 쿠폰도 살아난다

개선 예 — 정책에 따른 복구

java
if (coupon.getExpireAt().isBefore(now)) {
    couponMapper.markRestoredButExpired(couponIssueId);
    // CS 정책에 따라 별도 보상 쿠폰 발급 또는 포인트 환급
} else {
    couponMapper.markRestoredAndReusable(couponIssueId);
}
auditLog.record(RESTORE, couponIssueId, orderId, reason);

복구는 단순 상태 토글이 아니라 만료 여부, 환불 사유, CS 정책을 모두 본다. 그리고 모든 복구는 감사 로그에 남긴다 — 누가, 언제, 왜.

동시성 — 어디서 어떤 도구를 쓰는가

  • 단일 쿠폰의 이중 사용 방지: DB CAS UPDATE. 락 불필요.
  • 선착순 재고 차감: Redis DECR. 단일 명령 원자성.
  • 사용자당 1매 제약: Redis SET NX 또는 DB UNIQUE(template_id, user_id).
  • 포인트 잔액 조회 핫패스: StampedLock tryOptimisticRead + Redis 캐시. 쓰기 시 무효화.
  • 포인트 차감/적립: DB 트랜잭션 + 멱등키 UNIQUE.
  • 결제 후 비동기 적립/차감 메시지: RabbitMQ/Kafka, publisher confirm + at-least-once + 컨슈머 멱등성.

분산락(Redisson RLock)은 마지막 수단이다. 위 도구로 안 풀릴 때만 쓰는데, 쿠폰·포인트 도메인은 거의 모두 위 도구로 풀린다.

감사 로그와 CS 운영

쿠폰·포인트는 돈과 같다. 모든 상태 변경은 append-only 로그에 남긴다.

  • coupon_audit_log(coupon_issue_id, action, before_status, after_status, actor, reason, created_at)
  • point_audit_log(transaction_id, action, amount, before_balance, after_balance, actor, reason, created_at)

actor는 USER / SYSTEM / CS_AGENT_ID. CS가 직접 복구한 건은 반드시 사람의 ID가 남아야 한다. 이 로그는 별도 OLAP 또는 Elasticsearch로 흘려서 정산팀과 CS팀이 자유 검색할 수 있게 한다.

CS 복구 시나리오는 미리 메뉴화한다. "결제 취소 후 쿠폰 자동복구 실패", "포인트 차감되었는데 주문 누락", "선착순 이벤트 정원 초과 발급" 같은 빈발 케이스는 CS 어드민에 전용 버튼을 만들어서 사람이 SQL 치지 않게 한다. 어드민 행동 하나하나가 감사 로그에 들어가야 한다.

로컬 실습 환경

yaml
# docker-compose.yml
services:
  mysql:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: promo
    ports: ["3306:3306"]
  redis:
    image: redis:7
    ports: ["6379:6379"]
  rabbitmq:
    image: rabbitmq:3-management
    ports: ["5672:5672", "15672:15672"]

테이블 스키마 일부.

sql
CREATE TABLE coupon_template (
  id            BIGINT PRIMARY KEY AUTO_INCREMENT,
  name          VARCHAR(100) NOT NULL,
  scope         VARCHAR(20)  NOT NULL,
  discount_type VARCHAR(20)  NOT NULL,
  discount_value INT         NOT NULL,
  min_order_amount INT       NOT NULL DEFAULT 0,
  total_stock   INT          NULL,
  per_user_limit INT         NOT NULL DEFAULT 1,
  starts_at     DATETIME(6)  NOT NULL,
  ends_at       DATETIME(6)  NOT NULL
) ENGINE=InnoDB;
 
CREATE TABLE coupon_issue (
  id          BIGINT PRIMARY KEY AUTO_INCREMENT,
  template_id BIGINT NOT NULL,
  user_id     BIGINT NOT NULL,
  status      VARCHAR(20) NOT NULL,
  order_id    BIGINT NULL,
  issued_at   DATETIME(6) NOT NULL,
  used_at     DATETIME(6) NULL,
  expire_at   DATETIME(6) NOT NULL,
  UNIQUE KEY uq_template_user (template_id, user_id),
  KEY idx_user_status (user_id, status, expire_at)
) ENGINE=InnoDB;
 
CREATE TABLE coupon_use_log (
  order_id        BIGINT NOT NULL,
  coupon_issue_id BIGINT NOT NULL,
  applied_amount  INT NOT NULL,
  created_at      DATETIME(6) NOT NULL,
  PRIMARY KEY (order_id, coupon_issue_id)
) ENGINE=InnoDB;

실행 가능한 예제 — 선착순 발급 컨트롤러

java
@PostMapping("/events/{eventId}/coupons")
public CouponIssueResponse claim(@PathVariable Long eventId,
                                 @AuthenticationPrincipal User user) {
    String stockKey = "event:" + eventId + ":stock";
    String userKey  = "event:" + eventId + ":user:" + user.getId();
 
    Boolean firstClaim = redis.opsForValue()
        .setIfAbsent(userKey, "1", Duration.ofDays(1));
    if (Boolean.FALSE.equals(firstClaim)) {
        throw new AlreadyClaimedException();
    }
 
    Long remaining = redis.opsForValue().decrement(stockKey);
    if (remaining == null || remaining < 0) {
        redis.delete(userKey);
        throw new SoldOutException();
    }
 
    rabbit.convertAndSend("coupon.issue",
        new IssueMessage(eventId, user.getId(), UUID.randomUUID()));
    return CouponIssueResponse.queued();
}

컨슈머는 IssueMessage를 받아 coupon_issue에 INSERT IGNORE(또는 ON DUPLICATE KEY UPDATE)로 멱등 발급한다.

면접 답변 프레이밍

Q. 선착순 1만장 쿠폰 이벤트가 들어오면 어떻게 설계하시겠어요?

"재고 차감과 영구 발급을 분리합니다. 재고는 Redis DECR로 원자 차감해서 1만 장이라는 게이트만 통과시키고, 실제 발급은 RabbitMQ로 비동기로 흘려서 RDBMS에 coupon_issue로 저장합니다. 1인 1매 제약은 Redis SET NX와 DB UNIQUE 키 두 군데에 둬서, Redis가 휘발되어도 DB가 막아주게 합니다. 캐시와 DB 정합성 문제는 캐시 정합성 작업할 때 겪었던 패턴 그대로, Redis는 게이트키퍼고 진실의 원천은 DB라는 원칙으로 풉니다."

Q. 결제 취소 시 쿠폰 복구는 어떻게 처리해야 하나요?

"단순 상태 토글이 아니라 정책 분기입니다. 만료된 쿠폰은 그대로 살리지 않고 별도 보상 정책을 태우고, 만료 전이라면 RESTORED가 아닌 ISSUED로 되돌립니다. 모든 복구는 coupon_audit_log에 actor와 reason과 함께 남겨서 CS와 정산이 추적할 수 있게 합니다. 결제 시스템과 쿠폰 시스템 사이는 분산 트랜잭션 대신 결제 이벤트를 Kafka로 흘리고, 컨슈머에서 멱등키 기반으로 복구를 적용합니다."

Q. 포인트 잔액 조회가 매우 잦은데 어떻게 받쳐줄 건가요?

"잔액은 point_transaction의 합이 진실이지만, 매 조회마다 합산하면 비쌉니다. point_balance 캐시 테이블 + Redis 캐시 두 단을 두고, 읽기는 StampedLock의 tryOptimisticRead로 락 없이 받습니다. 쓰기 시점에만 writeLock으로 캐시를 무효화하고 Redis도 expire합니다. 캐시가 어긋나면 야간 배치가 트랜잭션 합계로 재계산해서 보정합니다."

Q. 쿠폰 적용 우선순위는 어떻게 정하나요?

"메뉴 단위 → 카테고리 → 주문 총액 → 멤버십 등급 → 포인트 → 결제수단 순으로 고정합니다. 같은 레벨에서 여러 장이 있으면 priority 필드로 결정하고, 중복 가능 여부는 stackable_with 그룹으로 제어합니다. 결제 화면에서 미리보는 할인 금액과 결제 시점 최종 할인 금액이 달라지면 사고로 이어지니, 견적 계산기와 적용 계산기는 같은 정책 엔진을 호출하도록 일원화합니다."

체크리스트

  • coupon_issue.status는 RESERVED를 포함한 5상태 머신인가
  • 쿠폰 사용은 조건부 UPDATE(CAS)인가, SELECT-then-UPDATE가 아닌가
  • coupon_use_log에 (order_id, coupon_issue_id) UNIQUE가 있는가
  • 선착순 재고 차감은 Redis 원자 명령으로 분리되어 있는가
  • 발급 메시지 큐는 publisher confirm + at-least-once인가, 컨슈머는 멱등인가
  • 1인 1매 제약이 Redis와 DB 양쪽에 모두 걸려 있는가
  • 환불·복구가 만료 여부와 CS 정책에 따라 분기되는가
  • 모든 상태 변경이 감사 로그에 actor와 reason까지 남는가
  • 포인트는 point_transaction 단일 진실원 + 잔액 캐시 구조인가
  • 포인트 차감에 (order_id, type) UNIQUE 멱등키가 걸려 있는가
  • 견적 계산기와 적용 계산기가 같은 정책 엔진을 호출하는가
  • CS 어드민이 직접 SQL을 치지 않도록 정형 복구 메뉴가 있는가
  • 정합성 보정 배치가 야간에 잔액·쿠폰 상태를 재계산하는가
on this page
  • 01왜 중요한가
  • 02도메인 모델 — 4개 축을 분리한다
  • 03핵심 정합성 원칙 — 멱등키와 상태 전이
  • 04선착순 이벤트 — Redis가 정답인 이유
  • 05정책 엔진 — 우선순위와 중복 가능 규칙
  • 06포인트 — 가용/적립예정/만료 분리
  • 07나쁜 예 vs 개선 예
  • 나쁜 예 — 쿠폰 사용을 SELECT 후 UPDATE로 처리
  • 개선 예 — 조건부 UPDATE + 멱등 로그
  • 나쁜 예 — 환불 시 쿠폰 단순 복구
  • 개선 예 — 정책에 따른 복구
  • 08동시성 — 어디서 어떤 도구를 쓰는가
  • 09감사 로그와 CS 운영
  • 10로컬 실습 환경
  • 11실행 가능한 예제 — 선착순 발급 컨트롤러
  • 12면접 답변 프레이밍
  • 13체크리스트

댓글 (0)