Spring Batch : Retry 기능

    이 글은 인프런 정수원님의 강의를 복습하며 작성한 글입니다.

    Spring Batch : Retry

    Spring Batch는 Chunk 기반 Step에서 예외가 발생했을 때, 잘 동작하도록 Retry 기능을 지원해준다. 앞서 Skip 기능에 대해 정리를 했었는데, Skip 기능과 더불어 Retry 기능도 지원한다. Retry 기능은 Chunk Step을 처리하면서 Exception이 발생했을 때, 재시도를 통해서 Step이 정상적으로 성공하는 것을 도모한다. 

    한 가지 알아두어야 할 점은 Retry는 ItemReader에서는 지원하지 않는다. Retry는 ItemProcessor, ItemWriter에서만 지원한다. 따라서 ItemReader에서는 Skip 기능만 지원한다는 것을 잘 이해해두자. 

     

    Retry의 개략적인 동작 방식

     Step은 기본적으로 RepeatTemplate을 통해 실행이 된다. 실행을 할 때, Retry Template에 실행을 위임한다. 아래의 두 가지 예를 들어보자. 

    ItemProcessor에서 Item2를 처리하는 과정에서 예외가 발생한다. 이 때, Retry Templeate은 Step의 Repeat Template으로 돌아간다. 그리고 ItemReader로부터 값을 다시 요청하는 것이 아니라 캐싱해둔 Input Chunk를 가지고 와서 ItemProcessor는 Item1부터 다시 프로세싱을 시도한다. Skip과는 다르게 Retry는 Item2를 다시 시도한다. 따라서 Item2의 문제가 해결이 되지 않으면, Item2만 계속 재시도하다가 Step은 실패로 끝난다. 

    ItemWriter에서 item2를 쓰는 과정에서 예외가 발생한다. 이 때, Retry Tempalte은 Step의 Repeat Template으로 돌아간다. 즉, 처음으로 돌아가서 Input Chunk부터 다시 받아와서 Process를 다시 실행한다. 그리고 ItemWriter도 Output Chunk를 덩어리 형태로 다시 받아서 처음부터 쓰기 시작한다. Processor로부터 하나씩 Item을 전달받는 Skip과 다르게 동작한다. 

     

    Retry Template의 구조

    Retry Template은 RetryOperations의 구현체다. Retry Template을 통해서 계속 반복을 시도하게 된다. 자세한 내용은 뒷쪽에 정리될 예정이다.

    RetryTemplate은 내부적으로 RetryCallBack / RecoveryCallBack 메서드를 가진다. RetryCallBack 메서드는 사용자가 직접 사용하는 비즈니스 로직으로 이해를 할 수 있다. RecoveryCallBack 메서드는 Retry 횟수를 다 소진했을 때, Step을 정상적으로 종료하고자 할 때 사용된다. 

    정확하진 않지만 RecoveryCallBack은 크게 두 가지로 사용할 수 있는 것 같다. 만약 stepBuilderFactory에서 API를 통해 Retry Template을 구성했다면, RecoveryCallBack은 Skip 기능을 구현하는 방향으로 적용된다. 예를 들어 Retry 횟수가 다 되었으면, 다음에 접근했을 때 Skip 가능한지 확인하고 Skip을 하면서 Recovery를 해버리는 방법이다. 

    또 다른 방법은 Retry Template을 직접 구현해서 ItemProcessor나 ItemWriter에 직접 적용하는 방법이다. 이 경우에는 Recovery CallBack 함수를 직접 구현해서 전달할 수 있는데, 실패한 Item들에 대해서 기본값을 주는 객체를 만드는 식으로 처리를 해버릴 수도 있는 것 같다. 

     

    Retry의 실행 구조 

    1. Chunk Step은 기본적으로 Repeat Template을 통해 Chunk Size만큼 반복하면서 수행된다.
    2. Retry가 설정되면, RepeatTemplate은 설정되면 RetryTemplate에 ItemReader / ItemProcessor / ItemWriter를 실행을 위임한다.
    3. Retry Template은 Retry가 가능한지 확인한다. Retry가 가능한 경우 Retry Callback 함수를 호출하고, 불가능한 경우는 RecoveryCallBack 함수를 호출한다. 이 때, 결과물은 Chunk에 저장되거나 Chunk에 쓰이게 된다. 
    4. Chunk에 저장했을 때, Exception이 발생하면 RetryPolicy는 Retry가 가능한지 확인한다. Retry가 가능하면 BackOffPolicy(기다리는 정책)를 통해서 RepeatTemplate의 처음으로 돌아가서 다시 한번 RetryTemplate을 통해 Retry가 가능한지 확인한다. 
    5. Retry가 불가능한 경우에는 Step으로 돌아가서 실패로 끝이 난다. 

     

     

    Retry Policy 

    Retry Policy는 Chunk 단위로 Retry 기능을 처리할 때, 현재 발생한 Exception이 Retry 가능한지를 판단한지를 결정해준다. Retry Policy는 크게 두 가지를 따져서 Retry가 가능한지를 살펴본다.

    1. Retry가 가능한 Exception인가?
    2. Retry 횟수는 충분한가? 

    1번은 RetryPolicy 내부에 있는 Classifier 객체를 통해서 한다. Classifier는 <Throwable, Boolean>형태의 Map 저장소인데, 여기에 저장된 Exception의 Boolean값을 확인해서 반복 가능한 객체인지 판단한다. 그래서 결국 Retry Policy는 1번과 2번이 True인 경우에만 Exception을 재시작한다. 

    RetryPolicy 인터페이슨 내부적으로 canRetry, open, close, register 메서드를 가지고 있다. 주로 canRetry 메서드를 이용해서 Retry가 가능한지를 확인하고, open을 통해서 Retry와 관련된 RetryContext 정보를 가져온다. 

    Spring Batch는 다음과 같은 Retry Policy 구현체를 제공해준다. 필요한 Retry Policy를 가져다 사용하면 되고, faultToleant() API를 사용할 경우, 기본적으로 설정되는 Retry Policy는 SimpleRetryPolicy다. 

    특이한 것은 NeverRetryPolicy인데, 얘는 최초 한번만 실행을 허용하고 그 이후로는 허용하지 않는다. 왜냐하면 RetryTemplate의 동작방식인데, 처음 시도할 때도 Retry Template은 반드시 Retry를 따져보고 실행한다. 이 때, Exception이 발생하지 않았으면 그냥 평소처럼 실행이 된 것이다. 이런 이유 때문에 NenverRetryPolicy도 반드시 1회는 실행하게 된다. 

     

    BackoffPolicy

    BackOffPolicy는 Exception이 발생하고, 다시 시작하기까지 Tem을 두는 정책이다. 예를 들어 DB에서 뭔가를 가져와서 처리를 하는데 Exception이 발생했다고 해보자. 이 때, 텀을 10초 정도 주면 정상 성공이 되는데 바로 시작하면 항상 실패하는 상황이 있다고 생각해보자. 이런 경우에 BackOffPolicy로 필요한 시간을 설정해주면 그 이후로 실행할 수 있다.

     

     

    Retry API

    public Step batchStep() {
            return stepBuilderFactory.get(“batchStep")
     				.<I, O>chunk(10)
    				.reader(ItemReader)
    				.writer(ItemWriter)
                    .falutTolerant()
                    .retry(Class<? extends Throwable> type)
                    .retryLimit(int skipLimit)
                    .retryPolicy(SkipPolicy skipPolicy)
                    .noRetry(Class<? extends Throwable> type)
                    .backOffPolicy(BackOffPolicy backOffPolicy)
                    .noRollback(Class<? extends Throwable> type)
                    .build();
    }
    • Retry 설정과 관련된 API는 다음과 같다.
    • retry : Retry가 가능한 Exception을 설정함.
    • retryLimit : Retry가 가능한 횟수를 설정한다.
    • retryPolicy : Retry 정책을 설정한다. 설정하지 않을 경우 SimplRetryPolicy가 default 값으로 들어감.
    • noRetry : Retry 할 수 없는 예외를 설정한다.
    • backoffPolicy :  Retry 하기 전까지의 식나을 설정한다. (FixedBackkOffPolicy)
    • noRollBack : 예외 발생 시, RollBack 하지 않을 예외 타입을 설정함. 

     다음과 같은 형태의 API를 이용해 Retry를 손쉽게 설정할 수 있다. 

    @Bean
    public RetryPolicy myRetryPolicy() {
    
        Map<Class<? extends Throwable>, Boolean> exceptionClassifier = new HashMap<>();
        exceptionClassifier.put(MyRetryException.class, true);
        exceptionClassifier.put(MyNoRetryException.class, false);
        
        SimpleRetryPolicy simpleRetryPolicy = new SimpleRetryPolicy(2,exceptionClassifier);
        return simpleRetryPolicy;
    }

    이 때, Retry Policy를 좀 더 세분화해서 위와 같이 설정할 수도 있다. 그렇지만 나는 위 API를 사용해서 하는 것을 선호하는 편이다. 왜냐하면 너무 귀찮기 때문이다. 

     

     

    Retry 실행 코드 확인하기

    @Bean
    public Step myRetryStep() {
        return stepBuilderFactory.get("myRetryStep")
                .<String, String>chunk(10)
                .reader(myRetryReader())
                .processor(myRetryProcessor())
                .writer(myRetryWriter())
                .faultTolerant()
                .skip(MyRetryException.class)
                .skipLimit(2)
                .retry(MyRetryException.class)
                .retryLimit(2)
                .noRetry(MyNoRetryException.class)
                .build();
    }
    @Bean
    public ItemProcessor<String, String> myRetryProcessor() {
        return new ItemProcessor<String, String>() {
            @Override
            public String process(String item) throws Exception {
                System.out.println("Processor item = " + item);
                if (item.equals("1") || item.equals("2")) {
                    System.out.println(">> Error ! item = " + item);
                    throw new MyRetryException();
                }
    
                return item;
            }
        };

    나는 1~20까지 제공을 하는데, ItemProcessor 단계에서 Item이 2,3 일 때 Exception이 발생하는 코드를 작성했다. 이 때, 재시도, Skip 가능 최대 횟수는 2번씩이다. 위의 실행 과정을 하나씩 찾아가본다.

    ChunkOrientedTasklet

    ChunkOrientedTasklet의 chunkProvider로 넘어온다. 그리고 여기서 Input Chunk를 받고 ChunkProcessor.process()로 넘어간다.

    SimpleChunkProcessor

    SimpleChunkProcessor로 넘어와 transform() 메서드를 이용해서 ItemProcessor의 로직을 처리하러 넘어간다.

    FaultTolearntChunkProcessor

    FaultTolerantChankProcessor로 넘어오게 된다. 이 때, Output Chunk와 Iterator를 만든다. 이 때, Iterator는 Input Chunk의 값으로 채워진다. 

    FaultTolearntChunkProcessor

    Iterator에 Item이 있는 값을 For문으로 순회를 한다. 이 때, Item은 iterator()에서 다음값이 저장이 되고, Retry CallBack, Recovery CallBack이 만들어진다는 것이다.

    FaultTolearntChunkProcessor

    이후 batchRetryTemplate.execute()에 retryCallBack / RecoveryCallback 함수 만든 것을 넘겨주면서 실행을 한다. 

    BatchRetryTemplate

    BatchRetryTemplate은 전달받은 값을 그대로 전달해주면서 execute 해준다.

    RetryTemplate

    RetryTemplate은 전달받은 retryCallback 함수와 recoveryCallBack 함수를 전달해주면서 doExecute()를 한다. 아까보면 RepeatTemplate에서 각각의 Chunk Item을 RetryTemplate에서 수행한다고 한 지점이 이 부분이다. 

    RetryTemplate

    doExecute()로 넘어오면 먼저 retryPolicy와 backkoffPolicy를 불러온다. 그리고 open 메서드를 통해 RetryContext를 가져온다. 

    RetryTemplate.open()

    open 메서드로 넘어오면 현재 state에 있는 key값을 바탕으로 RetryContext를 만들어준다. 이 말은 Key값이 없으면 RetryContext를 만들 수 없다는 말이 된다.

    RetryTemplate.doExecute()

    RetryContext까지 만들었으면, while문에서 canRetry와 isExhuastedonly 메서드를 이용해서 Retry 가능한지 확인한다. 일단 처음 시작할 때는 Context도 텅텅 비었고, 횟수를 다 했을리도 없기 때문에 실행이 된다. 그래서 하단에 있는 retryCallBack.doWithRetry() 메서드를 통해서 실행이 된다.

    FaultToleratnChunkProcessor

    그러면 다시 FaultTolerantChunkProcessor로 넘어온다. 여기서 doProcess()로 Item을 전달시켜준다. 

    SimpleChunkProcessor

    SimpleChunkProcessor의 doProcess로 넘어온다. 이후, ItemProcessor.process()를 통해 실제로 실행을 해주고, 실행된 결과값을 Output Chunk에 넣어준다. 

    동일한 방법으로 2번째 Item을 처리하던 도중 Exception이 발생했다. 이 때, Exception은 ChunkOrientedTasklet의 Repeat Template까지 던져진다. 

    ChunkOrientedTasklet

    처음으로 돌아온 셈이다 이미 input은 기존에 캐싱된 값이 있기 때문에 chunkProvider.process()로 넘어가지는 않는다. 그리고 ChunkProcessor.process()로 넘어간다.

    FaulTolerantChunkProcessor

    다시 FaultTolerantChunkProcessor로 넘어와서 output Chunk를 다시 만든다.

    RetryContext

    RetryContext도 확인이 가능한데, 현재 Item1을 확인하고 있는 것이 보인다. 

    FaulTolerantChunkProcessor

    처음부터 다시 시작하는 것이기 때문에 1은 정상적으로 처리되는 것을 확인할 수 있다. 그렇다면 문제의 2가 다시 발생하는지를 봐야하는데...

    문제의 2에서 다시 한번 에러가 발생하는 것을 확인할 수 있었다.

    Retry Template

    RetryException Retry teample 까지 타고 올라와서 If문에서 canretry / context.isExhuastedOnly()의 심판을 받게 된다. 

    Retry Template

    결국은 handleRetryExhuasted 메서드까지 들어가게 된다.

    Retry Template

    이 메서드에서는 state, 즉 state에 어떤 key값이 있는 경우 retryContextCache에서 키 값을 삭제한다. retryContextCache는 Input Chunk의 값인데, Input Chunk에서 현재 문제가 있는 값을 삭제한다는 것이다. 즉, 바꿔 말하면 Skip 기능이 동작했다는 것이다. 

    FaultTolerantProcessor input Chunks

    실제로 FaultTolerantProcessor에서 가지고 있는 Input Chunk에서 2의 값이 사라졌다(Skip 되었다)

    SimpleChunkProcessor

    SimplChunkProcessor의 Inputs의 값도 동일하게 없어졌다. 왜냐하면 같은 객체기 때문이다. 즉, Skip이 실행되었다고 볼 수 있다. 

    2를 Skip하고 Item 3에 대해서 다시 한번 일을 하고 있다. 동일하게 RetryTeamplte에서 흐름이 되고 있고, canRetry에서 Retry 가능한지를 확인한다. 확인해보니 처음이고 아직 Exception이 없기 때문에 실행을 할 수 있다. 그런데 3에서 다시 한번 Exception이 발생했다.

    다시 한번 RepeatTemplate의 처음으로 돌아와서, 1부터 다시 시작한다. 1은 무난히 통과하고, 이제 Input Chunk에 2는 없기 때문에 바로 3으로 넘어간다. 

    3에 대한 Context는 다음과 같이 만들어진다. Count가 1이 된 것이 보인다. 아직 1이기 때문에 한번 더 시도할 수 있다는 소리다. 그래서 다시 3번에서 예외가 발생하고 처음으로 돌아와서 1부터 시작해서 3으로 돌아온다.

    이 때 3의 Context Count는 2가 된다. 

    이 때, canRetry는 True이지만, Context.isExhaustedOnly()에서 false가 발생하면서 Retry를 할 수 없게 된다. 왜냐하면 최대 시도 횟수가 2번이기 때문이다..

    그리고 다시 handleRetryExhausted로 넘어가서 Skip을 하게 된다(Skip 횟수는 2회)

     

    위 내용에서 중요한 것은 다음과 같다.

    각 Item에 대한 Retry Context가 만들어진다. 그리고 Retry Context는 각 시도 횟수를 가지고 있다. 그리고 이 시도 횟수가 끝나면 Recovery Callback을 시도한다. 즉, 모든 Item은 Retry 횟수만큼 시도가 가능하다는 이야기다. 

    Recovery Callback이 시도되면, Skip이 시도된다. 이 때, Iterator에서 현재 state에 있는 값을 빼는데, State는 key가 설정된 경우에만 확인이 가능하다. 여튼, Iterator에서 현재 Item을 빼게 되는데, Iterator는 결국 Input Chunk다. 즉, Input Chunk에서 값이 없어지기 때문에 나중에 Retry할 때도 그 값을 무시하고 하게 된다. 

    댓글

    Designed by JB FACTORY