공동구매 참여 API에 낙관적 락 + Spring Retry를 적용하는 과정에서
계속해서 다음 에러가 발생했습니다.Cannot locate recovery method
처음에는 @Recover 메서드 시그니처 문제라고 생각했지만, 실제 원인은 Spring Retry의 동작 방식과 프록시 한계에 있었습니다.
이번 글에서는 왜 이 에러가 발생했는지, 그리고 구조를 어떻게 변경해서 해결했는지를 정리합니다.

공동구매 참여 시 동시 요청이 많기 때문에,
@Version 기반 낙관적 락과 함께 @Retryable을 적용했다.
초기 코드 구조
@Retryable(
retryFor = {OptimisticLockException.class, ObjectOptimisticLockingFailureException.class},
noRetryFor = {CustomException.class},
maxAttempts = 3,
backoff = @Backoff(delay = 20)
)
@Transactional
public ParticipateInfo participate(UUID groupPurchaseId, int quantity) {
GroupPurchase groupPurchase = groupPurchaseRepository.findById(groupPurchaseId)
.orElseThrow(() -> new CustomException(GROUPPURCHASE_NOT_FOUND));
boolean success = groupPurchase.increaseQuantity(quantity);
if (!success) {
return ParticipateInfo.failure(...);
}
groupPurchaseRepository.save(groupPurchase);
return ParticipateInfo.success(...);
}
@Recover
public ParticipateInfo recover(
OptimisticLockException e,
UUID groupPurchaseId,
int quantity
) {
...
}
하지만 실행 시 계속 Cannot locate recovery method 에러 발생했다.
이 에러의 핵심 원인은 Spring Retry의 Recover 메서드 매칭 규칙이다.
@Recover 메서드는 다음 조건을 정확히 만족해야 한다.
(1) 첫 번째 파라미터 : @Retryable에서 지정한 예외 타입과 동일하거나 상위 타입
(2) 그 이후 파라미터 : @Retryable이 붙은 메서드의 파라미터 순서, 타입이 정확히 동일
(3) 반환 타입 : @Retryable 메서드의 반환 타입과 동일
겉보기엔 문제 없어 보였지만, 실제 런타임에서는 다른 문제가 있었다.
같은 클래스 내부 호출
파라미터로 단순 ID(UUID)를 넘기고 내부에서 조회
트랜잭션 경계 안에서 엔티티 재조회
이 조합에서 Retry 프록시가 정상적으로 Recover를 찾지 못하는 경우가 발생한다.
OptimisticLockException (JPA)
ObjectOptimisticLockingFailureException (Spring ORM)
실제로는 OptimisticLockingFailureException 계열로 래핑됨
→ retryFor / recover 예외 타입 매칭이 불안정해짐
Retry 대상 메서드는 “순수 비즈니스 로직”만 담당하게 한다.
즉,
예외 타입은 Spring 표준 예외 하나로 통일
public GroupPurchase findGroupPurchaseById(UUID id) {
return groupPurchaseRepository.findById(id)
.orElseThrow(() -> new CustomException(GROUPPURCHASE_NOT_FOUND));
}
@Retryable(
retryFor = OptimisticLockingFailureException.class,
maxAttempts = 3,
backoff = @Backoff(delay = 20)
)
@Transactional
public ParticipateInfo participate(GroupPurchase groupPurchase, int quantity) {
boolean success = groupPurchase.increaseQuantity(quantity);
if (!success) {
return ParticipateInfo.failure(...);
}
groupPurchaseRepository.saveAndFlush(groupPurchase);
return ParticipateInfo.success(...);
}
@Recover
public ParticipateInfo recover(
OptimisticLockingFailureException e,
GroupPurchase groupPurchase,
int quantity
) {
return ParticipateInfo.failure(
groupPurchase.getStatus().name(),
groupPurchase.getRemainingQuantity(),
"현재 참여자가 많아 잠시 후 다시 시도해주세요."
);
}
더 이상 Cannot locate recovery method 발생하지 않는다.
이번 이슈는 단순한 문법 문제가 아니라
Spring Retry가 어떤 방식으로 동작하는지 이해하지 못하면 반드시 겪게 되는 문제였다.
결과적으로는
에러를 없애는 것보다, 구조를 바꾸는 게 정답인 케이스였다.