플래시 세일 설계: 1초 내 10,000개 상품 판매, 마이너스 재고 없이
1. 개요
플래시 세일은 시스템 설계의 고전적인 과제입니다. 수백만 명이 정확히 같은 순간에 “구매” 버튼을 누릅니다. 재고는 수천 개에 불과합니다. 시스템은 갑작스러운 트래픽 급증에 견디어야 하고, 절대로 재고보다 많이 판매하면 안 됩니다.
이 글은 1초 내에 10,000개 상품을 판매하는 구체적인 사례 연구를 다룹니다. 읽고 나면 요구사항 정의, 데이터 모델과 API 설계, Redis Lua로 원자성 있는 자리 예약, Kafka를 통한 비동기 주문 저장, 고객이 결제하지 않은 경우의 재고 반환 처리 방법을 알게 될 것입니다.
2. 요구사항
2.1. 기능 요구사항
- 각 판매 기간에는 하나 이상의 상품이 있으며, 각 상품은 고정 수량과 판매 가격을 가집니다.
- 로그인한 사용자가 “구매” 버튼을 누르면 다음 중 하나의 결과를 받습니다: 자리 예약 성공, 품절, 또는 이미 구매함.
- 각 사용자는 판매 기간당 1개 상품만 구매할 수 있습니다.
- 예약 주문은 10분 이내에 결제되어야 합니다. 시간을 초과하면 재고에 반환되어 다른 사람이 구매할 수 있습니다.
2.2. 규모
- 재고: 10,000개 상품.
- 참가자: 약 500,000명, 대부분 처음 1~2초 안에 클릭.
- 예상 피크 트래픽: 구매 엔드포인트로 초당 200,000~500,000 요청.
이 중 10,000개만 성공합니다. 98% 이상의 요청이 “품절” 응답입니다. 이것이 핵심입니다: 시스템은 거부를 싸게, 빠르게 처리해야 합니다.
2.3. 제약사항
- 마이너스 재고 없음: 유효한 주문 수는 절대 10,000을 초과할 수 없습니다.
- 과도한 미판매 없음: 예약된 상품이 결제되지 않으면 반드시 반환되어야 합니다.
- 일반 판매 시스템 독립성: 플래시 세일 중에도 일반 판매 시스템에 영향을 주지 않아야 합니다.
3. 전체 아키텍처
단일 관계형 데이터베이스는 같은 행에 대한 UPDATE를 초당 몇 천 개 정도 처리할 수 있습니다. 이는 수백만에 훨씬 못 미칩니다. 따라서 우리는 스트림을 여러 계층으로 분리하고, 각 계층이 다음 계층의 부하를 줄입니다:
Client ──► CDN (정적 판매 페이지, 카운트다운)
│
▼
API Gateway (사용자/IP 기반 레이트 제한, 봇 차단)
│
▼
Flash Sale Service ──► Redis (Lua: 검사 + 재고 차감, 원자성 보장)
│ │ 성공 (~10,000회)
│ ▼
│ Kafka topic "flash-sale-orders"
│ │
▼ ▼
202 Accepted Order Consumer ──► PostgreSQL (최종 진실의 원천)
핵심 아이디어:
- Redis는 게이트키퍼. 재고 차감 작업은 메모리에서 단일 스레드로 실행되므로 원자성이 보장되고 빠릅니다.
- Kafka는 버퍼. 약 10,000개의 성공한 메시지만 큐에 들어갑니다. Consumer는 DB의 처리 속도에 맞춰 DB에 쓰기를 수행합니다.
- PostgreSQL은 안전망. DB의 제약 조건이 Redis에 문제가 생기더라도 마이너스 재고 판매를 방지합니다.
4. 데이터 모델
PostgreSQL에서는 두 개의 테이블이 필요합니다:
CREATE TABLE flash_sale_item (
id BIGINT PRIMARY KEY,
sale_id BIGINT NOT NULL,
product_id BIGINT NOT NULL,
quantity INT NOT NULL,
sold INT NOT NULL DEFAULT 0,
CONSTRAINT chk_not_oversold CHECK (sold <= quantity)
);
CREATE TABLE flash_sale_order (
id UUID PRIMARY KEY,
item_id BIGINT NOT NULL REFERENCES flash_sale_item(id),
user_id BIGINT NOT NULL,
status VARCHAR(16) NOT NULL, -- PENDING, PAID, EXPIRED
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT uq_item_user UNIQUE (item_id, user_id)
);
두 가지 중요한 제약 조건:
chk_not_oversold는sold이quantity를 초과하지 않도록 DB 계층에서 방지합니다.uq_item_user는 “사용자당 1개 상품” 규칙을 적용하면서 동시에 Kafka가 메시지를 다시 전달할 때 Consumer를 멱등성으로 만듭니다.
Redis에서는 각 판매 상품에 두 개의 키를 사용합니다:
{item:1001}:stock -> "10000" (String, 남은 수량)
{item:1001}:buyers -> {userId, ...} (Set, 자리를 예약한 사람들)
{item:1001} 부분은 해시 태그입니다. 덕분에 두 키는 Redis Cluster의 동일한 슬롯에 항상 위치합니다. Lua 스크립트는 모든 키가 같은 슬롯에 있을 때만 실행됩니다.
판매 개시 전에 job이 재고를 Redis에 미리 로드합니다:
redis-cli SET "{item:1001}:stock" 10000
5. API
POST /api/flash-sales/items/{itemId}/purchase
Authorization: Bearer <token>
202 Accepted {"orderId": "c0a8...", "status": "PENDING"}
409 Conflict {"error": "ALREADY_PURCHASED"}
410 Gone {"error": "SOLD_OUT"}
GET /api/flash-sales/orders/{orderId}
200 OK {"orderId": "c0a8...", "status": "PENDING"}
구매 엔드포인트는 201 대신 202를 반환합니다. 주문이 즉시 DB에 기록되지 않기 때문입니다. 클라이언트는 orderId를 보관하고 주문이 결제 준비가 될 때까지 GET 엔드포인트를 폴링합니다. userId는 토큰에서 가져오며 본문에서 받지 않으므로 사용자가 다른 사람을 대신해 구매할 수 없습니다.
6. Redis Lua로 원자성 있는 자리 예약
dependency 추가:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
가장 흔한 실수는 Java에서 GET 다음 DECR을 별도의 두 명령으로 작성하는 것입니다. 두 명령 사이에 수백 개의 다른 스레드가 같은 값 1을 읽고 차감할 수 있습니다. 결과적으로 재고가 음수가 됩니다. Lua 스크립트는 이를 해결합니다. Redis가 전체 스크립트를 하나의 블록으로 실행하고 그 사이에 다른 명령을 끼워 넣지 않기 때문입니다.
파일 src/main/resources/lua/reserve.lua:
-- KEYS[1] = stock, KEYS[2] = buyers, ARGV[1] = userId
if redis.call('SISMEMBER', KEYS[2], ARGV[1]) == 1 then
return -2
end
local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
if stock <= 0 then
return -1
end
redis.call('DECR', KEYS[1])
redis.call('SADD', KEYS[2], ARGV[1])
return stock - 1
스크립트는 사용자가 이미 구매했으면 -2를 반환하고, 품절이면 -1을 반환하며, 그 외에는 차감 후 남은 수량을 반환합니다. 검사와 재고 차감이 동일한 단계에서 발생하므로 경쟁 조건이 없습니다.
재고 반환 스크립트 src/main/resources/lua/release.lua:
-- 사용자가 실제로 자리를 예약한 경우에만 재고를 증가
if redis.call('SREM', KEYS[2], ARGV[1]) == 1 then
return redis.call('INCR', KEYS[1])
end
return -1
조건 SREM == 1은 같은 사람에게 재고를 두 번 반환하는 것을 방지합니다. 이는 만료 job과 주문 취소 스레드가 병렬로 실행될 때 발생하는 흔한 오류입니다.
스크립트를 호출하는 Service:
public enum ReserveResult { SUCCESS, SOLD_OUT, ALREADY_PURCHASED }
@Service
public class StockReservationService {
private static final RedisScript<Long> RESERVE =
RedisScript.of(new ClassPathResource("lua/reserve.lua"), Long.class);
private static final RedisScript<Long> RELEASE =
RedisScript.of(new ClassPathResource("lua/release.lua"), Long.class);
private final StringRedisTemplate redis;
public StockReservationService(StringRedisTemplate redis) {
this.redis = redis;
}
public ReserveResult reserve(long itemId, long userId) {
Long result = redis.execute(RESERVE, keys(itemId), String.valueOf(userId));
if (result == null) throw new IllegalStateException("Redis returned null");
if (result == -2) return ReserveResult.ALREADY_PURCHASED;
if (result == -1) return ReserveResult.SOLD_OUT;
return ReserveResult.SUCCESS;
}
public void release(long itemId, long userId) {
redis.execute(RELEASE, keys(itemId), String.valueOf(userId));
}
private static List<String> keys(long itemId) {
String tag = "{item:" + itemId + "}";
return List.of(tag + ":stock", tag + ":buyers");
}
}
Spring Data Redis는 스크립트를 EVALSHA로 보내고, Redis가 아직 스크립트를 캐시하지 않았으면 자동으로 EVAL로 폴백합니다. SHA를 직접 관리할 필요가 없습니다.
7. Kafka를 통한 비동기 주문 저장
dependency 추가:
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
7.1. Controller: 자리 예약 후 이벤트 발행
public record OrderPlaced(UUID orderId, long itemId, long userId) {}
@RestController
@RequestMapping("/api/flash-sales")
public class PurchaseController {
private final StockReservationService reservation;
private final KafkaTemplate<String, OrderPlaced> kafka;
public PurchaseController(StockReservationService reservation,
KafkaTemplate<String, OrderPlaced> kafka) {
this.reservation = reservation;
this.kafka = kafka;
}
@PostMapping("/items/{itemId}/purchase")
public ResponseEntity<Map<String, Object>> purchase(@PathVariable long itemId,
@AuthenticationPrincipal Jwt jwt) {
long userId = Long.parseLong(jwt.getSubject());
return switch (reservation.reserve(itemId, userId)) {
case SOLD_OUT -> ResponseEntity.status(410).body(Map.of("error", "SOLD_OUT"));
case ALREADY_PURCHASED -> ResponseEntity.status(409).body(Map.of("error", "ALREADY_PURCHASED"));
case SUCCESS -> {
var event = new OrderPlaced(UUID.randomUUID(), itemId, userId);
kafka.send("flash-sale-orders", String.valueOf(itemId), event)
.whenComplete((ok, ex) -> {
if (ex != null) reservation.release(itemId, userId);
});
yield ResponseEntity.accepted()
.body(Map.of("orderId", event.orderId(), "status", "PENDING"));
}
};
}
}
유의해야 할 몇 가지 사항:
- “품절” 요청은 Redis를 한 번 호출한 후 반환합니다. 이것이 98% 요청의 경로입니다.
- Kafka 전송이 실패하면
release를 호출하여 재고를 반환합니다. 이 보정 단계가 없으면 상품이 영구적으로 “유령 예약”이 됩니다. - 메시지의 키는
itemId입니다. 같은 상품의 모든 주문이 같은 파티션으로 들어가므로 순서대로 처리됩니다.
Producer는 메시지 손실을 방지하려면 acks=all과 idempotence를 활성화해야 합니다:
spring.kafka.producer.acks=all
spring.kafka.producer.properties.enable.idempotence=true
spring.kafka.producer.value-serializer=org.springframework.kafka.support.serializer.JsonSerializer
7.2. Consumer: DB는 안전망
@Component
public class OrderConsumer {
private final JdbcTemplate jdbc;
public OrderConsumer(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Transactional
@KafkaListener(topics = "flash-sale-orders", groupId = "order-writer")
public void handle(OrderPlaced event) {
int inserted = jdbc.update("""
INSERT INTO flash_sale_order (id, item_id, user_id, status)
VALUES (?, ?, ?, 'PENDING')
ON CONFLICT DO NOTHING
""", event.orderId(), event.itemId(), event.userId());
if (inserted == 0) return; // 메시지 반복, 이미 처리됨
int updated = jdbc.update("""
UPDATE flash_sale_item SET sold = sold + 1
WHERE id = ? AND sold < quantity
""", event.itemId());
if (updated == 0) {
throw new IllegalStateException("DB stock exhausted for item " + event.itemId());
}
}
}
ON CONFLICT DO NOTHING은 Consumer를 멱등성으로 만듭니다. Kafka는 최소 1회 전달을 보장하므로 동일한 메시지가 두 번 도착할 수 있습니다. 두 번째는 행이 삽입되지 않고 조기에 반환합니다.
UPDATE ... WHERE sold < quantity 문은 DB 계층의 원자성 조건입니다. Redis가 불일치하면 (예: 누군가 잘못된 수를 수동으로 로드하면) 명령이 0을 반환합니다. Transaction이 롤백되고 주문이 생성되지 않습니다. 무한 재시도 대신 에러 핸들러를 구성하여 실패한 메시지를 dead-letter 토픽으로 푸시하고 Redis 재고를 반환해야 합니다.
Consumer는 약 10,000개의 메시지만 수신하고 파티션별로 순차적으로 기록하므로 DB는 초당 수천 개의 transaction을 처리합니다. 이 수준은 완전히 정상입니다.
8. 결제 시간 초과 및 재고 반환
정기적으로 만료된 주문을 검사하는 job:
@Component
public class ExpiredOrderJob {
private final JdbcTemplate jdbc;
private final StockReservationService reservation;
public ExpiredOrderJob(JdbcTemplate jdbc, StockReservationService reservation) {
this.jdbc = jdbc;
this.reservation = reservation;
}
@Scheduled(fixedDelay = 30_000)
@Transactional
public void expire() {
List<long[]> expired = jdbc.query("""
UPDATE flash_sale_order SET status = 'EXPIRED'
WHERE status = 'PENDING' AND created_at < now() - interval '10 minutes'
RETURNING item_id, user_id
""", (rs, i) -> new long[]{rs.getLong(1), rs.getLong(2)});
for (long[] row : expired) {
jdbc.update("UPDATE flash_sale_item SET sold = sold - 1 WHERE id = ?", row[0]);
reservation.release(row[0], row[1]);
}
}
}
UPDATE ... RETURNING은 상태를 변경하고 한 명령어로 주문 목록을 가져옵니다. 두 instance가 동시에 job을 실행해도 각 행은 한 instance만 업데이트합니다. 왜냐하면 조건 status = 'PENDING'은 첫 번째 업데이트 후 더 이상 참이 아니기 때문입니다.
순서 주의: Redis release는 DB 트랜잭션 내에 있지만 트랜잭션에 속하지 않습니다. 트랜잭션 롤백이 release 후 발생하면 Redis에 여분의 단위가 남습니다. 실제로 TransactionSynchronization.afterCommit을 사용하여 commit 후에 release를 분리해야 합니다. 또는 Kafka를 통해 “OrderExpired” 이벤트를 발행할 수 있습니다. 여기의 축약 버전은 단지 설명 목적입니다.
9. 테스트: 절대 마이너스 재고 판매하지 않기
테스트하려는 가장 중요한 동작은: 1,000명이 100개 상품을 놓고 경쟁하면 정확히 100명이 성공해야 합니다. 실제 Redis를 실행하기 위해 Testcontainers를 사용합니다.
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
@Testcontainers
class StockReservationServiceTest {
@Container
static GenericContainer<?> redisContainer =
new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);
@Test
void whenThousandUsersCompeteForHundredItems_thenExactlyHundredSucceed() {
var factory = new LettuceConnectionFactory(
redisContainer.getHost(), redisContainer.getMappedPort(6379));
factory.afterPropertiesSet();
var template = new StringRedisTemplate(factory);
var service = new StockReservationService(template);
template.opsForValue().set("{item:1}:stock", "100");
var successes = new AtomicInteger();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (long userId = 0; userId < 1_000; userId++) {
long id = userId;
executor.submit(() -> {
if (service.reserve(1, id) == ReserveResult.SUCCESS) {
successes.incrementAndGet();
}
});
}
}
assertThat(successes.get()).isEqualTo(100);
assertThat(template.opsForValue().get("{item:1}:stock")).isEqualTo("0");
assertThat(service.reserve(1, 0)).isEqualTo(ReserveResult.ALREADY_PURCHASED);
}
}
virtual thread executor가 있는 try-with-resources 블록은 모든 task가 완료될 때까지 기다립니다. 예상 결과:
successes = 100
stock = "0"
Lua 스크립트를 GET 다음 별도의 DECR로 바꾸면 이 테스트는 불안정하게 실패하고 stock이 자주 음수가 됩니다.
10. 어려운 점과 흔한 오류
Redis 핫 키. 모든 요청이 동일한 키 {item:1001}:stock을 수정합니다. 하나의 Redis 노드는 약 100,000 명령/초를 처리합니다. 더 높은 부하에서는 두 가지 처리 방법이 있습니다:
- 재고 샤딩: 10,000을 10개 키
{item:1001:0}…{item:1001:9}로 분할하고, 각 키 1,000이 다른 노드에 위치합니다. 요청이 임의로 shard를 선택하고 품절하면 다른 shard를 시도합니다. 이 경우 “사용자당 1개 상품” 규칙은userId % 10에 따라 사용자를 고정 shard로 라우팅하거나 DB 계층에서 검사해야 합니다. - 로컬 품절 플래그: Redis가
SOLD_OUT을 보고하면 각 인스턴스가 메모리의AtomicBoolean을 켜고 Redis를 다시 호출하지 않고 즉시 거부합니다.
Redis 이전의 트래픽 급증. 게이트웨이에서의 레이트 제한, CAPTCHA 또는 “가상 대기열”은 대부분의 봇을 차단하는 데 도움이 됩니다. 판매 페이지는 CDN의 정적 페이지여야 합니다. “구매” 버튼은 시간이 되었을 때만 활성화되고, 구매 URL은 스크립트로 조기 클릭을 줄이기 위해 그 때만 반환됩니다.
Redis 데이터 손실. Redis가 failover되고 replica가 마지막 DECR 명령을 수신할 시간이 없으면 Redis의 재고가 실제보다 높을 수 있습니다. 이때 DB의 sold < quantity 조건이 작동합니다. 이 안전망을 절대 제거하면 안 됩니다.
최종 일관성. 사용자가 202를 받지만 주문이 아직 DB에 없습니다. UI는 “처리 중” 상태를 표시하고 폴링해야 하며 즉시 “구매 성공”이라고 보고하면 안 됩니다.
주의: flash sale을 위한 기본 메커니즘으로 재고 행에
SELECT ... FOR UPDATE를 사용하지 마세요. 일관성 측면에서는 맞지만, 모든 트랜잭션이 같은 행 잠금을 위해 대기합니다. 처리량이 초당 수백~수천 요청으로 급락하고 연결 풀이 고갈되며 일반 판매 시스템 전체가 영향을 받습니다.
주의: Redis + Kafka 아키텍처는 정말 큰 트래픽일 때만 가치가 있습니다. 판매 기간의 트래픽이 초당 수백 요청뿐이면 PostgreSQL의
UPDATE ... WHERE sold < quantity한 문장으로 충분합니다. 훨씬 간단하고 두 데이터 저장소 간 동기화 문제가 없습니다.
11. 결론
flash sale 설계는 한 가지 원칙을 중심으로 합니다: 각 계층에서 부하를 필터링하고 엄격한 제약 조건이 있는 단일 진실 공급원을 유지합니다. 이 글에서 당신은 다음을 봤습니다:
- Redis Lua 스크립트가 원자성 있게 재고를 검사하고 차감하며, “품절”을 한 번의 왕복으로만 거부합니다.
- Kafka가 버퍼 역할을 하여 DB는 정확한 수의 성공한 주문만 받습니다.
- PostgreSQL의
UNIQUE,CHECK,UPDATE ... WHERE sold < quantity가 마이너스 재고 판매와 중복 메시지 처리를 방지하는 안전망 역할을 합니다. - Kafka 전송 실패 또는 결제 시간 초과 시 보정 메커니즘, 재고 반환 중복 방지.
실제 적용할 때는 섹션 9의 경쟁 테스트로 시작하세요. 그 다음 첫 번째 판매를 시작하기 전에 실제 피크에 가까운 데이터로 부하 테스트를 실행하세요.