배치 작업시 예외가 발생한다면 어떻게 처리할까?

반응형
만약 외부 API의 스펙이 변경된다면 어떻게 처리해야 할까요? 이 경우에도 skip을 해야 할까요, 아니면 retry를 해야 할까요?

외부 API 장애라고 해서 모두 같은 방식으로 처리하면 안 됩니다. 예를 들어 특정 row의 값이 잘못되었거나 필수 데이터가 누락된 경우라면, 이는 개별 데이터 품질 문제로 보고 skip 처리를 고려할 수 있습니다. 반면 외부 API의 응답 구조가 변경된 경우는 다르게 봐야 합니다. 이는 단순히 특정 데이터 하나가 잘못된 것이 아니라, 배치가 기대하던 계약이나 제약 자체가 깨진 상황입니다. 이런 경우까지 skip해버리면 전체 데이터가 잘못 처리되거나, 문제를 늦게 발견할 수 있습니다. 또한 네트워크 타임아웃이나 5XX 오류처럼 일시적인 장애는 retry 대상이 될 수 있습니다. 하지만 API 스펙 변경은 retry를 한다고 해결되는 문제가 아닙니다. 구조 자체가 달라졌기 때문입니다. 따라서 응답 구조 변경이 감지되면 skip이나 retry보다는 배치를 실패시키고, 빠르게 문제를 감지할 수 있도록 처리하는 것이 더 안전합니다.

어째서 process단계에서 skip or retry를 하는가?

배치 작업은 크게 읽기(Read) → 처리(Process) → 쓰기(Write) 단계로 구분할 수 있습니다. 이 중에서 skip이나 retry 같은 예외 처리 전략이 가장 중요하게 적용되는 구간은 처리(Process) 단계입니다. 그 이유는 단순합니다. 데이터를 읽거나 저장하는 것도 중요하지만, 실제 비즈니스 로직과 데이터 변환이 수행되는 중심 영역이 바로 처리 단계이기 때문입니다. 예를 들어 특정 row의 데이터가 잘못되었거나 필수 값이 누락된 경우에는 처리 단계에서 이를 검증하고 skip 여부를 결정할 수 있습니다. 또한 일시적인 네트워크 장애나 외부 API 호출 실패가 발생했다면 retry 전략 역시 이 과정에서 고려될 수 있습니다. 반면 외부 API의 응답 구조 자체가 변경된 경우는 다르게 봐야 합니다. 이는 단순 데이터 오류가 아니라, 처리 로직이 기대하던 계약 자체가 깨진 상황입니다. 이런 경우까지 skip하거나 무의미하게 retry를 반복하면 문제를 숨긴 채 잘못된 데이터가 계속 처리될 수 있습니다. 따라서 처리 단계에서는 예외를 단순히 "실패"로만 보는 것이 아니라, 어떤 종류의 실패인지 구분하여 skip, retry, fail-fast 전략을 다르게 가져가는 것이 중요합니다.

먼저 커스텀 exception을 준비해줍니다.

사실 커스텀 Exception은 반드시 만들어야 하는 것은 아닙니다. 기존 자바에서 제공하는 Exception만으로도 충분히 예외 처리는 가능합니다. 그럼에도 불구하고 커스텀 Exception을 만드는 이유는 단순합니다. 예외가 발생했을 때 상황을 더 명확하게 구분하고, 보다 간단하게 처리하기 위함입니다. 예를 들어 IllegalArgumentException, RuntimeException만 사용하게 되면 현재 발생한 문제가 비즈니스 오류인지, 외부 API 문제인지, 일시적인 장애인지 구분하기 어려워질 수 있습니다.

반면 커스텀 Exception을 사용하면 예외 자체에 의미를 부여할 수 있습니다.

  • InvalidCouponException
  • ExternalApiTimeoutException
  • ResponseSpecChangedException

이처럼 예외를 목적에 따라 분리하면 catch 구문이나 예외 처리 정책에서도 의도를 명확하게 표현할 수 있습니다.

결국 핵심은 단순합니다. 커스텀 Exception의 목적은 "새로운 기능 추가"가 아니라,
예외 상황을 명확하게 분리하고 처리 흐름을 단순하게 만들기 위함입니다.

만약 커스텀 Exception을 사용하지 않는다면, 예외를 구분하기 위한 조건문이 많아지면서 처리 로직이 복잡해질 수 있습니다.
결과적으로 catch 구문 내부가 점점 비대해지고, 코드 역시 읽기 어려워질 가능성이 높아집니다. 예를 들어 메시지 문자열이나 상태값으로 예외를 구분하기 시작하면, 예외 처리의 책임이 분산되고 유지보수도 어려워질 수 있습니다. 반면 커스텀 Exception을 사용하면 예외 자체에 의미를 담을 수 있기 때문에, 어떤 상황에서 발생한 문제인지 명확하게 표현할 수 있습니다. 또한 예외 타입 기준으로 처리 전략을 나눌 수 있어 코드 흐름도 훨씬 단순해집니다.

즉 핵심은 단순합니다. 커스텀 Exception은 단순히 예외 클래스를 늘리는 것이 아니라, 복잡한 예외 처리 로직을 역할별로 분리하여 코드의 가독성과 유지보수성을 높이기 위한 방법입니다.

그러면 어떤 정보를 skip할까?

중요한 점은 커스텀 Exception을 만들었다고 해서 비즈니스 로직이 사라지는 것은 아니라는 것입니다.

결국 skip 여부를 결정하는 기준은 Exception 클래스가 아니라, 비즈니스적으로 "이 데이터를 버려도 되는가"에 대한 판단입니다.
예를 들어 특정 row의 필수 값이 누락되었거나, 이미 잘못된 데이터라고 판단되는 경우에는 해당 데이터만 skip하고 다음 작업을 이어갈 수 있습니다. 이런 경우는 개별 데이터 문제이기 때문에 전체 배치를 중단할 필요는 없을 수 있습니다. 

이런 경우는 해당 row 자체의 문제이기 때문에 무시하고 넘어갈 수 있습니다. 하지만 네트워크 이슈로 예외가 발생한 경우는 다르게 봐야 합니다. 이 경우에도 skip을 해야 할까요? 물론 skip을 한다고 해서 배치 자체가 바로 실패하지는 않을 수 있습니다. 하지만 중요한 점은, 이 데이터는 잘못된 데이터가 아니라 정상 데이터라는 것입니다. 단지 외부 API 호출이나 네트워크 문제로 인해 일시적으로 처리하지 못했을 뿐입니다. 그런데 이를 skip해버리면 정상 데이터를 처리하지 않고 넘어간 것이 됩니다. 즉, 데이터 누락이 발생할 수 있습니다.

따라서 네트워크 타임아웃이나 일시적인 5XX 오류처럼 복구 가능성이 있는 장애라면 skip보다는 retry를 먼저 고려하는 것이 안전합니다.
retry 후에도 계속 실패한다면, 별도의 실패 이력으로 남기거나 배치를 실패시켜 후속 조치가 가능하도록 만들어야 합니다.

이번에는 skip이 아니라 retry를 적용해봅시다.

대부분의 retry 대상은 비즈니스 예외가 아니라 네트워크 이슈입니다. 그렇기 때문에 커스텀 Exception의 위치도 다르게 가져가는 것이 좋습니다. 예를 들어 skip 대상 Exception은 처리 단계에서 발생하는 데이터 검증 실패나 비즈니스 규칙 위반에 가깝기 때문에 process 하위 패키지에 둘 수 있습니다.

하지만 retry 대상 Exception은 성격이 다릅니다. 네트워크 타임아웃, 일시적인 5XX 응답, 외부 API 호출 실패처럼 외부 시스템과의 통신 과정에서 발생하는 예외에 가깝습니다. 따라서 이를 비즈니스 예외와 같은 위치에 두면 예외의 의미가 섞일 수 있습니다.

만약 같은 위치에서 관리하고 싶다면, exception 패키지를 별도로 만들고 그 안에서 skip과 retry에 사용되는 예외를 함께 관리하는 것이 더 적절하다고 생각합니다.

retry Exception은 비즈니스 예외가 아니라 외부 통신 과정에서 발생하는 예외에 가깝습니다. 따라서 이를 process 내부의 비즈니스 코드에서 직접 사용하는 것은 적절하지 않습니다.

그래서 저는 Mapper 클래스를 만들어 적용하기로 했습니다.

retry 대상 예외는 비즈니스 예외가 아니라 외부 통신 과정에서 발생하는 예외에 가깝습니다. 따라서 이를 process 내부의 비즈니스 코드에 직접 넣기보다는, 외부 API 응답이나 예외를 배치에서 사용할 수 있는 형태로 변환하는 계층이 필요하다고 판단했습니다.

이렇게 구성해두면 외부에서 어떤 예외가 발생했는지와 관계없이, 내부에서는 일관된 방식으로 처리할 수 있습니다.
즉, 외부 라이브러리나 API의 예외에 직접 의존하는 것이 아니라,
내부에서 정의한 예외 기준으로 retry나 실패 정책을 적용할 수 있게 됩니다. 또한 해당 코드는 상태를 가지는 객체가 아니라 단순히 예외를 변환하는 역할만 수행합니다. 즉, 특정 값을 저장하거나 관리하지 않고 입력값을 기반으로 결과만 반환하는 유틸성 메소드에 가깝습니다. 그래서 저는 이 코드를 static으로 구성했습니다. 굳이 Spring Bean으로 등록해서 관리할 필요가 없다고 판단했기 때문입니다.

둘 다 해당되지 않는 경우는?

여기서 한 가지 의문이 듭니다. skip도 적용했고, retry도 적용했습니다. 그렇다면 두 가지에 모두 해당하지 않는 예외는 어떻게 처리해야 할까요? 이 경우에는 무리하게 skip이나 retry 대상으로 포함시키면 안 됩니다.
skip은 "해당 데이터를 버려도 되는 경우"에 사용하고, retry는 "다시 시도하면 성공할 가능성이 있는 경우"에 사용합니다.

반대로 두 조건에 모두 해당하지 않는 예외라면, 이는 배치가 계속 진행해도 안전한지 판단하기 어려운 예외입니다.

따라서 이런 예외는 기본적으로 배치를 실패시키는 것이 더 안전합니다.
예상하지 못한 예외를 억지로 무시하면 데이터 누락이나 잘못된 처리로 이어질 수 있기 때문입니다.

즉 핵심은 단순합니다. 결국 배치에서는 "처리해도 되는 실패"와 "멈춰야 하는 실패"를 구분하는 것이 중요합니다.
위 서두에서 말한 외부 API 스펙 변경이 바로 여기에 해당됩니다. 스펙 변경은 특정 row의 데이터 품질 문제가 아니기 때문에 skip 대상이 아닙니다. 또한 네트워크 타임아웃이나 5XX처럼 일시적인 장애도 아니기 때문에 retry 대상도 아닙니다.

즉, 다시 시도한다고 해결되지 않고, 무시하고 넘어가도 안 되는 예외입니다. 이런 경우에는 배치를 계속 진행시키는 것보다 실패시키는 것이 더 안전합니다.
왜냐하면 배치가 기대하던 응답 구조와 실제 응답 구조가 달라졌다는 것은, 이후 처리 결과를 신뢰하기 어렵다는 의미이기 때문입니다. 따라서 외부 API 스펙 변경처럼 skip과 retry 어디에도 해당하지 않는 예외는 fail 처리하여 빠르게 감지할 수 있도록 해야 합니다.

마무리

이 글에서 학습한 핵심은 실패를 어떻게 관리할 것인가에 있습니다. 배치에서 발생하는 모든 예외를 동일하게 처리해서는 안 됩니다. 무조건 실패시키는 것이 정답도 아니고, 반대로 모든 예외를 skip하거나 retry하는 것도 올바른 방식은 아닙니다. 어떤 예외는 무시해도 되는 데이터 품질 문제일 수 있습니다. 반면 어떤 예외는 일시적인 네트워크 장애이기 때문에 재시도를 통해 복구할 수 있습니다. 그리고 외부 API 스펙 변경처럼 시스템의 계약 자체가 깨진 경우라면, 빠르게 실패를 감지하고 배치를 중단하는 것이 더 안전할 수 있습니다. 결국 안정적인 프로젝트를 구성하기 위해서는 “성공 로직”만큼이나 “실패를 어떻게 관리할 것인가”에 대한 설계 역시 중요하다고 생각합니다.

반응형

댓글

Designed by JB FACTORY