본문 바로가기
Research Notes (개인)

Redis vs MySQL — 선착순 쿠폰 “동시성 발급” 테스트로 본 병목의 본질

선착순 쿠폰 발급은 “정확히 N장만”, “유저 중복 없이”, “짧은 시간에 트래픽 폭발”이 동시에 걸리는 전형적인 동시성 지옥 케이스다.

이번 글은 Redis / MySQL 두 방식으로 동시성 발급 게이트를 구현하고, 동일한 부하 조건에서 어디서 병목이 터지는지를 테스트로 확인한 기록이다.

결론부터 말하면:

  • 3만 건(30,000)까지는 둘 다 “돌긴 돈다”. 다만 Redis가 더 빠르다.
    • MySQL(모두 발급 완료): 약 11초
    • Redis(모두 발급 완료): 약 5초
  • 그 이상부터는 차이가 급격히 커진다.
  • 그리고 더 중요한 현실:
    • MySQL은 정확성을 지키려면 Master DB만 써야 해서 이런 “단시간 폭발성 이벤트”에서는 부하 관점에서 사실상 쓰기 어렵다.
    • Redis는 “왜 Redis를 쓰는가”에 대한 답이 명확해진다: 핫스팟(단일 row/락) 병목을 DB 밖으로 빼서 DB를 보호한다.

1) 요구사항 정리: 선착순 쿠폰에서 진짜 어려운 것

선착순 발급 로직은 결국 아래 3가지를 동시에 만족해야 한다.

  1. 정확히 N명까지만 성공 (초과 발급 0)
  2. 동일 유저 중복 발급 0
  3. 폭주 트래픽을 버티는 처리량 (수만~수십만 요청/초가 순간적으로 몰릴 수 있음)

여기서 1번(정확한 N장)이 핵심이다.

“SELECT로 현재 발급 수 읽고 → if로 체크하고 → UPDATE/INSERT” 같은 순서로 가면 레이스 컨디션으로 초과 발급이 바로 난다.

즉, “슬롯 확보” 자체가 원자적(atomic)이어야 한다.


2) MySQL 방식: 트랜잭션 + 원자적 UPDATE로 슬롯 확보

MySQL 방식의 핵심은 이거다:

  • 발급 슬롯 확보를 UPDATE ... WHERE issued_count < total_quota 같은 형태로 원자적으로 처리
  • 업데이트 성공(1 row)인 경우에만 쿠폰 INSERT

실제 서비스 코드에서 그 의도가 드러난다. (중복 체크 → 슬롯 확보 → 쿠폰 저장)

@Transactional
public Coupon issueFirstComeWithDatabase(String promoCode, String userId) {

    Promotion promo = promotionRepository.findByPromoCode(promoCode)
        .orElseThrow(() -> new IllegalArgumentException("Invalid promotion"));

    // 유저 중복 발급 방지
    if (couponRepository.existsByUserIdAndPromotionId(userId, promo.getId())) {
        throw new IllegalStateException("Already issued");
    }

    // 선착순 슬롯 확보 (동시성 핵심)
    int updated = promotionRepository.tryIncreaseIssuedCount(promoCode);
    if (updated == 0) {
        throw new IllegalStateException("Sold out");
    }

    // 쿠폰 생성 + 저장
    Coupon coupon = new Coupon();
    coupon.setPromotion(promo);
    coupon.setUserId(userId);
    coupon.setCode(UUID.randomUUID().toString());
    coupon.setStatus("ISSUED");
    coupon.setExpiresAt(promo.getEndsAt());
    coupon.setIssuedAt(LocalDateTime.now());

    return couponRepository.save(coupon);
}

MySQL 동시성에서 “진짜 병목”이 생기는 지점

  • tryIncreaseIssuedCount(promoCode)프로모션 1건(row) 을 대상으로 수만 요청이 동시에 업데이트하려고 달려드는 구조다.
  • InnoDB 기준으로는 사실상 단일 row 락 경합이 된다.
  • 즉, 처리량은 결국 “DB가 그 row 업데이트를 초당 몇 번 직렬로 처리하느냐”로 수렴한다.

그리고 여기서 운영 현실이 들어온다:

정확성을 보장하려면 Master DB에서 읽고/쓰고 해야 한다.

Replica에서 프로모션/발급 여부를 읽는 순간, 지연(replication lag) 때문에:

  • 이미 발급된 유저가 “미발급”으로 보일 수 있고
  • issued_count가 덜 증가한 값으로 보일 수 있다

즉, “정확성”을 지키려면 Master로 몰아야 하고, 그러면 이벤트 순간에 Master가 그대로 터진다.

이게 MySQL 방식이 실전에서 애매해지는 가장 큰 이유다.


3) Redis 방식: “게이트”를 Redis로 옮겨서 DB를 보호

Redis 방식은 구조가 다르다.

  • DB는 “정책/정합성 데이터(프로모션 정보)” 조회 정도만 하고
  • 선착순 슬롯 확보는 Redis에서 원자적으로 처리
  • 성공한 요청만 DB에 쿠폰을 저장(혹은 비동기 저장)

서비스 코드 흐름은 아래와 같다.

public Coupon issueFirstComeWithRedis(String promoCode, String userId) {

    // 1. 프로모션 정보는 DB에서 읽기 (여기서는 락 없이 SELECT)
    Promotion promo = promotionRepository.findByPromoCode(promoCode)
        .orElseThrow(() -> new IllegalArgumentException("Invalid promotion"));

    long quota = promo.getTotalQuota();

    // 2. Redis에서 선착순 슬롯 확보 시도
    long gateResult = couponGateRedisService.tryAcquireSlot(promoCode, userId, quota);

    if (gateResult == -1L) {
        throw new IllegalStateException("Already issued by this user");
    }
    if (gateResult == 0L) {
        throw new IllegalStateException("Sold out");
    }

    /*
    // 3. 여기까지 왔으면 이 유저는 '선착순 당첨' 상태
    //    이제 DB에 쿠폰 한 장 INSERT (이건 보통 경쟁이 적음)
    ...
    return couponRepository.save(coupon);
    */

    return null;
}

중요: 업로드된 예시 코드에서는 3번(DB 저장)이 주석 처리되어 있고 null을 리턴한다.

실전에서는 “당첨 확정 후 DB 저장(동기/비동기)”을 반드시 붙여야 테스트도 의미가 생긴다.

Redis 게이트의 핵심: Lua Script(원자성)

Redis 쪽 핵심은 여러 연산을 하나의 원자 동작으로 묶는 것이다.

이 코드는 DefaultRedisScript로 스크립트를 실행하고, 결과값으로 성공/마감/중복을 구분한다.

/**
 * @return 1: 성공, 0: 마감, -1: 이미 발급 받은 유저
 */
public long tryAcquireSlot(String promoCode, String userId, long totalQuota) {
    List<String> keys = List.of(
        issuedCountKey(promoCode),
        userSetKey(promoCode)
    );
    Long result = redisTemplate.execute(
        issueCouponScript,
        keys,
        userId,
        String.valueOf(totalQuota)
    );
    return result != null ? result : 0L;
}

여기서 의도하는 원자 로직은 보통 이런 형태다(개념적으로):

  • 이미 유저가 set에 있으면 1
  • 현재 count가 quota 이상이면 0
  • 아니면 SADD user, INCR count1

이렇게 하면 초과 발급/중복 발급을 Redis 단에서 차단할 수 있고, DB는 “당첨자만 저장”하는 구조로 바뀐다.


4) 테스트 구성: 3만 동시 요청을 어떻게 때렸나

DB 동시성 테스트

  • THREAD_COUNT = 30,000
  • ExecutorService 고정 스레드 풀 300
  • CountDownLatch로 동시에 기다렸다가 완료 측정
  • 테스트 클래스에서 트랜잭션을 끊어(NOT_SUPPORTED) 서비스 메서드의 트랜잭션이 실제 커밋되게 함
@SpringBootTest
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public class CouponDBConcurrencyTest {

    private final int THREAD_COUNT = 30000;
    private final long QUOTA = 30000;

    @Test
    void testConcurrentIssue() throws Exception {
        ExecutorService executor = Executors.newFixedThreadPool(300);
        CountDownLatch latch = new CountDownLatch(THREAD_COUNT);

        AtomicInteger success = new AtomicInteger();
        AtomicInteger failed = new AtomicInteger();

        for (int i = 0; i < THREAD_COUNT; i++) {
            final int idx = i;
            executor.submit(() -> {
                try {
                    couponService.issueFirstComeWithDatabase(PROMO, "USER" + idx);
                    success.incrementAndGet();
                } catch (Exception e) {
                    failed.incrementAndGet();
                } finally {
                    latch.countDown();
                }
            });
        }

        latch.await();
        executor.shutdown();

        Assertions.assertEquals(QUOTA, success.get());
    }
}

Redis 동시성 테스트

  • 동일하게 THREAD_COUNT = 30,000 / 풀 300
  • Redis 카운터 초기화 후 동시 호출
@BeforeAll
void setupRedis() {
    // Redis 카운터 초기화
    redis.delete("promo:" + PROMO + ":count");
    redis.opsForValue().set("promo:" + PROMO + ":count", "0");
}

여기서 바로 지적할 점(중요)

CouponGateRedisService가 쓰는 키는 promo:{code}:issued_count 인데, 테스트 초기화는 promo:{code}:count 를 지우고 있다.

즉, 지금 상태 그대로면 “초기화가 제대로 안 됐을 가능성”이 크다. 키 네이밍을 반드시 맞춰야 한다.

또한 유저 중복 방지용 set(promo:{code}:users)도 같이 초기화하는 게 안전하다. (현재 테스트에는 없음)


5) 결과: 3만까지는 “둘 다 되지만”, 그 이상은 갈라진다

이번 측정에서 공유된 수치는 다음이다.

조건MySQL(마스터 기준)Redis(게이트 기준)
30,000건 모두 발급 완료약 11초약 5초
30,000건 초과차이가 크게 증가상대적으로 유리

3만에서 이미 2배 가까이 차이가 나는데, 진짜 포인트는 그 다음이다.

MySQL은 구조적으로 “단일 row 업데이트 경합”이 곧 한계 처리량이 되면서 급격히 버벅이기 시작한다.


6) 왜 Redis가 유리한가 (속도 말고 “구조”가 다름)

(1) DB 핫스팟을 시스템 밖으로 뺀다

MySQL 방식은 프로모션 1 row에 락이 몰린다.

반면 Redis는 메모리 기반 + Lua 원자 처리로 “슬롯 확보”를 매우 싸게 처리한다.

그리고 DB는 “승자만” 처리하면 된다.

DB QPS/락 경합을 설계 단계에서 줄이는 구조가 된다.

(2) MySQL은 정확성 때문에 Scale-out이 안 된다 (여기서 게임 끝)

Replica를 섞는 순간 정확성 문제가 생기니, 이벤트 순간 트래픽은 결국 Master로 간다.

이건 “최적화로 해결”이 아니라 “구조적 제약”이다.

Redis 게이트는 정확성 요구사항을 Redis에서 충족시키고, DB를 이벤트 트래픽의 정면에서 빼낸다.


7) 운영 관점에서 Redis 설계 시 반드시 챙길 것

Redis가 만능은 아니고, 제대로 하려면 아래는 필수로 고민해야 한다.

  1. Redis Cluster 사용 시 키 해시슬롯
    • Lua script가 2개 키를 동시에 쓰면, Redis Cluster에서는 두 키가 같은 hash slot이어야 한다.
    • 보통 promo:{PROMO}:issued_count, promo:{PROMO}:users 처럼 hash tag를 넣는다.
  2. TTL/정리
    • 이벤트 끝난 키(카운터/유저셋)를 영구 보관하면 메모리만 축난다.
    • 종료 시간 기준 TTL 또는 배치 정리 전략이 필요하다.
  3. DB 저장을 동기로 할지/비동기로 할지
    • 동기: 구현 쉬움. 다만 Redis의 이점(DB 보호)을 일부 깎아먹음.
    • 비동기(추천): Redis에서 당첨 확정 → MQ/Stream에 적재 → 워커가 DB 저장
      • 재시도/중복 방지(Unique key)까지 설계하면 운영에 강해진다.
  4. 정합성(당첨은 됐는데 DB 저장 실패)
    • 이건 “없앨 수” 없고, 어떻게 다룰지 선택해야 한다.
    • 일반적으로는 “Redis 게이트 결과가 진짜”로 두고, DB 저장은 재시도로 eventually consistent하게 맞춘다.

마무리: “Redis를 쓰는 이유”가 성능이 아니라 생존인 경우

3만까지는 MySQL도 된다.

하지만 선착순 쿠폰은 보통 “3만에서 끝”이 아니라, 그 이상을 가정하는 순간 운영 리스크가 폭발한다.

  • MySQL: 정확성 때문에 Master로 몰림 → 이벤트 트래픽 = DB 장애 유발 트래픽
  • Redis: 원자 게이트로 트래픽을 흡수 → DB는 승자만 처리 → 시스템이 버틴다

이 테스트의 결론은 단순히 “Redis가 더 빠르다”가 아니라:

이벤트성 폭주 트래픽에서 DB를 보호하기 위해 Redis 게이트가 설계적으로 유리하다.