|
Note
|
Spring Retry는 Spring Batch, Spring Integration 내부에서 재시도를 위해 사용되던 모듈이었다. Spring framework 7.0 부터는 Spring Core 모듈로 흡수가 되었다.
현재 버전의 유요한 사용법은 Resilience Features을 참고할 수 있다. 이 글에 소개된 @Recover와 @CircuitBreaker는 Spring framework 7.0에서는 지원되지는 않는다.
|
이 실습에서는 스프링 리트라이의 기능들을 테스트 코드를 통해 확인해보겠다.
IDE에서 새로운 Gradle 프로젝트를 생성해보자.
이름은 프로젝트 이름은 'retry' 등으로 편하게 붙이자.
필요한 의존성 선언등을 담은 build.gradle 파일은 다음과 같이 작성하자.
plugins {
id 'java'
id 'org.springframework.boot' version '3.2.1'
id 'io.spring.dependency-management' version '1.1.4'
}
group = 'net.benelog.retry'
version = '1.0-SNAPSHOT'
repositories {
mavenCentral()
}
java {
sourceCompatibility = '21'
}
dependencies {
implementation 'org.springframework.retry:spring-retry' // (1)
implementation 'ch.qos.logback:logback-classic'
implementation 'org.springframework:spring-core:'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
test {
useJUnitPlatform()
}-
이 실습의 핵심인 spring-retry 모듈에 대한 의존성이다.
이 글의 예제에서 스프링 부트의 기능을 활용한 요소는 없다.
그럼에도 라이브러리 버전을 편리하게 지정하기 위해 그레인들 스프링 부트 플러그인과 spring-boot-starter-test 모듈을 추가했다.
테스트에서도 꼭 필요한 의존성만을 명시하고 싶다면 JUnit5와 AssertJ를 직접 추가해도 된다.
아래와 같이 정의한 NotificationService 인터페이스를 재시도 대상으로 삼겠다.
public interface NotificationService {
boolean send(String message);
}'src/main/' 폴더 하위에 NotificationService.java 파일을 추가하자.
패키지는 net.benelog.retry 등 편리한데로 지정한다.
실제 알림을 보내는 기능은 실습 대상은 아니다.
이 인터페이스에 맞추어 알림을 보내기 위해 원격 호출을 하는 구현 클래스가 있다고 상상만 하자.
실제로 원격 호출을 하지 않고 의도적으로 지정한 횟수만큼 실패를 하는 클래스를 UnstableNotificationService라는 이름으로 만들겠다.
테스트 용도의 클래스이므로 'src/test/' 폴더 하위에 패키지를 만들어서 추가하자.
send(String) 메서드를 호출했을 때 지정된 횟수만큼은 예외를 내도록 구현한다.
public class UnstableNotificationService implements NotificationService {
private final Logger logger = LoggerFactory.getLogger(UnstableNotificationService.class);
private final int failures;
private int tryCount = 0;
public UnstableNotificationService(int failures) { // (1)
this.failures = failures;
}
@Override
public boolean send(String message) {
this.tryCount++;
if (this.tryCount <= this.failures) { // (2)
throw new RuntimeException("실패 : " + tryCount);
}
logger.info("성공 : {}, {}", this.tryCount, message);
return true;
}
public int getTryCount() {
return this.tryCount; // (3)
}
}-
의도적으로 실패할 횟수를 생성자 파라미터로 받는다.
-
의도한 횟수보다 작거나 같으면 예외를 발생시킨다.
-
send(String)메서드가 실제로 몇번 호출되었는지 알려준다.
send(String) 메서드 호출을 재시도하는 코드를 이어지는 단락에서 작성하겠다.
RetryTemplate 은 스프링 리트라이의 핵심 기능과 구현을 구현한 클래스이다.
이 클래스는 4개의 .execute() 메서드를 제공하는데, 모두 RetryCallback 인터페이스 타입을 파라미터로 받는다.
이 메서드들은 RetryOperations 라는 인터페이스로도 따로 정의되어 있기도하다.
public final <T, E extends Throwable> T execute(RetryCallback<T, E> retryCallback throws E {
}RetryCallback는 아래와 같이 하나의 메서드만 정의된 인터페이스로 재시도 대상이 되는 작업을 구현하는 용도이다.
public interface RetryCallback<T, E extends Throwable> {
T doWithRetry(RetryContext context) throws E;
}앞 단락의 NotificationService.send(String) 메서드를 재시도하는 클래스를 NotificationRetryDecorator 라는 이름으로 만들어보겠다.
디자인 패턴 중 Decorator 패턴을 응용한 클래스임을 이름으로 표현했다.
Decorator는 기존 객체를 감싸는 방식으로 유연하게 책임을 추가하는 패턴이다.
RetryTemplate이 꼭 Decorator 패턴을 통해 적용되어야 하는 것은 아니다.
RetryCallback 안에서 호출하는 재시도 대상은 내부 private 메서드나 직접 기술한 긴 로직 등 무엇이든 될 수 있다.
구현하고자하는 재시도와 관련된 요구사항은 다음과 같다.
-
최대 시도 횟수는
NotificationRetryDecorator클래스의 생성자의 파라미터로 전달받는다. -
재시도 사이에는 200ms, 400ms, 600ms로 점점 증가하는 시간 차이를 둔다. 600ms를 최대 간격으로 한다.
src/main 디렉터리 아래에 NotificationService 인터페이스와 같은 패키지에 NotificationRetryDecorator.java 파일을 추가하자.
요구사항대로 RetyTemplate을 생성해주는 private 메서드부터 먼저 만들겠다.
import org.springframework.retry.RetryOperations;
import org.springframework.retry.backoff.ExponentialBackOffPolicy;
import org.springframework.retry.policy.MaxAttemptsRetryPolicy;
import org.springframework.retry.support.RetryTemplate;
public class NotificationRetryDecorator {
private RetryOperations buildRetryOperations(int maxAttempts) { // (1)
var retryPolicy = new SimpleRetryPolicy( // (2)
maxAttempts,
Map.of(RuntimeException.class, true)
);
var backOffPolicy = new ExponentialBackOffPolicy(); // (3)
backOffPolicy.setInitialInterval(200L);
backOffPolicy.setMultiplier(2d);
backOffPolicy.setMaxInterval(600L);
var retryTemplate = new RetryTemplate(); // (4)
retryTemplate.setRetryPolicy(retryPolicy);
retryTemplate.setBackOffPolicy(backOffPolicy);
return retryTemplate;
}
}-
RetryTemplate클래스가 구현한 인터페이스인RetryOperations를 반환한다. -
RuntimeException이 발생하면 'maxAttempts' 값으로 지정된 횟수만큼 재시도하도록 정책을 정의한다. -
이어지는 세터 호출을 통해 최초 200ms, 이후 2배씩, 최대 600ms까지만 재시도 간격을 늘리는 정책을 정의한다
-
앞서 정의한
RetyPolicy,BackOffPolicy를 참조할RetryTemplate의 인스턴스를 생성한다.
반환형으로 RetryOperations 인터페이스를 활용했기에 buildRetryOperations(int) 메서드를 호출해서 사용하는 쪽에서는 RetryTemplate 클래스 레벨에서만 정의된 setBackOffPolicy(..) 같은 초기 설정과 관련된 메서드를 호출할 수 없게 된다.
RetryOperations만 의존하는 코드에서는 execute(…) 메서드만 사용 대상으로 신경쓰면 된다.
혹시 모를 실수를 막고 IDE에서의 자동 완성의 추천 대상도 축소되어 고를 범위가 줄어든다는 장점이 있다.
여러 클래스에서 RetryTemplate을 동일한 정책으로 쓴다면 어플리케이션 컨텍스트에 등록하고 재시도가 필요한 클래스에서 주입 받는 방식도 가능하다.
그럴 경우에도 이를 참조해서 쓰는 쪽에서 RetryOperations 인터페이스에만 의존한다면 여러 클래스에서 공유되는 정책이 수정되지 않도록 방어된다.
NotificationRetryDecorator 실습을 이어가겠다.
'Decorator' 패턴을 구현하기 위해 NotificationService 인터페이스를 이 클래스가 구현한다.
생성자에서는 재시도 대상 클래스와 재시도 횟수를 주입 받고 앞선 예제의 buildRetryOperations(…) 메서드를 호출하는 등 재시도에 필요한 구성요소를 만들어 가자.
import org.springframework.retry.RetryCallback;
import org.springframework.retry.RetryContext;
public class NotificationRetryDecorator implements NotificationService { // (1)
private final Logger logger = LoggerFactory.getLogger(NotificationRetryDecorator.class);
private final NotificationService target;
private final RetryOperations retryOperations;
public NotificationRetryDecorator(NotificationService target, int maxAttempts) { // (2)
this.target = target;
this.retryOperations = buildRetryOperations(maxAttempts); // (3)
}
@Override
public boolean send(String message) {
RetryCallback<Boolean, RuntimeException> retryCallback = (RetryContext context) -> { // (4)
logger.info("{}", context);
return target.send(message);
};
return this.retryOperations.execute(retryCallback); // (5)
}
// buildRetryOperations(...) 메서드 생략
}-
NotificationService인터페이스를 구현함으로서 재시도 기능이 없었던 기존의 사용처에도 바로 적용될 수 있다. -
재시도의 대상 객체와 재시도 횟수를 생성자의 파라미터로 받는다.
-
생성자를 통해 받은 횟수만큼 재시도하는
RetyOperations객체를 생성한다. -
재시도 대상이 되는 동작을 람다 표현식으로 정의했다.
-
로깅을 하지 않는다면
retryOperations.executecontext) → target.send(message
위의 예제에서는 파라미터 타입을 더 명확히 드러내고자 'retryCallback’을 로컬 변수로 할당하고 실행 여부를 인지하기 쉽도록 콜백 메서드 안에서 로깅을 하는 코드도 필요했다. 실무에서는 아래와 같이 람다 표현식을 더 쓰는 방법도 고려해볼 수 있다.
@Override
public boolean send(String message) {
return this.retryOperations.execute((context) -> target.send(message));
}RetryTemplate 클래스는 builer() 메서드를 제공한다.
직접 RetyPolicy, BackOffPolicy를 지정해서 RetryTemplate을 생성했던 buildRetryOpertaions(int) 메서드는 아래와 같이 대체될 수 있다.
this.retryOperations = RetryTemplate.builder()
.retryOn(RuntimeException.class)
.maxAttempts(maxAttempts)
.exponentialBackoff(200L, 2d, 600L)
.build();;builder()를 쓰면서도 .customPolicy(RetryPolicy), .customBackOff(BackOffPolicy) 메서드를 통해 직접 Policy 구현 클래스를 지정하는 것도 가능하다.
테스트 코드를 통해 재시도가 지정한 정책대로 되었는지 확인해보자.
앞에서 만든 테스트 전용을 클래스인 UnstableNotificationService를 통해 의도적으로 실패할 횟수를 지정하고, 이 횟수를 넘어선 재시도를 해서 결국에는 성공함을 테스트 코드로 표현해보겠다.
class NotificationRetryDecoratorTest {
@Test
void successByRetry() {
var target = new UnstableNotificationService(3); // (1)
var decorator = new NotificationRetryDecorator(target, 4); // (2)
boolean success = decorator.send("hello");
assertThat(success).isTrue();
assertThat(target.getTryCount()).isEqualTo(4); // (3)
}
}-
최초 3회 호출은 예외를 내는
UnstableNotificationService인스턴스를 만든다. -
대상 객체를 총 4회까지 재시도하도록 decorator를 생성한다.
-
총 4회까지
UnstableNotificationService.send(String)메서드가 호출되었음이 확인된다.
위의 테스트 코드를 실행하면 UnstableNotificationService.send(String) 호출이 3번 실패하고 4번째에 성공하는 과정이 콘솔의 로그로도 확인이 된다.
INFO ko.co.wikibook.retry.NotificationRetryDecorator -- [RetryContext: count=0, lastException=null, exhausted=false]
INFO ko.co.wikibook.retry.NotificationRetryDecorator -- [RetryContext: count=1, lastException=java.lang.RuntimeException: 실패 : 1, exhausted=false]
INFO ko.co.wikibook.retry.NotificationRetryDecorator -- [RetryContext: count=2, lastException=java.lang.RuntimeException: 실패 : 2, exhausted=false]
INFO ko.co.wikibook.retry.NotificationRetryDecorator -- [RetryContext: count=3, lastException=java.lang.RuntimeException: 실패 : 3, exhausted=false]
INFO ko.co.wikibook.retry.UnstableNotificationService -- 성공 : 4, hello
로그 메시지에서 RetryContext가 가진 재시도 횟수(count), 마지막으로 발생한 예외(lastException)도 확인이 된다.
각각 RetryContext.getRetryCount(), RetryContext.getLastThrowable() 메서드로 참조할 수 있는 값이기도 하다.
필요하다면 RetryCallback.doWithRetry(RetryContext) 메서드를 구현한 메서드나 람다 표현식에서 이를 활용할 수도 있다.
재시도를 했음에도 마지막 시도까지 실패했을 때 다른 동작을 실행할 수도 있다.
RetryTemplate은 execute(RetryCallback, RecoveryCallback) 메서드를 통해 이를 지원한다.
재시도할 동작은 RetryCallback에, 최종 실패시의 동작은 RecoveryCallback에 담아서 넘기는 형식이다.
RecoveryCallback 인터페이스도 메서드 1개만이 단순하게 정의되어 있다.
public interface RecoveryCallback<T> {
T recover(RetryContext context) throws Exception;
}NotificationRetryDecorator에서도 이를 활용해 보자.
RecoveryCallback 지정 @Override
public boolean send(String message) {
return retryOperations.execute(
(context) -> this.target.send(message),
(context) -> recover(context.getLastThrowable(), message) // (1)
);
}
private boolean recover(Throwable error, String message) { // (2)
logger.warn("메시지 전송 최종 실패 후 recover : {}", message, error);
return false;
}-
같은 클래스의
recover(…)메서드를 호출하도록RecoveryCallback을 람다 표현식으로 정의했다. -
추가한 메서드. 전송을 요청한 메시지와 예외를 로깅하고
false값을 반환한다.
NotificationRetryDecoratorTest에 메서드를 추가하여 recover(…) 메서드가 의도대로 실행됨을 확인하자.
@Test
void recover() {
var target = new UnstableNotificationService(2); // (1)
var decorator = new NotificationRetryDecorator(target, 2); // (2)
boolean success = decorator.send("hello");
assertThat(success).isFalse(); // (3)
assertThat(target.getTryCount()).isEqualTo(2);
}-
처음 2번째 호출은 예외를 내는
NotificaitonService를 생성한다. -
2번까지 재시도하도록 'decorator’를 만든다. 결과적으로 모든 재시도 호출은 예외가 발생하여 실패한다.
-
마지막 시도가 실패했어도 예외가 발생하지 않고
recover(…)메서드가 실행되어false값이 반환되었다.
추가한 테스트 메서드를 실행하면 로그 메시지를 통해서도 recover(…) 메서드가 실행되었음이 확인된다.
RecoveryCallback를 통해 유도한 실행 흐름은 마치 try - catch 구문의 catch 블럭과 비슷한 역할을 한다.
배치잡 안의 중간 과정 중에서 다른 차선의 기능으로 대체하는 것이 작업을 멈추는 것보다는 나은 동작이 있다면 이 구조를 활용해볼만하다.
예를 들면 사용자의 프로필 이미지를 HTTP 호출로 읽어오는 동작이 실패했을 때 디폴트 이미지로 이를 대체하는 기능 같은 것들이다.
RetryPolicy 구현체 중 하나인 CircuitBreakerRetryPolicy 클래스는 실패하는 요청을 계속하지 않도록 하여 장애 확산을 방어하는 서킷 브레이커 개념을 지원한다.
특정 시간 내에 특정 건의 시도가 실패하면 '차단기’가 열려서 한동안은 실제 동작을 수행하는 메서드를 호출하지 않다.
예제를 통해 다음 정책을 구현해보자.
-
200ms동안 n번이상 특정 메서드 호출이 실패하면 차단기가 열린다. 실제 기능이 수행하는 메서드 호출을 하지 않고 리커버 동작을 수행한다.
-
차단기는 300ms동안 유지되고, 해당 시간이 지나면 닫힌다.
써킷 브레이커가 열리고 다시 닫히는 시간이 정책에 들어 간다.
앞에서 했던 단순히 횟수를 지정하는 정책과 다르게 호출이 실패한 시간 정보 등이 기록되어야 한다.
RetryTemplate에서는 내부적으로 `RetryContextCache 라는 인터페이스를 통해 상태 정보를 저정한다.
기본값으로는 메모리에 상태를 저장하는 MapRetryContextCache 구현체가 쓰인다.
여러 서버 간에 재시도 기준 정보를 공유하고 싶다면 RetryContextCache를 직접 구현해서 Redis 같은 글로벌 캐쉬에 저장하는 것도 가능한다.
이러한 상태 정보를 관리하는데 필요한 키값 등은 RetryState 인터페이스를 통해 전달된다.
기본 구현체로 DefaultRetryState 클래스가 제공되고 이를 통해 다음의 속성을 전달한다.
-
key : 재시도 상태를 관리하는 기준이 되는 키
-
forceRefresh : 완전히 새로운 상태로 시도할 경우 true
-
rollbackClassifier : 트랜잭션 롤백을 위해 특정 예외를 전파할지에 대한 결정하는 역할을 한다. {chunk-chapter}에서 소개한
Classifier인터페이스를 파라미터 타입으로 쓴다.
RestTemplate 클래스와 RestOpertaions 인터페이스에는 RetryState를 포함하는 execute(…) 메서드가 있다.
써킷 브레이커 정책을 적용하기 위해서는 이 메서드를 호출해야 한다.
<T, E extends Throwable> T execute(RetryCallback<T, E> retryCallback, RecoveryCallback<T> recoveryCallback,
RetryState retryState) throws E;위의 메서드를 활용하여 써킷 브레이커 정책을 활용하는 예제도 Decorator 패턴으로 먼저 구현해보겠다.
앞선 실습의 NotificationRetryDecorator 클래스와 거의 구조가 비슷하므로 복사해서 만들어도 좋다.
먼저 RetryTemplate을 생성하는 buildRetryOperations() 메서드부터 정의하겠다.
public class NotificationCircuitDecorator implements NotificationService {
// 중간 생략
private RetryOperations buildRetryOperations(int maxAttempts) {
var simplePolicy = new SimpleRetryPolicy(
maxAttempts,
Map.of(RuntimeException.class, true)
);
var circuitBreakerPolicy = new CircuitBreakerRetryPolicy(simplePolicy); // (1)
circuitBreakerPolicy.setOpenTimeout(200L); // (2)
circuitBreakerPolicy.setResetTimeout(300L); // (3)
var retryTemplate = new RetryTemplate();
retryTemplate.setRetryPolicy(circuitBreakerPolicy); // (4)
return retryTemplate;
}
}-
서킷 브레이커의 발동 조건이 되는 대상 예외와 발동 전 최대 호출 횟수는
SimpleRetryPolicy로 정의해서CircuitBreakerRetryPolicy내부에서 참조하도록 생성자의 인자로 전달한다. -
2000ms 동안
SimpleRetryPolicy에서 지정된 횟수의 시도가 실패하면 서킷 브레이커가 발동한다. -
3000ms가 지나면 서킷 브케이커가 닫혀서 다시 원래의 대상 메서드를 호출한다.
-
앞에서 구성한 정책을
RetryTemplate으로 넣는다.
NotificationCircuitDecorator의 나머지 부분도 완성을 하자.
public class NotificationCircuitDecorator implements NotificationService {
private final Logger logger = LoggerFactory.getLogger(NotificationCircuitDecorator.class);
private final NotificationService target;
private final RetryOperations retryOperations;
private final RetryState state; // (1)
public NotificationCircuitDecorator(NotificationService target, int maxAttempts) {
this.target = target;
this.retryOperations = buildRetryOperations(maxAttempts);
String stateKey = "NotificationService.send"; // (2)
var rollbackClassifier = new BinaryExceptionClassifier(false); // (3)
this.state = new DefaultRetryState(stateKey, rollbackClassifier);
}
@Override
public boolean send(String message) {
return this.retryOperations.execute(
(context) -> this.target.send(message),
(context) -> recover(context.getLastThrowable(), message),
this.state // (4)
);
}
// recover 메서드는 ..와 동일
}-
NotificationRetryDecorator클래스 대비 추가된 멤버 변수 -
재시도를 위한 상태 정보의 키를 재시도할 메서드의 이름으로 썼다.
-
원래의 메서드 호출해서 생기는 예외를 다시 전파하지 않도록 한다.
-
상태를 유지하는 재시도를 위해 여러
execute(…)메서드 중 `RetryState``를 파라미터로 받은 것을 호출한다.
테스트 코드로 써킷 브레이크가 잘 동작하는지 확인을 하자.
class NotificationCircuitDecoratorTest {
UnstableNotificationService target = new UnstableNotificationService(2); // (1)
NotificationCircuitDecorator decorator = new NotificationCircuitDecorator(target, 2); // (2)
private void callAndAssert(boolean expectedResult, int expectedTryCount) { // (3)
boolean success = decorator.send("Hello!");
assertThat(success).isEqualTo(expectedResult);
assertThat(target.getTryCount()).isEqualTo(expectedTryCount);
}
@Test
void recoveredWithCircuit() throws InterruptedException {
callAndAssert(false, 1); // (4)
callAndAssert(false, 2);
callAndAssert(false, 2); // (5)
TimeUnit.MILLISECONDS.sleep(310); // (6)
callAndAssert(true, 3); // (7)
}
}-
처음 2번은 예외를 내는 target 객체를 만든다.
-
2번 호출이 실패하면 써킷이 열리는 decorator 객체를 만든다.
-
반복되는 호출과 검증 동작을 private 메서드로 추출했다.
callAndAssert(…)메서드의 첫 번째 파라미터는 'decorator.send(…)' 메서드의 기대 결과값이고, 두번째는target.send(String)메서드가 실제 호출된 누적 횟수인다. -
첫 호출에도 리커버 메서드가 호출되어서
decorator.send()메서드에서는false를 반환했다. -
세번째 시도이지만 써킷이 열려서
target.send(String)메서드의 실제 호출 횟수는 2에서 더 증가하지 않았따. -
써킷이 닫히는 상태가 될 때까지 대기한다.
NotificationCircuitDecorator클래스에서circuitBreakerPolicy.setResetTimeout(300L)코드로 지정한 시간보다 긴 310ms를 기다린다. -
다시
target.send(String)가 실행되어 누적 호출 횟수는 3으로 증가했다.
특정 횟수를 단순히 재시도했던 SimpleRetryPolicy와는 최대 호출 횟수와 리커버 동작의 실행 시점 등 전체적인 흐름이 다르다는 것을 알 수 있다.
직접 RetryTemplate를 호출하지 않고도 어노테이션과 AOP를 이용해서 재시도를 할 수도 있다.
@Retryable, @Recover, @CircuitBreaker가 여기에 쓰이는 어노테이션이다.
이를 통해 앞에서 만든 'Decocrator' 클래스들로 구성한 재시도 정책을 동일하게 적용하면서도 더 짧은 코드로 구현해보겠다.
먼저 build.gradle의 dependencies 단락에 다음과 같이 spring-aspects 모듈을 추가한다.
spring-aspects 모듈 의존성 추가 implementation 'org.springframework:spring-aspects'스프링 리트라이의 AOP 관련 기능이 aspectjweaver 모듈을 런타임에 의존하고 있기 때문이다.
위 선언이 없으면 이어질 실습을 지냉하면 org.aspectj.lang.annotation.Pointcut 클래스를 찾을수 없다는 에러가 나온다.
@Retryable를 붙인 인터페이스를 NotificationRetryService로 정의하겠다.
앞 단락의 예제를 보존하기 위해서 NotificationService를 상속한 인터페이스를 따로 만들었다.
NotificationRetryService와 같은 패키지에 추가하자.
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Retryable;
interface NotificationRetryService extends NotificationService {
@Retryable(
retryFor = RuntimeException.class, maxAttempts = 4,
backoff = @Backoff(delay = 200L, multiplier = 2d, maxDelay = 600L)
)
boolean send(String message);
}RetryTemplate.builder() 로 했을 때와 유사하게 애너테이션의 속성으로 대상이 되는 예외, 재시도 횟수와 BackOff 정책을 정의했다.
@Retryable 애너테이션은 구현 클래스에 직접 붙일 수도 있다.
이 실습 프로젝트에서는 실제 원격 호출로 알림을 전송하는 구현 클래스는 상상 속에 존재하기 때문에 인터페이스에 붙였다.
테스트용 구현체인 UnstableNotificationService 클래스에 직접 @Retryable를 붙이는 것도 자연스럽지 않다.
인터페이스를 따로 정의했기에 이를 구현한 여러 클래스에 동일한 재시도 정책을 적용할 수 있다는 장점이 생겼다.
테스트를 위해서는 아래와 같이 UnstableNotificationService가 구현한 인터페이스를 NotificationRetryService로 바꿔보자.
public class UnstableNotificationService implements NotificationRetryService {의도대로 재시도가 되는지 다시 테스트 코드로 확인을 하자.
원래의 UnstableNotificationService 클래스의 생성자로 바로 만든 객체가 아닌, 스프링의 AOP에 의해서 재시도 기능이 덧붙여진 객체를 만들어야 한다.
스프링 리트라이에서 제공하는 @EnableRetry 애너테이션이 이를 위해 필요하다.
그리고 UnstableNotificationService를 어플리케이션 컨텍스트에 등록해서 스프링 컨테이너가 이 클래스에 AOP를 적용할 기회를 주어야한다.
이를 위해 테스트 전용 어플리케이션 컨텍스트를 구성하는 TestServiceConfig.java 파일을 /src/test/java 디렉터리의 하위 경로에 추가하자.
실제 운영에 쓰일 코드도 동일한 방식으로 적용이 가능하다.
@Configuration
@EnableRetry(proxyTargetClass = true) // (1)
public class TestServiceConfig {
@Bean
public UnstableNotificationService fail3Service() {
return new UnstableNotificationService(3); // (2)
}
}-
@Retryable애너테이션이 붙은 클래스가 어플리케이션 컨텍스트에 등록되면 재시도 기능을 추가한 프록시 객체를 만들어준다. -
3번째 시도까지 예외를 내는 테스트용 객체를 생성한다.
위의 예제에서는 (proxyTargetClass = true) 속성을 통해서 인터페이스가 아닌 클래스를 확장한 프록시 객체로 AOP가 적용되도록 유도했다.
테스트에서는 UnstableNotificationService 클래스 수준에서 정의된 getTryCount() 메서드를 활용하기 위해서 그렇게 했다.
스프링이 어플리케이션 컨텍스트를 통해 생성하는 프록시 객체의 타입에 대해서는 {chunk-chapter}의 @JobScope와 @StepScope를 통해 등록되는 객체의 타입' 단락을 참고할 수 있다.
TestServiceConfig에서 정의한 어플리케이션 컨텍스트를 올려서 AOP가 적용된 UnstableNotificationService를 테스트하는 코드를 만들어보자.
NotificationRetryServiceTest.java 파일로 이름을 붙이겠다.
@SpringJUnitConfig(TestServiceConfig.class) // (1)
class NotificationRetryServiceTest {
@Test
void retry(@Qualifier("fail3Service") UnstableNotificationService fail3Service) { // (2)
boolean success = fail3Service.send("Hello!");
assertThat(fail3Service.getTryCount()).isEqualTo(4); // (3)
assertThat(success).isTrue();
}
}-
앞서 만든 테스트용 어플리케이션 컨텍스트를 참조한다.
-
이후에 같은 타입으로 여러 개의 빈(bean)을 등록할 계획이 있기에
@Qualifier로 이름을 명시했다. -
send(String)메서드를 테스트에서 직접 호출한 것은 1번이지만 재시도가 3번되어서총 4번 실행이 되었다.
위의 결과를 보면 결국 implements NotificationRetryService 선언으로 NotificationRetryDecorator 클래스에서 직접 RetryTemplate를 써서 재시도 정책을 정의한 것과 동일한 효과를 낸다는 것을 알 수 있다.
Decrator 클래스를 Spring Rety 모듈이 자동으로 실행 시점에 만들어준다고 이해할 수 있다.
스프링 리트라이 안의 RetryOperationsInterceptor 클래스가 내부적으로 그런 역할을 한다.
RecoveryCallback 안에서 담겨서 호출될 메서드도 @Recover 애너테이션으로 지정할 수 있다.
NotificationRetryService 인터터페이스에 디폴트 메서드로 false 값을 반환하는 메서드를 추가하고 그 위에 @Recover 를 붙여보자.
Logger logger = LoggerFactory.getLogger(NotificationRetryService.class);
@Recover // (1)
default boolean recover(Throwable error, String message) { // (2)
logger.warn("메시지 전송 최종 실패 후 recover : {}", message, error);
return false;
}-
최종 재시도가 실패할 시 대신 호출된 메서드로 지정한다.
-
마지막 재시도에서 발생한 예외와 재시도 대상이였던 메서드로 넘어왔던 파라미터의 값들을 전달받는다.
@Recover 가 붙은 메서드의 첫 번째 파라미터를 Throwable 타입으로 선언하면 @Retrable 이 붙은 메서드에서 발생한 에러 객체를 전달받을 수 있다.
이 후의 파라미터들은 @Retryable 이 붙은 메서드의 파라미터를 순서대로 선언하면 같은 값을 넘겨 받을 수 있다.
@Recover를 붙인 메서드가 의도대로 호출되는지 테스트로 확인해보겠다.
앞서 NotificationRetryService.send(String) 메서드는 @Retyable 선언으로 4번 재시도를 하므로 호출이 4번까지는 실패해야 recover(…) 메서드가 호출된다.
이 테스트 시나리오를 위해 일부러 4번까지는 예외를 내는 UnstableNotificationService를 어플리케이션 컨텍스트에 등록하자. TestServiceConfig.java에 메서드를 추가하면 된다.
UnstableNotificationService 등록 @Bean
public UnstableNotificationService fail4Service() {
return new UnstableNotificationService(4);
}NotificationRetryServiceTest에서 위에서 만든 'fail4Service’를 호출해서 4번 재시도되는 결과를 확인한다.
@Test
void retryAndRecover(@Qualifier("fail4Service") UnstableNotificationService fail4Service) {
boolean success = fail4Service.send("Hello!");
assertThat(fail4Service.getTryCount()).isEqualTo(4);
assertThat(success).isFalse(); // (1)
}-
예외 대신
recover(…)메서드의 결과인false값이 반환된다.
테스트를 실행하면 로그 메시지 중에서도 '메시지 전송 최종 실패 후 recover’라는 문자열이 보일 것이다.
마지막으로 @CircuitBreaker 애너테이션을 알아보겠다.
앞에 나왔던 CircuitBreakerRetryPolicy를 활용했던 RetryCircuitDecorator 예제와 동일한 기능을 더 간결한 코드로 만들 수 있다.
NotificationCircuitService라는 새로운 인터페이스를 정의해서 @CircuitBreaker의 속성으로 대상 예외와 타임아웃 시간 등을 지정하다.
interface NotificationCircuitService {
@CircuitBreaker(
retryFor = RuntimeException.class, // (1)
maxAttempts = 2, openTimeout = 200L, resetTimeout = 300L // (2)
)
boolean sendOnCircuit(String message); // (3)
}-
RuntimeException이 발생한 경우를 써킷 브레이드가 발동될 대상으로 한다. -
최대 2회 재시도 후 실패하면 서킷이 열리고, 200밀리초동안 열겼다가 300밀리초가 지나면 서킷이 닫혀서 다시 실제 재시도 대상 메서드가 호출된다.
-
메서드명도 앞선 예제와 구분하기 위해
sendOnCircuit(String)으로 다르게 붙였다.
테스트로 이 동작을 확인해볼 FragileService라는 클래스를 만들겠다.
UnstableNotificationService와 동일한 동작을 하는 클래스가 필요한데 앞선 예제를 보존하기 위해 클래스를 하나 더 만들자.
메서드의 구현부는 동일하므로 실습 예제의 간결함을 위해 UnstableNotificationService 클래스를 상속하겠다.
public class FragileService extends UnstableNotificationService implements NotificationCircuitService {
public FragileService(int failures) {
super(failures);
}
@Override
public boolean sendOnCircuit(String message) {
return super.send(message);
}
}AOP가 적용되도록 앞선 실습에서 만들어 둔 TestServiceConfig 클래스에서 FragileService 클래스의 인스턴스를 @Bean 으로 등록한다.
@Bean
public FragileService fail2Service() {
return new FragileService(2); // (1)
}-
두 번은 의도적인 에러를 내도록
FragileService의 인스턴스를 생성한다.
어플리케이션 컨텍스트가 로딩되는 순간 `fail2Service 객체를 감싼 써킷 브레이커 정책이 적용되는 기능이 덧붙여진 객체가 자동으로 생성된다.
스프링 리트라이 내부의 StatefulRetryOperationsInterceptor 클래스가 그 역할을 한다.
Decorator로 써킷 브레이커를 적용했던 NotificationCircuitDecoratorTest와 동일한 시나리오로 테스트를 해보자.
@SpringJUnitConfig(TestServiceConfig.class) // (1)
class NotificationCircuitServiceTest {
@Autowired
FragileService fail2Service;
private void callAndAssert(boolean expectedResult, int expectedTryCount) { // (2)
boolean success = fail2Service.sendOnCircuit("Hello!");
assertThat(success).isEqualTo(expectedResult);
assertThat(fail2Service.getTryCount()).isEqualTo(expectedTryCount);
}
@Test
void recoveredWithCircuit() throws InterruptedException {
callAndAssert(false, 1); // (3)
callAndAssert(false, 2);
callAndAssert(false, 2); // (4)
TimeUnit.MILLISECONDS.sleep(310); // (5)
callAndAssert(true, 3); // (6)
}
}-
AOP가 적용된
FragileService를 참조할 수 있도록 어플리케이션 컨텍스트를 로딩한다. -
NotificationCircuitDecoratorTest예제의 것과 동일한 메서드이다. -
실제
sendOnCircuit()메서드 호출에서는 예외가 났지만recover()가 실행되어서 반환값은false가 나온다. -
차단기가 열린 이후부터는 더 이상 원래의
sendOnCircuit(String)메서드는 호출되지 않아서 호출 횟수가 증가하지 않는다. -
@CircuitBreaker에resetTimeout속성값보다 큰 값의 시간이 지나간다. 이후로는 차단기가 닫힌다. -
더 이상 호출이 차단되지 않아서 원래의
sendOnCircuit(String)메서드가 실행되고 실행횟수가 증가한다. 의도한 2번의 예외가 발생한 이후이므로 호출도 성공한다.
{event-listener-chapter}장에서도 소개한 RetryListener도 직접 RetyTemplate에서 지정하여 활용할 수 있다.
RetryListener 인터페이스에는 다음과 같이 정의되어 있다.
package org.springframework.retry;
public interface RetryListener {
default <T, E extends Throwable> boolean open(RetryContext context, RetryCallback<T, E> callback) {}
default <T, E extends Throwable> void onError(RetryContext context, RetryCallback<T, E> callback, Throwable throwable) {}
default <T, E extends Throwable> void close(RetryContext context, RetryCallback<T, E> callback, Throwable throwable) {}
default <T, E extends Throwable> void onSuccess(RetryContext context, RetryCallback<T, E> callback, T result) {}
}이 메서드들을 활용하면 재시도 싸이클이 시작되는 시점, 재시도 중 에러가 나는 시점, 성공하는 시점, 재시도가 끝나는 시점에 개입하여 확장된 기능을 넣을 수 있다.
예를 들면 특정 조건에 따라서 재시도를 하지 않거나 멈추는 것도 가능하다.
RetryListener.open(…) 메서드에서 false 를 반환하거나 onError(…) 안에서 RetryContext.setExhaustedOnly() 메서드를 호출하면 재시도는 더 이상 진행되지 않는다.
RetryListener의 메서드가 호출되는 시점을 확인하는 예제를 만들어보자.
LogRetryListener 라는 이름으로 각 메서드에서 로그 메시지를 남기는 클래스를 구현해보겠다.
import org.springframework.retry.RetryCallback;
import org.springframework.retry.RetryContext;
import org.springframework.retry.RetryListener;
public class RetryLoggingListener implements RetryListener {
private final Logger logger = LoggerFactory.getLogger(RetryLoggingListener.class);
@Override
public <T, E extends Throwable> boolean open(RetryContext context, RetryCallback<T, E> callback) {
logger.info("open : {}", context);
return true;
}
@Override
public <T, E extends Throwable> void onError(RetryContext context, RetryCallback<T, E> callback,
Throwable throwable) {
logger.warn("onError: {}", context, throwable);
}
@Override
public <T, E extends Throwable> void close(RetryContext context, RetryCallback<T, E> callback,
Throwable throwable) {
logger.info("close : {}", context);
}
@Override
public <T, E extends Throwable> void onSuccess(RetryContext context, RetryCallback<T, E> callback, T result) {
logger.info("onSuccess : {}", result);
}
}이번 장에서 만든 NotificationRetryDecorator에서 위의 리스너를 추가하자.
public NotificationRetryDecorator(NotificationService target, int maxAttempts) {
this.target = target;
this.retryOperations = RetryTemplate.builder()
// 중략
.withListener(new RetryLoggingListener()) // 추가된 라인
.build();
}-
RetryTemplate.setListeners(…)메서드를 호출하는 방법으로도 리스너를 지정할 수 있다.
여러 상황을 비교하기 위해 NotificationRetryDecoratorTest에 실패 없이 바로 성공하는 테스트 메서드도 추가해보자.
@Test
void successOnFirstTry() {
NotificationService target = (message) -> {
System.out.println("Message : " + message);
return true;
};
var decorator = new NotificationRetryDecorator(target, 2);
boolean success = decorator.send("hello");
assertThat(success).isTrue();
}위의 테스트를 실행하면 콘솔에서 LogRetryListener 가 남기는 로그 메시지가 보일 것이다.
이를 통해 RetryContext 가 어떤 정보를 담고 있는지도 대략 알 수 있다.
NotificationRetryDecoratorTest의 여러 메서드를 통해서 다음을 확인해볼 수 있다.
한번에 타깃 메서드 호출이 성공하는 경우는 다음과 같이 RetryListener의 메서드가 호출된다.
(NotificationRetryDecoratorTest.successOnFirstTry(…))
-
RetryListener.open(…) -
재시도 대상 호출
-
RetryListener.onSuccess(…) -
RetryListener.close(…)
한 번 이상 실패했지만 재시도로 성공한 경우의 호출 순서는 다음과 같다.
(NotificationRetryDecoratorTest.successOnFirstTry(…))
-
RetryListener.open(…) -
재시도 대상 메서드 호출했으나 실패
-
RetryListener.onError(…) -
2 ~ 3번 과정을 성공할 때까지 반복
-
RetryListener.onSuccess(…) -
RetryListener.close(…)
재시도를 했어도 최종 실패하고 리커버 메서드가 실행되면 다음과 같다.
(NotificationRetryDecoratorTest.recover(…))
-
RetryListener.open(…) -
재시도 대상 메서드 호출했으나 실패
-
RetryListener.onError(…) -
2 ~ 3번 과정을 최대 재시도 허용 횟수까지 반복
-
리커버 메서드 실행
-
RetryListener.close(…)
{event-listener-chapter}에서 나왔던 StepBuilder.faultTolerant(), listener(RetryListener)를 호출해서 스텝을 구성하는 예제도 한번 더 돌아보자.
그런 스텝에서 참조되는 RetryListener의 메서드는 ItemProcessor, ItemWriter의 호출 전 후에 2번 호출된다는 점도 유의하자.
이 글에서는 RetyTemplate을 직접 호출하는 방식과 AOP를 통한 두 가지 방법으로 재시도와 리커버, 써킷 브레이커 정책을 적용해보았다.
AOP가 편리한 면이 있지만 트랜잭션 등 다른 AOP와 함께 적용되면 적용 순서 등도 신경 써야 한다.
재시도를 명시적인 코드로 표현하기 위해 직접 RetyTemplate을 호출하는 방법도 유용한 때가 있다.
AOP를 쓰더라도 RetyTemplate과 그와 연관된 콜백 인터페이스, 재시도 정책 구현체를 잘 알고 있으면 의도대로 정책이 적용되지 않을 때 문제를 빠르게 파악할 수도 있다.
스프링 리트라이 내부에서 재시도가 적용되는 프록시 객체를 만들어주는 RetryOperationsInterceptor, StatefulRetryOperationsInterceptor 클래스에 브레이크 포인트를 걸어서 'retryOperations' 멤버 변수에 어떤 정책이 어떤 속성으로 지정되었는지 추적해 보는 문제 해결 방법도 유용하다.
실패 후 재시도 등의 동작이 의도대로 동작하는지를 확인하는 테스트도 중요하다. 평소가 아닌 문제나 장애가 발생할 때를 대비한 코드이므로 의식적으로 실패 상황을 만들지 않는다면 제대로 동작할지 알 수 없다. 검증되지 않은 방어 코드는 실제 상황에서 오히려 문제 상황을 더 심각하게 만드는 경우도 있다. 다양한 방법으로 테스트를 해야겠지만 이 글에서 소개한 테스트용 가짜 객체를 만드는 방법도 선택지 중의 하나다.