바우처 시스템 설계: 고부하 상황에서 사용 횟수 제한
1. 개요
“10만 할인, 1,000회만 사용 가능”한 바우처는 단순해 보입니다. 하지만 플래시 세일이 시작되면 수만 개의 요청이 동시에 들어옵니다. 이때 시스템은 1,000회 대신 1,050회를 발급하기 쉬우며, 초과분은 실제 손실입니다. 이 글은 완전한 사례 연구를 통해 요구 사항, 데이터 모델, API를 거쳐 동시성 문제, 일관성, 갑작스러운 부하라는 어려운 점들을 순차적으로 해결합니다. 이 글을 읽고 나면, atomic UPDATE만으로 충분할 때와 Redis와 Kafka를 도입해야 할 때를 알게 될 것입니다.
2. 요구 사항
2.1. 기능
- 코드, 할인액, 최소 주문 금액, 유효 기간이 있는 바우처 생성.
- 총 사용 횟수와 고객당 사용 횟수 제한.
- 체크아웃 시 바우처는 예약(reserve)됩니다. 결제 성공 시 확인(confirm)되고, 주문 취소 또는 기한 초과 시 반환(release)됩니다.
2.2. 규모 및 제약 조건
- 약 1,000만 명의 고객. 플래시 세일 바우처는 1,000회이지만 초기 몇 초 동안 초당 50,000개의 요청을 받을 수 있습니다.
- reserve API의 p99 레이턴시는 100ms 이하.
- 절대 초과 판매 금지. 임시 과소 판매는 허용됩니다. 예를 들어, 한 번이 예약되었다가 15분 후 반환되는 경우.
- 재시도된 요청(retry)은 추가 횟수를 소비하면 안 됩니다. 즉, API는 orderId에 따라 멱등성(idempotent)이어야 합니다.
3. 데이터 모델
데이터베이스(PostgreSQL)는 최종 소스입니다:
CREATE TABLE voucher (
code VARCHAR(32) PRIMARY KEY,
discount_amount BIGINT NOT NULL,
min_order_amount BIGINT NOT NULL DEFAULT 0,
total_limit INT NOT NULL,
used_count INT NOT NULL DEFAULT 0,
per_user_limit INT NOT NULL DEFAULT 1,
starts_at TIMESTAMP NOT NULL,
ends_at TIMESTAMP NOT NULL,
CONSTRAINT ck_used CHECK (used_count <= total_limit)
);
CREATE TABLE voucher_redemption (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
voucher_code VARCHAR(32) NOT NULL REFERENCES voucher(code),
customer_id BIGINT NOT NULL,
order_id VARCHAR(64) NOT NULL UNIQUE,
status VARCHAR(16) NOT NULL, -- RESERVED, CONFIRMED, RELEASED
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_voucher_customer UNIQUE (voucher_code, customer_id)
);
주목할 몇 가지 사항:
CHECK (used_count <= total_limit)는 최후의 안전망입니다. 코드에 버그가 있어도 데이터베이스는 초과 판매를 허용하지 않습니다.order_id UNIQUE는 멱등성을 보장합니다: 한 주문은 한 번만 사용할 수 있습니다.UNIQUE (voucher_code, customer_id)는per_user_limit = 1일 때만 유효하며, 이것이 가장 일반적입니다. 고객당 여러 회를 허용하려면 이 제약 조건을 제거하고 별도 테이블에서 카운트합니다.
4. API
POST /vouchers/{code}/reservations body: {customerId, orderId, orderAmount}
201 {orderId, discountAmount}
409 {error: "OUT_OF_STOCK" | "ALREADY_USED"}
422 {error: "NOT_ELIGIBLE"} (기한 만료, 최소 주문 금액 미달)
POST /vouchers/{code}/reservations/{orderId}/confirm
DELETE /vouchers/{code}/reservations/{orderId}
orderId는 클라이언트(주문 서비스)가 생성합니다. 따라서 재시도할 때 클라이언트는 동일한 orderId를 다시 보내고 서버는 이것이 중복 요청임을 인식합니다.
5. 단순한 방식과 왜 작동하지 않는가
@Transactional
public boolean redeemNaive(String code) {
Voucher v = voucherRepository.findById(code).orElseThrow();
if (v.getUsedCount() >= v.getTotalLimit()) {
return false;
}
v.setUsedCount(v.getUsedCount() + 1);
return true;
}
이는 고전적인 “check-then-act” 오류입니다. 두 개의 스레드가 모두 usedCount = 999를 읽으면, 둘 다 여유가 있다고 보고, 모두 1000을 기록합니다. 결과적으로 두 명의 고객이 할인을 받지만, 카운터는 1만 증가합니다. 기본 READ COMMITTED 격리 수준에서 트랜잭션은 이 경우를 구할 수 없습니다.
6. 해결책 1: 데이터베이스에서 조건부 UPDATE
가장 간단한 수정 방법은 검사와 쓰기를 하나의 atomic 명령어로 결합하는 것입니다:
@Service
public class VoucherService {
private final JdbcTemplate jdbc;
public VoucherService(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Transactional
public boolean redeem(String code, long customerId, String orderId) {
int updated = jdbc.update("""
UPDATE voucher SET used_count = used_count + 1
WHERE code = ?
AND used_count < total_limit
AND CURRENT_TIMESTAMP BETWEEN starts_at AND ends_at
""", code);
if (updated == 0) {
return false; // 횟수가 다 났거나 바우처가 만료됨
}
jdbc.update("""
INSERT INTO voucher_redemption(voucher_code, customer_id, order_id, status)
VALUES (?, ?, ?, 'CONFIRMED')
""", code, customerId, orderId);
return true;
}
}
위 코드는 다음과 같이 작동합니다:
- UPDATE 문은 바우처 행을 잠급니다. 데이터베이스는 잠금을 얻은 후
used_count < total_limit을 다시 평가하므로, 두 요청이 동시에 마지막 회차를 통과할 수 없습니다. updated == 0은 조건이 거짓임을 의미합니다: 횟수가 다 났거나 바우처가 만료되었습니다.- 고객이 바우처를 이미 사용했거나
orderId가 다시 전송되면, INSERT 문은DuplicateKeyException을 throw합니다. 트랜잭션이 롤백되고,used_count는 이전 값으로 되돌아갑니다. 컨트롤러 레이어에서 이 예외를409 ALREADY_USED로 매핑합니다.
6.1. 병렬 부하 테스트
@JdbcTest
@Import(VoucherService.class)
@Transactional(propagation = Propagation.NOT_SUPPORTED)
class VoucherServiceTest {
@Autowired JdbcTemplate jdbc;
@Autowired VoucherService voucherService;
@BeforeEach
void setUp() {
jdbc.update("DELETE FROM voucher_redemption");
jdbc.update("DELETE FROM voucher");
jdbc.update("""
INSERT INTO voucher(code, discount_amount, total_limit, starts_at, ends_at)
VALUES ('FLASH100', 100000, 10, DATEADD('DAY', -1, CURRENT_TIMESTAMP),
DATEADD('DAY', 1, CURRENT_TIMESTAMP))
""");
}
@Test
void shouldNeverExceedTotalLimit() throws Exception {
AtomicInteger success = new AtomicInteger();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 200; i++) {
long customerId = i;
executor.submit(() -> {
if (voucherService.redeem("FLASH100", customerId, "order-" + customerId)) {
success.incrementAndGet();
}
});
}
}
Integer used = jdbc.queryForObject(
"SELECT used_count FROM voucher WHERE code = 'FLASH100'", Integer.class);
assertThat(success.get()).isEqualTo(10);
assertThat(used).isEqualTo(10);
}
}
@Transactional(propagation = NOT_SUPPORTED)는 @JdbcTest의 기본 트랜잭션을 비활성화합니다. 그렇지 않으면 setUp에서 생성한 데이터가 커밋되지 않아 다른 스레드에서 볼 수 없습니다. 200개의 virtual thread가 10회를 두고 경쟁하며, 결과는 항상 정확히 10입니다:
success = 10, used_count = 10
6.2. 해결책 1의 한계
이 방법은 올바르며, 대부분의 일반 바우처(초당 수백 개의 요청)의 경우 충분합니다. 문제는 부하가 단일 행(hot row)에 집중될 때만 발생합니다. 초당 50,000개의 요청이 모두 FLASH100 행의 잠금을 기다리는 큐에 들어갑니다. 각 트랜잭션이 잠금을 몇 밀리초 동안 유지하므로, 실제 처리량은 초당 수천 개로 제한됩니다. 나머지는 타임아웃되고, 연결 풀이 고갈되며, 바우처와 무관한 테이블을 포함한 데이터베이스 전체가 느려집니다.
7. 해결책 2: Redis를 Lua 스크립트로 “게이트웨이” 역할
아이디어: 남은 횟수 카운터를 Redis에 저장합니다. Redis는 단일 스레드로 실행되며, 각 Lua 스크립트는 atomic하게 실행되고 몇 마이크로초만 소요됩니다. 자리를 예약한 요청만 데이터베이스로 계속 진행됩니다. 1,000회 바우처의 경우, 데이터베이스는 50,000회 대신 최대 1,000회 쓰기만 받습니다.
의존성:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
예약 스크립트, resources/scripts/reserve.lua에 저장:
-- KEYS[1] = voucher:{code}:remaining
-- KEYS[2] = voucher:{code}:users (hash customerId -> 사용 횟수)
-- KEYS[3] = voucher:{code}:orders (hash orderId -> customerId)
-- ARGV[1] = customerId, ARGV[2] = perUserLimit, ARGV[3] = orderId
if redis.call('HEXISTS', KEYS[3], ARGV[3]) == 1 then return 0 end -- 재시도, 이미 예약함
if redis.call('EXISTS', KEYS[1]) == 0 then return -3 end -- 바우처가 아직 로드되지 않음
local used = tonumber(redis.call('HGET', KEYS[2], ARGV[1]) or '0')
if used >= tonumber(ARGV[2]) then return -2 end -- 고객이 이미 사용함
if tonumber(redis.call('GET', KEYS[1])) <= 0 then return -1 end -- 사용 횟수 없음
redis.call('DECR', KEYS[1])
redis.call('HINCRBY', KEYS[2], ARGV[1], 1)
redis.call('HSET', KEYS[3], ARGV[3], ARGV[1])
return 1
키는 해시 태그 {code}를 사용합니다. 이 덕분에 Redis Cluster에서 세 개의 키가 모두 같은 슬롯에 있고 스크립트가 실행될 수 있습니다.
Java 측:
@Component
public class VoucherGate {
public enum Result { RESERVED, DUPLICATE, OUT_OF_STOCK, ALREADY_USED, NOT_LOADED }
private static final RedisScript<Long> RESERVE =
RedisScript.of(new ClassPathResource("scripts/reserve.lua"), Long.class);
private final StringRedisTemplate redis;
public VoucherGate(StringRedisTemplate redis) {
this.redis = redis;
}
public Result reserve(String code, long customerId, int perUserLimit, String orderId) {
String p = "voucher:{" + code + "}:";
Long r = redis.execute(RESERVE,
List.of(p + "remaining", p + "users", p + "orders"),
String.valueOf(customerId), String.valueOf(perUserLimit), orderId);
return switch (r.intValue()) {
case 1 -> Result.RESERVED;
case 0 -> Result.DUPLICATE;
case -1 -> Result.OUT_OF_STOCK;
case -2 -> Result.ALREADY_USED;
default -> Result.NOT_LOADED;
};
}
}
DUPLICATE는 성공으로 간주됩니다: 클라이언트가 재시도하면 이전 결과를 정확히 받습니다. NOT_LOADED는 바우처가 “warm up”되지 않았음을 의미합니다. SET ... NX를 사용하여 DB에서 지연 로드할 수도 있고, 더 좋은 방법은 job을 통해 판매 시작 전에 미리 로드하는 것입니다:
redis-cli SET "voucher:{FLASH100}:remaining" 1000
추가 최적화: remaining이 0이 되면, API 게이트웨이 또는 로컬 캐시 플래그(TTL 수 초)가 Redis를 호출하지 않고 즉시 OUT_OF_STOCK을 반환할 수 있습니다. 수만 개의 “늦게 도착한” 요청이 가장 바깥쪽 계층에서 중단됩니다.
8. Redis와 데이터베이스 간 일관성
Redis는 이제 결정을 내리는 곳이지만, 데이터베이스가 돈에 관한 진실입니다. 두 가지 동기화 방법이 있습니다.
방식 A: Redis 후 즉시 DB에 동기적으로 기록. 섹션 6에서와 같이 UPDATE 문을 계속 실행합니다. DB 오류 발생 시, release 스크립트를 호출하여 Redis에 횟수를 반환합니다. 이 방법은 간단하고 일관성이 더 엄격하며, 횟수가 적을 때 적합합니다.
방식 B: DB를 완전히 분리하고 싶을 때 Kafka를 통해 이벤트 푸시:
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
public record VoucherReserved(String code, long customerId, String orderId) {}
@Component
public class RedemptionConsumer {
private final JdbcTemplate jdbc;
public RedemptionConsumer(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@KafkaListener(topics = "voucher-reserved", groupId = "voucher-writer")
@Transactional
public void on(VoucherReserved e) {
int inserted = jdbc.update("""
INSERT INTO voucher_redemption(voucher_code, customer_id, order_id, status)
VALUES (?, ?, ?, 'RESERVED')
ON CONFLICT (order_id) DO NOTHING
""", e.code(), e.customerId(), e.orderId());
if (inserted == 1) {
jdbc.update("UPDATE voucher SET used_count = used_count + 1 WHERE code = ?", e.code());
}
}
}
서비스 측에서 Lua가 RESERVED를 반환한 후, kafkaTemplate.send("voucher-reserved", code, event)를 호출하며, 키는 code입니다. 컨슈머의 특징은 다음과 같습니다:
- Kafka는 at-least-once를 보장하므로, 동일한 이벤트가 두 번 도착할 수 있습니다.
ON CONFLICT (order_id) DO NOTHING은 두 번째를 no-op으로 변환하고,used_count는 새 행이 실제로 삽입될 때만 증가합니다. - 제약 조건
CHECK (used_count <= total_limit)는 여전히 존재합니다. Redis가 잘못된 숫자로 로드되면, DB가 거부하고 이벤트는 운영자가 처리할 수 있도록 dead-letter 토픽으로 이동합니다.
참고: Redis가 성공적으로 예약했지만 Kafka 전송이 실패하면, 그 회차는 Redis에서 “중단됩니다”. 이는 과소 판매이며 돈 면에서는 안전하지만 정리가 필요합니다. 따라서 조정 작업이 필요합니다(섹션 8.1).
8.1. 만료된 예약 해제 및 조정
고객이 바우처를 예약한 후 장바구니를 포기하면 그 회차는 반환되어야 합니다. 정기적인 작업이 이를 처리합니다:
@Scheduled(fixedDelay = 60_000)
public void releaseExpired() {
List<Map<String, Object>> expired = jdbc.queryForList("""
UPDATE voucher_redemption SET status = 'RELEASED'
WHERE status = 'RESERVED' AND created_at < CURRENT_TIMESTAMP - INTERVAL '15 minutes'
RETURNING voucher_code, customer_id, order_id
""");
expired.forEach(row -> voucherGate.release(
(String) row.get("voucher_code"),
((Number) row.get("customer_id")).longValue(),
(String) row.get("order_id")));
}
UPDATE ... RETURNING (PostgreSQL) 문은 상태를 변경하면서 동시에 변경된 행의 목록을 반환합니다. 따라서 두 개의 인스턴스가 동시에 작업을 실행해도 중복 해제되지 않습니다: 이미 RELEASED로 전환된 행은 더 이상 조건과 일치하지 않습니다. DB에서 해당 used_count를 줄여야 합니다. release 함수는 역의 Lua 스크립트를 호출합니다: HDEL orders orderId가 1을 반환할 때만 INCR remaining과 HINCRBY users customerId -1을 실행합니다. 따라서 release를 두 번 호출해도 안전합니다.
각 판매 후 실행되는 조정 작업을 추가합니다: DB의 total_limit - used_count를 Redis의 remaining과 비교하고, 불일치하면 경고합니다.
9. 흔한 실수와 사용하지 말아야 할 때
- Java에서 GET을 한 후 DECR을 두 개의 별도 명령으로 사용합니다. 이것은 다시 check-then-act 문제이며, Redis로 옮길 뿐입니다. 검사와 뺄셈은 같은 Lua 스크립트 내에 있어야 합니다.
- DECR만 사용한 후 음수이면 INCR로 되돌립니다. 이 방법은 작동하지만, 값이 음수인 순간 다른 요청이 잘못된 값을 읽을 수 있습니다. 또한 release 작업을 더 복잡하게 만듭니다.
- hot 바우처에 Optimistic locking(@Version) 사용. 50,000개의 요청이 한 행을 두고 경쟁할 때, 거의 모두 실패하고 재시도합니다. 부하는 pessimistic lock보다 더 심합니다.
- Redis에서 persistence/replication을 비활성화합니다. Redis가 재시작되어 데이터가 손실되고 warm-up job이
total_limit에서 다시 로드되면, oversell이 발생합니다. 항상 DB의total_limit - used_count에서 다시 로드하되,RESERVED상태인 회차를 추가로 빼야 합니다. - 모든 바우처에 Redis + Kafka 적용. 일반 바우처, 낮은 부하는 해결책 6만 필요합니다. 두 개의 시스템을 추가하는 것은 데이터가 불일치할 수 있는 두 곳을 추가하는 것입니다. 표시된 바우처에만 “플래시 세일 모드”를 활성화합니다.
참고: 위의 모든 계층은 최적화일 뿐입니다. 데이터베이스의 제약 조건(CHECK, UNIQUE)이 회차가 초과되지 않음을 보장하는 최종 보장입니다.
10. 결론
바우처 사용 횟수 제한 문제는 본질적으로 동시 환경에서의 atomic 카운팅 문제입니다. 중간 정도의 부하에서는 UPDATE ... WHERE used_count < total_limit 문과 unique 제약 조건만으로 충분히 정확하고 빠릅니다. 플래시 세일과 hot row가 있을 때는 Redis Lua 스크립트를 atomic 게이트웨이로 사용합니다. Kafka는 DB 쓰기를 hot path에서 분리하는 데 도움이 되고, 만료되는 예약 메커니즘과 조정 작업은 불일치 경우를 처리합니다. 전체 원칙은: 임시 과소 판매는 허용하되, 절대 초과 판매하지 않으며, 데이터베이스를 최종 보호 계층으로 유지하는 것입니다.