[개발지식] Web Application의 상호보완적 Eventually Consistency #3 - Business Phase에 따라 적절한 Step 유형을 선택하는 방안(Chunk/Tasklet 비교분석)

Hyo Kyun Lee·2025년 11월 7일
0

개발지식

목록 보기
97/100

1. 개요

Batch는 본질적으로 (보통) 대규모 "데이터"를 "일괄적으로" 처리하기 위한 방법으로, 업무적인 처리 방식(Business Phase)에 따라 처리 방안을 두가지로 크게 나눌 수 있다.

이 두가지를 이해하면, Batch에 대해 더욱 명확하게 이해할 수 있고 그만큼 적절한 설계 및 구현을 할 수 있는 토대가 될 수 있다.

Batch의 본질적인 개념을 이해하기 위한 마지막 발걸음인 Step분류, Chunk와 Tasklet에 대해 비교분석해보았다.

2. 데이터 처리 방법은 Batch를 나누는 기준이 아니다 - Business Phase에 따른 Step 구현방안

Batch는 기본적으로 "데이터"를 "일괄적으로"/"효율적으로" 처리하는 데이터 처리 방법의 일종이다.

이 Step을 통해 (사실 "대량의 데이터"가 Batch의 필수조건은 아니지만, Batch 구현의 특성상 "대량의 데이터"를 보통 다루기에 Batch의 데이터 수식에 "대량"이 들어가는 점을 유의한다) 대량의 데이터를 읽고/처리 및 가공/쓰기까지의 과정으로 세분화할 수 있는데, 사실 이것을 Batch와 동일시하였던 기존 개념이 Tasklet Step을 알게된다면 "데이터 처리 방법"은 Batch와 일반 스케쥴링 처리를 나누는 기준이 될 수 없다는 것을 알게된다.

물론 Batch의 본질적인 개념 자체는 변함이 없으나, "어떠한 업무를 어떻게 처리할 것인가"에 따라 Step을 어떻게 구성할 것인지가 달라지기에 "단순 업무 처리"를 무조건 Batch 설계대상으로 간주할 수 없다는 일편향적인 생각을 고칠 필요가 있다는 것이 핵심.

세부적으로 살펴볼 Step 구성방안에 따른 Batch 분류, Business Phase에 따라 Step을 Tasklet으로 구성할 것인지, Chunk로 구성할 것인지로 분류하는 기준과 함께 최종적으로 Batch의 본질을 어떻게 바라보는 것이 좋을까하는 "다양한 관점과 생각"에 대해 정리해보겠다.

3. 업무를 업무 로직으로 나누어 처리한다 - Tasklet

업무를 업무 로직으로 나누어 처리한다는 것은, 비교적 단순한 업무처리를 비교적 단순한 분기처리 혹은 업무 로직으로 구현하여 처리한다는 것을 의미한다.

(참고로, Chunk의 경우 데이터덩어리로 나누어 처리하기에, 배치처리의 기준이 데이터에 있음에 먼저 유의)

단일, 단순한 업무 처리를 일괄적으로 진행하되 명확한 데이터를 기준으로 처리하거나 데이터를 추출하는 방안이 필요할때, Tasklet 지향 처리를 고민해보면 좋겠다.

예를 들어 아래와 같은 처리를 Tasklet 지향 처리로 진행할 수 있다.

  • 캐시 갱신
  • 익일 사용자 로그 데이터를 바탕으로 위험행동 대상에게 경고 메시지 보내기

Tasklet은 일전에 구성했던 가장 기본적인 Boot Batch와 모양이 비슷한데, 다만 Step에 넘겨주는 과정에 차이가 있을 뿐이다.

/*
 * tasklet
 * */
@Slf4j
public class ZombieProcessCleanupTasklet implements Tasklet {
    private final int processesToKill = 10;
    private int killedProcesses = 0;

    @Override
    public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
        killedProcesses++;

        log.info("Process is Running .. {}/{}", killedProcesses, processesToKill);

        if(killedProcesses >= processesToKill) {
            log.info("Batch System would be terminated, Mission Completed.");
            return RepeatStatus.FINISHED;
        }

        return RepeatStatus.CONTINUABLE;
    }
}

이처럼 Tasklet을 상속받는 tasklet Class를 만들고, execute를 override하여 업무로직을 구성한다.

Tasklet은 데이터 덩어리와 같은 특별한 처리기준이 없고, 이 처리를 계속할지 안할지에 대해 명시적으로 분기처리를 구분해주어야만 한다(RepeatStatus.CONTINUABLE, RepeatStatus.FINISHED).

Tasklet에서 위의 Step tasklet을 처리하다가, 특정 기준을 만족한 후 RepeatStatus.FINISHED를 반환받게 된다면 그대로 Step을 종료한다. RepeatStatus.CONTIBUABLE을 반환받는다면 그 다음의 execute 메서드를 다시 호출하여 batch 로직을 지속 진행한다.

마지막으로, execute를 실행한 후 트랜잭션을 실시하고 RepeatStatus를 반환받은 후에 최종 commit한다. RepeatStatus에 따라, 다음 트랜잭션을 실시할지(execute 호출) 중단할지 결정한다.

  • 참고) tasklet 비즈니스 로직 수행 단계
    (1) execute() 호출
    (2) 트랜잭션 시작
    (3) Tasklet 비즈니스 로직 수행
    (4) return RepeatStatus.CONTINUABLE
    (5) 트랜잭션 Commit
    (6) 다음 loop → execute() 다시 호출 (새 트랜잭션 시작)
/*
 * tasklet -> batch step
 * */
@Configuration
public class ZombieBatchConfig {
    private final JobRepository jobRepository;
    private final PlatformTransactionManager transactionManager;

    public ZombieBatchConfig(JobRepository jobRepository, PlatformTransactionManager transactionManager) {
        this.jobRepository = jobRepository;
        this.transactionManager = transactionManager;
    }

    /*
     * tasklet class
     * */
    @Bean
    public Tasklet zombieProcessCleanupTasklet() {
        return new ZombieProcessCleanupTasklet();
    }

    /*
     * tasklet -> step
     * */
    @Bean
    public Step zombieProcessCleanupStep() {
        /*
         * Step building by tasklet (*transactionManager)
         * */
        return new StepBuilder("zombieCleanupStep", jobRepository)
                .tasklet(zombieProcessCleanupTasklet(), transactionManager)
                .build();
    }

//    /*
//     * tasklet -> step
//     * */
//    @Bean
//    public Step zombieProcessCleanupStep() {
//        /*
//         * 트랜잭션 불필요 -> ResourcelessTransactionManager
//         * Step building by tasklet (*ResourcelessTransactionManager)
//         * */
//        return new StepBuilder("zombieCleanupStep", jobRepository)
//                .tasklet(zombieProcessCleanupTasklet(), new ResourcelessTransactionManager())
//                .build();
//    }

    @Bean
    public Job zombieProcessCleanupJob() {
        return new JobBuilder("zombieProcessCleanupJob", jobRepository)
                .start(zombieProcessCleanupStep())
                .build();
    }

}

위에서 구성해준 Tasklet을 Step과 Job에 차례대로 넘겨주면 끝이다. 이때, StepBuilder에 넘겨주는 매개변수는 tasklet 클래스와 transactionManager, 두개이다(단일 트랜잭션에서 관리하기 위한 트랜잭션 매니저 컨텍스트).

3-1. 중요) while 반복문과 batch는 본질적으로 다른 개념이다.

Batch는 본질적으로 while과 같은 반복작업과 다른 개념이고, 애초에 처리지향점이 다르다.

  • Batch는 execute 호출 및 트랜잭션 진행 후 RepeatStatus 및 조건에 따라 트랜잭션을 커밋하고 다음단계를 진행하거나 중단한다.
  • 그만큼 세부적이고 정밀한 트랜잭션 분기처리 및 구성이 가능하며, 도중에 오류가 발생할 경우 중단점 복구 등의 절차를 진행할 수 있다.
  • 반면 단순한 while 반복문은 전체 데이터를 하나의 트랜잭션화하여, 도중에 오류가 발생할 경우 복구지점이 없이 그대로 롤백되어 이전의 처리내용이 모두 사라진다(안전하지도 않고, 기본적으로 로그가 남지않는 설계이기에 일괄처리하기에는 위험).

따라서 tasklet 지향 처리에 대해 알게 된 이상, 더이상 while문이 Batch처리와 같은 개념이다라는 치명적인 실수는 저지르지 않도록 한다.

3-2. TransactionManager

execute에서 발생하는 모든 데이터베이스 작업을 하나의 트랜잭션으로 관리하기 위해 transactionManager를 매개변수로 같이 전달해주었다.

하지만 일반적으로는 Tasklet은 단순하고 명확한 업무처리에 사용되어, 정확하게는 대규모 데이터를 DB 혹은 다른 파일로부터 읽는 처리를 필요로 하지 않는 경우가 대부분이기에, DB트랜잭션과 같은 transactionManager 인자를 고려할 필요가 없을 것이다.

이러한 파일 정리, 메일 발송(외부 API 호출), 알림 등과 같은 작업이 필요하다면, DB트랜잭션에 대한 transactionManager보다는 ResourcelessTransactionManager를 고려하면 되겠다.

이때, 다양한 tasklet step에서 이 ResourcelessTransactionManager 빈을 재사용할 수 있겠지만, 이 빈객체를 통해 Step과 Job의 상태관리에 사용하므로 일관된 상태관리를 위해선 정확하고 안전한 빈 정의 및 사용이 필요하다.

4. 업무를 데이터 덩어리로 나누어 처리한다 - Chunk

보통 Batch를 일컫는다면 이 Chunk 지향처리에 대한 내용일 것이고, "대용량 데이터"를 "일괄적으로", "효율적으로" 처리하기에 가장 안정적이고 위험성이 적은 처리 방안이라면 이에 대한 내용일 것이다.

보통 ETL(Extract - Transition - Load), 데이터를 특정 시스템 혹은 특정 공간에 적재된 상태에서 타겟 시스템 혹은 타겟을 하는 체계로, 정기적으로 형태를 변환하고 적재하는 과정을 말한다.

가장 중요한 점이자, Batch의 본질이라고 할 수 있는 "chunk"는 데이터를 쓸때, 즉 반영할때 그 반영기준(크기)를 어떻게 정할 것인가에 대한 기준이다.

단계동작
1Reader가 DB/파일에서 1 row 읽음 → Processor에 넘김
2Processor가 1 row 가공 후 결과를 임시 리스트 (chunk buffer) 에 저장
3이 작업이 10 row 될 때까지 계속 반복
410개가 모이면 Writer가 한 번에 10개 쓰기
5Writer 처리까지 성공하면 트랜잭션 Commit
6다시 다음 row로 반복

위 단계처럼,

  • 읽고 가공(처리)하는 단위는 1row이다.
  • 처리하는 단위는 1chunk이다.
  • 1 row만큼 읽고 이를 가공하며, 이 작업을 chunk 크기가 될 때까지 누적하여 일괄적으로 Write 및 최종 commit한다.

tasklet과 비슷하게, 처리 도중 오류가 발생할 경우 이전 청크 처리에 대해서는 커밋 완료, 이후 처리부터 복구 및 재시작하면 된다.

4-1. 읽기(ItemReader)/처리(ItemProcessor)/쓰기(ItemWriter)

각각의 읽기, 처리, 쓰기는 이를 구현할 구현체 컴포넌트들이 있고, 기본적으로 이러한 구현체들은 각 단계별 인터페이스를 상속받아 사용하기에 변경점에 유연하게 대응가능하며, 기술적 독립성을 유지할 수 있다.

또한 각각의 구현체(컴포넌트)들을 철저하게 분리하였기에 그만큼 재활용성, 책임분리, 대용량처리 패턴화 및 구조화가 가능해졌다.

@Bean
    public Step processStep() {
        /*
         * Step building by chunk (*transactionManager)
         * */
        return new StepBuilder("processStep", jobRepository)
                .<T1, T2>chunk(10, transactionManager)
                .reader(itemReader())
                .processor(itemProcessor())
                .writer(itemWriter())
                .build();

ItemReader가 더이상 읽을 수 있는 데이터가 없다면(null), batch는 더이상의 작업 데이터가 없다는 것으로 인식하고 다음 batch 작업을 수행하지 않는다.

ItemProcessor의 process()을 통해 입력 데이터(I)를 가공하거나 시스템이 요구하는 형태(O)로 변환하며, 처리할 데이터가 없을 경우 생략할 수 있다(유효하지 않은 데이터 및 처리가 불필요한 데이터를 필터링 가능). 또한 나아가, 입력 데이터의 유효성을 검사할 수 있고, 유효하지 못하다면 Exception을 발생하여 중단시키거나 skip할 수도 있다(생략가능하기도 하다).

(참고로 위 로직에서 T1 타입으로 읽어오고, T2 타입으로 처리하여 Writer에 넘긴다.)

ItemWriter를 통해 처리한 데이터를 최종적으로 저장하거나 출력, 적재, 쓰기 작업을 진행한다. 이때 처리한 데이터가 최종적으로 chunk size가 될때까지 적재하다가, chunk size까지 적재되었을때 최종적으로 결과에 반영한다.

(마지막 순환 시에도, 더이상 읽을 데이터가 없을때까지 읽고 처리하며, 이를 writer가 인지한 상태에서 그대로 결과를 적재한다.)

5. 흥미로운 점

배치를 실행할때 로그를 살펴보면 Job : SimpleJob 이라는 로그를 볼 수 있다.

이게 단순히 Job 내부의 Step 로직을 파악하고, 특정 기준 혹은 복잡도 등을 계산해서 "간단한 Job"일 경우에 나타나는 객체정보인걸까?

이에 대해 자세히 알아보았는데, "Job"에 대한 정보를 그만큼 자세하게, 내부적인 동작 과정을 알 수 있었다.

job으로 구성해준 객체를 살펴보자.

@Bean
    public Job zombieProcessCleanupJob() {
        return new JobBuilder("zombieProcessCleanupJob", jobRepository)
                .start(zombieProcessCleanupStep())
                .build();
    }

일단, 위에서 Job으로 구성한 로직의 반환형태는 Job "인터페이스"이다.

JobBuilder 내부로직을 살펴보면 JobBuilder의 부모객체(job 이름 속성 부여)를 반환한 후, 이 부모객체에 대해 start, build를 빌더패턴을 통해 SimpleJob을 최종적으로 반환한다.

참고로 SimpleJobBuilder의 build는

public Job build() {
		if (builder != null) {
			return builder.end().build();
		}
		SimpleJob job = new SimpleJob(getName());
		super.enhance(job);
		job.setSteps(steps);
		try {
			job.afterPropertiesSet();
		}
		catch (Exception e) {
			throw new JobBuilderException(e);
		}
		return job;
	}

의 과정으로 이루어지며, 위에서 볼 수 있듯이 부모객체로 생성한 job에서 job이름을 가져오고, step 정보를 그대로 job.setSteps를 통해 job에 넣어주며 최종적으로 "SimpleJob" 객체를 반환한다.

따라서, 객체반환형태인 "SimpleJob"을 그대로 로그에 나타내준다(SimpleJob : Job name).

6. Batch의 본질 - Batch 작업을 바라보는 다양한 관점과 생각

단순 API호출, 캐시삭제 등 단순한 작업은 배치범주에 들어가지 않는 것으로 생각했는데, Batch를 구성하다보니 확실한 기준이 있다는 것을 알 수 있었다.

1) Batch 만의 구성

  • 단순히 기능/업무적으로 배치를 구분하는 것보다는, Tasklet/Step/Job 등 배치만의 확실한 로직과 분기처리를 활용하여 Batch 처리를 진행한다.

2) Batch의 본질을 변하지 않는다

  • (특히 Tasklet과 스케쥴링 처리를 구분하는데 좋은 방법이 될 수 있을텐데) 이보다 더 확실한 것은 데이터를 일괄적으로 이용하고, 최종적으로 어떠한 처리 데이터(결과)를 도출(report)한다는 점이다.

3) 단순 처리도 Batch라 간주할 수 있다

  • 데이터 읽기 > 처리 > 쓰기의 구분이 확실하게 되어있는 작업은 청크지향처리에서 볼 수 있고, 구분되어있지 않은 단순 일괄작업은 테스크릿지향처리의 범주로 볼 수 있다.

최초 Batch에 대해 생각할때는 Chunk 지향처리가 Batch에 가장 알맞은 개념이지만, Tasklet을 이제는 인지하였기에 단순 업무 처리도 Batch의 범주안에 들어갈 수 있다는 것이 중요하다.

다만 그것을 Batch Step으로 나누어 처리할 수 있고, 단순히 스케쥴링을 통해 처리할 수 도 있기에 처리방안을 Spring Batch를 통해 하였는지에 대한 여부도 Batch 분류의 한 방법이 될 수 있겠다.

중요한 것은 "설계의 적절성"일텐데, 적절한 설계를 통해 Batch 처리방안을 적절하게 활용하고 목적에 맞게 구현해야 한다는 점이겠다.

7. 결론

지금쯤이면 Batch에 대해 확실하게 개념을 다잡은 상태이어야 하지 않을까?

Batch의 본질부터 시작하여 Class Loader, 스케쥴링과의 분리 이해 등 초반 2주가량 Batch를 이해하기 위한 필수전제사항에 대해 먼저 다루어보았다.

이제는 Batch에 대해 본격적으로 파고들어볼 단계이다.

확실하고 명확한 이해, 기본기를 바탕으로 Batch에 대해 본격적으로 다루어보고 적용해보도록 한다.

0개의 댓글