수량 제한이 있는 자원을 대량 요청에서 정확하게 분배하는 문제는 동시성 제어의 대표적인 시나리오다. 선착순 쿠폰 발급은 이 문제를 Kafka 파이프라인 위에서 해결하는 사례다.
동시성 제어 방식 선택
즉시 판정 vs 비동기 처리
수량 제한이 있는 발급에서 동시성을 제어하는 방식은 크게 두 가지로 나뉜다.
즉시 판정 방식은 아래 특성을 가진다.
- 요청 시점에 발급 가능 여부를 즉시 응답한다
- Redis 같은 인메모리 저장소로 원자적 카운터를 구현한다
- 사용자 경험이 좋다. 요청 즉시 성공/실패를 알 수 있다
- 동시성 제어가 복잡하다. 분산 환경에서 원자적 연산을 보장해야 한다
- 적합한 상황: 재고 차감처럼 즉시 결과가 필요한 경우
비동기 처리 방식은 아래 특성을 가진다.
- 요청 접수만 보장하고, 실제 처리는 나중에 한다
- 메시지 큐의 순차 처리로 동시성을 제어한다
- 사용자가 결과를 즉시 알 수 없다. polling이나 callback이 필요하다
- 동시성 제어가 단순하다. 순차 처리 자체가 동시성 해소다
- 적합한 상황: 접수 후 후처리가 가능한 경우
선착순 쿠폰에서의 선택 근거
선착순 쿠폰의 사용자 행동을 보면 아래와 같다.
- 사용자는 발급 '요청'을 한다
- 결과를 즉시 알 필요는 없다
- 잠시 후 결과를 확인하면 된다
이 행동 패턴은 비동기 처리에 자연스럽게 맞는다. 즉시 판정이 필요한 상황이 아니므로, 더 단순한 구조를 선택할 수 있다.
Redis 방식을 선택하면 인메모리 카운터의 원자성, 장애 시 복구, 데이터베이스와의 동기화 등 추가 복잡성이 생긴다. MQ 방식은 이런 복잡성 없이 순차 처리만으로 수량 초과를 방지한다.
요청 — 접수 구조
API 설계
비동기 처리에서 API 응답은 '접수 확인'이지 '처리 결과'가 아니다. HTTP 상태 코드 202 Accepted가 이 의미를 정확히 표현한다.
요청 시 클라이언트가 전달하는 정보는 아래와 같다.
- 쿠폰 템플릿 ID: 어떤 쿠폰을 발급받으려는지
- 요청 ID (선택): 멱등성 보장용. 미전달 시 서버가 생성한다
응답에는 요청 ID와 현재 상태가 포함된다.
예시 코드
// 요청
POST /coupon-issue-requests
{
"couponTemplateId": 1,
"requestId": "client-uuid-123"
}
// 응답
202 Accepted
{
"requestId": "client-uuid-123",
"status": "PENDING"
}멱등성 처리
같은 요청 ID로 여러 번 요청이 들어올 수 있다. 네트워크 재시도, 클라이언트 따닥 등의 상황이다.
요청 ID가 이미 존재하면 기존 요청의 상태를 반환한다. 새로 생성하지 않는다. 이때 요청 ID가 다른 사용자의 것이면 거부해야 한다. 요청 ID가 전역 유일하므로 다른 사용자가 같은 ID를 사용할 확률은 극히 낮지만, 의도적 접근을 방지하기 위해 사용자 ID를 함께 검증한다.
Outbox 연동
요청 저장과 Outbox 이벤트 저장이 같은 트랜잭션에 묶인다. 요청은 PENDING 상태로 저장되고, Outbox에 발급 요청 이벤트가 저장된다. Relay 스케줄러가 이 이벤트를 Kafka로 발행한다.
이 구조에서 요청 저장은 됐는데 이벤트 발행이 실패하는 상황은 발생하지 않는다. Outbox 테이블에 이벤트가 남아 있으므로 스케줄러가 재발행한다.
처리 — 순차 발급
단일 파티션 순차 처리의 원리
쿠폰 템플릿 ID를 파티션 키로 사용하면, 같은 쿠폰에 대한 모든 발급 요청이 한 파티션에 적재된다. 소비자가 이 파티션을 순차 처리하므로, 동시에 같은 쿠폰의 수량을 확인하는 상황이 발생하지 않는다.
이 구조에서 동시성 제어는 아래와 같이 단순해진다.
- 현재 발급 수량을 조회한다
- 최대 수량과 비교한다
- 미초과면 발급한다, 초과면 거부한다
이 세 단계가 순차적으로 실행되므로, 조회와 발급 사이에 다른 요청이 끼어들지 않는다. 데이터베이스 락이나 분산 락이 필요 없다.
발급 처리 상세 흐름
소비자가 발급 요청을 처리하는 전체 흐름은 아래와 같다.
- 멱등 검사: 이벤트 ID가 이미 처리되었는지 확인한다
- 요청 조회: 발급 요청이 실제로 존재하는지 확인한다
- 템플릿 검증: 쿠폰 템플릿이 존재하는지, 삭제되었는지, 만료되었는지 확인한다
- 중복 발급 검사: 같은 사용자가 같은 쿠폰을 이미 발급받았는지 확인한다
- 수량 확인: 현재 발급 수량이 최대 수량에 도달했는지 확인한다
- 발급 또는 거부: 조건을 만족하면 발급하고, 아니면 거부 사유와 함께 거부한다
- 멱등 기록: 이벤트 ID를 처리 완료로 기록한다
각 단계에서 거부가 발생하면 거부 사유를 요청 상태에 기록한다. 사용자가 polling할 때 거부 사유를 확인할 수 있다.
거부 사유의 종류는 아래와 같다.
- 템플릿 미존재: 요청 시점과 처리 시점 사이에 템플릿이 삭제된 경우
- 템플릿 만료: 처리 시점에 만료 기한이 지난 경우
- 중복 발급: 같은 사용자가 이미 발급받은 경우
- 수량 초과: 최대 발급 수량에 도달한 경우
수량 확인 방식
최대 발급 수량은 쿠폰 템플릿에 정의된다. null이면 무제한이다.
수량 확인은 단순한 COUNT 조회로 구현한다.
SELECT COUNT(*) FROM issued_coupon WHERE coupon_template_id = ?이 값이 최대 수량 이상이면 거부한다. 단일 파티션 순차 처리이므로 COUNT 조회와 INSERT 사이에 다른 요청이 끼어들지 않아 정확하다.
다만 이 방식은 처리량이 파티션 하나의 소비 속도에 제한된다. 초당 수만 건 이상의 발급 요청이 몰리는 극단적 상황에서는 Redis 기반 원자적 카운터로 전환하거나, 파티션 수를 늘리고 파티션별로 수량을 나누는 방식을 고려해야 한다.
최대 수량 검증
최대 수량은 쿠폰 템플릿 생성 시 설정된다. 도메인 모델에서 아래를 검증한다.
- null 허용: 무제한 발급
- 양수만 허용: 0이나 음수는 거부
0이 허용되면 생성 즉시 수량이 소진된 쿠폰이 만들어진다. 음수가 허용되면 비정상적인 상태가 영구적으로 고정된다. 두 경우 모두 도메인 레벨에서 차단하는 편이 안전하다.
결과 확인 — Polling 구조
Polling API
사용자는 요청 시 받은 요청 ID로 결과를 조회한다.
예시 코드
GET /coupon-issue-requests/{requestId}
// 대기 중
200 OK { "requestId": "...", "status": "PENDING" }
// 발급 완료
200 OK { "requestId": "...", "status": "ISSUED" }
// 거부
200 OK { "requestId": "...", "status": "REJECTED" }본인 확인
Polling 시 요청 ID만으로 조회하면 다른 사용자의 발급 상태를 엿볼 수 있다. 사용자 ID를 함께 검증하여 본인의 요청만 조회할 수 있게 한다.
기존 동기 발급 API 폐기의 이유
기존에 동기 발급 API가 존재했다면, Kafka 비동기 경로와 공존시키면 안 된다.
- 동기 경로와 비동기 경로가 공존하면 같은 쿠폰에 대해 두 경로에서 동시에 발급할 수 있다
- 수량 제어가 일관성 없어진다. 동기 경로는 데이터베이스 락으로, 비동기 경로는 파티션 순차 처리로 각각 제어하기 때문이다
- 모든 발급을 단일 경로로 통일해야 수량 제어가 정확하다
따라서 비동기 경로를 도입하면 기존 동기 발급 API는 완전히 제거한다.
Polling vs Callback vs SSE
각 방식의 특성
발급 결과를 사용자에게 전달하는 방식은 크게 세 가지가 있다.
Polling은 아래 특성을 가진다.
- 구현이 가장 단순하다
- 클라이언트가 주기적으로 결과를 조회한다
- 불필요한 요청이 발생한다. 아직 처리되지 않았는데 조회하는 경우가 많다
- 서버 상태가 무상태이므로 확장이 쉽다
Callback은 아래 특성을 가진다.
- 서버가 처리 완료 시 클라이언트에 알린다
- 불필요한 요청이 없다
- 클라이언트가 콜백을 수신할 수 있어야 한다. 모바일 앱이면 푸시, 웹이면 웹훅
- 콜백 실패 시 재시도 정책이 필요하다
SSE (Server-Sent Events)는 아래 특성을 가진다.
- 서버가 클라이언트에 단방향 스트림으로 결과를 전달한다
- 실시간성이 좋다
- 연결을 유지해야 하므로 서버 자원이 든다
- 로드 밸런서 설정이 필요하다
선착순 쿠폰에서의 선택
선착순 쿠폰의 처리 지연은 보통 수 초 이내다. 사용자가 몇 초 기다리는 것은 수용 가능하다.
Polling이 가장 단순하고, 서버 구현 변경 없이 클라이언트만으로 구현할 수 있다. 처리 지연이 짧으므로 Polling의 단점(불필요한 요청)도 크지 않다.
SSE나 Callback은 처리 지연이 길거나 실시간 알림이 중요한 경우에 더 적합하다.
정리
선착순 쿠폰 발급의 핵심 판단은 세 가지다.
- 동시성 제어: 즉시 판정이 필요한가, 비동기 처리가 가능한가
- 순차 처리: 같은 대상의 요청이 순서대로 처리되는가
- 결과 전달: 사용자가 결과를 어떻게 확인하는가
이 판단이 정해지면 구조는 자연스럽게 따라온다. 비동기 처리가 가능하면 MQ 기반 순차 처리로 동시성을 해소하고, Polling으로 결과를 전달한다.
'이 요청은 즉시 결과가 필요한가, 아니면 접수 확인만으로 충분한가'