재고 차감 락을 인메모리 DB로 고르면 틀린다: 실 MySQL에서 낙관적 락이 가장 느렸다
목차
재고 20개에 스레드 30개가 동시에 차감하는 실험에서 세 락 전략 모두 초과판매는 0이었고 걸린 시간만 갈렸습니다. H2 인메모리 DB에서는 낙관적 락이 비관적 락보다 빨랐는데 실 MySQL 8.4에서는 조건부 UPDATE 40ms, 비관적 락 75ms, 낙관적 락 151ms로 순서가 뒤집혔습니다. 스레드를 150개로 늘리면 조건부 UPDATE와 낙관적 락의 차이는 79ms 대 429ms로 5.4배까지 벌어졌습니다.
결론부터
- 세 전략 모두 초과 판매는 막았지만, 인메모리에서 빠른 전략이 실 MySQL에서도 빠르지는 않았습니다.
- 조건부
UPDATE를 기본 전략으로 골랐고, 낙관적 락은 경합이 커질수록 재시도 비용 때문에 뒤로 밀렸습니다. - 재고 차감 전략은 락 이름이 아니라 실제 DB의 성공률·지연·재시도 비용을 함께 측정해 정해야 합니다.
무엇을 비교했나
선착순이나 인기 상품의 재고는 같은 행에 차감이 몰리는 자리입니다. 음수 재고와 초과판매를 막아야 하고 그러면서 차감이 결제 흐름을 느리게 만들면 안 됩니다. 이 글은 락 전략 셋을 어떤 환경에서 재야 결론이 성립하는지를 다룹니다. 처음 잰 환경이 결론을 바꿨습니다.
락 비교는 JPA와 트랜잭션 계층을 빼고 raw JDBC로 SQL 패턴만 격리해 쟀습니다. 서비스 전체의 지연이 아니라 전략 사이의 상대 비교입니다.
세 후보
| 전략 | 방식 | 충돌이 나면 |
|---|---|---|
| 비관적 락 | SELECT ... FOR UPDATE로 행을 잠그고 차감 | 앞 트랜잭션이 끝날 때까지 줄을 선다 |
| 낙관적 락 | @Version으로 읽고 쓸 때 버전을 비교 | 실패하고 다시 읽어 다시 쓴다 |
| 조건부 UPDATE | UPDATE ... SET quantity = quantity - :n WHERE quantity >= :n | 조건이 거짓이면 0행이 바뀌고 끝난다 |
셋 다 초과판매를 막을 수 있습니다. 비용이 어디서 생기는지가 다릅니다. 비관적 락은 기다리는 시간과 커넥션 점유, 낙관적 락은 재시도할 때마다 DB를 다시 왕복하는 비용, 조건부 UPDATE는 DB 안에서 한 문장으로 끝나는 대신 차감 전후에 복잡한 계산을 끼워 넣기 어렵다는 점을 치릅니다.
인메모리에서 먼저 쟀다
처음에는 Docker 없이 도는 H2 인메모리 DB로 쟀습니다. 기록된 첫 측정에서 낙관적 락은 17ms, 비관적 락은 32ms였습니다. 이 글을 쓰면서 같은 테스트를 한 번 더 돌렸을 때는 조건부 UPDATE 6ms, 낙관적 락 17ms, 비관적 락 21ms가 나왔습니다. 워밍업 없는 한 번의 실행이라 값은 흔들리지만 낙관적 락이 비관적 락보다 빠르다는 순서는 두 번 모두 같았습니다.
이 결과만 보면 “충돌이 나도 재시도가 싸니 낙관적 락도 괜찮다”는 결론이 나옵니다. 인메모리에는 네트워크 왕복도 디스크도 없어서 재시도가 사실상 공짜입니다. 재시도 기반 전략에 유리하게 기운 환경이었습니다.
실 MySQL에서 다시 쟀다
Testcontainers로 MySQL 8.4를 띄워 같은 조건을 워밍업 후 5회 돌리고 중앙값을 냈습니다.
| 전략 | 초과판매 | 완판 | 30스레드 | 150스레드 |
|---|---|---|---|---|
| 조건부 UPDATE | 0 | 20/20 | 40ms (24~49) | 79ms (66~110) |
| 비관적 락 | 0 | 20/20 | 75ms (59~144) | 재지 않음 |
| 낙관적 락 | 0 | 20/20 | 151ms (99~165) | 429ms (298~451) |
순서가 뒤집혔습니다. 낙관적 락은 충돌할 때마다 조회와 갱신을 다시 왕복하는데 실 DB에서는 그 왕복이 실제 비용입니다. 비관적 락은 한 번 줄을 서면 왕복이 한 번뿐이라 재시도보다 쌌습니다. 조건부 UPDATE는 락도 재시도도 없이 원자적인 UPDATE 한 번으로 끝났습니다. 데드락과 락 대기 타임아웃은 0건이었습니다. 단일 행 경합이라 대기 관계가 한 줄로 이어지기 때문입니다.
고경합에서 차이가 더 벌어졌습니다. 150스레드에서 낙관적 락은 재시도 상한 50회 안에서 미달판매까지 가지는 않았지만 그 상한을 지키느라 지연이 429ms까지 늘었습니다.
결정과 그 범위
기본 전략은 조건부 UPDATE입니다. 결제가 승인되면 체크아웃이 조건부 차감을 부릅니다. 나머지 두 구현은 비교 근거로 코드에 남겨 두었습니다.
- 비관적 락은 차감 전후에 여러 번 읽고 계산해야 하는 경우에만 값을 합니다. 단순 차감에는 과합니다.
- 낙관적 락은 충돌이 드문 자원에 맞습니다. 한 행에 쓰기가 몰리는 인기 상품 재고에는 맞지 않습니다.
- 여러 인스턴스에서 DB 부하를 나눠야 하는 규모가 되면 Redis 분산 락을 추가로 검토합니다. 지금은 단일 DB 기준입니다.
두 환경의 테스트는 역할을 나눠 남겼습니다. H2 테스트는 CI에서 매번 결정적으로 도는 안전 검사(초과판매 0, 최종 재고 = 재고 − 성공)로 쓰고 성능 수치는 Docker가 필요한 MySQL 테스트에서만 냅니다. 인메모리 결과를 성능 근거로 인용하지 않기 위해서입니다.
같은 판단이 나중에 한 번 더 쓰였습니다. 포인트 적립이 같은 계좌 행을 두고 낙관적 락 충돌을 내며 결제 승인을 500으로 만들었을 때, 적립은 조건도 필요 없는 더하기라서 DB에서 바로 더하는 원자 증가로 바꿨습니다. 조건부 UPDATE와 같은 처방에서 조건만 뺀 것입니다. 그 과정은 정산을 떼어 낼 근거 하나를 기각했다에 있습니다.
이 측정이 말하지 않는 것
- raw JDBC로 SQL 패턴만 비교했습니다. 서비스 계층을 포함한 결제 흐름 전체의 지연이 아닙니다.
- 로컬 Testcontainers 값입니다. 네트워크 거리가 있는 운영 DB에서는 왕복 비용이 더 커지므로 낙관적 락이 더 불리해질 것으로 보지만 재지는 않았습니다.
- 비관적 락의 150스레드 값은 재지 않았습니다.
이어서 읽기
- 재고 동시성은 조건부 UPDATE를 고른 뒤에도 끝나지 않았다: 재고를 잡는 시점, 한 행이 버티는 처리량, 느슨한 카운터와의 비교
참고
댓글
댓글 수정/삭제는 GitHub Discussions에서 가능합니다.