낙관적 락 충돌로 실패한 작업을 트랜잭션을 새로 열어 다시 시도하는 처리 방식.
왜 필요한가
낙관적 락은 충돌을 감지만 한다. 감지 후 무엇을 할지는 애플리케이션 몫이다.
- UPDATE stock SET qty = 9, version = 4 WHERE id = 1 AND version = 3;
- 0행 영향 → OptimisticLockException
여기서 그냥 사용자에게 에러를 던지면? "다른 사용자가 먼저 수정했습니다. 다시 시도해 주세요."
- 사용자가 직접 새로고침하고 다시 눌러야 한다
재고 차감처럼 사용자가 개입할 필요 없는 작업은 서버가 알아서 재시도해야 한다.
반드시 트랜잭션 밖에서 재시도해야 한다 — 가장 중요한 지점
// ❌ 잘못된 방법 — 같은 트랜잭션 안에서 반복
@Transactional
public void order(Long id) {
for (int i = 0; i < 3; i++) {
try {
Stock s = repo.findById(id).get();
s.decrease();
return;
} catch (OptimisticLockException e) { /* 재시도 */ }
}
}
왜 안 되나 — 두 가지 이유
- 영속성 컨텍스트가 오염된 상태로 남는다. 1차 캐시에 이전 버전의 엔티티가 그대로 있어, 다시 조회해도 DB가 아닌 캐시에서 옛 값을 반환한다. → 몇 번을 재시도해도 같은 버전으로 실패한다.
- 예외가 발생하면 트랜잭션이 rollback-only로 표시되어,
이후 무엇을 해도 커밋 시점에
UnexpectedRollbackException이 난다.
// ✅ 올바른 방법 — 트랜잭션 자체를 새로 연다
@Retryable(
retryFor = { OptimisticLockingFailureException.class },
maxAttempts = 3,
backoff = @Backoff(delay = 50, multiplier = 2, random = true)
)
@Transactional
public void order(Long id) {
Stock s = repo.findById(id).get();
s.decrease();
}
@Retryable이 메서드 전체를 다시 호출하므로 트랜잭션도, 영속성 컨텍스트도
새로 만들어진다.
주의 — 스프링 AOP는 프록시 기반이라 같은 클래스 안에서 호출하면 재시도가 걸리지 않는다. 외부에서 호출되는 진입점에 붙여야 한다.
백오프와 지터
backoff = @Backoff(delay = 50, multiplier = 2, random = true)
충돌한 요청들이 동시에 재시도하면 또 충돌한다. 무작위 지연으로 흩어야 한다. 지수 백오프에서 본 thundering herd와 같은 문제다.
언제 재시도가 답이 아닌가
① 경쟁이 극심할 때
동시 요청 100건 → 1건 성공, 99건 재시도 → 1건 성공, 98건 재시도 …
총 시도 5000회. 대부분이 낭비
→ 비관적 락이나 Redis 원자 연산(DECR)으로 바꾼다.
② 사용자의 판단이 필요할 때
- 게시글을 두 사람이 동시에 편집
- 자동으로 재시도하면 뒤에 저장한 사람이 앞사람 내용을 덮어쓴다
- "다른 사람이 수정했습니다. 변경 내용을 확인하세요"가 옳다
자동 재시도는 "다시 해도 결과가 같아야 하는" 작업에만 쓴다.
재시도 횟수를 초과하면
@Recover
public void recover(OptimisticLockingFailureException e, Long id) {
log.warn("재고 차감 최종 실패: {}", id);
throw new OrderFailedException("주문이 몰려 처리하지 못했습니다");
}
조용히 삼키면 주문이 사라진 것처럼 보인다. 명확히 실패시키고 기록해야 한다.