포스트

한정수량 100개짜리 쿠폰이 999개 발급됐다 — 락 사다리 v1에서 v5까지

락 없이 동시 요청을 흘렸더니 100장짜리 쿠폰이 999장 나갔다. synchronized, DB 행 락, Redis 분산 락까지 다섯 단계를 직접 짜고 측정한 뒤, 가장 단순한 곳으로 한 칸 내려온 기록.

한정수량 100개짜리 쿠폰이 999개 발급됐다 — 락 사다리 v1에서 v5까지

동시성을 공부하며 직접 부딪힌 기록이다. “락 걸면 되는 거 아냐?”에서 시작했는데, 사다리를 끝까지 올라가 보고 나서야 어디에 거는가가 진짜 질문이라는 걸 알았다.

🎟️ 100장인데 999장이 나갔다

선착순 한정수량 쿠폰을 만든다고 해보자. 재고는 딱 100장. 규칙은 하나뿐이다. 100장을 넘게 발급하면 안 된다.

발급 로직 자체는 단순하다. “재고 남았나? → 남았으면 하나 깎고 발급.” 혼자 천천히 누르면 아무 문제 없다. 그래서 처음엔 이게 동시성 문제라는 생각조차 안 했다. 그냥 if문 하나면 끝날 일처럼 보였으니까.

부하를 걸어봤다. 같은 쿠폰 하나(couponId=1)에 500명이 10초 동안 동시에 달려드는 상황을 만들었다. 그리고 발급이 끝난 뒤 DB를 직접 세어봤다.

1
2
SELECT COUNT(*) FROM coupon_issue;
-- 999

100장짜리 쿠폰이 999장 나갔다. 10배다. 더 황당한 건, 앱이 들고 있던 카운터(issued 컬럼)는 멀쩡하게 100이라고 적혀 있었다는 거다. 장부는 100이라고 말하는데 실제 발급 행은 999개. 둘이 따로 놀고 있었다.

원인은 흔히 말하는 check-then-act 경합이다. “재고를 확인하고(check)” “하나 깎는(act)” 두 동작 사이에, 다른 요청 수백 개가 똑같이 “재고 있네!”를 보고 우르르 통과해버린다. 100을 보고 다 같이 깎으니 카운터는 lost update로 100에 갇히고, 실제 발급 행만 끝없이 쌓인다.

여기서부터 락을 v1에서 v5까지 한 칸씩 올려보기로 했다.

📏 숫자를 비교하기 전에 정한 규칙

사다리를 타기 전에 하나 정하고 갔다. 버전끼리 처리량(throughput)을 직접 비교하지 않는다.

처음엔 당연히 비교하고 싶었다. “v3가 초당 317건, v4가 6건이면 v3가 50배 빠른 거 아냐?” 하지만 이건 함정이다. 버전을 바꿀 때마다 락의 위치경합 정책동시에 바뀌기 때문이다.

버전락 위치경합이 나면
v2JVM 메모리기다림
v3DB 행기다림
v4Redis즉시 거절 (fail-fast)
v5Redis즉시 거절 (fail-fast)

v3에서 v4로 넘어가는 한 칸만 봐도, 락 위치(DB→Redis)와 경합 정책(기다림→즉시 거절)이 한꺼번에 바뀐다. 이러면 처리량이 달라져도 둘 중 무엇 때문에 달라졌는지 가를 수가 없다. 그래서 처리량 숫자는 아예 나란히 놓지 않기로 했다.

그래서 버전끼리 비교한 건 딱 하나였다. 초과 발급을 막았는가, 아닌가.

그리고 그 판정은 앱 카운터가 아니라 SELECT COUNT(*) FROM coupon_issue 실제 행 수로만 했다. 첫 실험에서 카운터(100)와 실제(999)가 따로 놀던 걸 봤으니, 앱이 보고하는 숫자는 더 이상 믿지 않기로 했다. 진실은 행 수 하나다.

🪜 락 사다리 v1 → v5

먼저 결론부터. 다섯 칸을 한 장으로 요약하면 이렇다.

 락을 어디에 거나초과 막음?다음 칸으로 간 이유
v1안 검❌ 999개임계 구간을 직렬화해야 함
v2JVM 모니터 (synchronized)⚠️ 단일만멀티 인스턴스에서 깨짐 (201개)
v3DB 행 락 (FOR UPDATE)← 최종 채택
v4Redis 분산 락 (Redisson)자원이 DB에 있는데 과한 외부 의존
v5Redis 직접 구현 (SET NX)fencing 공백이 드러남

분산 락을 직접 손으로 짜는 v5까지 가보고 나서, 가장 단순한 v3로 한 칸 내려왔다. 왜 그랬는지가 이 글의 줄거리다. 한 칸씩 올라가 보자.

조건은 전부 고정이다. 재고 100, 핫키 하나, 500 VU 10초. HikariCP 커넥션 풀 10개, 커넥션 타임아웃 30초, MySQL 행 락 대기 50초. 여기서 발급 서비스 구현만 갈아끼웠다.

v1 · 락 없음 — 기준선

@Transactional만 붙은 발급 메서드. 트랜잭션은 있지만 동시성 제어는 없다. 여기서 짚고 갈 게 있는데, 트랜잭션이 있다고 초과가 막히는 게 아니다. 트랜잭션은 “원자적으로 커밋되느냐”의 문제고, 초과 발급은 “같은 재고를 동시에 본 게 몇 명이냐”의 문제다. 결이 다르다.

결과는 위에서 본 그대로. 단일 인스턴스에서 actual 999 / 100, p95 1838ms. 인스턴스를 2개로 늘리니 2001개까지 나갔다. 막힌 곳: 재고를 깎는 임계 구간을 직렬화해야 한다 → v2로.

v2 · synchronized — 단일 JVM에선 막힌다

가장 먼저 떠오르는 건 자바의 synchronized다. 임계 구간을 한 번에 한 스레드만 통과시키면 되니까. 그런데 여기서 예상 못 한 함정에 걸렸다.

1
2
3
4
5
6
@Transactional
public void issue(...) {
    synchronized (lock) {   // ❌ 락은 메서드 안, 커밋은 메서드 밖
        coupon.issue();
    }
}   // ← 트랜잭션 커밋은 여기서 일어난다

@Transactional 메서드 안에 synchronized를 걸면, 락이 풀린 뒤에 커밋이 일어난다. 스프링의 @Transactional프록시가 메서드를 감싸서 동작하기 때문에, 트랜잭션 경계가 synchronized 블록보다 바깥에 있다. 락을 놓은 직후 다음 스레드가 들어오는데, 앞 스레드의 변경은 아직 커밋 전이라 보이지 않는다(stale read). 단일 JVM인데도 초과가 났다.

해법은 순서를 뒤집는 것. 락이 트랜잭션을 감싸야 한다. 락은 트랜잭션 밖에서 걸고, 실제 발급은 별도 빈으로 분리해서 그 안에서 @Transactional이 돌게 했다.

1
2
3
4
5
public CouponIssueResponse issue(Long couponId, Long userId) {
    synchronized (lock) {                        // ① 트랜잭션 밖 모니터
        return inner.issueTx(couponId, userId);  // ② 별도 빈 → 커밋이 모니터 안에서 끝남
    }                                            // ③ 커밋 끝난 뒤에 락 해제
}

빈을 굳이 분리한 이유는, 같은 클래스 안에서 메서드를 호출하면 프록시를 안 거쳐서 @Transactional이 무시되기 때문이다(self-invocation). 이걸 피하려고 inner를 별도 빈으로 뺐다.

이제 단일 JVM에선 actual 100 / 100으로 깔끔하게 막힌다. 그런데 인스턴스를 2개로 늘리니 201개가 나왔다. 코드는 한 글자도 안 바꿨는데 토폴로지만 바꿨더니 깨진 것이다. 당연하다. synchronized의 모니터는 프로세스 로컬이라, JVM이 둘이면 락도 둘이다. 막힌 곳: 직렬화 좌표를 공유 위치로 옮겨야 한다 → v3.

v3 · SELECT … FOR UPDATE — 공유 위치에서 줄 세우기

여러 인스턴스가 공유하는 곳이 어디냐면, 결국 DB다. 그래서 DB의 비관적 행 락을 썼다. 재고 행을 SELECT ... FOR UPDATE로 잠그면, 그 행을 만지려는 다른 요청은 커밋 때까지 DB에서 줄을 선다.

1
2
3
4
5
6
7
@Transactional
public CouponIssueResponse issue(Long couponId, Long userId) {
    Coupon coupon = couponRepository.findByIdForUpdate(couponId)  // SELECT ... FOR UPDATE
            .orElseThrow(...);
    coupon.issue();
    couponIssueRepository.save(new CouponIssue(couponId, userId));
}   // 커밋 = 행 락 해제

구조가 v2와 정반대다. v2는 “락이 트랜잭션을 감싸야” 했는데, v3는 행 락이 커밋 시점에 풀리기 때문에 락·검사·발급을 하나의 @Transactional 안에 다 넣는 게 맞다. 락과 트랜잭션의 포함 관계가 뒤집힌다.

1
2
3
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select c from Coupon c where c.id = :id")
Optional<Coupon> findByIdForUpdate(@Param("id") Long id);

단일도 다중(2인스턴스)도 actual 100 / 100. 드디어 멀티 인스턴스에서 정합이 잡혔다.

여기서 흥미로운 관찰 두 개.

  • 커넥션 풀이 꽉 찬다. active 10/10, idle 0, 대기(pending) 189. 처음엔 “장애 아냐?” 싶었는데, 결함이 아니라 이 선택의 특성이다. 요청들이 행 락을 기다리느라 커넥션을 붙잡고 있는 것.
  • 503이 0건이다. 풀이 꽉 찼는데 왜 안 터지나 했더니, 임계 구간이 짧아서 커넥션이 빠르게 순환했다. 30초 타임아웃을 넘기기 전에 다들 처리된 것. 경합을 에러가 아니라 지연(p95 2630ms)으로 흡수한 셈이다.

v3로 정합은 잡혔다. 그런데 앞에서 본 풀 압박이 걸렸다 — 요청들이 행 락을 기다리느라 DB 커넥션을 붙잡고 있었다. 그럼 락을 아예 DB 밖으로 빼면 어떨까? 대기를 Redis에서 하면 그동안 DB 커넥션을 안 물고 있을 테니, 풀도 숨통이 트이고 성능도 나아지지 않을까. 그 기대로 v4로 가봤다.

v4 · Redisson 분산 락 — DB 밖으로 빼봤다

Redis 기반 분산 락 라이브러리인 Redisson을 써봤다. 락을 데이터(DB)에서 떼어 Redis로 옮기는 시도다.

1
2
3
4
5
6
7
8
RLock lock = redissonClient.getLock("lock:coupon:" + couponId);
boolean locked = lock.tryLock(0, TimeUnit.SECONDS);   // waitTime=0 → 못 잡으면 즉시 실패
if (!locked) throw new LockNotAcquiredException(couponId);
try {
    return inner.issueTx(couponId, userId);
} finally {
    if (lock.isHeldByCurrentThread()) lock.unlock();   // 내가 쥔 락만 해제
}

여기서 정책이 확 바뀐다. v3는 락을 기다렸지만, 여기선 waitTime=0으로 못 잡으면 즉시 거절(fail-fast)하게 했다. 그래서 10초 부하 동안엔 actual이 62로 미달했다. 처음엔 “어 100 안 채워졌네?” 했는데, 이건 정상이다. 거절당한 요청이 재시도로 돌아오면 90초쯤 뒤에 100으로 수렴한다. 합격 기준은 “정확히 100”이 아니라 actual ≤ total, 즉 초과만 안 나면 된다니까.

503 비율이 99.9%(249,563건 전부 락 미획득)인데, fail-fast 정책에선 당연한 결과다. v3의 503=0과 절대 나란히 비교하면 안 되는 숫자다. 전략이 다르니까.

leaseTime을 안 주면 Redisson이 워치독으로 락을 자동 연장해준다. 작업이 길어져도 락이 멋대로 만료되지 않게 지켜주는 것. 편하다. 그런데 이 “알아서 해준다”가 v5의 동기가 됐다. 저 자동 갱신과 안전 해제를, 라이브러리 없이 직접 짜보면 뭐가 보일까?

v5 · 커스텀 SET NX + Lua — 직접 짜보니 함정이 보였다

원리는 두 줄이다. 획득은 SET key token NX PX ttl(없을 때만 세팅, TTL 부여), 해제는 Lua로 “내 토큰이 맞을 때만 삭제”(compare-and-delete).

1
2
3
4
5
// 획득
redis.opsForValue().setIfAbsent(key, token, Duration.ofMillis(ttl));   // SET NX PX

// 해제 (Lua, 원자적)
// if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end

해제에서 토큰을 비교하는 이유는, TTL이 만료된 뒤 남의 락을 실수로 지우지 않기 위해서다. “내가 건 락만 내가 푼다.”

직접 짜니까 라이브러리가 숨겨주던 함정이 정면으로 나왔다. 기본 RedisTemplate은 값을 JSON으로 직렬화한다. 그러면 토큰이 "abc"처럼 따옴표가 붙은 채 저장되는데, Lua 스크립트가 비교하는 raw 값(abc)과 안 맞는다. 그래서 해제가 조용히 항상 실패한다. 에러도 안 나고, 그냥 락이 안 풀린다. 매 요청마다 TTL 30초짜리 락이 쌓이는데 아무도 안 알려준다.

해결은 락 전용으로 StringRedisTemplate을 따로 써서 획득과 해제의 직렬화를 일치시키는 것. 검증은 간단했다. “발급 두 번 연속으로 즉시 성공하나?”를 보면 된다. 성공하면 매번 제대로 해제되고 있다는 뜻이다.

actual 100 / 100, 다중도 통과. 그런데 여기서 마지막 질문이 남았다. 락에 “만료(TTL)”가 있다는 건, 쥔 채로 만료되면 어떻게 되는가?

💀 안전한 해제 ≠ 상호배제 — 스톨 데모

개인적으로 이 실험에서 가장 많이 배운 지점이다. 정상 부하에선 안 보이니까, 일부러 통제 실험을 만들었다.

조건을 극단으로 좁혔다. 재고 total=1, 락 TTL을 1초로 줄이고, 락을 쥔 홀더가 3초간 멈추게 했다(GC 일시정지나 네트워크 단절을 흉내 낸 것). 타임라인은 이렇게 흘러간다.

  1. A가 락을 잡고 발급 작업을 시작한다.
  2. A가 3초간 멈춘 사이, 1초짜리 TTL이 만료된다. → Redis에서 락이 사라진다.
  3. B가 빈 자리를 보고 락을 잡는다. → B도 발급한다.
  4. A가 깨어나 락을 해제하려 한다. compare-and-delete가 “이건 네 토큰이 아니야”라며 거부한다. released=false. 오해제는 정확히 막혔다.

그런데 결과를 세어보면 actual 2 > total 1이다. 둘 다 발급해버렸다.

해제 로직은 제대로 동작했다. 남의 락을 안 지웠으니까. 그런데도 초과가 났다. 정리하면 이렇다.

안전한 해제(오해제 차단) ≠ 상호배제(동시 점유 차단).

이 둘은 다른 문제다. compare-and-delete는 해제를 안전하게 만들 뿐, A가 멈춘 동안 B가 못 들어오게 막지는 못한다. 그리고 이건 어떤 분산 락도 마찬가지다. TTL 만료-중간탈취는 fencing token(자원 쪽에서 번호표를 검증하는 장치) 없이는 막을 수 없다. Redisson의 워치독도 결국 GC 정지나 네트워크 단절 앞에선 갱신 신호가 안 가서 똑같은 구멍이 뚫린다.

“분산 락 걸었으니 안전해”라는 말이 왜 위험한지, 이 데모 하나로 분명해졌다.

⬇️ 끝까지 올라가 보고, 한 칸 내려왔다

사다리를 v5까지, 스톨 데모까지 다 타보고 나서 내린 결론은 v3(DB 행 락) 였다. Redis 분산 락(v4·v5)까지 만들어보고 나서 더 단순한 쪽으로 한 칸 내려온 것이다.

근거는 속도가 아니다(애초에 처리량은 비교 안 하기로 했으니까). 두 가지였다.

  • 락과 데이터가 한 트랜잭션 안에 한 몸으로 있다. v3는 행 락이 곧 커밋 시점에 풀리기 때문에, 방금 본 “쥔 채로 만료” 같은 상황이 구조적으로 성립하지 않는다. fencing 틈이 애초에 없다. 분산 락이 token으로 막아야 했던 그 구멍이, v3엔 처음부터 없는 것이다.
  • 멀티 인스턴스 정합이 이미 확보됐다. v3는 단일·다중 모두 100/100을 지킨다. 굳이 외부 Redis 의존을 더 얹을 이유가, 이 워크로드에선 없었다.

이 워크로드는 임계 구간이 사실상 DB 쓰기 그 자체다. 보호해야 할 자원이 DB 안에 있는데 락을 굳이 DB 밖으로 빼면, 락과 자원이 분리되면서 fencing 문제를 떠안게 된다. 자원 옆에 락을 두는 게 가장 단순하고 안전했다.

솔직히 더 새롭고 정교한 분산 락 쪽이 끌렸다. 그런데 사다리를 끝까지 올라가 보니, 이 경우엔 가장 단순한 v3가 맞았다. v4·v5를 직접 만들어보지 않았다면 “분산 락이 더 좋겠지” 하고 막연히 믿고 넘어갔을 거다.

🔭 다음 이야기

여기까지가 정합의 문제 — “초과를 막았는가”다. 그런데 v3를 채택하고 나서 다른 질문이 남았다. p95 2630ms, 대기 189건. 느린 건 어떻게 하지? 정합은 잡았는데 점유 시간이 천장을 만든다.

그래서 다음 글은 같은 v3를 출발선에 놓고, 이번엔 성능 사다리를 타본다. “커넥션 풀을 늘리면 빨라지지 않을까?”라는, 역시 흔한 직관을 데이터로 검증해볼 참이다. (스포일러: 톨게이트가 느리면 차선을 늘려도 차만 쌓인다.)


이 글의 바탕이 된 전체 자료(선수지식·용어집·전 버전 코드)는 동시성 락 사다리 v1→v5 페이지에, 3축 분석·메타회고를 담은 phase 3-3 회고 리포트에 있다.

이 글은 직접 진행한 실험과 측정 기록을 바탕으로, AI(Claude)의 도움을 받아 작성했습니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.