Spring Batch

05. Backup Batch - 1

  • -

이번 포스트에서는 간단한 Backup batch를 만들어보면서 Step을 구성해보자.

기본 작업

 

배치 시나리오 및 Mapper 설정

이번에 처리해볼 배치는 매월 source인 Sakila에 있는 대량의 결재 정보를 target인 quietjun에 백업하는 작업이다.

첫 번째 배치

실제 비지니스에서도 아주 오래된 결재 내역을 운영 DB에서 계속 가져갈 경우 부하가 증가할 수 있기 때문에 최근의 이력만을 가져가는게 유리한데 그렇다고 무작정 지워버리면 법적으로 문제되기 때문에 주기적인 백업이 필요한 상황이다.

현재 sakila database에 쌓인 결재 내역은 다음의 쿼리로 확인할 수 있다.

select date_format(payment_date, '%Y-%m') as 'yearMonth' ,  count(payment_id) as '결재건수'
from payment
group by yearMonth;

 

 
 

backup table 및 DTO 작성

sakila의 payment를 참조하여 backup_db에 backup 테이블을 구성한다.

CREATE TABLE payment_backup (
    payment_id SMALLINT primary key,
    customer_id SMALLINT UNSIGNED NOT NULL,
    staff_id TINYINT UNSIGNED NOT NULL,
    rental_id INT DEFAULT NULL,
    amount DECIMAL(5,2) NOT NULL,
    payment_date DATETIME NOT NULL,
    last_update TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    backup_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 
    payment_ym VARCHAR(7) NOT NULL,
    INDEX idx_year_month (payment_ym),
    INDEX idx_customer (customer_id),
    INDEX idx_payment_date (payment_date)
);

그리고 위 테이블을 참조해서 DTO를 작성해보자. 이 DTO는 sakila와 backup에서 동일하게 사용한다.

@Data
public class Payment {
    private Integer paymentId;
    private Integer customerId;
    private Integer staffId;
    private Integer rentalId;
    private BigDecimal amount; // 실수계산 정확도를 위해 double보다 BigDecimal 권장
    private LocalDateTime paymentDate;
    private LocalDateTime lastUpdate;
    
    private String paymentYm; // YYYYMM 형태로 저장
    private LocalDateTime backUpDate;
}

 

데이터 조회를 위한 mapper 구성

<!--sakila-mapper.xml-->
<select id="selectMonthlyPayments" resultMap="paymentBasic">
  SELECT
    payment_id, customer_id, staff_id, rental_id, amount,
    payment_date, last_update
  FROM payment
  WHERE DATE_FORMAT(payment_date, '%Y-%m') = #{yearMonth}
  ORDER BY payment_id ASC
</select>

<resultMap type="payment" id="paymentBasic" autoMapping="true">
  <id property="paymentId" column="payment_id" />
  <result property="customerId" column="customer_id" />
  <result property="staffId" column="staff_id" />
  <result property="rentalId" column="rental_id" />
  <result property="paymentDate" column="payment_date" />
  <result property="lastUpdate" column="last_update" />
  <result property="paymentYm" column="payment_ym" />
  <result property="backupDate" column="backup_date" />
</resultMap>
public interface SakilaMpper {
    public List<Payment> selectMonthlyPayments(String paymentDate);
}

 

데이터 저장을 위한 mapper 구성

<insert id="insertPaymentBackup" parameterType="Payment">
  INSERT INTO payment_backup (payment_id, customer_id, staff_id, rental_id, amount, 
                              payment_date, last_update, backup_date, payment_ym) 
  VALUES (#{paymentId}, #{customerId}, #{staffId}, #{rentalId}, 
                   #{amount}, #{paymentDate}, #{lastUpdate}, #{backupDate}, #{paymentYm})
</insert>
public interface QuietjunMapper {
    public int insertPaymentBackup(Payment payment);
}

 

ItemReader

 

기본

ItemReader는 배치 작업에서 입력 소스에서 데이터를 가져올 수 있는 기본 interface이고 read() 메서드를 통해 대상을 조회할 수 있다.

@FunctionalInterface
public interface ItemReader<T> {
	@Nullable T read() throws Exception;
}

ItemReader는 다양한 입력 소스를 지원한다.

  • Flat File: CSV 같은 일반 텍스트 파일에서 한 줄 씩 데이터를 읽어와 고정된 위치, 특정 문자 기준으로 데이터를 처리한다. 대표적 구현체로 FlatFileItemReader가 사용된다.
  • XML: XML 파일에서 데이터를 읽어오는데 파싱이나 매핑 같은 기술에 구애받지 않고 유연하게 처리할 수 있다. 대표적인 구현체로  StaxEventItemReader 를 들 수 있다.
  • Database: SQL을 활용하는 ItemReader들로 사용하는 persistence framework에 따라 다양하게 제공된다.
    • JDBC 기반: JdbcCursorItemReader, JdbcPagingItemReader
    • JPA 기반: JpaCursorItemReader, JpaPagingItemReader
    • MyBatis 기반: MyBatisCursorItemReader, MyBatisPagingItemReader

이 외에도 다양한 ItemReader가 제공되고 있다.

일반적으로 Cursor 기반과 Paging 기반은 다음의 차이를 갖는다.

기준 Mybatis Cursor 기반 (MybatisCursorItemReader) Mybatis Paging 기반 (MybatisPagingItemReader)
데이터 로딩 방식 Mybatis의 resultHandler 또는 cursor 기능으로 쿼리 결과를 한 건씩 스트리밍 처리. 쿼리는 한 번 실행. OFFSET과 LIMIT 등을 활용하여 페이지 단위로 쿼리를 여러 번 실행하여 데이터를 가져옴.
메모리 사용 한 번에 한 건만 처리하므로 매우 효율적 (특히 초거대 데이터셋에 유리). 페이지 단위로 데이터를 메모리에 올리므로, 페이지 크기에 따라 메모리 사용량 증가.
트랜잭션/DB 연결 쿼리 결과를 모두 읽을 때까지 DB 연결과 트랜잭션을 계속 유지. 각 페이지 쿼리 실행 시에만 짧은 트랜잭션을 사용하며, 필요한 시점에 DB 연결을 맺고 끊을 수 있음.
데이터 정합성 쿼리 실행 시점의 스냅샷을 기반으로 하므로, 작업 도중 데이터 변경에도 높은 일관성 유지. 데이터 중복/누락 위험 적음. 페이지가 넘어갈 때마다 쿼리가 재실행되므로, 작업 도중 데이터 추가/삭제 시 중복되거나 누락될 가능성 있음.
적합한 상황 - 데이터 정합성이 매우 중요하고, DB 연결을 오래 유지해도 괜찮을 때
- 메모리 부족이 우려되는 초대용량 데이터 처리 시
- 대부분의 배치 작업 (더 일반적)
- 짧은 트랜잭션으로 DB 부하를 분산해야 할 때
- 재시작 안정성을 확보하고 싶을 때

 

ItemReader 빈 작성

우리는 MyBatis를 이용하고 있으므로 MyBatisCursorItemReader를 이용해서 ItemReader를 작성해보자.

@Bean
@StepScope
MyBatisCursorItemReader<Payment> paymentReader(
        @Qualifier("sakilaSqlSessionFactory") SqlSessionFactory sqlSessionFactory,
        @Value("#{jobParameters['yearMonth']}") String yearMonth) {
        
    return new MyBatisCursorItemReaderBuilder<Payment>()
            .sqlSessionFactory(sqlSessionFactory)
            .queryId("com.quietjun.batch.model.mapper.SakilaMapper.selectMonthlyPayments")
            .parameterValues(Map.of("yearMonth", yearMonth))
            .build();
}

 

@StepScope와 SPEL

@StepScope지연 바인딩(Late Binding)을 위한 annotation으로 해당 빈이 존재하는 scope(ex: singleton, prototype, ...)를 step으로 지정하는 애너테이션이다. 즉 Step 마다 새로 만들어야 함을 의미한다.

이 빈은 Job이 실행되면서 전달되는 JobParameters에서 yearMonth를 사용해야 하는데

  • 만약 @StepScope가 없다면 애플리케이션 구동 시 singleton으로 paymentReader 빈이 만들어지고 아직 JobParmaeters가 없기 때문에 yearMonth를 전달받지 못한다.
  • @StepScope는 지연 바인딩을 일으켜 애플리케이션이 시작될 때는 paymentReader에 대한 Proxy만 생성하고 나중에 Step 이 실행되면서 JobParameters를 받을 수 있을 때 실제 빈 객체가 생성된다.

StepScope의 빈은 자연스럽게 StepExecutionContext의 내용에 접근할 수 있는데 StepExecutionContext는 JobParameters에 접근할 수 있다. 이 정보에 쉽게 문자열 형태로 접근하기 위해 SPEL(Spring Expression Language)을 사용한다.

@Value("#{jobParameters['yearMonth']}") String yearMonth

SPEL을 통해 접근할 수 있는 속성과 대상은 다음과 같다.

  • jobParameters: 입력값
  • stepExecutionContext: Step 별 저장소 (Map)
  • jobExecutionContext: Job 전체 저장소 (Map)
  • stepExecution: Step 상태 객체 (통계 + 저장소) 
  • jobExecution: Job 상태 객체

 

ItemProcessor

 

기본

ItemProcessor는 ItemReader에서 read()를 통해 반환된 Item을 ItemWriter로 넘겨주기 전에 변환하기 위한 로직은 갖는 빈이다. 물론 간단한 변환의 경우 ItemReader/ItemWriter에 변환 로직을 넣을 수 있지만 읽기/쓰기와 변환이 혼재되면 재사용이 어렵기 때문에 분리해서 사용하는 것이 권장된다.

@FunctionalInterface
public interface ItemProcessor<I, O> {
	@Nullable O process(I item) throws Exception;
}

 

ItemProcessor 빈 작성

다음은 ItemProcessor의 예이다. source에서 조회된 Payment에 backupdate와 yearmonth 속성을 설정하는 변환을 가한다. 여기서도 jobParameters를 사용하기 위해 @StepScope를 적용했다.

@Bean
@StepScope
ItemProcessor<Payment, Payment> paymentProcessor(
        @Value("#{jobParameters['yearMonth']}") String yearMonth) {
        
    return payment -> {
        // 이럴일은 없지만 혹시 null인 경우 처리
        if (payment.getPaymentId() ==null) {
            return null;  // Writer로 전달 안 됨
        }
        // 백업 시점 기록
        payment.setBackupDate(LocalDateTime.now());
        // 년월 정보 추가
        payment.setPaymentYm(yearMonth);         
        return payment;
    };
}

만약 ItemProcessor에서 null이 반환되는 경우는 writer로 전달되지 않는다. 불필요한 데이터를 filtering 할 때 null을 반환해주자.

 

ItemWriter

 

기본

ItemWriter는 ItemReader의 반대말이다. 따라서 flat file, xml, database 등 다양한 소스를 대상으로 데이터 쓰기를 지원한다.

@FunctionalInterface
public interface ItemWriter<T> {
    void write(Chunk<? extends T> chunk) throws Exception;
}

ItemWrite는 Chunk 단위로 한방에 커밋이 발생한다. 즉 한 Chunk 안에 있는 모든 데이터가 성공적으로 처리돼야 한 번에 DB에 커밋되고 하나라도 문제가 발생하면 해당 Chunk의 데이터는 모두 롤백 된다. 

Chunk의 크기는 성능과도 연결되는데 너무 작으면 I/O 작업이 많아져서 오버헤드가 발생한다. 반면 너무 커지면 적재에 대한 비용이 발생하며 실패 시 부담이 커진다. Chunk의 크기는 Step에서 지정한다.

일반적으로 사용되는 데이터베이스 기반의 writer는 다음과 같이 정리해볼 수 있다.

  • JDBC 기반:  JdbcBatchItemWriter
  • JPA 기반: JpaItemWriter
  • MyBatis 기반: MyBatisBatchItemWriter

이 외에도 다양한 ItemWriter가 제공되고 있다.

 

ItemWriter 구현

다음은 MyBatisBatchItemWriter를 사용하는 예이다.

코드는 특별한 부분이 없고 ItemReader와 달리 @StepScope가 없는 것을 확인할 수 있다. 이 ItemWriter는 Job에서 추가적인 정보를 받을 필요가 없기 때문에 애플리케이션 시작 시점에 빈을 singleton 타입으로 만들면 되기 때문이다.

@Bean
MyBatisBatchItemWriter<Payment> paymentWriter(
            @Qualifier("quietjunSqlSessionFactory") SqlSessionFactory sqlSessionFactory) {
        
    return new MyBatisBatchItemWriterBuilder<Payment>()
            .sqlSessionFactory(sqlSessionFactory)
            .statementId("com.quietjun.batch.model.mapper.QuietjunMapper.insertPaymentBackup")
            .build();
}

 

Step 구성

 

기본

Step은 앞서 살펴봤듯이 Job 내부의 독립적이고 순차적인 처리 단계로 앞서 작성한 ItemReader, ItemProcessor, ItemWriter로 구성된다. 다음의 예는 Chunk_Size=2인 상태에서 Step의 동작 과정을 보여준다. 아이템이 많다면 read ~ write 과정이 반복된다고 보면 된다.

Step의 동작 절차

Step을 생성할 때는 StepBuilder를 이용해서 필요한 요소들은 넣어주면 되기 때문에 특별한 어려움은 없다.

ItemReader등을 만들 때 @StepScope를 썻던 것 처럼 Step을 만들 때 @JobScope를 사용할 수 있는데 이 애너테이션 역시 late binding을 위한 것이다. 만약 Step에서 JobParameters를 사용할 예정이라면 필요하지만 그렇지 않다면 불필요하다.

 

Step 구현

private static final int CHUNK_SIZE = 100;
    
@Bean
Step backupPaymentStep(
        JobRepository jobRepository,
        @Qualifier("quietjunTransactionManager") PlatformTransactionManager transactionManager,
        MyBatisCursorItemReader<Payment> reader,
        ItemProcessor<Payment, Payment> processor,
        MyBatisBatchItemWriter<Payment> writer) {
        
    return new StepBuilder("backupPaymentStep", jobRepository)
            .<Payment, Payment>chunk(CHUNK_SIZE)
            .transactionManager(transactionManager)
            .reader(reader)
            .processor(processor)
            .writer(writer)
            .build();
}

 

'Spring Batch' 카테고리의 다른 글

07. Backup Batch - 3  (0) 2026.01.07
06. Backup Batch - 2  (0) 2026.01.06
04. Project 구성  (1) 2026.01.04
03. SpringBatch 핵심 개념  (0) 2026.01.03
02. Spring Batch 계층 구조  (0) 2026.01.02
Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.