100장 한정 쿠폰이 999장 발급됐다. 락 없음 → JVM → DB → Redis 분산 락까지 다섯 단계를 전부 구현·측정하고, 마지막에 사다리를 한 칸 내려와 DB 행 락을 선택한 기록.
이 글은 "동시성 제어를 한 번도 해본 적 없는" 백엔드 개발자를 기준으로 쓴다. 아래 일곱 개념은 본문 어딘가에서 반드시 발목을 잡는 것들이다. 이미 아는 카드는 건너뛰어도 된다 — 각 카드 하단에 어느 장면에서 필요한지 적어 두었으니, 막히면 돌아와서 읽으면 된다.
"확인하고(check) → 행동한다(act)"가 두 단계로 쪼개져 있으면, 확인과 행동 사이에 다른 스레드가 끼어들 수 있다.
if (issued < total) 확인 후 issued++ 하는 코드가 정확히 이 모양이다.
누가 먼저 끼어드느냐에 따라 결과가 달라지는 상황을 레이스 컨디션이라 부른다.
트랜잭션 안에서 바꾼 값은 커밋되기 전까지 다른 트랜잭션에게 보이지 않는다. 그래서 "내가 값을 바꿨다"와 "남이 그 값을 본다" 사이에 시차가 있다. 락이 원자성을 줘도, 락 경계가 커밋(가시성 경계)까지 덮지 못하면 남은 여전히 옛날 값(stale)을 읽는다.
@Transactional은 프록시다Spring은 @Transactional 메서드를 직접 실행하지 않는다. 원본 객체를 감싼
프록시가 "트랜잭션 시작 → 원본 메서드 → 커밋"을 대신 수행한다.
그래서 커밋은 메서드 본문이 아니라 프록시 계층에서, 메서드가 끝난 뒤 일어난다 —
이 위치 감각이 없으면 v2를 이해할 수 없다.
synchronized를 어디에 둘지가 여기서 갈린다.앱은 DB 커넥션을 매번 새로 만들지 않고 풀(pool)에서 빌려 쓴다. 이 실험에선 HikariCP 풀 크기를 10으로 고정했다. 동시 요청이 500이어도 DB에 닿는 차선은 10개 — 나머지는 풀 앞에서 줄을 선다. 커넥션을 쥔 채로 무언가를 기다리면 차선이 그만큼 잠긴다.
JVM 안의 객체(모니터, static 변수…)는 그 프로세스 안에서만 존재한다. 서버를 2대로 늘리면(스케일아웃) 같은 코드가 두 프로세스에서 돌고, 각자 자기만의 락 객체를 갖는다. 서로의 락을 모르는 두 프로세스는 동시에 임계 구간에 들어간다.
Redis는 모든 서버가 공유하는 외부 키-값 저장소다. 키에 TTL(유효 시간)을 걸면 시간이 지나 자동 삭제된다. 분산 락은 "키가 비어 있을 때만 내 것으로 세팅"으로 락을 흉내 내는데 — 이때 락을 현재 쥐고 있는 주체(이 글에선 락을 획득한 그 요청/스레드)를 홀더(holder)라 부른다. 홀더가 죽어도 락이 회수되도록 TTL을 건다. 문제는 — 홀더가 안 죽었는데 TTL이 먼저 끝나면?
VU는 부하 도구(k6)의 가상 사용자 — "500 VU"는 500명이 쉬지 않고 요청한다는 뜻. p95는 응답시간 백분위 — 95%의 요청이 이 시간 안에 끝났다는 뜻. HTTP 409는 "한도 소진/중복" 같은 정상 거절(상태가 그래서 — 재시도 무의미), 503은 "지금은 처리 못 함"(풀 고갈 ⓐ, 행락 대기 초과 ⓑ, 락 미획득 ⓒ — 타이밍이 그래서, 재시도 가능).
409와 503은 같은 "거절"처럼 보이지만 책임의 소재가 다르다. 기준은 하나 — 다시 보내면 결과가 달라질 수 있는가?
밑줄 점선이 그어진 용어는 호버/탭으로 정의를 볼 수 있고, 전체 목록은 맨 아래 부록에 있다.
발급 한도 total=100인 쿠폰에 500명의 가상 사용자가 10초간 동시에 달려들었다.
결과를 두 가지 방법으로 세어 보자.
두 숫자가 갈라졌다. 카운터는 100을 가리키는데, 실제 발급 이력은 999행이다. 카운터가 거짓말을 한 게 아니라, 카운터를 갱신하는 과정 자체가 동시성 앞에서 무너진 것이다. 이 현상이 lost update(갱신 손실)다.
// 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 값을 읽으면,
전부 "아직 한도 미만"이라는 확인을 통과해 버린다. 직접 눌러 보자.
SELECT COUNT(*) FROM coupon_issue — 실제 발급 행 수(actual)다.해결의 방향은 단순하다 — 한 번에 한 명만 장부를 고치게 만들면 된다. 그 "한 번에 한 명"을 강제하는 장치가 락(lock)이고, 보호해야 하는 구간 — 여기서는 read·check·increment·insert — 을 임계 구간이라 부른다. 이제부터의 질문은 "락을 걸까 말까"가 아니라 — 락을 어디에 두고, 어떻게 운영할 것인가다.
지금부터 다섯 버전이 나온다. 미리 못 박아 둘 것이 하나 있다 — 이 다섯은 "낮은 게 틀리고 높은 게 맞는" 등급이 아니다. 각자 다른 조건에서 각자 정답인 다섯 개의 도구다. "누가 빠른가"로 줄을 세우면 틀린 결론에 빠진다 (왜 그런지는 §5에서 직접 증명한다). 대신 이 세 가지 질문으로 읽으면 각 버전의 정체가 드러난다.
한 프로세스(JVM) 안에서만? 서버 여러 대 사이에서도? 락이 사는 위치가 이 범위를 정한다.
무시하고 들이받나(v1), 줄 서서 기다리나(v2·v3), 즉시 포기하고 거절하나(v4·v5). 직렬화 비용이 지연으로 나올지 거절로 나올지가 여기서 갈린다.
락에 유효 시간이 있는 버전(v4·v5)만의 축. 자동으로 연장해 주나(워치독), 고정 시간이 지나면 무조건 떨어지나(고정 TTL).
각 칸은 같은 구조로 읽는다 — 앞 칸에서 막힌 것 → 그 자리의 선택지 → 채택과 구현 → 실측 → 새로 드러난 한계. 한계가 다음 칸의 문이 된다.
락을 전혀 걸지 않고 500 VU를 들이받았다. 목적은 두 가지 — 초과 발급이 정말 재현되는지, 그리고 측정 도구가 그 사고를 제대로 잡아내는지. 결과는 §1에서 본 그대로다: actual=999, 초과 +899. 그리고 카운터는 100에 캡 — lost update의 물증.
눈여겨볼 것: 무락인데도 p95가 1.8초다. 락이 없어도 커넥션 풀 10개를 500 VU가 나눠 쓰는 대기(선수지식 P4)는 그대로 있다 — 이후 모든 버전이 이 바닥 위에서 돈다.
직렬화 수단의 후보는 셋이었다.
| 후보 | 왜 검토했나 | 왜 안 골랐나 / 골랐나 |
|---|---|---|
낙관적 락 (@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에서도 초과가 났을 것이므로.
"모두가 공유하는 좌표"의 후보도 셋이었다.
| 후보 | 맞는 조건 | 이 워크로드에선 |
|---|---|---|
| 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)으로 흡수한다. "타임아웃이 안 났다"는 빈칸이 아니라, 이 버전의 경합 흡수 방식을 보여주는 적극적인 관찰(음성 결과)이다.
직렬화 좌표를 DB도 JVM도 아닌 외부 Redis로 옮긴다. v3 대비 무엇이 더해지고 무엇이 딸려오는지부터 표로 갈라 두자 — 이 표가 §6 결정의 재료가 된다.
| 축 | DB 행 락 (v3) | Redis 분산 락 (v4·v5) |
|---|---|---|
| 보호 대상이 DB 안 | 락↔데이터가 같은 시스템 — 원자적 | 락↔데이터가 분리 — 틈이 생긴다(§4) |
| 보호 대상이 DB 밖 | 닿지 않음 (DB 트랜잭션 밖) | 적합 — 외부 API 멱등성 등 임의 구간 보호 |
| DB 부하 | 락 경합이 DB·커넥션 풀에 실림 | 락 경합을 DB 밖으로 분리 |
| 인프라 비용 | 추가 0 | Redis 운영 + 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)가 나기 때문이다.
거절률 99.9%는 "분산 락은 다 이렇다"가 아니라 단일 핫키 + 즉시실패 조합의 귀결이다. 키가 분산되거나 짧은 대기를 주면 달라진다. 한편 v3에서 음성이던 503 분류 코드는 여기서 249,563건 전부가 미분류 0으로 라우팅되며 실주행이 증명됐다.
분산 락의 원리는 두 문장이다. 획득은 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의 두 번째 함정으로 이어진다).
모니터(프로세스 내 객체)
서버 2대 = 막대 2개 → 깨짐행 잠금 (중앙 장부)
서버 여러 대 OK · 락↔데이터 한몸열쇠 부스 (락 전용 저장소)
DB 밖 임의 구간도 보호 · 단, 락↔데이터 분리락을 바깥으로 옮길수록 더 넓은 범위를 아우르지만 — 그만큼 새로운 비용(운영, SPOF)과 새로운 실패 양식이 따라온다. 그 실패 양식이 바로 다음 장이다.
통제 실험이다. 한도 1짜리 쿠폰에 TTL 1초 < 작업 정지(stall) 3초를 일부러 박았다. 홀더 A가 락을 쥔 채 3초간 멈추는 동안 — GC pause나 네트워크 단절이 현실에서 일으키는 바로 그 상황 — A의 TTL이 먼저 만료된다. 그 순간 B가 "비어 있는" 고리에 자기 이름표를 건다.
released=false (compare-del이 "내 이름표가 아니다"를 보고 해제 거부 ✓)
· 그럼에도 actual=2 > total=1 — 두 홀더가 동시 점유 ✗, 둘 다 발급됨.
이 한 줄이 시리즈 전체에서 가장 중요한 발견이다:
안전한 해제(오해제 차단)와 상호배제(동시 점유 차단)는 별개다. compare-del은 전자만 준다. 후자 — 만료-중간탈취로 두 홀더가 겹치는 것 — 는 fencing token (자원 측에서 번호표를 검증하는 장치) 없이는 어느 분산 락도 막지 못한다. 워치독(v4)도 마찬가지다 — GC pause나 네트워크 단절 앞에서는 갱신 자체가 멈추므로 같은 구멍이 열린다.
다섯 버전의 성공 처리량을 한 줄에 적으면 이렇다 — 404.0 / 263.6 / 317.5 / 6.3 / 28.4 (/s). 이 숫자들을 나란히 놓고 "v4가 제일 느린 락"이라거나 "DB 락이 synchronized보다 빠르다"고 읽고 싶어진다. 둘 다 틀린 독해다. 이 실험에서 가장 공들인 규율이 바로 이것이다.
v2·v3은 막히면 기다리고(park / DB 대기열), v4·v5는 막히면 즉시 거절한다(fail-fast). 같은 "성공 처리량" 컬럼이라도, 대기 정책은 경합을 전부 성공으로 흘려보내고 거절 정책은 경합의 99%를 503으로 배출한 뒤의 숫자다. 락 위치와 경합 정책이 동시에 움직였으니, 처리량 격차의 원인이 어느 쪽인지 이 측정만으로는 가릴 수 없다 — 교란 변수다. 사과(지연으로 흡수)와 오렌지(거절로 배출)를 한 그래프에 그린 셈이다.
그래서 별도의 통제 재실험을 돌렸다 — 경합 정책을 전 버전 "유한 대기"로 통일하고 락 위치만 바꿔서. 그러자 원래의 극적인 격차는 작은 단조 비용(락이 멀수록 약간 비쌈)으로 수렴했고, v3 > v2 같은 "반전"도 함께 무너졌다. 원래 격차의 지배 변수는 락의 기질이 아니라 경합 처리 방식이었던 것이다. 잘 나온 자기 수치라도, 교란이 있으면 헤드라인으로 쓰지 않는다.
v5(28.4/s)가 v4(6.3/s)보다 빨랐다고 "고정 TTL이 워치독보다 빠르다"고 읽어도 틀린다. 락 보유 시간(수 ms)이 TTL(30s)보다 압도적으로 짧아, 정상 부하에선 고정 TTL이 중간에 만료될 일이 없다 — 즉 워치독이 막아줄 사건 자체가 안 생긴다. 두 버전의 처리량 차이는 갱신 정책이 아니라 대기/락 구현의 아티팩트(Redisson의 pub/sub + 갱신 스케줄링 vs 가벼운 SET NX)다. 갱신 정책의 진짜 차이는 §4처럼 보유 시간 > TTL인 조건에서만 드러난다.
| v1 | v2 | v3 | v4 | v5 | |
|---|---|---|---|---|---|
| 정합 actual (total=100) | 999 | 100 | 100 | ≤total ✓ | 100 |
| 초과 발급 | +899 | 0 | 0 | 0 | 0 |
| 정합 범위 | 없음 | 단일 JVM | 다중 (공유 DB행) | 다중 (외부 Redis) | 다중 (외부 Redis) |
| 경합 흡수 | 무시(초과) | park | DB 대기열(지연) | fail-fast(거절) | fail-fast(거절) |
| 갱신 정책 | — | — | tx와 한몸 | 워치독 | 고정 TTL 30s |
| fencing 틈 | — | — | 구조적으로 없음 | 있음 | 있음 |
| 맞는 조건 | 초과 허용 집계 | 단일 JVM·단순성 | 고경합·보호 대상 DB 안 | 비-DB 구간·부하 분리 | 좌동(원리 노출) |
분산 락까지 직접 구현해 보고 — 정규 발급 경로 POST /api/coupons/issue는
v3(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)일 뿐이다.
본문에 점선 밑줄로 등장한 용어 + 함께 알아두면 좋은 용어. 가나다·알파벳 혼합 순.
if (issued < total) issued++가 전형.synchronized가 이를 사용한다.SELECT … FOR UPDATE@Transactional 등을 구현하는 방식 — 원본 빈을 감싼 대리 객체가 "tx 시작 → 원본 호출 → 커밋"을 수행. 같은 빈 내부 자기호출은 프록시를 안 거친다.SET NX PXSELECT COUNT(*) FROM coupon_issue 행 수(actual).