이 사이트의 실습은 자바스크립트를 켜야 움직입니다. 아래는 같은 내용을 글로 적은 것입니다.
공부 › JPA › 웹 애플리케이션과 성능
재고 차감과 락
재고 3개에 주문이 동시에 몰리면
여러 요청이 동시에 "재고 확인 → 1 감소 → 주문 생성"을 합니다. 아무 장치가 없으면 재고보다 많이 팔리거나 재고 숫자가 실제와 어긋납니다. 이런 동시 수정을 막는 대표적인 장치가 락이며, 락을 거는 여러 방법과 락 없이 막는 방법의 결과를 비교합니다.
실행으로 대조 · MySQL 8.4.11과 Redis에서 45가지 조합을 실행해 대조
이 실습에 나오는 개념
- 비관적 락
- 충돌이 일어날 것이라고 가정하고 데이터를 읽는 시점에 먼저 잠그는 방식이다. SELECT ... FOR UPDATE로 행을 잠그면 다른 트랜잭션은 그 행을 수정하려 할 때 잠금이 풀릴 때까지 기다린다. 충돌이 잦은 경우에 확실하지만 대기와 교착 상태의 가능성이 있다.
- 낙관적 락(@Version)
- 충돌이 드물다고 가정하고 DB 잠금을 걸지 않는 대신, 수정 시점에 그 사이 다른 트랜잭션이 먼저 수정했는지를 버전 번호로 확인하는 방식이다. 엔티티에 @Version 필드를 두면 수정할 때 버전을 비교해, 이미 바뀌어 있으면 예외가 발생한다. 충돌이 잦지 않은 경우에 잠금 대기 없이 정합성을 지킬 수 있다.
- 공유 락과 배타 락
- 공유 락(S)은 읽기를 위한 잠금으로 여러 트랜잭션이 동시에 가질 수 있다. 배타 락(X)은 쓰기를 위한 잠금으로 한 트랜잭션만 가질 수 있으며, 배타 락이 걸린 데이터에는 다른 트랜잭션이 공유 락도 배타 락도 걸 수 없다.
- 분산 락
- 여러 서버에서 실행되는 프로세스들 사이에서 한 번에 하나만 특정 작업을 수행하도록 하는 잠금이다. 자바의 synchronized는 한 서버 안에서만 유효하므로, 서버가 여러 대일 때는 모두가 함께 접근하는 저장소(Redis 등)에 "잠금 표시"를 두는 방식으로 구현한다. 잠금을 건 쪽에 문제가 생겨도 풀리도록 만료 시간을 둔다.
- 경쟁 상태(Race Condition)
- 둘 이상의 스레드가 같은 데이터를 동시에 읽고 쓸 때 실행 순서에 따라 결과가 달라지는 상황이다. count++는 값을 읽고, 1을 더하고, 다시 쓰는 세 단계로 이뤄지는데 두 스레드의 단계가 섞이면 한쪽의 증가가 사라진다. 테스트에서는 잘 드러나지 않다가 부하가 걸릴 때 나타나는 것이 특징이다.
이렇게 바꿔 보세요
- 처음 설정으로 — 장치 없음. 재고 3개에 주문 4건이 전부 성공합니다.
- 비관적 락 — 한 번에 하나씩만 재고 행을 읽고 고칩니다. 나머지는 줄을 섭니다.
- 낙관적 락 — 일단 진행하고, 저장할 때 누가 먼저 바꿨으면 실패시킵니다.
- 조건을 넣은 UPDATE — 확인과 감소를 SQL 한 문장으로 합칩니다.
- 낙관적 락에 주문이 몰리면 — 6건이 경쟁합니다. 재시도 횟수를 보세요.
처음 설정으로 실행하면
- 요청 1: 재고를 조회합니다. 3개입니다. 0보다 크므로 주문을 진행합니다.
- 요청 2: 재고를 조회합니다. 3개입니다. 0보다 크므로 주문을 진행합니다. 앞 요청들이 아직 저장하기 전이라 같은 값을 읽었습니다.
- 요청 3: 재고를 조회합니다. 3개입니다. 0보다 크므로 주문을 진행합니다. 앞 요청들이 아직 저장하기 전이라 같은 값을 읽었습니다.
- 요청 4: 재고를 조회합니다. 3개입니다. 0보다 크므로 주문을 진행합니다. 앞 요청들이 아직 저장하기 전이라 같은 값을 읽었습니다.
- 요청 1: 자기가 읽은 3에서 1을 뺀 값(2)을 재고로 저장하고 주문을 만들었습니다.
- 요청 2: 자기가 읽은 3에서 1을 뺀 값(2)을 재고로 저장하고 주문을 만들었습니다. 앞 요청이 저장한 값을 같은 값으로 덮어썼습니다. 재고는 줄지 않았는데 주문은 하나 더 생겼습니다.
- 요청 3: 자기가 읽은 3에서 1을 뺀 값(2)을 재고로 저장하고 주문을 만들었습니다. 앞 요청이 저장한 값을 같은 값으로 덮어썼습니다. 재고는 줄지 않았는데 주문은 하나 더 생겼습니다.
- 요청 4: 자기가 읽은 3에서 1을 뺀 값(2)을 재고로 저장하고 주문을 만들었습니다. 앞 요청이 저장한 값을 같은 값으로 덮어썼습니다. 재고는 줄지 않았는데 주문은 하나 더 생겼습니다.
실행 결과
- 주문은 4건 만들어졌는데 DB의 재고는 3개에서 2개로 1만 줄었습니다. 재고보다 1건이 더 팔렸습니다.
- 조회와 수정 사이에 다른 요청이 끼어들 수 있기 때문입니다. @Transactional만으로는 막아지지 않습니다. 같은 값을 읽은 요청들이 서로의 수정을 덮어쓰는 것을 갱신 손실이라고 합니다.
코드
@Transactional
public void order(long productId) {
Product p = productRepository.findById(productId).orElseThrow();
if (p.getStock() <= 0) throw new SoldOutException();
p.setStock(p.getStock() - 1); // 읽은 값에서 1을 뺀다
orderRepository.save(new Order(productId));
}
이 모형의 전제
요청들이 가장 나쁘게 겹치는 순서(모두가 먼저 읽고 나서 쓰는 순서)를 가정했습니다. 실제로는 일부만 겹치므로 어긋나는 정도가 매번 다릅니다. 낙관적 락의 재시도는 실패한 요청이 한꺼번에 다시 시도한다고 보았습니다. 다섯 가지 방법 모두를 실제 MySQL 8.4.11에서 주문마다 세션을 따로 열어 45가지 조합(재고 1·3·5개, 주문 2·4·6건)으로 실행해, 만들어진 주문 수와 남은 재고, 대기와 재시도 횟수가 화면과 일치하는 것을 확인했습니다(화면과 같은 "가장 나쁜 순서"가 되도록 실행 순서를 맞춤). 분산 락은 실제 Redis의 SET NX로 실행했습니다. 낙관적 락은 JPA를 거치지 않고 @Version이 만들어 내는 것과 같은 SQL(WHERE version = ?)로 실행했습니다.
앞 실습: 커넥션 풀과 OSIV · 다음 실습: DTO로 바로 조회하기