낙관적 락을 쿠폰 사용으로 풀어봤습니다

낙관적 락 쿠폰 사용으로 풀어봤습니다. 적용하면 핵심은 “쿠폰을 읽을 때 버전도 같이 읽고, 사용할 때 내가 읽었던 버전이 아직 맞는 경우에만 업데이트한다”입니다.
왜 이게 중요할까요? 정답은 마지막에 있습니다.
예시 테이블:
CREATE TABLE coupons (
id BIGINT PRIMARY KEY,
code VARCHAR(50) UNIQUE NOT NULL,
used BOOLEAN NOT NULL DEFAULT FALSE,
used_by BIGINT NULL,
used_at TIMESTAMP NULL,
version BIGINT NOT NULL DEFAULT 0
);
쿠폰 사용 흐름:
-- 1. 쿠폰 조회
SELECT id, code, used, version
FROM coupons
WHERE code = 'WELCOME10000';
조회 결과:
id: 1
code: WELCOME10000
used: false
version: 3
사용자가 쿠폰을 사용하려고 하면, 읽었던 version = 3을 조건에 넣어서 업데이트합니다.
UPDATE coupons
SET
used = true,
used_by = 123,
used_at = NOW(),
version = version + 1
WHERE
id = 1
AND used = false
AND version = 3;
이때 업데이트된 row 수가 1이면 성공입니다.
affected rows = 1
쿠폰 사용 성공
동시에 두 명이 같은 쿠폰을 사용하면 이렇게 됩니다.
사용자 A 조회: used=false, version=3
사용자 B 조회: used=false, version=3
A가 먼저 업데이트:
UPDATE coupons
SET used = true, used_by = 123, used_at = NOW(), version = version + 1
WHERE id = 1 AND used = false AND version = 3;
결과:
affected rows = 1
성공
쿠폰 상태는 이제:
used = true
version = 4
B가 나중에 업데이트:
UPDATE coupons
SET used = true, used_by = 456, used_at = NOW(), version = version + 1
WHERE id = 1 AND used = false AND version = 3;
결과:
affected rows = 0
실패
왜 실패하냐면 DB 안의 쿠폰은 이미 version = 4이고 used = true라서, B가 알고 있던 version = 3 조건과 맞지 않기 때문입니다.
애플리케이션 코드로 쓰면 대략 이렇게 됩니다.
async function useCoupon(code: string, userId: number) {
const coupon = await db.coupons.findUnique({
where: { code },
select: {
id: true,
used: true,
version: true,
},
});
if (!coupon) {
throw new Error("쿠폰이 존재하지 않습니다.");
}
if (coupon.used) {
throw new Error("이미 사용된 쿠폰입니다.");
}
const result = await db.coupons.updateMany({
where: {
id: coupon.id,
used: false,
version: coupon.version,
},
data: {
used: true,
usedBy: userId,
usedAt: new Date(),
version: {
increment: 1,
},
},
});
if (result.count === 0) {
throw new Error("다른 사용자가 먼저 쿠폰을 사용했습니다.");
}
return {
success: true,
};
}
중요한 부분은 updateMany입니다. 일반적인 update는 조건 불일치 시 예외 처리가 애매할 수 있으므로, count === 0으로 충돌 여부를 판단하는 방식이 명확합니다.
낙관적 락 없이 이렇게 하면 위험합니다.
UPDATE coupons
SET used = true, used_by = 123
WHERE id = 1;
이 경우 동시에 여러 요청이 들어오면 마지막 요청이 앞선 요청을 덮어쓸 수 있습니다.
쿠폰 사용에서는 보통 아래 조건을 같이 둡니다.
WHERE id = ?
AND used = false
AND version = ?
수량형 쿠폰이라면 예시는 조금 달라집니다.
CREATE TABLE coupon_campaigns (
id BIGINT PRIMARY KEY,
code VARCHAR(50) UNIQUE NOT NULL,
total_quantity INT NOT NULL,
used_quantity INT NOT NULL DEFAULT 0,
version BIGINT NOT NULL DEFAULT 0
);
사용 처리:
UPDATE coupon_campaigns
SET
used_quantity = used_quantity + 1,
version = version + 1
WHERE
id = 1
AND used_quantity < total_quantity
AND version = 7;
이것도 affected rows = 1이면 성공, 0이면 재조회 후 재시도하거나 “쿠폰이 모두 소진되었습니다”로 처리합니다.
정리하면:
단일 쿠폰 1회 사용:
WHERE used = false AND version = 기존버전
수량 제한 쿠폰:
WHERE used_quantity < total_quantity AND version = 기존버전
성공 판단:
UPDATE 결과 row count가 1이면 성공, 0이면 충돌 또는 소진
실무에서는 쿠폰 사용 기록 테이블도 함께 둡니다.
CREATE TABLE coupon_usages (
id BIGINT PRIMARY KEY,
coupon_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
used_at TIMESTAMP NOT NULL,
UNIQUE (coupon_id),
UNIQUE (coupon_id, user_id)
);
더 안전하게 하려면 쿠폰 상태 업데이트와 사용 기록 저장을 하나의 트랜잭션으로 묶습니다.
BEGIN;
UPDATE coupons
SET
used = true,
used_by = 123,
used_at = NOW(),
version = version + 1
WHERE
id = 1
AND used = false
AND version = 3;
-- affected rows가 1일 때만 실행
INSERT INTO coupon_usages (id, coupon_id, user_id, used_at)
VALUES (1001, 1, 123, NOW());
COMMIT;
낙관적 락을 사용해야 하는 이유
분산 시스템의 애플리케이션에서는 기능이 복잡해지고 여러 테이블이 다양한 경로에서 참조되면서, 모든 트랜잭션에서 락 획득 순서를 일관되게 유지하기 어려워진다. 이 경우 SELECT FOR UPDATE 같은 비관적 락은 데드락 가능성을 높이고, 트래픽이 증가할수록 락 대기와 재시도 비용도 커질 수 있다.
따라서 충돌 빈도가 높지 않고 실패 후 재시도가 가능한 업무에서는 비관적 락보다 낙관적 락을 우선 고려하는 것이 적절하다. 낙관적 락은 데이터 조회 시점의 버전 또는 상태를 조건부 UPDATE에 포함해, 실제 변경 시점에 충돌 여부를 감지하고 실패한 요청만 재시도하거나 거절하는 방식이다. 이를 통해 긴 락 점유를 피하고 데드락 가능성을 줄일 수 있다.
트래픽 증가 자체가 데드락의 직접 원인은 아니지만, 동시 트랜잭션 수가 늘면 락 경합과 데드락 발생 가능성이 커진다. 따라서 락 획득 순서를 완전히 통제하기 어려운 도메인에서는 비관적 락 중심 설계보다 낙관적 락, 조건부 UPDATE을 사용한다. 도메인에 따라서 락을 통한 정합성 유지가 더 중요한 경우 유니크 제약 큐 기반 직렬화 등을 조합하는 것을 고려 해야한다.