한화시스템 BEYOND SW 11기/실버케어 플랫폼 프로젝트

[Spring]스프링 배치를 통한 대용량 데이터 처리 작업

삼록이 2025. 5. 18. 21:50

스프링 배치란?

일정량의 작업을 한 번에 묶어서 정해진 시간에 자동으로 처리할 때 사용하는 것으로 대용량의 데이터 처리에 적합한 프레임워크다.

보통 아래와 같은 예시에서 사용된다.

 

ex.

-쇼핑몰에서 하루 동안의 주문 정보를 모아 매일 자정에 정산 처리

-고객 목록을 읽고 이메일을 대량 발송

 

우리 팀의 프로젝트에서도 이런 배치 작업이 필요했다.

스마트워치를 통해 사용자의 건강데이터가 축적되면, 이를 매일,매주,매월 새벽 1시에 챗GPT API로 일간/주간/월간건강리포트를 만들어내는 작업이 있었기 때문이다.

모든 유저에 대해 AI리포트를 생성해야하므로 이는 배치 작업에 적합했다.

 


0.핵심개념

-Job

:배치작업이 이루어지는 실행단위

예를 들어서, 우리 프로젝트에서는 3개의 Job이 있었다. 
매일 새벽 1시에 실행되는 일간 리포트를 만드는 Job ,  매주 월요일 새벽 1시에 실행되는 주간 리포트를 만드는 Job, 매월 1일에 실행되는 월간 리포트를 만드는 Job

 

-Step

:하나의 Job안의 세부적인 실행단위

예를 들어서, 일간 리포트를 만드는 Job에는 곧바로 모든 유저의 건강데이터를 가지고 리포트를 생성하는 하나의 Step만이 있다.

그런데 주간 리포트를 만드는 Job에는 사용자마다 그 주의 건강데이터 평균을 내는 Step 1개, 그리고 그 주간 평균을 가지고 AI리포트를 생성하는 Step 1개. 이 같은 경우에는 하나의 Job안에 2개의 Step으로 구성되어있는 것이다.

그리고 하나의 Step을 수행하는 방식에는 2가지 구조가 있는데 그것이 바로 아래에 있는 Chunk구조와 Tasklet구조다.

 

-Chunk

:데이터를 여러개 읽어서 처리하는 구조

예를 들어, 10,000명의 유저의 리포트를 생성하는 Step에서 10,000명분의 리포트를 한꺼번에 처리하는게 아니라 100명분씩(이렇게 뭉탱이씩 하는게 Chunk느낌이라고 생각하면된다) 나눠서 처리하는 것이다. 이때 한 청크 단위가 커밋/롤백이 되는 트랜잭션 범위가 된다.

즉 이 말은, 예를 들어 chunk단위가 100이라 하고 105번째에서 에러가 났다면  100~104번째 성공했던게 롤백되고, 106부터 200까지는 수행을 안하고 바로 다음 201번부터 작업이 재시작된다.

이럴땐 또 skip(특정 예외 발생시 해당 레코드만 건너띄고 다음 곧바로 시작하도록 하는 설정), retry(일시적 에러일 경우 재시도하도록 설정)설정을 두어야 하는데 여기서는 다루지 않으니 검색해서 필요하다면 보완하길 바란다.

 

Chunk구조의 Step을 활용하려면 3가지 핵심 컴포넌트를 사용해야하는데 그것이 바로 ItemReader,ItemProcessot,ItemWriter이다. 마치 우리가 Controller, Service, Repository 이 3가지 컴포넌트를 사용해서 하나의 API를 구성하는 것처럼 이 ItemReader,ItemProcessot,ItemWriter 3가지를 생각하면 쉽다.

 

ItemReader는 외부에서 데이터를 한 건 씩 읽어오고, ItemProcessor 읽어온 데이터를 가공하고 변환하는 등의 핵심로직이 있고, ItemWriter는 Processor에서 처리된 데이터를 다시 저장/전송/출력하는 역할이다.

 

-Tasklet

:단순하고 짧은 작업을 한 번만 실행하는 구조

여기서는 10,000명의 유저 리포트를 한꺼번에 생성하는 건데, 사실 Batch를 사용하는 철학에 있어서는 Tasklet보다는 Chunk구조가 맞다고 생각한다. 그래서 Chunk구조로 Step을 실행하는 것을 권한다.

 

 

 

1.제일 먼저 스프링 배치 의존성을 추가한다.

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

 

2. yml파일에 메타데이터가 저장될 db관련 설정을 추가해준다.

spring:
 batch:
  job:
    enabled: false
  initialize-schema: never

 

db관련 설정이 무엇인가?

Spring Batch는 다음과 같은 메타데이터용 DB테이블 7개를 내부적으로 사용한다.

아래 각각의 테이블에 대해서 하나하나 알 필요는 없지만 어떤 db인지 기록해놓았다.

그냥 Batch가 자기가 작업을 하다 실패하면 알아서 어디서 실패했는지 확인하고 그 다음부터 다시 작업을 실행할 수 있던 이유가 이러한 DB에 메타데이터들을 저장해놓아서구나 라고 생각하면 된다. 

 

1.BATCH_JOB_INSTANCE : 하나의 Job이 어떤 파라미터로 실행되었는지를 저장함(이를 하나의 단위로 JobInstance로 저장)

2.BATCH_JOB_EXECUTION : JobInstance의 실제 실행 기록에 대해서 저장함(실행 시점,상태,종료 코드 등)

3.BATCH_JOB_EXECUTION_CONTEXT : JobExecution에서 사용한 JobParameter의 상세 기록을 저장함

4.BATCH_STEP_EXECUTION : Job 내부의 각 Step이 어떻게 실행되었는지를 저장함(Step이 성공했는지 실패했는지 등)

5.BATCH_STEP_EXECUTION_CONTEXT : Step실행 중 유지해야할 상태정보나 중간 결과를 저장함(마지막 읽은 row의 위치 등)

6.BATCH_JOB_EXECUTION_PARAMS : Job 실행 중 유지해야할 전역 context 정보 저장

7.BATCH_JOB_EXECUTION_SEQ : ID 자동 증가용 테이블로 DBMS가 자동 증가를 지원하지 않는 경우 사용한다.

 

 

그리고 initialize-schma는 어플리케이션을 시작할 때 자동으로 생성해주는 거를 말하는데 보통 always,never를 쓴다.

always는 항상 테이블을 생성하는 것으로 jpa 설정에서  ddl-auto의 create같은 느낌이다.

never는 생성하지 않는 것으로 이미 만들어놓은 테이블의 초기화를 막기위해 운영환경에서 사용된다.

나 같은 경우는 Spring Boot 3점대 이상 버전을 사용해서 제일 최신의 Spring Batch는 5점대 버전이 사용되었는데.

이 5버전부터는 제일 처음 실행할 때 always를 해도 메타 데이터 테이블이 안만들어지는 에러가 있다고 한다.

이때 그냥 메타데이터 테이블을 수동으로 추가해주면 되는데 나 같은 경우도 수동으로 추가하여 제일처음부터 never에 값을 두었다. 그래서 아래 스크립트를 이용하여 수동으로 추가해준다.

 

아래는 db에 수동으로 추가했을경우 사용할 스크립트(MariaDB 기준)

CREATE TABLE BATCH_JOB_INSTANCE  (
	JOB_INSTANCE_ID BIGINT  NOT NULL PRIMARY KEY ,
	VERSION BIGINT ,
	JOB_NAME VARCHAR(100) NOT NULL,
	JOB_KEY VARCHAR(32) NOT NULL,
	constraint JOB_INST_UN unique (JOB_NAME, JOB_KEY)
) ENGINE=InnoDB;

CREATE TABLE BATCH_JOB_EXECUTION  (
	JOB_EXECUTION_ID BIGINT  NOT NULL PRIMARY KEY ,
	VERSION BIGINT  ,
	JOB_INSTANCE_ID BIGINT NOT NULL,
	CREATE_TIME DATETIME(6) NOT NULL,
	START_TIME DATETIME(6) DEFAULT NULL ,
	END_TIME DATETIME(6) DEFAULT NULL ,
	STATUS VARCHAR(10) ,
	EXIT_CODE VARCHAR(2500) ,
	EXIT_MESSAGE VARCHAR(2500) ,
	LAST_UPDATED DATETIME(6),
	constraint JOB_INST_EXEC_FK foreign key (JOB_INSTANCE_ID)
	references BATCH_JOB_INSTANCE(JOB_INSTANCE_ID)
) ENGINE=InnoDB;

CREATE TABLE BATCH_JOB_EXECUTION_PARAMS  (
	JOB_EXECUTION_ID BIGINT NOT NULL ,
	PARAMETER_NAME VARCHAR(100) NOT NULL ,
	PARAMETER_TYPE VARCHAR(100) NOT NULL ,
	PARAMETER_VALUE VARCHAR(2500) ,
	IDENTIFYING CHAR(1) NOT NULL ,
	constraint JOB_EXEC_PARAMS_FK foreign key (JOB_EXECUTION_ID)
	references BATCH_JOB_EXECUTION(JOB_EXECUTION_ID)
) ENGINE=InnoDB;

CREATE TABLE BATCH_STEP_EXECUTION  (
	STEP_EXECUTION_ID BIGINT  NOT NULL PRIMARY KEY ,
	VERSION BIGINT NOT NULL,
	STEP_NAME VARCHAR(100) NOT NULL,
	JOB_EXECUTION_ID BIGINT NOT NULL,
	CREATE_TIME DATETIME(6) NOT NULL,
	START_TIME DATETIME(6) DEFAULT NULL ,
	END_TIME DATETIME(6) DEFAULT NULL ,
	STATUS VARCHAR(10) ,
	COMMIT_COUNT BIGINT ,
	READ_COUNT BIGINT ,
	FILTER_COUNT BIGINT ,
	WRITE_COUNT BIGINT ,
	READ_SKIP_COUNT BIGINT ,
	WRITE_SKIP_COUNT BIGINT ,
	PROCESS_SKIP_COUNT BIGINT ,
	ROLLBACK_COUNT BIGINT ,
	EXIT_CODE VARCHAR(2500) ,
	EXIT_MESSAGE VARCHAR(2500) ,
	LAST_UPDATED DATETIME(6),
	constraint JOB_EXEC_STEP_FK foreign key (JOB_EXECUTION_ID)
	references BATCH_JOB_EXECUTION(JOB_EXECUTION_ID)
) ENGINE=InnoDB;

CREATE TABLE BATCH_STEP_EXECUTION_CONTEXT  (
	STEP_EXECUTION_ID BIGINT NOT NULL PRIMARY KEY,
	SHORT_CONTEXT VARCHAR(2500) NOT NULL,
	SERIALIZED_CONTEXT TEXT ,
	constraint STEP_EXEC_CTX_FK foreign key (STEP_EXECUTION_ID)
	references BATCH_STEP_EXECUTION(STEP_EXECUTION_ID)
) ENGINE=InnoDB;

CREATE TABLE BATCH_JOB_EXECUTION_CONTEXT  (
	JOB_EXECUTION_ID BIGINT NOT NULL PRIMARY KEY,
	SHORT_CONTEXT VARCHAR(2500) NOT NULL,
	SERIALIZED_CONTEXT TEXT ,
	constraint JOB_EXEC_CTX_FK foreign key (JOB_EXECUTION_ID)
	references BATCH_JOB_EXECUTION(JOB_EXECUTION_ID)
) ENGINE=InnoDB;

CREATE SEQUENCE BATCH_STEP_EXECUTION_SEQ START WITH 1 MINVALUE 1 MAXVALUE 9223372036854775806 INCREMENT BY 1 NOCACHE NOCYCLE ENGINE=InnoDB;
CREATE SEQUENCE BATCH_JOB_EXECUTION_SEQ START WITH 1 MINVALUE 1 MAXVALUE 9223372036854775806 INCREMENT BY 1 NOCACHE NOCYCLE ENGINE=InnoDB;
CREATE SEQUENCE BATCH_JOB_SEQ START WITH 1 MINVALUE 1 MAXVALUE 9223372036854775806 INCREMENT BY 1 NOCACHE NOCYCLE ENGINE=InnoDB;

 

 

3. 배치작업에서 수행될 Job과 Step을 정의한다.

그러면 앞서 개념에서 설명했듯이 본격적으로 배치작업을 수행할 Job을 정의하면 된다. 

Job을 정의한다는 것이 그 내부의 Step도 정의한다는 것이므로 같이 진행해나간다.

여기서는 주간리포트를 생성하는 Job에 대해서만 설명하도록 하겠다.

 

아래 코드를 보면 JobRepository, PlatformTransactionManager의존성이 있는데 이것은 필수적으로 넣어야하고,

WeeklyHealthDataReader / WeekltHealthDataProcessor/ WeeklyHealthDataWriter은 은 chunk구조의 Step을 만들기 위해서 필요한 ItemReader, ItemProcessor, ItemWriter다. 

 

나의 코드를 보면 weeklyAverageHealthJob이라는 하나의 Job을 만들었고 그 내부에는
weeklyAverageStep(사용자의 주간 건강데이터 평균을 내는 작업임)이 먼저 이루어지고
그 다음에 weeklyHealthReportStep(사용자의 주간 데이터를 기반으로 주간 리포트를 생성하는 작업임)으로 진행되게 했다.

 

나는 Chunk 구조와 Tasklet구조를 둘 다 사용해보려고 평균데이터를 내는 작업에는 Chunk구조를 리포트를 생성하는 Step에는 Tasklet구조를 사용했는데 사실 둘 다 Chunk구조로 진행하는 게 맞다.

 

@Configuration
public class SpringBatchConfig {

    private final JobRepository jobRepository; //배치 작업의 실행이력을 db에 저장하는 역할.job이 언제 실행되었는지, 성공했는지 실패했는지 등
    private final PlatformTransactionManager transactionManager; //배치는 db작업을 묶어서 실행하는 경우가 많음. 이걸 트랜잭션 단위로 관리하기 위해 필요한 객체. 얘가 있어야 작업도중 실패하면 롤백이 가능
    private final WeeklyHealthDataReader weeklyHealthDataReader;
    private final WeeklyHealthDataProcessor weeklyHealthDataProcessor;
    private final WeeklyHealthDataWriter weeklyHealthDataWriter;

    public SpringBatchConfig(JobRepository jobRepository, PlatformTransactionManager transactionManager, WeeklyHealthDataReader weeklyHealthDataReader, WeeklyHealthDataProcessor weeklyHealthDataProcessor, WeeklyHealthDataWriter weeklyHealthDataWriter) {
        this.jobRepository = jobRepository;
        this.transactionManager = transactionManager;
        this.weeklyHealthDataReader = weeklyHealthDataReader;
        this.weeklyHealthDataProcessor = weeklyHealthDataProcessor;
        this.weeklyHealthDataWriter = weeklyHealthDataWriter;
    }

//    위클리 잡-----------------------------------------------------------------------------------------------------------------------

    @Bean
    @Qualifier("weeklyAverageHealthJob")
//    실제로 실행할 배치작업을 만듬.Job은 하나의 배치 작업 단위. ex.모든 사용자 주간 건강데이터 평균 내기
    public Job weeklyAverageHealthJob(){
        return new JobBuilder("weeklyAverageHealthJob", jobRepository) //JobBuilder를 job을 만드는 도구로 인자로 이 job의 이름과 jobRepository를 받음. 여기서 바로 job의 이름을 설정하는 것이기도 함
                .start(weeklyAverageStep()) // 이 job을 시작 할 때 어떤 step을 실행할 지 지정하는 것
                .next(weeklyHealthReportStep())
                .build();
    }

    @Bean
//   위에서 지정한 Step을 정의하는 부분(Step은 실제로 데이터를 읽고, 가공하고, 저장하는 작업단위)
    public Step weeklyAverageStep(){
        return new StepBuilder("weeklyAverageStep",jobRepository) //StepBuilder는 Step을 만들기 위한 Builder 마찬가지로 이름과 jobRepository를 인자로 받으며, 바로 step의 이름을 설정
//<InputType,OutputType>chunk(청크크기,트랜잭션매니저) 인풋타입은 읽어들인 데이터로 ItemReader의 출력타입이고 아웃풋타입은 처리된 후의 데이터로(ItemWriter의 입력 타입이다). 즉 이 스텝은 User객체를 읽고->HealthData로 바꾸고 DB에 저장한다는 흐름
                .<User, HealthData>chunk(100,transactionManager) // 이 스텝이 chunk기반으로 처리하겠다.(이외에도 tasklet도 있음), 100개단위의 트랜잭션 단위
                .reader(weeklyHealthDataReader) //이 스텝이 사용할 ItemReader 입력 데이터를 읽는 역할(여기서는 모든 User를 불러옴)
                .processor(weeklyHealthDataProcessor) //이 스탭이 사용할 ItemProcessor 읽어온 데이터를 가공하는 역할(여기서는 해당 유저의 주간 평균 HealthData생성/ 로직)
                .writer(weeklyHealthDataWriter) //이 스탭이 사용할 ItemWriter 처리된 결과를 저장하는 역할(여기서는 DB에 HealthData저장)
                .build();
    }


    @Bean
    public Step weeklyHealthReportStep(){
        return new StepBuilder("weeklyHealthReportStep",jobRepository)
                .tasklet(weeklyHealthReportTasklet,transactionManager)
                .build();
    }

 

 

다시 살펴보자면, weeklyAverageHealthJob 이라는 Job을 정의했다. 이 Job은 사용자의 주간 평균헬스데이터를 집계하고, 그를 바탕으로 헬스리포트를 생성한다.

집계하는 Step이 weeklyAverageStep()으로 정의했고, 헬스리포트를 생성하는 Step을 weeklyHealthReportStep()으로 정의했다. 그리고 다시 weeklyAverageStep은 chunk구조, weeklyHealthReportStep은 tasklet구조로 구성했다.

이때, chunk구조를 사용하기 위해서는 ItemReader,ItemProcessor,ItemWriter 3가지 핵심 컴포넌트가 필요하다. 

그래서 아래부터는 이 weeklyAverageStep을 구성하는 3개의 컴포넌트에 대해서 살펴보겠다.

 

4.Chunk구조에 필요한 3가지 컴포넌트

개념부분에서 보았지만, 다시 설명하자면

ItemReader는 외부에서 데이터를 한 건 씩 읽어오고, 

ItemProcessor 읽어온 데이터를 가공하고 변환하는 등의 핵심로직이 있고,

ItemWriter는 Processor에서 처리된 데이터를 다시 저장/전송/출력하는 역할이다.

나는 이 3가지에 대해서 하나의 API를 구성하는 컨트롤러-서비스-레포지토리 같은 역할과 비슷하다고 생각했는데

그렇게 생각하면 아마 쉽지 않을까 쉽다.

 

먼저 ItemReader 클래스를 만들어야 한다.

@Component
public class WeeklyHealthDataReader implements ItemReader<User> { //ItemReader<T> 읽어올 데이터 타입 T를 지정

    private final UserRepository userRepository;
    private Iterator<User> userIterator; //Iterator는 리스트 등의 컬렉션에 대해 반복해서 하나씩 꺼낼 수 있게 해주는 도구

    public WeeklyHealthDataReader(UserRepository userRepository) {
        this.userRepository = userRepository;
    }


    @Override
    //스프링 배치는 이 read()를 반복해서 실행하면서 데이터를 하나씩 읽어가므로 한 번 호출될 때 마다 한 명의 유저를 반환
    public User read() throws Exception, UnexpectedInputException, ParseException, NonTransientResourceException {
       //userIterator를 초기화하기 위한 코드로 제일 처음 한 번실행할때만 초기화 되면 되니까 userIterator == null이라는 조건 담
        if(userIterator == null){
            List<User> users = userRepository.findAll();
            userIterator = users.iterator(); //전체 유저리스트에서 하나씩 꺼내기 위해 서 iterator를 만들어줌
        }
        return userIterator.hasNext() ? userIterator.next() : null; //꺼낼 다음 유저가 있는지 확인하고 있으면 다음거 꺼내고 없으면 null리턴

    }
}

 

그 다음, ItemProcessor

앞선 ItemReader에서 불러온 User에 대해 일주일간 헬스데이터를 뽑아 최종적으로는 평균집계를 낸 헬스데이터를 만들어 저장하는 것이다. 아래 implements ItemProcesscor<input,output> 에서 input타입은 ItemReader에서 읽어온 타입을 명시하고, output타입에는 ItemWriter에 넘길 타입을 명시해야한다. 여기서는 유저를 데리고와서 헬스데이터를 뽑아 헬스데이터를 저장하는 로직이므로 IteaReader User타입을 불러왔고 ItemWriter에 HealthData타입에 저장해야하므로 input과 output에 User,HealthData타입을 명시했다.

@Component
public class WeeklyHealthDataProcessor implements ItemProcessor<User, HealthData> {

    private final HealthDataRepository healthDataRepository;

    public WeeklyHealthDataProcessor(HealthDataRepository healthDataRepository) {
        this.healthDataRepository = healthDataRepository;
    }


    @Override
    //ItemReader가 넘긴 유저 한 명에 대해 실행되고, 이 안에서 평균 계산
    public HealthData process(User user) throws Exception {
        LocalDate today = LocalDate.now();
        LocalDate startDate = today.with(DayOfWeek.MONDAY).minusWeeks(1); //지난 주 월요일
        LocalDate endDate = today.minusDays(1); //이 메서드는 월요일에 실행될거니까 하루 빼면 지난 주 일요일이 되는 거

        List<HealthData> weekData = healthDataRepository.findByUserIdAndCreatedDateBetweenAndDataType(user.getId(), startDate, endDate, DataType.DAY); //지난 주 월요일~일요일까지의 일간 데이터
        if(weekData.isEmpty()) {
            return null; // 스프링 배치는 null 을 무시하고 넘어가기 때문에 nullPointerException과 같은 에러가 발생하지 않는다
        }
        // 이 헬스데이터가 0월0째주 데이터인지 알려주기 위한 것으로 startDate(지난 주 월요일)기준으로 주차를 뽑아낸다
        WeekFields weekFields =WeekFields.of(Locale.KOREA);
        int year = startDate.getYear();
        int month = startDate.getMonthValue();
        int weekOfMonth = startDate.get(weekFields.weekOfMonth());
        String period = String.format("%d년 %d월 %d주차", year, month, weekOfMonth);


        AverageData averageData = AverageData.makeAvg(weekData); //내가 만든 평균 내는 클래스
        HealthData weekAvg =
                HealthData.builder().dataType(DataType.WEEKAVG).step(averageData.getAvgStep()).heartbeat(averageData.getAvgHeartBeat())
                        .distance(averageData.getAvgDistancd()).calory(averageData.getAvgCalory()).activeCalory(averageData.getAvgActiveCalory())
                        .totalSleepMinutes(averageData.getAvgTotalSleepMinutes()).deepSleepMinutes(averageData.getAvgDeepSleepMinutes()).lightSleepMinutes(averageData.getAvgLightSleepMinutes())
                        .remSleepMinutes(averageData.getAvgRemSleepMinutes())
                        .user(user).createdDate(today).period(period).build();
        user.getMyHealthData().add(weekAvg);
        return weekAvg;
    }
}

 

ItemWriter

@Component
public class WeeklyHealthDataWriter implements ItemWriter<HealthData> {

    private final HealthDataRepository healthDataRepository;

    public WeeklyHealthDataWriter(HealthDataRepository healthDataRepository) {
        this.healthDataRepository = healthDataRepository;
    }

    @Override
    //청크는 리스트 같은 하나의 묶음. 우리가 config에서 설정한 크기. 여기선 100개 단위로 처리. 즉 IteamReader->Processor해서 100번 작업하여 100개의 HealthData가 만들어지면 하나의 청크가 묶인다.
    //하나의 청크가 생기면 자동으로 write()가 호출되어 100개의 HealthData가 자동으로 db에 저장된다.

    public void write(Chunk<? extends HealthData> chunk) throws Exception {
            healthDataRepository.saveAll(chunk);
            healthDataRepository.flush(); // 현재 전체 job에서 이 주간데이터가 다 만들어져도 db에는 바로 저장이 안되서 주간데이터를 기반으로 주간리포트를 만드는 다음 스탭에서 에러가 남. 따라서 flush 는 db로 즉시 쿼리를 날리게 함
    }
}

 

5.Job실행

이제 앞서 Job과 Job을 이루는 Chunk구조의 Step에 대해서도 다 작성했으니 남은건 Job을 실제 필요한 주기마다 실행하게 하는 것이다. 그래서 나는 JobScheduler클래스를 따로 만들었다.

일단 나는 여러개의 다른 Job도 두었지만 아래 코드에서는 주간리포트 Job부분만 뽑았으니 의존성 부분은 JobLauncher와 weeklyAverageHealthJob만 보면 될 것이다.

 

JobLaucher는 스프링 배치에서 말 그대로 Job을 시작시키는 역할을 한다.

나는 Scheduler어노테이션을 통해 매주 월요일 마다 실행하도록 했다.

@Component
public class JobScheduler {
    private final JobLauncher jobLauncher;
    private final Job dailyMakingReport;
    private final Job weeklyAverageHealthJob;
    private final Job weeklyHealthReportJob;
    private final Job monthlyAverageHealthJob;

    public JobScheduler(JobLauncher jobLauncher,@Qualifier("dailyMakingReport") Job dailyMakingReport,
                        @Qualifier("weeklyAverageHealthJob") Job weeklyAverageHealthJob,@Qualifier("monthlyAverageHealthJob") Job monthlyAverageHealthJob) {
        this.jobLauncher = jobLauncher;
        this.dailyMakingReport = dailyMakingReport;
        this.weeklyAverageHealthJob = weeklyAverageHealthJob;
        this.weeklyHealthReportJob = weeklyHealthReportJob;
        this.monthlyAverageHealthJob = monthlyAverageHealthJob;
    }


 1. 주간 평균 헬스데이터,헬스리포트 생성
 
    @Scheduled(cron = "0 0 0 * * MON") // 매주 월요일 마다 실행
    public void runWeeklyHealthDataJob(){

        try{
            JobParameters jobParameters = new JobParametersBuilder()
                    .addLong("timestamp",System.currentTimeMillis()) // 스프링 배치는 jobParameters가 동열하면 같은 job으로 재실행 안함 따라서 매번 실행시 timpstamp같은 고유한 값을 넣어줘야함, 매주 잡이 실행될 수 있도록 하는거
                    .toJobParameters();

            jobLauncher.run(weeklyAverageHealthJob,jobParameters); //실제로 배치 잡을 실행하는 부분
        } catch (Exception e){
            e.printStackTrace();
        }

      }
    }

 

 

 


 

스프링 배치에서 Chunk구조의 Step에서 다시 한 번 유의해야 할 점은

한 청크 단위에서 작업을 수행하던 중 에러가 터지면 전체 트랜잭션 롤백처리된다는 것이다.

트랜잭션의 단위가 chunk의 단위와 동일하기 때문에.

즉 chunk 단위가 100이고, 105번째에서 에러가 터졌다면.

100~200번 작업을 수행하던 중 에러가 터졌다는 것이므로 100~104번 작업이 정상적으로 수행했다하더라도 롤백처리되어 db에 저장된 값은 없다는 것인데 이것에 유념해서 필요하다면 skip정책을 추가한다던가 조치가 필요할 것이다.