올리브영 테크블로그 포스팅 올영세일 선착순 쿠폰, 미발급 0%를 향한 여정
Tech

올영세일 선착순 쿠폰, 미발급 0%를 향한 여정

Redis와 Message Queue로 구축한 비동기 시스템의 정합성 개선기

2025.12.15

안녕하세요. 올리브영 쿠폰 시스템 백엔드 개발자 '라이트'입니다.

올리브영에서 행사와 쿠폰을 빼면 고객들의 관심도가 떨어질 만큼 쿠폰은 아주 중요한 요소 중 하나입니다. 특히 선착순 쿠폰은 올영세일 같은 대규모 행사 때마다 핵심 이벤트로 빛을 발하기 때문에, 오픈 시점에 스파이크성의 동시다발적인 대량 트래픽이 발생합니다. 올리브영에서는 여러 차례에 걸쳐 쿠폰 관련 포스트를 게시했습니다. 그 중에서도 쿠폰 발급 RabbitMQ 도입기는 대량 트래픽에도 안정적인 발급 시스템을 구축한 이야기를 다루고 있죠.
이렇게 지속적인 개선으로 시스템 자체의 안정화에 도달했지만, 여전히 비동기 시스템의 단점이 남아 있었습니다. 이런 상황에 쿠폰 미발급 문제를 직면하니 제대로 해결해보자는 강한 의지가 생겼습니다.


문제 상황 - 올영세일에서 마주한 과제

2025년 6월 올영세일, 7일간의 대규모 선착순 쿠폰 행사를 운영하면서 지속적으로 모니터링한 결과 예상치 못한 문제를 발견했습니다. 세일 기간에는 온라인몰에서 동시다발적으로 쿠폰 발급 요청이 발생합니다. 모든 요청의 제한 수량 유효성 검사를 통과하면 Message Queue로 메시지가 발행되고, 발급 워커가 유효성 검사를 또 한 번 진행한 뒤 쿠폰을 최종 발급합니다. 그런데 6월 올영세일에서 발급 워커의 2차 유효성 검사가 실패해, 쿠폰이 발급되지 않은 문제가 발생했습니다. 이로 인해 사용자에게는 발급 성공으로 응답했지만, 실제 워커에서는 발급에 실패했습니다. 이는 곧바로 CS 문의로 이어지는 치명적인 문제였습니다.

2025년 6월 올영세일 선착순 쿠폰 현황

  • 📅 행사 기간: 7일간 (05.31 ~ 06.06)
  • 📊 평균 미발급률: 약 0.014% (10,000건당 약 1.4건 수준)
일차별 미발급률
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1일차 ███     0.021%
2일차 ██      0.008%
3일차 ██████  0.037% 
4일차 ██      0.013%
5일차 ██      0.013%
6일차 █       0.004%
7일차 ████    0.025%

미발급률이 평균 0.014%라는 수치는 통계적으로는 낮아 보일 수 있습니다. 하지만 이는 사용자에게 발급 성공 응답을 보냈음에도 실제 처리가 실패하여 고객 경험의 불일치를 야기하는 심각한 문제였습니다. 특히, 선착순 쿠폰을 기다린 고객에게 브랜드 신뢰도 하락과 CS 처리 비용 증가로 이어지는 치명적인 비즈니스 리스크로 인식되었습니다. 이 미발급 문제를 해결하기 위해 다방면으로 고민하고 개선한 과정을 지금부터 자세히 소개해 드리겠습니다.


비동기 시스템의 단점


비동기 처리는 요청처에 빠르게 응답하여 트래픽 지연을 해소할 수 있는 장점이 있지만, 실제 데이터 처리 결과는 비동기 로직 수행이 완료된 후 알 수 있기 때문에 데이터 일관성을 보장하기 어렵습니다. 성능과 안정성이 중요한 선착순 쿠폰 시스템에서 대량의 트래픽을 감당하기에 MQ 기반 비동기 시스템이 탁월한 방안이었지만, 여전히 개선이 필요한 점들이 남아있었습니다.


선착순 쿠폰 발급 로직 (기존)
선착순 쿠폰 발급 로직 (기존)

기존의 쿠폰 발급 로직을 보면, 발급 수량 유효성 검사에 대한 빠른 처리를 위해 데이터 저장소를 DB 외에 인메모리 데이터 저장소인 Redis를 하나 더 두어 수량을 관리하고 있습니다. 매번 DB를 조회하는 비용이 크기 때문에, 수량 관리 용도로 Redis에 쿠폰 유효 기간만큼의 TTL(Time To Live)을 설정한 쿠폰별/유저별 키를 세팅하고, 쿠폰 발급 요청 시 Redis를 통해 수량을 조회하여 유효성 검사를 수행합니다.


다만, 앞서 말씀드린 바와 같이 실제 데이터 처리는 워커에서 비동기로 수행하기 때문에 1차 유효성 검사 시점과 실제 DB에 반영되어 수량을 처리하는 시점 사이에 필연적으로 시간 간극(Time Gap)이 존재할 수밖에 없는 구조였습니다. 특히, 밀리초(ms) 단위로 다량의 발급 요청이 동시에 들어온다면, 1차 유효성 검사를 통과한 n개의 요청이 모두 발급 성공 응답을 했지만, 일부 요청은 2차 유효성 검사에서 실패하게 되는 문제가 발생했습니다.


문제의 근본 원인

이 문제를 더 깊이 분석해보니 세 가지 핵심 원인이 있었습니다.

1. Time Gap (시간 간극)

T0: Redis GET (count=99) → 통과 ✅
T1: MQ 발행 (10ms)
T2: Worker 수신 (50ms)
T3: Redis GET (count=101) → 실패 ❌  <- T0와 T3 사이의 간극!

첫 번째 검증(T0)과 워커의 재검증(T3) 사이에는 MQ 지연 시간을 포함한 시간 간극이 발생합니다. 이처럼 짧은 순간(예: 100ms)에 수백 건의 요청이 동시에 몰리면서 경합 상태(Race Condition)가 발생하여, T0 검증이 무의미해지는 결과를 낳았습니다.

2. Double Validation Overhead (이중 검증 오버헤드)

[Online Mall]        [Worker]
유효성 검사 1차  ---→  유효성 검사 2차
    (통과)            (실패 가능)

비동기 시스템의 신뢰성을 위해 워커에서 한 번 더 검증하는 것은 필수였지만, 이 시간 간극으로 인해 앞단의 1차 검증 결과가 무의미해지는 상황이 발생했습니다.

3. Redis GET/INCR 원자성(Atomicity) 부재

// 문제가 되는 코드 패턴
val currentCount = redisTemplate.opsForValue().get(key)  // GET
if (currentCount < maxCount) {
    redisTemplate.opsForValue().increment(key)  // INCR (별도 명령)
    // ⚠️ GET과 INCR 사이에 다른 요청이 끼어들 수 있음! (이 짧은 간극 사이에 경합 발생)
}

Redis의 개별 명령('GET', 'INCR')은 원자적으로 동작하지만, 이들을 조합한 'GET'과 'INCR' 로직이 분리되어 수행되면서 문제가 발생했습니다. 즉, 두 명령 사이에 다른 요청이 개입할 수 있게 되어 결과적으로 원자성이 깨지고 수량 오버플로우를 유발했습니다. 이를 해결하기 위해 일부 로직을 변경하며 겪은 시행착오는 다음과 같습니다.


개선 방안


1️⃣ 시도 1: 발급 수량 선차감 (시간 간극 줄이기)

  • 문제점: 기존에는 워커가 수량을 증가(INCR) 처리했기 때문에, 1차 검증(GET)과 실제 수량 반영(INCR) 사이에 MQ 전송 시간까지 포함된 긴 시간 간극이 발생했습니다.
  • 개선 방안: 이 시간 간극을 줄이기 위해 'Redis 발급 수량 증가' 처리를 워커가 아닌, 온라인몰 앞단에서 선차감하도록 로직을 변경했습니다.
  • 실패 처리: 당연히 발급 실패 시 처리도 고려했습니다. 워커에서 실제 DB 처리 중 이슈가 발생하면 선차감했던 수량을 다시 원상복구(Rollback) 하도록 구현했습니다.

선착순 쿠폰 발급 로직 (개선)
선착순 쿠폰 발급 로직 (개선)
  • 결과 및 한계: 이처럼 수량 선차감 로직을 적용한 후 부하 테스트를 진행한 결과, 미발급 건수가 줄기는 했지만 여전히 잔존하는 문제를 발견했습니다. 이는 Time Gap을 줄여도 원자성이 보장되지 않았기 때문입니다. Redis GET과 INCR이 여전히 분리되어 수행되는 한, 경합 상태는 원천적으로 차단될 수 없었습니다.


2️⃣ 시도 2: Lua 스크립트 적용 (원자성 확보)

첫 번째 시도에서 확인했듯이, 근본적인 문제는 Time Gap이 아니라 원자성 부재였습니다. 이 문제를 완전히 해결하려면 Redis의 GET/INCR 명령 수행 간 원자성이 보장되어야 합니다. 그래야 모든 요청이 순차 처리되어 정확한 유효성 검사가 가능해집니다. 이러한 원자성 보장을 위해 저희가 적용을 검토한 방법이 바로 Lua 스크립트입니다.


💡 여기서 Lua 스크립트란?

루아(Lua) 스크립트는 가볍고 빠른 스크립트 프로그래밍 언어로, 다른 시스템(예: 게임 엔진, 서버, 데이터베이스 등)에 내장해서 동작을 확장하거나 자동화할 때 자주 사용됩니다. Redis는 서버 내부에서 Lua 스크립트를 실행할 수 있게 지원하며, 여러 명령을 원자적(atomic) 으로 실행하거나, 복잡한 로직을 네트워크 왕복 없이 한 번에 처리할 수 있습니다.

local current = redis.call('GET', KEYS[1])
if current then
    local num = tonumber(current)
    if num and num < tonumber(ARGV[1]) then
        redis.call('INCR', KEYS[1])
        return true
    else
        return false
    end
else
    redis.call('SET', KEYS[1], 1)
    redis.call('EXPIREAT', KEYS[1], tonumber(ARGV[2]))
    return true
end

참고 문서) Redis Programmability - Scripting with Lua


위의 예시처럼 작성된 스크립트를 하나의 트랜잭션처럼 묶어서 처리하므로 원자성을 보장하지만, Redis는 단일 스레드 구조이기 때문에 스크립트를 수행하는 동안 다른 요청은 모두 대기하게 됩니다. 따라서, 스크립트가 길고 복잡해질수록 성능은 낮아질 수밖에 없습니다. 실제로 스크립트를 적용하여 부하 테스트를 수행한 결과, 미발급/과발급 건은 모두 0건으로 해소되었지만, 기존 대비 약 21% 정도의 성능 저하가 발생하였습니다.

⚠️ 주의사항: Lua 스크립트와 성능

고성능을 요구하는 시스템에서는 Lua 스크립트의 수행 시간을 최소화하고, 반드시 필요한 로직만 원자적으로 처리하도록 구현해야 합니다.


처음부터 원자성을 고려한 스크립트 적용으로 설계가 되었으면 모르겠으나, 이미 선착순 쿠폰 발급에 대한 우수한 성능을 자랑하던 시스템에 성능 저하가 되는 로직을 넣기에는 무리였습니다.

3️⃣ 시도 3: 발급 요청 수량 체크 용도의 별도 Redis Key 추가 관리

이중 카운터 전략

원자성 보장과 성능 유지라는 두 마리 토끼를 잡기 위해 고안한 방법은 바로 이중 카운터(Double Counter) 전략입니다. 이 전략은 실제 쿠폰 발급 제한 수량을 관리하는 키 외에, 발급 요청 수량 관리를 위한 별도의 Redis 키를 추가하여 유효성 검사를 이중화하는 방식입니다.


Redis Key 용도
C001-count 최종 쿠폰 발급 제한 수량 제어 (DB 정합성에 사용)
C001-countReq 쿠폰 발급 요청 수량 제어 (경합 상태 방지)

기존 방식의 문제

Redis 수량 차감 흐름도 (기존)
Redis 수량 차감 흐름도 (기존)

개선된 방식

Redis 수량 차감 흐름도 (개선)
Redis 수량 차감 흐름도 (개선)

왜 이 방법이 효과적인가?

  • ✅ 요청 카운터가 먼저 증가하므로 동시 요청 차단
  • ✅ 실제 카운터는 통과된 요청만 증가 → 정확성 보장
  • ✅ Lua 스크립트 없이 2개 명령으로 해결 → 성능 영향 최소화

참고 문서) Redis Docs - Pattern:Counter

결론적으로, 실제 수량이 초과될 상황이 원천 차단되었으며, 워커로 넘어가는 초과 발급 건수는 모두 0건으로 완벽히 해소되었습니다.




최종 의사결정: 1번 + 3번 조합 선택

다양한 시도 끝에 발급 수량 선차감(1번)과 이중 카운터 전략(3번)을 조합한 방식을 최종 선택했습니다.

의사결정 과정

방안 정확성 성능 선택 여부
1️⃣ 수량 선차감 △ (개선됨) ⭐⭐⭐⭐⭐ ✅ 채택
2️⃣ Lua 스크립트 ⭐⭐⭐⭐⭐ ⭐⭐⭐ (21%↓) ❌ 기각
3️⃣ 이중 카운터 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ (8%↓) ✅ 채택

기각 이유 (Lua 스크립트):

  • 성능 저하: 21%의 처리량 감소는 대규모 트래픽 환경에서 치명적
  • Redis 부하: 단일 스레드 특성상 전체 시스템 병목 가능성
  • 운영/관리 복잡도 증가: 스크립트 디버깅과 모니터링의 어려움

채택 이유 (이중 카운터):

  • 허용 가능한 성능 영향: 8%의 성능 저하가 발생했지만, 운영 환경 스펙 및 실제 요청량 대비 TPS 및 분당 처리량을 계산했을 때 충분히 감당 가능한 수준이라고 판단 (정확성 100% 확보와 동시에 성능 트레이드오프 최소화)



개선 결과


📊 성능 지표 비교 및 성과

개발 환경에서 수행한 부하테스트 결과입니다. 실제 운영 환경보다 높은 부하 조건에서 측정한 수치이므로 미발급률에 다소 차이가 있을 수 있으나, 이를 감안해도 개선 효과는 매우 분명하게 나타났습니다.

부하 테스트 결과 (쿠폰 제한 수량 10,000건)

선착순 쿠폰 제한 수량 10,000건을 기준으로 비교한 결과, 발급 정확도는 100%로 향상되었고, 미발급 건수는 0건으로 완전히 감소했습니다.

구분 개선 전 개선 후 개선율
발급 성공 응답 10,162건 10,000건 ⭐️100% 정확⭐️
미발급 건수 162건 0건 ⭐️100% 감소⭐️
미발급률 1.62% 0% ⭐️완전 해소⭐️

실제 운영에서도 동일한 결과가 확인되었습니다. 정확한 비교를 위해 9월 올영 세일 3일차까지는 위에서 개선한 기능 플래그를 OFF로 해두고, 4일차부터 ON하여 미발급 건의 차이가 있는지 확인해보았습니다. 그 결과 3일차까지 기존처럼 미발급 건이 발생하였고, 4일차부터는 미발급 건수는 0건, 그리고 분당/초당 최대 처리량 역시 6월 대비 저하 없이 안정적으로 유지되었습니다! 🥳🎉👏🏻👏🏻 (8% 성능 저하는 실전에서 영향이 미미했습니다.)

개선 방안을 실무에 적용 시 고려했던 사항들은 아래와 같습니다.

1. Redis 키 관리 전략

  • 키 네이밍 명확히 구분 및 적절한 TTL 설정
// 쿠폰별 키 네이밍 규칙
val countKey = "coupon:${couponId}:count"       // 실제 발급 수
val countReqKey = "coupon:${couponId}:countReq" // 발급 요청 수

// TTL 설정 (쿠폰 종료 시각 + 버퍼)
val ttl = couponEndTime.plusHours(1)

2. 모니터링 포인트

  • 정합성 체크: count vs countReq 간의 지속적인 차이 모니터링 (경합 상태 완화 후에도 발생 가능한 비정상적 흐름 감지)
  • 알람 설정: 두 키의 카운트 차이가 임계치를 벗어날 경우 즉시 알람 설정
  • Redis 자원 추적: Redis 메모리 사용량 및 명령 처리 지연(Latency) 추적

3. 장애 대응 및 안정화 시나리오

  • Fallback 전략: Redis 장애 발생 시 DB 기반 Fallback 로직을 통한 서비스 연속성 확보 필요 (단, 성능 저하 위험 인지)
  • 정합성 보정: countcountReq 수량 불일치 감지 시 자동 또는 수동 정합성 체크 및 수량 조정 프로세스 마련



마무리


이 프로젝트를 통해 배운 것은 크게 세 가지입니다.
첫째, 완벽한 시스템은 없다, 그러나 개선은 계속된다. 어떠한 예외 상황에서도 완벽한 시스템을 만들어내는 것은 거의 불가능에 가까운 일 아닌가 싶습니다. 99.99%의 고가용성 시스템이라도 단 0.01% 가 실패하면 그건 완벽한 시스템은 아니니까요. 그래서 문제가 발생하면 빠르게 캐치하고, 더 나은 해결책을 찾아 적용하는 과정이 무엇보다 중요하다는걸 느꼈습니다.
둘째, 성능과 정확성의 균형이 필요하다. Lua 스크립트가 기술적으로 가장 완벽한 해결책처럼 보였지만, 실제 프로덕션 환경에서는 21%의 성능 저하가 더 큰 문제였습니다. 반면 이중 카운터 전략을 통해 시스템의 응답 속도를 유지하면서도 데이터 정합성을 100% 보장하는 실질적인 균형점을 찾아냈습니다.
셋째, 데이터 정합성과 응답 속도는 트레이드오프다. 비동기 시스템은 빠른 응답 속도를 얻는 대신 데이터 정합성 보장이 어렵다는 근본적인 트레이드오프를 가지고 있습니다. 다행히 이번 개선에서는 응답 속도를 유지하면서도 정합성을 보장하는 방법을 찾았습니다.

저의 해결책 역시 올리브영 쿠폰 시스템 환경에 최적화된 결과일 뿐, 완벽하다고 말할 수는 없습니다. 하지만 각 시스템마다 최우선시해야 하는 요소가 무엇인지에 따라 해결책은 다양하게 적용될 수 있습니다. 올리브영 쿠폰 시스템에서는 고객 신뢰가 가장 중요한 가치였기에, 저희는 정확성을 최우선으로 두면서도 성능을 포기하지 않는 합리적인 방법을 찾아냈습니다. 이 과정에서 얻은 경험과 노하우가 비슷한 고민을 하는 분들께 조금이나마 도움이 되길 바랍니다.
저의 첫 포스팅을 읽어 주셔서 감사합니다. 다음에는 더 좋은 내용으로 찾아뵙겠습니다. 🙏

정합성Redis비동기
올리브영 테크 블로그 작성 올영세일 선착순 쿠폰, 미발급 0%를 향한 여정
💡
라이트 |
Back-end Engineer
유레카!