안녕하세요. IT 교육 전문가입니다.
Spring Batch를 처음 학습하시거나 실무에 막 도입하셨을 때, 가장 당혹스러운 순간 중 하나는 코드를 한 줄도 고치지 않았는데 배치가 실행되지 않는 상황일 것입니다. 어제는 잘 돌아가던 배치가 오늘은 시작하자마자 에러를 뱉어냅니다. 로그를 확인해보니 다음과 같은 무시무시한 예외가 찍혀 있습니다.
org.springframework.batch.core.repository.JobInstanceAlreadyCompleteException: A job instance already exists and is complete for parameters={...}. If you want to run this job again, change the parameters.
“아니, 내가 내 배치를 돌리겠다는데 왜 막는 거야?”라고 생각하실 수 있습니다. 하지만 이 에러는 Spring Batch가 여러분의 시스템을 보호하기 위해 보내는 강력한 경고 신호입니다. 오늘은 이 에러가 왜 발생하는지 그 근본적인 원인을 파헤치고, 개발 단계와 운영 단계에서 각각 적용할 수 있는 해결 방법, 그리고 더 나아가 이 구조를 이용해 안전한 배치를 설계하는 철학까지 5,000자 이상의 깊이 있는 내용으로 다뤄보겠습니다.
1. 에러의 정체: 도대체 JobInstance가 뭐길래?
이 오류를 이해하려면 Spring Batch의 핵심 세계관인 JobInstance를 알아야 합니다. Spring Batch에서 ‘Job’은 설계도(Class)이고, ‘JobInstance’는 그 설계도로 찍어낸 제품(Object)과 비슷합니다. 그런데 이 제품을 식별하는 고유한 ID가 바로 ‘Job 이름 + Job Parameter’의 조합입니다.

예를 들어, SettlementJob(정산)이라는 Job을 date=2023-10-27이라는 파라미터로 실행했다고 가정해봅시다. Spring Batch는 메타 테이블(BATCH_JOB_INSTANCE)에 이 조합을 기록해 둡니다. “2023년 10월 27일자 정산 작업”이라는 하나의 인스턴스가 생성된 것입니다. 이 작업이 성공적으로 끝났다면(COMPLETED), Spring Batch는 이 인스턴스를 “완료된 작업”으로 마킹합니다.
그런데 여러분이 실수로, 혹은 테스트를 위해 동일한 파라미터(date=2023-10-27)로 다시 배치를 실행하려 합니다. Spring Batch는 메타 테이블을 조회한 후 이렇게 판단합니다.
“어? 10월 27일 정산은 이미 성공적으로 끝났는데? 또 돌리면 정산이 두 번 되잖아? 중복 실행을 막아야해!”
그리고는 JobInstanceAlreadyCompleteException을 던지며 실행을 차단합니다. 즉, 이 에러는 버그가 아니라 중복 실행 방지(Idempotency)를 위한 Spring Batch의 핵심 기능입니다.
2. 해결 방법 1: 파라미터 변경 (가장 단순하지만 비추천)
에러 메시지에서도 친절하게 “If you want to run this job again, change the parameters”라고 알려줍니다. 가장 직관적인 해결책은 파라미터를 바꾸는 것입니다.
시간 값을 파라미터에 추가하기
Job Parameter에 현재 시간이나 난수(Random) 값을 추가하면, Spring Batch는 이를 매번 새로운 JobInstance로 인식합니다.
long time = System.currentTimeMillis();
JobParameters params = new JobParametersBuilder()
.addString("requestDate", "2023-10-27")
.addLong("time", time) // 매번 변하는 값을 추가
.toJobParameters();
jobLauncher.run(job, params);
이 방법은 당장 에러를 피할 수 있지만, 치명적인 단점이 있습니다. 메타 테이블이 오염됩니다.
의미 없는 time 파라미터 때문에, 논리적으로는 같은 ’10월 27일 정산’임에도 불구하고 수십, 수백 개의 별도 인스턴스가 생성됩니다. 나중에 “10월 27일 정산 이력 찾아줘”라고 했을 때 관리가 불가능해집니다. 따라서 이 방법은 로컬 테스트 용도로만 제한적으로 사용해야 합니다.
3. 해결 방법 2: RunIdIncrementer (Spring Batch 표준)
Spring Batch는 이러한 재실행 니즈를 우아하게 해결하기 위해 JobParametersIncrementer라는 인터페이스와 그 구현체인 RunIdIncrementer를 제공합니다.
동작 원리
RunIdIncrementer를 설정하면, Job이 실행될 때마다 내부적으로 run.id라는 파라미터를 1씩 증가시킵니다.
* 1회차 실행: date=2023-10-27, run.id=1
* 2회차 실행: date=2023-10-27, run.id=2
이렇게 되면 기존의 비즈니스 파라미터(date)는 유지하면서도, run.id가 다르기 때문에 새로운 JobInstance로 인식되어 실행이 가능해집니다.
적용 코드
Job을 빌드할 때 .incrementer() 메서드만 호출하면 됩니다.
@Bean
public Job settlementJob(JobRepository jobRepository, Step settlementStep) {
return new JobBuilder("settlementJob", jobRepository)
.incrementer(new RunIdIncrementer()) // 이 한 줄로 해결!
.start(settlementStep)
.build();
}
이 방식은 표준적이고 깔끔합니다. 하지만 주의할 점은, 이것 역시 ‘새로운 인스턴스’를 만드는 행위라는 것입니다. 만약 정말로 ‘실패한 작업을 이어서(Restart)’ 하고 싶은 것이라면 이 방법을 쓰면 안 됩니다. 이 방법은 “처음부터 다시 돌리고 싶을 때” 사용하는 방법입니다.
4. 해결 방법 3: 개발 환경에서의 메타 테이블 초기화
개발 중에는 코드를 수정하고 같은 파라미터로 수십 번 테스트해야 합니다. 이때마다 파라미터를 바꾸거나 Incrementer를 쓰는 것도 번거롭습니다. 로컬 개발 환경(H2 DB 등)이나 개발 서버에서는 과감하게 메타 데이터를 날려버리는 것이 가장 속 시원할 수 있습니다.
SQL로 삭제하기
Spring Batch 테이블들은 외래키(FK)로 묶여 있어 삭제 순서가 중요합니다. 아래 순서대로 삭제해야 합니다.
DELETE FROM BATCH_STEP_EXECUTION_CONTEXT;
DELETE FROM BATCH_STEP_EXECUTION;
DELETE FROM BATCH_JOB_EXECUTION_CONTEXT;
DELETE FROM BATCH_JOB_EXECUTION_PARAMS;
DELETE FROM BATCH_JOB_EXECUTION;
DELETE FROM BATCH_JOB_INSTANCE;
이 쿼리를 IDE의 SQL 콘솔에 저장해두고, 에러가 날 때마다 실행해주면 됩니다. 단, 운영 환경(Production)에서는 절대로, 네버, 실행해서는 안 됩니다. 운영 이력이 모두 날아가고, 현재 돌고 있는 배치에도 심각한 영향을 줄 수 있습니다.
5. 심화: “재시작(Restart)”과 “재실행(Rerun)”의 구분
많은 분들이 이 에러를 만났을 때 혼동하는 것이 있습니다.
“나는 실패해서 다시 돌리는 건데 왜 에러가 나죠?”
JobInstanceAlreadyCompleteException은 성공(COMPLETED)한 Job을 다시 돌릴 때 발생합니다.
만약 이전 실행이 실패(FAILED)로 끝났다면, Spring Batch는 이 에러를 내지 않고 자동으로 재시작(Restart) 모드로 진입합니다. 즉, 실패한 지점부터 이어서 실행해줍니다.
따라서 만약 실패한 Job을 다시 돌리는데 이 에러가 난다면, 이전 실행 상태가 FAILED가 아니라 COMPLETED나 UNKNOWN, ABANDONED 등으로 잘못 기록되어 있는지 확인해야 합니다.
또한, 간혹 Job 자체는 성공했지만 특정 Step만 다시 돌리고 싶은 경우가 있습니다. 이때는 Job 설정이 아니라 Step 설정에서 allowStartIfComplete(true) 옵션을 줘야 합니다.
@Bean
public Step cleaningStep(JobRepository jobRepository) {
return new StepBuilder("cleaningStep", jobRepository)
.tasklet(myTasklet, transactionManager)
.allowStartIfComplete(true) // 성공했어도 무조건 실행
.build();
}
이 옵션은 “Job은 성공했지만, 이 Step은 매번 실행해야 해” (예: 임시 파일 삭제)라는 특수한 상황에서 사용합니다. Job 전체의 중복 실행을 허용하는 것과는 스코프가 다릅니다.

6. 결론
JobInstanceAlreadyCompleteException은 Spring Batch가 우리에게 주는 선물과도 같습니다. “당신, 지금 이미 끝난 일을 또 하려고 해요! 실수인가요?”라고 물어봐주는 안전장치죠.
이 에러를 만났을 때 무조건 RunIdIncrementer를 붙여서 회피하려고만 하지 마세요.
1. 정말 다시 실행해야 하는가? (중복 처리는 아닌가?)
2. 재시작(Restart)을 원하는가, 새로운 실행(Rerun)을 원하는가?
3. 파라미터 설계가 비즈니스 유니크 키를 제대로 반영하고 있는가?
이 세 가지 질문을 먼저 던져보시길 바랍니다. 운영 환경에서는 파라미터에 날짜뿐만 아니라 version이나 type 같은 구분자를 두어 잡 인스턴스를 체계적으로 관리하는 것이 좋습니다.
Spring Batch의 이러한 깐깐함이 결국 여러분의 데이터를 안전하게 지켜주는 방패가 됩니다. 오늘 알려드린 해결책들을 상황에 맞게 적절히 활용하여, 더욱 견고하고 유연한 배치 시스템을 구축하시기 바랍니다.
다음 시간에는 이렇게 관리된 JobInstance들이 쌓여 메타 테이블이 비대해졌을 때, 어떻게 데이터를 정리하고 성능을 유지할 수 있는지에 대해 알아보겠습니다. 감사합니다.






