[개발지식] Web Application의 상호보완적 Eventually Consistency #2 - Spring Framework Batch와 Spring Boot Batch 비교분석(Spring Batch의 필요성과 편의성을 중심으로)

Hyo Kyun Lee·2025년 10월 28일
0

개발지식

목록 보기
95/100

1. 개요

Spring Framework, Boot에서 Batch를 실행하기 전에 본질적인 개념(Batch vs Scheduler)부터 시작해서 환경설정에 대한 기본 개념(Bean / Class Loader / Gradle/Spring Project init)까지 기초공사부터 먼저 하느라 Batch 실행과 각 프레임워크 간의 비교가 지연되었다.

이제 비로소, Framework와 Boot의 Batch 실행을 진행해보았고 이 두 프레임워크 간의 극명한 차이점과 Boot Batch의 편의성을 비교분석할 수 있었다.

다만 차이를 극명하게 느꼈던, 핵심적인 부분을 위주로 정리하고자 하며 이에 대한 내용을 기록한다.

2. 핵심

결론부터 말하자면,

Framework Batch에서는 개발자가 환경설정, 심지어 "진입점 및 실행"까지 신경써야 하지만, Boot Batch에서는 이러한 실행흐름과 뼈대를 알아서 해주기에 Batch Job/Step(배치로직) 구성에만 집중해주면 된다.

기존 프레임워크에서는 Configuration과 제어(구현)영역을 모두 개발자가 해주어야 하였다.

하지만 Boot는 이러한 뼈대를 알아서 생성해주었고, 실행시점에 알아서 정의하고 주입해주도록 하므로 개발자는 이 뼈대 내에서 배치로직을 구현해주기만 하면 된다.

물론 이 구현영역은 Job/Step이지만, 일전에 한번 자세하게 다룬 적이 있으므로 이 글에서는 넘어간다.

참고로 이 글에서는 환경설정, 작동원리 관점에서 중점적으로 살펴보았다.

3. framework의 환경구성

Spring framework에서는 Batch Job/Step을 실행하기 위한 환경구성을 직접 해주어야 한다.

특히, Job/Step의 실행정보 및 상태를 관리하기 위한 JobRepository와 Batch를 트랜잭션 내에서 처리하고 관리하기 위한 transactionManager를 설정해주어야, Batch 컴포넌트들을 작성할 수 있다.

이를 위해 최초로 jobRepository 빈 객체를 주입받을 수 있는 DefaultBatchConfiguration이 필요하고, 이를 위해 해당 클래스를 상속받은 BatchConfig를 구성해주면 된다.

@Configuration
public class BatchConfig extends DefaultBatchConfiguration {

    @Bean
    public DataSource dataSource() {
        return new EmbeddedDatabaseBuilder()
                .setType(EmbeddedDatabaseType.H2)
                .addScript("org/springframework/batch/core/schema-h2.sql")
                .build();
    }

    @Bean
    public PlatformTransactionManager transactionManager() {
        return new DataSourceTransactionManager(dataSource());
    }
}

이 BatchConfig 하나로 Batch 실행에 필요한 기본적인 컴포넌트 구성요소를 주입받을 수 있다.

최종적으로,

@Import(BatchConfig.class)
public class SystemTerminationConfig {
    /*
    * web run != batch run
    * batch 동작을 위한 CommandJobRunner 별도 실행 및 이를 실행하기 위한 SystemConfig FCQN / Job name 전달 필요
    * */
    private final JobRepository jobRepository;
    private final PlatformTransactionManager transactionManager;

    private AtomicInteger processesKilled = new AtomicInteger(0);
    private final int TERMINATION_TARGET = 5;

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

    @Bean
    public Job systemTerminationSimulationJob(){
        return new JobBuilder("systemTerminationSimulationJob", jobRepository)
                .start(enterWorldStep())
                .next(meetNPCStep())
                .next(defeatProcessStep())
                .next(completeQuestStep())
                .build();
    }

    @Bean
    public Step enterWorldStep(){
        return new StepBuilder("enterWorldStep", jobRepository)
                .tasklet((contribution, chunkContext) -> {
                    System.out.println("Entered to System Termination World.");
                    return RepeatStatus.FINISHED;
                }, transactionManager)
                .build();
    }

    @Bean
    public Step meetNPCStep(){
        return new StepBuilder("meetNPCStep", jobRepository)
                .tasklet((contribution, chunkContext) -> {
                    System.out.println("meet NPC.");
                    System.out.println("First mission : Zombie process : " + TERMINATION_TARGET + " has been Killed");
                    return RepeatStatus.FINISHED;
                }, transactionManager)
                .build();
    }

    @Bean
    public Step defeatProcessStep(){
        return new StepBuilder("defeatProcessStep", jobRepository)
                .tasklet((contribution, chunkContext) -> {
                  int terminated = processesKilled.incrementAndGet();
                  System.out.println("defeatProcessStep is Running : target has been killed by "+ terminated);

                  if(terminated < TERMINATION_TARGET){
                      return RepeatStatus.CONTINUABLE;
                  }else{
                      return RepeatStatus.FINISHED;
                  }
                }, transactionManager)
                .build();
    }

    @Bean
    public Step completeQuestStep(){
        return new StepBuilder("completeQuestStep", jobRepository)
                .tasklet((contribution, chunkContext) -> {
                    System.out.println("complete quest.");
                    System.out.println("Congratulations! Your Batch Step has just reached the basic one!");
                    return  RepeatStatus.FINISHED;
                }, transactionManager)
                .build();
    }
}

Batch Job을 실행하는 클래스에서 생성자 주입을 통해 프로퍼티를 주입받은 후, Job/Step을 본격적으로 실행할 수 있다.

3-1. CommandJobRunner를 통한 Batch 실행

더불어 이 환경과 Batch Job/Step을 실행하기 위한 batch core 모듈의 클래스를 명시적으로 지정해주어야 한다.

참고로, BatchConfig는 컴포넌트 스캔 대상이지만 SystemTerminationConfig는 컴포넌트 스캔 대상이 아니다.

배치를 실행할때, CommandJobRunner에게 최초 실행대상 클래스와 Job 이름을 SystemTerminationConfig로 알려줄 것이고, 이에 따라 SystemTerminationConfig를 먼저 실행하게 된다.

따라서 BatchConfig라는 환경설정 파일은 배치 실행 시, 최초 클래스가 실행 및 생성자가 호출되면서 이 시점에 @Import(BatchConfig.Class)를 Bean Definition하여(register), 이후 bean refresh 시점에 빈객체를 등록하고 의존성을 주입받게 된다.

SystemTerminationConfig = "메인 레시피"
@Import(BatchConfig)   = "이 레시피에서 사용되는 추가 재료 목록"

Component Scan = "냉장고에서 자동으로 재료 찾기"
register()     = "내가 직접 원하는 재료를 테이블에 올려놓기"

refresh() = "모든 재료로 요리를 실제로 만드는 시점"

참고로 저 메인레시피를 먼저 탐색하고 의존성을 발견한 시점에

ctx.register(SystemTerminationConfig.class);  // ★ 여기서 직접 등록됨

Spring context는 저 클래스를 직접 등록한다.

이 등록시점에 의존성을 발견하고 환경설정 의존성까지 등록해주는 것이고,

SystemTerminationConfig
    ↓ (의존성 분석)
@Import(BatchConfig.class)
    ↓
BatchConfig도 구성 클래스 목록에 추가됨

이제 레시피에 모든 메뉴를 담았다면, 본격적으로 bean definition 및 refresh를 진행한다.

ctx.refresh():
  - 모든 Configuration 클래스의 @Bean 메서드 → BeanDefinition 생성
  - BeanDefinition 기반으로 싱글톤 빈 생성
  - SystemTerminationConfig 생성자 호출
      → jobRepository 빈 주입
      → transactionManager 빈 주입

이제 위와 같은 동작원리를 이해했다면, Framework의 batch 실행은 거의 이해한 것이다.

Framework는 CommandLineJobRunner라는 Spring Batch Core 모듈 내의 클래스를 이용하기에, 반드시 실행 시 이 클래스를 실행진입점으로 명시해주어야 하고, 이 CommandLineJobRunner가 실행할 최초 클래스와(우리가 만든) job 이름을 같이 인자로 전달해주어야 한다.

이를 위해 먼저,

application {
    // Define the main class for the application.
    mainClass = 'org.springframework.batch.core.launch.support.CommandLineJobRunner'
}

mainClass를 spring batch core의 CommandLineJobRunner를 선택해주고,

gradle명령어를 통해

./gradlew run --args ="com.system.batch.config.SystemTerminationConfig systemTerminationSimulationob"

우리가 지정한 job 실행 클래스의 FQCN, job 이름을 인자로 전달해준다.

이러면 비로소 Framework batch 기초공사가 완료된 것이다.

4. Boot의 환경구성

Boot Batch는 이에 비하면 굉장히 편리한 점이 많다.

일단 기본적으로 Boot 내부적으로 환경구성을 모두 해주었기 때문에, 우리는 Job/Step 구성에만 열중하면 되겠다.

@Configuration
public class SystemTerminationConfig {
    private AtomicInteger processesKilled = new AtomicInteger(0);
    private final int TERMINATION_TARGET = 5;

    private final JobRepository jobRepository;
    private final PlatformTransactionManager transactionManager;

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

    @Bean
    public Job systemTerminationSimulationJob(){
        return new JobBuilder("systemTerminationSimulationJob", jobRepository)
                .start(enterWorldStep())
                .next(meetNPCStep())
                .next(defeatProcessStep())
                .next(completeQuestStep())
                .build();
    }

    @Bean
    public Step enterWorldStep(){
        return new StepBuilder("enterWorldStep", jobRepository)
                .tasklet((contribution, chunkContext) -> {
                    System.out.println("Entered to System Termination World.");
                    return RepeatStatus.FINISHED;
                }, transactionManager)
                .build();
    }

    @Bean
    public Step meetNPCStep(){
        return new StepBuilder("meetNPCStep", jobRepository)
                .tasklet((contribution, chunkContext) -> {
                    System.out.println("meet NPC.");
                    System.out.println("First mission : Zombie process : " + TERMINATION_TARGET + " has been Killed");
                    return RepeatStatus.FINISHED;
                }, transactionManager)
                .build();
    }

    @Bean
    public Step defeatProcessStep(){
        return new StepBuilder("defeatProcessStep", jobRepository)
                .tasklet((contribution, chunkContext) -> {
                    int terminated = processesKilled.incrementAndGet();
                    System.out.println("defeatProcessStep is Running : target has been killed by "+ terminated);

                    if(terminated < TERMINATION_TARGET){
                        return RepeatStatus.CONTINUABLE;
                    }else{
                        return RepeatStatus.FINISHED;
                    }
                }, transactionManager)
                .build();
    }

    @Bean
    public Step completeQuestStep(){
        return new StepBuilder("completeQuestStep", jobRepository)
                .tasklet((contribution, chunkContext) -> {
                    System.out.println("complete quest.");
                    System.out.println("Congratulations! Your Batch Step has just reached the basic one!");
                    return  RepeatStatus.FINISHED;
                }, transactionManager)
                .build();
    }
}

이와 같이 job/step을 빈객체로 등록하고 이를 위해 이 클래스를 Configuration Component로 등록하였으며, jobRepository나 transactionManager와 같이 의존성 주입을 명시적으로 해줄 필요없이 그대로 모듈에서 사용해주면 된다.

4-1. JobLauncherApplicationRunner를 통한 Batch 실행

Boot의 배치실행은 곧 Web의 실행과 같다.

위 Framework의 실행과정은 application 실행과 다소 차이가 있는데, Boot의 경우 application 실행이 곧 Boot 실행이기에 컴포넌트 구성 및 Job/Step 구성에만 신경써주면 된다.

./gradlew bootRun --args='--spring.batch.job.name=systemTerminationSimulationJob'

이와 같이 실행 시 필요한 매개변수도 job이름만 필요하다.

또한, Framework에서 수동으로 지정해주었던 진입점을 개발자가 신경 쓸 필요없이, 자동으로 boot가 알아서 실행해주고 의존성을 주입해준다.

JobLauncherApplicationRunner
CommandLineRunner
ApplicationRunner

JobLauncherApplicationRunner라는 것이 알아서 다 해주기에, 개발자는 job, step 구성에만 신경써주면 된다.

5. 오류발생 시 디버깅 관점에서의 차이점

이 외, 다른 부분에서도 Framework 배치와 Boot 배치는 많은 차이점이 있는데, 이를 두가지 관점으로 나누어 살펴보고자 한다.

위에서 확인할 수 있듯이, framework에서는 CommandJobRunner라는 클래스를 batch 실행클래스로 지정하여주고(Build.gradle), 실행 시 batch job의 FCQN/job name을 매개변수로 전달하여, "스크립트로" 실행하는 방식이다.

따라서 Batch 실행에 오류가 생겼을 경우 단순히 "실행실패"라는 오류만 확인 가능하며, 디버깅이 상당히 까다롭다.

반면에 Boot Batch의 경우 Boot 실행이 곧 Batch 실행이고, 환경구성은 Boot측에서 해주고 나머지 로직구성만 개발자 몫이기에, 사실상 모든 오류는 컴파일러 단계 혹은 실행오류라도 Boot에서 관리하는 CheckedException으로 확인 가능하다.

즉, Boot Batch를 진행할 경우 디버깅이 Framework에 비해 상당히 편리하다.

6. Batch 실행 후 역추적 및 로그의 편의성 관점

뿐만 아니라, framework의 경우 로그기준으로 job/step 등의 경계없이 그대로 표현하기에, 이를 그대로 로그파일로 남긴다고 해도 역추적이 쉽지 않다.

boot의 경우 JobLauncerApplicationRunner부터 시작하여 모든 컴포넌트/Job/Step의 경계를 분리하고, 각 영역에 대한 로그를 기록해주기에 역추적이 상당히 편하다.

7. 결론

framework에서는 환경설정, job/step을 모두 신경써주었다면, boot에서는 환경설정을 boot 측에서 알아서 다 해주기에 job/step만 개발자가 신경써주면 된다.

또한 진입점 명시와 같은 세부적인 부분들까지 boot가 자동으로 해주고, 무엇보다 batch 실행을 application 실행과 굳이 나누지 않고 동일시해주었기에 framework에 비해 편의성이나 유지관리성 측면에서 모두 개선되었음을 알 수 있었다.

이제 어느정도 기초공사가 완료되었는데, 본격적으로 job/step을 구성해서 batch를 실행해보도록 한다.

0개의 댓글