본문으로 건너뛰기
Forward Engineering
Go back

MySQL 크레딧 차감 락 4종 비교 — 비관락 180ms / 100% 정확, 그리고 측정 도중 발견한 self-invocation 함정

- views

Table of contents

Open Table of contents

들어가며

결제 도메인 코드 리뷰 중에 또 그 패턴이 눈에 들어왔습니다. 잔액에서 1을 차감 — 너무 흔해서 사람들이 별생각 없이 쓰는 코드. SELECT balance → if (balance >= amount) → UPDATE balance - amount. 한 명일 땐 문제없습니다.

그런데 누군가 무심코 던진 질문이 머릿속을 헤집었습니다. “100명이 동시에 1씩 차감하면, 잔액 100 인 계정이 정말 0 으로 끝나나요?” 머릿속으로는 “락 잡으면 되겠지”라고 답했지만, 어떤 락을 잡아야 하는지 — 그리고 락을 잡았다고 끝나는지 자신 있게 말할 수 있는 사람은 적습니다.

자료를 뒤져보면 더 어이가 없습니다. 낙관락이 빠르다 는 글, 비관락이 안전하다 는 글, 분산락은 Redisson 이라는 글이 따로따로 흩어져 있습니다. 같은 시나리오에 4 가지 락을 모두 적용해서 측정값을 나란히 놓은 글은 거의 없었습니다.

그래서 직접 측정했습니다. 잔액 100 / 100 worker / 1 씩 차감 / 4 락 — 같은 시나리오를 4 락 (낙관/비관/MySQL GET_LOCK/Redisson) 으로 보호해서, 처리량과 정확성이 어떻게 갈리는지 끝까지 풀어봤습니다.

그러다 측정 도중 더 깊은 함정을 만났습니다. 첫 측정에서 낙관락이 successes=100 인데 잔액이 그대로 100 이었습니다. 코드 logic 이 틀린 게 아니었습니다 — Spring 의 AOP proxy 가 우회되어서 @Transactional 이 작동하지 않았습니다. 같은 클래스 내부 호출의 알려진 함정. JPA / Spring 의 진짜 함정은 코드 logic 이 아니라 AOP proxy 우회 였습니다.

이 글은 그 측정과 함정을 끝까지 풀어본 기록입니다.

  1. 첫 측정 — self-invocation 함정 발견: @Transactional proxy 우회로 successes=100 인데 잔액 그대로. Spring AOP 메커니즘 분해
  2. Fix 후 재측정 — 4 락 비교: 비관락 180ms / 낙관락 549ms / GET_LOCK 5015ms / Redisson 53/100
  3. GET_LOCK 의 connection-bound 함정 4 시나리오: connection close 시 자동 release / commit 후에도 lock 유지 / connection pool 의 추적 불능
  4. 락 선택 결정 트리: 충돌 빈도 / 분산 환경 / SLA 기준

결론부터 말하면:

머릿속의 “락 잡으면 끝이지” 가 어떻게 반쪽 답 인지 라인 단위로 나눠봅니다.


1. Context — 동시성 차감 시나리오의 교과서적 함정

1.1 도메인

서비스는 멀티 플랫폼 커머스 SaaS 의 백엔드입니다. B사·C사·Y사·D사 같은 외부 커머스 플랫폼의 정산이 자체 크레딧 시스템과 묶여 들어옵니다. 운영 사장님 계정의 크레딧 잔액 차감 은 가장 핫한 동시성 경계입니다.

평소엔 sequential 합니다. 그런데 다음과 같은 burst 가 들어옵니다.

평소 단일 worker 면 문제없습니다. 그런데 동시 worker 가 같은 row 를 노리는 순간 — 코드는 그대로인데 잔액이 음수로 떨어지거나 / 차감이 누락되거나 합니다.

1.2 두 교과서적 anomaly

// 흔한 모양 — 락 없음
public void deduct(long accountId, long amount) {
    AccountBalance acc = repo.findByAccountId(accountId);  // SELECT balance
    if (acc.getBalance() < amount) {
        throw new InsufficientBalanceException();
    }
    acc.setBalance(acc.getBalance() - amount);              // UPDATE
    repo.save(acc);
}

두 가지 anomaly 가 발생합니다.

(1) Lost Update — 두 worker 가 같은 시점에 SELECT 하면 둘 다 balance=100 을 봅니다. 둘 다 99 로 UPDATE. 두 번 차감했는데 한 번만 적용 됨. 재고 / 잔액 / 카운터 도메인의 가장 흔한 함정.

(2) 음수 잔액balance=1 일 때 두 worker 가 동시에 들어오면 둘 다 >= 1 통과. 둘 다 차감. 잔액 -1. 결제 도메인에선 돈을 잃는 결과.

이 둘은 검증 코드 로 막을 수 없습니다. SELECT 와 UPDATE 사이의 race 가 본질이라, DB 레벨의 락 또는 버전 비교 로만 방지 가능합니다.

1.3 가설

1.4 측정 환경

항목
OS / 호스트macOS 14.x, MacBook Pro M2 16GB
DBMySQL 8.0.44 (Docker, host 3307)
RedisRedis 7 (Docker, host 6379) — Redisson 분산락용
테이블account_balance (id, account_id, balance, version, updated_at)
초기 잔액100
시나리오100 worker × 1 씩 차감 → 잔액 0 도달이 정확성
도구Java 21 + Spring Boot 3.4 + Hibernate 6.6 + Redisson 3.34
측정totalMs (전체 elapsed) / avg per op / success / fail / final balance

2. 측정 환경 + 4 락 전략 정의

같은 시나리오를 4 락으로 보호. 각 락의 핵심 메커니즘부터 짚고 갑니다.

2.1 (a) 낙관락 — @Version 기반

@Entity
public class AccountBalance {
    @Id private Long id;
    private Long accountId;
    private Long balance;
    @Version private Long version;   // ← 핵심
}

@Transactional
public void deductOnce(Long accountId, long amount) {
    AccountBalance acc = repo.findByAccountId(accountId);   // SELECT (version=v1)
    if (acc.getBalance() < amount) {
        throw new InsufficientBalanceException();
    }
    acc.setBalance(acc.getBalance() - amount);
    // dirty checking 으로 commit 시 자동 UPDATE
    // → UPDATE ... WHERE id=? AND version=v1
    // → version 이 다른 worker 에 의해 v2 로 바뀌었으면 0 row affected
    // → Hibernate 가 OptimisticLockException 발생
}

메커니즘: SELECT 시점의 version 을 기록 → UPDATE 시 WHERE version=? 추가 → 다른 worker 가 먼저 commit 했으면 0 row affected → 예외 → 재시도.

장점: 락을 물리적으로 잡지 않으므로 read 동시성 최대. 충돌 적은 환경에서 가장 빠름.

단점: 충돌 시 재시도 폭증. 100 worker 가 같은 row 노리면 — 첫 worker 만 성공, 99 가 retry. 다시 99 중 1 만 성공, 98 retry… N² 복잡도.

2.2 (b) 비관락 — SELECT ... FOR UPDATE

@Transactional
public void deductPessimistic(Long accountId, long amount) {
    AccountBalance acc = repo.findByAccountIdForUpdate(accountId);  // ← X-lock 획득
    // SQL: SELECT ... FROM account_balance WHERE account_id=? FOR UPDATE
    if (acc.getBalance() < amount) {
        throw new InsufficientBalanceException();
    }
    acc.setBalance(acc.getBalance() - amount);
    // commit 시 X-lock 자동 해제
}

메커니즘: InnoDB 의 row-level X-lock. SELECT 시점에 락 획득 → 다른 worker 는 같은 row 의 X-lock 을 기다림 → 줄 서서 차감 → release.

장점: 정확성 보장 + 재시도 없음. high contention 환경에서 안정적.

단점: 락을 물리적으로 잡으므로 read 도 wait. lock wait timeout 이슈 가능.

2.3 (c) MySQL GET_LOCK — advisory named lock

@Transactional
public void deductWithGetLock(Long accountId, long amount) {
    String lockName = "credit_account:" + accountId;
    Integer acquired = jdbcTemplate.queryForObject(
        "SELECT GET_LOCK(?, 5)", Integer.class, lockName);   // 5초 timeout
    if (acquired == null || acquired != 1) {
        throw new LockAcquireFailedException();
    }
    try {
        AccountBalance acc = repo.findByAccountId(accountId);
        if (acc.getBalance() < amount) {
            throw new InsufficientBalanceException();
        }
        acc.setBalance(acc.getBalance() - amount);
        repo.save(acc);
    } finally {
        jdbcTemplate.queryForObject("SELECT RELEASE_LOCK(?)", Integer.class, lockName);
    }
}

메커니즘: MySQL 의 named lock — connection-bound. row 와 무관하게 이름 으로 잡는 advisory lock. 같은 이름으로 동시 시도 시 직렬화.

장점: row 가 없거나 여러 row 를 보호해야 하는 경우 유용. DB 마이그레이션 / DDL 동시 실행 방지 같은 admin 용도.

단점: connection-bound 함정 (8장에서 시연). 단일 MySQL 안에서만 작동 — 분산 환경 부적합.

2.4 (d) Redisson 분산락

@Service
public class RedissonDeductExecutor {
    private final RedissonClient redisson;
    private final AccountBalanceRepository repo;

    public void deductOnce(Long accountId, long amount) {
        RLock lock = redisson.getLock("credit_account:" + accountId);
        boolean acquired = lock.tryLock(5, 20, TimeUnit.SECONDS);  // 5s wait, 20s lease
        if (!acquired) {
            throw new LockAcquireFailedException();
        }
        try {
            AccountBalance acc = repo.findByAccountId(accountId);
            if (acc.getBalance() < amount) {
                throw new InsufficientBalanceException();
            }
            acc.setBalance(acc.getBalance() - amount);
            repo.save(acc);
        } finally {
            lock.unlock();
        }
    }
}

메커니즘: Redis 의 SET NX + Pub/Sub 기반 분산락. Watchdog 이 lease 자동 연장 (default 30s). 여러 인스턴스가 같은 Redis 를 공유하면 분산 lock 작동.

장점: 진짜 분산락. 멀티 인스턴스 환경에서 같은 lock 공유.

단점: Redis 라운드트립 비용. 단일 인스턴스 안 동시성엔 비관락 우위.

2.5 4 락 비교 한눈에

graph LR
    subgraph "락 위치 / 락 단위"
        A1["낙관락 - DB row + version 컬럼"]
        A2["비관락 - DB row + X-lock"]
        A3["GET_LOCK - MySQL named hash"]
        A4["Redisson - Redis SET NX"]
    end

    subgraph "직렬화 방식"
        B1["낙관락 - 재시도로 직렬화"]
        B2["비관락 - lock wait 로 직렬화"]
        B3["GET_LOCK - lock wait 로 직렬화"]
        B4["Redisson - lock wait 로 직렬화"]
    end

    A1 -.-> B1
    A2 -.-> B2
    A3 -.-> B3
    A4 -.-> B4

→ 같은 정확성 보장이지만 직렬화 비용 이 다 다릅니다. 다음 섹션에서 측정값으로 확인합니다.


3. 첫 측정 — self-invocation 함정 발견

자, 이제 4 락을 같은 시나리오에 돌립니다. 100 worker 가 동시에 1 씩 차감 → 잔액 0 도달이 정확성.

첫 측정 결과가 이상했습니다.

StrategytotalMssuccessfinalBalance정상?
낙관락 (@Version)412100100 ⚠️차감 안 됨
비관락 (FOR UPDATE)1801000
MySQL GET_LOCK5015919⚠️ 9 timeout
Redisson 분산락3215347⚠️ 47 timeout

낙관락이 successes=100 인데 잔액이 그대로 100. 100 worker 모두 예외 없이 끝났는데 차감이 0 건. 비관락은 정상이라 코드 logic 의 문제가 아닙니다 — 그러면 무엇이 문제인가?

코드를 다시 봤습니다.

@Service
public class CreditDeductionService {

    public void deductOptimistic(Long accountId, long amount) {
        // 외부에서 호출되는 진입점
        deductOptimisticOnce(accountId, amount);   // ← 같은 클래스 내부 호출 ⚠️
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void deductOptimisticOnce(Long accountId, long amount) {
        AccountBalance acc = repo.findByAccountId(accountId);
        if (acc.getBalance() < amount) {
            throw new InsufficientBalanceException();
        }
        acc.setBalance(acc.getBalance() - amount);
        // dirty checking 으로 commit 시 update 예상
    }
}

겉보기엔 정상 입니다. @Transactional(REQUIRES_NEW) 로 새 트랜잭션 시작 → SELECT → 차감 → commit 시 자동 UPDATE. 그런데 차감이 안 일어났습니다.

원인은 한 줄에 있습니다 — deductOptimistic 이 같은 클래스의 deductOptimisticOnce 를 호출 하는 것. Spring AOP proxy 가 우회 되어 @Transactional작동하지 않았습니다.

→ 트랜잭션이 시작 안 되니 flush 도 안 일어나고, dirty checking 으로 만든 UPDATE 가 DB 에 반영되지 않음. successes=100 (예외 없으니까) 인데 잔액 그대로 (UPDATE 0 건이니까).

이게 Spring 의 가장 유명한 함정 — self-invocation. 다음 섹션에서 메커니즘을 분해합니다.


4. self-invocation deep dive — Spring AOP proxy 메커니즘

4.1 @Transactional 이 작동하는 원리 — proxy 패턴

@Transactional 은 그냥 어노테이션이 아닙니다. Spring 이 proxy 객체 를 생성해서, 메서드 호출 직전 / 직후에 트랜잭션 begin / commit / rollback 을 끼워 넣는 AOP (Aspect-Oriented Programming) 메커니즘.

sequenceDiagram
    participant Client as 외부 호출자
    participant Proxy as CreditDeductionService$$Proxy
    participant TxManager as TransactionManager
    participant Real as CreditDeductionService (실제)

    Client->>Proxy: deductOptimisticOnce()
    Proxy->>TxManager: begin TX
    Proxy->>Real: deductOptimisticOnce() 실행
    Real-->>Proxy: 정상 종료
    Proxy->>TxManager: commit (flush + UPDATE 실행)
    Proxy-->>Client: 반환

핵심: Client → Proxy → Real 순서. Proxy 가 트랜잭션 boundary 를 관리. Real 객체는 자기가 트랜잭션 안에 있는지 모름.

4.2 self-invocation 함정 — proxy 우회

같은 클래스 내부에서 다른 메서드를 호출하면 어떻게 될까요?

public void deductOptimistic(Long accountId, long amount) {
    deductOptimisticOnce(accountId, amount);  // ← this.deductOptimisticOnce()
}

this.deductOptimisticOnce()Real 객체 자기 자신 을 호출합니다. Proxy 를 거치지 않음. 따라서:

sequenceDiagram
    participant Client as 외부 호출자
    participant Proxy as CreditDeductionService$$Proxy
    participant Real as CreditDeductionService (실제)

    Client->>Proxy: deductOptimistic()
    Proxy->>Real: deductOptimistic() 실행
    Note over Real: this.deductOptimisticOnce() 호출
    Real->>Real: deductOptimisticOnce() 실행 (Proxy 우회)
    Note over Real: TX 시작 안 됨!<br/>flush 안 됨!<br/>UPDATE 안 됨!
    Real-->>Proxy: 정상 종료
    Proxy-->>Client: 반환

Proxy 우회 = @Transactional 작동 안 함. 메서드는 정상 실행되어 successes 는 100. 그런데 트랜잭션이 시작 안 되니 dirty checking 결과가 flush 되지 않음. UPDATE 가 DB 에 반영 X.

이게 measure 결과의 finalBalance=100 의 정체입니다.

4.3 같은 함정이 발생하는 다른 어노테이션들

@Transactional 만의 문제가 아닙니다. Spring AOP proxy 기반 어노테이션 모두 동일 함정.

어노테이션문제되는 케이스
@Transactionalself-invocation 시 트랜잭션 시작 안 됨
@Asyncself-invocation 시 비동기로 안 돌고 동기 실행
@Cacheableself-invocation 시 캐시 lookup / put 안 일어남
@PreAuthorizeself-invocation 시 권한 체크 안 됨 (보안 함정)
@Retryableself-invocation 시 재시도 안 됨

→ 메서드 호출이 같은 클래스 내부에서 일어나면, 모든 AOP 기반 동작이 우회 됩니다.

4.4 Fix — 별도 @Service 빈으로 분리

해결은 단순합니다. 트랜잭션이 필요한 메서드를 별도 빈 으로 분리. 그러면 외부 빈 호출이 되어 Proxy 가 정상 작동.

// ✅ Fix — 별도 @Service 빈으로 분리
@Service
public class OptimisticDeductExecutor {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void deductOnce(Long accountId, long amount) {
        AccountBalance acc = repo.findByAccountId(accountId);
        if (acc.getBalance() < amount) {
            throw new InsufficientBalanceException();
        }
        acc.setBalance(acc.getBalance() - amount);
        // dirty checking 으로 정상 UPDATE
    }
}

@Service
public class CreditDeductionService {
    private final OptimisticDeductExecutor executor;  // ← 다른 빈

    public void deductOptimistic(Long accountId, long amount) {
        executor.deductOnce(accountId, amount);   // ← 외부 빈 호출 → Proxy 정상 작동
    }
}

핵심: executor.deductOnce()다른 빈 의 메서드 호출. Spring 이 executor 를 주입할 때 Proxy 객체 를 주입했으므로, 호출 시 Proxy 를 거쳐서 트랜잭션이 정상 시작.

4.5 다른 fix 옵션들 (참고)

방법권장비고
별도 @Service 빈으로 분리✅ 권장명시적, 테스트 쉬움
AopContext.currentProxy() 사용⚠️ 비추<aop:aspectj-autoproxy expose-proxy="true"> 필요, 코드 복잡
AspectJ compile-time weaving⚠️ 비추빌드 복잡, 운영 진입장벽
@Async + Future 로 우회❌ 금지동시성 더 복잡, 본질 해결 X

운영 표준은 별도 빈 분리. 코드 한 줄 더 추가되지만, 명시적 이라 PR 리뷰에서 함정이 안 보임.

4.6 발견의 의미

“JPA / Spring 의 진짜 함정은 코드 logic 이 아니라 AOP proxy 우회

이게 핵심입니다. 코드 logic 만 보면 정상 입니다. @Transactional(REQUIRES_NEW) 명시, 차감 로직 정상, dirty checking 신뢰. 그런데 proxy 가 우회되면 모든 추상화가 무너집니다.

PR 리뷰 체크리스트 0번 항목: @Transactional / @Async / @Cacheable 메서드는 외부 빈 호출 인지 확인. 같은 클래스 내부 호출이면 즉시 차단.

자매 글 — Spring 트랜잭션 외부 API 풀 고갈 도 같은 맥락입니다 — @Transactional 의 추상화가 운영 환경에서 어떻게 무너지는지의 다른 사례.


5. Fix 후 재측정 — 4 락 비교 [실측]

OptimisticDeductExecutor 별도 빈 분리 후 재측정. 이번엔 4 락 모두 정상 작동.

5.1 종합 표 [실측 — Java/Spring]

StrategytotalMsavg/op(ms)successfailfinalBalanceverdict
낙관락 (@Version)549292.0510000✅ 정확
비관락 (FOR UPDATE)180100.4210000✅ 정확
MySQL GET_LOCK5015575.719199⚠️ 9 timeout
Redisson 분산락321175.61534747⚠️ 47 timeout

(H1) 검증: ✅ 4 락 모두 자기가 잡는 만큼은 정확. GET_LOCK / Redisson 의 fail 은 락 자체 실패 (timeout) — 차감 누락이 아니라 차감 시도 실패.

(H2) 검증: ✅ 비관락 (FOR UPDATE) 가 가장 빠르고 정확. 180ms / 100% / avg 100ms.

(H3) 검증: ✅ 낙관락은 contention 폭증으로 비관락의 3배 느림 (549ms vs 180ms).

(H4) 검증: ✅ Redisson 은 단일 인스턴스 안 동시성에선 처리량 한계 — 53/100. 분산 환경에서만 진가.

5.2 latency / success 비교 한눈에

totalMs:
낙관락          ████████████████████████ 549ms
비관락 ⭐       ████████ 180ms
GET_LOCK       ████████████████████████████████████████████████████████████████████████████ 5015ms
Redisson       ██████████████ 321ms

success/100:
낙관락          ████████████████████████████████████████████████████████████████████████████████████████████████ 100
비관락 ⭐       ████████████████████████████████████████████████████████████████████████████████████████████████ 100
GET_LOCK       █████████████████████████████████████████████████████████████████████████████████████ 91
Redisson       ████████████████████████████████████████████████ 53

finalBalance (정확성, 0 이 정답):
낙관락          0  ✅
비관락 ⭐       0  ✅
GET_LOCK       9  (lock 잡은 91 만 차감)
Redisson       47 (lock 잡은 53 만 차감)

→ 한눈에 비관락이 압도적. 그런데 왜 그런지 — 다음 섹션에서 메커니즘으로 풀어봅니다.


6. 비관락이 우위인 이유

6.1 row-level X-lock 의 본질

SELECT ... FOR UPDATE 는 InnoDB 의 row-level X-lock (Exclusive Lock) 을 잡습니다. 다른 worker 가 같은 row 의 X-lock 을 시도하면 — 즉시 wait queue 에 줄섬 → 앞 worker 가 commit / rollback 으로 lock 풀면 → 다음 worker 가 lock 획득 → 차감 → release.

Worker 1: SELECT FOR UPDATE → 락 획득 → 차감 → commit (release)
Worker 2: SELECT FOR UPDATE → wait → ... → 락 획득 → 차감 → commit (release)
Worker 3: SELECT FOR UPDATE → wait → wait → ... → 락 획득 → 차감 → commit (release)
...
Worker 100: SELECT FOR UPDATE → wait × 99 → 락 획득 → 차감 → commit (release)

100 worker 가 줄 서서 차감. 각 worker 의 wait + 차감 시간이 누적되어 totalMs = 180ms / avg = 100ms.

6.2 비관락 vs GET_LOCK row-level vs named-lock 메커니즘

[비관락 — row-level X-lock]
┌─────────────────────────────────┐
│ InnoDB Buffer Pool              │
│ ┌─────────────┐                 │
│ │ Page X      │                 │
│ │  Row R1 ────┼─→ X-lock owner: │
│ │  Row R2     │   tx_id=42      │
│ │  Row R3     │                 │
│ └─────────────┘                 │
└─────────────────────────────────┘
   ↑              ↑
   같은 row 노리는 worker 가 wait
   다른 row 는 free


[GET_LOCK — named lock]
┌─────────────────────────────────┐
│ MySQL named lock hash           │
│ ┌──────────────────────────┐   │
│ │ "credit_account:1"       │   │
│ │   → connection_id=12059  │   │
│ │ "credit_account:2"       │   │
│ │   → connection_id=12061  │   │
│ └──────────────────────────┘   │
└─────────────────────────────────┘

   같은 이름 시도 시 wait
   row 와 무관 (이름 = 의미)

비관락은 row 자체가 락의 주인. GET_LOCK 은 이름이 락의 주인. row-level lock 이 더 직관적이고, InnoDB 의 lock manager 가 깊이 최적화되어 있습니다.

6.3 InnoDB 의 lock manager 최적화

비관락이 빠른 이유는 InnoDB 의 lock manager 가 B+-tree 인덱스 위에 직접 lock metadata 를 저장 하기 때문입니다.

항목InnoDB 비관락GET_LOCK
Lock metadata 위치B+-tree leaf page 옆별도 named hash
Index 활용row PK 로 즉시 lookup이름 hash 로 lookup
Wait queue 관리InnoDB 전용 wait graphMySQL server thread
Deadlock detectionInnoDB 자동수동 (timeout 기반)
라운드트립SELECT FOR UPDATE 1회GET_LOCK + RELEASE_LOCK 2회

InnoDB 가 row-level lock 에 깊이 최적화 되어 있어, GET_LOCK 보다 본질적으로 빠릅니다. 5015ms vs 180ms = 27.8배 차이 가 그 증명.

6.4 비관락의 적합한 도메인

“잔액 차감 / 결제 / 인벤토리 감소” 같이 write 충돌 빈번 한 도메인의 기본 선택

도메인적합이유
크레딧 / 잔액 차감row-level write contention
재고 감소같은 SKU 동시 차감
결제 idempotency같은 idempotency key 충돌
좌석 예약같은 좌석 동시 시도
사장님 프로필 update충돌 드묾 → 낙관락
대시보드 read-heavyread 동시성 손상

7. 낙관락 contention 폭증 — N² 재시도

7.1 100 worker 시나리오의 진짜 모습

낙관락이 549ms 인 이유는 단순합니다 — 재시도 폭증.

초기: 100 worker 가 동시에 SELECT (모두 version=1 본다)
  → 모두 UPDATE WHERE version=1 시도
  → 1 worker 만 0→1 차감 성공 (version 2 됨)
  → 99 worker 는 0 row affected → OptimisticLockException → retry

1차 retry: 99 worker 가 SELECT (version=2 본다)
  → 1 worker 만 차감 성공 (version 3 됨)
  → 98 worker retry

2차 retry: 98 worker → 1 성공, 97 retry
3차 retry: 97 worker → 1 성공, 96 retry
...
99차 retry: 1 worker 성공

총 시도 횟수: 100 + 99 + 98 + … + 1 = 5,050 회. 100 worker 인데 5,050 SELECT + 5,050 UPDATE 가 발생.

7.2 N² 재시도 그래프

재시도 횟수 (cumulative SELECT + UPDATE):

worker 수 |  시도 횟수  | 그래프
─────────┼────────────┼────────────────────────────
   10    |    55       | █
   50    |  1,275      | █████
  100    |  5,050      | ████████████████████
  200    | 20,100      | ████████████████████████████████████████████████████████████████████████████████
  500    | 125,250     | (그래프 못 그릴 정도)

      → N² / 2 의 폭증

worker 수가 2 배가 되면 시도 횟수는 4 배. N² 복잡도 의 본질.

7.3 낙관락이 충돌 적은 환경에 적합한 이유

worker 가 같은 row 를 노릴 확률이 낮으면 — 첫 시도가 거의 다 성공. 재시도 거의 없음 → 가장 빠름.

worker 100 이 100 개 row 에 분산:
  → 각 row 에 평균 1 worker → 충돌 거의 없음 → 1 회 시도로 끝
  → 낙관락이 가장 빠름 (락 잡지 않으니까)

worker 100 이 1 개 row 에 집중 (본 측정):
  → N² 재시도 폭증 → 비관락의 3배 느림

낙관락의 강점은 충돌 드문 환경. 운영 사장님 프로필 update / 대시보드 read 같은 케이스. 결제 / 잔액 / 재고 같은 high contention 엔 부적합.

7.4 낙관락의 운영 함정 — retry 정책

낙관락을 운영에 쓰려면 명시적 retry 가 필수입니다. Spring 의 @Retryable 또는 수동 try-catch.

@Service
public class OptimisticDeductExecutor {

    @Retryable(
        retryFor = OptimisticLockException.class,
        maxAttempts = 5,
        backoff = @Backoff(delay = 50, multiplier = 2)
    )
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void deductOnce(Long accountId, long amount) {
        // ...
    }
}

주의: @Retryable 도 AOP proxy 기반. self-invocation 시 작동 안 함 (4장 함정 그대로). 별도 빈 으로 분리한 이유 중 하나도 이것.

7.5 이 챕터의 답을 자기 말로

이 챕터가 측정으로 보여준 것은 — 낙관락의 처리량이 충돌 빈도 에 따라 N² 으로 갈린다는 것. 충돌 적은 환경에선 락 자체를 잡지 않으니 가장 빠르고, 100 worker 가 같은 row 를 노리는 시나리오에선 5,050 회 시도로 비관락의 3배 느려집니다. 운영 결정의 임계값은 충돌 5% — 측정상 그 아래면 낙관락, 그 위면 비관락. 잔액 차감 / 결제 도메인이 비관락 표준이 된 이유는 도메인 특성상 같은 계정에 트래픽이 모여 충돌 빈도가 본질적으로 높기 때문이지, 모든 도메인의 답이 그렇다는 의미는 아닙니다 ([실측 — Java/Spring]).


8. MySQL GET_LOCK 함정 — connection-bound 4 시나리오 시연

GET_LOCK 이 5015ms 로 가장 느린 이유는 advisory lock 의 본질적 cost 입니다. 그런데 더 깊은 함정은 connection-bound 동작에서 옵니다 — 이게 분산락 의도면 위험 한 진짜 이유.

같은 시나리오를 raw JDBC 로 다루어서 4 시나리오 직접 시연. 이건 W3 ⑥ — GET_LOCK trap 학습 노트 에서 발췌한 내용.

8.1 4 시나리오 한눈에

Scenario동작함정 여부
1. 같은 connection 안 GET_LOCK / RELEASE_LOCK정상안전
2. RELEASE_LOCK 안 하고 connection close자동 release⚠️ 함정
3. 두 connection 이 같은 key 시도한쪽만 성공안전
4. COMMIT 후에도 connection 살아있으면lock 유지⚠️ 함정

8.2 Scenario 1 — 정상 동작 (대조군)

try (Connection conn = dataSource.getConnection()) {
    PreparedStatement ps = conn.prepareStatement("SELECT GET_LOCK(?, 5)");
    ps.setString(1, "my_lock");
    ResultSet rs = ps.executeQuery();
    rs.next();
    log.info("GET_LOCK = {}", rs.getInt(1));   // 1 (성공)

    PreparedStatement check = conn.prepareStatement("SELECT IS_USED_LOCK(?)");
    check.setString(1, "my_lock");
    ResultSet rs2 = check.executeQuery();
    rs2.next();
    log.info("IS_USED_LOCK = {}", rs2.getInt(1));   // connection_id 반환

    PreparedStatement release = conn.prepareStatement("SELECT RELEASE_LOCK(?)");
    release.setString(1, "my_lock");
    release.executeQuery();
    log.info("RELEASE_LOCK 완료");
}

✅ 교과서적 사용 패턴. try-finally 또는 try-with-resources 로 release 보장.

8.3 Scenario 2 ⚠️ — connection close 시 자동 release

// Connection A
Connection connA = dataSource.getConnection();
runQuery(connA, "SELECT GET_LOCK('shared_lock', 5)");   // 1 (성공)
// ⚠️ RELEASE_LOCK 호출 안 함
connA.close();   // ← 여기서 lock 자동 release

// Connection B (1초 후)
Thread.sleep(1000);
Connection connB = dataSource.getConnection();
int result = runQuery(connB, "SELECT GET_LOCK('shared_lock', 1)");
log.info("GET_LOCK = {}", result);   // 1 ⚠️ A 의 lock 자동 release 됐으니까

시연 결과: GET_LOCK = 1 — A 가 명시적으로 release 안 했는데, connection close 시점에 자동 release.

→ 운영 코드에서 try-finally 로 release 안 하면 — connection 끊기는 의도와 다른 시점 에 lock 풀림. 분산락의 기본 보장 (lock 획득자가 명시 release 또는 timeout 까지 보유) 가 깨짐.

8.4 Scenario 3 — 두 connection 충돌 (정상)

sequenceDiagram
    participant A as Connection A
    participant DB as MySQL
    participant B as Connection B

    A->>DB: GET_LOCK('shared', 5)
    DB-->>A: 1 (성공)

    Note over B: A 가 점유 중
    B->>DB: GET_LOCK('shared', 1)
    DB-->>B: 0 (1초 timeout, 실패)

    A->>DB: RELEASE_LOCK('shared')

    B->>DB: GET_LOCK('shared', 1)
    DB-->>B: 1 (이제 성공)

✅ 정상 동작. 같은 이름으로 시도하면 한쪽만 성공.

8.5 Scenario 4 ⚠️ — COMMIT 후에도 lock 유지

@Transactional
public void doWork(Long id) {
    jdbcTemplate.queryForObject("SELECT GET_LOCK(?, 5)", Integer.class, "my_lock");
    // 작업
    // ⚠️ RELEASE_LOCK 명시 호출 안 함
    // 트랜잭션 commit 됨
    // 그런데 connection 이 풀로 반환되었어도
    //   다음 worker 가 같은 connection 가져오면 lock 상속
}

시연 결과: COMMIT 후에도 connection 이 살아있으면 — IS_USED_LOCK('my_lock') 가 여전히 connection_id 반환. 트랜잭션 commit 과 lock release 가 분리 됨.

→ Hibernate / JPA 의 자동 commit / 트랜잭션 종료가 lock release 까지 보장 안 함. try-finally 로 명시 release 필수.

8.6 Connection 풀 환경의 추적 불능

이 모든 함정이 합쳐지면 운영의 비결정적 동작을 만듭니다.

1. Worker A 가 connection X 에서 GET_LOCK 획득
2. Worker A 의 트랜잭션 commit → connection X 풀에 반환 (RELEASE_LOCK 잊었다 가정)
3. Worker B 가 connection X 가져옴
4. Worker B 가 GET_LOCK 시도 → ⚠️ 자동 성공 (같은 connection 이라 lock 상속)
5. Worker B 가 잘못된 가정 하에 작업 진행

→ “내 worker 가 잡은 lock 인 줄 알았는데 이전 worker 의 lock 을 상속받음”. HikariCP 같은 connection pool 환경에선 어느 connection 이 어디서 lock 잡았나 추적 불능.

8.7 GET_LOCK 사용 시 강제 룰

본 시연으로 도출된 PR 리뷰 체크리스트:

GET_LOCK 의 진짜 사용처는 매우 좁음 — DB 마이그레이션 / DDL 동시 실행 방지 같은 admin 용도. 일반적인 동시성 제어는 비관락 (FOR UPDATE) 또는 Redisson.


9. Redisson 한계 — 53/100 success 의 진짜 의미

9.1 측정 결과 다시 보기

StrategytotalMssuccessfinalBalance
Redisson 분산락32153 / 10047

47 worker 가 5초 안에 lock 못 받음 → timeout. 이게 Redisson 의 한계인가?

답은 그렇기도 하고 아니기도 하다 입니다. 시나리오에 따라 정반대 평가가 됩니다.

9.2 단일 인스턴스 안 동시성 — 비관락 우위

본 측정 환경: 1 개 Spring Boot 인스턴스 + 100 worker thread. 이 시나리오에선:

단일 인스턴스 안에서 분산락 쓰는 건 over-engineering. 비관락이 정답.

9.3 Redisson 의 진짜 강점 — Pub/Sub + Watchdog

Redisson 이 빛나는 건 멀티 인스턴스 환경입니다.

sequenceDiagram
    participant Inst1 as Spring Boot 인스턴스 #1
    participant Redis as Redis (분산 lock 저장소)
    participant Inst2 as Spring Boot 인스턴스 #2
    participant Watchdog as Redisson Watchdog

    Inst1->>Redis: SET NX lock:account:1 (lease 30s)
    Redis-->>Inst1: OK (lock 획득)

    Watchdog->>Redis: 10s 마다 lease 연장 (30s 유지)

    Inst2->>Redis: SET NX lock:account:1
    Redis-->>Inst2: nil (실패)
    Inst2->>Redis: SUBSCRIBE lock:account:1 unlock channel

    Inst1->>Redis: 작업 완료, DEL lock:account:1
    Redis->>Inst2: PUBLISH unlock event
    Inst2->>Redis: SET NX lock:account:1
    Redis-->>Inst2: OK

핵심 메커니즘:

  1. SET NX: Redis 의 atomic 한 lock 획득
  2. Pub/Sub: 다른 worker 가 lock 풀리는 순간 알림 받음 (poll 안 함)
  3. Watchdog: 작업이 lease (30s) 보다 오래 걸려도 자동 연장

→ 멀티 인스턴스 환경에선 다른 어떤 메커니즘으로도 대체 불가. DB 비관락은 같은 DB 안에서만 작동.

9.4 53/100 의 의미 재해석

본 시나리오 (단일 인스턴스 + 100 worker thread) 는 Redisson 을 잘못 쓰는 케이스. 그래서 53/100 은 Redisson 의 한계가 아니라 사용처 misuse 의 결과.

시나리오권장 락비관락 처리량Redisson 처리량
단일 인스턴스 + thread 동시성비관락 ⭐180ms / 100%321ms / 53%
멀티 인스턴스 (2~10)Redisson ⭐(불가, 인스턴스 간 락 공유 안 됨)정상 작동
멀티 인스턴스 (>10) + 짧은 criticalRedisson ⭐ + 짧은 lease(불가)정상 작동

9.5 Redisson 사용 결정 기준

“분산락 의도면 Redisson, 단일 DB 안 동시성이면 비관락 (FOR UPDATE) 가 더 안전 + 빠름”

Redisson 을 쓰는 신호:

  1. 같은 도메인 로직이 여러 인스턴스 에서 동시 실행 가능
  2. DB 가 읽기 전용 replica 또는 sharded 라 비관락 못 잡는 환경
  3. 락의 보호 대상이 DB row 가 아닌 외부 자원 (file / 외부 API rate limit / cache stampede)
  4. lease 자동 연장 (Watchdog) 이 필수 — 작업 시간 변동 큰 케이스

Redisson 안 쓰는 신호:

  1. 단일 인스턴스 안 thread 동시성 → 비관락 / synchronized
  2. 짧은 critical (< 100ms) → 비관락이 본질적으로 빠름
  3. 정확성 + 처리량 둘 다 중요 → 비관락 (DB transaction boundary 와 lock boundary 일치)

10. 락 선택 결정 트리

지금까지의 측정값을 결정 트리로 정리.

10.1 결정 트리

flowchart TD
    Start[락 필요한 동시성 시나리오] --> Q1{충돌 빈도}
    Q1 -->|충돌 빈번 - 같은 row 다수 worker| Q2
    Q1 -->|충돌 드묾 - 같은 row 1 worker 거의| OptLock[낙관락 - @Version]

    Q2{분산 환경 - 멀티 인스턴스} -->|단일 인스턴스| PesLock[비관락 - SELECT FOR UPDATE]
    Q2 -->|멀티 인스턴스| Redisson[Redisson 분산락]

    OptLock --> CheckRetry{재시도 정책 있나}
    CheckRetry -->|YES| OK1[OK]
    CheckRetry -->|NO| Warning1[운영 위험 - retry 정책 추가]

    PesLock --> CheckTx{트랜잭션 boundary 짧나}
    CheckTx -->|YES <100ms| OK2[OK - 가장 빠름]
    CheckTx -->|NO >1s| Warning2[lock wait timeout 위험 - 트랜잭션 분리]

    Redisson --> CheckLease{lease 시간 적정}
    CheckLease -->|YES Watchdog 활용| OK3[OK]
    CheckLease -->|NO 너무 짧음| Warning3[중간 lock 풀릴 위험 - lease 늘리기]

10.2 도메인별 추천

도메인추천 락이유
크레딧 / 잔액 차감비관락 ⭐high contention + 짧은 critical
결제 idempotency비관락 ⭐같은 idempotency key 동시 시도
재고 차감비관락 ⭐같은 SKU 동시 차감
좌석 예약비관락 ⭐같은 좌석 동시 시도
사장님 프로필 update낙관락충돌 드묾
사용자 설정 update낙관락본인만 수정 → 충돌 0
Cache stampede 방지Redisson ⭐멀티 인스턴스 환경
분산 cron job 락Redisson ⭐멀티 인스턴스에서 1 worker 만 실행
DB 마이그레이션 락GET_LOCKDDL 동시 실행 방지 admin

10.3 SLA 와 trade-off

우선순위추천 락이유
정확성 100%비관락row-level X-lock
처리량 max낙관락 (충돌 적은 환경)락 안 잡음
분산 환경 정확성RedissonPub/Sub + Watchdog
avg latency 최소비관락본 측정 100ms / op
P99 latency 안정비관락재시도 폭증 없음 (낙관락 단점)

본 시나리오 (100 worker × 1 차감): 충돌 극도로 빈번 + 단일 인스턴스 → 비관락이 정답. Redisson 은 같은 시나리오라도 멀티 인스턴스 면 정답.


11. 운영 표준 — PR 리뷰 체크리스트 + 강제 룰

지금까지의 발견을 운영 룰로 압축.

11.1 락 사용 PR 리뷰 체크리스트

11.2 lint 룰 — self-invocation 자동 차단

GitHub Actions 또는 SonarQube 룰 추가. 같은 클래스 안에서 @Transactional 메서드를 호출하는 패턴 검출.

# pseudo lint script (CI)
# Class 내부에서 같은 class 의 @Transactional 메서드를 this.method() 로 호출하면 차단
spotbugs --include-bug-categories=BAD_PRACTICE \
         --plugin spring-aop-self-invocation-detector

운영의 진짜 가치는 이 차단이 PR 단계에서 막아준다는 점. 한 번 운영에 풀리면 — 차감이 일어나지 않은 채 successes=N 응답이 나가서 데이터 불일치 발견까지 며칠 걸립니다. PR 단계에서 lint 한 줄로 막는 게 1/100 비용.

11.3 운영 모니터링

지표의미알람 조건
innodb_row_lock_waits비관락 wait 발생 횟수평소 대비 5배 spike
innodb_row_lock_time_avg평균 wait 시간 (ms)100ms 이상 지속
Spring Retry counter낙관락 retry 횟수평소 대비 10배
Redisson RLock.tryLock 실패 카운터분산락 timeout 횟수평소 대비 5배
INFORMATION_SCHEMA.METADATA_LOCKSGET_LOCK 누수1분 이상 점유된 lock

11.4 이 결정이 틀렸다고 판단할 기준


12. 빅테크 사례

12.1 Vlad Mihalcea — Optimistic vs Pessimistic Locking

Vlad Mihalcea — A beginner’s guide to JPA optimistic locking — Hibernate / JPA 의 권위자가 정리한 Optimistic vs Pessimistic 가이드.

핵심 인용:

“Optimistic locking is best when conflicts are rare. Pessimistic locking is best when conflicts are frequent or when the cost of a failed transaction is high.”

본 측정 [실측] 으로 이 주장을 굳혔습니다 — 100 worker contention 시나리오에서 비관락이 3배 빠름.

12.2 Stripe Idempotency 표준

Stripe — Idempotency — 결제 도메인의 동시성 처방 표준.

핵심: idempotency key + DB unique constraint. 같은 key 의 결제 시도 둘이 동시에 들어오면 — DB unique constraint 위반으로 한쪽만 성공. 사실상 DB 비관락의 변형 입니다.

→ 결제 도메인의 락 패턴은 비관락 + idempotency key 조합이 표준.

12.3 토스페이먼츠 멱등성 (한국 사례)

토스페이먼츠 개발자 가이드 — 멱등성 키 — Stripe 패턴의 한국 PG 사 적용.

핵심 인용:

“멱등성 키를 사용해 같은 요청이 중복 처리되지 않도록 합니다.”

같은 idempotency key + DB unique constraint = 비관락 효과. 한국 결제 도메인 표준.

12.4 Baeldung — Spring Self-Invocation

Baeldung — Self-Invocation in Spring — self-invocation 함정의 권위 있는 가이드. 본 글 4장의 메커니즘 설명을 더 깊이 파고든 자료.

핵심:

“Self-invocation calls bypass Spring’s AOP proxies and therefore aspects do not apply.”

→ 본 글의 4장 함정과 정확히 같은 메커니즘.

12.5 Spring 공식 — @Transactional proxy mode

Spring Framework Reference — Declarative transaction management@Transactional 의 proxy mode 와 self-invocation 한계 명시.

핵심 인용:

“In proxy mode (which is the default), only external method calls coming in through the proxy are intercepted. This means that self-invocation, in effect, a method within the target object calling another method of the target object, will not lead to an actual transaction at runtime.”

Spring 공식 문서가 self-invocation 한계를 직접 명시. 본 글 4장의 함정이 well-known 한 이유.

12.6 Redisson docs — Watchdog 메커니즘

Redisson Wiki — 8.1 Lock — Watchdog 동작 명세.

핵심:

“If lease time is not specified, lock will be released using watchdog mechanism by default. Watchdog renews the lock by default every 10 seconds while owner thread is still alive.”

→ 본 글 9.3 의 Watchdog 메커니즘 출처.

12.7 MySQL 공식 — GET_LOCK / IS_USED_LOCK

MySQL 8.0 Reference — Locking Functions — GET_LOCK / RELEASE_LOCK / IS_USED_LOCK 명세.

핵심:

“A lock obtained with GET_LOCK() is released explicitly by executing RELEASE_LOCK() or implicitly when your session terminates (either normally or abnormally).”

→ 본 글 8장의 connection-bound 함정 출처.


정리 — 이 글을 한 번 더, 자기 말로

이 글을 다 읽은 누군가가 “그래서 이게 뭐였지?” 묻는다면 — 측정으로 풀었던 답을 자기 말로 정리해보면 다음과 같습니다.

Q. “잔액 차감에 어떤 락을 써야 하나요?”

기본 답은 비관락 (SELECT FOR UPDATE) 입니다. [실측] 100 worker 가 같은 row 차감하는 시나리오에서 180ms / 100% / 잔액 0 — 가장 빠르고 정확. InnoDB 의 row-level X-lock 이 lock manager 에 깊이 최적화되어 있어서, 같은 시나리오의 GET_LOCK (5015ms) 보다 27.8 배 빠릅니다. 단 트랜잭션 boundary 가 짧아야 합니다 (< 100ms) — 외부 API 호출이 트랜잭션 안에 들어가면 lock wait timeout 위험.

Q. “낙관락을 써야 할 시점은?”

충돌 빈도가 5% 미만 일 때입니다. 운영 사장님 프로필 update / 사용자 설정 update 같이 본인만 수정하는 도메인. 100 worker 가 100 row 에 분산되는 시나리오면 첫 시도 거의 다 성공 → 비관락보다 빠름. 단 같은 row 노리는 worker 가 많아지면 N² 재시도 폭증 — [실측] 100 worker 시나리오에서 5,050 회 시도 발생. 그리고 낙관락 사용 시 @Retryable 명시 필수 — 그것도 별도 빈으로 분리해서 self-invocation 함정 피해야 합니다.

Q. “Redisson 분산락이 53/100 success 였는데 왜 추천하나요?”

본 측정 시나리오 (단일 인스턴스 + 100 worker thread) 가 Redisson misuse 였기 때문입니다. 단일 JVM 안 동시성엔 비관락이 본질적으로 빠릅니다 — Redis 라운드트립 비용이 누적되니까. Redisson 의 진짜 강점은 멀티 인스턴스 환경 — 여러 Spring Boot 인스턴스가 같은 lock 공유해야 할 때. Pub/Sub 으로 lock 풀림 알림 + Watchdog 으로 lease 자동 연장. 분산 cron job / Cache stampede 방지 같은 용도가 표준.

Q. “self-invocation 함정이 그렇게 위험한가요?”

위험하다기보다 눈에 안 보이는 게 문제입니다. 코드 logic 만 보면 정상. @Transactional(REQUIRES_NEW) 명시, 차감 로직 정상, dirty checking 신뢰. 그런데 같은 클래스 내부 호출로 인한 proxy 우회로 — successes=100 인데 차감 0 건. 운영에선 데이터 불일치 발견까지 며칠 걸립니다. PR 단계에서 lint 룰로 막는 게 1/100 비용. Spring 공식 문서에 명시된 well-known 함정인데도 코드 리뷰 단계에서 매번 놓치는 이유 — proxy 메커니즘이 추상화 뒤에 가려져 있기 때문.

Q. “MySQL GET_LOCK 의 진짜 사용처는?”

매우 좁습니다. DB 마이그레이션 / DDL 동시 실행 방지 같은 admin 용도. connection-bound 함정 (close 시 자동 release / commit 후 lock 유지 / connection pool 환경 추적 불능) 때문에 분산락으로 부적합. 그렇다고 row-level lock 도 아니라 비관락보다 27.8 배 느림. 그래서 [실측] 잔액 차감 시나리오에선 91/100 success / 5015ms — 가장 나쁜 결과.


다음 글에서

본 측정은 단일 인스턴스 + 100 worker thread 시나리오입니다. 운영에서는 다음 축들도 같이 봐야 합니다.


참고자료


Share this post on:

Previous Post
프로덕션이 'Check failed: node->IsInUse()' 한 줄로 죽었습니다 (1) — V8 GlobalHandles 해부
Next Post
RDB Mastery #3 — EXPLAIN ANALYZE 마스터: Push Down 함정과 Index Selection 의 진짜 메커니즘