MySQL 8에서 격리 수준을 올려도 Lost Update는 막히지 않았습니다
목차
재고 100개짜리 상품에 주문 120건을 동시에 넣어 봤더니 120건이 전부 성공으로 끝났고, 재고는 3개만 줄어 있었습니다. 성공 주문 120건 중 재고 차감이 117건 누락됐습니다. 초기 재고 100개를 기준으로 초과 접수된 주문은 20건입니다. 격리 수준을 READ COMMITTED에서 REPEATABLE READ로 올려도 결과는 같았고, 5회 모두 같았습니다. SELECT ... FOR UPDATE나 조건부 UPDATE 한 줄로 바꾸자 5회 모두 정확히 100건만 팔렸습니다. 대신 전체 처리 시간은 약 50ms에서 140ms로 늘었습니다(로컬 측정).
@Transactional 실수 5가지 글 끝에 다음 글에서 격리 수준을 다루겠다고 적어 두고 한참을 미뤘습니다. 격리 수준 글은 보통 4단계 표와 Dirty Read 설명으로 끝나는데, 실무에서 자주 듣는 질문은 “재고가 꼬이는데 격리 수준을 올리면 되나요?”였기 때문입니다. 말로 답하기 전에 직접 돌려 보기로 했습니다.
실험 환경
- 애플리케이션: Java 21, Spring Boot 3.3.5, Hibernate 6.5.3, HikariCP 풀 50개
- DB: MySQL 8.0.46 (Docker, 기본 격리 수준 REPEATABLE READ)
- 장비: Apple M2 Mac mini 한 대 (앱과 DB가 같은 장비)
- 시나리오: 재고 100개짜리 상품 1개에 1개씩 주문 120건을 스레드 50개로 동시에 투입
- 반복: 방식마다 5회, 표의 시간은 중앙값
로컬 장비 한 대에서 잰 값이라 절대 시간보다 방식 사이의 차이를 봐 주시면 됩니다.
실험한 코드
흔히 보는 재고 차감 코드입니다. 조회해서 남은 재고를 확인하고, 엔티티 값을 바꾸면 커밋 때 Hibernate가 UPDATE를 보냅니다.
@Transactional(isolation = Isolation.REPEATABLE_READ)
public boolean order(Long productId) {
Product product = productRepository.findById(productId).orElseThrow();
if (product.getStock() < 1) {
return false; // 품절
}
product.decrease(1); // stock = stock - 1 (메모리에서 계산)
return true; // 커밋 시 UPDATE product SET stock = ? WHERE id = ?
}문제는 마지막 줄입니다. Hibernate가 보내는 UPDATE는 stock = stock - 1이 아니라 stock = 99처럼 계산이 끝난 값입니다. 100을 읽은 트랜잭션 50개가 모두 99를 쓰면, 50건을 팔았는데 재고는 1개만 줄어듭니다.
sequenceDiagram
participant A as 주문 A
participant DB as product (stock=100)
participant B as 주문 B
A->>DB: SELECT stock → 100
B->>DB: SELECT stock → 100
A->>DB: UPDATE stock = 99, COMMIT
B->>DB: UPDATE stock = 99, COMMIT
Note over DB: 2건 판매, 재고는 1개만 감소읽고, 판단하고, 고쳐 쓰는 사이에 다른 트랜잭션이 끼어드는 이 현상이 Lost Update입니다.
격리 수준만 바꿔 봤습니다
같은 코드에서 isolation만 바꿨습니다.
| 격리 수준 | 판매 성공 | 실제 차감 | 차감 누락 | 실패 | 전체 시간 |
|---|---|---|---|---|---|
| READ COMMITTED | 120건 | 3개 | 117건 | 0 | 67ms |
| REPEATABLE READ | 120건 | 3개 | 117건 | 0 | 48ms |
| SERIALIZABLE | 3~6건 | 3~6개 | 0건 | 114~117건 | 51ms |
여기서 차감 누락은 성공 주문 수에서 실제 재고 감소량을 뺀 값입니다. 물리적 재고 대비 초과 접수량과는 다른 지표입니다.
REPEATABLE READ로 올려도 READ COMMITTED와 똑같았습니다. MySQL의 REPEATABLE READ는 같은 트랜잭션 안에서 읽은 값이 바뀌지 않게 해 줄 뿐이고, 남이 그 사이에 커밋한 행을 내가 덮어쓰는 것까지 막아 주지는 않습니다. 각 트랜잭션은 자기가 본 100을 믿고 99를 썼고, MySQL은 그 UPDATE를 그대로 받았습니다.
PostgreSQL의 REPEATABLE READ는 이 상황에서 뒤늦게 쓰는 쪽을 직렬화 오류로 되돌리는 것으로 문서에 나와 있습니다. 이번 실험은 MySQL만 돌렸고, PostgreSQL은 직접 재현하지 않았습니다.
SERIALIZABLE은 초과 판매를 막긴 했습니다. 다만 막는 방식이 문제였습니다. MySQL은 이 수준에서 일반 SELECT에도 공유 락을 거는데, 50개 트랜잭션이 공유 락을 잡은 채로 동시에 쓰기 락으로 올라가려다 서로를 기다립니다. 5회 동안 로그에 Deadlock found when trying to get lock이 581번 찍혔고, 회차마다 주문 120건 중 114~117건이 실패했습니다. 재고가 94~97개나 남았는데 손님 대부분을 돌려보낸 셈입니다.
락으로 풀어 봤습니다
격리 수준은 기본값(REPEATABLE READ)으로 두고 차감 방식만 바꿨습니다.
먼저 비관적 락입니다. 읽을 때부터 행을 잠가서, 다음 주문은 앞 주문이 커밋할 때까지 기다립니다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select p from Product p where p.id = :id")
Optional<Product> findByIdForUpdate(@Param("id") Long id); // SELECT ... FOR UPDATE다음은 조건부 UPDATE입니다. 읽지 않고 한 줄로 끝내고, “남은 재고가 있으면 줄여라”를 DB가 원자적으로 판단합니다. 영향받은 행이 0이면 품절입니다.
@Modifying
@Query("update Product p set p.stock = p.stock - :q where p.id = :id and p.stock >= :q")
int decreaseIfEnough(@Param("id") Long id, @Param("q") int q);마지막은 낙관적 락입니다. 엔티티에 @Version을 두고, 버전이 어긋나 실패하면 트랜잭션 밖에서 다시 시도합니다. 같은 트랜잭션 안에서 재시도하면 이미 롤백 대상으로 찍힌 트랜잭션이라 소용이 없어서, 재시도 루프는 서비스 바깥에 뒀습니다.
| 방식 | 판매 성공 | 품절 처리 | 초과 판매 | 실패 | 전체 시간 | 주문당 p95 | 재시도 |
|---|---|---|---|---|---|---|---|
| 비관적 락 (FOR UPDATE) | 100건 | 20건 | 0 | 0 | 140ms | 71ms | - |
| 조건부 UPDATE | 100건 | 20건 | 0 | 0 | 133ms | 77ms | - |
| 낙관적 락, 재시도 10회 (간격 없음) | 22건 | 0 | 0 | 98건 | 456ms | 230ms | 981회 |
| 낙관적 락, 재시도 100회 (1~19ms 랜덤 대기) | 100건 | 20건 | 0 | 0 | 456ms | 430ms | 약 1,160회 |
비관적 락과 조건부 UPDATE는 5회 모두 정확히 100건을 팔고 20건을 품절로 돌려보냈습니다. 시간도 비슷했습니다. 격리 수준만 바꾼 경우(약 50ms)보다 세 배 가까이 걸렸지만, 그 차이는 줄을 세운 비용입니다.
낙관적 락은 처음에 결과가 이상하게 나왔습니다. 재고가 78개나 남았는데 98건이 실패했습니다. 50개 스레드가 같은 행에 동시에 부딪히니 매번 한 명만 이기고 나머지는 곧바로 다시 부딪혔고, 10번을 다 쓰고 포기한 것입니다. 재시도 사이에 랜덤 대기를 넣자 정확히 100건이 팔렸지만, 재시도가 1,100번을 넘었고 p95는 비관적 락의 여섯 배였습니다.
어떤 걸 쓸지
재고, 포인트, 잔액처럼 숫자 하나를 줄이는 경우라면 저는 조건부 UPDATE부터 보겠습니다. 가장 단순하고 읽기 없이 한 번에 끝납니다. 줄이기 전에 다른 행이나 규칙을 같이 봐야 한다면 비관적 락으로 판단과 쓰기 사이를 묶는 쪽입니다. 낙관적 락은 게시글 수정처럼 같은 행에 동시에 몰리는 일이 드문 곳에서는 평소 락 비용이 없어 좋지만, 이번 실험처럼 한 행에 몰리는 곳에서는 재시도가 폭주하니 피하겠습니다. 격리 수준을 올리는 쪽은 REPEATABLE READ가 효과가 없었고 SERIALIZABLE은 대부분을 실패시켰으니 고르지 않겠습니다.
각각 약점도 있습니다. 조건부 UPDATE는 엔티티를 거치지 않기 때문에, 같은 트랜잭션 안의 영속성 컨텍스트에 남은 Product가 옛 값을 들고 있습니다. 차감 뒤에 같은 엔티티를 다시 쓸 일이 있다면 먼저 미반영 변경을 flush하고 clear한 뒤 조회하거나, 벌크 변경을 별도 작업 경계로 분리해야 합니다. clear만 호출하면 미반영 변경을 잃을 수 있고, 같은 영속성 컨텍스트에서 findById만 다시 호출하면 기존 엔티티를 받을 수 있습니다. 비관적 락은 잠그는 시간이 길어지면 다른 트랜잭션과 엉켜 데드락으로 번질 수 있습니 다. 긴 트랜잭션이 락을 오래 쥐고 있다가 API 서버와 부딪힌 사례는 스케줄러 데드락 해결기에 따로 적었습니다.
마치며
이번에는 PostgreSQL을 돌리지 못했고, 로컬 장비 한 대에서 잰 값이라 운영 환경의 절대 시간으로 읽으시면 안 됩니다. 다음에 재고 차감 코드를 고칠 때는 조회한 값으로 계산해 덮어쓰는 부분이 있는지부터 보고, 스레드 수십 개로 동시에 쏘는 테스트를 먼저 하나 만들어 두려 합니다. 이번 차감 누락 117건은 그런 테스트 하나면 바로 드러났을 문제입니다.
