High-Traffic Lab · Phase 3-3 · Concurrency
장부 (counter)100 그러나 실제는 실제 발급 (행 수)999

한정 수량 쿠폰은 어떻게 무너지고,
락은 그것을 어디서 막는가

100장 한정 쿠폰이 999장 발급됐다. 락 없음 → JVM → DB → Redis 분산 락까지 다섯 단계를 전부 구현·측정하고, 마지막에 사다리를 한 칸 내려와 DB 행 락을 선택한 기록.

읽는 시간 ≈ 25분 · 대상: 동시성 제어가 처음인 백엔드 개발자
Part 0 · Prerequisites

본문에 들어가기 전, 일곱 가지만 챙기자

이 글은 "동시성 제어를 한 번도 해본 적 없는" 백엔드 개발자를 기준으로 쓴다. 아래 일곱 개념은 본문 어딘가에서 반드시 발목을 잡는 것들이다. 이미 아는 카드는 건너뛰어도 된다 — 각 카드 하단에 어느 장면에서 필요한지 적어 두었으니, 막히면 돌아와서 읽으면 된다.

P1

check-then-act, 그리고 레이스 컨디션 — 원자성의 문제

"확인하고(check) → 행동한다(act)"가 두 단계로 쪼개져 있으면, 확인과 행동 사이에 다른 스레드가 끼어들 수 있다. if (issued < total) 확인 후 issued++ 하는 코드가 정확히 이 모양이다. 누가 먼저 끼어드느냐에 따라 결과가 달라지는 상황을 레이스 컨디션이라 부른다.

어디서: §1 사건 전체. 999가 나온 근본 원인.
P2

트랜잭션과 커밋 — "언제 남에게 보이나" — 가시성의 문제

트랜잭션 안에서 바꾼 값은 커밋되기 전까지 다른 트랜잭션에게 보이지 않는다. 그래서 "내가 값을 바꿨다"와 "남이 그 값을 본다" 사이에 시차가 있다. 락이 원자성을 줘도, 락 경계가 커밋(가시성 경계)까지 덮지 못하면 남은 여전히 옛날 값(stale)을 읽는다.

어디서: §3 v2의 프록시 함정 — 락은 풀렸는데 커밋이 안 끝난 순간.
P3

@Transactional은 프록시다

Spring은 @Transactional 메서드를 직접 실행하지 않는다. 원본 객체를 감싼 프록시가 "트랜잭션 시작 → 원본 메서드 → 커밋"을 대신 수행한다. 그래서 커밋은 메서드 본문이 아니라 프록시 계층에서, 메서드가 끝난 뒤 일어난다 — 이 위치 감각이 없으면 v2를 이해할 수 없다.

어디서: §3 v2. synchronized를 어디에 둘지가 여기서 갈린다.
P4

커넥션 풀 — DB로 가는 차선은 10개뿐

앱은 DB 커넥션을 매번 새로 만들지 않고 풀(pool)에서 빌려 쓴다. 이 실험에선 HikariCP 풀 크기를 10으로 고정했다. 동시 요청이 500이어도 DB에 닿는 차선은 10개 — 나머지는 풀 앞에서 줄을 선다. 커넥션을 쥔 채로 무언가를 기다리면 차선이 그만큼 잠긴다.

어디서: §3 v3의 풀 포화(active 10/10, pending 189).
P5

프로세스 경계 — 스케일아웃이 깨뜨리는 것

JVM 안의 객체(모니터, static 변수…)는 그 프로세스 안에서만 존재한다. 서버를 2대로 늘리면(스케일아웃) 같은 코드가 두 프로세스에서 돌고, 각자 자기만의 락 객체를 갖는다. 서로의 락을 모르는 두 프로세스는 동시에 임계 구간에 들어간다.

어디서: §3 v2의 한계 — v3로 넘어가는 이유.
P6

Redis와 TTL — 만료되는 자물쇠

Redis는 모든 서버가 공유하는 외부 키-값 저장소다. 키에 TTL(유효 시간)을 걸면 시간이 지나 자동 삭제된다. 분산 락은 "키가 비어 있을 때만 내 것으로 세팅"으로 락을 흉내 내는데 — 이때 락을 현재 쥐고 있는 주체(이 글에선 락을 획득한 그 요청/스레드)를 홀더(holder)라 부른다. 홀더가 죽어도 락이 회수되도록 TTL을 건다. 문제는 — 홀더가 안 죽었는데 TTL이 먼저 끝나면?

어디서: §3 v4·v5, 그리고 §4 스톨 데모 전체.
P7

측정 용어 — VU, p95, 409와 503

VU는 부하 도구(k6)의 가상 사용자 — "500 VU"는 500명이 쉬지 않고 요청한다는 뜻. p95는 응답시간 백분위 — 95%의 요청이 이 시간 안에 끝났다는 뜻. HTTP 409는 "한도 소진/중복" 같은 정상 거절(상태가 그래서 — 재시도 무의미), 503은 "지금은 처리 못 함"(풀 고갈 ⓐ, 행락 대기 초과 ⓑ, 락 미획득 ⓒ — 타이밍이 그래서, 재시도 가능).

어디서: 모든 실측 수치 박스.

P7 보충 — 거절을 가르는 한 가지 질문

409와 503은 같은 "거절"처럼 보이지만 책임의 소재가 다르다. 기준은 하나 — 다시 보내면 결과가 달라질 수 있는가?

409 vs 503 — 거절의 두 종류 한정 수량 쿠폰 발급 사례 요청이 거절됐다 다시 보내면 결과가 달라질 수 있나? 아니오 — 상태가 그래서 예 — 타이밍이 그래서 409 Conflict — 영구 거절 · SOLD_OUT — 한도 소진 · ALREADY_ISSUED — 같은 사용자 중복 비즈니스 "상태"의 결론. 정상 경로다. → 재시도 중단 ("끝났어요") 503 Unavailable — 일시 거절 ⓐ 커넥션 풀 고갈 (connTO 30s 초과) ⓑ DB 행 락 대기 초과 (50s 초과) ⓒ 분산 락 미획득 (fail-fast) → 잠시 후 재시도 ("지금만 안 돼요") 부하 테스트에서의 독해 대기 락 (v2·v3) → 503 = 0 이 정상 — 경합을 지연으로 흡수 즉시거절 락 (v4·v5) → 503율 ~99% 가 정상 — 경합을 거절로 배출 같은 "거절률" 컬럼이라도 정책이 다르면 의미가 다르다 — 버전 간 직접 비교를 금지한 이유(§5).

밑줄 점선이 그어진 용어는 호버/탭으로 정의를 볼 수 있고, 전체 목록은 맨 아래 부록에 있다.

Part 1 · The Incident

사건 — 장부는 100이라고 말했다

발급 한도 total=100인 쿠폰에 500명의 가상 사용자가 10초간 동시에 달려들었다. 결과를 두 가지 방법으로 세어 보자.

v1 · No Lock — 같은 결과를 두 지표로 세다
발급 한도 (total_qty)100
쿠폰 테이블의 카운터 (coupon.issued)100  "한도 지켜진 듯?"
발급 이력 행 수 (SELECT COUNT(*) FROM coupon_issue)999
초과 발급+899 (10배)

두 숫자가 갈라졌다. 카운터는 100을 가리키는데, 실제 발급 이력은 999행이다. 카운터가 거짓말을 한 게 아니라, 카운터를 갱신하는 과정 자체가 동시성 앞에서 무너진 것이다. 이 현상이 lost update(갱신 손실)다.

🎟️ 비유. 점원 두 명이 동시에 장부를 본다. 둘 다 "지금까지 94장 나갔네"라고 읽는다. 각자 한 장씩 발급하고 "95장"이라고 적는다. 실제로는 96장이 나갔는데 장부엔 95 — 한 명의 기록이 통째로 증발했다. 점원이 500명이면, 장부는 100에서 멈추고 실제 발급은 999장이 된다.

코드로 보면 — 트랜잭션은 있었다

// v1 — @Transactional은 걸려 있다. 초과는 트랜잭션 부재가 아니라 동시성 제어 부재에서 난다.
@Transactional
public CouponIssueResponse issue(Long couponId, Long userId) {
    Coupon coupon = couponRepository.findById(couponId).orElseThrow(...);
    ...
    coupon.issue();   // 내부: if (issued < total) issued++  ← check-then-act, 비원자
    couponIssueRepository.save(new CouponIssue(couponId, userId));
    ...
}

coupon.issue() 안에서 일어나는 읽고(read) → 확인하고(check) → 더하는(increment) 세 단계가 하나의 원자적 동작이 아니다(선수지식 P1). 500개의 트랜잭션이 거의 같은 순간 같은 issued 값을 읽으면, 전부 "아직 한도 미만"이라는 확인을 통과해 버린다. 직접 눌러 보자.

레이스 시뮬레이터 — 세 스레드가 같은 장부를 읽으면
스레드 A
스레드 B
스레드 C
한도 total100
장부 issued (counter)97
실제 발급 행 수 (actual)97
버튼을 눌러 READ → CHECK → WRITE 순서로 진행해 보자.
⚠ 교훈 하나를 먼저 챙기자. 정합성 판정을 카운터로 하면 안 된다. 카운터는 lost update로 천장에 캡된 것처럼 보일 수 있다. 이 실험 전체에서 정합성의 단일 진실 소스SELECT COUNT(*) FROM coupon_issue — 실제 발급 행 수(actual)다.

해결의 방향은 단순하다 — 한 번에 한 명만 장부를 고치게 만들면 된다. 그 "한 번에 한 명"을 강제하는 장치가 락(lock)이고, 보호해야 하는 구간 — 여기서는 read·check·increment·insert — 을 임계 구간이라 부른다. 이제부터의 질문은 "락을 걸까 말까"가 아니라 — 락을 어디에 두고, 어떻게 운영할 것인가다.

🪶 락 = 발언권 막대. 회의에서 막대를 쥔 사람만 말할 수 있듯, 락을 쥔 스레드만 장부를 고친다. 다섯 버전의 차이는 결국 세 가지다 — 그 막대를 어디에 두는가, 막대가 비기를 기다릴 것인가 포기할 것인가, 막대를 얼마나 오래 쥘 수 있는가.
Part 2 · How to Read

버전을 가르는 세 개의 축

지금부터 다섯 버전이 나온다. 미리 못 박아 둘 것이 하나 있다 — 이 다섯은 "낮은 게 틀리고 높은 게 맞는" 등급이 아니다. 각자 다른 조건에서 각자 정답인 다섯 개의 도구다. "누가 빠른가"로 줄을 세우면 틀린 결론에 빠진다 (왜 그런지는 §5에서 직접 증명한다). 대신 이 세 가지 질문으로 읽으면 각 버전의 정체가 드러난다.

정합 범위 — 락이 어디까지 통하나

한 프로세스(JVM) 안에서만? 서버 여러 대 사이에서도? 락이 사는 위치가 이 범위를 정한다.

경합 흡수 — 막혔을 때 어쩌나

무시하고 들이받나(v1), 줄 서서 기다리나(v2·v3), 즉시 포기하고 거절하나(v4·v5). 직렬화 비용이 지연으로 나올지 거절로 나올지가 여기서 갈린다.

갱신 정책 — 쥔 채로 만료되면

락에 유효 시간이 있는 버전(v4·v5)만의 축. 자동으로 연장해 주나(워치독), 고정 시간이 지나면 무조건 떨어지나(고정 TTL).

Part 3 · The Ladder

한 문제, 다섯 번의

각 칸은 같은 구조로 읽는다 — 앞 칸에서 막힌 것 → 그 자리의 선택지 → 채택과 구현 → 실측 → 새로 드러난 한계. 한계가 다음 칸의 문이 된다.

v1FAIL · 초과 발급

No Lock — 문제를 또렷이 재현한다

기준선. 여기서 PASS가 났다면 그게 버그다.

락을 전혀 걸지 않고 500 VU를 들이받았다. 목적은 두 가지 — 초과 발급이 정말 재현되는지, 그리고 측정 도구가 그 사고를 제대로 잡아내는지. 결과는 §1에서 본 그대로다: actual=999, 초과 +899. 그리고 카운터는 100에 캡 — lost update의 물증.

정합 (actual)
999 / 100
FAIL = 기준선의 정답
성공 처리량
404.0/s
락 대기 0의 속도
성공 p95
1838ms
풀 10개 대기 비용

눈여겨볼 것: 무락인데도 p95가 1.8초다. 락이 없어도 커넥션 풀 10개를 500 VU가 나눠 쓰는 대기(선수지식 P4)는 그대로 있다 — 이후 모든 버전이 이 바닥 위에서 돈다.

막힌 것: 한정 수량 자원에서 초과는 곧 결함이다. 임계 구간을 직렬화할 무언가가 필요하다. → v2
v2PASS단일 JVM 한정

synchronized — 건물 안에 막대를 하나 둔다

정합 범위: 단일 JVM · 경합 흡수: park(무한 대기)

직렬화 수단의 후보는 셋이었다.

후보왜 검토했나왜 안 골랐나 / 골랐나
낙관적 락 (@Version)락을 안 잡고 충돌 시 재시도 — 저경합에 가볍다 단일 쿠폰(핫키)에 500 VU가 몰리는 초고경합 — 충돌·재시도가 폭증하는 재시도 폭풍
ReentrantLock타임아웃·공정성 등 세밀한 제어필요한 건 단일 임계 구간 하나 — 추가 제어를 쓸 데가 없어 복잡도만 증가
synchronized가장 단순한 JVM 모니터, 의도가 코드에 드러남채택 — 단, 프록시 함정 하나를 반드시 피해야 한다
⚠ 프록시 함정 — 이 버전의 진짜 내용. @Transactional 메서드에 synchronized같이 걸면 망한다. 커밋은 프록시 계층에서, 메서드가 끝난 뒤에 일어난다(선수지식 P3). 즉 모니터는 메서드 끝에서 풀리는데 커밋은 그 다음 — 락이 풀린 뒤 커밋되는 틈에 다음 스레드가 아직 커밋 안 된 stale issued를 읽는다(P2). 단일 JVM에서도 초과가 난다.

해법은 순서를 뒤집는 것이다 — 락이 트랜잭션을 감싸야 한다(락 ⊃ tx). 모니터를 트랜잭션 경계 바깥(파사드)에 두고, 실제 트랜잭션은 별도 빈을 프록시로 호출한다. 같은 빈 안에서 자기 메서드를 부르면 프록시를 안 거치므로 반드시 빈을 분리해야 한다.

// 파사드 — 모니터가 트랜잭션 경계를 감싼다 (락 ⊃ tx)
public CouponIssueResponse issue(Long couponId, Long userId) {
    synchronized (lock) {                       // ① 모니터 획득 — tx 밖
        return inner.issueTx(couponId, userId); // ② "별도 빈" 프록시 호출 → 시작·커밋이 모니터 안에서 완료
    }                                            // ③ 커밋이 끝난 뒤에야 모니터 해제
}

실측: actual=100, PASS. 그리고 카운터까지 100==actual — 직렬화가 read-modify-write를 원자화하자 카운터도 진실과 일치하게 됐다(v1과의 결정적 차이). 이 PASS 자체가 "파사드+별도 빈 분리가 런타임에 올바로 먹었다"는 증거다 — 분리가 실패했다면 단일 JVM에서도 초과가 났을 것이므로.

정합 (actual)
100 / 100
counter==actual
성공 처리량
263.6/s
전직렬화의 대가
성공 p95
2971ms
경합을 지연으로 흡수
503 거절
0
park = 무한 대기, 거절 없음
🏢 비유. 발언권 막대가 한 건물 안에만 있다. 그 건물 직원들끼리는 완벽하게 한 명씩 처리한다. 그런데 지점을 하나 더 열면? 새 건물엔 또 다른 막대가 생긴다. 두 건물은 서로의 막대를 모른 채 동시에 발급한다.
막힌 것: 모니터는 그 JVM 프로세스 안에서만 유효하다(P5). 인스턴스를 늘리는 순간 정합이 깨진다 — 이 붕괴 자체는 단일 JVM 측정으론 실증할 수 없어 알려진 한계로 기록해 둔다. 직렬화 좌표를 모두가 공유하는 곳으로 옮겨야 한다. → v3
v3PASS다중 인스턴스 (공유 DB행)

SELECT … FOR UPDATE — 중앙 장부가 직접 잠근다

정합 범위: 다중(공유 DB 행) · 경합 흡수: DB 대기열(지연)

"모두가 공유하는 좌표"의 후보도 셋이었다.

후보맞는 조건이 워크로드에선
DB 유니크 제약"같은 키의 중복"을 막을 때이미 unique(coupon_id, user_id)로 같은 사용자 중복은 차단 중. 그러나 지금 막을 것은 서로 다른 사용자들의 초과 — 유니크가 닿지 않는다
낙관적 락 + 재시도공유 + 저경합v2 자리에서 본 그대로 — 단일 핫키 고경합엔 재시도 폭풍
비관적 행 락 (FOR UPDATE)공유 + 고경합 + 보호 대상이 DB 안세 조건이 전부 들어맞는다

구조는 v2와 정반대다. DB 행 락은 커밋되는 순간 풀리므로, 락 획득·검사·발급을 전부 하나의 @Transactional에 넣는다 (단일 빈, 파사드 없음 — tx ⊃ 락이 자연 성립). 락을 잡은 뒤에 검사해야 stale 읽기가 원천 봉쇄된다.

// v3 — 하나의 @Transactional 안: 행 락 → 검사 → 발급 → 커밋(=락 해제)
@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));
    ...
}                                                                // 커밋 — 이 순간 행 락이 풀린다

실측: actual=100, PASS. 직렬화 지점이 로컬 모니터에서 공유 DB 행으로 이동했다. 인스턴스를 늘려도 모두 같은 행 락을 거치므로 정합이 경계를 넘는다 — 단, 이것도 단일 JVM 측정에서는 개념적 보장이다(다중 인스턴스 실증은 다음 과제로 명시해 둔다).

📊 이 선택에 딸려오는 비용 — 풀 압박. FOR UPDATE는 커넥션을 쥔 채 행 락을 기다린다(P4). 부하 중 풀을 들여다보면: active 10/10(포화), pending 189, idle 0. 결함이 아니라 이 선택의 특성이다 — 풀 크기·타임아웃을 핀으로 박아 측정·튜닝하는 대상.

흥미로운 건 503이 0건이라는 점이다. 풀이 포화됐는데 왜 아무도 타임아웃당하지 않았나? 임계 구간이 짧아서(검사+INSERT+커밋, 수 ms) 커넥션이 빠르게 순환했고, 어느 대기자도 커넥션 타임아웃 30s를 넘기지 않았다. 즉 — 이 조건에서 DB 행 락은 경합을 거절(503)이 아니라 지연(성공 p95 2.6s)으로 흡수한다. "타임아웃이 안 났다"는 빈칸이 아니라, 이 버전의 경합 흡수 방식을 보여주는 적극적인 관찰(음성 결과)이다.

정합 (actual)
100 / 100
PASS
성공 처리량
317.5/s
자체 특성으로만 읽기
성공 p95
2630ms
경합 = DB 대기열
503 거절
0
지연으로 흡수(음성 결과)
풀 (Hikari)
10/10
pending 189
📒 비유. 막대를 중앙 장부(DB)가 직접 들고 있다. 어느 건물이든 그 줄(row)을 고치려면 중앙 장부를 거쳐야 하고, 장부는 한 번에 한 명에게만 그 줄을 내준다. 건물이 몇 개든 장부는 하나라, 경계를 넘어 통한다.
그런데 한 가지 질문이 남는다: 직렬화 부담을 전부 DB가 떠안고 있고, 락 경합이 커넥션 풀을 잠근다. 락을 DB 으로 빼면 — 예컨대 Redis로 — DB 부하를 분리하고, DB와 무관한 임계 구간(외부 API 호출 같은)도 보호할 수 있지 않을까? 트렌드이기도 하니, 끝까지 가 보자. → v4
v4PASS (≤ total, 초과 0)다중 인스턴스 (외부 Redis)

Redisson RLock — 전용 열쇠 부스를 둔다

정합 범위: 다중(외부 Redis) · 경합 흡수: fail-fast(거절) · 갱신: 워치독

직렬화 좌표를 DB도 JVM도 아닌 외부 Redis로 옮긴다. v3 대비 무엇이 더해지고 무엇이 딸려오는지부터 표로 갈라 두자 — 이 표가 §6 결정의 재료가 된다.

DB 행 락 (v3)Redis 분산 락 (v4·v5)
보호 대상이 DB 안락↔데이터가 같은 시스템 — 원자적락↔데이터가 분리 — 틈이 생긴다(§4)
보호 대상이 DB 밖닿지 않음 (DB 트랜잭션 밖)적합 — 외부 API 멱등성 등 임의 구간 보호
DB 부하락 경합이 DB·커넥션 풀에 실림락 경합을 DB 밖으로 분리
인프라 비용추가 0Redis 운영 + SPOF
다중 인스턴스 정합공유 DB 행으로 이미 확보공유 Redis로 확보 — v3 대비 범위가 늘어나는 게 아님

구현은 Redisson 라이브러리의 RLock. 두 가지 운영 결정이 이 버전의 정체성이다:

RLock lock = redissonClient.getLock("lock:coupon:" + couponId);
boolean locked = lock.tryLock(0, TimeUnit.SECONDS);  // waitTime=0 — 못 잡으면 즉시 503 (fail-fast)
                                                       // leaseTime 미지정 — 워치독이 보유 중 자동 연장
if (!locked) throw new LockNotAcquiredException(couponId);
try {
    return inner.issueTx(couponId, userId);            // 별도 빈 @Transactional — 락 ⊃ tx (v2와 같은 골격)
} finally {
    if (lock.isHeldByCurrentThread()) lock.unlock();   // 미보유 unlock 금지 — 거절이 500으로 새는 것 방지
}

fail-fast(waitTime=0): 막대가 없으면 줄을 서지 않고 즉시 503으로 돌아간다. 그래서 10초 부하에선 한도를 다 못 채웠다 (actual=62) — 이건 정합 실패가 아니라 대기 큐가 없는 정책의 정상 귀결이다. 90초로 늘리자 정확히 100으로 수렴했고, 초과는 단 한 건도 없었다. 합격 기준은 actual ≤ total(초과 0).

워치독(leaseTime 미지정): 일하는 동안 비서가 자동으로 시간을 연장해 준다. 고정 시간을 박았다가 작업이 길어지면 보유 중에 락이 만료되는 사고(§4)가 나기 때문이다.

정합 (actual)
≤ total ✓
10s 62 → 90s 100 수렴, 초과 0
성공 처리량
6.3/s
⚠ §5 — 비교 금지
성공 p95
418ms
거절 p95는 50ms
503 거절률
99.9%
전부 ⓒ 락 미획득 (249,563건)

거절률 99.9%는 "분산 락은 다 이렇다"가 아니라 단일 핫키 + 즉시실패 조합의 귀결이다. 키가 분산되거나 짧은 대기를 주면 달라진다. 한편 v3에서 음성이던 503 분류 코드는 여기서 249,563건 전부가 미분류 0으로 라우팅되며 실주행이 증명됐다.

🔑 비유. 모든 건물이 들르는 별도 열쇠 부스(Redis)를 둔다. 열쇠를 받아야 발급하고, 일하는 동안 비서(워치독)가 반납 시간을 자동 연장해 준다. 열쇠가 없으면? 기다리지 않고 즉시 돌아간다(fail-fast).
막힌 것이 아니라, 확인하고 싶은 것: Redisson이 대신 해주는 것 — 자동 갱신과 안전한 해제 — 의 정체를 분리해 보고 싶다. 그걸 손으로 직접 짜 보면 "라이브러리가 사주는 안전"과 "어느 쪽도 못 사주는 안전"이 갈라진다. → v5
v5PASS다중 인스턴스 (외부 Redis)

커스텀 SET NX + Lua — 부스의 작동 원리를 직접 만든다

정합 범위: 다중(외부 Redis) · 경합 흡수: fail-fast(거절) · 갱신: 고정 TTL 30s 무갱신

분산 락의 원리는 두 문장이다. 획득은 SET key token NX PX ttl — "고리가 비었을 때만(NX) 내 이름표(token)를 건다, 유효시간(TTL)과 함께". 해제는 Lua compare-and-delete — "뗄 때 아직 내 이름표일 때만 뗀다".

// 획득 — 비어 있을 때만 내 토큰으로 세팅 (원자적)
redis.opsForValue().setIfAbsent(key, token, Duration.ofMillis(ttl));   // SET NX PX

// 해제 — 내 토큰일 때만 삭제 (Lua라서 get-비교-del이 원자적)
"if redis.call('get', KEYS[1]) == ARGV[1]
   then return redis.call('del', KEYS[1]) else return 0 end"

해제에 왜 Lua까지 동원하나? 그냥 DEL 하면, 내 락이 만료되고 남이 새로 잡은 락을 내가 지워버릴 수 있다(오해제). compare-del은 그 사고를 막는다 — 단, 이게 막아주는 건 오해제뿐이라는 사실이 §4의 복선이다.

⚠ 구현 함정 — 직렬화 불일치. 기본 RedisTemplate의 value 직렬화가 JSON이면 토큰에 따옴표가 붙는다. Lua의 raw 문자열 비교가 조용히 항상 실패 → 해제 불가 → 매 요청이 TTL 30초까지 락을 점유. 에러도 안 나서 잡기 어렵다. 락 전용 StringRedisTemplate으로 획득/해제의 직렬화를 일치시켜 회피했다 (스모크: 2연속 발급이 즉시 성공 = 매번 해제되고 있음).

갱신 정책은 v4와 반대로 고정 TTL 30초, 무갱신. 정상 경로의 락 보유 시간은 수 ms라 TTL 30s를 넘길 일이 없다 — 그래서 정상 부하에선 워치독과 고정 TTL을 구별할 수 없다 (이 사실이 §5의 두 번째 함정으로 이어진다).

정합 (actual)
100 / 100
PASS, 초과 0
성공 처리량
28.4/s
⚠ §5 — 비교 금지
성공 p95
106ms
거절 p95는 66ms
503 거절률
99.7%
전부 ⓒ (281,977건)
🏷️ 비유. 부스 직원이 하던 일을 직접 한다. SET NX = 빈 고리에만 내 이름표를 건다. Lua compare-del = 뗄 때 아직 내 이름표인지 확인하고 뗀다. 그리고 비서(워치독) 없이 — 30초가 지나면 이름표는 무조건 떨어진다.
이제 마지막 질문: 워치독이든 고정 TTL이든 — 락에 "만료"가 존재하는 한, 쥔 채로 만료되면 무슨 일이 벌어지는가? 정상 부하에선 절대 안 일어나니, 일부러 일으켜 보자. → §4

한눈에 — 막대의 위치가 정합 범위를 정한다

🏢

v2 · JVM 안

모니터(프로세스 내 객체)

서버 2대 = 막대 2개 → 깨짐
📒

v3 · 공유 DB 안

행 잠금 (중앙 장부)

서버 여러 대 OK · 락↔데이터 한몸
🔑

v4·v5 · 외부 Redis

열쇠 부스 (락 전용 저장소)

DB 밖 임의 구간도 보호 · 단, 락↔데이터 분리

락을 바깥으로 옮길수록 더 넓은 범위를 아우르지만 — 그만큼 새로운 비용(운영, SPOF)과 새로운 실패 양식이 따라온다. 그 실패 양식이 바로 다음 장이다.

Part 4 · The Stall Demo

락을 쥔 채 만료되면 — fencing의 부재

통제 실험이다. 한도 1짜리 쿠폰에 TTL 1초 < 작업 정지(stall) 3초를 일부러 박았다. 홀더 A가 락을 쥔 채 3초간 멈추는 동안 — GC pause나 네트워크 단절이 현실에서 일으키는 바로 그 상황 — A의 TTL이 먼저 만료된다. 그 순간 B가 "비어 있는" 고리에 자기 이름표를 건다.

total=1 · ttl=1s < stall=3s · 순차 트리거
홀더 A
락 획득·read
스톨 3s…
(TTL 만료됨)
…발급·커밋
홀더 B
만료된 락 획득 → 발급 → 커밋
TTL 만료
결과 — A의 해제 시도: released=false (compare-del이 "내 이름표가 아니다"를 보고 해제 거부 ✓)  ·  그럼에도 actual=2 > total=1 — 두 홀더가 동시 점유 ✗, 둘 다 발급됨.

이 한 줄이 시리즈 전체에서 가장 중요한 발견이다:

안전한 해제(오해제 차단)와 상호배제(동시 점유 차단)는 별개다. compare-del은 전자만 준다. 후자 — 만료-중간탈취로 두 홀더가 겹치는 것 — 는 fencing token (자원 측에서 번호표를 검증하는 장치) 없이는 어느 분산 락도 막지 못한다. 워치독(v4)도 마찬가지다 — GC pause나 네트워크 단절 앞에서는 갱신 자체가 멈추므로 같은 구멍이 열린다.

🎫 비유 — 번호표. 진짜 해법은 부스가 아니라 창구(자원) 쪽에 있다. 락을 받을 때마다 증가하는 번호표를 주고, 장부가 "37번 이후만 받습니다, 36번은 거부"라고 검증하면 늦게 깨어난 옛 홀더의 쓰기를 장부가 쳐낸다. 이 검증을 자원이 안 하면 — 부스를 아무리 잘 만들어도 못 막는다.
📌 범위 한정. 위의 actual=2는 데모의 통제 산물이다 — TTL을 일부러 작업보다 짧게 박았다. 정상 운영의 v4·v5는 둘 다 초과 0이었다. 이 데모가 말하는 건 "v4·v5가 틀렸다"가 아니라, 락과 데이터를 다른 시스템으로 분리하는 순간 구조적으로 열리는 틈이 존재한다는 것이다. 그리고 — 락과 데이터가 같은 트랜잭션 안에 있는 v3는 락이 커밋과 함께 풀리므로 "쥔 채 만료"라는 사건 자체가 성립하지 않는다. 이 차이가 §6 결정의 핵심 근거가 된다.
Part 5 · Reading the Numbers

왜 처리량으로 우승자를 뽑지 않았나

다섯 버전의 성공 처리량을 한 줄에 적으면 이렇다 — 404.0 / 263.6 / 317.5 / 6.3 / 28.4 (/s). 이 숫자들을 나란히 놓고 "v4가 제일 느린 락"이라거나 "DB 락이 synchronized보다 빠르다"고 읽고 싶어진다. 둘 다 틀린 독해다. 이 실험에서 가장 공들인 규율이 바로 이것이다.

함정 1 — 변한 건 락의 위치만이 아니다

v2·v3은 막히면 기다리고(park / DB 대기열), v4·v5는 막히면 즉시 거절한다(fail-fast). 같은 "성공 처리량" 컬럼이라도, 대기 정책은 경합을 전부 성공으로 흘려보내고 거절 정책은 경합의 99%를 503으로 배출한 뒤의 숫자다. 락 위치와 경합 정책이 동시에 움직였으니, 처리량 격차의 원인이 어느 쪽인지 이 측정만으로는 가릴 수 없다 — 교란 변수다. 사과(지연으로 흡수)와 오렌지(거절로 배출)를 한 그래프에 그린 셈이다.

그래서 별도의 통제 재실험을 돌렸다 — 경합 정책을 전 버전 "유한 대기"로 통일하고 락 위치만 바꿔서. 그러자 원래의 극적인 격차는 작은 단조 비용(락이 멀수록 약간 비쌈)으로 수렴했고, v3 > v2 같은 "반전"도 함께 무너졌다. 원래 격차의 지배 변수는 락의 기질이 아니라 경합 처리 방식이었던 것이다. 잘 나온 자기 수치라도, 교란이 있으면 헤드라인으로 쓰지 않는다.

함정 2 — 정상 부하는 갱신 정책을 가릴 수 없다

v5(28.4/s)가 v4(6.3/s)보다 빨랐다고 "고정 TTL이 워치독보다 빠르다"고 읽어도 틀린다. 락 보유 시간(수 ms)이 TTL(30s)보다 압도적으로 짧아, 정상 부하에선 고정 TTL이 중간에 만료될 일이 없다 — 즉 워치독이 막아줄 사건 자체가 안 생긴다. 두 버전의 처리량 차이는 갱신 정책이 아니라 대기/락 구현의 아티팩트(Redisson의 pub/sub + 갱신 스케줄링 vs 가벼운 SET NX)다. 갱신 정책의 진짜 차이는 §4처럼 보유 시간 > TTL인 조건에서만 드러난다.

⚠ 가져갈 규율. ① 정합성 판정은 카운터가 아니라 행 수로(단일 진실 소스). ② 버전 간 처리량 직접 비교 금지 — 비교하려면 교란 변수를 통제한 별도 실험으로. ③ "안 일어난 일"(503=0, 갱신 정책 무차이)도 빈칸이 아니라 기록할 발견이다.

합법적인 비교 — 세 축으로 본 다섯 버전

v1v2v3v4v5
정합 actual (total=100)999100100≤total ✓100
초과 발급+8990000
정합 범위없음단일 JVM다중 (공유 DB행)다중 (외부 Redis)다중 (외부 Redis)
경합 흡수무시(초과)parkDB 대기열(지연)fail-fast(거절)fail-fast(거절)
갱신 정책tx와 한몸워치독고정 TTL 30s
fencing 틈구조적으로 없음있음있음
맞는 조건초과 허용 집계단일 JVM·단순성고경합·보호 대상 DB 안비-DB 구간·부하 분리좌동(원리 노출)
Part 6 · The Verdict

사다리를 한 칸 내려와서 — v3

분산 락까지 직접 구현해 보고 — 정규 발급 경로 POST /api/coupons/issuev3(DB 비관적 행 락)에 배선했다. v1·v2·v4·v5는 비교 실험용으로 코드에 공존하되 운영 트래픽과 무관하다.

핵심 근거 — 락과 데이터가 한몸이다

이 워크로드의 임계 구간은 그 자체로 DB 쓰기다(coupon 행 UPDATE + coupon_issue INSERT). 보호할 대상이 이미 DB 안에 있다. v3는 락(FOR UPDATE 행 락)과 데이터가 같은 DB, 같은 트랜잭션 안에 있어 원자적이다 — 락은 커밋과 함께 풀리므로 "쥔 채 만료"가 성립하지 않고, fencing 틈이 구조적으로 없다. 락을 Redis로 빼는 순간 락↔데이터가 다른 시스템으로 분리되고 그 틈이 열린다 — §4 스톨 데모의 actual=2가 그 틈의 실측 증거다.

그래서 v3는 v4·v5보다 단지 "충분"한 게 아니라, 이 워크로드에선 더 안전하다. "왜 분산 락(트렌드)을 안 썼나"의 답은 트렌드 무지가 아니라 워크로드 적합성 판단이다 — 사다리를 끝까지 올라가 본 뒤에 내린.

근거 보강 — 그리고 정직한 꼬리표

다중 인스턴스 정합도 v3가 이미 준다. 공유 DB 행 락이라 인스턴스를 늘려도 모두 같은 행 락을 거친다 — Redis는 스케일아웃의 전제가 아니다. v4·v5가 v3 너머로 더해주는 건 정합의 범위가 아니라 "DB 밖 임계 구간 보호"뿐이다. (단, 이 다중 인스턴스 정합은 단일 JVM 측정에선 개념적 보장이다 — 다중 인스턴스 실측은 다음 과제로 백로그에 명시해 두었다.)

대가는 수용 가능하다. v3의 풀 압박(active 10/10, pending 189)은 측정·튜닝 가능한 비용이고 이 워크로드 규모에서 감당된다. 그리고 처리량은 근거로 쓰지 않았다 — §5의 규율 그대로. 결정의 근거는 빠름이 아니라 구조적 안전이다.

이 결정의 범위 — 조건이 바뀌면 답도 바뀐다

이 선택은 인프라 맥락의 함수다. Redis가 이미 핵심 캐시로 깔려 있고, DB 부하 분리가 절실하고, 보호 대상이 DB 밖(외부 API 호출의 멱등성 같은)이라면 — 그때는 락을 데이터에서 떼어낼 수밖에 없으니 분산 락이 본래 자리를 찾고, v4로 간다. 분산 락이 틀린 도구인 게 아니라, DB 행을 보호하는 일에 락을 데이터에서 떼어내는 것이 역효과(fencing 틈 + SPOF)일 뿐이다.

최종 장부 — 이 시리즈가 남긴 것
초과 발급9990
정규 발급 경로v3 · SELECT FOR UPDATE
결정의 근거락↔데이터 동치 (fencing 틈 없음)
처리량 우승자뽑지 않음 (§5 규율)
Appendix · Glossary

용어 정리

본문에 점선 밑줄로 등장한 용어 + 함께 알아두면 좋은 용어. 가나다·알파벳 혼합 순.

check-then-act
"확인하고 행동한다"가 두 단계로 쪼개진 패턴. 확인과 행동 사이에 다른 스레드가 끼어들면 확인이 무효가 된다. if (issued < total) issued++가 전형.
레이스 컨디션 (race condition)
실행 타이밍(누가 먼저 끼어드는가)에 따라 결과가 달라지는 상황. 동시성 버그의 뿌리.
lost update (갱신 손실)
두 실행 주체가 같은 값을 읽고 각자 갱신하면, 나중에 쓰는 쪽이 앞의 갱신을 덮어써 한 번의 갱신이 사라지는 현상. v1에서 카운터가 100에 캡된 이유.
임계 구간 (critical section)
동시에 둘 이상이 실행하면 결과가 깨지는 코드 구간. 락이 보호하는 대상.
직렬화 (serialization, 동시성 맥락)
동시에 들어온 요청을 한 줄로 세워 한 번에 하나씩 처리하게 만드는 것. 정합의 대가로 대기 또는 거절이 발생한다.
락 (lock) / 모니터 (monitor)
임계 구간에 한 번에 하나만 들어가게 강제하는 장치. 자바에서 모든 객체에 내장된 락을 모니터라 하며 synchronized가 이를 사용한다.
park
락을 기다리는 스레드를 OS 수준에서 재워 두는 것. v2의 경합 흡수 방식 — 거절 없이 무한 대기.
낙관적 락 (optimistic lock)
락을 잡지 않고 일단 진행한 뒤, 커밋 시점에 버전 번호로 충돌을 감지하면 재시도하는 방식. 충돌이 드문 저경합에 적합 — 단일 핫키 고경합에선 재시도 폭풍.
비관적 락 (pessimistic lock) / SELECT … FOR UPDATE
읽는 시점에 미리 행 락을 잡아 남이 못 건드리게 하는 방식. DB가 락을 관리하며 커밋 시 해제. v3의 메커니즘.
핫키 (hot key)
동시 요청이 한 개의 키/행에 집중되는 상황(이 실험: couponId=1 하나). 경합의 밀도를 정하는 조건.
프록시 (Spring AOP proxy)
Spring이 @Transactional 등을 구현하는 방식 — 원본 빈을 감싼 대리 객체가 "tx 시작 → 원본 호출 → 커밋"을 수행. 같은 빈 내부 자기호출은 프록시를 안 거친다.
커넥션 풀 / HikariCP
DB 커넥션을 미리 만들어 빌려주는 저장소. 이 실험은 풀 크기 10 고정 — 커넥션을 쥔 채 대기하면 풀이 포화된다(active 10/10, pending 189).
홀더 (holder)
지금 락을 쥐고 있는 주체 — 이 글에선 락을 획득해 발급 작업 중인 요청/스레드. 스톨 데모의 "홀더 A/B"가 이것. "보유 중 만료", "만료-중간탈취"는 전부 홀더 관점의 사건이다.
분산 락 (distributed lock)
여러 프로세스/서버가 공유하는 외부 저장소(Redis 등)에 두는 락. 프로세스 경계를 넘는 대신 락↔데이터 분리라는 새 위험을 떠안는다.
SET NX PX
Redis 명령 — "키가 없을 때만(NX) 값을 세팅하고 TTL(PX)을 건다". 원자적이라 분산 락 획득의 기본 프리미티브.
Lua compare-and-delete
"키의 값이 내 토큰일 때만 삭제"를 Lua 스크립트로 원자화한 해제. 오해제(남의 락 삭제)를 막는다 — 단, 상호배제는 못 준다.
TTL (time-to-live)
키의 유효 시간. 홀더가 죽어도 락이 회수되게 하는 안전핀이지만, 홀더가 살아 있는데 먼저 만료되면 만료-중간탈취가 열린다.
워치독 (watchdog)
Redisson이 락 보유 중 TTL을 자동 연장해 주는 백그라운드 장치. 보유 중 만료를 좁히지만 — GC pause/네트워크 단절 앞에선 갱신도 멈추므로 fencing을 대체하지 못한다.
fail-fast
자원을 못 잡으면 기다리지 않고 즉시 실패(503)를 반환하는 정책. 대기 큐가 없는 대신 거절률이 올라간다. v4·v5의 경합 흡수 방식.
fencing token (번호표)
락 획득마다 단조 증가하는 번호를 발급하고, 자원 측이 낮은 번호의 쓰기를 거부하게 하는 장치. 만료-중간탈취의 최종 방어선 — 자원이 검증하지 않으면 어느 분산 락도 동시 점유를 못 막는다.
SPOF (single point of failure)
이 하나가 죽으면 전체가 멈추는 구성 요소. 단일 노드 Redis 분산 락의 운영 리스크.
단일 진실 소스 (single source of truth)
지표들이 갈릴 때 무엇을 믿을지 미리 정한 기준. 이 실험: SELECT COUNT(*) FROM coupon_issue 행 수(actual).
교란 변수 (confounding variable)
비교 대상과 함께 움직여 인과를 가리는 제3의 변수. 락 위치와 경합 정책이 같이 변한 것이 이 실험의 교란 — 통제 재실험으로 분리했다.
음성 결과 (negative result)
"일어나지 않았다"는 관찰. v3의 503=0은 빈칸이 아니라 "행 락이 경합을 거절이 아닌 지연으로 흡수한다"는 발견이다.
VU / p95 / 409 / 503
VU=부하 도구의 가상 사용자. p95=요청 95%가 끝난 응답시간. 409=한도 소진·중복 등 정상 거절. 503=일시적 처리 불가(풀 고갈 ⓐ, 행락 타임아웃 ⓑ, 락 미획득 ⓒ로 서브분류).