Spring Batch 입문 19편 — TaskletStep (단발 작업의 정석)

2026-05-17Spring Batch 입문에서 운영까지

Spring Batch 입문 19편. Chunk-oriented Step 의 대척점 — TaskletStep. RepeatStatus.FINISHED·CONTINUABLE, MethodInvokingTaskletAdapter, SystemCommandTasklet, 단발 SQL·script·정리 작업 패턴, transaction 단위까지 정리한 학습 노트.

📚 Spring Batch 입문에서 운영까지 · 19편 — TaskletStep (단발 작업의 정석)

이 글은 Spring Batch 입문에서 운영까지 시리즈 48편 중 19편이에요. 18편 까지 Part 3 (Step·Chunk Processing) 을 끝냈다면, 이번 19편부터는 Part 4 — TaskletStep · Flow Control 로 들어갑니다. 첫 주제는 TaskletStep.

왜 TaskletStep 이 필요한가

지금까지 Step = Chunk-oriented Processing (대량 데이터를 묶음 단위로 처리) — read → process → write 패턴.

근데 실무에서 자주 등장하는 case 가 있어요. Stored Procedure (DB 안에 저장된 프로시저) 한 번 호출, FTP 파일 정리·압축, DB 통계 테이블 일괄 갱신, 외부 API 한 번 호출 후 끝, Step 시작 전·끝난 후 환경 준비/정리 같은 것들이죠.

이런 단발성 작업을 Chunk-oriented 로 억지로 짜면 no-op ItemWriter (아무 일도 안 하는 빈 writer) 같은 이상한 코드가 나와요. → TaskletStep.

Tasklet 인터페이스 — 단 1개의 메서드

public interface Tasklet {
    RepeatStatus execute(StepContribution contribution,
                         ChunkContext chunkContext) throws Exception;
}

이게 끝. execute 메서드 하나가 반복 호출되어 RepeatStatus.FINISHED 를 return 하거나 예외 throw 할 때까지 돕니다.

RepeatStatus — 두 가지 신호

public enum RepeatStatus {
    CONTINUABLE,
    FINISHED;
}
  • FINISHED — Step 종료
  • CONTINUABLE — Tasklet execute 한 번 더 호출 (각 호출 = 새 transaction)

대부분 케이스 = FINISHED 한 번 return. CONTINUABLE 은 조건 만족할 때까지 반복하는 패턴 (배치 종료 조건이 외부 상태인 경우).

TaskletStep 구성

@Bean
public Step myStep(JobRepository repo, PlatformTransactionManager tx) {
    return new StepBuilder("myStep", repo)
        .tasklet(myTasklet(), tx)
        .build();
}

@Bean
public Tasklet myTasklet() {
    return (contribution, chunkContext) -> {
        // 작업 코드
        return RepeatStatus.FINISHED;
    };
}

.tasklet() 호출 시 .chunk() 와 동시 사용 금지. 둘은 상호 배타예요.

execute 의 두 인자

1. StepContribution

public class StepContribution {
    int readCount;
    int writeCount;
    int filterCount;
    ExitStatus exitStatus;
    StepExecution stepExecution;     // 부모 참조
}

현재 chunk 의 누적 통계 (read·write·filter count) 를 담아요. ExitStatus (Step 종료 상태 코드) 와 StepExecution (Step 실행 인스턴스) 까지 들고 있고요. Tasklet 안에서 수동 증가도 가능 — 메트릭 보고용으로 쓰입니다.

contribution.incrementWriteCount(processedCount);
contribution.setExitStatus(ExitStatus.COMPLETED.addExitDescription("처리 완료"));

2. ChunkContext

public class ChunkContext {
    Map<String, Object> attributes;
    StepContext stepContext;          // StepExecution + JobParameters 접근
}

임시 상태 저장 + Step·Job context 접근용. JobParameters (Job 실행 인자) 와 ExecutionContext (재시작용 영속 상태) 까지 도달합니다.

JobParameters params = chunkContext.getStepContext()
    .getStepExecution().getJobParameters();
String inputFile = params.getString("inputFile");

TaskletStep 의 transaction 단위

Tasklet 1회 호출 = 1 transaction. execute 진입 시 transaction 시작, execute 종료 (FINISHED 또는 CONTINUABLE return) 시 commit, 예외 throw 시 rollback.

CONTINUABLE 로 반복 = 매 호출마다 새 transaction. 짧은 transaction 여러 번이 긴 transaction 1번보다 안전한 시나리오에 유리해요.

단발 Tasklet 예제 — 파일 정리

public class FileDeletingTasklet implements Tasklet, InitializingBean {

    private Resource directory;

    @Override
    public RepeatStatus execute(StepContribution contribution,
                                ChunkContext chunkContext) throws Exception {
        File dir = directory.getFile();
        Assert.state(dir.isDirectory(), "The resource must be a directory");

        File[] files = dir.listFiles();
        for (File file : files) {
            boolean deleted = file.delete();
            if (!deleted) {
                throw new UnexpectedJobExecutionException(
                    "Could not delete file " + file.getPath());
            }
        }
        return RepeatStatus.FINISHED;     // 한 번만 실행
    }

    public void setDirectoryResource(Resource directory) {
        this.directory = directory;
    }

    @Override
    public void afterPropertiesSet() throws Exception {
        Assert.state(directory != null, "Directory must be set");
    }
}

특징을 보면, 디렉토리 정리는 1회면 충분하니까 FINISHED 로 끝나고, 예외 발생 시 throw 하면 Step rollback 으로 이어집니다. TaskletInitializingBean (스프링 bean 초기화 후 콜백 인터페이스) 을 동시 구현했기 때문에 bean 초기화 시 의존성 검증까지 같이 되고요.

Job 연결

@Bean
public Job taskletJob(JobRepository repo, Step deleteFilesStep) {
    return new JobBuilder("taskletJob", repo)
        .start(deleteFilesStep)
        .build();
}

@Bean
public Step deleteFilesStep(JobRepository repo, PlatformTransactionManager tx) {
    return new StepBuilder("deleteFilesStep", repo)
        .tasklet(fileDeletingTasklet(), tx)
        .build();
}

@Bean
public FileDeletingTasklet fileDeletingTasklet() {
    FileDeletingTasklet tasklet = new FileDeletingTasklet();
    tasklet.setDirectoryResource(new FileSystemResource("target/test-outputs/test-dir"));
    return tasklet;
}

CONTINUABLE 패턴 — 외부 조건 폴링

@Bean
public Tasklet pollingTasklet() {
    return (contribution, chunkContext) -> {
        boolean ready = checkExternalSystem();
        if (ready) {
            doFinalWork();
            return RepeatStatus.FINISHED;
        }
        Thread.sleep(5000);
        return RepeatStatus.CONTINUABLE;   // 5초 후 다시
    };
}

외부 시스템이 준비될 때까지 폴링하는 패턴이에요. 각 호출이 독립 transaction 으로 돕니다. 주의할 점은 세 가지인데, 무한 폴링을 막으려면 카운트 또는 timeout 체크를 꼭 추가해야 하고, Thread.sleep 안에서도 transaction 은 살아 있어 DB 커넥션을 계속 점유하니까 sleep 은 짧게 잡는 게 좋고, 더 권장하는 패턴은 38편 RepeatRepeatTemplate (Spring Batch 반복 추상화) 이나 scheduled job (스케줄러로 분리한 잡) 으로 빼는 거예요.

MethodInvokingTaskletAdapter — 기존 DAO 재사용

기존 메서드를 Tasklet 으로 wrapping 할 수 있어요.

@Bean
public MethodInvokingTaskletAdapter myTasklet(FooDao fooDao) {
    MethodInvokingTaskletAdapter adapter = new MethodInvokingTaskletAdapter();
    adapter.setTargetObject(fooDao);
    adapter.setTargetMethod("updateFoo");
    return adapter;
}

fooDao.updateFoo() 가 그대로 Tasklet 으로 변환됩니다. 반환값이 void 또는 non-null 이면 FINISHED, null 이면 CONTINUABLE 로 해석돼요.

사용 시점

  • 기존 Service·DAO 메서드를 그대로 batch 의 Step 으로 활용
  • Tasklet 인터페이스 의존성 없이 POJO (평범한 자바 객체) 유지

한계

  • StepContribution·ChunkContext 접근 불가 (메트릭·context 활용 X)
  • 인자 전달은 setArguments(Object[]) 로 정적 — JobParameters 동적 주입은 21편 Late Binding 와 결합 필요

SystemCommandTasklet — OS 명령 실행

@Bean
public SystemCommandTasklet shellTasklet() {
    SystemCommandTasklet tasklet = new SystemCommandTasklet();
    tasklet.setCommand("rsync", "-avz", "src/", "dst/");
    tasklet.setTimeout(60_000);                          // 60초
    tasklet.setInterruptOnCancel(true);
    return tasklet;
}

외부 shell command 를 실행합니다. timeout·termination check·환경 변수까지 세밀하게 제어할 수 있고요.

주의

exit code 0 = COMPLETED, non-zero = FAILED 가 default 입니다. Long-running command 라면 setTimeout() 을 꼭 걸어야 하고요. 보안 측면에서는 사용자 입력을 직접 command 에 넣지 않게 조심해야 합니다 (injection 위험).

CallableTaskletAdapter — Callable 기반

@Bean
public CallableTaskletAdapter callableTasklet() {
    CallableTaskletAdapter adapter = new CallableTaskletAdapter();
    adapter.setCallable(() -> {
        doWork();
        return RepeatStatus.FINISHED;
    });
    return adapter;
}

Callable<RepeatStatus> 를 Tasklet 으로 변환해 줘요. Java 동시성 API 와 함께 쓸 때 유용합니다.

Tasklet · Listener 자동 등록

If it implements the StepListener interface, TaskletStep automatically registers the tasklet as a StepListener.

Tasklet 이 StepExecutionListener (Step 시작/종료 콜백 리스너) 도 같이 구현하면 자동으로 listener 등록이 됩니다. 18편 Step Listener 자동 등록 규칙과 동일해요.

public class MyTasklet implements Tasklet, StepExecutionListener {

    @Override
    public RepeatStatus execute(...) { ... }

    @Override
    public void beforeStep(StepExecution exec) {
        // Step 시작 hook
    }

    @Override
    public ExitStatus afterStep(StepExecution exec) {
        // Step 종료 hook
        return exec.getExitStatus();
    }
}

TaskletStep · ItemReader 결합 — 함정

Tasklet 안에서 ItemReader (배치 입력 추상화) 를 직접 사용하면 ItemStream (재시작 상태 저장 인터페이스) 자동 등록이 안 됩니다 (17편).

public class CustomTasklet implements Tasklet {

    @Autowired
    private FlatFileItemReader<Item> reader;

    @Override
    public RepeatStatus execute(...) {
        Item item;
        while ((item = reader.read()) != null) {
            process(item);
        }
        return RepeatStatus.FINISHED;
    }
}

@Bean
public Step myStep(...) {
    return new StepBuilder("myStep", repo)
        .tasklet(customTasklet(), tx)
        .stream(reader())                  // ★ 수동 등록 필수
        .build();
}

.stream() 으로 명시 등록해서 재시작 안전성을 확보해야 합니다.

TaskletStep vs Chunk-oriented — 선택 기준

시나리오 권장
대량 데이터 read→process→write Chunk-oriented
단발 SQL·SP 호출 Tasklet
파일 정리·압축·이동 Tasklet
외부 API 1회 호출 Tasklet
Job 시작/종료 전후 환경 준비 Tasklet
OS 명령 실행 SystemCommandTasklet
외부 시스템 폴링 Tasklet (CONTINUABLE) 또는 Scheduled Job
기존 DAO 메서드 재사용 MethodInvokingTaskletAdapter

대체로 read·process·write 의 명시적 분리 + 대량 데이터 = Chunk. 그 외 = Tasklet.

자주 만나는 사고

사고 1: .tasklet() + .chunk() 동시 호출

원인 — 두 메서드가 상호 배타.

해결 — 둘 중 하나만 호출. Step 분리 권장.

사고 2: CONTINUABLE 의 무한 루프

원인FINISHED return 조건 누락.

해결 — 카운트·시간 제한 추가. 무한 폴링은 Scheduled job 으로 분리.

사고 3: Tasklet 안 외부 시스템 호출 실패

원인 — 외부 호출이 현재 transaction 안에서 일어남. 외부 실패 시 rollback 발생.

해결 — transaction 분리 또는 비동기 + retry.

사고 4: Tasklet 안 ItemReader 재시작 안 됨

원인.stream() 등록 누락.

해결.stream(reader) 명시 (17편 참고).

사고 5: MethodInvokingTaskletAdapter 인자 전달

원인setArguments() 가 static. JobParameters 동적 주입이 안 됨.

해결 — 21편 Late Binding 패턴을 쓰거나 Tasklet 을 직접 구현.

운영 권장 패턴

Pattern 1: Job 전후 정리 Step

@Bean
public Job dataJob(JobRepository repo, Step prepareStep, Step mainStep, Step cleanupStep) {
    return new JobBuilder("dataJob", repo)
        .start(prepareStep)          // TaskletStep: 환경 준비
        .next(mainStep)              // Chunk-oriented: 데이터 처리
        .next(cleanupStep)           // TaskletStep: 파일 정리·통계 갱신
        .build();
}

Tasklet → Chunk → Tasklet 흐름. 가장 흔한 batch 구조예요.

Pattern 2: 외부 시스템 사전 검증

@Bean
public Tasklet preCheckTasklet() {
    return (contribution, chunkContext) -> {
        if (!externalSystem.isAvailable()) {
            throw new IllegalStateException("External system down");
        }
        return RepeatStatus.FINISHED;
    };
}

Job 시작 시 외부 시스템 상태를 fail-fast (문제를 일찍 드러내서 즉시 중단) 로 확인합니다 — 큰 batch 시작 전 안전 게이트.

Pattern 3: 결과 통계 통보

public class StatsReportTasklet implements Tasklet {
    @Override
    public RepeatStatus execute(StepContribution contribution,
                                ChunkContext chunkContext) {
        StepContext stepCtx = chunkContext.getStepContext();
        JobExecution jobExec = stepCtx.getStepExecution().getJobExecution();

        long total = jobExec.getStepExecutions().stream()
            .mapToLong(StepExecution::getWriteCount).sum();

        slackService.notify("Batch completed: " + total + " items");
        return RepeatStatus.FINISHED;
    }
}

이전 Step 들의 누적 통계를 Slack·이메일로 통보합니다.

Pattern 4: 멱등성 보장 Tasklet

@Bean
public Tasklet idempotentTasklet() {
    return (contribution, chunkContext) -> {
        String marker = "lastRun." + LocalDate.now();
        ExecutionContext ctx = chunkContext.getStepContext()
            .getStepExecution().getExecutionContext();

        if (ctx.containsKey(marker)) {
            // 이미 실행됨 — 스킵
            return RepeatStatus.FINISHED;
        }

        doWork();
        ctx.put(marker, true);
        return RepeatStatus.FINISHED;
    };
}

ExecutionContext 마커로 재시작 시 중복 실행을 막습니다.

시험 직전 한 번 더 — TaskletStep 함정 압축 노트

  • Tasklet 인터페이스 = execute(StepContribution, ChunkContext) 메서드 1개
  • return = RepeatStatus.FINISHED (종료) 또는 CONTINUABLE (한 번 더)
  • Tasklet 1회 호출 = 1 transactionexecute 진입~종료까지
  • 예외 throw = Step rollback
  • .tasklet().chunk() = 상호 배타 (동시 호출 금지)
  • StepContribution = 현재 chunk 통계·ExitStatus
  • ChunkContext = 임시 상태 + StepContext·JobExecution 접근
  • ChunkContext 에서 JobParameters 접근 — chunkContext.getStepContext().getStepExecution().getJobParameters()
  • MethodInvokingTaskletAdapter = 기존 메서드 → Tasklet 변환, setTargetObject·setTargetMethod
  • 반환 void·non-null = FINISHED, null = CONTINUABLE
  • SystemCommandTasklet = OS 명령 실행, setTimeout() 필수
  • exit code 0 = COMPLETED, non-zero = FAILED
  • CallableTaskletAdapter = Callable<RepeatStatus> → Tasklet
  • Tasklet 이 StepExecutionListener 도 구현 = 자동 listener 등록
  • Tasklet 안 ItemReader 사용 = .stream(reader) 수동 등록 필수 (17편)
  • 함정 — CONTINUABLE 무한 루프 (FINISHED 조건 누락)
  • 함정 — .tasklet() + .chunk() 동시 = 예외
  • 함정 — 외부 호출 실패 → transaction rollback
  • 함정 — MethodInvokingTaskletAdapter 의 setArguments = static (JobParameters 동적 주입은 21편 Late Binding)
  • 패턴 — Job 전후 Tasklet 정리 Step
  • 패턴 — Pre-check fail-fast
  • 패턴 — 결과 통계 통보 (이전 Step writeCount 합산)
  • 패턴 — ExecutionContext 마커로 멱등성

공식 문서: TaskletStep 에서 원문을 확인할 수 있어요.

시리즈 다른 편 (앞뒤 글 모음)

이전 글:

다음 글:

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!