본문 바로가기
BackEnd/Project

[PT Manager] Ch03. Batch 이용 기간에 따른 만료

by 개발 Blog 2024. 8. 27.

공부 내용을 정리하고 앞으로의 학습에 이해를 돕기 위해 작성합니다.

이번 글에서는 이용권의 만료 처리를 배치 작업으로 구현해 보겠다. 이용권은 각각 사용할 수 있는 기간이 started_at, ended_at으로 명시되어 있는데, 현재 시점으로 ended_at이 지나면 만료된 것으로 보고 상태 값을 만료 상태로 변경하는 과정이다.

Step 구성

스프링 배치에서 Step은 ItemReader, ItemProcessor, ItemWriter로 구성된다. 이 중 ItemReader는 데이터를 읽어오는 역할을 한다. 스프링 배치는 다양한 데이터를 처리할 수 있는 방법을 제공하는데, 예를 들어 파일에서 데이터를 읽거나 DB에서 데이터를 가져오는 등의 기능을 기본적으로 제공한다.

 

1. ItemReader의 역할

ItemReader 인터페이스의 read 메소드는 스텝 내에서 처리할 아이템 한 개를 반환한다. 이 메소드는 스프링 배치가 호출하며, 각 아이템을 읽어서 다음 단계로 넘기게 된다.

 

2. Step의 처리 과정

Step에서는 ItemReader를 사용해 각 아이템을 개별적으로 읽은 다음 ItemProcessor에게 전달하여 필요한 처리를 수행한다. 이 작업을 chunk 사이즈만큼 반복하고, chunk가 하나 완성되면 목록을 ItemWriter로 전달하여 데이터를 기록한다.

 

3. 데이터를 읽어오는 기법

배치에서 대용량 데이터를 다룰 때는 한 번에 모든 데이터를 가져오는 대신, Cursor 또는 Paging 방식으로 처리할 수 있는 만큼의 데이터만 가져오는 것이 효율적이다.

 

4. ItemWriter의 역할

ItemWriter는 데이터를 쓰는 부분을 담당한다. ItemReader와 ItemProcessor와 달리 아이템을 건건이 쓰는 것이 아니라, chunk 단위로 데이터를 기록한다.

 

5. 실습

이번 실습에서는 ExpirePassesJobConfig를 작성하기 전에, 테스트 환경에서 Spring Batch와 JPA를 설정할 수 있도록 TestBatchConfig를 먼저 설정해 준다. 이 클래스는 Spring Batch 테스트를 위한 설정 파일로, 테스트 환경에서 필요한 다양한 설정을 포함하고 있다. 이 설정 파일을 통해 Batch와 JPA가 올바르게 동작하도록 설정하며, 배치 작업을 테스트할 때 필요한 설정을 포함하고 있다.

package com.example.pass.config;

import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.transaction.annotation.EnableTransactionManagement;

@Configuration
@EnableJpaAuditing
@EnableAutoConfiguration
@EnableBatchProcessing
@EntityScan("com.example.pass.repository")
@EnableJpaRepositories("com.example.pass.repository")
@EnableTransactionManagement
public class TestBatchConfig {
}

@Configuration

  • Spring에서 설정 파일임을 명시. 이 클래스는 Spring 컨텍스트에서 빈으로 등록된다.

@EnableJpaAuditing

  • JPA 엔티티에서 @CreatedDate, @LastModifiedDate 등의 자동 시간 기록을 활성화하기 위해 사용된다.

@EnableAutoConfiguration

  • Spring Boot의 자동 설정 기능을 활성화하여, 필요한 설정들을 자동으로 구성한다.

@EnableBatchProcessing

  • Spring Batch 기능을 활성화하여 배치 작업을 쉽게 설정할 수 있도록 해준다.

@EntityScan, @EnableJpaRepositories

  • @EntityScan: 지정된 패키지에서 JPA 엔티티를 스캔한다.
  • @EnableJpaRepositories: 지정된 패키지에서 JPA 레포지토리를 활성화한다.

@EnableTransactionManagement

  • 트랜잭션 관리를 활성화하여 JPA 트랜잭션을 처리한다.

이제 ExpirePassesJobConfig와 관련된 설정과 테스트 코드를 작성해 보자.

 
 

이번 시간에 구현할 내용은 ExpiredPassesJob이다. ExpirePassesJobConfig는 만료된 이용권을 자동으로 처리하는 배치 작업을 구성하는 클래스이다. 이 작업은 Spring Batch의 구성 요소들을 활용해 작성되었으며, 배치 잡, 스텝, 그리고 각 단계별로 데이터를 처리하는 설정들을 포함하고 있다.

package com.example.pass.job.pass;

import com.example.pass.repository.pass.PassEntity;
import com.example.pass.repository.pass.PassStatus;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing;
import org.springframework.batch.core.configuration.annotation.JobBuilderFactory;
import org.springframework.batch.core.configuration.annotation.StepBuilderFactory;
import org.springframework.batch.core.configuration.annotation.StepScope;
import org.springframework.batch.item.ItemProcessor;
import org.springframework.batch.item.database.JpaCursorItemReader;
import org.springframework.batch.item.database.JpaItemWriter;
import org.springframework.batch.item.database.builder.JpaCursorItemReaderBuilder;
import org.springframework.batch.item.database.builder.JpaItemWriterBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.persistence.EntityManagerFactory;
import java.time.LocalDateTime;
import java.util.Map;

@Configuration
@EnableBatchProcessing
public class ExpirePassesJobConfig {
    private final int CHUNK_SIZE = 1;

    // @EnableBatchProcessing로 인해 Bean으로 제공된 JobBuilderFactory, StepBuilderFactory
    private final JobBuilderFactory jobBuilderFactory;
    private final StepBuilderFactory stepBuilderFactory;
    private final EntityManagerFactory entityManagerFactory;

    public ExpirePassesJobConfig(JobBuilderFactory jobBuilderFactory, StepBuilderFactory stepBuilderFactory, EntityManagerFactory entityManagerFactory) {
        this.jobBuilderFactory = jobBuilderFactory;
        this.stepBuilderFactory = stepBuilderFactory;
        this.entityManagerFactory = entityManagerFactory;
    }

    @Bean
    public Job expirePassesJob() {
        return this.jobBuilderFactory.get("expirePassesJob")
                .start(expirePassesStep())
                .build();
    }

    @Bean
    public Step expirePassesStep() {
        return this.stepBuilderFactory.get("expirePassesStep")
                .<PassEntity, PassEntity>chunk(CHUNK_SIZE)
                .reader(expirePassesItemReader())
                .processor(expirePassesItemProcessor())
                .writer(expirePassesItemWriter())
                .build();

    }

    /**
     * JpaCursorItemReader: JpaPagingItemReader만 지원하다가 Spring 4.3에서 추가되었습니다.
     * 페이징 기법보다 보다 높은 성능으로, 데이터 변경에 무관한 무결성 조회가 가능합니다.
     */
    @Bean
    @StepScope
    public JpaCursorItemReader<PassEntity> expirePassesItemReader() {
        return new JpaCursorItemReaderBuilder<PassEntity>()
                .name("expirePassesItemReader")
                .entityManagerFactory(entityManagerFactory)
                // 상태(status)가 진행중이며, 종료일시(endedAt)이 현재 시점보다 과거일 경우 만료 대상이 됩니다.
                .queryString("select p from PassEntity p where p.status = :status and p.endedAt <= :endedAt")
                .parameterValues(Map.of("status", PassStatus.PROGRESSED, "endedAt", LocalDateTime.now()))
                .build();
    }

    @Bean
    public ItemProcessor<PassEntity, PassEntity> expirePassesItemProcessor() {
        return passEntity -> {
            passEntity.setStatus(PassStatus.EXPIRED);
            passEntity.setExpiredAt(LocalDateTime.now());
            return passEntity;
        };
    }

    /**
     * JpaItemWriter: JPA의 영속성 관리를 위해 EntityManager를 필수로 설정해줘야 합니다.
     */
    @Bean
    public JpaItemWriter<PassEntity> expirePassesItemWriter() {
        return new JpaItemWriterBuilder<PassEntity>()
                .entityManagerFactory(entityManagerFactory)
                .build();
    }

}

Job 설정 (expirePassesJob):

  • 배치 작업의 최상위 구성을 담당하며, 하나의 스텝(expirePassesStep)을 실행하도록 설정한다.
  • 이 Job은 만료된 이용권을 처리하는 전 과정을 포함한다.

Step 설정 (expirePassesStep):

  • Step은 chunk 단위로 데이터를 처리하며, 이 과정에서 데이터를 읽고(reader), 처리하고(processor), 저장(writer)하는 작업을 한다.
  • 여기서 chunk size는 1로 설정되어 있어, 한 번에 하나의 아이템을 처리한다.

ItemReader (expirePassesItemReader):

  • JpaCursorItemReader를 사용해 만료 대상인 이용권을 데이터베이스에서 읽어온다.
  • JPA 쿼리를 통해 status가 PROGRESSED이며, endedAt이 현재 시간보다 이전인 이용권을 조회한다.
  • Cursor 방식으로 데이터를 스트리밍 하듯 하나씩 가져오므로, 대용량 데이터를 처리하기에 적합하다.

ItemProcessor (expirePassesItemProcessor):

  • 조회된 이용권의 상태를 EXPIRED로 변경하고, 만료 시점(expiredAt)을 현재 시점으로 설정한다.
  • 이 단계에서는 데이터를 가공하거나 비즈니스 로직을 추가할 수 있다.

ItemWriter (expirePassesItemWriter):

  • JpaItemWriter를 사용해 처리된 이용권 데이터를 데이터베이스에 반영한다.
  • 이 writer는 JPA의 영속성 컨텍스트를 이용해 변경된 데이터를 자동으로 업데이트한다.

이렇게 설정된 구성은 배치 작업이 실행될 때마다 만료된 이용권을 자동으로 처리하며, 상태 업데이트 및 필요한 데이터를 변경한다. 이 작업은 Spring Batch의 기본 패턴을 따르며, 데이터를 안전하고 효율적으로 처리할 수 있는 구조를 제공한다.

 

다음으로 ExpirePassesJobConfig 배치 작업의 동작을 검증하기 위한 테스트 클래스를 작성한다. 이 테스트 코드는 Spring Batch에서 제공하는 테스트 유틸리티를 활용하여 배치 잡이 의도한 대로 정확히 수행되는지 확인한다. 이를 통해 배치 프로세스의 신뢰성을 보장하고 잠재적인 문제를 사전에 식별할 수 있다.

package com.example.pass.job.pass;

import com.example.pass.config.TestBatchConfig;
import com.example.pass.repository.pass.PassEntity;
import com.example.pass.repository.pass.PassRepository;
import com.example.pass.repository.pass.PassStatus;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.batch.core.ExitStatus;
import org.springframework.batch.core.JobExecution;
import org.springframework.batch.core.JobInstance;
import org.springframework.batch.test.JobLauncherTestUtils;
import org.springframework.batch.test.context.SpringBatchTest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.ContextConfiguration;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;

import static org.junit.jupiter.api.Assertions.assertEquals;

@Slf4j
@SpringBatchTest
@SpringBootTest
@ActiveProfiles("test")
@ContextConfiguration(classes = {ExpirePassesJobConfig.class, TestBatchConfig.class})
public class ExpirePassesJobConfigTest {
    @Autowired
    private JobLauncherTestUtils jobLauncherTestUtils;

    @Autowired
    private PassRepository passRepository;

    @Test
    public void test_expirePassesStep() throws Exception {
        // given
        addPassEntities(10);

        // when
        JobExecution jobExecution = jobLauncherTestUtils.launchJob();
        JobInstance jobInstance = jobExecution.getJobInstance();

        // then
        assertEquals(ExitStatus.COMPLETED, jobExecution.getExitStatus());
        assertEquals("expirePassesJob", jobInstance.getJobName());

    }

    private void addPassEntities(int size) {
        final LocalDateTime now = LocalDateTime.now();
        final Random random = new Random();

        List<PassEntity> passEntities = new ArrayList<>();
        for (int i = 0; i < size; ++i) {
            PassEntity passEntity = new PassEntity();
            passEntity.setPackageSeq(1);
            passEntity.setUserId("A" + 1000000 + i);
            passEntity.setStatus(PassStatus.PROGRESSED);
            passEntity.setRemainingCount(random.nextInt(11));
            passEntity.setStartedAt(now.minusDays(60));
            passEntity.setEndedAt(now.minusDays(1));
            passEntities.add(passEntity);

        }
        passRepository.saveAll(passEntities);

    }

}

 

@SpringBatchTest, @SpringBootTest, @ActiveProfiles("test"), @ContextConfiguration

  • @SpringBatchTest: Spring Batch에서 제공하는 테스트 유틸리티를 사용하기 위해 설정한다.
  • @SpringBootTest: Spring Boot 애플리케이션 컨텍스트를 로드하여 테스트를 실행 한다.
  • @ActiveProfiles("test"): 테스트용 프로파일을 활성화하여 테스트에 적합한 환경을 설정한다.
  • @ContextConfiguration: 테스트 시 필요한 구성 클래스(ExpirePassesJobConfig, TestBatchConfig)를 로드한다.

의존성 주입

  • JobLauncherTestUtils: 배치 잡을 테스트할 때 필요한 유틸리티 클래스로, 잡 실행, 스텝 실행 등을 도와준다.
  • PassRepository: 테스트 데이터 저장 및 조회를 위해 사용된다.

test_expirePassesStep 메서드

  • 준비 단계 (given): addPassEntities 메서드를 통해 테스트에 필요한 10개의 PassEntity 객체를 데이터베이스에 저장한다.
  • 실행 단계 (when): jobLauncherTestUtils.launchJob()을 사용해 배치 잡을 실행한다.
  • 검증 단계 (then): 잡 실행 결과(ExitStatus.COMPLETED)와 잡 이름(expirePassesJob)이 예상한 대로 동작하는지 검증한다.

addPassEntities 메서드

  • 이 메서드는 테스트를 위해 만료 대상이 될 PassEntity 객체들을 생성해 데이터베이스에 저장한다.
  • 생성된 각 PassEntity는 현재 시점에서 60일 전부터 시작되었고, 1일 전에 종료된 상태로 설정된다. 이 설정은 만료 처리 대상이 될 조건을 만족한다.

  • 위 테스트 코드 실행 결과는 이용권 상태를 만료 처리하는 ExpirePassesJob의 동작을 확인하는 과정이다. 먼저 10개의 PassEntity를 생성하고, 각각의 종료 시점이 현재 시점보다 과거인 데이터를 만료 대상으로 설정한 후, Batch Job을 실행했다.
  • 로그 출력에서는 Hibernate가 생성한 쿼리를 확인할 수 있다. 각 PassEntity의 상태가 PROGRESSED에서 EXPIRED로 변경되고, expiredAt 필드가 현재 시간으로 설정되었다.

  • 이 이미지는 해당 Job이 완료된 후 데이터베이스에 반영된 결과를 보여준다. 10개의 레코드 모두 상태가 EXPIRED로 변경된 것을 확인할 수 있다.
  • 이 결과는 만료 처리 로직이 예상대로 동작했음을 보여주며, Job 실행 후에 데이터베이스의 이용권 상태가 정확히 업데이트되었음을 검증한다.
 

오늘은 Spring Batch에서 ItemReader와 ItemWriter의 개념을 이해하고, 이를 활용해 Chunk 기반의 Step을 구현해 보았다. 실습을 통해 이용권의 만료 처리 Job을 구현하고 테스트까지 완료하였다.