"배치가 도중에 죽어버리면 어떻게 할 것인가?"
현실의 배치 시스템은 완벽하지 않다. 네트워크 장애, 데이터베이스 연결 오류, 디스크 공간 부족 등 수많은 이유로 작업이 중단될 수 있다. 특히 대용량 데이터를 다룰 때는 이런 문제가 치명상이 된다.
이런 문제를 해결하려면 다음 기능들이 필요하다.
Spring Batch는 위 문제를 해결하기 위해 메타데이터 관리 시스템을 제공한다. 각 작업의 실행 상태, 처리한 항목 수, 실패 지점 등을 데이터베이스에 기록하고 추적한다. 이 덕분에 작업이 실패해도 마지막으로 성공한 지점부터 작업을 재개할 수 있다.
이번 장에서는 Spring Batch가 어떻게 이러한 실행 상태를 관리하는지, 메타데이터 저장소 테이블의 구조는 어떻게 구성되어있는지, 그리고 실패한 작업을 어떻게 효과적으로 재시작할 수 있는지 등을 자세히 알아보자.
Job이 어떤 작업을 할지를 정의하는 설계도라면, JobInstance는 그 설계도를 기반으로 언제, 어떤 데이터로 실행되는지를 구체화한 실체다.
예를 들어보자.
Job: 월간 매출 정산 작업
JobInstance#1 : 2024년 1월 매출 정산
JobInstance#2 : 2024년 2월 매출 정산
JobInstance#3 : 2024년 3월 매출 정산
그렇다면 Spring Batch는 어떻게 하나의 JobInstance를 다른 JobInstance와 구분할까? 무엇을 기준으로 이들을 서로 다른 실행으로 인식할까?
지금까지 작성했던, Job 설정 코드를 살펴보자. 모든 Job 정의에서 가장 먼저 한 일은 Job에 이름을 부여하는 것이었다.
new JobBuilder("monthlySalesSettlementJob", jobRepository)
이 Job의 이름은 애플리케이션 내의 수 많은 Job 중에서, 특정 Job을 식별하는 고유한 키 역할을 한다.
덕분에 커맨드라인에서 --spring.batch.job.name=monthlySalesSettlementJob 처럼 인자를 전달하면, Spring Batch는 어떤 Job을 실행해야 할지 알 수 있다.
그렇다면 동일한 Job 이름을 가진 JobInstance를 구분하는 기준은 무엇일까? 이 구분을 가능하게 하는 것이 바로 JobParameters다.
지금까지 우리는 JobParameters를 배치 작업을 동적으로 실행하기 위한 도구로만 사용해왔다. 하지만 Spring Batch 내부에서 JobParameters는 그보다 훨씬 중요한 역할을 수행한다.
JobParameters는 동일한 Job을 서로 다른 JobInstance로 구분하는 핵심 요소다. 간단히 말해, Job + JobParameters의 조합이 하나의 고유한 JobInstance를 결정한다.
JobInstance가 Job의 논리적인 실행을 의미한다면, JobExecution은 그 JobInstance의 실제 실행 이력을 나타낸다.
이 구분이 왜 중요할까? 하나의 JobInstance(예: 2024년 4월 데이터 처리)가 여러 번 실행될 수 있기 때문이다.
배치 작업을 운영하다보면 다양한 이유로 실패가 발생한다. 네트워크 오류, 데이터베이스 문제, 디스크 공간 부족 등의 이유로 작업이 중단될 수 있다. 이런 상황에서 같은 JobInstance를 다시 실행해야 할 필요가 생긴다.
JobInstance(id=666, name="월간_매출_정산_작업", parameters="year=2024,month=4") {
// 이 놈은 실패한 JobExecution
JobExecution(id=101) { // 첫 번째 실행 시도
status: FAILED
startTime: 2024-04-01 09:00:00
endTime: 2024-04-01 09:05:23
exitCode: FAILED
failureExceptions: "org.springframework.batch.item.file.FlatFileParseException"
}
// 재실행. 이 놈은 성공했다
JobExecution(id=102) { // 두 번째 실행 시도 (재시작)
status: COMPLETED
startTime: 2024-04-01 14:30:00
endTime: 2024-04-01 14:45:12
exitCode: COMPLETED
failureExceptions: null
}
}
JobExecution에는 다음과 같은 주요 실행 정보가 포함된다.
COMPLETED, FAILED, STOPPED 등)JobExecution은 COMPLETED, FAILED, STOPPED 등의 실행 상태를 가질 수 있고, 이 상태를 BatchStatus라고 한다.
Job 또는 Step의 실행 상태를 나타내는 열거형(Enum)이다.
이 상태 정보는 메타데이터 저장소에 저장되며, 배치 작업의 실행 흐름을 추적하고 관리하는 데 핵심적인 역할을 한다.
JobInstance, JobExecution, BatchStatus까지 알아봤으니, 이제 Spring Batch의 중요한 실행 원칙을 소개한다.
Spring BAtch는 한 번 완료된 JobInstance는 재실행할 수 없도록 제한한다. 다시 말해 한 번 성공적으로 완료된 Job을 동일한 JobParameters로 다시 실행하려 하면 Spring Batch는 예외를 발생시킨다. 이는 동일한 작업이 중복 실행되어 발생할 수 있는 부작용을 방지하기 위한 안전장치다.
이 원칙에 앞서 살펴본 도메인 개념들이 어떻게 상효작용하는지 알아보자.
Spring Batch는 Job을 실행할 때 마다 동일한 JobInstance가 과거에 성공적으로 완료된 적이 있는지 검사한다.
다시 말해 해당 JobInstance의 실행 이력(
JobExecution)중 BatchStatus가COMPLETED인 실행 이력이 존재하는지를 검사한다.
만약 존재한다면 JobInstanceAlreadyCompleteException을 발생시키며 작업의 재실행을 거부한다.
반면 COMPLETED 상태의 JobExecution이 없는 경우에는 해당 JobInstance를 다시 실행할 수 있다.
그런데 메타데이터 테이블을 날려버리지 않고도 동일한 Job을 반복해서 실행하고 싶을 수 있지 않은가? 멱등성이 보장되어 여러 번 실행해도 상관없는 Job이라면?
방법이 있다. 이런 상황을 위해 Spring Batch에서는 다음과 같은 기능을 제공한다. 바로 JobParametersIncrementer다.
JobParametersIncrementer는 JobParameters에 파라미터를 추가하거나, 수정하여, 동일한 Job을 여러 번 실행할 수 있게 해주는 특수 컴포넌트다.
메서드 시그니처는 다음과 같다.
public interface JobParametersIncrementer {
JobParameters getNext(@Nullable JobParameters paramters);
}
입력으로 JobParameters를 받아서, 새로운 JobParameters를 반환하다. 이 새로운 JobParameters가 생성됨에 따라 Spring Batch는 매번 다른 JobInstance로 인식하게 된다.
RunIdIncrementer는 JobParametersIncrementer의 대표적인 구현체다.
이 컴포넌트는 각 배치 실행마다 run.id라는 이름의 파라미터 값을 자동으로 증가시킨다.
예를 들어,
run.id=1run.id=2run.id=3@Bean
public Job firstJob() {
return new JobBuilder("firstJob", jobRepository)
.incrementer(new RunIdIncrementer())
.start(...)
.build();
}
이렇게만 추가하면 동일한 파라미터로 여러 번 실행해도 예외가 발생하지 않고 매번 새로운 JobInstance로 인식된다.
JobParameters의 로그를 보면, 각 파라미터에 identifying=true라는 속성이 설정되어있는 것이 보일 것이다.
이 속성의 의미와 역할을 알아보자.
여기까지 왔다면 JobParameters가 단순한 데이터 전달체가 아니라는 사실을 꺠달았을 것이다. JobInstance를 식별하는 핵심 키로 작동한다. 하지만 모든 파라미터가 식별 용도로 사용되는 것은 아니며, 여기서 등장하는 것이 identifying 속성이다.
이 솏성은 해당 잡 파라미터가 JobInstance를 식별하는 데 사용되는지 여부를 결정한다.
기본적으로 모든 JobParameter는 identifying=true로 설정된다.
때로는 파라미터가 실행마다 달라져도 같은 JobInstance로 취급해야 할 경우가 있다. 예를 들어,
verbose=true로 상세 로깅을 켜고 끄는 파라미터chunk.size=1000과 같은 실행 성능에만 영향을 주는 파라미터이런 파라미터들은 작업의 논리적 정체성을 바꾸지 않는다. 같은 데이터를 처리하지만 처리 방식만 다를 뿐이다.
1장에서 소개했던 **JobParameter의 표기법**을 기억하는가?
parameterName=paramterValue,parameterType,identifying
커맨드 라인에서 identifying 속성을 false로 설정하려면 다음과 같이 하면 된다.
예시를 보자.
./gradlew bootRun --args='--spring.batch.job.name=first verbose=true,java.lang.String,false'
이렇게 하면 verbose 파라미터는 JobInstance를 식별하는 데 사용되지 않는다.
Spring Batch의 Job은 기본적으로 실패한 경우 재시작이 가능하다. 하지만 모든 Job이 재시작 가능해야 하는 건 아니다. 비즈니스적인 이유로 작업이 한 번 실패하면 다시는 실행되지 않도록 해야 할 수도 있다.
이런 상황을 위해 Spring Batch는 preventRestart() 메서드를 제공한다.
이 메서드는 JobBuilder의 메서드로, 설정된 Job이 재시작 불가능하도록 만든다.
@Bean
public Job firstJob() {
return new JobBuilder("firstJob", jobRepository)
.start(...)
.preventRestart()
.build();
}
이 설정이 적용된 Job이 실패한 후 다시 실행하려고 하면, JobRestartException을 발생시키며 작업의 재실행을 차단한다.
preventRestart() 설정은 강력한 제약이다. 이 설정이 적용된 Job은 어떤 이유로든 실패하면 같은 파라미터로는 다시 실행할 수 없다. 그렇기에 이 작업이 실패하면 재시작하면 안되는 명확한 이유가 있는가?라는 질문을 던져보자.
특히 restartable 설정과 앞서 배운 RunIdIncrementer 설정을 함께 사용할 떄는 주의하자.
RunIdIncrementer는 매번 다른 run.id 값을 생성하여 새로운 JobInstance를 만들기 떄문에, 실제로는 preventRestart() 설정의 효과가 무력화된다.
이제 또 다른 핵심 도메인 개념을 살펴보자.
StepExecution은 단일 Step의 실행 이력을 나타내는 객체다. JobExecution이 Job의 실행 이력을 나타내듯, StepExecution은 Step의 실행 이력을 나타낸다.
하나의 Job이 여러 Step으로 구성될 수 있기 때문에, 하나의 JobExecution은 여러 개의 StepExecution을 포함할 수 있다.
JobExecution {
StepExecution("step1")
StepExecution("step2")
StepExecution("step3")
}
StepExecution은 Step 실행 시 생성되며, 해당 Step이 실제로 시작될 때만 생성된다. 예를 들어, 첫 번째 Step이 실패하면 두 번째 Step은 실행되지 않으므로, 두 번쨰 Step에 대한 StepExecution도 생성되지 않는다.
StepExecution은 다음과 같은 주요 실행 정보를 포함한다. 이 정보들은 배치 작업의 모니터링과 문제 해결에 매우 중요하다. 예를 들어, 읽기 카운트와 쓰기 카운트의 차이를 통해 처리 중 얼마나 많은 아이템이 필터링 되었는지 확인 할 수 있다. 또한 롤백 카운트와 스킵 카운트는 작업 중 바생한 오류의 수를 파악하는 데 도움이 된다.
위 항목중에 BatchStatus는 JobExecution과 마찬가지로 StepExecution에도 현재 실행 상태를 의미하는데 사용된다. JobExecution의 BatchStatus와 StepExecution의 BatchStatus는 면밀한 관계가 존재한다.
JobExecution의 최종 BatchStatus는 해당 JobExecution에서 가장 마지막에 실행된 StepExecution의 BatchStatus 값을 기준으로 결정된다. 이 원리를 다양한 시나리오로 살펴보자.
JobExecution {
StepExecution#1 ("step1", BatchStatus.COMPLETED)
StepExecution#2 ("step2", BatchStatus.COMPLETED)
// JobExecution의 최종 status: BatchStatus.COMPLETED
}
마지막에 실행된 BatchStatus가 COMPLETED 이므로, JobExecution의 최종 BatchStatus도 COMPLETED가 된다.
JobExecution {
StepExecution#1 ("step1", FAILED) // 실패!
StepExecution#2 ("step2") // 실행되지 않음
// JobExecution의 최종 status: BatchStatus.FAILED
}
이 경우, StepExecution#1이 실패했기 떄문에 StepExecution#2는 아예 실행되지 않는다.
따라서, 가장 마지막으로 실행된 StepExecution#1의 상태가 FAILED 이므로 JobExecution의 최종 상태 역시 FAILED로 결정된다.
이러한 상태 전파 원리를 이해했으니, 배치 작업이 실패하고 재시작될 때 어떻게 동작하는지 알아보자.
잡이 실패 후 다시 실행되면 JobExecution이 새로 생성되는 것과 마찬가지로, 스텝을 다시 실행할 때도 새로운 StepExecution이 생성된다. 이는 StepExecution이 JobExecution에 종속되어 있기 때문이다.
새로운 JobExecution이 생성되면 그에 속한 모든 StepExecution도 새롭게 생성되는 것이다.
아래 예시를 보자. 첫 번째 실행에서 step2가 실패한 후, JobExecution#2로 재시작했을 때 동일한 step2에 대해 StepExecution#3이 새롭게 생성되는 것을 볼 수 있다.
JobExecution#1 (FAILED) {
StepExecution#1 ("step1", COMPLETED)
StepExecution#2 ("step2", FAILED) // 이놈이 실패했다
}
JobExecution#2 (COMPLETED) {
// StepExecution#1 ("step1")은 이미 성공했으므로 다시 생성되지 않음
StepExecution#3 ("step2", COMPLETED) // 실패한 step2부터 재시작
}
이미 성공적으로 완료된 Step은 재실행되지 않으므로 새로운 StepExecution도 생성되지 않는다
StepExecution의 정보는 단순한 로깅 이상의 가치가 있다. 다음과 같이 활용할 수도 있을 것이다.
이제 마지막으로 살펴볼 컴포넌트는 바로 ExecutionContext다. 이 컴포넌트는 배치 작업의 상태를 저장하고 복원하는 데 있어 핵심적인 역할을 수행한다.
간단히 발해 배치 작업의 상태 정보를 저장하는 데이터 컨테이너다.
Key-Value 형태로 데이터를 저장하고 관리한다.
특히 비즈니스 로직 처리 중에 발생하는 사용자 정의 데이터를 관리할 방법이 필요한데, 이떄 사용하는 것이 바로 ExecutionContext다.
// ExecutionContext 예시
ExecutionContext {
"processingIndex": 42500, // 마지막으로 처리한 항목 인덱스
"totalAmount": 2750000.00, // 중간 집계 결과
"lastProcessedId": "TRX-20240315-789", // 마지막으로 처리한 거래 ID
}
4장에서 살펴본 ItemStream 인터페이스의 open()과 update() 메소드를 떠올려 보자.
ItemStream.open() 메서드는 ExecutionContext에서 이전 실행 상태 정보를 복원하고, update() 메서드는 현재 실행 중인 상태 정보를 ExecutionContext에 저장했다.
이 처럼 이전 상태를 가져오고, 현재 상태를 저장할 수 있었던 이유가 바로 ExecutionContext가 메타데이터 저장소에 영구적으로 저장되기 때문이다.
이것이 Spring Batch의 강력한 재시작 기능의 핵심이다.
1장에서 설명했듯이, JobExecution 수준의 ExecutionContext와 StepExecution 수준의 ExecutionContext가 별도로 존재한다. 실제 메타데이터 저장소에서도 물리적으로 분리되어 보관되며, 서로 다른 테이블 구조로 저장되고 관리된다.