배경
재고가 1개뿐인 상품에 10명이 동시에 주문을 넣었다.
결과는 10명 전부 주문 성공이었다. 에러도 없이. 재고는 -9가 됐다. 음.....?
뭐가 문제인지 보기 전엔 몰랐다
재고 차감 코드는 단순해 보인다.
@Transactional
public void order(Long optionId, int quantity) {
Stock stock = stockRepository.findByOptionId(optionId); // 1. 재고 읽기
if (stock.getQuantity() < quantity) { // 2. 재고 확인
throw new InsufficientStockException();
}
stock.decrease(quantity); // 3. 재고 차감
}
@Transactional도 붙어있고, 재고 확인도 하고 있으니 괜찮은 거 아닌가?
문제는 2번 재고 확인 시점에서 생긴다.
왜 이런 일이 생기나: Lost Update
10개 트랜잭션이 동시에 실행될 때 실제로 어떤 일이 벌어지는지 따라가보자.
T1: 재고 읽음 = 1 ← 스냅샷
T2: 재고 읽음 = 1 ← 똑같은 스냅샷 (T1이 아직 커밋 안 했으니까)
T1: 재고 확인 → 1 >= 1 → 조건 통과 → 재고 0으로 차감 → 커밋
T2: 재고 확인 → 1 >= 1 → 조건 통과 → 재고 0으로 차감 → 커밋
결과: 재고 -1
1편에서 다뤘던 REPEATABLE READ가 여기서 문제를 일으킨다.
- [Java / Spring Boot] @Transactional 붙이면 동시성이 다 해결될 줄 알았다
각 트랜잭션은 자신이 시작된 시점의 스냅샷을 읽는다. T1이 아직 커밋하지 않은 상태에서 T2가 읽으면, T2도 재고가 1개라고 읽는다. 둘 다 조건을 통과하고, 둘 다 차감한다.
이게 Lost Update(갱신 손실) 다. T1이 바꾼 값을 T2가 덮어써버린다.
10개 트랜잭션이 동시에 실행되면 결국 재고가 -9가 된다. @Transactional이 있어도.
직접 재현해보기
동시 요청을 테스트하려면 두 가지 도구가 필요하다.
ExecutorService — Java 기본 기능(java.util.concurrent). 스레드 풀을 한 줄로 만들어준다.
CountDownLatch — "모든 스레드가 끝날 때까지 기다려줘"를 구현하는 Java 기본 기능.
@Test
void 동시_주문_재고_초과_실험() throws InterruptedException {
// given: 재고 1개
stockRepository.save(new Stock(optionId, 1));
int threadCount = 10;
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
AtomicInteger successCount = new AtomicInteger();
AtomicInteger failCount = new AtomicInteger();
// when: 10명이 동시에 주문
for (int i = 0; i < threadCount; i++) {
executor.submit(() -> {
try {
orderService.order(optionId, 1);
successCount.incrementAndGet();
} catch (InsufficientStockException e) {
failCount.incrementAndGet();
} finally {
latch.countDown(); // 하나 완료 신호
}
});
}
latch.await(); // 10개 전부 완료될 때까지 대기
Stock result = stockRepository.findByOptionId(optionId);
System.out.println("성공: " + successCount.get()); // 기대: 1
System.out.println("실패: " + failCount.get()); // 기대: 9
System.out.println("남은 재고: " + result.getQuantity()); // 기대: 0
}
실제 결과 (비관적 락 적용 전):
성공: 10
실패: 0
남은 재고: -9
에러 하나 없이 10명 전부 성공했다.
해결: 비관적 락 (Pessimistic Lock)
문제의 핵심은 "읽는 시점이 겹친다"는 거다. T1이 읽고 커밋하기 전에 T2도 읽어버리니까 둘 다 재고가 있다고 판단한다.
해결책은 간단하다. 읽는 시점에 락을 걸어서 순서를 만들면 된다.
락 있이:
T1 읽음 + 락 획득
T2 대기 (T1이 락 가지고 있으니까)
T1 차감 → 커밋 → 락 해제
T2 읽음 (이제 0) → 재고 부족 → 실패
"어차피 충돌날 거니까, 읽을 때부터 잠가버리자"는 전략이다. 이게 비관적 락이다.
트랜잭션이랑 락은 다른 거다
락 얘기가 나오면 "그럼 트랜잭션은 왜 걸어? 락만 걸면 되는 거 아닌가?" 하는 의문이 생길 수 있다.
역할이 다르다.
트랜잭션: "이 작업들을 하나의 단위로 묶어줘. 실패하면 다 되돌려줘."
락: "내가 이 데이터 쓰는 동안 다른 애들 못 건드리게 막아줘."
둘 중 하나만 있으면 이런 일이 생긴다.
락만 있고 트랜잭션 없으면:
T1: 락 획득 → 재고 차감 → 주문 저장 실패
→ 재고는 줄었는데 주문은 없는 상태 (데이터 망가짐)
트랜잭션만 있고 락 없으면:
→ 오늘 실험한 것처럼 Lost Update 발생
둘 다 있으면:
락 → 동시 접근 차단
트랜잭션 → 작업 묶음 보장 + 실패 시 롤백
락이 꼭 JPA 꺼일 필요는 없다
락을 거는 방법이 여러 가지다.
1. JPA @Lock → JPA가 FOR UPDATE 쿼리 자동 생성
2. 직접 SQL 쿼리 → SELECT ... FOR UPDATE 직접 작성
3. Redis 분산 락 → 서버가 여러 대일 때 (Redisson 등)
4. DB 네임드 락 → GET_LOCK() 함수 사용
JPA @Lock은 그냥 가장 편한 방법이라 많이 쓰는 거다. 내부적으로는 결국 DB의 FOR UPDATE로 변환된다.
FOR UPDATE가 수정 쿼리도 아닌데 왜 락이냐
FOR UPDATE를 처음 보면 "SELECT인데 왜 UPDATE가 붙어있지?" 싶다.
FOR UPDATE는 실제로 수정을 하는 게 아니다. "내가 이 행 곧 수정할 거니까 다른 애들 손대지 마" 라고 DB에 미리 예약을 거는 거다.
DB 락에는 두 종류가 있다.
공유 락 (Shared Lock, S-Lock)
→ 일반 SELECT 할 때
→ "나 읽는 중이야, 다른 사람도 읽어도 되는데 수정은 안 돼"
배타 락 (Exclusive Lock, X-Lock)
→ UPDATE, DELETE, SELECT FOR UPDATE 할 때
→ "나 쓰는 중이야, 다른 사람 읽기/쓰기 둘 다 안 돼"
FOR UPDATE는 SELECT인데 배타 락을 미리 거는 거다. 곧 수정할 거니까 읽는 시점에 선점하는 것이다.
T1: SELECT FOR UPDATE → 배타 락 획득 (아직 수정 안 함)
T2: SELECT FOR UPDATE → 대기 (T1이 배타 락 가지고 있음)
T1: UPDATE stock SET quantity = 0
T1: COMMIT → 락 해제
T2: 이제 진입 → SELECT → quantity = 0 읽음 → 재고 부족 → 실패
JPA에서는 @Lock 어노테이션 하나로 이 쿼리를 자동으로 만들어준다.
public interface StockRepository extends JpaRepository<Stock, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE) // SELECT ... FOR UPDATE
@Query("SELECT s FROM Stock s WHERE s.optionId = :optionId")
Optional<Stock> findByOptionIdWithLock(@Param("optionId") Long optionId);
}
-- JPA가 내부적으로 생성하는 쿼리
SELECT * FROM stock WHERE option_id = ? FOR UPDATE
서비스 코드는 조회 메서드만 바꾸면 된다.
@Transactional
public void order(Long optionId, int quantity) {
// 락을 잡고 조회 → 다른 트랜잭션은 이 락이 풀릴 때까지 대기
Stock stock = stockRepository.findByOptionIdWithLock(optionId)
.orElseThrow(() -> new StockNotFoundException(optionId));
if (stock.getQuantity() < quantity) {
throw new InsufficientStockException();
}
stock.decrease(quantity);
} // 트랜잭션 종료 시 락 해제 → 다음 트랜잭션 진입
실제 결과 (비관적 락 적용 후):
성공: 1
실패: 9
남은 재고: 0
10개 중 1개만 성공하고, 나머지 9개는 정확하게 InsufficientStockException을 받았다.
비관적 락의 트레이드오프
비관적 락이 좋은 것만은 아니다.
락을 걸면 한 명씩 순차적으로 처리된다. 동시에 1000명이 주문하면 999명이 줄을 서서 기다린다. 처리량(Throughput)이 줄어들고 병목현상이 생긴다.
최악의 경우 데드락도 발생한다.
T1: A 락 획득 → B 락 기다리는 중
T2: B 락 획득 → A 락 기다리는 중
→ 둘 다 영원히 대기 (교착 상태)
항목 내용
| 장점 | 충돌 확실히 방지, 구현 단순 |
| 단점 | 한 명씩 순차 처리 → 처리량 감소 |
| 단점 | 대기가 쌓이면 병목현상 |
| 단점 | 여러 자원을 동시에 잠글 때 데드락 위험 |
그럼 언제 비관적 락을 써야 하나
재고에 비관적 락을 선택한 이유는 두 가지다.
첫째, 돈과 연결된 데이터다.
재고 차감은 결제와 직결된다. 주문이 안 되는 것보다 주문했는데 나중에 취소되는 게 사용자 입장에서 훨씬 기분 나쁘다. 틀리면 절대 안 된다.
둘째, 충돌이 자주 일어난다.
인기 상품일수록 동시 요청이 몰린다. 충돌이 드문 상황이라면 다른 전략이 더 유리할 수 있다.
충돌이 자주 일어나는가? + 틀리면 절대 안 되는가?
→ YES → 비관적 락
→ NO → 다른 전략 고려
충돌이 드문 상황에서 더 효율적인 전략이 있다. 그 이야기는 다음 편에서 다룬다.
번외: Django에서는 어떻게 하나
Django ORM에도 똑같은 기능이 있다. select_for_update()로 비관적 락을 걸 수 있다.
from django.db import transaction
@transaction.atomic # Spring의 @Transactional
def order(option_id, quantity):
# SELECT ... FOR UPDATE
stock = Stock.objects.select_for_update().get(option_id=option_id)
if stock.quantity < quantity:
raise InsufficientStockException()
stock.quantity -= quantity
stock.save()
Spring이랑 1:1 대응이다.
Spring Django
─────────────────────────────────────────────
@Transactional → @transaction.atomic
@Lock(PESSIMISTIC_WRITE) → .select_for_update()
SELECT ... FOR UPDATE → SELECT ... FOR UPDATE (DB 쿼리는 동일)
결국 락은 DB가 처리하는 거라, 언어나 프레임워크가 달라도 DB에 내리는 쿼리는 똑같다. Spring @Lock이든 Django select_for_update()든 내부적으로는 동일한 FOR UPDATE로 변환된다.
시리즈 목차
- @Transactional 붙이면 동시성이 다 해결될 줄 알았다
- 스투시 반팔티를 10명이 동시에 주문했을 때 발생한 일 ← 현재 글
- 비관적 락 vs 낙관적 락, 선택 기준은 무엇이었나
- 쿠폰 사용에 대한 처리는 어떤 락이 적절할까
- 브랜드에서 전화가 왔다, "우리 상품 좋아요가 한번에 수십개씩 빠져요!"
'개발 > 개발' 카테고리의 다른 글
| [Java / Spring Boot] 쿠폰 사용에 대한 처리는 어떤 락이 적절할까 - 4편 (0) | 2026.03.06 |
|---|---|
| [Java / Spring Boot] 비관적 락 vs 낙관적 락, 선택 기준은 무엇이었나 - 3편 (0) | 2026.03.06 |
| [Java / Spring Boot] @Transactional 붙이면 동시성이 다 해결될 줄 알았다 -1편 (0) | 2026.03.05 |
| [TDD] TDD를 하다 보니 DIP와 VO를 이해해야 했다 (1) | 2026.02.27 |
| [스크랩 기능] 스크랩은 HardDelete vs SoftDelete? (0) | 2026.02.26 |