Mini
비관적 락으로 재고감소 로직에서 발생하는 '동시성 문제' 쉽게 풀기! 본문
들어가며
안녕하세요. 백엔드 개발자 유동훈입니다. 쇼핑몰 프로젝트를 고도화 하면서 여러 클라이언트에서 접근시 재고감소 로직이 의도대로 작동하지 않는 문제를 발견하고 해결한 과정을 공유하고자 합니다.
문제상황
문제 상황을 재현하기 위한 테스트코드는 다음과 같습니다.
@Test
@DisplayName("20명이 5개씩 주문하면 재고가 100이 줄어야 한다.")
public void stock_decrease_concurrency() throws Exception {
// given
int stockQuantity = 100;
TestDataDto testData = testDataService.createTestData(stockQuantity);
Long bookId = testData.getBookId();
Long memberId = testData.getMemberId();
log.info("초기 설정 완료 - 도서 ID: {}, 회원 ID: {}", bookId, memberId);
int orderCount = 5;
int threadCount = 20;
ExecutorService executorService = Executors.newFixedThreadPool(100);
CountDownLatch latch = new CountDownLatch(threadCount);
// when
for(int i=0; i<threadCount; i++){
executorService.submit(() -> {
try {
orderService.order(memberId, bookId, orderCount);
} finally {
latch.countDown();
}
});
}
latch.await();
executorService.shutdown();
//then
testDataService.verifyFinalStock(bookId, stockQuantity - orderCount * threadCount);
}
}
재고가 100개이고, 20명이 5개씩 주문한경우,
재고가 0이 되어야하는데 85가 되는 문제가 있습니다.

분석을 위해 스레드수 2개, 주문수량 5개로 설정하고 실행되는 쿼리를 살펴보겠습니다.
thread-1과 thread-2번에서 재고를 95라는 같은 값으로 update하고 있습니다.


thread-1번이 itemRepository의 findOne을 호출할때 (영속성 컨텍스트에 값이 없으므로) select쿼리가 발생합니다.
thread-2번도 마찬가지로 (영속성 컨텍스트에 값이 없으므로) select 쿼리를 발생시킵니다.
스레드1번이 item을 영속성 컨텍스트에 저장했는데, 스레드 2번에서 또 쿼리가 나가는 이유?
참고로, 트랜잭션별로 각각의 영속성 컨텍스트를 가집니다.
thread-1번에서 조회한 item객체는 thread-2번의 영속성 컨텍스트에 영향을 주지못합니다. 즉, thread-2번의 item에 대한 영속성 컨텍스트는 비어있기때문에 db에 쿼리를 보내게 됩니다.

두 트랜잭션은 각각 db 조회후, 현재 재고가 100이라는 정보를 얻었습니다.

그리고 item 객체의 수량을 5씩 빼줍니다.
public abstract class Item {
...
public void removeStock(int quatity){
int restStock = this.stockQuantity - quatity;
if(restStock<0){
throw new NotEnoughStockException("need more stock");
}
this.stockQuantity=restStock;
}
이후 order 메서드가 리턴되면서 orderServiceProxy에서 트랜잭션 커밋이 발생합니다.
이때, item의 상태(재고 95)가 스냅샷(재고 100)과 다르기때문에, 트랜잭션 커밋시점에 각각의 쓰레드에서 변경감지가 발생하여 쓰기지연 저장소에 있는 sql문이 실행됩니다.



이 문제가 대표적인 동시성 문제입니다. 전문용어로 lost update 라고 합니다.
원인은 읽기와 쓰기가 atomic 하게 이루어지지 않는다는 것입니다.
즉, 스레드 1번이 읽고 쓴 후에 스레드2번이 읽고 써야 우리가 원하는대로 작동 할 것입니다.
방법1 synchronized
자바의 synchronized는 해당 메서드를 하나의 쓰레드만 실행될수있도록 보장하는 기능입니다.
이걸 사용하면 어떻게 될까요?

결과는 예상과 달리 lost update문제가 다시 발생했습니다.

원인은 @Transactional 에 있습니다.
@Transactional은 orderServiceProxy를 생성합니다. 여기서 트랜잭션 을 시작합니다. orderServiceProxy에는 synchronized가 적용되지 않습니다. 스레드 1번이 order메서드를 실행후 종료하고, 트랜잭션 종료 프록시가 실행중일때(재고를 95로 커밋하려고 시도중), synchrinized가 걸린 order 메소드가 종료되었으므로, 스레드2번의 트랜잭션이 시작되어 버립니다. 이때, 스레드2번은 커밋되지 않은 데이터인 95대신 100을 읽어옵니다. 이후 스레드1에서 재고가 95로 커밋됩니다. 스레드2번은 100에서 95로 업데이트문을 보냅니다.

그러면 @Transcational을 사용하지 말아야 되는가?
@Transactional이 없다면, 개발자가 db 커넥션을 획득하고, 트랜잭션을 시작하고, 커밋하는 과정, 예외처리를 직접 작성해주어야 합니다. 또한, 자바의 Synchronized는 한 서버 내에서 동기화를 보장할뿐, 다중서버 상황에서 동시성 문제를 해결하지 못합니다. 서버 2개의 상황에서는 서버2대가 유사 멀티 쓰레드의 역할을 한다고 볼 수 있습니다.

따라서, synchronized를 이용한 방법은 단점이 크므로 다른 방법을 찾아 보겠습니다.
방법2 낙관적 락
낙관적 락은 이름 그대로 트랜잭션 대부분은 충돌이 발생하지 않는다고 가정하는 방법입니다.
이는 데이터베이스의 락에 의존하지 않습니다. JPA가 제공하는 버전관리 기능을 사용합니다.
구현방법은 Item Entity에 Version field를 추가하고, Itemrepository.find 메서드에 낙관적 락 모드를 명시합니다.
public abstract class Item {
...
@Version
private Long version;
참고로, OPTIMISTIC 옵션을 빼도 즉, Version 필드만 있어도 낙관적 락이 적용됩니다.
public Item findOne(Long id){
return em.find(Item.class, id, LockModeType.OPTIMISTIC);
}
OPTIMISTIC 옵션에 따른 낙관적 락의 작동 방식차이는 다음과 같습니다.
OPTIMISTIC 옵션이 없으면, 엔티티를 수정해야 버전을 체크합니다. 즉, 조회시점 부터 수정시점까지를 보장합니다. 엔티티를 수정할때 버전을 체크하므로, 재고 감소 예시와 잘 맞습니다. 이때, 조회시점의 버전과 수정시점의 버전이 다르면 예외를 발생시킵니다.
OPTIMISTIC 옵션이 있으면, 조회만해도 버전을 체크합니다. 즉, 조회 시점부터 트랜잭션이 끝날때까지 조회한 엔티티가 변경되지 않음을 보장합니다. 트랜잭션 커밋할때 select 쿼리를 이용해 버전정보를 조회하고, 다르면 아래의 예외를 발생시킵니다.
낙관적 락의 작동 매커니즘은 다음과 같습니다.
- 엔티티에 @Version 어노테이션이 붙은 필드가 있습니다.
- JPA는 엔티티를 조회할 때 현재 버전 값을 기억합니다.
- 업데이트 쿼리를 실행할 때, WHERE 절에 버전 조건을 추가합니다: where item_id=? and version=?
- 업데이트가 성공하면 버전 값을 증가시킵니다.
- 다른 트랜잭션이 먼저 동일한 레코드를 업데이트했다면, 버전이 이미 변경되어 업데이트 쿼리가 0행을 업데이트하게 됩니다.
- JPA는 이때 StaleStateException을 발생시킵니다.


해당 사용자의 주문은 예외가 발생하여 실패하게 되므로, 재시도를 하거나, 프론트단에서 사용자에게 알림을 주어야 합니다. (ex : 주문이 실패했어요. 등)
쇼핑몰 프로젝트에서는 재고가 있는한, 재시도를 하여 주문을 성공시키는것이 낫다고 생각됩니다. 재고가 없어서 주문이 실패한것이 아니라, race condition 때문에 주문이 실패한 것이기 때문입니다.
아래는 재시도 로직이 추가된 테스트 코드 입니다.
for(int i=0; i<threadCount; i++){
executorService.submit(() -> {
while (true) {
try {
orderService.order(memberId, bookId, orderCount);
break;
} catch (Exception e) {
log.error("주문 실패 : {}", e.getMessage());
// 재시도 전 잠깐 대기
try {
Thread.sleep(50);
} catch (InterruptedException ex) {
throw new RuntimeException(ex);
}
}
}
latch.countDown();
});
}
Test가 성공했습니다. 이어서 비관적 락을 구현해보고 성능을 비교해보고자 합니다.

방법3 비관적 락
비관적 락은 이름 그대로 트랜잭션의 충돌이 발생한다고 가정하고 우선 락을 걸고 보는 방법입니다.
대표적으로 select for update 구문을 사용합니다. 즉, 데이터베이스의 락에 의존합니다.
JPA에서는 아래와 같이 em.find에 매개변수로 LockModeType을 전달하면 됩니다.
비관적 락이라고 하면, PESSIMISTIC_WRITE 옵션을 주면 됩니다. 해당 옵션은 데이터베이스에 쓰기 락을 겁니다.
public Item findOne(Long id){
return em.find(Item.class, id, LockModeType.PESSIMISTIC_WRITE);
}
이전과 달리, item을 select 할때 쿼리문에 for update가 추가 되었습니다.

이로인해, 해당 row에 락이 걸립니다. 락이 걸린 row는 다른 트랜잭션이 수정 할 수 없습니다.
즉, 스레드2번은 스레드1번이 락을 풀어줄때 까지 기다립니다.
로그를 보면, 스레드 1번이 커밋된 후에야, 스레드2번이 ItemRepository의 findOne을 호출할수 있음을 볼수 있습니다.

참고로, 쓰기 락을 얻기 위해서는 @Transactional(readonly = false)로 설정해야 합니다.

테스트 성공! 드디어 초록불을 볼 수 있었습니다.

마치며
트랜잭션 충돌이 빈번할것으로 예상되는경우, 비관적 락(455ms)이 낙관적 락(3655ms)보다 더 좋은 성능을 보여줍니다.
- Test 환경 : 10명이 같은상품을 5개씩 주문한 경우, Test 실행시간 측정
레퍼런스
김영한 - 자바 ORM 표준 JPA 프로그래밍 도서
김영한 - 스프링 핵심원리 고급편 강의
[시스템 디자인] 재고시스템으로 알아보는 동시성이슈 해결방법 (1/3) - 동시성 이슈와 Application Le
💡 최상용님의 재고시스템으로 알아보는 동시성이슈 해결방법 강의를 듣고 정리한 내용입니다. 목차 글 목록[시스템 디자인] 재고시스템으로 알아보는 동시성이슈 해결방법 (1/3) - 동시
nooblette.tistory.com
https://ksh-coding.tistory.com/125#3-1.%20Java%EC%9D%98%20Synchronized-1
[Spring] 스프링 동시성 처리 방법(feat. 비관적 락, 낙관적 락, 네임드 락)
0. 들어가기 전 이전에는 DB 단의 동시성 처리 방법인 Lock에 대해서 알아봤습니다. https://ksh-coding.tistory.com/121 [DB] DB Lock이란? (feat. Lock 종류, 블로킹, 데드락) 0. 락(Lock)이란? 여러 커넥션에서 동시
ksh-coding.tistory.com
https://kdhyo98.tistory.com/59
[SPRING] synchronized와 @Transactional 을 동시에 사용 시 문제점
😗서론 @Transactional 어노테이션과 synchronized을 동시에 사용하고 싶은 경우가 있을 수 있다. 트랜잭션 격리수준과 별개로 해당 메소드를 동기화를 적용시키고 싶을 때. 하지만, 한 메소드 위에 해
kdhyo98.tistory.com
'기술블로그' 카테고리의 다른 글
No Offset 으로 페이징 성능 개선하기 (0) | 2025.05.01 |
---|---|
ThreadLocal로 싱글톤에서 발생하는 동시성문제 쉽게 풀기! (0) | 2025.04.13 |
@ExceptionHandler로 예외처리문제 쉽게 풀기! (0) | 2025.04.12 |