Backend/Spring+Boot

[SpringBatch] 주의사항

findmypiece 2022. 2. 28. 03:57
728x90

트랜잭션

Reader, Processor, Writer 구조 기준 PageSize 와 ChunkSize 를 설정할 수 있을 것이다. 이 의미를 정확하게 알고 사용할 필요가 있다.

 

PageSize의 경우 여러 Reader 구현체에서 실제로 한번에 읽어올 row 단위를 의미한다. 이렇게 읽어온 row 를 Reader와 Processor 에서는 무조건 1 row씩 읽어서 처리한다.

 

Writer 에서는 ChunkSize 가 사용되는데 ChunkSize 만큼 메모리에 row 결과가 쌓이면 그걸 한번에 처리한다. 여기에서 한번에 처리한다는 것은 트랜잭션 단위를 의미한다. 즉, DB트랜잭션 Start, End 상태에 해당하는 것이다.

 

이런 이유로 ChunkSize 중에서 특정 row를 별도로 CUD 작업 하고 싶다면 아래 설정을 통해 새로운 트랜잭션에서 처리되도록 해야 한다.

@Transactional(propagation = Propagation.REQUIRES_NEW)

 

여기에서 먼저 생기는 궁금증은 만약 PageSize 가 10 이고 ChunkSize 가 3일 경우 처리결과가 3개 row가 쌓일 때 마다 Writer 가 수행되고 commit 이 이뤄질텐데 마지막에 남은 1개 row는 어떻게 되나? 

 

테스트를 해보진 않았지만 아마도 1개 row에 대한 commit 이 누락되도록 허술하게 만들지는 않았을 것이다. Writer 당시에는 ChunkSize 에 도달하지 않아 처리를 하지 않겠지만 다음번 Reader 에서 더 이상 데이터가 없을 경우 기존에 쌓여있는 처리결과는 commit 되도록 구현되어 있지 않을까 싶다.

 

Skip

SpringBatch 에서는 기본적으로 Exception 발생시 배치 시스템 자체가 종료된다. 하지만 대부분의 경우 일부가 실패하더라도 나머지는 그대로 진행되길 원하고 이 경우 skip 기능을 활용할 수 있다.

 

skip 적용은 그냥 skip 처리할 Exception 을 지정하면 되는데 skip을 처리 방식이 우리가 기대하는 동작과는 다소 차이가 있기 때문에 주의가 필요하다.

 

chunk size가 10 이고 Writer에서 3번 대상을 처리하던 중 Exception이 발생해서 이를 skip 처리했다고 가정해보자. 그럼 바로 4번이 수행되길 기대하지만 실제로는 아래와 같이 동작한다.

 

  1. 수행됐던 Processor, Writer 내 DB 작업을 모두 rollback 한다.
  2. 일시적으로 commit-interval 을 1로 변경한다.
  3. Writer 로 chunk 사이즈 만큼 전달받은 요청을 1개씩 Processor->Writer 처리를 반복한다.
  4. commit-interval 1 이기 때문에 각각 commit 이 수행된다.
  5. 최초 skip 이 발생했던 작업은 다시 실패하거나 성공할 수 있다.

 

동작과정은 특이하지만 Processor, Writer 에 DB작업만 있다면 문제가 될 것은 없다. 하지만 post/put api call 이 포함되어 있다면 중복으로 호출되는 문제가 있고 경우에 따라 Processor 에 무거운 작업이 포함되어 있는 경우 해당 작업을 rollback 하고 다시 처리하는 일 자체가 부담스러울 때가 있다.

 

Processor 부터 재처리 되는 것은 아래와 같이 Step 에 processorNonTransactional() 를 지정하면 해결된다. 이는 Processor 에 트랜잭션 작업이 없다고 명시하는 것으로 SpringBatch 입장에서는 롤백된 것이 없을테니 재처리를 하지 않게 된다.

 

다만 이렇게 했는데 실제로 Processor 에 DB 관련 작업이 포함되어 있다면 rollback 될 수 있기 때문에 noRollback(InmsSkipException::class.java) 지정을 통해 트랜잭션이 rollback 되지 않도록 해야 한다.

 

@Bean
@JobScope
fun albumSubPushStep(): Step {
    return stepBuilderFactory[STEP_NAME]
        .chunk<AlbumSubArtistInfo.AlbumSubArtist, AlbumSubArtistInfo.AlbumSubArtist>(CHUNK_SIZE)
        .reader(albumSubPushReader(null))
        .processor(albumSubPushProcessor(null))
        .writer(albumSubPushWriter(null))
        .faultTolerant()
        .skip(InmsSkipException::class.java)
        .skipLimit(SKIP_LIMIT)
        .processorNonTransactional()
        .noRollback(InmsSkipException::class.java)
        .build()
}

이렇게 해서 Processor 에 api call 이 포함되어 있는 경우 중복처리 될 수 있는 문제도 함께 해결되었다. 하지만 noRollback(InmsSkipException::class.java) 에 의해 Writer 작업이 중복수행 되는 문제는 생긴다. 

 

이는 아래와 같이 Writer 에서 처리되는 작업을 구분할 수 있는 유니크키를 별도 전역변수로 저장해서 SpringBatch skip 에 의해 재처리될 경우 명시적으로 필터링하는 방법을 사용하면 된다. 

@Bean
@StepScope
fun albumSubPushWriter(@Value("#{jobParameters[requestDate]}") requestDate: String?): ItemWriter<AlbumSubArtistInfo.AlbumSubArtist> {
    return ItemWriter {

        it.filter { albumSubArtistInfo ->
            !writerCompleteArtistId.contains(albumSubArtistInfo.artistId)
        }.forEach { albumSubArtistInfo ->

            writerCompleteArtistId.add(albumSubArtistInfo.artistId)
    ...

 

 

한가지 문제는 이렇게 할 경우 하나의 Writer 작업은 일관되지 않은 상태에 놓일 수 있다는 것이다. 예를 들어 10개의 Writer 작업이 있고 그 안에 5개의 DB 또는 api 작업이 포함되어 있다고 가정해보자.

 

2번 Writer 에서 Exception 이 발생했고 skip 처리가 적용되면서 commit-interval 1 로 Writer 1번부터 다시 수행될 것이다. 1번 Writer는 위에 적용한 필터로직에 의해 skip 될 것이고 2번 Writer 의 3번 작업에서 다시 Exception 이 발생했다면 2번 Writer 는 1, 2번 작업만 수행된 상태가 될 수 있는 것이다.

 

이런 이유로 사실 가장 확실한 방법은 DB 롤백시 api call 역시 복구하는 것인데 이는 복구 api를 직접 호출해줘야 하기 때문에 그 비용 또한 결코 만만치 않다.

 

물론 MSA 환경에서는 일시적으로 비정합 데이터가 존재하더라도 최종 일관성만 유지하면 되는 것이 허용되기 때문에 별도의 메시지큐를 통해 복구 api 를 호출하는 것도 방법이긴하지만 푸시 메시지의 경우 이미 발송되었다면 이를 취소할 방법은 없다.

 

여기에서 말하는 skip 처리 구현방식은 기본적으로 retry를 사용하지 않을 때 기준이다. 만약 retry 정책도 함께 사용한다면 마지막에 발생된 Exception 역시 전역 변수에 보관해서 Processor, Writer 시작시 무조건 중복을 skip 하지 말고 retry 에 정책에 부합하는 경우 재시도 되도록 해야 한다.

 

하지만 DB로직만 존재하는 경우가 아니라면 왠만하면 SpringBatch 의 retry 로직은 사용하지 않는게 좋다는 입장이다. 사용할 경우 api 콜이 중복으로 발생할 수 있기 때문이다. 한다면 할 수 있겠지만 이 경우 api call 을 취소하는 로직도 반드시 필요한다. 이 경우 복잡도가 너무 높아진다.

 

네트워크 장애를 고려해서 반드시 retry를 도입하고 싶다면 http client 에 제한적으로 적용하자.

 

또한 단순히 특정 작업을 skip 하고 싶다면 SpringBatch 에서 제공하는 Skip 이 아니라 그냥 임의의 Exception을 발생시키고 try ... catch 로 잡으면 되지 않을까라고 생각할 수도 있다.

 

SpringBatch 까지 Exception 이 throw 되지만 않으면 다음 로직을 수행할 수 있으니 이 방법도 가능하긴 하지만 이는 SpringBatch 에서 제공하는 Skip과는 분명한 차이가 있다.

 

Reader, Processor, Writer 각 단계에서 Exception 발생 -> try ... catch 방식으로 특정 작업을 skip 할 경우 말그대로 단순히 skip 만하고 해당 자원이 다음 단계로 넘어간다.

 

예를 들어 Processor 에서 세번째 작업에 대해 위와 같이 skip을 했다면 skip되고 다음 작업을 수행하긴 하지만 Writer로 해당 작업이 넘어가고 chunk 에도 포함된다.

 

하지만 SpringBatch 에서 제공하는 Skip 을 사용할 경우 skip도 되고 다음단계로 해당 작업이 넘어가지 않는다.

 

Retry

모든 예외는 일시적일 수 있다. 이에 우리는 기본적으로 재시도 로직을 사용하고 SpringBatch 에서도 이러한 기능을 자체적으로 제공한다. 하지만 위의 skip 로직처럼 우리가 일반적으로 이해하는데로 동작하지 않는 경우가 있기 때문에 역시 주의가 필요하다.

 

일단 Reader 에서는 retry 가 동작하지 않기 때문에 원한다면 이를 직접 구현해야 한다. 그리고 Processor 에서 retry 발생시 해당 row의 Processor 가 재시도 되지만 Writer 에서 retry 발생시 skip 에서와 동일하게 해당 row가 Processor 부터 재시도 된다.

 

Reader 에서 retry의 경우 일반적인 경우 필요가 없지만 api 같이 단일 endpoint 를 1번 호출할 경우 네트워크 장애 등을 고려해서 필요할 수 있는데 이 경우 "ItemReader는 null 리턴하거나 Exception 이 발생될 때 때까지 반복된다" 특징을 이용해서 직접 구현하면 된다.

 

또한 skip 과 마찬가지로 Processor 부터 retry 된다는 점도 유의해야 한다. 하지만 DB로직만 존재하는 경우가 아니라면 왠만하면 SpringBatch 의 retry 로직은 사용하지 않는게 좋다는 입장이다. 사용할 경우 api 콜이 중복으로 발생할 수 있기 때문이다.

 

한다면 할 수 있겠지만 이 경우 api call 을 취소하는 로직도 반드시 필요한다. 이 경우 복잡도가 너무 높아진다. 네트워크 장애를 고려해서 반드시 retry를 도입하고 싶다면 http client 에 제한적으로 적용하자.

 

https://mayaul.github.io/spring-batch-skip-error/
https://stackoverflow.com/questions/56170179/retry-not-working-with-spring-batch-with-java-config\
https://ojt90902.tistory.com/802
https://sheerheart.tistory.com/entry/Spring-Batch-skip-로직-동작-방식

 

728x90