백엔드

[스프링부트] Spring Batch와 적용방법

KyuminKim 2024. 12. 9. 18:32

배경

프로젝트 진행 중, Spring Batch를 적용해야 했다.

정확히는 매 0시마다 DB에서 특정 데이터를 삭제해야 했다. (soft delete 된 데이터 영구삭제 구현)


Spring Batch란?

- 배치 프레임워크

- 대용량 데이터 처리에 필수적인 기능 제공 (로깅, 추적, 트랜잭션 관리, job 프로세싱 통계, job 재시작, 스킵, 리소스 관리 등)

 

Sprinb Batch Architecture

[공식문서] Batch Stereotypes

 

 

JobLauncher

Job을 실행하는 주체

 

Job

전체 배치 프로세스를 캡슐화한 엔티티

여러 개의 step으로 구성

@Bean
public Job footballJob() {
    return this.jobBuilderFactory.get("footballJob")
                     .start(playerLoad())
                     .next(gameLoad())
                     .next(playerSummarization())
                     .end()
                     .build();
}

football 이라는 job을 실행하기 위해

playerLoad(), gameLoad(), playerSummarization() step 수행

 

[공식문서]

JobInstance: Job의 논리적인 실행

예를 들어 1월 1일 job이 실행되었다면, 1월 1일의 JobInstance가 존재하는 것

 

(이때, 1월 1일이 JobParameter)

 

JobExecution: Job이 실제로 한번 실행되었을 때 나온 작업물

예를 들어 1월 1일 job이 여러번 실행되었다면, JobInstance는 하나 + JobExecution은 여러번

 

 

Step

- ItemReader, ItemProcessor, ItemWriter를 한개씩 가짐

- 실제 로직 (개발자 작성) ex) DB에서 데이터를 읽음 

 

JobRepository

- 실행중인 프로세스에 대한 메타 정보 저장

- DB에 접근 

 


구현

1. build.gradle 변경 (implementation 추가)

dependencies {
	// Spring Batch
	implementation 'org.springframework.boot:spring-boot-starter-batch'
}

 

2. application.yml 변경

spring:
  batch:
    jdbc:
      initialize-schema: always # batch 테이블 자동 생성
    job:
      enabled: true

 

3. batch 패키지 생성

 ── batch
    ├── CustomBatchConfig.java
    └── CustomBatchScheduler.java

 

CustomBatchConfig.java

@Configuration
@EnableScheduling
public class CustomBatchConfig {
    private final MemberRepository memberRepository;
    private final DataSource dataSource;

    public CustomBatchConfig(DataSource dataSource, MemberRepository memberRepository) {
        this.dataSource = dataSource;
        this.memberRepository = memberRepository;
    }

    @Bean
    @Primary
    @Qualifier("transactionManager")
    public JpaTransactionManager JpaTransactionManager(EntityManagerFactory entityManagerFactory) {
        return new JpaTransactionManager(entityManagerFactory);
    }

    @Bean
    public JobRepository customJobRepository(JpaTransactionManager jpaTransactionManager) throws Exception {
        JobRepositoryFactoryBean factoryBean = new JobRepositoryFactoryBean();
        factoryBean.setDataSource(dataSource);
        factoryBean.setTransactionManager(jpaTransactionManager);
        factoryBean.afterPropertiesSet(); // 설정 후 초기화
        return factoryBean.getObject();
    }

    @Bean
    public Job deleteExpiredMembersJob(JpaTransactionManager jpaTransactionManager, Step deleteExpiredMembersStep) throws Exception {
        return new JobBuilder("deleteExpiredMembersJob", customJobRepository(jpaTransactionManager))
                .start(deleteExpiredMembersStep)
                .build();
    }

    @Bean
    public Step deleteExpiredMembersStep(JpaTransactionManager jpaTransactionManager, MemberService memberService) throws Exception {
        return new StepBuilder("deleteExpiredMembersStep", customJobRepository(jpaTransactionManager))
                .tasklet((contribution, chunkContext) -> { //deleteExpiredMembersTasklet
                    LocalDateTime cutoffDate = LocalDateTime.now().minusDays(30);

                    // 만료된 멤버 조회
                    List<Member> expiredMembers = memberService.findExpiredMembers(cutoffDate);

                    // 만료된 멤버 삭제
                    int deletedCount = memberService.deleteExpiredMembers(cutoffDate);
                    System.out.println("# " + deletedCount + " expired members deleted.");

                    if (!expiredMembers.isEmpty()) {
                        for (Member member : expiredMembers) {
                            // 삭제 대상 멤버 정보 출력
                            System.out.println(" - ID: " + member.getMemberToken() + ", Name: " + member.getNickname() + ", email: " +  member.getEmail() + ", emailType: " +  member.getLoginType());
                        }
                    }
                    return RepeatStatus.FINISHED;
                }, jpaTransactionManager)
                .allowStartIfComplete(true)  // 이미 완료된 Step도 재실행
                .build();
    }
}

 

step에서 ItemReader, ItemProcessor, ItemWriter 없이 단순 처리했다

 

 

CustomBatchScheduler.java

@Component
public class CustomBatchScheduler {
    private final JobLauncher jobLauncher;
    private final Job deleteExpiredMembersJob;

    public CustomBatchScheduler(JobLauncher jobLauncher, Job deleteExpiredMembersJob) {
        this.jobLauncher = jobLauncher;
        this.deleteExpiredMembersJob = deleteExpiredMembersJob;
    }

    @Scheduled(cron = "0 0 0 * * ?") // 매일 자정에 실행
    public void runDeleteExpiredMembersJob() throws Exception {
        jobLauncher.run(deleteExpiredMembersJob, new JobParameters());
    }
}

 


구현 중 주의사항

batch table

Job Repository에서는 batch처리와 관련된 메타 정보를 저장하기 위해 DB에 접근한다.

이 DB는 batch job과 관련된 DB이다.

 

이 블로그 글에 따르면  

생성되는 DB Table은 이 6가지라 한다.

  • BATCH_JOB_INSTANCE
  • BATCH_JOB_EXECUTION
  • BATCH_JOB_EXECUTION_PARAMS
  • BATCH_JOB_EXECUTION_CONTEXT
  • BATCH_STEP_EXECUTION
  • BATCH_STEP_EXECUTION_CONTEXT

이 DB Table을 자동으로 생성하기 위해서는 설정을 추가해야 한다.

그 작업이 바로 ((2. application.yml 추가)) 였던 것이다.

spring:
  batch:
    jdbc:
      initialize-schema: always # batch 테이블 자동 생성
    job:
      enabled: true

 

 

@EnableBatchProcessing

블로그를 보면 이 어노테이션을 추가하라 한다.

 

하지만 이 어노테이션을 추가하면 application.yml로 작성한 spring batch 속성이 효력을 잃는다

(자동으로 spring batch를 구성하기 때문이다)

 

즉, application yml로 batch table을 자동생성하라 했는데

그것이 반영되지 않는 에러가 생겼다.

 

나의 경우에는

@EnableBatchProcessing를 삭제해,

batch table이 정상적으로 자동 생성되도록 설정했다.

 

 

DataSourceTransactionManager Bean cannot found 에러

Parameter 0 of method deleteExpiredMembersStep 
in solverz.business_card.batch.BatchConfig required a bean of type 
'org.springframework.jdbc.datasource.DataSourceTransactionManager' 
that could not be found.

에러가 생성되었다.

 

분명 application.yml로 batch config 설정을 완료했는데, Bean이 생성되지 않은 문제가 생긴 것이다.

 

이 문제는 명시적으로 DataSourceTransactionManager Bean을 등록해 해결했다.

@Configuration
@EnableScheduling
public class CustomBatchConfig {
    @Bean
    @Primary
    public DataSourceTransactionManager transactionManager(DataSource dataSource) {
        return new DataSourceTransactionManager(dataSource);
    }
    ... (생략)    
}

 

 

InvalidDataAccessApiUsageException: Executing an update/delete query 에러

org.springframework.dao.InvalidDataAccessApiUsageException: Executing an update/delete query
(중략)
at jdk.proxy2/jdk.proxy2.$Proxy156.deleteExpiredMembers(Unknown Source)

이번에는 update/delete query가 실행되지 않는다고 한다.

 

수많은 블로그에서는 @Transactional 어노테이션을 붙이라 말한다.

하지만 나는 여전히 해결할 수 없었다

 

stack overflow의 글에 따르면,

DataSourceTransactionManager 대신 JpaTransactionManager를 사용하라 말한다.

@Configuration
@EnableScheduling
public class CustomBatchConfig {
    @Bean
    @Primary
    @Qualifier("transactionManager")
    public JpaTransactionManager JpaTransactionManager(EntityManagerFactory entityManagerFactory) {
        return new JpaTransactionManager(entityManagerFactory);
    }
    ... (생략)    
}

 

JpaTransactionManager를 사용하고,

@Qualifier("transactionManager") 를 통해 Bean을 transactionManager로 등록시켰다

 

 

restart

배치 job을 실행했을 때, 동일한 JobInstance의 JobExecution이 존재한다면 재시작으로 간주된다

 

나의 코드에서는 항상 같은 JobInstance을 실행할 것이다.

 

하지만 배치 job에서는 기본적으로 이미 완료된 스텝의 재시작을 허용하지 않는다.

 

그렇기에 아래와 같은 에러가 생성되었다.

Step already complete or not restartable,so no action to execute: 
StepExecution: id=55, version=3, name=deleteExpiredMembersStep,
status=COMPLETED, exitStatus=COMPLETED, readCount=0,
(생략)

 

allowStartIfComplete(true) 통해 이미 완료된 step의 재실행을 허용해 해결할 수 있다.

(또는, timestamp 등 고유한 job parameter를 추가해 해결할 수 있다)

@Configuration
@EnableScheduling
public class CustomBatchConfig {
   // (생략)

    @Bean
    public Step deleteExpiredMembersStep(JpaTransactionManager jpaTransactionManager, MemberService memberService) throws Exception {
        return new StepBuilder("deleteExpiredMembersStep", customJobRepository(jpaTransactionManager))
                .tasklet((contribution, chunkContext) -> { //deleteExpiredMembersTasklet
                    // (생략)
                    }
                    return RepeatStatus.FINISHED;
                }, jpaTransactionManager)
                .allowStartIfComplete(true)  // 이미 완료된 Step도 재실행
                .build();
    }
}

 


여담

이번에는 아주 간단하게 job을 생성해 보았다.

 

다음에는 chunk방식도 도입해보고, 

ItemReader, ItemProcessor, ItemWriter도 도입해보며 방식을 비교해보고 싶다!


참고

 

스프링배치 공식문서 한글화 https://godekdls.github.io/Spring%20Batch/introduction/

 

스프링배치 예제, 설명 https://jojoldu.tistory.com/347

 

스프링배치 예제, 설명 https://github.com/prodo-developer/spring-batch-example

 

스프링배치 예제, 설명 https://seodeveloper.tistory.com/entry/Spring-Batch-환율-정보-API-를-간단한-배치-스케줄러-추가-예제편

 

@EnableBatchProcessing 설명 https://curiousjinan.tistory.com/entry/spring-boot-3-batch-5-table-creation-fix

 

스프링배치 공식 예제 https://github.com/spring-projects/spring-batch/tree/main/spring-batch-samples

 

Batch Job 관련 DB Table 설명 https://velog.io/@s2moon98/JobRepository와-메타-데이터

 

스프링배치 Tasklet 방식, Chunk 방식 설명 https://jgrammer.tistory.com/entry/Spring-Batch-실패를-다루는-기술-ItemStream

 

Item Stream https://jgrammer.tistory.com/entry/Spring-Batch-실패를-다루는-기술-ItemStream