이커머스 시스템을 개발하면서 상품 재고에 대한 동시성 처리에 대한 부분이 고민이 되었다.
현재 Product와 ProductStock이 분리되어 있는 구조로 Product는 상품에 대한 메타 데이터를 관리하고 ProductStock은 ProductId를 가지며, Product에 대한 재고를 가지고 있다.
재고에 대한 동시성처리를 위해서 주로 많이 사용하는 3가지 케이스에 대해서 테스트를 진행했고, 테스트 결과는 어느정도 예상은 했지만 아래와 같았다.
============= (threads=8, 요청량=8000, 재고=6000 → 품절 2000건) =============
A) 비관적 락 : 전체 14,599 ms | 410 ops/s | 지연(avg) 14.595 ms | 락대기 누적 98,689 ms (op평균 12.336 ms)
B) 낙관적 락 : 전체 17,269 ms | 347 ops/s | 지연(avg) 17.195 ms | 재시도 41971회(성공당 7.00회) 재시도 누적 113,759 ms
C) 조건부 원자 : 전체 8,115 ms | 739 ops/s | 지연(avg) 8.107 ms | 단일 UPDATE
=> 처리량 C 원자(2.13배) > A 비관락(1.18배) > B 낙관락(1.00배) [최저 처리량 기준]
====================================================================
참고로 세 방식 모두 초과 판매 없이 정확히 6,000건만 차감됐다. 정확성은 동일하고 차이는 속도뿐이었다.
비관적 락
동시성 충돌이 많이 날거라고 예상하고 하나의 트랜잭션에서 해당 row에 대해서 접근하게 될 경우 그 트랜잭션이 종료될때 까지 락을 잡고 있게 된다. 비관적 락은 쿼리에 FOR UPDATE를 붙여서 쿼리가 나가게 된다. 여기서 재미있는 점은 FOR UPDATE 쿼리에 대해서만 블로킹 상태가 되고 일반적인 SELECT 문은 블로킹이 걸리지 않는다.
InnoDB는 MVCC로 각 행의 변경 전 버전을 undo log에 들고 있다. 그래서 일반 SELECT는 락을 잡지 않고, 트랜잭션 시작 시점의 스냅샷(이전 버전)을 undo log에서 읽어버린다. 누군가 그 행을 X락으로 잡고 UPDATE 중이어도 락이 풀리길 기다리지 않고 바로 옛 값을 반환하는 것이다. 반대로 SELECT ... FOR UPDATE는 스냅샷이 아니라 현재 행에 직접 락을 걸어야 하는 읽기(locking read)라, 그 락이 풀릴 때까지 블로킹된다.
읽기 위해 미리 잠금 → 앱이 값 보고 판단 → 다시 UPDATE (왕복 2번, 락 보유가 길다)
- SELECT ... FOR UPDATE; -- 여기서 X락
- (앱에서 quantity 확인) UPDATE ... SET quantity - 1; -- 락 계속 들고 차감
- COMMIT;
private void pessimisticDecrement(Connection conn) throws Exception {
conn.setAutoCommit(false);
try (Statement st = conn.createStatement()) {
long lockStart = System.nanoTime();
long current;
try (ResultSet rs = st.executeQuery(
"SELECT quantity FROM product_stocks WHERE product_id = " + PRODUCT_ID + " FOR UPDATE")) {
rs.next();
current = rs.getLong(1);
}
pessimisticLockWaitNanos.addAndGet(System.nanoTime() - lockStart);
if (current >= 1) {
st.executeUpdate("UPDATE product_stocks SET quantity = " + (current - 1) + " WHERE product_id = " + PRODUCT_ID);
success.incrementAndGet();
} else {
stockout.incrementAndGet();
}
}
conn.commit();
}

비관적락은 8,000건을 다 처리하는 데 전체 14.6초, 초당 410건(처리량), 한 건당 평균 14.6ms(지연) 걸렸다. 그중 다른 스레드가 락을 미리 선점하고 있어서 요청 1건당 평균 락 대기시간이 12.336ms(전체 누적 98689ms)였다.
낙관적 락
비관적 락과 다르게 물리적인 락을 사용하지 않고, 동시성 충돌이 거의 없을거라고 예상하고 version 컬럼을 두고 처리하는 방식이다. 두 세션 모두 동시에 하나의 row에 SELECT로 접근이 가능하다. 여기서 version 비교는 값을 읽을 때가 아니라 UPDATE 할 때 일어난다. 세션 B가 version 0을 읽고 차감하려는 사이에 세션 A가 먼저 업데이트하면서 version을 1로 올리면, 세션 B의 UPDATE는 WHERE version = 0 조건에 걸려(이미 1이라) 0건만 매칭되어 실패하게 되고, application 레벨에서 재시도 혹은 재처리 로직을 직접 작성해야한다.
private void optimisticDecrement(Connection conn) throws Exception {
conn.setAutoCommit(true);
while (true) {
long iterStart = System.nanoTime();
long current;
long version;
try (Statement st = conn.createStatement();
ResultSet rs = st.executeQuery(
"SELECT quantity, version FROM product_stocks WHERE product_id = " + PRODUCT_ID)) {
rs.next();
current = rs.getLong(1);
version = rs.getLong(2);
}
if (current < 1) {
stockout.incrementAndGet();
return;
}
int updated;
try (Statement st = conn.createStatement()) {
updated = st.executeUpdate(
"UPDATE product_stocks SET quantity = " + (current - 1) + ", version = " + (version + 1)
+ " WHERE product_id = " + PRODUCT_ID + " AND version = " + version);
}
if (updated == 1) {
success.incrementAndGet();
return;
}
optimisticRetries.incrementAndGet();
optimisticRetryWasteNanos.addAndGet(System.nanoTime() - iterStart);
}
}

낙관적 락은 전체 17.3초, 초당 347건(처리량), 한 건당 평균 17.2ms(지연) 걸렸다. 문제는 재시도였다. 한 건을 차감하는 데 버전이 안 맞아 평균 7번씩 다시 시도했고(총 41,971회), 그렇게 실패한 시도에만 누적 113,759ms가 들었다. 낙관적 락이 쓴 전체 시간의 약 83%가 이 재시도로 소요되었다.
원자적 업데이트
/** C) 조건부 원자 UPDATE: 단일 statement 로 조건부 차감. 0 행이면 재고 소진. */
private void atomicDecrement(Connection conn) throws Exception {
try (PreparedStatement ps = conn.prepareStatement(
"UPDATE product_stocks SET quantity = quantity - 1 WHERE product_id = ? AND quantity >= 1")) {
ps.setLong(1, PRODUCT_ID);
if (ps.executeUpdate() == 1) {
success.incrementAndGet();
} else {
stockout.incrementAndGet();
}
}
}
원자적 업데이트의 경우 native query로 직접 해결하는 방법으로 아래와 같은 쿼리를 사용한다.UPDATE product_stocks SET quantity = quantity - 1 WHERE product_id = 1 AND quantity >= 1
비관적 락이나 낙관적 락은 엔티티를 조회한 뒤 아래 ProductStock.decrease()를 호출해서 차감을 처리한다. 차감이라는 행동과 그 안의 검증(수량 체크 등)을 도메인 객체가 책임지기 때문에 도메인을 두텁게 가져갈 수 있다. 반면 원자적 업데이트는 차감을 도메인의 행동으로 처리하는 게 아니라 native query에 위임하기 때문에, decrease() 같은 도메인 로직이 빠지면서 도메인이 부실해진다는 단점이 있다.
class ProductStock {
public void decrease(int qty) {
if (qty <= 0) {
throw new CoreException(ErrorType.BAD_REQUEST);
}
if (quantity < qty) {
throw new CoreException(ProductErrorCode.OUT_OF_STOCK);
}
this.quantity -= qty;
}
}
원자적 업데이트로 처리하면 위 decrease() 도메인 로직을 타지 않고 아래처럼 쿼리로 직접 처리하게 된다.
@Modifying(clearAutomatically = true)
@Query("""
UPDATE ProductStock s
SET s.quantity = s.quantity - :qty
WHERE s.productId = :productId AND s.quantity >= :qty
""")
int decreaseIfEnough(@Param("productId") Long productId, @Param("qty") int qty);
반환값(바뀐 행 수)이 1이면 차감 성공, 0이면 재고 부족으로 보고 처리하면 된다.

조건부 원자 업데이트는 전체 8.1초, 초당 739건(처리량), 한 건당 평균 8.1ms(지연)로 셋 중 가장 빨랐다. 락 대기도 재시도도 없이 UPDATE 한 문장으로 끝나기 때문이다.
세 방식 비교
낙관적 락이든 비관적 락이든 원자적 업데이트든, UPDATE 문이 실행되는 순간 InnoDB는 결국 그 행에 X락을 잡는다. 차이는 락을 잡느냐 마느냐가 아니라, 그 락을 얼마나 오래 쥐고 있느냐다. 비관적 락은 트랜잭션이 끝날 때까지 쥐고, 원자적 업데이트는 UPDATE 한 문장이 끝나는 찰나만 쥔다.
| 방식 | 물리적 락 | 동시성 제어 수단 | X락 보유 시간 |
|---|---|---|---|
| 비관적 | X락 | 락 그 자체 (다른 요청 대기) | 트랜잭션 끝까지 |
| 낙관적 | X락 | version 비교 | 단일 UPDATE (재시도 누적) |
| 원자적 | X락 | WHERE 조건 | 단일 문장 (가장 짧다) |
어떤 방식을 선택할까
나는 이 셋 중에서 어떤 방법으로 처리할지 고민이 많았다.
비관적 락을 쓰면 해당 트랜잭션 동안 해당 row에 lock이 유지되니까 사용자 응답속도는 조금 느리게 느껴질 수 있다. 대신 lock으로 직렬화하는 만큼 직관적으로 안전하다는 장점이 있다. 낙관적 락은 반대로 동시성 충돌이 많을 때 오히려 비관적 락보다 재시도가 늘어나 리소스를 더 낭비할 수 있다.
실제 테스트에서도 동시 요청이 계속 실패하면서 재시도 횟수가 생각보다 높게 누적됐다.
충돌이 심할수록 재시도가 기하급수적으로 늘어난다. 다만 동시성 충돌이 적은 서비스라면 낙관적 락이 비관적 락보다 효율이 좋을 것 같다. 물론 이번 테스트는 하나의 상품 한 row에 경합이 몰린 경우라서 주문이 여러 상품으로 분산되면 낙관적 락의 version 충돌과 재시도는 크게 줄어 순위가 달라질 수 있다.
사실 10명 남짓 쓰는 서비스에서 성능을 최우선으로 둘 필요가 있을까? 성능도 어느 정도는 고려해야겠지만, 그보다는 도메인을 두텁게 만들고 응집도가 높은 서비스로 개발하는게 더 중요하다고 생각한다.
대기업 이커머스처럼 트래픽이 많아 처리량이 정말 중요하다면, 도메인이 얇아지더라도 성능을 우선해 원자적 업데이트를 쓰는 게 맞는 것 같다. 하지만 그 정도 트래픽을 받는 서비스는 현실적으로 흔치 않고, 서비스 규모와 충돌 정도에 따라 낙관적 락 -> 비관적 락 -> 원자적 업데이트 순으로 골라서 사용하면 될 것 같다.
'dev > Spring' 카테고리의 다른 글
| 좋아요 순 정렬은 왜 7초가 걸렸을까? (0) | 2026.06.19 |
|---|---|
| 테스트 더블 정리 (Dummy, Fake, Stub, Spy, Mock) (0) | 2026.05.26 |
| Resilience4j CircuitBreaker 슬라이딩 윈도우 동작 원리(COUNT_BASED vs TIME_BASED) (0) | 2026.05.14 |
| [SpringMVC] 요청 매핑, API 요청 매핑 (0) | 2022.11.23 |
| 스프링 컨테이너와 스프링 빈 (1) | 2022.11.05 |