낙관적 락 은 충돌 이 드물 다가정동시성 전략. 그러나 production 의 *현실동시 요청 의 *몇 % 가 *반드시 *충돌. 그 때 코드 가 어떻게 반응 하는지시스템 의 *진짜 품질. 기능 구현반대 편실패 의 *우아 한 처리.

이 글은 settlement 의 SKU (ProductVariant) 에 *@Version + 100 쓰레드 동시성 테스트14 개월 운영 경험 위에서 방어 코드 의 *7 가지 패턴현실 가이드.


1. 낙관적 락 의 진짜 작동

@Entity
class ProductVariant {
    @Id Long id;
    private Integer stock;

    @Version
    private Long version;
}

@Version진짜 의미UPDATE 시 *WHERE 절 에 version 자동 추가. Hibernate 의 생성 SQL:

UPDATE product_variant 
SET stock = ?, version = version + 1
WHERE id = ? AND version = ?

동시 2 개 트랜잭션같은 id읽고 *수정 하면둘 다 같은 version 으로 UPDATE 시도먼저 commit 한 것 만 *성공, 두 번 째 는 *affected_rows = 0 → Hibernate 가 OptimisticLockException 발생.

tx1: SELECT id=1, version=5
tx2: SELECT id=1, version=5
tx1: UPDATE ... WHERE id=1 AND version=5  → version=6, 성공
tx2: UPDATE ... WHERE id=1 AND version=5  → affected=0, 실패

충돌 의 *자동 검출. DB lock 없음 + 동시성 처리량 *높음. 대가충돌 시 *어떻게 처리책임 이 *애플리케이션 측.


2. 실패 의 3 가지 시그널

JPA / Spring Data 의 예외 위계:

DataAccessException (Spring)
└── OptimisticLockingFailureException
    ├── ObjectOptimisticLockingFailureException (id + entity class)
    └── StaleObjectStateException (Hibernate)

PersistenceException (JPA)
└── OptimisticLockException (Jakarta)
try {
    productVariantRepo.save(variant);
} catch (ObjectOptimisticLockingFailureException e) {
    // Spring의 wrapper — 가장 흔히 잡음
    log.warn("Optimistic lock conflict on {}", e.getIdentifier());
}

⚠️ 함정OptimisticLockExceptionflush 시점 에 던져짐. @Transactional 메서드 *내부 에서 catch 하면 commit 이 일어나기 전 의 시점flush 가 *transaction commit 시 발생 하면 catch 못 함.

해결entityManager.flush() 명시 호출 또는 Outer transaction 에서 catch.


3. 패턴 1 — 재시도 (Retry) 의 *4 가지 구현

낙관적 락 의 흔한 응답재시도. 낮은 충돌 률가정 위 에서 재시도 1~3 회대부분 성공.

(1) Spring Retry — AOP 기반

<!-- build.gradle -->
implementation 'org.springframework.retry:spring-retry'
implementation 'org.springframework:spring-aspects'
@EnableRetry
@Configuration
class RetryConfig {}

@Service
class StockService {

    @Retryable(
        retryFor = ObjectOptimisticLockingFailureException.class,
        maxAttempts = 3,
        backoff = @Backoff(delay = 50, multiplier = 2, random = true)
    )
    @Transactional
    public void decreaseStock(Long variantId, int quantity) {
        ProductVariant v = repo.findById(variantId).orElseThrow();
        v.decreaseStock(quantity);   // ← @Version 자동 증가
        repo.save(v);
    }

    @Recover
    public void recover(ObjectOptimisticLockingFailureException e, 
                        Long variantId, int quantity) {
        log.error("Stock decrease failed after 3 retries: {}", variantId);
        throw new StockUpdateFailedException(variantId);
    }
}

깔끔 함. 재시도 + backoff + recover 가 *애너테이션 만으로.

핵심@Transactional@Retryable 안쪽 에 위치. 재시도 마다 *새 트랜잭션새 SELECT + 새 version 읽기.

(2) Resilience4j — 코드 기반

@Service
@RequiredArgsConstructor
class StockService {
    private final Retry retry = Retry.of("stock-update",
        RetryConfig.<Void>custom()
            .maxAttempts(3)
            .waitDuration(Duration.ofMillis(50))
            .retryOnException(ObjectOptimisticLockingFailureException.class::isInstance)
            .build());

    public void decreaseStock(Long variantId, int quantity) {
        Retry.decorateRunnable(retry, () -> 
            doDecrease(variantId, quantity)
        ).run();
    }

    @Transactional
    protected void doDecrease(Long variantId, int quantity) {
        ProductVariant v = repo.findById(variantId).orElseThrow();
        v.decreaseStock(quantity);
        repo.save(v);
    }
}

Spring Retry 와 동등. 추가 의존성 없음 (이미 Circuit Breaker / Bulkhead 쓰는 프로젝트 면 통합 자연).

(3) 수동 루프 — 명시 적 제어

public void decreaseStock(Long variantId, int quantity) {
    int attempt = 0;
    int maxAttempts = 3;

    while (true) {
        try {
            doDecreaseInNewTx(variantId, quantity);
            return;
        } catch (ObjectOptimisticLockingFailureException e) {
            if (++attempt >= maxAttempts) {
                throw new StockUpdateFailedException(variantId, e);
            }
            sleepWithJitter(50, attempt);
        }
    }
}

@Transactional(propagation = Propagation.REQUIRES_NEW)
protected void doDecreaseInNewTx(Long variantId, int quantity) {
    ProductVariant v = repo.findById(variantId).orElseThrow();
    v.decreaseStock(quantity);
    repo.save(v);
}

private void sleepWithJitter(long baseMs, int attempt) {
    long jitter = ThreadLocalRandom.current().nextLong(baseMs);
    try {
        Thread.sleep(baseMs * (1L << (attempt - 1)) + jitter);
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        throw new RuntimeException(e);
    }
}

프레임워크 없이 *완전 제어. 디버깅 의 *투명 성. production 의 *fine-tuning 필요 한 hot path 에 적합.

(4) @Transactional 경계 — 흔한 함정

@Service
class StockService {
    
    @Retryable(...)          // ← AOP 1
    @Transactional           // ← AOP 2
    public void decreaseStock(...) {  // ← 같은 메서드
        ...
    }
}

⚠️ 순서 의 함정Spring AOP 의 *애너테이션 처리 순서. @Transactional바깥 이면 재시도 가 *같은 트랜잭션 안에서 발생 → 항상 stale version 으로 *영원 실패.

해결분리:

@Service
class StockService {
    @Autowired private TransactionalStockService txService;

    @Retryable(...)
    public void decreaseStock(...) {
        txService.doDecrease(...);   // ← 새 트랜잭션
    }
}

@Service
class TransactionalStockService {
    @Transactional
    public void doDecrease(...) { ... }
}

또는 Spring Retry 의 *@Retryable 이 @Transactional 보다 *바깥 (높은 priority)기본 동작Spring Boot 의 *@EnableRetry 가 *적절 한 order 보장.


4. 패턴 2 — 충돌 률 의 *모니터링

재시도 의 횟수 / 성공 률추적 의무. Micrometer + Prometheus:

@Service
@RequiredArgsConstructor
class StockService {
    private final MeterRegistry registry;

    @Retryable(...)
    @Transactional
    public void decreaseStock(Long variantId, int quantity) {
        registry.counter("stock.decrease.attempt", 
                "variant", variantId.toString()).increment();
        ...
    }

    @Recover
    public void recover(ObjectOptimisticLockingFailureException e, 
                        Long variantId, int quantity) {
        registry.counter("stock.decrease.exhausted").increment();
        throw new StockUpdateFailedException(variantId);
    }
}
# Grafana — *충돌 률*
rate(stock_decrease_attempt_total[5m]) / 
  rate(stock_decrease_success_total[5m])

충돌 률 이 *> 10% 지속 이면 Pessimistic 으로 전환 검토. 내 클러스터 의 *settlement< 1% 유지.


5. 패턴 3 — 멱등성 (Idempotency)결합

재시도 의 전제 조건멱등성. 같은 입력 의 *N 회 실행같은 결과.

위험 한 패턴

@Retryable(...)
@Transactional
public void chargeAndDeduct(Long userId, Long productId, int qty) {
    paymentService.charge(userId, totalAmount);   // ← 외부 PG 호출
    stockService.decrease(productId, qty);         // ← @Version
}

⚠️ 재시도 시 *결제 가 *2 번 발생. PG 의 멱등성 키 없으면 *2 회 청구.

안전 한 패턴

@Retryable(...)
@Transactional
public void chargeAndDeduct(Long userId, Long productId, int qty, 
                             String idempotencyKey) {
    Payment p = paymentService.charge(userId, totalAmount, idempotencyKey);
    // ← PG 가 idempotencyKey 로 중복 청구 차단
    stockService.decrease(productId, qty);
}

settlement 의 Refund 엔티티 의 `(payment_id, idempotency_key) UNIQUE 제약같은 발상.

Outbox 패턴 의 적용

재시도 가 DB 변경 + 외부 호출조합 이면 — Outbox + Triple Idempotency:

@Transactional
public void chargeAndDeduct(...) {
    stockService.decrease(...);                   // ← DB only
    outboxRepo.save(new OutboxEvent(...));        // ← 같은 tx
    // 외부 호출 = 별도 polling worker
}

DB 부분 만 재시도 안전. 외부 호출 의 *별도 worker멱등성 키 로 *중복 방지.


6. 패턴 4 — Atomic Update 의 *우회

@Version대안단순 한 카운터 / 잔액 / 재고Atomic UPDATE훨씬 단순.

// ❌ Optimistic Lock 의 *재시도 지옥*
@Transactional
public void decreaseStock(Long variantId, int qty) {
    ProductVariant v = repo.findById(variantId).orElseThrow();
    v.setStock(v.getStock() - qty);  // ← read-modify-write
    repo.save(v);
}

// ✅ Atomic UPDATE — 충돌 없음
@Modifying
@Query("UPDATE ProductVariant v " +
       "SET v.stock = v.stock - :qty " +
       "WHERE v.id = :id AND v.stock >= :qty")
int decreaseStockAtomic(@Param("id") Long id, @Param("qty") int qty);

public void decreaseStock(Long variantId, int qty) {
    int affected = repo.decreaseStockAtomic(variantId, qty);
    if (affected == 0) {
        throw new InsufficientStockException(variantId);
    }
}

DB 가 *원자 적 으로 처리. 재시도 불필요. @Version 도 불필요. 충돌 = 부족 한 재고명확 한 차이.

언제 사용단순 한 수치 변경 + 비즈니스 로직 이 거의 없음. 복잡 한 도메인 로직 (예: 가격 정책 + 할인 + VIP 등급)Optimistic Lock 의 표준 패턴 이 더 자연.


7. 패턴 5 — Pessimistic 으로 *전환언제?

충돌 률 이 높으면 *Optimistic 의 *재시도 지옥. Pessimistic 으로 전환:

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT v FROM ProductVariant v WHERE v.id = :id")
ProductVariant findByIdForUpdate(@Param("id") Long id);

@Transactional
public void decreaseStock(Long variantId, int qty) {
    ProductVariant v = repo.findByIdForUpdate(variantId);  // ← FOR UPDATE
    v.decreaseStock(qty);
    repo.save(v);
}

생성 SQL — SELECT ... FOR UPDATE. 다른 tx 의 *SELECT FOR UPDATE / UPDATE 가 *대기. 충돌 없음.

Pessimistic 의 *비용

  • 동시 처리량 ↓ (직렬화)
  • deadlock 위험 ↑ (락 순서)
  • long-running tx 의 *영향 ↑

Optimistic 유지 vs Pessimistic 전환 의 *결정 기준

| 상황 | 권장 | |—|—| | 충돌 률 < 5% | Optimistic + Retry | | 충돌 률 5~20% | Atomic UPDATE (가능 하면) | | 충돌 률 > 20% + 짧은 tx | Pessimistic | | 충돌 률 > 20% + 긴 tx | 설계 재검토 (도메인 분할) | | 분산 환경 + 비-DB 자원 | Distributed Lock (Redis / etcd) |

settlement 의 경험재고 (SKU)Optimistic + Retry. 금액 (정산 잔액)Pessimistic. 도메인 특성 의 차이명시 적 선택.


8. 패턴 6 — 사용자 응답 의 *우아 함

재시도 exhausted 후사용자 응답5xx 의 *지옥 이 아니라 비즈니스 의미.

@RestControllerAdvice
class GlobalExceptionHandler {

    @ExceptionHandler(StockUpdateFailedException.class)
    ResponseEntity<ApiError> handleStock(StockUpdateFailedException e) {
        return ResponseEntity.status(HttpStatus.CONFLICT)
            .body(new ApiError(
                "STOCK_CONCURRENT_UPDATE",
                "다른 고객 이 동시에 주문 중 입니다. 잠시 후 다시 시도 해주세요.",
                Map.of("productId", e.getProductId())
            ));
    }

    @ExceptionHandler(InsufficientStockException.class)
    ResponseEntity<ApiError> handleInsufficient(InsufficientStockException e) {
        return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY)
            .body(new ApiError(
                "STOCK_INSUFFICIENT",
                "재고 가 부족 합니다.",
                Map.of("productId", e.getProductId(), 
                       "available", e.getAvailable())
            ));
    }
}

HTTP 409 Conflict vs 422 Unprocessable. 전자 = 재시도 가능, 후자 = 비즈니스 실패. 클라이언트 의 *재시도 결정 의 명확 한 신호.

Frontend 의 재시도

async function orderProduct(productId: number, qty: number) {
    for (let attempt = 0; attempt < 3; attempt++) {
        try {
            return await api.post('/orders', { productId, qty });
        } catch (err) {
            if (err.response?.status === 409 && attempt < 2) {
                await sleep(200 * Math.pow(2, attempt));
                continue;
            }
            throw err;
        }
    }
}

서버 + 클라이언트2 단 재시도. 대부분 의 *transient 충돌 의 *흡수.


9. 패턴 7 — 테스트 의 *진짜 검증

재시도 코드 가 진짜 작동 하는지증명100 쓰레드 동시성 테스트. settlement 의 실제 테스트:

@SpringBootTest
class StockConcurrencyTest {

    @Autowired StockService stockService;
    @Autowired ProductVariantRepository repo;

    @Test
    void concurrent_100_threads_no_oversell() throws Exception {
        // Given: 재고 100 개 SKU
        ProductVariant v = repo.save(new ProductVariant("SKU-001", 100));

        // When: 100 쓰레드 가 동시 에 1 개 씩 차감
        int threads = 100;
        CountDownLatch ready = new CountDownLatch(threads);
        CountDownLatch start = new CountDownLatch(1);
        CountDownLatch done = new CountDownLatch(threads);
        AtomicInteger success = new AtomicInteger();
        AtomicInteger conflict = new AtomicInteger();

        ExecutorService pool = Executors.newFixedThreadPool(threads);
        for (int i = 0; i < threads; i++) {
            pool.submit(() -> {
                try {
                    ready.countDown();
                    start.await();
                    stockService.decreaseStock(v.getId(), 1);
                    success.incrementAndGet();
                } catch (StockUpdateFailedException e) {
                    conflict.incrementAndGet();
                } catch (Exception e) {
                    log.error("Unexpected", e);
                } finally {
                    done.countDown();
                }
            });
        }
        ready.await();
        start.countDown();   // ← 동시 시작
        done.await(30, TimeUnit.SECONDS);

        // Then: 정확 히 100 개 차감 + oversell 없음
        ProductVariant final_ = repo.findById(v.getId()).orElseThrow();
        assertThat(final_.getStock()).isEqualTo(0);
        assertThat(success.get() + conflict.get()).isEqualTo(threads);
        log.info("Success: {}, Conflict (retry exhausted): {}", 
                 success.get(), conflict.get());
    }
}

진짜 동시성검증. unit test 가 *못 잡는 *race condition 의 *유일한 진단 도구.

내 settlement 의 경험100 쓰레드 + Retry 3 회대부분 success 100 / conflict 0. 재시도 backoff 가 *충분 한 분산.


10. 분산 환경 의 Optimistic Lock

JPA 의 @VersionDB 단 의 *낙관 적 락. 비-DB 자원 (Redis cache / 외부 API state) 의 동시성별도 패턴.

Redis SETNX + CAS

public boolean updateRedisCounter(String key, int delta) {
    while (true) {
        String current = redis.get(key);
        long curVal = Long.parseLong(current);
        long newVal = curVal + delta;
        
        // CAS — WATCH + MULTI/EXEC
        Boolean ok = redis.executePipelined(...).contains(true);
        if (ok) return true;
        // retry
    }
}

etcd / ZooKeeper — revision 기반

// etcd v3 API
TxnResponse resp = etcd.txn()
    .If(Cmp.create(KEY, Cmp.Op.EQUAL, expectedRevision))
    .Then(Op.put(KEY, newValue, PutOption.DEFAULT))
    .commit().get();
if (!resp.isSucceeded()) {
    // CAS 실패 — 재시도
}

핵심 — 모든 *낙관 적 락공통 패턴 = CAS (Compare-And-Swap). JPA, Redis, etcd, ZooKeeper, Kafka 모두 같은 발상.


11. 운영 함정 — 내 14 개월

(1) Cascade 의 @Version

@OneToMany cascade = ALLparent 만 *@Version. child 추가 / 삭제parent version 증가 X (Hibernate 기본 동작).

해결 — @OptimisticLock(excluded = false) 또는 parent.lastModified 명시 적 변경.

(2) 부분 update 의 *함정

// ❌ @DynamicUpdate 없으면 *모든 컬럼 update*
@Entity class ProductVariant { @Version Long version; ... }

// 두 tx 가 *다른 컬럼* 변경 해도 *@Version 충돌*

해결 — @DynamicUpdate + 부분 update (그러나 동일 column 충돌 의 *위험 은 *남음).

(3) @Lock(OPTIMISTIC_FORCE_INCREMENT)

read 만 하는 tx 도 *version 증가. 조회 가 *관련 도메인 의 *일관성 보장 의무 가 있을 때.

@Lock(LockModeType.OPTIMISTIC_FORCE_INCREMENT)
ProductVariant findById(Long id);

(4) long-running tx 의 *재앙

@Transactional 메서드 가 수초~수분 걸리면 — 그 동안 의 *모든 동시 요청충돌. @Transactional 안 외부 호출 금지진짜 의미.

(5) Hibernate 의 *flush 시점

같은 트랜잭션 의 *2 번 째 SELECT1 차 캐시 의 *옛 version 반환. 변경 사항 보려면 entityManager.refresh(entity) 명시.


12. 마치며 — 작은 결론

낙관 적 락 의 진짜 가치낮은 충돌 률 + 높은 처리 량최적 조합. DB lock 의 *직렬화 비용 없음 + 충돌 시 *재시도 의 *작은 비용.

낙관 적 락 의 진짜 책임충돌 시 *어떻게 처리애플리케이션 측 의 *명시 적 결정. Pessimistic 의 *자동 대기 와 다르게 코드 가 *7 가지 패턴 (Retry / Atomic UPDATE / Pessimistic 전환 / 멱등성 / 사용자 응답 / 모니터링 / 동시성 테스트) 을 명시 적 으로 *조합.

settlement 의 14 개월 운영재고 (SKU)Optimistic + Retry + Atomic Stock UPDATE 의 *3 단, 금액 (정산 잔액)Pessimistic. 도메인 특성 별 *명시 적 선택production 의 *결함 0 회근거.

핵심 메시지: “낙관 적 락 은 *옵션 이 아니라 *완전 한 패턴 의 *조합. @Version 한 줄겉모습 뒤7 가지 책임체화진짜 의 quality”*


참고