이 글은 오버셀링을 두 겹으로 막기: 분산 락과 낙관적 락의 재검토 편입니다.

분산 락 글을 쓰고 얼마 뒤 유튜브에서 “재고 차감은 조건부 UPDATE 한 문장이면 락 없이도 안전하다” 는 주장을 봤습니다. PeekCart는 같은 문제를 Redis 분산 락 + DB 낙관적 락 두 겹으로 막았는데 그것이 정말 이 작업에 적정한 방식이었는지 다시 따져보고 싶어졌습니다. 결과를 먼저 보면 코드는 바꾸지 않기로 했다. 다만 그 “안 바꾼다”가 알고 남긴 결정 이 되도록, 언제 바꿀지의 기준을 함께 정했습니다. PeekCart는 포트폴리오로 보여 줄 학습 프로젝트라, 이 결정에는 기술적 적정성과 함께 무엇을 보여 줄지도 걸려 있었습니다. 이 글은 그 결정에 이르는 과정입니다.

먼저 이 글에서 쓰는 용어를 정리합니다. 특히 처음 두 개는 사람들이 자주 뭉뚱그리는데 이 글의 절반은 그 둘을 가르는 데 있습니다.

용어이 글에서의 의미
@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)
자원 토폴로지한 연산이 건드리는 자원의 형태 - 단일 행 / 다중 행과 다중 자원 / 핫 로우 등

이 연재는 PeekCart를 네 단계로 진행했습니다. Phase 1은 단일 Spring Boot 모놀리스로 도메인을 구현한 단계, Phase 2는 캐시, 분산 락, Kafka와 Outbox로 성능과 정합성을 보강한 단계, Phase 3은 CI, Kubernetes, 관측성, 부하 테스트로 운영 환경을 검증한 단계, Phase 4는 서비스별로 나눈 MSA 단계입니다.

이번 재검토에서 답하고 싶은 질문은 다음과 같습니다.

  1. @Transactional만으로는 왜 오버셀링을 못 막는데 조건부 UPDATE는 락 없이도 막을까?
  2. PeekCart의 분산 락은 이 작업에 과방어일까? 그렇다면 왜 그럴까?
  3. “조건부 UPDATE로 바꾼다”는 결정은 실제로 얼마나 큰 결정일까?
  4. 바꾸면 잃는 것은 무엇이고 안 바꾸면 잃는 것은 무엇일까?
  5. 조건부 UPDATE로 바꾸면 핫 로우 고부하 TPS도 해결될까?
  6. 그래서 둘 중 무엇이 맞을까? 아니면 질문 자체가 틀렸을까?
  7. 조건부 UPDATE로 바꾸면 @Version은 빼도 될까?
  8. Phase 4에서 Inventory가 분리되면 이 결정은 어떻게 달라질까?

1. 계기 - @Transactional은 원자성이지 직렬화가 아니다

영상에서 지적한 내용은 다음과 같았습니다. 재고 차감을 @Transactional로 감싸는 것만으로는 동시성에서 안전하지 않습니다. 그런데 사람들이 여기서 자주 멈추는 오해가 있습니다. “트랜잭션으로 묶었으니 원자적이고, 원자적이면 동시에 들어와도 안전한 거 아닌가?”

아닙니다. 원자성과 직렬화는 다른 보장입니다.

T1: SELECT stock (=1)
T2: SELECT stock (=1)      ← 둘 다 1을 읽음
T1: stock = 1 - 1 = 0 → commit
T2: stock = 1 - 1 = 0 → commit   ← T1의 갱신이 사라짐(lost update)
결과: 재고는 0인데 두 건이 팔림, 오버셀링

각 트랜잭션은 자기 안에서 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

즉 조건 평가와 차감이 하나의 락 구간 안에서 원자적으로 일어납니다. 1절의 “조회와 차감을 한 문장으로 합쳤다”는 답을 한 단계 더 풀면, 질문 1의 답은 합친 UPDATE의 읽기가 스냅샷이 아닌 current read라는 점입니다. 단일 행 차감에 한해, 이것은 교과서적 정답에 가깝습니다.

3. 현재 PeekCart의 차감 경로

PeekCart의 차감 경로는 영상 방식과 다릅니다. 세 레이어를 거칩니다(자세한 설계 의도는 분산 락 글).

레이어코드역할
Redis 분산 락InventoryLockFacade#decreaseStock정상 상황 동시 요청 직렬화 (wait 3s / lease 5s)
트랜잭션InventoryService#decreaseStockSELECT, 엔티티 로드, decrease(), 커밋 경계
낙관적 락Inventory#version (@Version)Redis 장애와 락 만료 시 stale 갱신 거부 (최후 방어)

흐름은 락 획득 → 트랜잭션(조회·검증·차감·커밋) → 락 해제이고 검증 로직(stock < quantity → PRD-002)은 엔티티 Inventory#decrease() 안에 있습니다.

이 중 동시 요청을 막는 것은 분산 락과 낙관적 락이고, 트랜잭션은 그 둘이 지키는 범위를 정합니다. 이 글에서 “두 겹”은 분산 락과 낙관적 락을 가리킵니다.

참고: 복구(restoreStock)는 분산 락 경로를 타지 않는다. @Transactional + @Version만으로 보호된다. 즉 분산 락은 차감 경로 단독 전용이다.

4. 두 방식 비교

항목현재 (분산 락 + 낙관적 락)조건부 UPDATE
네트워크 홉Redis lock, DB SELECT, DB UPDATE, Redis unlockDB UPDATE 1회
락 보유 시간Redis 락이 차감 트랜잭션 구간(주문 경로에서는 주문 커밋 전에 해제)DB row 락이 UPDATE부터 커밋까지(단독 호출이면 짧고, 주문 경로에서는 주문 커밋까지)
직렬화 범위같은 상품 모든 요청이 Redis 한 점을 통과DB 엔진이 row 단위로만
외부 의존핫패스에 Redis 필수불필요
동시성 안전성보장 (단독 호출은 2겹, 주문 경로는 사실상 @Version 1겹)보장 (DB row 락)

핫 상품 기준 TPS는 구조상 조건부 UPDATE가 유리할 것으로 봅니다. 홉이 적고 Redis를 거치지 않습니다. 두 방식을 같은 조건에서 측정하지는 않았습니다. 그렇다면 단일 행 차감에 분산 락은 과할까? 먼저 분산 락이 정당해지는 경우 부터 살핍니다.

  • 여러 row/자원을 한 트랜잭션에서 묶어 조율할 때 (예: 재고 + 쿠폰 + 포인트 동시 차감)
  • DB 락 경합과 데드락을 앞단에서 미리 줄이고 싶을 때
  • DB 바깥 자원까지 함께 동기화해야 할 때

지금 decreaseStock 호출 하나는 한 상품의 한 행만 건드립니다. 여러 자원을 묶지도, DB 바깥 자원을 건드리지도 않습니다. 행 하나만 잠그니 잠금 순서가 엇갈려 생기는 데드락을 앞단에서 줄일 이유도 없습니다. 위 세 경우 중 어디에도 해당하지 않습니다.

설계대로라면 @Version은 “Redis lease(5s)가 트랜잭션 도중 만료되는 엣지케이스”의 백업이고, 분산 락이 동작하는 한 거의 발동하지 않습니다. 다만 이것은 차감이 독립 트랜잭션으로 실행될 때의 이야기입니다. 실제 주문 경로에서는 클래스 단위 @Transactional이 붙은 OrderCommandService가 차감을 호출하고, InventoryService의 트랜잭션은 그 바깥 트랜잭션에 합류합니다. 그래서 Facade의 finally가 락을 푸는 시점이 주문 커밋보다 앞섭니다. 이 경로에서는 분산 락의 직렬화가 커밋까지 이어지지 않고, 정합성은 @Version에 기댑니다. 이 결함은 8절과 10절에서 다시 다룹니다.

5. 결정의 진짜 크기 - 인프라 전체가 이 경로 하나에 묶여 있다

여기서 대부분의 비교는 “조건부 UPDATE가 더 빠르다”에서 멈춥니다. 하지만 확인해야 할 것은 “그래서 바꾸는 게 얼마나 큰 결정이냐” 입니다. 분산 락 인프라가 어디에 쓰이는지 전수 조사했습니다.

컴포넌트사용처
DistributedLockManager (global/lock)InventoryLockFacade만
RedissonConfig 와 RedissonClient위 매니저만
InventoryLockFacadeProductPortAdapter#decreaseStockAndGetUnitPrice만

호출 지점은 단 한 곳이다. Redisson 의존성을 포함한 분산 락 인프라 전체 가 단일 행 재고 차감 하나를 위해 존재합니다. 따라서 “조건부 UPDATE로 바꾼다”는 분산 락 인프라를 통째로 들어낼 것인가 의 결정이 됩니다.

이 사실이 결정을 바라보는 기준을 바꿉니다. 거꾸로 보면, 이 코드는 프로젝트에서 “Redisson 분산 락을 다뤄보고 싶다” 는 초기 목표를 채워주는 거의 유일한 자산이기도 합니다. 단일 메서드 리팩토링이라면 망설일 게 없지만 “인프라를 들어낸다 vs 시연 자산을 남긴다”의 결정이라면 얘기가 완전히 달라집니다.

6. 바꾸면 잃는 것 - 그리고 그게 과장이 아닌지 점검

성능과 단순성만 보면 조건부 UPDATE가 이기지만 전환에는 대가가 있습니다.

  1. 에러 구분이 무너진다. 현재는 PRD-001(상품 없음)과 PRD-002(재고 부족)를 구분합니다. affected rows == 0은 둘을 구분하지 못합니다. 살리려면 실패 경로에서 SELECT를 한 번 더 실행해야 하고 그만큼 이점이 깎입니다(단, 실패 경로에서만).
  2. 차감 시점 도메인 이벤트를 붙이기 어려워진다. 지금 차감에는 이벤트가 없습니다. 나중에 엔티티 변경에 이벤트나 Outbox 기록을 묶으려 해도, 벌크 UPDATE는 JPA dirty checking을 거치지 않으므로 그 훅을 실행하기 어렵습니다.
  3. DDD 제약과의 충돌. 처음에는 “조건부 UPDATE는 stock >= qty 불변식을 엔티티에서 SQL로 끌어내린다”를 전환 비용으로 꼽았는데, 이것은 설계 선택 입니다. 검증은 여전히 inventory.decrease()가 표현하게 두고 Repository는 그 결과를 벌크 UPDATE로 반영하는 절충(검증=엔티티 / 영속화 전략=쿼리)이 가능합니다. 그래서 이 항목은 “신경 쓰지 않으면 깨지기 쉽다” 정도로 읽는 게 맞을 것 같습니다.

반대로, 안 바꾸고 방치만 해도 안 됩니다. 단일 행에 분산 락은 over-engineering이 맞고, “왜 여기 분산 락?”에 답이 없으면 곤란합니다. 모르고 남긴 것 과 알고 남긴 것 은 코드가 같아도 의미가 다릅니다. 이 기준은 8절에서 결정으로 정리합니다.

7. 천장 - 조건부 UPDATE도 핫 로우 고부하 TPS의 답은 아니다

여기서 한 가지를 더 짚어야 합니다. PeekCart는 대용량 트래픽 을 표방하는 프로젝트인데, “단일 자원은 조건부 UPDATE”로 깔끔하게 정리하면 처리량 한계를 알기 어렵습니다.

조건부 UPDATE도 결국 그 한 행의 DB 배타 락에 직렬화 됩니다. 플래시 세일처럼 한 상품에 트래픽이 쏠리면, 그 한 행이 곧 병목입니다. 조건부 UPDATE는 분산 락보다 빠르지만(홉이 적으니), “단일 핫 상품 TPS”라는 시나리오의 답은 아니다.

오버셀링을 막는 길은 보통 넷으로 셉니다. 비관적 락, 낙관적 락, 분산 락, 조건부 UPDATE. 핫 로우 TPS의 답은 그 넷에 없는 다섯 번째입니다.

  • Redis 원자 카운터(DECRBY)를 재고의 source of truth로 두고 DB는 비동기 reconciliation
  • 또는 재고를 N개 버킷으로 샤딩 해 락 경합을 분산(stock_bucket_0 ~ stock_bucket_{N-1})

즉 동시성 제어 방식은 트래픽 강도에 따라 계층을 이룹니다.

트래픽 강도 / 자원 형태적정 해법
단일 행, 일반 동시성조건부 UPDATE (정답에 가까움)
다중 자원과 교차 시스템 조율분산 락 (재고+쿠폰+포인트, DB 바깥 자원)
단일 핫 로우, 고부하 TPSRedis 카운터 / 샤딩 + 비동기 reconciliation

PeekCart의 현재 분산 락 구조도, 조건부 UPDATE도 세 번째 칸은 넘지 못합니다. 거기는 다른 영역입니다. 이 경계를 그어 두었다는 점이, 조건부 UPDATE에서 멈추지 않고 더 나아가 따져 본 결과입니다.

8. 결정 - 자원 토폴로지에 따른 선택

그래서 내린 결정은 이렇습니다.

코드는 그대로 두고, 인식만 전환한다. 단일 행 차감에 분산 락이 과방어임을 명시적으로 인정 하고, 적정 구간 선택 기준을 정해 “알고 남긴 결정”으로 만든다.

결정의 근거는 두 축이었습니다.

  • 기술 관점: 영상이 맞습니다. 단일 행 차감은 UPDATE ... WHERE stock >= qty + affected-rows 체크가 정답에 가깝고 분산 락 제거 후보입니다.
  • 포트폴리오 관점: 전환의 이득(TPS, 단순성)은 이 프로젝트가 실제 부하를 받지 않는 한 측정되지도, 보이지도 않습니다. 제가 이 프로젝트로 보여 주려는 것은 코드와 설계 판단입니다. 반면 앞에서 봤듯 전환은 분산 락 인프라 전체 를 들어내는 일이라 측정되지 않는 이득을 위해 보이는 시연 자산을 버리는 손익 역전이 됩니다.

여기서 스스로 짚고 넘어가야 할 반론이 하나 있습니다. “시연하려는 그 분산 락이 주문 경로에서 무력화돼 있다면(4절에서 본, 락이 주문 커밋보다 먼저 풀리는 결함), 그건 ‘분산 락을 다룰 줄 안다’가 아니라 ‘틀리게 썼다’는 시그널 아닌가?” 맞는 지적입니다.

그래서 시연 자산의 가치를 “현재 배선이 완벽하다”에 두지 않는다. 그 가치는 분산 락 패턴을 이해하고 적정 구간을 판단할 줄 안다(이 글 전체가 그 과정입니다)에 있습니다. 그리고 그 배선 결함은 알고 별도 기술 부채로 추적 중 이므로, 결함을 못 본 경우와는 정반대 시그널입니다.

무력화 자체는 모놀리스 트랜잭션 경계의 산물이라 Phase 4 분리로 소멸하지만 ‘지금 코드’를 보는 리뷰어에게 답이 되는 건 그 인지와 추적 입니다.

그런데 한 가지 유혹이 있습니다. “그럼 차감은 조건부 UPDATE로 내리고, 분산 락은 다중 자원 경로로 재배치 하면 삭제보다 정직하지 않나?”에 대해서는 기각 했습니다. PeekCart에는 쿠폰과 포인트 도메인이 없고 Phase 4의 조율 모델은 Choreography Saga(이벤트 기반 비동기 보상)라 이 분산 락 인프라를 쓰지 않습니다. 옮길 자리가 없는 재배치 는 가상의 사용처를 위한 premature한 구조 도입일 뿐입니다.

그래서 재배치 트리거 만 정해 두었습니다. 실측 부하에서 분산 락 경합이 병목으로 측정되거나 실제 동기 다중 자원 차감 경로(쿠폰/포인트 등)가 도입되면, 그때 차감을 조건부 UPDATE로 내리고 분산 락을 그 경로로 옮깁니다. 지금 PeekCart는 둘 다 아닙니다.

한 문장으로:

분산 락 경합이 실측 병목으로 드러나거나 다중 자원 조율이 로드맵에 있으면 전환(또는 재배치), 둘 다 아니면 코드는 그대로 둔다. 지금은 후자다.

9. 그럼 @Version은 빼도 되나

전환을 검토하면 따라오는 질문이 하나 있습니다. “조건부 UPDATE로 바꾸면 @Version을 완전히 빼도 되는가?” 3절의 참고에서 본, 복구 경로는 분산 락을 타지 않는다는 사실이 답의 절반입니다.

조건부 UPDATE는 그 자체로 lost update를 막으므로, 차감 경로에서 @Version은 중복 입니다. 게다가 벌크 UPDATE는 dirty checking을 거치지 않아 @Version이 자동 증가하지도 않습니다. 둘은 자연스럽게 결합되지도 않습니다.

하지만 restoreStock은 여전히 read-modify-write 입니다(조건부 UPDATE 없이 inventory.restore(), 엔티티 변경, flush 순서). 거기서는 @Version이 유지되어야 합니다. 그래서 만약 전환해도 @Version을 전부 제거할 수는 없고, “차감 경로에서는 중복, 복구 경로에선 잔존” 이라는 비대칭으로 끝납니다. 3절에서 본 차감 경로와 복구 경로의 비대칭이 여기서도 그대로 남습니다. (지금은 전환하지 않으므로 @Version은 양쪽 다 그대로 둡니다.)

10. Phase 4 MSA에서는 어떻게 바뀌는가

지금 이 결정은 “한 프로세스, 한 DB, 한 트랜잭션” 위에 서 있습니다. Phase 4에서 Inventory가 별도 서비스와 별도 DB로 분리되면 그림이 바뀝니다.

그대로 가는 것

  • 조건부 UPDATE vs 분산 락의 선택 기준 자체 는 분리 후에도 유효합니다. 오히려 더 또렷해집니다. Inventory Service 안에서 “단일 행 차감을 어떻게 직렬화할 것인가”는 여전히 같은 질문이고 답도 같은 표로 정해집니다.
  • @Version 은 Inventory DB 안의 동시성 제어라 서비스가 쪼개져도 유지됩니다. 서비스 사이를 한 트랜잭션으로 묶을 수 없으니, 재고 행의 갱신 충돌을 막는 이 장치의 비중은 오히려 커집니다.

바뀌는 것

  • 분산 락 글에서 본 “주문 경로가 락 ⊃ 트랜잭션 불변식을 깬다”는 결함이 자연 소멸한다. Order와 Inventory가 다른 서비스가 되면 Order 트랜잭션이 Inventory 차감 트랜잭션을 더 이상 감쌀 수 없습니다. 그러면 유지하기로 한 분산 락이 비로소 설계대로 동작합니다. 4절에서 본 대로 모놀리스 단계의 주문 경로에서는 그 직렬화가 커밋까지 이어지지 않아 정합성을 @Version에 기대고 있었습니다. 이 점이 분산 락 유지 결정의 가장 솔직한 한계입니다.
  • 재배치 트리거가 현실이 될 수 있다. Phase 4에서 쿠폰과 포인트 같은 도메인이 추가되고 “주문 시 재고+쿠폰+포인트 묶음 차감” 경로가 생기면 앞서 정한 그대로 차감은 조건부 UPDATE로 내리고 분산 락을 그 다중 자원 경로로 옮기는 게 가장 적절한 구조가 됩니다.
  • 다중 Pod가 분산 락의 근거가 되지는 않는다. Pod가 하나라면 JVM 락으로도 충분했겠지만, Phase 3에서 이미 HPA로 모놀리스 Pod가 최대 3개까지 늘었습니다(동시 주문 부하 테스트 글). Phase 4에서 Inventory Pod가 따로 늘어도 사정은 같습니다. 그래도 단일 행 차감은 조건부 UPDATE가 DB에서 직렬화하므로, Pod가 여럿이라는 사실만으로 분산 락이 필요해지지는 않습니다. 분산 락이 꼭 필요해지는 곳은 위의 다중 자원 경로입니다.

11. 이 글은 어떤 질문에 연결해서 읽을까

개념연결해 읽을 자료
원자성과 직렬화의 차이Designing Data-Intensive Applications (Kleppmann) - Weak Isolation / Lost Update
snapshot read vs current readMySQL Reference - Consistent Nonlocking Reads / Locking Reads
핫 로우 분산 (카운터/샤딩)Redis 원자 연산 / sharding 패턴

정리하면 이 보론은 “분산 락 vs 조건부 UPDATE”라는 이분법으로 시작했지만 도착한 곳은 동시성 제어의 적정 구간을 트래픽 강도와 자원 토폴로지로 계층화 하는 기준이었습니다. 그리고 그 기준에서 PeekCart의 현재 위치는 “기술적으론 조건부 UPDATE가 맞지만 포트폴리오 맥락에선 알고 남긴 분산 락”입니다. 코드 대신 결정 을 남기는 것이 재검토의 결과물입니다.