Post

선착순 쿠폰 발급과 동시성 문제

선착순 쿠폰 발급과 동시성 문제

들어가며

Real MySQL 8.0 11.4.13 "잠금을 사용하는 SELECT" 를 읽다 보면, 선착순 쿠폰 발급 시 동시에 1000개의 요청이 들어오는 상황을 예로 들며 NOWAITSKIP LOCKED를 설명한다. 책에서는 간단히 언급하고 넘어가지만, 실제로 이 문제를 해결하려면 상당히 깊은 이해가 필요하다.

특히 배달, 커머스 기업 등에서는 선착순 쿠폰 발급 요청이 동시에 수백, 수천만개가 들어온다고 한다.

이 글에서는 선착순 쿠폰 발급의 동시성 문제가 왜 발생하는지, 그리고 MySQL의 잠금 기능부터 Redis, Kafka를 활용한 대규모 처리까지 단계별로 살펴본다. 필자는 Java 코드로 진행하고자 한다.


Race Condition

선착순 100명에게 쿠폰을 발급하는 요구사항을 생각해보자. 가장 직관적인 구현은 다음과 같다.

1
2
3
4
5
6
public void issueCoupon(Long userId) {
    long count = couponRepository.count();  // 현재 발급된 쿠폰개수 조회
    if (count < 100) {
        couponRepository.save(new Coupon(userId));  // 쿠폰 발급
    }
}

단일 스레드 환경에서는 완벽하게 동작한다. 하지만 동시에 여러 요청이 들어오면 어떻게 될까?

1
2
3
4
5
6
7
8
시간   스레드1              스레드2
───────────────────────────────────────────────────────────
T1    count = 99         
T2                        count = 99
T3    99 < 100 → true    
T4                        99 < 100 → true  
T5    save(쿠폰)          
T6                        save(쿠폰)  ← 101번째 쿠폰 발급!

99번째 쿠폰이 발급된 상태에서 20개의 스레드가 동시에 count()를 호출하면 전부 “아직 100개 안 됐네” 라고 판단하고 쿠폰을 발급해버린다. 이게 바로 Race Condition 문제다.


MySQL 락으로 해결하기

FOR UPDATE : 비관적 잠금

가장 직관적인 방법은 MySQL의 FOR UPDATE를 사용하는 것이다.

1
2
3
4
5
6
7
8
9
10
11
-- 트랜잭션 시작
START TRANSACTION;

-- 쿠폰 이벤트 레코드에 배타적 잠금(X Lock) 획득
SELECT * FROM coupon_event WHERE id = 1 FOR UPDATE;

-- 발급 수량 확인 후 (유효성 검사) 쿠폰 발급

-- ...

COMMIT;

FOR UPDATE는 해당 레코드에 배타적 잠금(X Lock) 을 건다. 다른 트랜잭션이 같은 레코드에 FOR UPDATEFOR SHARE를 시도하면 잠금이 해제될 때까지 대기해야 한다.

1
2
3
4
5
6
7
8
9
스레드1                                스레드2
───────────────────────────────────────────────────────────────────
SELECT ... FOR UPDATE (X Lock 획득)
                                      SELECT ... FOR UPDATE
                                      (대기... 잠금 풀릴 때까지)
count 확인 → 발급 → COMMIT
(X Lock 해제)
                                      (잠금 획득)
                                      count 확인 → 발급 → COMMIT

동시성 문제는 해결된다. 하지만 치명적인 단점이 있다.

1000명이 동시에 요청하면 999명이 대기한다.

대기 시간이 길어지면 Lock wait timeout exceeded 에러가 발생하고, 사용자 경험도 최악이 된다.


Lock Compatibility

본격적으로 SKIP LOCKED를 설명하기 전에, MySQL의 잠금 호환성을 이해해야 한다.

  X Lock (FOR UPDATE) S Lock (FOR SHARE)
X Lock 보유 중 ❌ 대기 ❌ 대기
S Lock 보유 중 ❌ 대기 ✅ 허용

X Lock(배타적 잠금) 은 다른 어떤 잠금도 허용하지 않는다. 반면 S Lock(공유 잠금) 은 다른 S Lock과는 호환된다.

여기서 중요한 점이 하나 있다. 일반 SELECT는 잠금을 요청하지 않는다.

1
2
3
4
5
-- 세션1
SELECT * FROM coupon WHERE id = 1 FOR UPDATE;  -- X Lock 획득

-- 세션2
SELECT * FROM coupon WHERE id = 1;  -- 즉시 반환! (대기 없음)

세션1이 X Lock을 걸어도 세션2의 일반 SELECT는 대기하지 않는다. InnoDB는 MVCC(Multi-Version Concurrency Control) 를 사용하여 잠금 없이 과거 스냅샷을 읽기 때문이다.

1
2
3
4
5
-- 세션1
SELECT * FROM coupon WHERE id = 1 FOR UPDATE;  -- X Lock 획득

-- 세션2
SELECT * FROM coupon WHERE id = 1 FOR SHARE;  -- 대기! (X Lock 해제까지)

반면 FOR SHARE는 S Lock을 요청하므로, X Lock이 해제될 때까지 대기한다.


SKIP LOCKED : 잠긴 레코드 건너뛰기

MySQL 8.0.1부터 도입된 SKIP LOCKED는 잠긴 레코드를 건너뛰고 잠금이 걸리지 않은 레코드를 선택한다. 이를 활용하면 DB만으로 큐(Queue) 를 구현할 수 있다.

테이블 설계

쿠폰을 미리 생성해두고, 발급 시 사용자를 할당하는 방식이다.

1
2
3
4
5
6
7
8
9
10
11
12
CREATE TABLE coupon (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    coupon_code VARCHAR(50) NOT NULL,
    user_id BIGINT DEFAULT 0,        -- 0이면 미발급
    issued_at DATETIME NULL,
    created_at DATETIME NOT NULL,
    INDEX idx_user_id (user_id)
);

-- 1000개 쿠폰 미리 생성
INSERT INTO coupon (coupon_code, user_id, created_at) 
VALUES ('COUPON-0001', 0, NOW()), ('COUPON-0002', 0, NOW()), ...;

user_idNULL로 설계해도 되고 0으로 설계해도 된다. 둘 다 인덱스를 정상적으로 사용한다. NULL은 “값이 없음”이라는 의미가 명확하고, 0NOT NULL 제약조건을 활용할 수 있다는 장점이 있다.

발급 쿼리

1
2
3
4
5
6
7
8
9
10
11
12
START TRANSACTION;

-- 잠기지 않은 미발급 쿠폰 1개를 가져와서 잠금
SELECT * FROM coupon 
WHERE user_id = 0 
LIMIT 1 
FOR UPDATE SKIP LOCKED;

-- 해당 쿠폰에 사용자 할당
UPDATE coupon SET user_id = ?, issued_at = NOW() WHERE id = ?;

COMMIT;

동작 원리

SKIP LOCKED의 핵심은 각 트랜잭션이 서로 다른 레코드를 처리 한다는 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
시간   스레드1                  스레드2                  스레드3
─────────────────────────────────────────────────────────────────────────────────
T1    SELECT SKIP LOCKED
      → 쿠폰#1 획득 (X Lock)
      
T2                            SELECT SKIP LOCKED
                              → 쿠폰#1 잠김, SKIP!
                              → 쿠폰#2 획득 (X Lock)
                           
T3                                                     SELECT SKIP LOCKED
                                                       → 쿠폰#1, #2 잠김, SKIP!
                                                       → 쿠폰#3 획득

T4    UPDATE 쿠폰#1           UPDATE 쿠폰#2             UPDATE 쿠폰#3
T5    COMMIT                 COMMIT                    COMMIT

기존 FOR UPDATE와 비교하면 처리량 차이가 극명하다.

1
2
3
4
5
6
7
8
9
[FOR UPDATE] - 직렬 처리
요청1: ──────────────────────>
요청2:        (대기)──────────────────────>
요청3:              (대기)(대기)──────────────────────>

[FOR UPDATE SKIP LOCKED] - 병렬 처리
요청1: ──────────────────────>  (쿠폰#1)
요청2: ─"skip"─>──────────────────────>  (쿠폰#2)
요청3: ─"skip"─>─"skip"─>──────────────────────>  (쿠폰#3)

Spring Data JPA 구현

1
2
3
4
5
6
7
8
9
10
public interface CouponRepository extends JpaRepository<Coupon, Long> {
    
    @Query(value = """
        SELECT * FROM coupon 
        WHERE user_id = 0 
        LIMIT 1 
        FOR UPDATE SKIP LOCKED
        """, nativeQuery = true)
    Optional<Coupon> findAvailableCouponWithSkipLock();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Service
@RequiredArgsConstructor
public class CouponService {
    
    private final CouponRepository couponRepository;
    
    @Transactional
    public CouponResponse issueCoupon(Long userId) {
        Coupon coupon = couponRepository.findAvailableCouponWithSkipLock()
            .orElseThrow(() -> new SoldOutException("쿠폰이 모두 소진되었습니다."));
        
        coupon.assignToUser(userId);
        
        return CouponResponse.from(coupon);
    }
}

SKIP LOCKED의 한계

SKIP LOCKED는 강력하지만 몇 가지 제약이 있다.

  1. 쿠폰을 미리 생성해야 한다 : 1000개는 괜찮지만, 10만 개라면?
  2. 중복 발급 방지가 별도로 필요하다 : 같은 사용자가 여러 번 요청하면??
  3. 엄격한 선착순이 아니다 : 쿠폰#1을 받은 사람이 실제로 먼저 요청했다고 확답할 순 없다.

일단 중복 발급 방지는 UNIQUE 제약조건으로 해결할 수 있다.

1
ALTER TABLE coupon ADD UNIQUE INDEX uk_user_event (user_id, event_id);


NOWAIT: 즉시 실패

SKIP LOCKED와 함께 도입된 NOWAIT은 잠긴 레코드를 만나면 대기하지 않고 즉시 에러를 반환 한다.

1
2
3
4
SELECT * FROM coupon WHERE id = 1 FOR UPDATE NOWAIT;

-- 잠긴 상태라면:
-- ERROR 3572: Statement aborted because lock(s) could not be acquired immediately
옵션 동작 적합한 상황
NOWAIT 잠금 충돌 시 즉시 에러 특정 좌석 예매 (이미 선택된 좌석)
SKIP LOCKED 잠금 충돌 시 다음 행 선택 선착순 쿠폰 발급 (아무 쿠폰이나)


Redis + Kafka (대규모 트래픽)

SKIP LOCKED는 중규모까지는 충분하지만, 올영세일이나 네고왕 같은 대규모 이벤트에서는 DB 자체가 병목이 된다. 이때 RedisKafka 를 활용한다.

왜 Redis?

Redis가 동시성 제어에 적합한 이유는 명확하다.

  1. 싱글 스레드 : 모든 명령이 순차 처리되어 원자성 보장됨
  2. 인메모리 : 디스크 I/O 없이 초당 10만 건 이상 처리가능함
  3. 원자적 연산 : INCR, DECR, SETNX 등 제공함

아키텍처

1
2
3
4
5
6
7
8
9
10
11
12
13
사용자 요청 (동기)           백그라운드 (비동기)
───────────────────────────────────────────────────
    │                             
    ▼                             
┌────────┐                        
  Redis    ← 즉시 응답 (수 ms)   
└────────┘                        
    │                             
    ▼                             
┌────────┐      ┌────────┐      ┌────────┐
  Kafka     →    Consumer    →     MySQL 
└────────┘      └────────┘      └────────┘
                              (나중에 저장)

사용자에게는 Redis 처리 결과만으로 즉시 응답하고, 실제 DB 저장은 Kafka를 통해 비동기로 처리한다.

Lua Script로 원자적 처리

Redis 명령 여러 개를 원자적으로 실행하려면 Lua Script를 사용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
-- coupon_issue.lua
-- KEYS[1] = 발급 수량 키
-- KEYS[2] = 발급 사용자 Set 키
-- ARGV[1] = 최대 수량
-- ARGV[2] = 사용자 ID

local count = redis.call('GET', KEYS[1])
if count == false then
    count = 0
else
    count = tonumber(count)
end

-- 수량 체크
if count >= tonumber(ARGV[1]) then
    return -1  -- 품절
end

-- 중복 체크
local added = redis.call('SADD', KEYS[2], ARGV[2])
if added == 0 then
    return -2  -- 이미 발급받음
end

-- 수량 증가
redis.call('INCR', KEYS[1])
return 1  -- 성공

Java 구현 예시

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@Service
@RequiredArgsConstructor
public class CouponService {
    
    private final RedisTemplate<String, String> redisTemplate;
    private final KafkaTemplate<String, CouponIssueMessage> kafkaTemplate;
    private final RedisScript<Long> issueScript;
    
    private static final String COUNT_KEY = "coupon:event:%d:count";
    private static final String USERS_KEY = "coupon:event:%d:users";
    
    public CouponIssueResult requestCoupon(Long userId, Long eventId, int maxCount) {
        String countKey = String.format(COUNT_KEY, eventId);
        String usersKey = String.format(USERS_KEY, eventId);
        
        // Lua Script 실행 (원자적)
        Long result = redisTemplate.execute(
            issueScript,
            List.of(countKey, usersKey),
            String.valueOf(maxCount),
            String.valueOf(userId)
        );
        
        if (result == -1) return CouponIssueResult.SOLD_OUT;
        if (result == -2) return CouponIssueResult.ALREADY_ISSUED;
        
        // Kafka로 비동기 처리 요청
        kafkaTemplate.send("coupon-issue", 
            new CouponIssueMessage(userId, eventId));
        
        return CouponIssueResult.SUCCESS;
    }
}

Kafka 메시지에는 userIdeventId가 담긴다. eventId는 쿠폰 이벤트를 식별하는 값으로, 같은 이벤트에 참여하는 모든 사용자가 동일한 eventId를 가진다.

1
2
3
4
5
Kafka Topic: coupon-issue

[userId:1,    eventId:1]  ← 유저1이 "올영세일" 쿠폰 신청
[userId:999,  eventId:1]  ← 유저999가 "올영세일" 쿠폰 신청
[userId:1234, eventId:1]  ← 유저1234가 "올영세일" 쿠폰 신청

Kafka Consumer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Component
@RequiredArgsConstructor
public class CouponIssueConsumer {
    
    private final CouponRepository couponRepository;
    
    @KafkaListener(topics = "coupon-issue", groupId = "coupon-group")
    public void consume(CouponIssueMessage message) {
        // 이미 Redis에서 검증 완료된 요청만 들어옴
        Coupon coupon = Coupon.create(
            message.getUserId(), 
            message.getEventId()
        );
        couponRepository.save(coupon);
    }
}

Redis에서 “발급 성공” 응답을 보낸 시점과 실제 DB에 저장되는 시점 사이에 지연이 발생할 수 있다. 여기어때 기술블로그에 따르면, 이 부분은 “이벤트 진행 중, 지급 성공 응답과 실제 쿠폰 지급 처리 사이에 Delay가 발생할 수 있기 때문에, 이벤트 기획 협의를 통해서 적절한 문구로 고객에게 안내 하였습니다.” 라고 기술한다.


실제 기업들의 선택

기업 기술 스택 특징
올리브영 Redis → RabbitMQ 과발급/미발급 문제 해결 과정 상세
여기어때 Redis + Kafka 네고왕 이벤트 실전 대응
컬리 Redisson 분산락 트랜잭션과 락 순서 주의점

올리브영 기술블로그에서는 Redis Pub/Sub의 “100% 전송 보장이 되지 않는” 특성 때문에 미발급 문제가 발생했고, 이를 Redis List + RabbitMQ로 해결한 과정을 상세히 다룬다.

마지막 레퍼런스에 관련 링크들을 첨부하며 마치겠다.


마치며

선착순 쿠폰 발급은 동시성 제어의 대표적인 사례다. 핵심 원칙을 정리하면 다음과 같다.

  1. Race Condition 고려하기 : 단순 구현은 반드시 문제가 생긴다
  2. 락 범위 최소화 : FOR UPDATE보다 SKIP LOCKED가 처리량이 높다
  3. DB 부하 분산 : 대규모 트래픽은 Redis로 먼저 처리하고, DB는 비동기로
  4. 사용자 응답과 데이터 저장 분리하기 : Kafka로 비동기 처리하면 응답 속도가 빨라진다

Real MySQL에서 SKIP LOCKED를 간단히 소개했지만, 실제로 이를 활용하려면 잠금 호환성, MVCC, 분산 시스템까지 이해해야 한다. 이 글이 조금이라도 도움이 되었으면 한다.


References

This post is licensed under CC BY 4.0 by the author.