예외 종류별 처리 표준은 벌크·배치 경로(웹 요청이 아니라 배치 작업이 DB의 행을 한꺼번에 바꾸는 코드 흐름)에서 예외를 번역하지 않기로 하면서, 대신 로그를 남길 자리를 이렇게 정했다.

벌크 경로의 로그를 StepExecutionListener.afterStep에서 getFailureExceptions로 남기면
실패가 한 곳에서 한 번 남는다.

Spring Batch는 배치 작업 하나(Job)를 여러 단계(Step)로 나눠 돌린다. StepExecutionListener는 그 Step 하나가 끝난 뒤 afterStep이라는 메서드로 불리는 콜백이다. afterStep은 방금 끝난 Step의 실행 정보(이름, 처리 건수, 실패 목록 등)를 담은 StepExecution 객체를 인자로 받는다. 그 객체의 getFailureExceptions()는 그 Step을 실패시킨 예외 목록을 돌려주는 메서드다.

왜 이 자리가 중요한지는 표준 글의 흐름에 있다. 웹 요청을 처리하다 난 예외는 아무도 catch하지 않으면 메서드를 부른 쪽으로 계속 던져져 올라간다. 모든 요청이 거쳐 가는 DispatcherServlet까지 올라온 예외는 @RestControllerAdvice를 붙인 클래스(Advice)가 받는다. 그래서 웹 경로에서는 번역된 예외든 아니든 Advice 한 곳에서 로그를 한 번만 남긴다. 여러 곳에서 남기면 같은 사건이 두 번 찍힌다.

벌크 경로는 DispatcherServlet을 거치지 않으니 그 Advice가 없다. 번역해 봐야 그 예외를 받아 로그를 남겨 줄 곳이 없다. 그래서 표준은 번역을 포기하고, 로그를 남길 「한 곳」을 StepExecutionListener로 옮겼다.

그러니 이 문장이 거짓이면 실패가 한 줄도 안 남는다. Advice 대신 고른 유일한 기록 자리가 비어 있게 되고, 번역하지 않기로 한 대가가 그대로 손실이 된다.


주장 — skip된 예외는 getFailureExceptions에 들어온다

Spring Batch에서 skip된 예외는
StepExecutionListener.afterStep의 StepExecution.getFailureExceptions()에 들어온다.

여기서 skip은 Spring Batch가 처리 중 예외를 만나도 그 아이템만 건너뛰고 나머지는 계속 처리하게 하는 설정이다. 아래 설계에서 실제로 어떻게 설정했는지 보인다.

근거 종류는 1(기술 사실)이다. 이 저장소는 증명할 근거를 네 종류로 나누고 종류마다 증명 방법을 하나씩 정해 두었는데(표준을 증명하는 방식 원칙 1), 1번 기술 사실은 직접 돌려 보면 참·거짓이 가려지는 종류다.


반증 조건 — skip이 났는데 목록이 비어 있으면 반증이다

판정 이름은 셋이다 — refuted(반증됨, 주장이 틀렸다), supported(받쳐짐, 주장대로였다), inconclusive(판정 못 함). 무엇을 보면 어느 판정인지는 실험을 돌리기 전에 이렇게 적어 두었다.

  • refuted — getSkipCount가 1 이상인데 getFailureExceptions가 비어 있다
  • supported — getFailureExceptions에 그 예외가 들어 있다
  • inconclusive — 잡이 돌지 않거나, getSkipCount가 0이다(skip 설정이 안 걸린 것이다)

getSkipCount는 그 Step에서 skip이 몇 건 났는지 세는 메서드다. 이것을 먼저 보는 이유는 skip이 실제로 일어났는지부터 확인해야 「목록이 비었다」가 의미를 갖기 때문이다.


설계 — 아이템 다섯 중 하나를 skip시키고 afterStep에서 전부 읽는다

아이템 5개를 읽는 chunk 스텝을 만들고, Processor가 3번째에서 IllegalStateException을 던지게 했다. Step은 이렇게 만들었다(실험 코드에서 이 부분만 옮겼다).

new StepBuilder("proofStep", jobRepository)
        .<Integer, Integer>chunk(5, transactionManager)
        .reader(new ListItemReader<>(new ArrayList<>(ITEMS)))
        .processor(processor)
        .writer(writer)
        .faultTolerant()
        .skip(IllegalStateException.class)
        .skipLimit(10);

StepBuilder는 Step을 한 번에 만들지 않고 설정을 하나씩 받아 두었다가 마지막에 Step을 만들어 주는 객체다. 각 메서드가 다시 설정을 받을 객체를 돌려주므로 점(.)으로 계속 이어 부른다. 실험 코드는 이 결과에 build()를 불러 Step을 완성한다.

한 줄씩 보면, chunk(5, ...)는 아이템을 5개씩 묶어 처리한다는 뜻이고 <Integer, Integer>는 읽는 아이템과 쓰는 아이템이 둘 다 Integer라는 뜻이다. reader·processor·writer는 읽기·처리·쓰기를 맡을 부품을 끼운다. faultTolerant()는 그 뒤에 skip 같은 실패 허용 설정을 붙일 수 있는 객체(FaultTolerantStepBuilder)로 바꿔 준다. skip(IllegalStateException.class)는 IllegalStateException이 나면 그 한 건만 건너뛰라는 뜻이고, IllegalStateException.class는 예외 객체가 아니라 그 클래스 자체를 값으로 넘기는 클래스 리터럴이다. skipLimit(10)은 그렇게 건너뛰는 것을 최대 10건까지 허용한다는 뜻이다.

afterStep에서 getSkipCount(Step 전체의 skip 건수)·getProcessSkipCount(그중 Processor 단계에서 일어난 skip만 센 건수)·getFailureExceptions·스텝 상태·쓰기 건수를 모두 찍는다. SkipListener의 onSkipInProcess가 불린 횟수도 함께 세어, skip이 진짜 일어났는지를 Step 기록(앞의 두 숫자)과 리스너, 두 갈래로 확인한다.

JobRepository가 DB를 쓰므로 이 컴퓨터에서 돌고 있는 PostgreSQL 17에 접속해 그 DB를 썼다. 실험 전용 데이터베이스를 새로 만들고 끝나면 지웠다. 환경은 Spring Boot 3.5.16 하나다 — Boot 4에서 Spring Batch API가 어떻게 바뀌었는지 확인하지 않아 범위를 좁혔다.


결과 — skip은 1건, 목록은 0건이었다

실험 코드는 afterStep과 SkipListener에서 모은 값을 잡이 끝난 뒤 한 줄로 찍는다. 줄 앞의 PROBE는 이 실험 코드가 결과 줄 앞에 붙인 표지다. 아래는 그 한 줄을 읽기 좋게 세 줄로 나눈 것이다.

PROBE boot=3.5.16 db=proof_batch_skip_failure_log_1789514243_9705
      skip=1 procskip=1 skiplistener=1:IllegalStateException
      fails=0 failclasses=none
      stepstatus=COMPLETED stepexit=COMPLETED written=4 jobstatus=COMPLETED jobfails=0

로그 한 줄씩 따라가기. boot는 사용한 Spring Boot 버전이고, db는 이번 실험에 새로 만든 데이터베이스 이름이다. skip은 getSkipCount, procskip은 getProcessSkipCount의 값이다. skiplistener는 SkipListener가 센 건수와 그때 터진 예외 타입이다(1:IllegalStateException은 1건이 IllegalStateException으로 skip됐다는 뜻이다). fails는 getFailureExceptions에 담긴 예외 개수이고 failclasses는 그 예외들의 타입 목록이다(none은 하나도 없다는 뜻이다). stepstatus는 StepExecution.getStatus()로 읽은 Step의 실행 상태이고, stepexit은 getExitStatus()로 읽은 종료 코드다. 실행 상태는 Spring Batch가 정하고, 종료 코드는 afterStep이 값을 돌려주면 그 값과 합쳐져 바뀔 수 있다(이 실험의 afterStep은 null을 돌려 그대로 두었다). 그래서 둘을 따로 찍었고, 이번에는 둘 다 COMPLETED였다. written은 getWriteCount, 곧 Writer에 넘어가 쓰인 아이템 수다(이 실험의 Writer는 DB에 저장하지 않고 개수만 센다). jobstatus·jobfails는 Job 전체의 상태와 실패 예외 개수다.

판정은 refuted다. skip은 세 값(getSkipCount·getProcessSkipCount·SkipListener)에서 모두 1로 확인됐는데 getFailureExceptions는 비어 있다.


예상과 달랐던 점 — 스텝도 잡도 COMPLETED로 끝난다

목록이 비었다는 것보다 더 눈에 띈 것은 상태다. 스텝과 잡이 모두 COMPLETED이고, 아이템 5개 중 4개만 쓰였다. 실패가 있었다는 흔적이 상태에도 없다.

getFailureExceptions는 이름 그대로 스텝을 실패시킨 예외를 담는 자리다. skip은 「실패시키지 않기로 한 예외」라 거기 들어갈 이유가 없다. 표준 글은 이 둘을 같은 것으로 보고 로그 자리를 정했다.

그래서 표준대로 짜면 이렇게 된다.

  표준 글이 기대한 것 실제
skip된 예외 afterStep에서 한 번 남는다 한 줄도 안 남는다
스텝 상태 표준 글은 말하지 않았다 COMPLETED
쓰기 건수 표준 글은 말하지 않았다 5개 중 4개

skip 예외를 잡으려면 SkipListener가 필요하다는 것이 실험에서 보인다. 이번 실험이 그 경로로 1건을 셌기 때문이다. 다만 이 실험은 「SkipListener로 옮기면 충분한가」를 재지 않았다.


이 결과가 표준에 남기는 것 — 로그 자리를 다시 정해야 한다

규칙(벌크 경로는 번역하지 않는다)은 이 실험이 판정하지 않는다. 무너진 것은 그 대가를 받아준다던 로그 자리다. 이 실험을 돌린 시점의 표준 글대로 하면 skip된 실패가 조용히 사라지고, 스텝은 성공으로 기록된다.

규칙은 바로 고치지 않는다. 이 저장소는 실험 한 건으로 규칙을 고치지 않는다 — 실험이 틀렸을 수 있어서다. 대신 표준 글의 해당 원칙 헤딩 바로 아래에 「이 원칙의 근거가 반증됐다」는 한 줄을 인용문으로 단다. > 반증: <결과 요약> — [증명](이 글 주소) 모양이다. 그리고 GitHub 이슈를 「표준끼리 또는 표준과 실측이 어긋난다」는 뜻의 kind: 모순 라벨을 붙여 열고, 그 이슈에서 로그 자리를 SkipListener로 옮길지 정한다.

그 뒤 이슈 #160에서 이 결과를 다뤘고(닫힘), 지금 표준 글 원칙 8은 > 반증: 줄을 단 채 StepExecutionListener 옆에 SkipListener를 함께 두도록 고쳐져 있다.


정리

  • skip 1건, getFailureExceptions 0건. 세 값으로 skip을 확인했으므로 설정 문제가 아니다
  • 스텝과 잡이 COMPLETED로 끝나고 쓰기는 4건이었다. 상태에도 흔적이 없다
  • 표준이 정한 로그 자리로는 skip된 실패가 남지 않는다
  • 재지 않은 것 — Boot 4의 Spring Batch, SkipListener로 옮겼을 때 충분한지, retry(실패한 아이템을 다시 시도하는 설정)가 섞였을 때

자신만의 철학을 만들어가는 중입니다.
최상단으로 이동했습니다!
확대 이미지

댓글남기기