선착순 쿠폰 발급과 동시성 문제
들어가며
Real MySQL 8.0 11.4.13 "잠금을 사용하는 SELECT" 를 읽다 보면, 선착순 쿠폰 발급 시 동시에 1000개의 요청이 들어오는 상황을 예로 들며 NOWAIT과 SKIP 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 UPDATE나 FOR 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_id를NULL로 설계해도 되고0으로 설계해도 된다. 둘 다 인덱스를 정상적으로 사용한다.NULL은 “값이 없음”이라는 의미가 명확하고,0은NOT 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는 강력하지만 몇 가지 제약이 있다.
- 쿠폰을 미리 생성해야 한다 : 1000개는 괜찮지만, 10만 개라면?
- 중복 발급 방지가 별도로 필요하다 : 같은 사용자가 여러 번 요청하면??
- 엄격한 선착순이 아니다 : 쿠폰#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 자체가 병목이 된다. 이때 Redis 와 Kafka 를 활용한다.
왜 Redis?
Redis가 동시성 제어에 적합한 이유는 명확하다.
- 싱글 스레드 : 모든 명령이 순차 처리되어 원자성 보장됨
- 인메모리 : 디스크 I/O 없이 초당 10만 건 이상 처리가능함
- 원자적 연산 :
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 메시지에는 userId와 eventId가 담긴다. 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로 해결한 과정을 상세히 다룬다.
마지막 레퍼런스에 관련 링크들을 첨부하며 마치겠다.
마치며
선착순 쿠폰 발급은 동시성 제어의 대표적인 사례다. 핵심 원칙을 정리하면 다음과 같다.
- Race Condition 고려하기 : 단순 구현은 반드시 문제가 생긴다
- 락 범위 최소화 :
FOR UPDATE보다SKIP LOCKED가 처리량이 높다 - DB 부하 분산 : 대규모 트래픽은 Redis로 먼저 처리하고, DB는 비동기로
- 사용자 응답과 데이터 저장 분리하기 : Kafka로 비동기 처리하면 응답 속도가 빨라진다
Real MySQL에서 SKIP LOCKED를 간단히 소개했지만, 실제로 이를 활용하려면 잠금 호환성, MVCC, 분산 시스템까지 이해해야 한다. 이 글이 조금이라도 도움이 되었으면 한다.
References
- Real MySQL 8.0 2권 - 백은빈, 이성욱
- 올리브영 테크블로그 - 올리브영 쿠폰 발급 개선 이야기
- 올리브영 테크블로그 - Redis Pub/Sub을 활용한 쿠폰 발급 비동기 처리
- 올리브영 테크블로그 - 쿠폰 발급 RabbitMQ도입기
- 올리브영 테크블로그 - 올리브영 초대량 쿠폰 발급 시스템 개선기
- RabbitMQ Classic Queue 메모리 장애와 Quorum Queue 전환기
- 여기어때 기술블로그 - Redis&Kafka를 활용한 선착순 쿠폰 이벤트 개발기 (feat. 네고왕)
- 컬리 기술블로그 - 풀필먼트 입고 서비스팀에서 분산락을 사용하는 방법
