이 글은 9편 - 오버셀링을 두 겹으로 막기: 분산 락과 낙관적 락의 재검토 편이다.
9편을 쓰고 얼마 뒤 유튜브에서 “재고 차감은 조건부 UPDATE 한 문장이면 락 없이도 안전하다” 는 주장을 봤다. PeekCart는 같은 문제를 Redis 분산 락 + DB 낙관적 락 두 겹으로 막았는데, 그게 정말 이 작업에 적정한 무게였는지 다시 따져보고 싶어졌다. 결론부터 말하면 코드는 바꾸지 않기로 했다. 다만 그 “안 바꾼다”가 몰라서 그대로 둔 것 이 아니라 알고 남긴 결정 이 되도록, 판단 기준을 별도의 설계 결정 기록(ADR, Architecture Decision Record)으로 남겼다. 이 글은 그 판단에 이르는 과정이다.
먼저 이 글에서 쓰는 용어를 정리한다. 특히 처음 두 개는 사람들이 자주 뭉뚱그리는데, 이 글의 절반은 그 둘을 가르는 데 있다.
| 용어 | 이 글에서의 의미 |
|---|---|
@Transactional의 원자성 | 한 요청 안의 여러 DB 변경이 전부 커밋되거나 전부 롤백(all-or-nothing). 동시 요청을 직렬화하지는 않는다. |
| 조건부 UPDATE | 재고 확인(WHERE stock >= :qty)과 차감(SET stock = stock - :qty)을 DB 단일 문장으로 결합 |
| Lost Update | 두 트랜잭션이 같은 값을 읽고 각자 갱신해, 한쪽 갱신이 사라지는 현상. 오버셀링의 원인 |
| 잠금 읽기(current read) | UPDATE/SELECT ... FOR UPDATE가 하는 읽기. MVCC 스냅샷이 아니라 최신 커밋 값을 다시 읽고 락을 잡는다 |
| 일관 읽기(snapshot read) | 평범한 SELECT. 트랜잭션 시작 시점의 스냅샷을 본다(REPEATABLE READ) |
| 자원 토폴로지 | 한 연산이 건드리는 자원의 형태 - 단일 행 / 다중 행·다중 자원 / 핫 로우 등 |
이번 재검토에서 답하고 싶은 질문은 다음과 같다.
@Transactional만으로는 왜 오버셀링을 못 막는데, 조건부 UPDATE는 락 없이도 막는가?- PeekCart의 분산 락은 이 작업에 과방어인가? 그렇다면 왜 그런가?
- “조건부 UPDATE로 바꾼다”는 결정은 실제로 얼마나 큰 결정인가?
- 바꾸면 잃는 것은 무엇이고, 안 바꾸면 잃는 것은 무엇인가?
- 그래서 둘 중 무엇이 맞는가? 아니면 질문 자체가 틀렸는가?
1. 계기 - @Transactional은 원자성이지 직렬화가 아니다
영상의 지적은 이거였다. 재고 차감을 @Transactional로 감싸는 것만으로는 동시성에서 안전하지 않다. 그런데 사람들이 여기서 자주 멈추는 오해가 있다. “트랜잭션으로 묶었으니 원자적이고, 원자적이면 동시에 들어와도 안전한 거 아닌가?”
아니다. 원자성과 직렬화는 다른 보장이다.
T1: SELECT stock (=1)
T2: SELECT stock (=1) ← 둘 다 1을 읽음
T1: 1개 차감 → commit
T2: 1개 차감 → commit ← lost update → 재고 -1, 오버셀링
각 트랜잭션은 자기 안에서 all-or-nothing이다. 하지만 트랜잭션은 동시 요청을 자동으로 한 줄로 세워주지 않는다. “재고는 0 이상”이라는 비즈니스 불변식은 트랜잭션 경계와는 별개의 동시성 제어가 있어야 지켜진다. 영상의 출발점은 정확하다.
그럼 영상이 말한 해법은 무엇이냐.
UPDATE inventories
SET stock = stock - :qty
WHERE product_id = :id AND stock >= :qty;
-- affected rows == 1 → 차감 성공
-- affected rows == 0 → 재고 부족(또는 상품 없음)
조회와 차감을 하나의 UPDATE로 합치면 애플리케이션에서 “조회 → if 검증 → 변경”을 쪼개지 않으므로, 그 틈에 끼어들 다른 트랜잭션이 없다. 락도 @Version도 없이 lost update가 사라진다는 것이다.
2. 조건부 UPDATE는 왜 락 없이도 안전한가 - 한 단계 더
“DB가 row 락으로 직렬화해주니까”는 절반의 답이다. 진짜 헷갈리는 지점은 따로 있다. MySQL(InnoDB) 기본 격리수준은 REPEATABLE READ인데, 그럼 두 트랜잭션이 각자 자기 스냅샷(stock=1)을 보고 둘 다 깎으면 안 되나?
답은 쓰기는 스냅샷을 읽지 않는다 는 데 있다.
- 평범한
SELECT stock은 일관 읽기(snapshot read) 다. 트랜잭션 시작 시점의 MVCC 스냅샷을 본다. 그래서SELECT → 검증 → UPDATE로 쪼개면 검증이 보는 값이 stale일 수 있다(앞의 lost update). - 그러나
UPDATE ... WHERE stock >= :qty가 행을 찾을 때 하는 읽기는 잠금 읽기(current read) 다. 스냅샷이 아니라 그 순간 최신 커밋된 값을 다시 읽고 배타 락을 잡는다.
그래서 같은 행에 두 UPDATE가 동시에 들어오면, DB 엔진이 row 배타 락으로 직렬화하고 뒤에 들어온 쪽은 앞 트랜잭션이 커밋한 결과(stock=0)를 다시 읽어 WHERE stock >= 1을 평가한다. 조건이 깨졌으니 affected rows = 0. 차감되지 않는다.
T1: UPDATE ... WHERE stock>=1 → row X-lock 획득, stock 1→0, commit
T2: UPDATE ... WHERE stock>=1 → (T1 락 대기) → 최신값 0 재읽기 → 조건 불만족 → 0 rows
즉 조건 평가와 차감이 하나의 락 구간 안에서 원자적으로 일어난다. “왜 @Transactional 격리수준만으로는 안 되는데 조건부 UPDATE는 되는가”의 완성된 답은 읽기를 스냅샷에서 current read로 바꿨기 때문이다. 단일 행 차감에 한해, 이건 교과서적 정답에 가깝다.
3. 현재 PeekCart는 - 사실상 삼중 방어
PeekCart의 차감 경로는 영상 방식과 다르다. 세 겹으로 쌓여 있다(자세한 설계 의도는 9편).
| 레이어 | 코드 | 역할 |
|---|---|---|
| Redis 분산 락 | InventoryLockFacade#decreaseStock | 정상 상황 동시 요청 직렬화 (wait 3s / lease 5s) |
| 트랜잭션 | InventoryService#decreaseStock | SELECT → 엔티티 로드 → decrease() → 커밋 경계 |
| 낙관적 락 | Inventory#version (@Version) | Redis 장애·락 만료 시 stale 갱신 거부 (최후 방어) |
흐름은 락 획득 → 트랜잭션(조회·검증·차감·커밋) → 락 해제이고, 검증 로직(stock < quantity → PRD-002)은 엔티티 Inventory#decrease() 안에 있다.
한 가지 용어를 분명히 해두자. 엄밀히 트랜잭션 레이어는 동시성 방어 가 아니라 경계 다. 직렬화를 실제로 책임지는 건 분산 락과 낙관적 락 두 겹이다. 그래서 이 글이 이후 “두 겹”이라 부르는 건 실제 방어를 센 것이고, 위 표의 “삼중”은 차감이 거쳐 가는 스택을 그대로 센 것일 뿐이다. 둘은 모순이 아니라 같은 구조의 두 가지 셈법이다.
참고: 복구(
restoreStock)는 분산 락 경로를 타지 않는다.@Transactional+@Version만으로 보호된다. 즉 분산 락은 차감 경로 단독 전용이다.
4. 두 방식 비교
| 항목 | 현재 (분산 락 + 낙관적 락) | 조건부 UPDATE |
|---|---|---|
| 네트워크 홉 | Redis lock → DB SELECT → DB UPDATE → Redis unlock | DB UPDATE 1회 |
| 락 보유 시간 | Redis 락이 트랜잭션 전체 구간 | DB row 락이 UPDATE 순간만 |
| 직렬화 범위 | 같은 상품 모든 요청이 Redis 한 점을 통과 | DB 엔진이 row 단위로만 |
| 외부 의존 | 핫패스에 Redis 필수 | 불필요 |
| 동시성 안전성 | 보장 (2겹) | 보장 (DB row 락) |
핫 상품 기준 TPS는 조건부 UPDATE가 명백히 유리하다. 홉이 적고 락 보유 시간이 짧다. 그렇다면 단일 행 차감에 분산 락은 과한가? 그 판단은 이후 섹션에서 다뤄보고 여기서는 먼저 분산 락이 정당해지는 경우 부터 살핀다. 그런 경우는 따로 있다.
- 여러 row/자원을 한 트랜잭션에서 묶어 조율할 때 (예: 재고 + 쿠폰 + 포인트 동시 차감)
- DB 락 경합·데드락을 앞단에서 미리 줄이고 싶을 때
- DB 바깥 자원까지 함께 동기화해야 할 때
지금 decreaseStock은 한 상품의 한 행만 건드린다. 위 어디에도 해당하지 않는다. 그래서 @Version은 사실상 “Redis lease(5s)가 트랜잭션 도중 만료되는 엣지케이스”의 백업일 뿐, 분산 락이 정상 동작하는 한 거의 발동하지 않는다.
5. 결정의 진짜 크기 - 인프라 전체가 이 경로 하나에 묶여 있다
여기서 대부분의 비교는 “조건부 UPDATE가 더 빠르다”에서 멈춘다. 하지만 진짜 물어야 할 건 “그래서 바꾸는 게 얼마나 큰 결정이냐” 다. 분산 락 인프라가 어디에 쓰이는지 전수 조사했다.
| 컴포넌트 | 사용처 |
|---|---|
DistributedLockManager (global/lock) | InventoryLockFacade만 |
RedissonConfig → RedissonClient | 위 매니저만 |
InventoryLockFacade | ProductPortAdapter#decreaseStockAndGetUnitPrice만 |
호출 지점은 단 한 곳이다. Redisson 의존성을 포함한 분산 락 인프라 전체 가 단일 행 재고 차감 하나를 위해 존재한다. 따라서 “조건부 UPDATE로 바꾼다”는 메서드 하나 교체가 아니라 분산 락 인프라를 통째로 들어낼 것인가 의 결정이 된다.
이 사실이 결정의 무게를 뒤집는다. 거꾸로 보면, 이 코드는 프로젝트에서 “Redisson 분산 락을 다뤄보고 싶다” 는 초기 목표를 채워주는 거의 유일한 자산이기도 하다. 단일 메서드 리팩토링이라면 망설일 게 없지만 “인프라를 들어낸다 vs 시연 자산을 남긴다”의 결정이라면 얘기가 완전히 달라진다.
6. 바꾸면 잃는 것 - 그리고 그게 과장이 아닌지 점검
성능·단순성만 보면 조건부 UPDATE가 이기지만, 전환에는 대가가 있다.
- 에러 구분이 무너진다. 현재는 PRD-001(상품 없음)과 PRD-002(재고 부족)를 구분한다.
affected rows == 0은 둘을 구분하지 못한다. 살리려면 실패 경로에서 SELECT를 한 번 더 쏴야 하고, 그만큼 이점이 깎인다(단, 실패 경로에서만). - 차감 시점 도메인 이벤트가 어려워진다. 벌크 UPDATE는 JPA dirty checking을 거치지 않으므로, 엔티티 변경에 묶인 이벤트/Outbox 훅을 태우기 어렵다.
- DDD 제약과의 충돌. “조건부 UPDATE는
stock >= qty불변식을 엔티티에서 SQL로 끌어내린다”고 적었었는데, 이건 불가피한 결과가 아니라 설계 선택 이다. 검증은 여전히inventory.decrease()가 표현하게 두고, Repository는 그 결과를 벌크 UPDATE로 반영하는 절충(검증=엔티티 / 영속화 전략=쿼리)이 가능하다. 그래서 이 항목은 “반드시 깨진다”가 아니라 “신경 쓰지 않으면 깨지기 쉽다” 정도로 읽는 게 맞을 것 같다.
반대로, 안 바꾸고 방치만 해도 안 된다. 단일 행에 분산 락은 over-engineering이 맞고, 안목 있는 리뷰어는 그걸 알아챈다. “왜 여기 분산 락?”에 답이 없으면 오히려 감점이다. 모르고 남긴 것 과 알고 남긴 것 은 코드가 같아도 시그널이 정반대다.
7. 천장 - 조건부 UPDATE도 핫 로우 고부하 TPS의 답은 아니다
여기서 한 가지를 더 짚어야 한다. PeekCart는 대용량 트래픽 을 표방하는 프로젝트인데, “단일 자원 → 조건부 UPDATE”로 깔끔하게 정리하면 천장이 안 보인다.
조건부 UPDATE도 결국 그 한 행의 DB 배타 락에 직렬화 된다. 플래시 세일처럼 한 상품에 트래픽이 쏠리면, 그 한 행이 곧 병목이다. 조건부 UPDATE는 분산 락보다 빠르지만(홉이 적으니), “단일 핫 상품 TPS”라는 시나리오의 답은 아니다.
오버셀링을 막는 길은 보통 넷으로 센다. 비관적 락, 낙관적 락, 분산 락, 조건부 UPDATE. 핫 로우 TPS의 답은 그 넷에 없는 다섯 번째다.
- Redis 원자 카운터(
DECRBY)를 재고의 source of truth로 두고, DB는 비동기 reconciliation - 또는 재고를 N개 버킷으로 샤딩 해 락 경합을 분산(
stock_bucket_0..N)
즉 동시성 제어는 우열이 아니라 트래픽 강도에 따른 계층 이다.
| 트래픽 강도 / 자원 형태 | 적정 해법 |
|---|---|
| 단일 행, 일반 동시성 | 조건부 UPDATE (정답에 가까움) |
| 다중 자원·교차 시스템 조율 | 분산 락 (재고+쿠폰+포인트, DB 바깥 자원) |
| 단일 핫 로우, 고부하 TPS | Redis 카운터 / 샤딩 + 비동기 reconciliation |
중요한 건 PeekCart의 현재 분산 락 구조도 조건부 UPDATE도 세 번째 칸은 못 넘는다. 거기는 다른 영역이다. 이 경계를 그어두는 것 자체가 “조건부 UPDATE에서 멈추지 않았다”는 신호다.
8. 결정 - 우열이 아니라 자원 토폴로지에 따른 선택
그래서 내린 결정은 이렇다.
코드는 그대로 두고, 인식만 전환한다. 단일 행 차감에 분산 락이 과방어임을 명시적으로 인정 하되, 적정 구간 선택 기준을 ADR로 명문화해 “알고 남긴 결정”으로 만든다.
판단의 근거는 두 축이었다.
- 기술 관점: 영상이 맞다. 단일 행 차감은
UPDATE ... WHERE stock >= qty+ affected-rows 체크가 정답에 가깝고, 분산 락 제거 후보다. - 포트폴리오 관점: 전환의 이득(TPS·단순성)은 이 프로젝트가 실제 부하를 받지 않는 한 측정되지도, 보이지도 않는다. 내가 이 프로젝트로 말하고자 하는 건 벤치마크 숫자가 아니라 코드와 ADR이다. 반면 앞에서 봤듯 전환은 분산 락 인프라 전체 를 들어내는 일이라 측정되지 않는 이득을 위해 보이는 시연 자산을 버리는 손익 역전이 된다.
여기서 스스로 짚고 넘어가야 할 반론이 하나 있다. “시연하려는 그 분산 락이 지금 무력화돼 있다면(9편에서 짚은 결함, 주문 경로의 바깥 트랜잭션이 락 ⊃ 트랜잭션 불변식을 깬다), 그건 ‘분산 락을 다룰 줄 안다’가 아니라 ‘틀리게 썼다’는 시그널 아닌가?” 맞는 지적이다. 그래서 시연 자산의 가치를 “현재 배선이 완벽하다”에 두지 않는다. 그 가치는 분산 락 패턴을 이해하고 적정 구간을 판단할 줄 안다(이 글 전체가 그 판단이다)에 있다. 그리고 그 배선 결함은 모르고 둔 것 이 아니라 알고 별도 기술 부채로 추적 중 이라는 점에서 결함을 못 본 것과 정반대 시그널이다. 무력화 자체는 모놀리스 트랜잭션 경계의 산물이라 Phase 4 분리로 소멸하지만, ‘지금 코드’를 보는 리뷰어에게 답이 되는 건 그 인지와 추적 이다.
그런데 한 가지 유혹이 있다. “그럼 차감은 조건부 UPDATE로 내리고, 분산 락은 다중 자원 경로로 재배치 하면 삭제보다 정직하지 않나?”에 대해서는 기각 했다. PeekCart에는 쿠폰·포인트 도메인이 없고, Phase 4의 조율 모델은 Choreography Saga(이벤트 기반 비동기 보상)라 이 분산 락 인프라를 쓰지 않는다. 옮길 자리가 없는 재배치 는 가상의 사용처를 위한 premature한 구조 도입일 뿐이다.
그래서 결정 기록에는 재배치 트리거 만 남겼다. 실측 부하에서 분산 락 경합이 병목으로 관측되거나 실제 동기 다중 자원 차감 경로(쿠폰/포인트 등)가 도입되면, 그때 차감을 조건부 UPDATE로 내리고 분산 락을 그 경로로 옮긴다. 지금 PeekCart는 둘 다 아니다.
한 문장으로:
부하가 실재하거나 다중 자원 조율이 로드맵에 있으면 전환(또는 재배치), 둘 다 아니면 코드는 두고 ADR만 쓴다. 지금은 후자다.
9. 남겨둔 질문에 답하기 - 그럼 @Version은 빼도 되나
앞서 한 가지 질문을 미뤄뒀다. “조건부 UPDATE로 바꾸면 @Version을 완전히 빼도 되는가?” 이제 답할 수 있다.
조건부 UPDATE는 그 자체로 lost update를 막으므로, 차감 경로에서 @Version은 중복 이다. 게다가 벌크 UPDATE는 dirty checking을 거치지 않아 @Version이 자동 증가하지도 않는다. 둘은 자연스럽게 결합되지도 않는다.
하지만 restoreStock은 여전히 read-modify-write 다(조건부 UPDATE가 아니라 inventory.restore() → 엔티티 변경 → flush). 거기서는 @Version이 살아 있어야 한다. 그래서 만약 전환한다면 “@Version 전부 제거”가 아니라 “차감 경로에서는 중복, 복구 경로에선 잔존” 이라는 비대칭으로 끝난다. 앞에서 미리 짚은 그 비대칭이 여기서 결론을 맺는다. (지금은 전환하지 않으므로 @Version은 양쪽 다 그대로 둔다.)
10. 이 글은 어떤 질문에 연결해서 읽을까
| 개념 | 연결해 읽을 자료 |
|---|---|
| 원자성 ≠ 직렬화 | Designing Data-Intensive Applications (Kleppmann) - Weak Isolation / Lost Update |
| snapshot read vs current read | MySQL Reference - Consistent Nonlocking Reads / Locking Reads |
| over-engineering 인정과 결정 기록 | ADR 방법론 (Michael Nygard) |
| 핫 로우 분산 (카운터/샤딩) | Redis 원자 연산 / sharding 패턴 |
11. Phase 4 MSA에서는 어떻게 바뀌는가
지금 이 결정은 “한 프로세스·한 DB·한 트랜잭션” 위에 서 있다. Phase 4에서 Inventory가 별도 서비스·별도 DB로 분리되면 그림이 바뀐다.
그대로 가는 것
- 조건부 UPDATE vs 분산 락의 선택 기준 자체 는 분리 후에도 유효하다. 오히려 더 또렷해진다. Inventory Service 안에서 “단일 행 차감을 어떻게 직렬화할 것인가”는 여전히 같은 질문이고, 답도 같은 표로 정해진다.
@Version은 Inventory DB 안의 동시성 제어라 서비스가 쪼개져도 산다. 분산 트랜잭션이 사라지면서 “한 DB 안의 원자성”이 유일하게 믿을 수 있는 보장이 되므로 오히려 더 중요해진다.
바뀌는 것
- 9편에서 본 “주문 경로가 락 ⊃ 트랜잭션 불변식을 깬다”는 결함이 자연 소멸한다. Order와 Inventory가 다른 서비스가 되면 Order 트랜잭션이 Inventory 차감 트랜잭션을 더 이상 감쌀 수 없다. 그러면 유지하기로 한 분산 락이 비로소 설계대로 동작한다. 모놀리스 단계에선 사실 그 직렬화가 무력화된 채
@Version이 일하고 있었다는 점이, 분산 락 유지 결정의 가장 솔직한 한계다. - 재배치 트리거가 현실이 될 수 있다. Phase 4에서 쿠폰·포인트 같은 도메인이 추가되고 “주문 시 재고+쿠폰+포인트 묶음 차감” 경로가 생기면 앞서 정한 그대로 차감은 조건부 UPDATE로 내리고 분산 락을 그 다중 자원 경로로 옮기는 게 가장 정직한 그림이 된다.
- 단일 Pod에서 다중 Pod로의 의미 확장. 모놀리스 단일 Pod에선 분산 락이 사실 JVM 락으로도 충분했지만, HPA로 Inventory Pod가 늘면(17편) 분산 락이 비로소 본질적으로 필요해진다. 단, 그 필요는 “단일 행 차감”이 아니라 위 다중 자원 경로에서 정당화된다.
정리하면, 이 보론은 “분산 락 vs 조건부 UPDATE”라는 이분법으로 시작했지만, 도착한 곳은 동시성 제어의 적정 구간을 트래픽 강도와 자원 토폴로지로 계층화 하는 지도였다. 그리고 그 지도 위에서 PeekCart의 현재 좌표는 “기술적으론 조건부 UPDATE가 맞지만 포트폴리오 맥락에선 알고 남긴 분산 락”이다. 코드가 아니라 결정 을 남기는 것이 재검토의 결과물이다.