왜 선착순 쿠폰 발급 구조를 다시 고민했나
선착순 쿠폰 이벤트는 정해진 시간에 한정된 수량의 쿠폰을 먼저 요청한 사람에게 발급하는 마케팅 이벤트다.
이벤트가 시작되면 수천~수만 명의 사용자가 동시에 요청을 보낸다.
이때 시스템이 해결해야 할 문제는 단순하지 않다.
같은 사용자에게 두 번 발급하면 안 되고, 재고보다 많이 발급해서도 안 된다.
네트워크 재시도나 중복 클릭 같은 예외 상황에서도 결과가 일관되어야 한다.
기존 시스템은 Redis를 중심으로 이 문제를 해결하고 있었다.
Redis의 원자 연산으로 중복과 재고를 빠르게 체크하고, ZSet으로 선착순 순서를 관리했다.
사용자에게는 즉시 응답을 주고, 실제 발급은 백그라운드 스케줄러가 처리하는 구조였다.
빠른 응답과 동시성 제어라는 측면에서는 잘 동작했다.
하지만 운영 관점에서 보면 몇 가지 구조적 한계가 있었다.
스케줄러가 멈추면 대기열 처리가 같이 멈추고, 처리 중 실패한 요청을 추적하거나 재처리하기 어려웠다.
Kafka를 도입해 발급 처리의 안정성과 확장성을 보강하는 방향을 정리해 보았다.
시나리오
1. 이벤트 목적
- • 특정 시점에 선착순 쿠폰을 한정 수량만 발급하는 마케팅 이벤트다.
- • 이벤트 시작 직후 수많은 사용자가 동시에 몰릴 수 있는 구조다.
- • 중복 발급, 마감 이후 발급, 재고 초과 발급은 반드시 방지되어야 한다.
- • 발급이 즉시 이루어지지 않더라도 큰 문제가 되지는 않는다. (단, 최대한 빠른 발급은 필요)
- • 정확하고 일관된 발급 처리가 더 중요하다.
2. 기술적 요구사항
| 항목 | 요구 조건 |
|---|---|
| 대용량 트래픽 대응 | 이벤트 시작 직후 수천~수만 건의 동시 요청을 처리할 수 있어야 함 |
| 정합성 보장 | 한 사용자당 한 장만 발급되도록 하고, 재고 초과 또는 마감 이후의 발급은 발생하지 않아야 함 |
| 중복 처리 방지 | 네트워크 재시도, 중복 클릭 등 다양한 중복 요청에도 일관된 결과가 보장되어야 함 |
| 처리 지연 허용 | 사용자 경험을 크게 해치지 않는 범위 내에서 일정 수준의 지연은 허용될 수 있음 |
| 모니터링 및 복구 가능성 | 발급 처리 중 문제가 발생하더라도 원인 추적 및 재처리가 가능해야 함 |
한눈에 보기
| 처리 단계 | Kafka 도입 여부 | 설명 |
|---|---|---|
| 1. 사용자 요청 수신 | Redis 유지 | Redis로 중복 및 재고 확인 후 즉시 응답 |
| 2. 발급 요청 대기열 저장 | Redis 유지 | Redis ZSet으로 선착순 큐 등록 |
| 3. 발급 요청 전송 | ✅ Kafka Producer | 1차 검증 완료된 요청을 Kafka 토픽으로 발행 |
| 4. 발급 처리 로직 | ✅ Kafka Consumer | Kafka 메시지 수신 후 중복/재고 확인 및 발급 수행 |
| 5. 발급 성공 기록 및 이벤트 발행 | ✅ Kafka Consumer | DB 저장 후 발급 완료 이벤트 발행 |
| 6. 실패/예외 처리 | ✅ Kafka DLQ | DLQ로 이관 후 재시도 또는 분석 대응 |
| 7. 발급 완료 후 Redis 상태 갱신 | ✅ Kafka Consumer | 발급 완료 이벤트 기반으로 Redis 갱신 처리 |
AS-IS: Redis를 활용한 선착순 쿠폰 발급
기존 시스템은 Redis를 중심으로 설계된 2단계 처리 구조다.
아래 이미지를 통해 전체 흐름을 표현하였다.
▶ 로직 1: 사용자 요청 처리 (즉시 응답 중심)
쿠폰 발급 요청
사용자가 쿠폰 발급을 요청한다.
중복 발급 여부 확인
Redis SISMEMBER를 통해 이미 발급된 사용자인지 확인한다.
발급 요청을 대기열에 저장
문제가 없으면 Redis ZADD로 대기열에 저장한다.
요청 성공 응답 즉시 반환
"쿠폰 발급 요청이 접수되었어요. 발급 완료 시 앱 푸시로 알려드릴게요!"
▶ 로직 2: 실제 발급 처리 (스케줄러 기반)
대기열에서 요청 꺼내기
백그라운드 스케줄러가 일정 간격으로 Redis ZPOPMIN으로 요청을 꺼낸다.
중복 발급 여부 확인
Redis SISMEMBER로 다시 한 번 확인한다.
재고 차감 + 발급자 기록
DECR로 재고 차감, 성공 시 SADD로 발급자 정보를 기록한다.
쿠폰 발급 내역 DB 저장
발급 이력을 DB에 영속 저장한다.
기존 구조의 장점
| 항목 | 설명 |
|---|---|
| ⚡ 빠른 응답 | Redis는 인메모리 기반이라 사용자 요청에 빠르게 응답 가능 |
| 🔒 동시성 제어 | Redis의 원자 연산 (SISMEMBER, ZADD, DECR)으로 정합성 확보 |
| 🧵 부하 완급 조절 | 스케줄러가 발급 요청을 적절히 소화 가능 (트래픽 버퍼 역할) |
| 🎯 선착순 정렬 | ZSet의 score(timestamp) 기반 정렬로 정밀한 선착순 처리 가능 |
기존 구조의 한계
| 항목 | 설명 |
|---|---|
| ⏱ 발급 시점 불확실 | 스케줄러 주기에 따라 실제 발급 시점이 달라진다. 사용자는 요청 후 언제 발급될지 알 수 없다. |
| 🔄 실패 추적과 재처리 어려움 | Redis 큐에서 꺼낸 뒤 처리가 실패하면, 해당 요청의 상태를 추적하거나 재처리하기 어렵다. 리플레이, DLQ 같은 구조가 없다. |
| 🧱 스케줄러 확장 한계 | 스케줄러가 단일 인스턴스 중심이면 장애 시 처리가 멈추고, 여러 인스턴스로 늘릴 경우 중복 처리와 락 관리가 복잡해진다. |
| 💾 Redis 대기열 내구성 한계 | Redis 설정에 따라 장애 발생 시 대기열 데이터가 유실될 수 있고, 메시지 처리 이력과 실패 원인 추적이 Kafka보다 약하다. |
TO-BE: Redis + Kafka를 활용한 선착순 쿠폰 발급
개선된 시스템은 기존 Redis의 강점을 유지하면서,
Kafka를 통합해 분산 처리, 확장성, 안정성을 강화한 구조다.
Redis, Kafka, DB의 역할 분리
Kafka를 도입한다고 해서 선착순 판단 자체를 Kafka에 맡기는 것은 아니다.
Kafka는 파티션 단위 순서만 보장하기 때문에 전체 요청의 전역 순서를 보장하기 어렵다.
따라서 선착순 후보 선정과 재고 선점은 Redis의 원자 연산으로 처리하고,
Kafka는 선별된 발급 요청을 안정적으로 전달하고 재처리 가능한 구조를 만들기 위해 사용한다.
Redis
- 빠른 중복 요청 차단
- 재고 선점
- 선착순 후보군 관리
- 사용자에게 즉시 응답하기 위한 1차 상태 저장
Kafka
- 실제 발급 요청을 안정적으로 비동기 전달
- Consumer 확장을 통한 처리량 확보
- 실패 메시지 재시도/DLQ/추적 가능성 확보
DB
- 최종 발급 이력 저장
- 사용자별 중복 발급 방지 (유니크 제약)
- 쿠폰 재고 차감의 최종 정합성 보장
서로 다른 시스템에 대한 작업이므로 중간 실패 가능성을 고려해야 한다.
Redis에는 등록됐지만 Kafka 발행에 실패한 요청이 생길 수 있기 때문에,
발행 상태를 함께 기록하거나, Redis ZSet을 기준으로 미발행 요청을 재스캔하는 보정 프로세스가 필요하다.
운영 안정성을 더 높이려면 DB Outbox 패턴처럼 발행 대상 이벤트를 영속 저장한 뒤
Kafka로 전달하는 방식도 고려할 수 있다.
▶ 로직 1: 사용자 요청 접수 (Redis 기반 1차 필터링)
중복 확인
Redis SISMEMBER를 통해 이미 발급된 사용자인지 확인한다.
대기열 등록
Redis ZADD로 발급 요청을 ZSet에 저장해 선착순 순서를 보장한다.
Kafka 발행
1차 검증과 선착순 후보 등록이 완료된 요청을 Kafka 토픽(coupon.issue.request)으로 발행한다.
즉시 응답
요청 접수 결과를 사용자에게 빠르게 반환한다.
▶ 로직 2: Kafka 기반 비동기 발급 처리
이벤트 수신
KafkaConsumer가 요청 메시지를 수신한다.
중복/재고 확인
DB에서 최종 중복 여부 및 재고 상태를 확인한다.
발급 처리
유효 요청에 대해 재고 차감 및 발급 이력 저장 (트랜잭션 처리).
결과 이벤트 발행
발급 결과에 따라 내부 이벤트 발행 (성공, 마감, 오류 등).
▶ 로직 3: 발급 결과 후처리 (이벤트 기반)
- Redis 갱신 — 발급 성공 시 Redis
coupon:issued추가, 마감 시coupon:available비활성화 - 알림 전송 — 앱 푸시 등 사용자 피드백 제공
- 모니터링/로깅 — Slack 등 관리자 알림 및 에러 로깅
▶ 예외 및 DLQ 처리
DLQ는 DB 장애, 메시지 역직렬화 실패, 외부 시스템 장애처럼 재시도 후에도 처리할 수 없는 시스템 예외를 대상으로 사용한다.
- 자동 재시도 — 시스템 예외 발생 시 KafkaConsumer가 설정된 횟수만큼 재시도
- DLQ 전송 — 재시도 후에도 처리할 수 없는 메시지는 Dead Letter Queue로 이동
- 문제 추적 및 재처리 — DLQ 기반 문제 분석 및 수동/자동 재처리 가능
개선된 구조의 장점
| 항목 | 설명 |
|---|---|
| 📈 트래픽 분산 처리 | Kafka 기반 비동기 구조로 발급 처리 부하를 분산하고 안정적으로 소화 |
| ♻️ 실패 복구 가능성 | Kafka 재시도 및 DLQ를 통해 장애 발생 시 복구 가능 |
| 🧩 유연한 후처리 | 이벤트 기반 구조로 Redis 갱신, 알림 등 확장 용이 |
| 🔍 추적 가능성 | 메시지 기반 구조이므로 처리 이력, 실패 원인, 처리 시간 등을 추적할 수 있음 |
Kafka 시스템 설계 — 조금 더 나아가기
아래 내용은 예상되는 대규모 트래픽 환경에서
Kafka를 어떻게 효과적으로 설정하고 운영할 수 있을지에 대한 가정 기반의 설계다.
1. 파티션 수 설정
설계 가정
이벤트 시작 직후 최대 5만 TPS 발생 가능성
설계 제안
초기에는 12~16개 파티션을 고려하고, 실제 Consumer 처리량과 DB 부하를 보고 조정한다.
근거:
- • Kafka 자체는 파티션 하나당 약 1,000~10,000 TPS 처리가 가능하지만,
Consumer 측 DB 트랜잭션 비용이 병목이 될 수 있다. - • 따라서 파티션당 실 처리량은 1,000~3,000 TPS 수준으로 보수적으로 가정한다.
- • 병렬 소비의 최소 단위는 파티션 수 → 컨슈머 확장성과 직접 연결됨
- • 지나치게 많은 파티션은 클러스터 관리 복잡도를 높이므로, 초기에는 12~16개로 시작하고 모니터링 결과에 따라 조정한다.
- • 단, 파티션 수 증설은 key 기반 라우팅과 순서 보장에 영향을 줄 수 있으므로,
이벤트 진행 중보다는 사전 산정 후 고정하는 것이 안전하다.
2. 파티션 키 전략
설계 제안
eventId + userId 조합을 기본 파티션 키로 사용
근거:
- •
userId만 키로 사용하면 같은 사용자의 모든 이벤트 요청이 같은 파티션으로 모인다. - •
eventId + userId를 사용하면 이벤트 단위로 요청을 분산하면서도,
같은 이벤트 안에서 동일 사용자의 중복 요청은 같은 파티션으로 모을 수 있다. - • 전체 순서 보장이 반드시 필요한 영역은 Redis ZSet의 score를 기준으로 판단하고,
Kafka는 분산 처리와 Consumer 측 멱등성 보장을 조합하는 방식이 현실적이다.
3. 컨슈머 그룹 구성
설계 제안
단일 컨슈머 그룹(coupon-issue-group) 구성, 파티션 수에 맞춰 인스턴스 배포
근거:
- • Kafka는 파티션당 하나의 컨슈머 인스턴스만 메시지를 소비함
- • 예: 파티션 8개에 컨슈머 10개 배포 시 2개는 유휴 상태
- • 병렬성 = 파티션 수 → 컨슈머 수가 이를 초과해도 처리량 증가 없음
- • 발급 처리 전담 컨슈머 그룹과 후처리용 그룹(알림, Redis 갱신 등)을 분리해 로직 간 영향도 최소화
4. 리밸런싱 대응
발생 조건
• 컨슈머 인스턴스 수 변경
• 장애 및 heartbeat 누락
• 파티션 수 변경, poll 지연 등
대응 방안:
- • 이벤트 기간에는 Consumer 인스턴스 수를 고정해 리밸런싱을 줄이고,
이벤트 종료 후에는 일반 트래픽 수준에 맞춰 스케일인한다. - • 즉, 이벤트 스케줄과 연동해 사전 확장 → 이벤트 중 고정 → 종료 후 축소하는 운영 절차가 필요하다.
- • Kafka 2.4 이상이면
group.instance.id활용 → incremental rebalancing으로 리밸런싱 중단 시간 최소화 - • 롤링 배포 적용으로 리밸런싱 최소화
| 설정 키 | 권장 값 | 설명 |
|---|---|---|
session.timeout.ms |
30000 | 30초 |
heartbeat.interval.ms |
10000 | 10초 |
max.poll.interval.ms |
300000 | 5분 |
5. Kafka 주요 설정값
| 설정 키 | 권장 값 | 설명 |
|---|---|---|
acks |
all | 리더 + ISR 복제 완료 후 응답 (데이터 유실 방지) |
retries |
Integer.MAX | delivery.timeout.ms 범위 내에서 가능한 만큼 재시도.예를 들어 delivery.timeout.ms가 120초라면, 120초 안에서 재시도 후 최종 실패 처리됨 |
delivery.timeout.ms |
120000 | 전체 전송 타임아웃 제한 (2분) |
max.in.flight.requests |
1~5 | 메시지 순서 보장 (idempotence=true일 때 5, 필요시 1로 낮춤) |
enable.idempotence |
true | Producer → Broker 구간에서 동일 메시지가 중복 기록될 가능성을 줄임. 단, 전체 쿠폰 발급의 Exactly-Once를 보장하지는 않으므로 Consumer 멱등 처리와 DB 유니크 제약이 함께 필요함 |
enable.auto.commit |
false | 수동 커밋으로 처리 완료 후 오프셋 커밋 |
auto.offset.reset |
earliest | 컨슈머 그룹에 커밋된 오프셋이 없을 때 가장 오래된 메시지부터 읽기 시작 |
consumer.max.poll.records |
10~100 | poll당 메시지 수 제한 (트랜잭션 부하 제어 목적) |
linger.ms |
5~10 | 메시지를 즉시 보내지 않고 짧게 모아 배치 전송해 처리량을 높임 |
batch.size |
32768~65536 | Producer 배치 크기. 대량 트래픽에서는 기본값보다 크게 잡아 전송 효율을 높일 수 있음 |
compression.type |
lz4 또는 snappy | 네트워크 사용량을 줄이고 처리량을 높이기 위해 압축 사용 |
운영 중 실수로
group.id가 변경되면 기존 메시지를 처음부터 재소비할 수 있으므로,배포 시 group.id 변경 여부를 반드시 확인해야 한다.
6. Dead Letter Queue (DLQ) 및 모니터링 전략
- • 실패 메시지는
coupon.issue.dlq토픽으로 이관해 추후 수동/자동 재처리 - • DLQ 메시지에는 원본 topic, partition, offset, consumer group, 실패 사유, 실패 시간을 헤더에 함께 기록한다.
- • DLQ 발생 시 Slack 알림을 보내 운영자가 즉시 인지할 수 있게 한다.
- • 재처리 시에는 기존 발급 이력과 DB 유니크 제약을 기준으로 멱등성을 보장한다.
- • Prometheus + Grafana로 Kafka 브로커 상태, 컨슈머 lag, 리밸런싱 이벤트 실시간 모니터링
'Programming > 프로그래밍 내용 정리' 카테고리의 다른 글
| 수학 공식으로 점철된 SQL 수십 줄, PostGIS 함수 한 줄로 정리하다 (0) | 2026.01.16 |
|---|---|
| Apache Kafka 톺아보기 (0) | 2025.05.30 |
| [ 보고서 ] Redis 도입을 통한 선착순 쿠폰 발급 시스템 개선 (0) | 2025.05.23 |
| [ 보고서 ] 동시성 이슈 - 비관적 락 vs 낙관적 락 (0) | 2025.04.22 |
| [ 보고서 ] 정렬 인덱스, 필터 인덱스, 그 조합이 만드는 쿼리의 운명 (0) | 2025.04.17 |