blog post cover

수천 명이 동시에 눌러도 overselling 0건 — Redis ZSet과 MySQL 조건부 UPDATE의 이중 재고 경로

CODIN

Redis ZSet의 ZPOPMIN 하나로 “재고 차감”과 “티켓 번호 부여”를 원자적으로 해결하고, MySQL 조건부 UPDATE와 Unique Constraint로 이중/삼중 안전장치를 구성하여 overselling 0건을 달성한 과정을 공유합니다.


들어가며

티켓 오픈 순간, 수천 명의 사용자가 동시에 “참여하기” 버튼을 누릅니다. 100장의 재고에 1,000명이 동시에 접근하면, 시스템은 다음 세 가지를 동시에 보장해야 합니다.

  1. 100장 초과 판매(overselling) 없이 정확히 100명만 성공
  2. 각 참여자에게 고유한 티켓 번호(1~100)를 부여
  3. 같은 사용자의 중복 참여 차단

단순해 보이지만, 동시성이 개입하는 순간 이 세 조건을 모두 만족시키는 것은 쉽지 않습니다. 이 글에서는 Codin 티케팅 서비스에서 Redis ZSet과 MySQL을 조합하여 이 문제를 해결한 설계 과정을 공유합니다.

기술 스택: Spring Boot 3 · Java 21 · Redis (ZSet) · MySQL · JPA


배경

도메인 구조

Codin은 인천대학교 학생들을 위한 티케팅 플랫폼입니다. 관리자가 이벤트를 생성하면 재고(Stock)가 할당되고, 학생들은 티켓을 선착순으로 발급받습니다.

도메인 구조

핵심 제약 조건은 두 가지입니다.

  • Stock.remaining_stock은 0 미만이 되어서는 안 됩니다 (overselling 방지)
  • Participation(event_id, user_id)는 유일해야 합니다 (중복 참여 방지)

DB만으로 관리한다면

MySQL만으로 재고를 차감하는 가장 단순한 접근을 생각해봅니다.

-- 1. 재고 확인
SELECT remaining_stock FROM stock WHERE event_id = 1;  -- 결과: 1

-- 2. 재고 차감
UPDATE stock SET remaining_stock = remaining_stock - 1 WHERE event_id = 1;

1번과 2번 사이에 다른 트랜잭션이 끼어들면, 두 트랜잭션 모두 remaining_stock = 1을 읽고 각각 차감합니다. 결과적으로 재고가 -1이 되는 overselling이 발생합니다.

이를 방지하는 방법은 조건부 UPDATE입니다.

UPDATE stock SET remaining_stock = remaining_stock - 1
WHERE event_id = 1 AND remaining_stock > 0;

WHERE remaining_stock > 0 조건이 MySQL의 행 잠금(row-level lock) 아래에서 평가되므로, 재고가 0인 상태에서 차감은 일어나지 않습니다. Overselling은 해결됩니다.

하지만 이 접근에는 두 가지 한계가 있습니다.

한계설명
티켓 번호 부여UPDATE 한 줄로는 “몇 번 티켓인지” 알 수 없습니다. 별도의 채번 로직이 필요합니다.
성능모든 요청이 같은 행의 잠금을 획득해야 하므로, 동시 요청이 직렬화됩니다.

문제 정의

정리하면, 티케팅 시스템은 세 가지 요구사항을 하나의 요청 처리 안에서 만족해야 합니다.

#요구사항구체적 조건
1재고 정합성remaining_stock이 0 미만이 되지 않아야 합니다
2성능수천 건의 동시 요청을 밀리초 단위로 처리해야 합니다
3티켓 고유성각 참여자에게 1번부터 시작하는 고유한 번호를 부여해야 합니다

1번과 3번을 DB 잠금으로 해결하면 2번(성능)이 희생됩니다. 1번과 2번을 Redis DECR로 해결하면 3번(번호 부여)에 추가 로직이 필요합니다.

세 조건을 동시에 만족시키는 자료구조가 필요했습니다.


해결 과정

대안 비교

방식재고 정합성성능번호 고유성비고
비관적 락 (SELECT FOR UPDATE)OXX모든 요청 직렬화
낙관적 락 (version 기반)OX충돌 시 재시도 비용
Redis DECROOX번호 부여 별도 관리 필요
Redis ZSet ZPOPMINOOO원자 연산 하나로 해결

핵심 발견: ZPOPMIN은 두 문제를 동시에 해결합니다

Redis ZSet(Sorted Set)은 각 멤버에 score를 부여하고, score 순으로 정렬된 상태를 유지합니다. ZPOPMIN은 가장 낮은 score를 가진 멤버를 원자적으로 제거하고 반환합니다.

이벤트 생성 시 ZSet을 이렇게 초기화합니다.

ZADD {event:1}:available 1 "1" 2 "2" 3 "3" ... 100 "100"

재고 100장이 score 1~100으로 저장됩니다. 이 상태에서 ZPOPMIN을 호출하면:

  • 재고 차감: ZSet에서 멤버가 하나 제거됩니다 (100 → 99)
  • 번호 부여: 제거된 멤버의 값(“1”, “2”, …)이 곧 티켓 번호입니다

하나의 원자 연산으로 “재고 차감”과 “티켓 번호 부여”가 동시에 완료됩니다. ZSet이 비어있으면 null을 반환하므로 overselling도 자연스럽게 방지됩니다.

3중 방어선 설계

ZPOPMIN만으로도 핵심 문제는 해결되지만, Redis와 MySQL 사이의 정합성을 보장하고 엣지 케이스를 차단하기 위해 3중 방어선을 구성했습니다.

3중 방어선 아키텍처

방어선역할기술
1차빠른 재고 차감 + 번호 부여Redis ZPOPMIN
2차DB 레벨 overselling 차단MySQL UPDATE WHERE remaining > 0
3차동일 사용자 중복 참여 차단UNIQUE(event_id, user_id)

구현

1차 방어: Redis ZSet — 원자적 재고 선점

이벤트가 생성되면 ZSet에 티켓 번호 1~N을 초기화합니다.

// RedisEventService.java
private static final String AVAILABLE_TICKETS_KEY_PREFIX = "{event:%d}:available";

public void initializeTickets(Long eventId, int totalTickets) {
    String key = generateKey(eventId);
    eventRedisTemplate.delete(key);

    for (int i = 1; i <= totalTickets; i++) {
        eventRedisTemplate.opsForZSet().add(key, String.valueOf(i), i);
    }
}

키 형식은 {event:%d}:available입니다. 중괄호 {}는 Redis Cluster의 해시 태그로, 같은 이벤트의 모든 키가 동일한 슬롯에 배치되도록 보장합니다.

참여 요청이 들어오면 ZPOPMIN으로 가장 낮은 번호를 원자적으로 꺼냅니다.

// RedisEventService.java
public Integer getTicket(Long eventId) {
    String key = generateKey(eventId);
    ZSetOperations.TypedTuple<String> ticketNumber =
        eventRedisTemplate.opsForZSet().popMin(key);

    if (ticketNumber == null || ticketNumber.getValue() == null
        || ticketNumber.getValue().equals("-1")) {
        throw new TicketingException(TicketingErrorCode.SOLD_OUT);
    }

    return Integer.parseInt(ticketNumber.getValue());
}

popMin()은 Redis의 ZPOPMIN 명령을 래핑합니다. Redis는 싱글 스레드로 명령을 처리하므로, 수천 개의 동시 요청이 들어와도 ZPOPMIN은 정확히 하나의 멤버만 반환합니다. 두 요청이 같은 번호를 받는 일은 구조적으로 불가능합니다.

취소 시에는 ZADD로 해당 번호를 ZSet에 다시 추가합니다.

// RedisEventService.java
public void returnTicket(Long eventId, int ticketNumber) {
    String key = generateKey(eventId);
    eventRedisTemplate.opsForZSet().add(key, String.valueOf(ticketNumber), ticketNumber);
}

ZPOPMIN으로 빠져나간 번호가 ZADD로 다시 들어가므로, 취소된 번호는 다음 참여자에게 재발급됩니다. ZSet의 score 기반 정렬 덕분에 반환된 번호는 자연스럽게 올바른 위치에 삽입됩니다.

2차 방어: MySQL 조건부 UPDATE

Redis에서 번호를 획득한 후, MySQL에서도 재고를 차감합니다.

// StockRepository.java
@Modifying
@Query("update Stock s set s.remainingStock = s.remainingStock - 1 "
     + "where s.event.id = :eventId and s.remainingStock > 0")
int decrementStockByEventId(Long eventId);

WHERE s.remainingStock > 0 조건이 MySQL 행 잠금 하에서 평가되므로, 설령 Redis 장애 등으로 정합성이 깨지더라도 DB 레벨에서 overselling을 차단합니다. 정상 운영 중에는 Redis ZSet과 MySQL 재고가 동기 상태이므로, 이 조건에 걸리는 시나리오는 발생하지 않습니다. Redis가 1차 관문, MySQL이 2차 안전망입니다.

3차 방어: Unique Constraint

Participation 엔티티에 (event_id, user_id) 복합 유니크 인덱스가 설정되어 있습니다.

// Participation.java
@Entity
@Table(
    name = "ticketing_participation",
    indexes = {
        @Index(name = "idx_event_profile",
               columnList = "event_id, user_id", unique = true)
    }
)
public class Participation extends BaseEntity {

    @Column(name = "user_id", nullable = false, length = 24)
    private String userId;

    @Column(name = "ticket_number", nullable = false)
    private Integer ticketNumber;

    // ...
}

동일 사용자가 같은 이벤트에 두 번 INSERT를 시도하면, 애플리케이션 레벨의 중복 체크를 우회하더라도 DB가 DataIntegrityViolationException을 발생시킵니다.

3중 방어선 결합

세 계층이 ParticipationService.createParticipation()에서 결합됩니다.

// ParticipationService.java
private ParticipationResponse createParticipation(Event event,
                                                  UserInfoResponse userInfo) {
    try {
        // ── 1차 방어: Redis ZSet ZPOPMIN ── 번호 획득 + 재고 선점
        Integer ticketNumber = redisEventService.getTicket(event.getId());

        // ── 2차 방어: MySQL 조건부 UPDATE ── DB 재고 차감
        ticketingService.decrement(event.getId());

        Participation participation = Participation.builder()
                .event(event)
                .ticketNumber(ticketNumber)
                .userInfoResponse(userInfo)
                .build();

        // ── 3차 방어: INSERT ── Unique Constraint 검증
        Participation saved = participationRepository.save(participation);

        // 참여 생성 이벤트 발행 (캐시 갱신, 실시간 재고 push 등)
        eventPublisher.publishEvent(new ParticipationCreatedEvent(saved));

        return ParticipationResponse.of(saved);
    } catch (DataIntegrityViolationException dup) {
        // Unique 위반 = 이미 참여한 사용자 → 기존 참여 반환
        return participationRepository
                .findByUserIdAndEventIdAndNotCanceled(
                    userInfo.getUserId(), event.getId())
                .map(ParticipationResponse::of)
                .orElseThrow(() -> dup);
    }
}

이 메서드가 호출되기 전에, saveParticipation()에서 이벤트 상태 검증과 기존 참여 여부를 먼저 확인합니다. 캐시(Redis) → DB 순으로 조회하여 이미 참여한 사용자는 티켓을 소모하지 않고 기존 결과를 반환합니다.

// ParticipationService.java
@Transactional
public ParticipationResponse saveParticipation(Long eventId) {
    UserInfoResponse userInfo = userClientService.fetchUser();
    Event event = findEvent(eventId);

    if (event.getEventStatus() != EventStatus.ACTIVE) {
        throw new TicketingException(TicketingErrorCode.EVENT_NOT_ACTIVE);
    }

    // 캐시 → DB 순으로 기존 참여 확인
    return findParticipationResponse(userInfo.getUserId(), event.getId())
            .orElseGet(() -> createParticipation(event, userInfo));
}

전체 흐름을 시퀀스 다이어그램으로 정리하면 다음과 같습니다.

시퀀스 다이어그램


검증

부하 테스트: 1,000명 동시 burst

100장의 재고에 1,000명이 동시에 참여를 시도합니다. k6의 shared-iterations executor로 1,000 VU가 각 1회씩, 동시에 요청을 보냅니다. Micrometer Timer로 각 방어 계층의 레이턴시를 계측했습니다.

항목
동시 사용자1,000 VU
총 소요 시간2.7초
성공 (티켓 발급)100건
매진 (SOLD_OUT)900건
에러0건

정합성 검증은 k6 종료 후 자동화된 SQL + Redis 쿼리로 수행합니다.

검증 항목기대값실제값
참여 수 (DB)100100
고유 티켓 번호 수100100
남은 재고 (DB)00
남은 재고 (Redis ZSet)00

Overselling 0건, 정합성 100%.

방어 계층별 레이턴시

레이턴시 측정 결과

계층p50p95p99
1차: Redis ZPOPMIN1.10ms4.01ms6.98ms
2차: MySQL UPDATE34.5ms55.9ms64.3ms
전체39.5ms61.5ms78.2ms

Redis ZPOPMIN이 p95에서도 4ms — 원자적 연산의 이점이 수치로 확인됩니다. MySQL 조건부 UPDATE가 p95 55.9ms로 병목이지만, 이는 2차 안전망의 비용이므로 허용 범위입니다. 전체 p99가 78.2ms이므로, 1,000명 동시 접근에서도 100ms 이내에 참여 처리가 완료됩니다.


결과

실시간 재고 상태 Push

참여가 생성될 때마다 Spring의 ApplicationEventPublisher를 통해 ParticipationCreatedEvent가 발행됩니다. 이 이벤트 기반 구조 위에, Quartz 스케줄러(StockCheckJob)가 주기적으로 MySQL 재고 변화를 감지하여 Redis Stream으로 발행하고, 클라이언트에 SSE(Server-Sent Events)로 실시간 재고 상태를 push합니다.

실시간 재고 Push 아키텍처

StockCheckJobConcurrentHashMap으로 이벤트별 마지막 재고를 추적하며, 변화가 감지된 경우에만 이벤트를 발행합니다. 불필요한 네트워크 트래픽 없이 재고 변동분만 클라이언트에 전달됩니다.

Before / After

항목DB Only (조건부 UPDATE)Redis ZSet + MySQL
Overselling 방지OO
티켓 번호 부여X (별도 구현 필요)O (ZPOPMIN 원자적)
동시성 성능△ (행 잠금 직렬화)O (Redis 싱글 스레드)
중복 참여 차단Unique ConstraintUnique Constraint
취소 후 재발급복잡 (번호 관리 필요)O (ZADD로 반환)
장애 대응단일 경로3중 방어선

마치며

핵심 통찰

이 설계에서 가장 중요한 발견은 자료구조 선택이 곧 아키텍처라는 점입니다. Redis ZSet의 ZPOPMIN이 “재고 차감”과 “번호 부여”라는 두 개의 문제를 하나의 원자 연산으로 해결한다는 것을 인식한 순간, 분산 락이나 별도의 채번 테이블 같은 복잡한 대안들이 모두 불필요해졌습니다.

3중 방어선의 핵심은 각 계층이 독립적으로 정합성을 보장한다는 것입니다. Redis가 장애를 일으켜도 MySQL의 조건부 UPDATE가 overselling을 차단하고, 애플리케이션 레벨의 중복 체크가 실패해도 Unique Constraint가 최종 방어선 역할을 합니다.

향후 과제

  • Redis 장애 시 fallback — 현재 Redis가 불가용하면 참여 자체가 불가합니다. MySQL 기반 fallback 경로를 설계할 필요가 있습니다.
  • 대규모 이벤트 대응 — 재고 수천 장 규모의 이벤트에서 ZSet 초기화 비용과 메모리 사용량에 대한 검증이 필요합니다.
  • 분산 환경 확장 — 다중 인스턴스 환경에서 Redis ZSet의 정합성은 Redis 자체의 싱글 스레드 특성으로 보장되지만, MySQL과의 동기화 실패 시 보상 트랜잭션 설계가 필요합니다.

References