[Spring-batch] 외부 API 호출 및 DB 저장 Batch 구현

오늘내일·2024년 7월 19일
0

외부 API를 호출하여 날씨 데이터를 읽어오고, 읽어온 데이터를 DB에 저장하는 Batch 작업을 구현하여 보자.

주간날씨 정보를 기상청 open API(이하 ‘외부 API’)를 통해 호출하여 데이터를 읽어오고, 읽어온 데이터를 DB에 저장하는 Batch 작업을 구현해보려 한다.

혹시 외부 API 호출하는 작업이나 이 프로젝트 관련하여 혹시 궁금하신 점이 있으신 분들은 아래에서 전체 코드를 확인할 수 있다.

1. 기존 주간날씨 호출 및 저장 메소드 확인 및 reader, processor, writer 구현 구상

  • batch 작업을 구현하기 위해서는 기존에 구현한 주간날씨 호출 및 DB저장 메소드를, Step을 구성할 수 있는 reader, processor, writer로 구분하여 각각 구현하면 될 듯 하다.
  • 아래는 기존에 구현한 주간날씨 호출 및 DB저장 메소드이다.
@Scheduled(cron = "${schedules.cron.update.weather}")
  @Transactional
  public void updateLongTermWeather()
      throws URISyntaxException, NoSuchFieldException, IllegalAccessException {
    LocalDateTime now = LocalDateTime.now();

    List<Location> allLocation = locationRepository.findAll();

    for (Location location : allLocation) {
      WeeklyForecastItem forecast = getWeeklyForecastFromApi(now,
          location.getLocationLandCode());
      WeeklyTemperatureItem temperature = getWeeklyTemperatureFromApi(now,
          location.getLocationCode());

      for (int i = 2; i <= 6; i++) {
        String minTemperature = "minTemperaturePlus" + i + "Days";
        String maxTemperature = "maxTemperaturePlus" + i + "Days";
        String morningForecast = "weatherForecastAmPlus" + i + "Days";
        String afternoonForecast = "weatherForecastPmPlus" + i + "Days";

        WeeklyWeather weeklyWeather = WeeklyWeather.builder()
            .date(now.toLocalDate().plusDays(i))
            .minTemperature(
                getField(temperature.getClass(), minTemperature).getInt(temperature))
            .maxTemperature(
                getField(temperature.getClass(), maxTemperature).getInt(temperature))
            .morningWeatherForecast(
                getField(forecast.getClass(), morningForecast).get(forecast).toString())
            .afternoonWeatherForecast(
                getField(forecast.getClass(), afternoonForecast).get(forecast).toString())
            .location(location)
            .build();

        weeklyWeatherRepository.save(weeklyWeather);
      }
    }

    weeklyWeatherRepository.deleteAllByCreatedAtBefore(now);
  }
  • 메소드를 간단하게 설명하자면,
    • 1단계 : DB에 저장되어 있는 전체 지역(Location)을 List로 읽어온다. 외부 API에 날씨를 호출하기 위해서 지역 관련 정보(지역코드)가 필요하기 때문이다.
    • 2단계 : 지역 List를 순회하며 해당 지역 날씨를 외부 API로부터 getWeeklyForecastFromApi(), getWeeklyTemperatureFromApi() 메서드를 통해 호출하여 WeeklyForecastItem, WeeklyTemperatureItem 객체를 만든다.
    • 3단계 : 외부 API로 부터 데이터를 읽어와 만든 객체를 적절하게 변환하여 WeeklyWeather 객체로 만든 후 DB에 저장한다.
    • 4단계 : 위 과정을 전부 끝마치면 기존 날씨 정보를 전부 삭제한다.
  • 메소드를 reader, processor, writer 관점으로 나눠보자.
    • reader : 기존에 Location(지역) 객체를 DB에서 읽어오는 과정
      • 위 코드에서는 Location 객체를 findAll 메서드를 사용하여 List로 처리하였는데, batch 작업으로 변경하려면 Location 객체를 하나 또는 일부를 읽어와 작업을 처리하는 것이 좋을 것 같다. 왜냐하면 하나 또는 일부를 읽어와야 Batch 작업의 장점인 chunk로 관리 할 수 있지 않을까 해서이다.
    • processor : reader를 통해 읽어들인 Location을 파라미터로 입력하여 외부 API 호출하여 읽은 중기날씨 데이터를 WeeklyWeather 객체로 변경하는 과정
    • writer : WeeklyWeather 객체 DB 저장 과정

2. ItemReader 구현

  • JpaCursorItemReader와 JpaPagingItemReader에 대해서 자세히 알고 싶으면 전에 정리한 글을 참고해주기 바란다.(참고 : https://velog.io/@nadoran/Spring-batch-Jpa-ItemReader)
  • JpaCursorItemReader와 JpaPagingItemReader 중 카카오페이 개발자 분이 작성한 글을 참고하여 JpaPagingItemReader를 선택하였다. 성능 저하가 우려되지만 현재 내가 읽어들일 Location은 국내 지역으로 총 250개로 한정되어 있기 때문이다. JpaCursorItemReader를 사용할 경우 애플리케이션에서 직접 Cursor 처리를 하여 성능이 우수할 수 있으나, 트래픽이 많아질 경우 서버에 부하가 걸릴 것이 우려되기 때문이다.(참고 : https://tech.kakaopay.com/post/ifkakao2022-batch-performance-read/)
  • 따라서 아래와 같이 설정하였다.
  @Bean
  public JpaPagingItemReader<Location> locationReader(EntityManagerFactory emf) {
    return new JpaPagingItemReaderBuilder<Location>()
        .name("locationReader")
        .entityManagerFactory(emf)
        .queryString("select L from Location L")
        .pageSize(5)
        .build();
  }

  • TroubleShooting : queryString에 최초에는 "select l from location l"라고 입력했는데, location 엔티티를 찾지 못 해 예외를 던졌다. 그래서 검색을 해보니 Jpql은 대소문자를 구분한다고 한다. 그래서 위와 같이 “select L from Location L"이라고 입력하니 정상 작동하였다.

3. ItemProcessor 구현

  • ItemProcessor를 구현하기 위해 기존에 구현했던 주간날씨 호출 및 DB저장 메서드를 아래와 같이 location을 파라미터로 입력하여 WeeklyWeather 리스트를 반환하는 메서드로 별도 분리하였다.
      @Scheduled(cron = "${schedules.cron.update.weather}")
      @Transactional
      public void updateLongTermWeather()
          throws URISyntaxException, NoSuchFieldException, IllegalAccessException {
        LocalDateTime now = LocalDateTime.now();
    
        List<Location> allLocation = locationRepository.findAll();
    
        for (Location location : allLocation) {
          List<WeeklyWeather> weeklyWeathers = getWeeklyWeatherByLocation(now, location);
    
          weeklyWeatherRepository.saveAll(weeklyWeathers);
        }
    
        weeklyWeatherRepository.deleteAllByCreatedAtBefore(now);
      }
    
      public List<WeeklyWeather> getWeeklyWeatherByLocation(LocalDateTime now,
          Location location)
          throws URISyntaxException, NoSuchFieldException, IllegalAccessException {
        List<WeeklyWeather> weeklyWeathers = new CopyOnWriteArrayList<>();
        WeeklyForecastItem forecast = getWeeklyForecastFromApi(now,
            location.getLocationLandCode());
        WeeklyTemperatureItem temperature = getWeeklyTemperatureFromApi(now,
            location.getLocationCode());
    
        for (int i = 2; i <= 6; i++) {
          String minTemperature = "minTemperaturePlus" + i + "Days";
          String maxTemperature = "maxTemperaturePlus" + i + "Days";
          String morningForecast = "weatherForecastAmPlus" + i + "Days";
          String afternoonForecast = "weatherForecastPmPlus" + i + "Days";
    
          WeeklyWeather weeklyWeather = WeeklyWeather.builder()
              .date(now.toLocalDate().plusDays(i))
              .minTemperature(
                  getField(temperature.getClass(), minTemperature).getInt(temperature))
              .maxTemperature(
                  getField(temperature.getClass(), maxTemperature).getInt(temperature))
              .morningWeatherForecast(
                  getField(forecast.getClass(), morningForecast).get(forecast).toString())
              .afternoonWeatherForecast(
                  getField(forecast.getClass(), afternoonForecast).get(forecast).toString())
              .location(location)
              .build();
    
          weeklyWeathers.add(weeklyWeather);
        }
        
        weeklyWeatherRepository.deleteAllByLocation(location);
    
        return weeklyWeathers;
      }
    • 제일 마지막에 weeklyWeatherRepository.deleteAllByLocation()을 통해 과거 데이터를 삭제하는 코드를 작성하였다. chunk단위로 transaction이 관리되기 때문에 삭제 코드를 작성하였다.(그러나 주간날씨를 호출하고 저장하는 batch 작업 중에 삭제를 하는 것은 아쉬운 부분이 있다. 예상치 못 한 사유로 데이터가 제거되고 item이 저장되지 않으면 데이터 유실 가능성이 있기 때문이다. 따라서 item을 저장하는 batch 작업과 별도로 과거 데이터를 삭제하는 batch 작업을 실행하는 것이 바람직해 보인다.)
  • 위 메서드를 활용하여 ItemProcessor의 구현체인 LocationItemProcessor 클래스를 아래와 같이 구현하였다.
    @RequiredArgsConstructor
    @Setter
    public class LocationItemProcessor implements ItemProcessor<Location, List<WeeklyWeather>> {
    
    	// 날씨 호출 서비스
      private final LongTermWeatherService longTermWeatherService;
      private LocalDateTime now;
    
      @Override
      public List<WeeklyWeather> process(Location item) throws Exception {
        return longTermWeatherService.getWeeklyWeatherByLocation(now, item);
      }
    }
    • 위에서 분리한 날씨 호출 메서드를 활용하기 위하여 생성자를 통해 LongTermWeatherService를 주입하였다.
    • 또, 날씨를 호출할 때 현재 시간이 필요하기 때문에 LocationItemProcessor가 생성된 후 현재 시간을 설정하기 위해 @Setter를 사용하였다.
    • 주간 날씨를 호출하기 때문에 하나의 Location객체 당 5개의 WeeklyWeather 객체가 생성되기 때문에 List 형식으로 반환값을 처리하였다.
  • 위 구현체를 사용하여 아래와 같이 Bean을 등록했다.
      @Bean
      public LocationItemProcessor locationProcessor(LongTermWeatherService longTermWeatherService) {
        LocationItemProcessor locationItemProcessor = new LocationItemProcessor(
            longTermWeatherService);
        locationItemProcessor.setNow(LocalDateTime.now());
        return locationItemProcessor;
      }

4. ItemWriter 구현

  • JpaItemWriter에 대해서 자세히 알고 싶으면 전에 정리한 글을 참고해주기 바란다.(참고 : https://velog.io/@nadoran/Spring-batch-JpaItemWriter)
  • JpaItemWriter를 바로 사용하기에는 나의 경우 문제가 있었다. JpaItemWriter는 Entity를 처리하도록 되어 있는데, 나는 이전에 LocationItemProcessor에서 Entity의 List 형태로 반환했기 때문이다.
  • 이를 처리하기 위해서 블로그 글을 참고하여 JpaItemWriter를 한번 감싸서 아래와 같이 새로운 List 전용 객체(WeeklyWeatherItemWriter)를 구현하였다.(참고 : https://jojoldu.tistory.com/140)
  • 위 블로그를 참고하여 JpaItemWriter를 상속받아 작성하였지만 JpaItemWriter에는 entityManagerFactory가 설정되지 않으면 예외를 던지게 되어있어 빌드 과정에서 예외가 발생하였다. 따라서 JpaItemWriter를 상속받는 것이 아닌 ItemWriter 인터페이스를 구현하는 방식으로 변경하였다.
    public class WeeklyWeatherItemWriter<T> implements ItemWriter<List<T>> {
    
      private JpaItemWriter<T> wrapped;
    
      public WeeklyWeatherItemWriter(JpaItemWriter<T> wrapped) {
        this.wrapped = wrapped;
      }
    
      @Override
      public void write(Chunk<? extends List<T>> items) {
        Chunk<T> chunk = new Chunk<>();
    
        for (List<T> subList : items) {
          chunk.addAll(subList);
        }
    
        wrapped.write(chunk);
      }
    }
  • Bean등록은 아래와 같이 하였다.
      @Bean
      public WeeklyWeatherItemWriter<WeeklyWeather> weeklyWeatherWriter(
          EntityManagerFactory emf) {
        JpaItemWriter<WeeklyWeather> writer = new JpaItemWriterBuilder<WeeklyWeather>()
            .entityManagerFactory(emf)
            .usePersist(true)
            .build();
        return new WeeklyWeatherItemWriter<>(writer);
      }

5. Step 구성

  • Step은 아래와 같이 구성하였다.
      @Bean
      public Step weeklyWeatherStep(JobRepository jobRepository,
          JpaTransactionManager transactionManager,
          JpaPagingItemReader<Location> reader,
          ItemProcessor<Location, List<WeeklyWeather>> processor,
          WeeklyWeatherItemWriter<WeeklyWeather> writer) {
        return new StepBuilder("weeklyWeatherStep", jobRepository)
            .<Location, List<WeeklyWeather>>chunk(chunkSize, transactionManager)
            .reader(reader)
            .processor(processor)
            .writer(writer)
            .build();
      }
    • transaction을 관리하기 위해 JpaTransactionManager를 파라미터로 입력받는다.
    • JpaTransactionManager는 아래와 같이 별도 Bean 등록하였다.
      @Configuration
      public class JpaTransactionConfiguration {
        @Bean
        public JpaTransactionManager transactionManager() {
          return new JpaTransactionManager();
        }
      }

6. Job 구성

  • Job은 아래와 같이 구성하였다.
      @Bean
      public Job weeklyWeatherJob(JobRepository jobRepository, Step weeklyWeatherStep) {
        return new JobBuilder("weeklyWeatherJob", jobRepository)
            .start(weeklyWeatherStep)
            .build();
      }

TroubleShooting

  • SpringBatch 구동시 'batch_job_instance' doesn't exist 에러가 발생하여 진행이 안되고 있는 상황 발생하여 블로그(https://wylee-developer.tistory.com/68)를 참고하여 profile 파일에 아래와 같이 입력하여 해결함
    spring.batch.jdbc.initialize-schema=*always*
  • JobExecutionAlreadyRunningException 예외 : 디버깅을 하면서 Batch작업을 강제종료하니 jobExecution, stepExecution에서 작업의 진행상황을 제대로 모니터링하지 못 했다. 그래서 BATCH_JOB_EXECUTION, BATCH_STEP_EXECUTION 테이블을 보니 STATUS, EXIT_CODE가 STARTED 또는 UNKNOWN으로 되어 있거나 비어져 있는 상태였다. 그래서 수동으로 STATUS, EXIT_CODE를 FAILED로 입력하니 정상 작동하였다.
  • JpaItemWriter, JpaPagingItemReader 등에서 EntityManagerFactory 빈을 호출하여 DI해야 하는데 debuging하여 보니 EntityManagaerFactory 가 null로 제대로 주입되지 않았다. 그래서 EntityManagerFactory가 bean 등록하는 과정의 LocalContainerEntityManagerFactoryBean 객체에서 EntityManagerFactory를 반환하는 변수명이 emf로 되어 있는 것을 발견하여, 주입받는 EntityManagerFactory의 property의 변수명을 emf로 변경하였더니 정상 작동하였다. Jpa를 사용하기 위한 EntityManagerFactory bean id가 emf로 등록되어 있는듯하다.
  • 기존에 job, step, reader, processor, writer 의 메서드의 파라미터로 필요한 객체들을 주입하였는데, test 또는 sceduler로 job을 실행시킬 때 마다 파라미터를 입력하는 것이 번거로웠다. 따라서 WeeklyWeatherJobConfiguration 객체를 생성할 때 생성자를 통해 필요한 객체들을 DI하는 방식으로 아래 최종코드와 같이 변경하였다.

최종코드

@Configuration
@RequiredArgsConstructor
public class WeeklyWeatherJobConfiguration {

  @Value("${spring.batch.chunkSize}")
  private int chunkSize;
  @Value("${spring.batch.pageSize}")
  private int pageSize;
  private final EntityManagerFactory emf;
  private final LongTermWeatherService longTermWeatherService;
  private final JobRepository jobRepository;
  private final JpaTransactionManager transactionManager;

  @Bean
  public JpaPagingItemReader<Location> locationReader() {
    return new JpaPagingItemReaderBuilder<Location>()
        .name("locationReader")
        .entityManagerFactory(emf)
        .queryString("select L from Location L")
        .pageSize(pageSize)
        .build();
  }

  @Bean
  public ItemProcessor<Location, List<WeeklyWeather>> locationProcessor() {
    LocationItemProcessor locationItemProcessor = new LocationItemProcessor(
        longTermWeatherService);
    locationItemProcessor.setNow(LocalDateTime.now());
    return locationItemProcessor;
  }

  @Bean
  public WeeklyWeatherItemWriter<WeeklyWeather> weeklyWeatherWriter() {
    JpaItemWriter<WeeklyWeather> writer = new JpaItemWriterBuilder<WeeklyWeather>()
        .entityManagerFactory(emf)
        .usePersist(true)
        .build();
    return new WeeklyWeatherItemWriter<>(writer);
  }

  @Bean
  public Step weeklyWeatherStep() {
    return new StepBuilder("weeklyWeatherStep", jobRepository)
        .<Location, List<WeeklyWeather>>chunk(chunkSize, transactionManager)
        .reader(locationReader())
        .processor(locationProcessor())
        .writer(weeklyWeatherWriter())
        .build();
  }

  @Bean
  public Job weeklyWeatherJob() {
    return new JobBuilder("weeklyWeatherJob", jobRepository)
        .start(weeklyWeatherStep())
        .build();
  }

}

Scheduler 적용

  • 아래와 같이 Scheduler를 적용하여 필요한 시간에 job이 작동할 수 있도록 구성하였다.
    @Component
    @Slf4j
    @RequiredArgsConstructor
    public class BatchScheduler {
    
      private final JobLauncher jobLauncher;
      private final WeeklyWeatherJobConfiguration weeklyWeatherJobConfiguration;
    
      @Scheduled(cron = "${schedules.cron.update.weather}", zone = "Asia/Seoul")
      public void runGetWeeklyWeatherJob() {
        JobParameters jobParameters = new JobParametersBuilder()
            .addDate("date", new Date())
            .toJobParameters();
    
        try {
          log.info("WeeklyWeatherJobConfiguration.run() start");
          jobLauncher.run(weeklyWeatherJobConfiguration.weeklyWeatherJob(), jobParameters);
        } catch (Exception e) {
          log.error("WeeklyWeatherJobConfiguration.run() error", e);
        }
      }
    }

Job Test code

  • Job test
    • Job을 테스트하는 경우 먼저 아래와 같이 spring-batch-test 의존성을 추가해야 한다.
      testImplementation 'org.springframework.batch:spring-batch-test'
    • 그 후 @SpringBatchTest 어노테이션을 적용하여 JobLauncherTestUtils 객체를 주입받을 수 있도록 한다. 아래 그림과 같이 IDE 상에서 빨간 줄로 나와도 당황하지 말고 그냥 실행시키면 된다. Untitled
    • @SpringBootTest 어노테이션의 classes 옵션으로 실행하려는 batch Job을 설정한 class를 한정하여도 되지만, 나의 경우 Job을 설정할 때 여러 객체들을 DI 받아 설정하였기 때문에 별도로 지정하지 않았다.
    • test를 위한 별도 H2 DB를 사용하기 위해 profile을 별도로 구성하여 @ActiveProfiles를 사용하여 활성화하였다.
    • 아래는 Job에 대한 test code 전체이다.
      @SpringBatchTest
      @SpringBootTest
      @ActiveProfiles("test")
      class WeeklyWeatherJobConfigurationTest {
      
        @Autowired
        private JobLauncherTestUtils jobLauncherTestUtils;
      
        @Autowired
        private WeeklyWeatherJobConfiguration weeklyWeatherJobConfiguration;
      
        @Autowired
        private LocationRepository locationRepository;
      
        @Autowired
        private WeeklyWeatherRepository weeklyWeatherRepository;
      
        @BeforeEach
        void setUp() {
          saveTestData();
        }
      
        @Test
        public void testJob() throws Exception {
      
          JobParameters jobParameters = new JobParametersBuilder()
              .addDate("date", new Date())
              .toJobParameters();
      
          jobLauncherTestUtils.setJob(weeklyWeatherJobConfiguration.weeklyWeatherJob());
          JobExecution jobExecution = jobLauncherTestUtils.launchJob(jobParameters);
      
          assertEquals("COMPLETED", jobExecution.getExitStatus().getExitCode());
      
          checkBatchData();
        }
      
        private void checkBatchData() {
          LocalDate now = LocalDate.now();
          List<Location> locationList = locationRepository.findAll();
      
          for (Location location : locationList) {
            List<WeeklyWeather> weeklyWeathers = weeklyWeatherRepository.findAllByLocation(
                location);
            weeklyWeathers.sort(Comparator.comparing(WeeklyWeather::getDate));
      
            for (int i = 2; i <= 6; i++) {
              assertEquals(weeklyWeathers.get(i - 2).getDate(), now.plusDays(i));
            }
          }
        }
      
        private void saveTestData() {
          List<Location> locationList = new ArrayList<>();
          List<WeeklyWeather> weeklyWeatherList = new ArrayList<>();
      
          BufferedReader br = null;
      
          try {
            File csv = new File("src/main/resources/location/location.csv");
      
            br = new BufferedReader(new FileReader(csv));
            String line = br.readLine();  // 제일 첫줄이 제목이라 한 줄 읽고 시작
            while ((line = br.readLine()) != null) {
              String[] input = line.split(",");
      
              Location location = Location.builder()
                  .sido(input[0])
                  .sigungu(input[1])
                  .xCoordinate(Integer.parseInt(input[2]))
                  .yCoordinate(Integer.parseInt(input[3]))
                  .locationCode(input[4])
                  .locationLandCode(input[5])
                  .build();
      
              locationList.add(location);
      
              LocalDate now = LocalDate.now();
      
              for (int i = 1; i <= 5; i++) {
                WeeklyWeather weeklyWeather = WeeklyWeather.builder()
                    .location(location)
                    .maxTemperature(i)
                    .minTemperature(i)
                    .afternoonWeatherForecast(String.valueOf(i))
                    .morningWeatherForecast(String.valueOf(i))
                    .date(now.plusDays(i))
                    .build();
      
                weeklyWeatherList.add(weeklyWeather);
              }
      
            }
      
            locationRepository.saveAll(locationList);
            weeklyWeatherRepository.saveAll(weeklyWeatherList);
          } catch (IOException e) {
            e.printStackTrace();
          } finally {
            try {
              if (br != null) {
                br.close();
              }
            } catch (IOException e) {
              e.printStackTrace();
            }
          }
        }
      
      }
      • 프로퍼티
        • jobLauncherTestUtils : 테스트 환경에서 job을 실행시켜 주기 위한 객체
        • weeklyWeatherJobConfiguration : 주간날씨를 호출해서 저장하는 Job을 설정한 클래스
        • locationRepository : test data를 조작하기 위한 jpaRepository
        • weeklyWeatherRepository : test data를 조작하기 위한 jpaRepository
      • 메서드
        • setUp() 및 saveTestData()
          • @BeforeEach 를 사용하여 테스트를 실행하기 전 테스트 데이터를 저장하기 위해 만든 메서드이다.
          • Location 관련 데이터는 csv 파일을 읽어 들어와 DB에 직접 저장하고, 각 Location 객체 당 임의의 5개 WeeklyWeather 객체를 만들어 DB에 저장하였다.
        • testJob()
          • jobParameter를 날짜를 기반으로 해서 만들고, jobLauncherTestUtils에 설정한 Job을 setting 하고, launchJob() 메서드의 파라미터로 jobParameter를 입력하여 Job을 실행하였다.
          • Job이 종료되고 ExitCode가 COMPLETED가 맞는지 확인한다.
          • checkBatchData() 메서드를 통해 Job이 실행되어 새롭게 저장한 데이터의 개수 및 내용이 맞는지 검증한다.

Scheduler Test Code

  • scheduelr가 제대로 작동하는지 테스트 코드는 awaitility 라이브러리를 사용하여 아래와 같이 의존성 추가 및 테스트 코드를 작성하였다.
    testImplementation 'org.awaitility:awaitility'
    @SpringBootTest(properties = {"schedules.cron.update.weather=* * * * * *",
        "schedules.zone.update.weather=Asia/Seoul"})
    class BatchSchedulerTest {
    
      @SpyBean
      private BatchScheduler batchScheduler;
    
      @Test
      void schedulerActionTest() {
        await()
            .atMost(Duration.of(1500, ChronoUnit.MILLIS))
            .untilAsserted(() -> verify(batchScheduler, atLeastOnce()).runGetWeeklyWeatherJob());
      }
    
      @Test
      @DisplayName("scheduled cron 표현식 확인")
      void batchScheduleCronTest() {
        String cronExpression = "0 5 6,18 * * *";
        String zone = "Asia/Seoul";
    
        Instant initialTime = LocalDateTime.of(2024, 7, 9, 20, 50)
            .atZone(ZoneId.of("Asia/Seoul")).toInstant();
        List<Instant> expectedTimes = Stream.of(
            LocalDateTime.of(2024, 7, 10, 6, 5),
            LocalDateTime.of(2024, 7, 10, 18, 5),
            LocalDateTime.of(2024, 7, 11, 6, 5)
        ).map(time -> time.atZone(ZoneId.of("Asia/Seoul")).toInstant()).toList();
    
        ScheduleTestUtils.assertCronExpression(cronExpression, zone, initialTime, expectedTimes);
    
      }
    
      public static class ScheduleTestUtils {
    
        public static void assertCronExpression(String cronExpression, String zone,
            Instant initialTime, List<Instant> expectedTimes) {
    
          CronTrigger trigger = getTrigger(cronExpression, zone);
          SimpleTriggerContext context = new SimpleTriggerContext(initialTime,
              initialTime, initialTime);
    
          for (Instant expected : expectedTimes) {
            Instant actual = trigger.nextExecution(context);
            assertEquals(actual, expected);
            context.update(actual, actual, actual);
          }
        }
    
        private static CronTrigger getTrigger(String cronExpression, String zone) {
          if (StringUtils.hasText(zone)) {
            return new CronTrigger(cronExpression,
                StringUtils.parseTimeZoneString(zone));
          } else {
            return new CronTrigger(cronExpression);
          }
        }
      }
    }
    • @SpringBootTest의 properties 옵션을 사용하여 스케쥴러의 cron 표현식과 zone을 직접 설정하였다.
    • @SpyBean으로 BatchScheduler 를 등록하여 schedulerActionTest() 메서드를 통해 스케쥴러가 올바르게 작동하는지 검증하였다.
    • batchScheduleCronTest()는 cron 표현식이 의도한 대로 표현되어 있는 지 검증하기 위해, 블로그(https://blog.benelog.net/2802946.html)를 참고하여 위의 ScheduleTestUtils를 내부 클래스로 작성하여 진행하였다.

spring-batch를 구현하는데 성공하였으나 아쉬운 부분이 있다. 기왕이면 spring-batch를 비동기 방식으로 사용하여 성능을 개선하였으면 하는 부분이다. 비동기 적용 사항은 추후 별도의 글로 정리해보도록 하겠다.

profile
다시 시작합니다.

0개의 댓글