쿠폰과 프로모션은 F&B 커머스에서 매출을 만드는 동시에 정합성 이슈가 가장 자주 터지는 영역이다. "1만 개 한정 50% 할인 쿠폰"이라는 한 줄 기획은 백엔드 입장에서 보면 동시성, 멱등성, 락 전략, 캐시 일관성, 보상 트랜잭션이 한꺼번에 등장하는 종합 문제다. 매장 오픈 이벤트, 앱 푸시 후 1분, 신메뉴 런칭 같은 짧고 강한 트래픽 스파이크에서 한...
쿠폰과 프로모션은 F&B 커머스에서 매출을 만드는 동시에 정합성 이슈가 가장 자주 터지는 영역이다. "1만 개 한정 50% 할인 쿠폰"이라는 한 줄 기획은 백엔드 입장에서 보면 동시성, 멱등성, 락 전략, 캐시 일관성, 보상 트랜잭션이 한꺼번에 등장하는 종합 문제다. 매장 오픈 이벤트, 앱 푸시 후 1분, 신메뉴 런칭 같은 짧고 강한 트래픽 스파이크에서 한 번이라도 초과 발급이 일어나면 회계·CS·마케팅 모두 영향을 받는다.
면접 관점에서도 쿠폰 도메인은 "동시성 문제를 실제로 다뤄봤는가"를 빠르게 검증할 수 있는 주제다. JPA, MySQL, Redis, 분산락, 이벤트 메시징을 한 번에 묶어서 물어보기 좋고, 답변 수준에 따라 시니어인지 미들인지 매우 잘 갈린다. CJ푸드빌처럼 매장/배달/앱 채널이 섞인 곳에서는 한 쿠폰이 여러 채널을 통해 동시에 사용 시도되는 시나리오가 빈번하므로, 기본기 자체가 평가 포인트가 된다.
이 글은 선착순 발급의 race condition, 중복 사용 방지, 발급/사용/복구의 상태 모델, RDBMS와 Redis의 역할 분담, optimistic/pessimistic lock과 분산락의 선택 기준, 그리고 이벤트 오픈 피크 트래픽 대응과 CS 복구까지를 한 번에 정리한다.
쿠폰 도메인을 설계할 때는 세 종류의 객체를 분리해서 생각한다.
policyId, userId, code, status, issuedAt, usedAt, expiredAt 같은 필드를 가진다.couponId, orderId, usedAt, revokedAt을 가진다. 한 쿠폰이 여러 번 사용 가능하다면(스탬프형) 이 객체가 N개가 된다.이 셋을 합쳐 한 테이블로 만들면 처음에는 편하지만, 사용/취소/복구 흐름이 들어오는 순간 상태 컬럼 하나로는 표현이 부족해진다. 면접에서 "쿠폰 테이블 어떻게 설계하시겠어요" 질문이 오면 이 세 객체를 분리한다고 답하면 좋은 출발이 된다.
쿠폰에서 정합성이 깨지는 경우는 크게 두 가지다.
대부분의 쿠폰 사고는 이 둘 중 하나다. "결제 실패했는데 쿠폰만 사용 처리됨" 같은 케이스는 보상 트랜잭션 설계 영역이고, 이건 별도로 다룬다.
가장 단순한 구현은 다음과 같다.
@Transactional
public Coupon issue(Long policyId, Long userId) {
CouponPolicy policy = policyRepository.findById(policyId).orElseThrow();
long issued = couponRepository.countByPolicyId(policyId);
if (issued >= policy.getTotalQuantity()) {
throw new CouponSoldOutException();
}
return couponRepository.save(new Coupon(policyId, userId));
}이 코드는 단일 스레드 환경에서는 잘 동작하지만, 동시 요청 1,000개가 들어오면 거의 확실히 초과 발급이 발생한다. count 시점과 save 시점 사이에 다른 트랜잭션이 끼어들 수 있기 때문이다. REPEATABLE READ 격리 수준이라도 phantom write 자체를 막아주지는 않는다(InnoDB의 gap lock은 SELECT ... FOR UPDATE나 잠금성 쿼리에만 붙는다).
해결 전략은 네 가지다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select p from CouponPolicy p where p.id = :id")
Optional<CouponPolicy> findByIdForUpdate(@Param("id") Long id);@Transactional
public Coupon issue(Long policyId, Long userId) {
CouponPolicy policy = policyRepository.findByIdForUpdate(policyId).orElseThrow();
if (policy.getIssuedCount() >= policy.getTotalQuantity()) {
throw new CouponSoldOutException();
}
policy.increaseIssuedCount();
return couponRepository.save(new Coupon(policyId, userId));
}장점은 명확함과 단순함. 단점은 모든 발급 요청이 한 row의 락을 직렬화하면서 통과한다는 것이다. 1초에 5천 건이 들어오면 락 대기 큐가 길어지면서 커넥션 풀이 고갈되고, 다른 일반 트래픽까지 같이 죽는다. 캠페인이 작거나 트래픽이 비교적 낮은 매장 단위 쿠폰이면 충분히 쓸 수 있다.
원자적 update 한 방으로 끝낸다.
UPDATE coupon_policy
SET issued_count = issued_count + 1
WHERE id = :id
AND issued_count < total_quantity;int updated = jdbcTemplate.update(SQL, policyId);
if (updated == 0) {
throw new CouponSoldOutException();
}UPDATE는 자동으로 row lock을 잡고 조건을 검증한다. update가 0건이면 매진. 락 보유 시간이 매우 짧기 때문에 전략 1보다 훨씬 견딘다. 단, 여전히 단일 row contention이라 초당 수천 건을 노린다면 한계가 온다.
뜨거운 카운터를 Redis로 옮긴다. 이벤트 시작 시점에 SET coupon:policy:1:remaining 10000을 미리 박아두고, 발급 요청은 DECR로 차감한다.
public boolean tryReserve(Long policyId) {
Long remaining = redisTemplate.opsForValue().decrement("coupon:policy:" + policyId + ":remaining");
if (remaining == null || remaining < 0) {
redisTemplate.opsForValue().increment("coupon:policy:" + policyId + ":remaining");
return false;
}
return true;
}DECR는 단일 키에 대해 원자적이다. 따라서 카운터 race는 사라진다. 차감에 성공한 요청만 RDBMS 발급으로 넘어간다. 이때 RDBMS 발급은 카운터를 다시 검증하지 않고, "이미 입장권을 받은 사람"으로 간주하고 row만 만든다.
위험 포인트가 두 개 있다.
DECR는 성공했는데 RDBMS insert가 실패하면 발급 수량이 영구적으로 1 줄어든다. 보상 INCR 또는 outbox/배치 보정이 필요하다.CJ푸드빌처럼 이벤트 오픈 피크가 짧고 강한 곳이라면 전략 3이 사실상 표준이다.
극단적 트래픽이라면 캠페인 시작 전 미리 N개의 빈 쿠폰 row를 만들어두고, 사용자는 그 중 하나를 "내 것으로 클레임"하는 방식으로 바꿀 수 있다. 클레임은 UPDATE coupon SET user_id = :uid WHERE id = ? AND user_id IS NULL. 이건 락 contention이 단일 정책 row가 아니라 N개의 쿠폰 row로 분산되기 때문에 매우 잘 견딘다. 단, 운영이 복잡해진다.
중복 사용은 두 가지 형태로 들어온다.
가장 견고한 1차 방어선은 DB unique key다.
CREATE TABLE coupon_redemption (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
coupon_id BIGINT NOT NULL,
order_id VARCHAR(64) NOT NULL,
used_at DATETIME(6) NOT NULL,
revoked_at DATETIME(6) NULL,
UNIQUE KEY uk_coupon_active (coupon_id, revoked_at)
);revoked_at이 NULL인 동안에는 한 쿠폰에 대해 활성 redemption이 단 하나만 존재할 수 있다. revoke되면 NULL이 아니게 되어 새 사용이 가능해진다. MySQL의 unique index는 NULL을 중복으로 보지 않기 때문에 이 기법이 동작한다.
상태 전이는 Coupon.status로 표현한다.
ISSUED → RESERVED → USED → (REVOKED → ISSUED)
↘ EXPIRED상태 변경은 항상 조건부 update로 한다.
UPDATE coupon
SET status = 'USED', used_at = NOW(6), order_id = :orderId
WHERE id = :couponId
AND status = 'ISSUED'
AND user_id = :userId
AND expired_at > NOW(6);update가 0건이면 이미 사용됐거나 만료됐거나 다른 사람의 쿠폰이다. 이건 optimistic lock의 일종이다. JPA @Version을 쓸 수도 있지만, 실무에서는 위처럼 도메인 조건이 함께 들어가는 명시적 update가 더 안전하다고 본다.
면접에서 자주 비교하는 셋의 기준은 단순히 "성능"이 아니라 "충돌 빈도"와 "락 범위"다.
분산락은 만능이 아니다. Redlock은 노드 fail-over 시점에 안전성 논쟁이 있고(Martin Kleppmann의 비판이 유명하다), TTL 만료와 작업 종료 사이의 race도 있다. "분산락을 걸었으니 안전하다"가 아니라 "분산락 + DB unique key + 상태 전이 update"가 안전하다고 답하는 편이 면접 점수가 더 좋다.
쿠폰 시스템에서 Redis와 RDBMS는 다음처럼 나누는 게 일반적이다.
| 책임 | RDBMS | Redis |
|---|---|---|
| 발급 권한 카운터 | 보조 (배치 보정) | 주 (이벤트 피크) |
| 사용자 보유 쿠폰 목록 | 주 | 캐시 |
| 사용 상태 전이 | 주 (트랜잭션 + unique key) | 보조 (속도 캐시) |
| 멱등키 | 보조 | 주 (SETNX, TTL) |
| 분산락 | DB advisory lock 가능 | 주 (SET NX EX) |
| 환불 시 복구 카운터 | 주 (소스 오브 트루스) | 보조 |
핵심 원칙: 돈이 걸린 정합성은 RDBMS가, 트래픽 흡수는 Redis가. Redis만으로 모든 걸 처리하려 하면 장애 한 번에 회계가 어긋난다. 반대로 RDBMS만 쓰면 이벤트 피크에 못 견딘다. 경계를 분명히 두는 게 시니어다운 답변이다.
한 쿠폰의 라이프사이클을 트랜잭션 경계와 함께 그려본다.
DECR로 입장권 확인 → RDBMS INSERT coupon. 두 단계가 같은 트랜잭션이 아니므로 outbox 또는 보정 배치를 둔다.Coupon.status = ISSUED → RESERVED. 다른 주문에 같은 쿠폰이 들어가지 못하게 짧게 잠근다.RESERVED → USED. coupon_redemption row 생성. 같은 paymentId로 두 번 호출되어도 멱등하게 처리한다.RESERVED → ISSUED. 자동 복구.USED → REVOKED → ISSUED 또는 USED → EXPIRED. 정책에 따라 다르다.이 중 가장 자주 사고가 나는 지점은 3과 4 사이의 PG 콜백 늦음이다. 사용자가 "결제 실패했다"고 보고 다시 시도하는 동안 PG가 늦게 콜백을 보내면 쿠폰이 두 번 사용 처리될 수 있다. 멱등키(Idempotency-Key 헤더 또는 paymentId)로 차단한다.
@Transactional
public OrderResult pay(Long couponId, OrderRequest req) {
Coupon coupon = couponRepository.findById(couponId).orElseThrow();
coupon.markUsed();
PgResponse pg = pgClient.charge(req);
if (!pg.isSuccess()) {
throw new PaymentFailedException();
}
return new OrderResult(pg);
}문제:
public OrderResult pay(Long couponId, OrderRequest req) {
String idemKey = req.getIdempotencyKey();
if (!idempotencyStore.tryClaim(idemKey)) {
return idempotencyStore.getResult(idemKey);
}
reserveCoupon(couponId, req.getOrderId());
PgResponse pg;
try {
pg = pgClient.charge(req);
} catch (Exception e) {
releaseCoupon(couponId, req.getOrderId());
throw e;
}
if (pg.isSuccess()) {
confirmCoupon(couponId, req.getOrderId(), pg.getPaymentId());
} else {
releaseCoupon(couponId, req.getOrderId());
}
OrderResult result = new OrderResult(pg);
idempotencyStore.saveResult(idemKey, result);
return result;
}
@Transactional
void reserveCoupon(Long couponId, String orderId) {
int updated = couponJdbc.updateStatus(couponId, "ISSUED", "RESERVED", orderId);
if (updated == 0) throw new CouponNotUsableException();
}핵심 변화:
RESERVED 상태를 둬서 쿠폰을 임시로 잠그되, 카운터 락은 잡지 않는다.MySQL 8과 Redis를 docker compose로 띄운다.
services:
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: coupon
ports: ["3307:3306"]
command: --transaction-isolation=READ-COMMITTED
redis:
image: redis:7-alpine
ports: ["6379:6379"]스키마.
CREATE TABLE coupon_policy (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100) NOT NULL,
total_quantity INT NOT NULL,
issued_count INT NOT NULL DEFAULT 0,
starts_at DATETIME(6) NOT NULL,
ends_at DATETIME(6) NOT NULL
);
CREATE TABLE coupon (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
policy_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
status VARCHAR(20) NOT NULL,
order_id VARCHAR(64) NULL,
issued_at DATETIME(6) NOT NULL,
used_at DATETIME(6) NULL,
expired_at DATETIME(6) NOT NULL,
UNIQUE KEY uk_user_policy (user_id, policy_id),
KEY ix_policy_status (policy_id, status)
);
CREATE TABLE coupon_redemption (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
coupon_id BIGINT NOT NULL,
order_id VARCHAR(64) NOT NULL,
used_at DATETIME(6) NOT NULL,
revoked_at DATETIME(6) NULL,
UNIQUE KEY uk_coupon_active (coupon_id, revoked_at)
);uk_user_policy가 1인 1쿠폰을 보장한다. 사용자가 동시에 두 번 발급 시도해도 두 번째는 unique 위반으로 떨어진다. DB가 끝까지 책임지는 안전망이 된다.
k6 또는 vegeta로 1,000 RPS를 30초간 쏜다. 첫 번째 구현은 카운트 후 insert, 두 번째 구현은 UPDATE coupon_policy SET issued_count = issued_count + 1 WHERE id = ? AND issued_count < total_quantity.
k6 run --vus 200 --duration 30s issue.jsSELECT COUNT(*) FROM coupon WHERE policy_id = 1 결과를 비교해서 첫 번째에서는 초과 발급이 일어나는지 확인한다. 100개 정원에 110~130개가 박히는 걸 보면 race condition을 눈으로 본 셈이다.
RedisAtomicLong counter = new RedisAtomicLong("coupon:policy:1:remaining", connectionFactory, 100L);
long remaining = counter.decrementAndGet();
if (remaining < 0) { counter.incrementAndGet(); throw new SoldOutException(); }같은 부하로 돌렸을 때 발급 수가 정확히 100인지 확인한다. 추가로 RDBMS insert를 일부러 50% 확률로 실패시켜서 누수가 일어나는지를 관찰하고, 보정 로직을 붙여본다.
한 쿠폰을 두 주문에서 동시에 사용 처리하는 두 스레드 테스트.
ExecutorService es = Executors.newFixedThreadPool(2);
CountDownLatch latch = new CountDownLatch(1);
Future<Boolean> a = es.submit(() -> { latch.await(); return service.use(couponId, "ORD-A"); });
Future<Boolean> b = es.submit(() -> { latch.await(); return service.use(couponId, "ORD-B"); });
latch.countDown();조건부 update 구현이라면 정확히 한 쪽만 true가 나와야 한다. 단순 read-then-write 구현이면 둘 다 true가 나오는 케이스를 재현할 수 있다.
같은 Idempotency-Key로 두 번 결제 요청을 보내고, PG 호출은 한 번만 일어나는지 로그로 확인한다. Redis SET key value NX EX 600 패턴을 쓰면 직관적이다.
REPEATABLE READ라도 락 없이는 동시 insert를 막지 못한다.@Transactional만 붙이면 동시성이 해결된다고 생각 — 트랜잭션 격리는 동시성 제어의 일부일 뿐, race condition을 다 막아주지 않는다.coupon.status = USED만 보고 사용 가능 여부 판단 — 만료, 정책 종료, 매장 제한도 같이 봐야 한다. 조건부 update에 모두 포함시킨다.coupon_redemption 이력이 사라지면 사후 분석이 불가능해진다. 이력 보존이 기본이다.피크 트래픽 대응은 단일 기법이 아니라 입구–카운터–DB–후속처리의 4단 방어다.
DECR 단일 키로 정원 검증. 매진되면 즉시 차단.추가 기법.
CS에서 가장 많이 들어오는 요청 세 가지에 대한 표준 복구.
coupon_redemption.revoked_at을 채우고, coupon.status를 ISSUED로 되돌린다. coupon_policy.issued_count는 건드리지 않는다(이미 발급된 쿠폰이므로). 운영 도구는 항상 이력을 남긴다.EXPIRED. 재사용 가능 정책이면 ISSUED로 되돌리고 expired_at은 그대로 둔다.coupon.status를 REVOKED로. 단, 이미 사용된 쿠폰을 회수하려면 회계와 합의가 필요하다.CS 복구 도구는 반드시 권한 체크와 감사 로그를 강제한다. 운영자가 임의로 쿠폰을 발급/회수할 수 있게 두면 정합성 검증 자체가 무의미해진다.
자주 받는 질문과 답변 골격.
"선착순 쿠폰 발급, 어떻게 설계하시겠어요?"
→ 트래픽 규모를 먼저 묻는다. 초당 수백이면 RDBMS UPDATE ... WHERE issued_count < total 한 방. 초당 수천 이상이면 Redis DECR + RDBMS insert + 보정 배치. 이유는 단일 row contention과 락 보유 시간이다. unique key로 1인 1쿠폰은 DB가 보장한다.
"쿠폰이 두 번 사용되는 사고가 났어요. 원인 분석 어떻게?"
→ 세 군데를 본다. (1) 사용 처리 SQL이 조건부 update인지, (2) coupon_redemption에 unique 인덱스가 있는지, (3) 결제 콜백 멱등 처리가 있는지. 보통 셋 중 둘 이상이 빠져 있다.
"Redis 분산락만으로 충분한가요?" → 충분하지 않다. Redlock의 안전성 논쟁, TTL 만료 race, 노드 fail-over 시점 문제가 있다. 분산락은 처리 직렬화 용도로 쓰고, 정합성 자체는 DB unique key와 조건부 update로 책임진다. "락은 빠르게, 정합성은 DB로"가 원칙이다.
"이벤트 피크에 DB 커넥션 풀이 다 죽었어요. 어떻게?" → 트랜잭션 안에서 외부 호출이 있는지 본다. 락 보유 시간을 줄이는 게 첫 번째. Redis 카운터로 입구를 막는 게 두 번째. 후속 처리를 비동기로 빼는 게 세 번째.
"발급 수량이 회계와 안 맞아요."
→ Redis와 RDBMS 사이의 보정 배치를 의심한다. Redis DECR는 성공했는데 RDBMS insert가 실패한 경로가 있는지, outbox가 멱등하게 동작하는지 확인. 소스 오브 트루스는 RDBMS여야 한다.
답변 시 구체 숫자(초당 RPS, 락 보유 ms, 커넥션 풀 사이즈)를 함께 말하면 경험치가 묻어난다. "이 정도 트래픽에서는 이게, 그 이상에서는 저게"의 구간 감각을 보여주는 게 시니어 답변의 차별점이다.
UPDATE ... WHERE 원자 update와 비관적 락의 trade-off를 비교할 수 있다.DECR 패턴의 부분 실패 시나리오와 보정 전략을 설명할 수 있다.coupon, coupon_redemption, coupon_policy의 책임 분리를 그림 없이 설명할 수 있다.SETNX + TTL)을 코드로 작성할 수 있다.revoke가 단순 status 변경이 아니라 이력 보존을 동반해야 하는 이유를 말할 수 있다.