토리맘의 한글라이즈 프로젝트 logo 토리맘의 한글라이즈 프로젝트

스프링 배치 공식 레퍼런스를 한글로 번역한 문서입니다.

전체 목차는 여기에 있습니다.

목차


실패하더라도 몇 번 더 시도하면 성공할 수 있는 작업은, 자동으로 다시 실행하면 좀 더 견고하고 실패하지 않는 프로그램을 만들 수 있다. 간헐적인 실패는 보통 일시적이다. 예제에서는 네트워크 결함이나 데이터베이스 업데이트 중 DeadlockLoserDataAccessException으로 실패할 수 있는 웹 서비스를 호출한다.


9.1. RetryTemplate

재시도 기능은 스프링 배치 2.2.0 버전부터 제외됐다. 현재는 Spring Retry 라이브러리에 포함되어 있다.

스프링 배치는 자동으로 재시도해주는 RetryOperations 전략이 있다. RetryOperations 인터페이스는 다음과 같다:

public interface RetryOperations {

    <T, E extends Throwable> T execute(RetryCallback<T, E> retryCallback) throws E;

    <T, E extends Throwable> T execute(RetryCallback<T, E> retryCallback, RecoveryCallback<T> recoveryCallback)
        throws E;

    <T, E extends Throwable> T execute(RetryCallback<T, E> retryCallback, RetryState retryState)
        throws E, ExhaustedRetryException;

    <T, E extends Throwable> T execute(RetryCallback<T, E> retryCallback, RecoveryCallback<T> recoveryCallback,
        RetryState retryState) throws E;

}

콜백 인터페이스에 재시도를 위한 비지니스 로직을 넣을 수 있다. 콜백 인터페이스 정의는 다음과 같다:

public interface RetryCallback<T, E extends Throwable> {

    T doWithRetry(RetryContext context) throws E;

}

실패하면 (Exception이 발생함으로써) 콜백이 실행되고, 성공하거나 혹은 콜백 구현체가 그만둘 때까지 재시도한다. RetryOperations 인터페이스에는 오버로드할 execute 메소드가 많이 있다. 이 메소드로 더 이상 재시도하지 못할 때를 위한 다양한 복구 로직과, 클라이언트와 구현체에서 필요한 각 호출 상태를 담고 있는 retry state를 처리한다 (자세한 내용은 이 챕터 뒤에서 다룬다).

RetryTemplateRetryOperations의 가장 간단한 구현체다. 아래처럼 사용할 수 있다:

RetryTemplate template = new RetryTemplate();

TimeoutRetryPolicy policy = new TimeoutRetryPolicy();
policy.setTimeout(30000L);

template.setRetryPolicy(policy);

Foo result = template.execute(new RetryCallback<Foo>() {

    public Foo doWithRetry(RetryContext context) {
        // Do stuff that might fail, e.g. webservice operation
        return result;
    }

});

위 예제는 웹 서비스를 호출해 결과를 돌려준다. 웹 서비스 호출에 실패하면 타임아웃에 도달할 때까지 재시도한다.

9.1.1. RetryContext

RetryCallback은 메소드 파라미터로 RetryContext를 받는다. 컨텍스트를 무시하는 콜백도 많지만, 원한다면 실행을 반복하는 동안 필요한 데이터를 컨텍스트 속성에 저장할 수도 있다.

같은 쓰레드에서 실행한 retry 블록이 중첩돼 있다면 RetryContext는 부모 컨텍스트를 가진다. 각 실행에서 공유할 데이터를 부모 컨텍스트에 저장하는 경우도 있다.

9.1.2. RecoveryCallback

더 이상 재시도할 수 없으면 RetryOperationsRecoveryCallback이라는 다른 콜백에 제어를 넘긴다. 이 기능을 사용하려면 아래 예제처럼 같은 메소드에 콜백을 함께 넘겨야 한다:

Foo foo = template.execute(new RetryCallback<Foo>() {
    public Foo doWithRetry(RetryContext context) {
        // business logic here
    },
  new RecoveryCallback<Foo>() {
    Foo recover(RetryContext context) throws Exception {
          // recover logic here
    }
});

템플릿이 재시도를 그만둘 때까지 비지니스 로직을 성공하지 못하면 recovery 콜백으로 다른 처리를 할 수 있다.

9.1.3. Stateless Retry

가장 간단한 재시도는 단순한 while 루프다. 최종적으로 성공하거나 실패할 때까지 RetryTemplate으로 단순히 실행을 반복할 수 있다. 재시도할지 중단할지 결정하기 위한 상태를 RetryContext에 저장하긴 하지만, 이 상태는 스택에 있고 전역적으로 저장할 필요가 없으므로 이를 상태가 없는(stateless) 재시도라고 부른다. RetryPolicy 구현체에 따라 상태가 없는(stateless) 재시도와 상태가 있는(stateful) 재시도로 나뉜다. (RetryTemplate은 둘 다 처리할 수 있다). 상태가 없는(stateless) 재시도는 항상 실패했을 때와 동일한 쓰레드에서 retry 콜백을 실행한다.

9.1.4. Stateful Retry

실패로 인해 트랜잭션이 망가질 수 있다면 다른 점을 함께 고려해야 한다. 원격 호출은 트랜잭션 리소스가 없기 때문에(일반적으로) 논외지만, 데이터베이스에 업데이트하는 경우라면, 특히 하이버네이트를 사용한다면 더 주의해야 한다. 이럴 때는 실패를 유발한 exception을 즉시 다시 던져야 트랜잭션을 롤백하고 유효한 새 트랜잭션을 시작할 수 있다.

트랜잭션과 묶여 있어서 예외를 다시 던지고 롤백한다면, RetryOperations.execute()에서 나와 더 이상 스택에 있던 컨텍스트에 접근할 수 없기 때문에 상태가 없는(stateless) 재시도로는 해결할 수 없다. 이를 방지하기 위해 스택을 벗어나 (최소한) 힙에 저장할 수 있는 스토리지 전략을 소개하겠다. 스프링 배치가 제공하는 스토리지 전략은 RetryContextCache로, RetryTemplate에 주입할 수 있다. RetryContextCache의 디폴트 구현체는 간단한 Map을 사용해서 메모리에 저장한다. 클러스터 환경에서 멀티 프로세스를 사용하고 있다면 일종의 클러스터 캐시를 사용해서 RetryContextCache를 구현하는 것을 고려해 보라 (하지만 클러스터 환경이라고 해도 이 기능은 과할 수 있다).

RetryOperations 책임 중 하나는 돌아와서 다시 실행할 때 (보통 새 트랜잭션으로 감싸져 있다) 실패한 명령을 찾아내는 것이다. 스프링 배치는 이를 위한 RetryState 인터페이스를 제공한다. RetryOperations 인터페이스에는 이 RetryState를 받는 execute 메소드가 있다.

어떤 작업이 실패했는지는 각 재시도의 상태를 보고 알 수 있다. 이 상태를 식별하려면 유니크 키를 리턴하는 RetryState가 필요하다. RetryContextCache 인터페이스에서 이 식별자를 키로 사용한다.

RetryState가 리턴하는 키에서 Object.equals() 메소드와 Object.hashCode()를 구현한다면 매우 조심해야 한다. item을 식별할 수 있는 비지니스 키를 사용하는 것이 가장 좋다. JMS 메세지라면 메세지 ID를 사용할 수 있다.

더 이상 재시도하지 못한다면 RetryCallback을 호출하는 대신 (이제는 더 호출해도 실패할 것으로 예상되는), 다른 방법으로 실패한 아이템을 처리할 수도 있다. 상태가 없는(stateless) 재시도와 마찬가지로 RetryOperationsexecute 메소드에 넘겨주는 RecoveryCallback으로 구현한다.

사실상 재시도 결정은 보통 RetryPolicy에 위임하므로, 일반적인 제한이나 타임아웃은 RetryPolicy에 주입할 수 있다 (이 챕터 뒤에서 설명한다).


9.2. Retry Policies

RetryTemplateexecute 메소드에서 재시도할지 실패로 처리할지는 RetryContext의 팩토리이기도 한 RetryPolicy가 결정한다. RetryTemplate의 책임은 현재 policy로 RetryContext를 생성해 매 시도마다 RetryCallback에 전달하는 것이다. 콜백이 실패하면 RetryTemplateRetryPolicy를 호출해 상태(RetryContext에 저장된) 변경을 요청하고 한 번 더 시도 할지 물어봐야 한다. policy는 더 이상 재시도할 수 없을 때(limit에 도달하거나 타임아웃 난 경우) 필요한 처리도 담당한다. 간단한 구현체는 RetryExhaustedException을 던져 감싸고 있는 모든 트랜잭션을 롤백시킨다. 더 정교하게 구현하면 트랜잭션을 유지한 채 복구 액션을 시도할 수도 있다.

재시도로 해결되는 실패인지 아닌지는 이미 정해져 있다. 비지니스 로직에서 같은 exception이 계속 발생한다면 재시도할 필요 없다. 따라서 모든 예외에서 재시도하지 마라. 그보단 재시도할 수 있는 예외에만 초점을 둬라. 재시도를 많이 하는 게 일반적으로 비즈니스 로직에 해가 되진 않지만, 항상 실패한다면, 이미 해결할 수 없는 걸 알고 있는데도 재시도하는 것이기 때문에 시간 낭비다.

스프링 배치는 SimpleRetryPolicy, TimeoutRetryPolicy(앞의 예제에서 사용했다)같은 상태가 없는(stateless) RetryPolicy 구현체를 제공한다.

SimpleRetryPolicy는 알고 있는 예외일 때만 고정된 횟수만큼 재시도한다. 절대 재시도하면 안 되는 “fatal” exception 리스트도 지원하며, 이 리스트는 재시도할 리스트보다 우선시되기 때문에 아래 예제처럼 좀 더 정교하게 설정할 수 있다:

SimpleRetryPolicy policy = new SimpleRetryPolicy();
// Set the max retry attempts
policy.setMaxAttempts(5);
// Retry on all exceptions (this is the default)
policy.setRetryableExceptions(new Class[] {Exception.class});
// ... but never retry IllegalStateException
policy.setFatalExceptions(new Class[] {IllegalStateException.class});

// Use the policy...
RetryTemplate template = new RetryTemplate();
template.setRetryPolicy(policy);
template.execute(new RetryCallback<Foo>() {
    public Foo doWithRetry(RetryContext context) {
        // business logic here
    }
});

구현체 중에는 ExceptionClassifier 인터페이스를 통해 exception마다 다른 재시도 로직을 지원하는, 좀 더 유연한 ExceptionClassifierRetryPolicy도 있다. 이 policy는 classifier를 호출해 exception을 위임할 RetryPolicy로 변환한다. 예를 들어 특정 exception에 다른 정책을 매핑해서, 실패했을 때 다른 exception보다 더 많이 재시도해볼 수 있다.

좀 더 세분화해서 결정하려면 자체 retry policy를 구현해야 한다. 예를 들어 커스텀 retry policy로 해결 방법이 명확한 특정 예외를 따로 분류하고, 재시도하거나 혹은 재시도하지 않도록 만들 수 있다.


9.3. Backoff Policies

실패가 일시적이었다면 보통 기다려야만 해결될 때가 많으므로 재시도 전 잠시 기다리는 것도 좋은 방법이다. RetryCallback이 실패하면 RetryTemplateBackoffPolicy에 따라 잠시 실행을 중단시킨다.

BackOffPolicy 인터페이스 정의는 다음과 같다:

public interface BackoffPolicy {

    BackOffContext start(RetryContext context);

    void backOff(BackOffContext backOffContext)
        throws BackOffInterruptedException;

}

BackoffPolicy의 backOff 메소드를 원하는 방식대로 구현하면 된다. 스프링 배치가 제공하는 모든 policy는 Object.wait()를 사용한다. 여러 번의 재시도로 step이 지연되고 전부 실패하지 않도록 보통은 재시도할 때마다 매번 더 많은 시간을 기다린다 (이건 이더넷에서 얻은 교훈이다). 스프링 배치는 이를 위한 ExponentialBackoffPolicy를 제공한다.


9.4. Listeners

가끔은 재시도할 때마다 횡단 관심사(cross cutting concerns)에서도 추가로 콜백을 받고 싶을 수 있다. 스프링 배치는 이를 위한 RetryListener 인터페이스를 지원한다. RetryTemplateRetryListeners를 등록하면 매번 RetryContext, Throwable과 함께 콜백 받을 수 있다.

RetryListener 인터페이스 정의는 다음과 같다:

public interface RetryListener {

    <T, E extends Throwable> boolean open(RetryContext context, RetryCallback<T, E> callback);

    <T, E extends Throwable> void onError(RetryContext context, RetryCallback<T, E> callback, Throwable throwable);

    <T, E extends Throwable> void close(RetryContext context, RetryCallback<T, E> callback, Throwable throwable);
}

가장 간단하게는 모든 재시도 전후에 open, close로 콜백 받고 onErrorRetryCallback을 사용 할 매다다 호출한다. 에러가 발생한 경우에 closeRetryCallback이 마지막으로 던진 Throwable을 받는다.


9.5. Declarative Retry

때로는 매번 재시도해야 하는 비지니스 로직도 있다. 외부 서비스를 호출하는 게 가장 대표적인 예시다. 스프링 배치는 이럴 때 사용할, 메소드 호출을 AOP 인터셉터로 감싸고 있는 RetryOperations 구현체를 지원한다. RetryOperationsInterceptor는 메소드 호출을 가로채서 RetryTemplate에 제공한 RetryPolicy에 따라 재시도한다.

아래 예제는 자바 기반 설정을 사용해서 재시도할 때마다 remoteCall 메소드로 서비스를 호출하게 설정했다 (AOP 인터페이스를 설정에 대한 자세한 정보는 Spring User Guide를 참고하라):

@Bean
public MyService myService() {
	ProxyFactory factory = new ProxyFactory(RepeatOperations.class.getClassLoader());
	factory.setInterfaces(MyService.class);
	factory.setTarget(new MyService());

	MyService service = (MyService) factory.getProxy();
	JdkRegexpMethodPointcut pointcut = new JdkRegexpMethodPointcut();
	pointcut.setPatterns(".*remoteCall.*");

	RetryOperationsInterceptor interceptor = new RetryOperationsInterceptor();

	((Advised) service).addAdvisor(new DefaultPointcutAdvisor(pointcut, interceptor));

	return service;
}

위에 있는 인터셉터는 내부에서 디폴트 RetryTemplate을 사용한다. policy나 listener를 바꾸고 싶으면 RetryTemplate 인스턴스를 인터셉터에 주입하면 된다.


전체 목차는 여기에 있습니다.

<< >>