필자는 MSA 기반 대규모 트래픽 처리 예약구매 이커머스 서비스를 개발중이었다. 깃허브 주소

프로젝트의 핵심 기능 중 하나가 예약구매였다. 한정된 재고에 대해 여러 사용자가 동시에 주문을 넣는 구조라 동시성 문제가 필연적으로 따라오는 기능이었다.
재고에 대한 동시성 문제(Race condition)는 Redis 분산락으로 테스트코드를 통해 해결됐음을 확인했다! (자세한 분산락 핸들링 기록은 아래 분산락 포스팅 참고)
[프로젝트/기술적 의사결정] Redis 분산락으로 재고 감소 동시성 이슈 해결하기 (1/2)
필자는 MSA 기반 이커머스 프로젝트에서 상품/재고/예약구매 도메인을 맡아 진행중이다. 지난 프로젝트에서 쿠폰 도메인을 맡아 개발했을 때 Race condition 문제를 예상치 못하게 겪고 (..) 이번 프
developer-jinnie.tistory.com
그 후 예약구매 생성 JMeter를 통해 부하테스트를 진행했더니..! 당연히 통과할 줄 알았더니만 기댓값과 다른 결과값이 도출되었다.
문제 발생 ㅡ 부하테스트 과정
재고가 50개인 상품의 경우, 주문 500개의 요청 시 재고 만큼 주문도 50개만 생성되어야 한다.




아니 재고가 50개면 주문도 50개만 생겨야 할 거 아니냐!!!!! 재고가 없는데 주문이 왜 계속 생기는건지..
재고 로직에 분산락을 걸어두어 재고 갯수가 음수로 떨어지지도 않고, 0개로 정상적으로 떨어지는데도 왜 이런 일이 생기는걸까?
JMeter 부하 테스트 시나리오
──────────────────────────────────────────────────────
재고 50개 상품에 대해 500개 동시 주문 요청
(스레드 50개 × 반복 10회)
기대값: 주문 50개 생성, 재고 0개
실제값: 재고는 0개인데 주문이 400개 이상 생성됨 💥
──────────────────────────────────────────────────────
원인 분석 ㅡ 갱신 유실 (Lost Update)
이 기능 개발할 때가 24년도 후반일때라 AI가 지금처럼 뾰족하게 발전하지 않았을 때여서, AI에게는뾰족한 답을 얻을 수 없었고 정말 온갖 구글링을 다해보고 스택오버플로우도 뒤져보고 유튜브도 뒤져보고 많은 기업들의 기술 블로그도 뒤져봤다. 부트캠프에서 기술 멘토님들의 도움도 받아봤는데 기술 멘토님들도 처음 겪어보시는 문제라고 하셨다..

꼬박 이틀동안 잠을 못자가면서 문제 원인을 찾으려고 했는데 이걸 적용해보고 저걸 적용해봐도 부하테스트에 실패하는 것이었다 ㅠ 동시성 문제를 포기해야하나 했는데 예약구매가 우리 서비스의 핵심 서비스이기도 하고, 대규모 트래픽을 전제로 한 프로젝트라 무조건 내 힘으로 해결하고 싶다라고 생각하던 찰나에 가뭄에 단비같은 트러블슈팅 영상을 보게 된다.
https://www.youtube.com/watch?v=UOWy6zdsD-c&t=424s

실제로 토스에서 발생한 문제를 어떻게 해결했는지 공유하신 영상이다. 영상을 보니 현재 우리 서비스 예약구매 기능에서 겪고있던 바로 그 문제와 정말 동일했다...!! 이 영상에서는 이 문제를 '갱신 유실' 이라고 명명하고 있었다.
사실 갱신 유실이라는 단어 자체를 이번 문제로 처음 알게 되었다. 낯설은 단어였다. 갱신 유실이 뭔지부터 자세히 알아봤다.
💡갱신 유실이란?
분산락이 정상적으로 동작해도 갱신 유실이 발생할 수 있다. 핵심은 분산락의 타임아웃과 DB 트랜잭션의 커밋 타이밍이 어긋날 수 있다는 것이다.

위의 표를 보자.
- 0초에 T1과 T2 트랜잭션이 동시에 발생했다고 가정한다. T1은 락을 획득하고 T2는 락을 대기하고 있는 상태다.
- 2초 뒤에 T1의 락이 락 타임아웃으로 인해 해제된다 (트랜잭션은 아직 살아있음)
- T2가 락을 획득하여 재고를 읽고 5개 차감한다.
- 그리고 2초 뒤인 4초에 지연된 T1 트랜잭션 처리가 완료되어 재고를 20개 차감하게 되면,
- 재고는 0개가 되어 갱신 유실이 발생한다.
문제의 본질은 이거다.
분산락 타임아웃이 지나 락 자체는 해제됐지만, 트랜잭션이 아직 끝나지 않은 상태여서 다른 트랜잭션과 경합이 일어날 수 있는 환경
분산락을 해제하기 전에 DB 트랜잭션이 커밋되거나, 분산락을 해제하고 나서 커밋이 되는 경우 모두 갱신 유실이 발생할 수 있다. JPA에서는 '쿼리 쓰기 지연' 등으로 인해 이 타이밍 문제가 발생할 확률이 비교적 높다고 한다.
갱신 유실 문제에 대해 자세히 알아봤으니 이제 이 문제를 어떻게 해결할지 알아보자.
해결 방법 ㅡ 기술적 의사결정
갱신 유실을 방지하는 방법은 크게 네 가지다.
1. 원자적 연산 사용 (❌)
DBMS에 의존적이기 때문에 ORM과 궁합이 좋지 않다. (e.g. ORM에서는 DBMS의 고유한 원자적 연산을 직접 지원하지 않는 경우가 많다)
또한 JPA 같은 ORM을 사용하는 경우, 데이터베이스에 직접적인 SQL 연산을 보내는 대신 객체 상태를 추적하고 변경하는 방식으로 동작하기 때문에 원자적 연산을 사용하려면 SQL 네이티브 쿼리를 사용해야 할 때가 많다.
2. 명시적 잠금 (❌)
여러 테이블을 갱신하는 트랜잭션에서는 비용이 매우 비싸다. ( = 락을 걸고 유지하는 동안, 여러 테이블에 걸쳐 발생하는 데이터 변경 작업이 성능 저하를 일으킬 수 있다는 말이다.)
3. 갱신 손실 자동 감지 (❌)
원자적 연산 사용 방법과 마찬가지로, 이 방법 또한 DBMS에 의존적이라 ORM과 궁합이 좋지 않다. 또한 DBMS가 제공하는 버전 관리나 갱신 손실 감지 기능을 ORM과 함께 사용하는 경우 ORM의 추상화 계층 때문에 이 기능이 제대로 동작하지 않거나 비효율적일 수 있다.
4. CAS 연산 (Compare-and-set) (⭕)
JPA에서는 OptimisticLocking 애너테이션을 통해 간단하게 CAS 연산이 가능하다. OptimisticLocking은 version을 통해 갱신 유실을 방지할 수 있다.
💡CAS 연산이란?
메모리에서 값을 읽고, 그 값이 예상한 값과 동일할 때만 새 값을 설정하는 방식. 이는 '동시에 두 스레드가 같은 데이터를 수정하려고 하면, 한 쪽만 성공하고 다른 쪽은 실패' 한다는 개념이다.
4-1. OptimisticLocking 이 갱신 유실을 막는 원리

위에서 언급한 것 처럼, 낙관적 락은 버전(version) 번호로 동작한다. 데이터를 읽을 때 버전을 함께 읽어두고, 커밋 시점에 버전이 내가 읽었던 것과 같은지 확인한다. 다른 트랜잭션이 중간에 수정했다면 버전이 올라가 있을 테고, 그러면 커밋을 거부한다.
- T1이 v1을 읽은 상태에서 대기하는 동안 T2가 먼저 커밋해서 버전이 v2가 됐다.
- 이후 T1이 커밋을 시도하면 '내가 읽었을 때 v1이었는데 지금은 v2야, 누군가 수정했군'하고 감지해서 Exception을 던지고 커밋을 거부한다.
분산락만 있을 때는 락이 해제된 사이에 T1의 커밋이 T2의 결과를 덮어쓸 수 있었는데, 낙관적 락이 있으면 버전 불일치 → 커밋 거부로 갱신 유실을 막아주는 것이다.
여기까지 생각하니 한가지 의문이 들었다.
💡그럼 분산락 없이 Optimistic Locking 만으로도 지금 내 케이스의 동시성을 충분히 제어할 수 있나?
답은 No 이다. 분산락이 없다면, 동시에 발생하는 트랜잭션들은 대기 없이 실패하게 되거나 별도의 재시도 구현이 필요할 것이다. 트랜잭션 재시도 구현은 재시도 자체의 실패 등 여러 케이스들을 고려해야 하고, 이는 곧 코드의 복잡도 상승으로 이어진다.
대부분의 상황에서 분산락은 정상적으로 동작하기에, 1. 분산락으로 동시성을 제어하고, 2. 만약의 상황에서도 OptimisticLocking을 통해 데이터 정합성이 틀어지지 않도록 할 수 있다.
주요 테이블들은 하이버네이트 envers를 이용해 변경 히스토리를 저장하여 원활히 데이터 흐름을 파악할 수 있도록 할 수도 있다!
기술적 의사결정을 요약한 표는 아래와 같다.
| 방법 | 설명 | 이 상황에서의 문제 |
| 원자적 연산 사용 | DB 네이티브 연산 사용 | DBMS 의존적, JPA/ORM과 궁합 나쁨 |
| 명시적 잠금 | DB 레벨 락 | 여러 테이블 갱신 시 비용이 매우 비쌈 |
| 갱신 손실 자동 감지 | DBMS 기능 활용 | DBMS 의존적, ORM과 충돌 가능 |
| CAS 연산 | 비교 후 조건 업데이트 | ✅ JPA @OptimisticLocking으로 간단하게 구현 가능 |
문제 해결
1차 시도 - 낙관적 락 적용
재고 엔티티에 JPA 낙관적 락을 도입했다.
@Version
@Column(name = "version")
private Long version = 0L;
도입 후 바로 부하테스트를 단계별로 실행해봤더니, 적은 스레드 기준으로는 문제가 해결됐다. 하지만 조금만 스레드 갯수를 늘려도 아래와 같은 락 충돌 에러(StaleObjectStateException)가 발생한다 🥲

이유를 분석해보니 낙관적 락을 사용한 경우에는 트랜잭션 충돌로 인해 StaleObjectStateException이 발생할 수 있고(@Version 필드가 있으면 업데이트 시 버전을 비교해서 내가 읽은 이후로 다른 트랜잭션이 수정했다면 해당 예외 던지는 것), 이를 해결하려면 예외 발생 시 재시도하는 로직을 추가해야 한다.
즉 트랜잭션 충돌이 발생해서 재시도를 해야 하는데, 재시도 로직이 없으니 그냥 실패로 끝난 상황이라고 이해하면 된다😅
2차 시도 - @Retryable로 재시도 로직 추가
GitHub - spring-projects/spring-retry
Contribute to spring-projects/spring-retry development by creating an account on GitHub.
github.com
위 라이브러리의 @Retryable 어노테이션을 활용하면 낙관적 락의 재시도 로직을 aop 방식으로 손쉽게 수행할 수 있다.
재시도 간격 설정을 위한 @Backoff, retry 후 최종적으로 Exception이 발생했을 때 처리를 돕는 @Recover 어노테이션도 제공한다. (의존성 등록해야한다)
낙관적 락의 버전 충돌 시 발생하는 예외는 아래와 같이 세 가지이다.
- (JPA) javax.persistence.OptimisticLockException
- (Hibernate) org.hibernate.StaleObjectStateException in Hibernate
- (Spring) org.springframework.orm.ObjectOptimisticLockingFailureException
Spring Data JPA에서 낙관적락을 사용하게 되면 버전 충돌 시 Hibernate는 StaleStateException을 발생시킨다.
Spring은 이를 OptimisticLockingFailureException으로 래핑하여 처리한다.
나의 경우에는 Spring Data JPA를 사용했으므로, ObjectOptimisticLockingFailureException 발생 시 재시도하도록 작성했다.
// 재고 1 감소 메서드에 @Retryable 적용
@Retryable(
retryFor = {ObjectOptimisticLockingFailureException.class}, // 낙관적 락 예외에 대해 재시도
maxAttempts = 500, // 최대 500번까지 재시도
backoff = @Backoff(100) // 재시도 간격 100ms
)
@Transactional
public void decreaseStockQuantity(UUID productId) {
Stock stock = findStockByProductId(productId);
stock.decreaseStock(); // 수량 1 감소
stockRepository.save(stock);
}
하지만!! 재시도 로직까지 구현했는데도 불구하고 부하 테스트 시 여전히 StaleObjectStateException이 발생했다.
3차 시도 - 낙관적 락 전략 변경
낙관적 락 전략을 OPTIMISTIC_FORCE_INCREMENT로 변경했다.

// 예약 구매: 재고 1 감소 메서드
@Retryable(
retryFor = {StaleObjectStateException.class, OptimisticLockException.class, ObjectOptimisticLockingFailureException.class},
maxAttempts = 500,
backoff = @Backoff(100)
)
@Transactional
public void decreaseStockQuantityWithRetry(UUID productId) {
Stock stock = stockRepository.findByProduct_ProductIdWithLock(productId) // 호출 메서드 findByProduct_ProductIdWithLock으로 변경
.orElseThrow(() -> new CustomException(CommerceErrorCode.STOCK_DATA_NOT_FOUND_FOR_PRODUCT));
stock.decreaseStock();
stockRepository.save(stock);
}
일반적인 OPTIMISTIC 락에서는 엔티티가 조회될 때, 버전 필드를 확인만 하고 아무 변화가 없다. 그 이후 엔티티가 수정될 때만 버전이 증가한다.
반면에 OPTIMISTIC_FORCE_INCREMENT는 조회하는 순간 버전 필드를 강제로 증가시킨다. 즉, 해당 엔티티가 읽히면 JPA가 그 엔티티를 '수정된 것처럼' 간주하고, 즉시 버전을 1 증가시켜 다른 트랜잭션에서 동시에 접근할 때 충돌 가능성을 더 엄격하게 관리할 수 있는 것이다.
낙관적 락 전략 비교
──────────────────────────────────────────────────────
OPTIMISTIC 읽은 시점과 커밋 시점의 버전 불일치 시에만 예외 발생
OPTIMISTIC_FORCE_INCREMENT 읽을 때 무조건 버전을 증가시킴
→ 다른 트랜잭션이 같은 행을 읽었다면 반드시 충돌로 감지됨
──────────────────────────────────────────────────────
재고 차감처럼 동시에 같은 행을 수정하면 안 되는 케이스에는 OPTIMISTIC_FORCE_INCREMENT가 더 강력하게 충돌을 감지해준다.
최종 구조 - 분산락 + 낙관적 락 (OPTIMISTIC_FORCE_INCREMENT)
분산락 (1차 방어)
→ 대부분의 동시 요청을 직렬화해서 처리
낙관적 락 OPTIMISTIC_FORCE_INCREMENT (2차 방어)
→ 분산락 타임아웃 등으로 경합이 발생하더라도
버전 충돌로 갱신 유실을 감지하고 차단
대규모 트래픽이 전제되는 상황에선 분산락 하나만으로는 갱신 유실에 취약하고, 낙관적 락을 함께 쓰면서 분산락이 뚫리는 상황에서도 정합성을 보장할 수 있도록 장치해두는 구조라고 보면 된다.
결과
최종 구조 적용 후 JMeter 부하 테스트를 다시 돌렸다.
최종 부하 테스트 결과
──────────────────────────────────────────────────────
시나리오: 재고 30개 상품에 1,000개 동시 주문 요청
기대값: 주문 30개 생성, 최종 재고 0개
결과: 주문 30개 생성 ✅, 최종 재고 0개 ✅
──────────────────────────────────────────────────────


1,000개의 동시 요청에서도 재고 30개만큼 정확히 주문이 생성됐다. 갱신 유실 없이 데이터 정합성 보장 성공..!

마무리
이 트러블슈팅 경험으로 배운 게 몇 가지 있다.
1. 분산락이 만능이 아니라는 것. 분산락은 동시 접근을 제어하지만, 락 타임아웃과 트랜잭션 커밋 타이밍이 어긋나면 갱신 유실이 발생한다. '락 걸었으니 되겠지'라고 생각했다가 부하 테스트에서 발견했는데, 실제 서비스에서 발견했다면 해결하기까지 유저 불편함이 컸을 것이다. (테스트의 중요성도 함께 깨달았다고 하겠다😅)
2. 트랜잭션 커밋 시점과 동시성 이슈가 맞물린다는 것도 있다. JPA의 쓰기 지연 특성 때문에 락이 해제된 뒤에도 트랜잭션이 살아있을 수 있고, 그 사이에 다른 트랜잭션이 끼어드는 게 갱신 유실의 원인이었다. 락을 다룰 때는 트랜잭션의 생명주기까지 함께 고려해야 한다.
3. 다음은 계층적 방어가 필요하다는 것. 분산락으로 1차 제어를 하고, 낙관적 락으로 2차 안전망을 깔았더니 비로소 안정적인 구조가 됐다. 각 계층이 서로 보완하는 구조가 대규모 트래픽 환경에서 데이터 정합성을 지킬 수 있는 방법 중 하나이고 실제 현업에서도 이런 방법을 쓴다는걸 해결책을 찾아보면서 더 깊게 알게되었다.
4. 그리고 마지막으론, 기술적인 해결책 외에도 트래픽 자체를 제어하는 방법도 고민하게 됐다. 아무리 잘 짠 동시성 코드라도 트래픽이 폭발적으로 몰리면 한계가 있다. 애초에 대규모 트래픽을 한 엔드포인트로 받으려고 하는 것 보단 그 전 단계에 정적 html을 둬서 트래픽을 한 차례 분산되게 한다던지, 대기열 시스템이나 요청 제한(Rate Limiting) 같은 방법으로 서버에 들어오는 트래픽 자체를 조절하는 구조도 함께 고민해봐야 대규모 트래픽 환경에서 더 견고한 서비스를 만들 수 있다는 걸 깨닫게 되었다.
문제 확인부터 해결까지 3일 정도는 쓴 트러블슈팅이었는데, 문제 원인이 감이 안잡혀서 여러 방면으로 생각해보고 적용해본 것이 개발 시야를 넓히는 것에 도움이 많이 되었다.
또 해결책을 찾고 찾다가 토스에서 해당 트러블슈팅을 유튜브로 공유해주신 것 덕분에 내 트러블슈팅을 해결한것 처럼(진짜 눈물날 뻔 했음) 역시 개발에서는 문제 해결 경험을 서로 공유하는 것이 모두에게 도움이 된다라는 것도🥹 느끼게 되었다.
비슷한 문제를 겪고 있는 분이 있다면 이 글이 그 3일을 조금이라도 줄여줄 수 있었으면 한다!
'Project > 대용량 트래픽 프로젝트' 카테고리의 다른 글
| [프로젝트/구현] Redis Replication 마스터-슬레이브 구조를 통한 분산 처리 적용 과정 (0) | 2025.05.04 |
|---|---|
| [기술적 의사결정] MSA 환경에서 배송 정보 임시 저장소로 Redis를 사용한 이유 (3) | 2025.05.03 |
| [프로젝트/구현] Redis 분산락으로 재고 감소 동시성 이슈 해결하기 (2/2) (feat. Facade 패턴) (7) | 2025.04.24 |
| [프로젝트] Windows 환경에서 JMeter 설치 및 부하 테스트 하기 (8) | 2024.12.11 |
| [프로젝트] QueryDSL 사용 시 페이징 응답 JSON 데이터 최적화 하기 (1) | 2024.12.11 |