Spring Retry에서 Cannot locate recovery method 에러 해결 과정

·2025년 12월 10일

troubleshooting

목록 보기
10/11

공동구매 참여 API에 낙관적 락 + Spring Retry를 적용하는 과정에서
계속해서 다음 에러가 발생했습니다.

Cannot locate recovery method

처음에는 @Recover 메서드 시그니처 문제라고 생각했지만, 실제 원인은 Spring Retry의 동작 방식과 프록시 한계에 있었습니다.
이번 글에서는 왜 이 에러가 발생했는지, 그리고 구조를 어떻게 변경해서 해결했는지를 정리합니다.

1. 최초 시도: @Retryable + @Recover 적용

공동구매 참여 시 동시 요청이 많기 때문에,
@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 에러 발생했다.


2. 왜 Cannot locate recovery method가 발생했을까?

이 에러의 핵심 원인은 Spring Retry의 Recover 메서드 매칭 규칙이다.

Spring Retry의 @Recover 매칭 조건

@Recover 메서드는 다음 조건을 정확히 만족해야 한다.

(1) 첫 번째 파라미터 : @Retryable에서 지정한 예외 타입과 동일하거나 상위 타입
(2) 그 이후 파라미터 : @Retryable이 붙은 메서드의 파라미터 순서, 타입이 정확히 동일
(3) 반환 타입 : @Retryable 메서드의 반환 타입과 동일


겉보기엔 문제 없어 보였지만, 실제 런타임에서는 다른 문제가 있었다.

3. 진짜 문제: 프록시 + 트랜잭션 + 엔티티 재조회 구조

(1) @Retryable은 프록시 기반 AOP

같은 클래스 내부 호출

파라미터로 단순 ID(UUID)를 넘기고 내부에서 조회

트랜잭션 경계 안에서 엔티티 재조회

이 조합에서 Retry 프록시가 정상적으로 Recover를 찾지 못하는 경우가 발생한다.

(2) Optimistic Lock 예외 타입 혼재

OptimisticLockException (JPA)

ObjectOptimisticLockingFailureException (Spring ORM)

실제로는 OptimisticLockingFailureException 계열로 래핑됨

→ retryFor / recover 예외 타입 매칭이 불안정해짐


4. 해결 전략: 구조 자체를 단순화

Retry 대상 메서드는 “순수 비즈니스 로직”만 담당하게 한다.

즉,

  • ID로 엔티티 조회 X
  • 엔티티를 파라미터로 직접 전달 O

예외 타입은 Spring 표준 예외 하나로 통일

5. 수정 후 구조

(1) 엔티티 조회는 Retry 밖에서 수행

public GroupPurchase findGroupPurchaseById(UUID id) {
    return groupPurchaseRepository.findById(id)
            .orElseThrow(() -> new CustomException(GROUPPURCHASE_NOT_FOUND));
}

(2) Retry 대상 메서드는 엔티티 기반으로 동작

@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(...);
}
  • 예외 타입을 OptimisticLockingFailureException 하나로 통일
  • Retry 대상 메서드가 단순해짐

(3) Recover 메서드도 동일 시그니처 유지

@Recover
public ParticipateInfo recover(
        OptimisticLockingFailureException e,
        GroupPurchase groupPurchase,
        int quantity
) {
    return ParticipateInfo.failure(
            groupPurchase.getStatus().name(),
            groupPurchase.getRemainingQuantity(),
            "현재 참여자가 많아 잠시 후 다시 시도해주세요."
    );
}

더 이상 Cannot locate recovery method 발생하지 않는다.


6. 이 경험에서 얻은 정리

  • @Retryable 메서드는 최대한 단순하게
  • 엔티티 조회는 Retry 밖에서
  • 예외 타입은 Spring 표준 예외로 통일
  • @Recover 시그니처는 파라미터 순서까지 정확히 일치
  • 트랜잭션 + Retry + AOP 조합은 구조가 가장 중요

7. 마무리

이번 이슈는 단순한 문법 문제가 아니라
Spring Retry가 어떤 방식으로 동작하는지 이해하지 못하면 반드시 겪게 되는 문제였다.

결과적으로는

  • 낙관적 락 재시도 안정성 확보
  • 비즈니스 로직과 조회 책임 분리
  • Retry/Recover 구조 명확화
    라는 측면에서 오히려 코드 품질이 좋아졌다.

에러를 없애는 것보다, 구조를 바꾸는 게 정답인 케이스였다.

0개의 댓글