[Spring Batch] 이미지 삭제 스케줄링 구현 + Spring Batch 5 변경사항으로 인한 트러블 슈팅

Elmo·2024년 10월 18일
0

구현 목표

이전 게시글에서 이미지 업로드 방식에 대해 다뤘습니다. 이미지를 미리 업로드해서 이미지 파일 엔티티와 미리 매핑하고, 만약 사용자가 글 작성을 취소했을 시 업로드된 이미지들은 사용되지 않기 때문에 주기적으로 삭제해야합니다.

단순히 스케줄러만 이용해도 괜찮지만, 만약 사용자가 늘어 대량의 쿼리를 처리해야될 수도 있기 때문에 Spring Batch + Spring Scheduler를 이용하였습니다.

기능 구현에 앞서 Spring Batch와 Spring Scheduler에 대해 알아봅시다.

Spring Batch란

Batch

배치는 데이터를 실시간으로 처리 하지 않고 특정 시간에 일괄적으로 모아서 처리하는 작업이다.사용자의 요청에 대해 즉각적인 응답을 필요로 하지 않은 서비스에 적용한다. 은행 정산 등을 예시로 보면 된다.

  • 반대되는 개념 : OLTP (Online Transaction Processing) 실시간 트랜잭션 처리, 주로 은행에서 계좌 송금이나 결제 등 실시간 데이터 처리에 사용

특징

  • 대용량 데이터 : 대량의 데이터를 처리하는 연산
  • 자동화 : 사용자의 개입 없이 실행 가능
  • 견고성 : 잘못된 데이터를 충돌 혹은 중단 없이 처리
  • 신뢰성 : 로깅 혹은 알림을 통해 오류가 발생한 부분을 추적
  • 성능 : 지정한 시간 내에 처리를 완료 하거나 배치 애플리케이션과 동시에 수행 하고 있는 다른 애플리케이션을 방해하지 않아야함

Spring Batch

대용량 데이터 처리를 효율적으로 수행하기 위한 스프링 프레임워크의 배치 아키텍처

  • Logging, 추적, 트랜잭션 관리, 작업 처리 통계, 리소스 관리 등 최적화 및 파티셔닝 기술을 통해 대용량의 레코드 처리 및 고성능 배치 작업
  • Restartability : 배치 수행이 실패하면 실패한 지점부터 재실행
  • 중복 실행 방지 : 이미 성공한 배치를 다시 실행하려고 하면 exception 발생

주요 개념

  • Job: 배치 작업의 논리적 단위. 여러 개의 Step을 포함할 수 있으며, 하나의 Job은 특정한 트리거(예: 스케줄러)에 의해 실행.

  • Step: Job 내에서 하나의 개별 작업 단위로, 실제 데이터를 처리하는 로직이 포함됨.

  • Job Repository: 로그와 메타데이터를 저장하는 저장소로, 작업 실행 중 발생한 상태 및 결과 관리.

  • JobLauncher: Job을 실행시키는 인터페이스로, Job을 시작하거나 멈추는 역할.

장점

  • Spring 프레임워크에서 지원하므로 개발의 편의성, Spring 기반이기 때문에 DI, AOP, 서비스 추상화 가능
  • 병렬성 등을 고려하지 않아도 개발자들이 비즈니스 로직에만 집중 가능
  • 오픈 소스

Spring Batch 자체로 기능을 주기적으로 실행할 수 없다. 따라서 Spring Scheduler, Quartz와 같이 사용한다.

Tasklet vs Chunk

Spring Batch의 처리 방식은 크게 Tasklet 방식과 Chunk 방식으로 나눠짐

Chunk 기반 처리
대량의 데이터를 작은 덩어리(chunk)로 나누어 처리하는 방식으로 설계. 예를 들어, 1000개의 데이터를 처리할 때, 이를 한 번에 처리하는 대신, 100개의 덩어리로 나누어 처리.

  • ItemReader, ItemProcessor, ItemWriter 이해 필요
  • 청크 단위로 트랜잭션이 적용됨
  • Reader, Processor, Writer로 구성해서 작업 처리
  • 대량의 데이터를 처리할 때, 데이터가 많아서 처리 단위를 나눠야 할 경우. 예를 들어, 데이터베이스 레코드 수천 개를 읽고 변환 후 저장하는 작업에 적합.

각 Step은 ItemReader, ItemProcessor, ItemWriter로 나뉘어 데이터 읽기, 처리, 쓰기를 수행.

ItemReader: 데이터를 읽어오는 역할(파일, DB, 큐 등에서 데이터를 읽음)
ItemProcessor: 읽은 데이터를 가공하는 역할(필터링, 변환 등)
ItemWriter: 가공된 데이터를 저장하는 역할(DB, 파일, 메시지 큐 등으로 저장)

Tasklet 기반 처리
배치 작업을 단일 작업으로 처리하는 방식.

  • tasklet안에서 한번에 처리하며 excute 메소드를 오버라이딩하여 사용
  • 주로 파일 삭제,메시지 전송 등 단일 작업이나 단일 트랜잭션으로 끝나는 경우에 사용
  • 배치처리가 단순한 작업에 적합, 대량 처리의 경우 청크처럼 나눠서 구현 시 매우 비효율적이므로 적합하지 않음

따라서 구현하려는 기능에 맞는 방식을 채택하면 된다. 현재는 이미지 엔티티를 단순히 일괄 삭제하는 작업이므로 Tasklet이 적합하다고 판단했다.

Spring Scheduler

정 시간 간격에 따라 작업을 주기적으로 실행할 수 있도록 도와주는 스프링 프레임워크의 기능

  • Quartz는 외부 라이브러리로 정교한 job 스케줄링을 다룰 수 있지만, 복잡하고 설정과 사용에 번거로움이 있는 등 리소스가 더 필요하다.

@Scheduled 속성

cron : cron 표현식 지원, 초 분 시 일 월 주 (년)으로 표현
fixedDelay : ms 단위,이전 작업이 끝난 시점으로부터 고정된 시간을 설정
fixedRate : ms 단위, 이전 작업이 수행되기 시작한 시점으로부터 고정된 시간을 설정
initialDelay : 스케줄러에서 메소드가 등록되자마자 수행하는 것이 아니라, 초기 지연시간을 설정
zone : time zone 설정, default는 서버의 time zone

Thread Pool 설정을 통해 여러 개의 스레드에서 각각의 작업을 수행할 수 있다. 하지만 비동기 설정을 안해주면 각각의 스레드에서 실행돼도 작업은 동기적으로 차례대로 수행될 수 있다. 따라서 @Async를 통해 비동기 설정을 해줘야한다. 이를 통해 병렬 작업 처리와 리소스 최적화, 그리고 애플리케이션 성능 향상을 이룰 수 있다.

구현

긴 이론 설명이 끝났으니 구현해봅시당

application.yml

spring:
  batch:
    jdbc:
      initialize-schema: always

배치관련 설정을 추가합니다. initialize-schema: always 부분은 밑에서 설명하겠습니다.

build.gradle

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

BatchConfig.java

@Slf4j
@Configuration
@RequiredArgsConstructor
@EnableConfigurationProperties(BatchProperties.class)
public class BatchConfig {
    private final DeleteImgTasklet deleteImgTasklet;

    @Bean
    @ConditionalOnMissingBean
    @ConditionalOnProperty(prefix = "spring.batch.job", name = "enabled", havingValue = "true", matchIfMissing = true)
    public JobLauncherApplicationRunner jobLauncherApplicationRunner(JobLauncher jobLauncher, JobExplorer jobExplorer,
                                                                     JobRepository jobRepository, BatchProperties properties) {
        JobLauncherApplicationRunner runner = new JobLauncherApplicationRunner(jobLauncher, jobExplorer, jobRepository);
        String jobNames = properties.getJob().getName();
        if (StringUtils.hasText(jobNames)) {
            runner.setJobName(jobNames);
        }
        return runner;
    }

    @Bean
    public Job deleteImgJob(JobRepository jobRepository, PlatformTransactionManager transactionManager){
        return new JobBuilder("deleteImgJob", jobRepository)
                .start(deleteImgStep(jobRepository, transactionManager))
                .build();
    }

    @Bean
    public Step deleteImgStep(JobRepository jobRepository, PlatformTransactionManager transactionManager) {
        return new StepBuilder("deleteImgStep", jobRepository)
                .tasklet(deleteImgTasklet, transactionManager)
                .allowStartIfComplete(true)
                .build();
    }
}

Batch 설정 클래스를 생성합니다. Job, Step을 생성해줬어요.
위와 같은 코드가 나온 이유는 스프링 배치 버전에 따른 여러 에러를 해결하면서였어요. 여러분도 참고하세요!

1.org.springframework.batch.core.configuration.annotation.StepBuilderFactory' is deprecated since version 5.0.0 and marked for removal

@Autowired
private StepBuilderFactory stepBuilderFactory;
@Autowired
private JobBuilderFactory jobBuilderFactory;

대부분의 블로그에서는 위와 같은 코드를 사용했습니다. 에러가 발생하더라구요.

https://docs.spring.io/spring-batch/docs/current/api/deprecated-list.html

https://alwayspr.tistory.com/49

- 스프링 배치 5.0부터는 사용하지 못합니다.

  • Builder에서 JobRepository가 생성되고 설정된다는 사실을 숨기고 있었는데 이를 명시적으로 사용하도록 방식을 바꾼 것이다. 사용자가 문서를 읽지 않으면 이 사실을 모르기 때문에 명시적인 코드 작성을 강제한거 같습니다.
  • 따라서 예전에는 get 메서드로 Factory에서 가져오는 방식이었는데 이제는 직접 생성해야한다.

따라서 직접 jobRepository를 파라미터로 받도록 BatchConfig 파일을 작성했습니다.

2. 구동시 'batch_job_instance' doesn't exist 에러발생
이는 배치 작업 정보를 저장하기 위한 기본 테이블이 존재하지 않아 발생하는 문제였어요.
따라서 application.yml에 다음을 추가해줘야해요.

spring:
  batch:
    jdbc:
      initialize-schema: always

이는 스프링 애플리케이션이 시작할 때 기본 테이블을 자동 생성하도록 지시하는 것입니다. 하지만 이 부분을 추가해줘도 테이블이 자동 생성되지 않고 같은 에러가 반복해서 발생했습니다.

https://velog.io/@seongwop/Spring-Batch-5.0-변경-사항-initialize-schema-에러

  • SpringBoot 3.0부터 @EnableBatchProcessing 권장되지 않음
  • @EnableBatchProcessing 대신 DefaultBatchConfigration 클래스를 추가하여 @EnableBatchProcessing을 통해 등록된 Bean 및 다양한 메소드들을 상속받고 커스터마이징 할 수 있다고 하네요.

문제는 여기에서 시작됐어요.

  • @EnableBatchProcessing 어노테이션 사용: 이 어노테이션은 Spring Batch의 기능을 활성화하며, 내부적으로 DefaultBatchConfiguration을 자동으로 등록합니다. 즉, 이 어노테이션이 클래스에 붙어 있으면 Spring은 DefaultBatchConfiguration 빈을 생성합니다.
  • @ConditionalOnMissingBean의 역할: BatchAutoConfiguration 클래스에서 @ConditionalOnMissingBean(value = DefaultBatchConfiguration.class, annotation = EnableBatchProcessing.class)가 붙어 있는 것은, 만약 DefaultBatchConfiguration 빈이 이미 존재하거나, @EnableBatchProcessing 어노테이션이 적용된 클래스가 있을 경우, 그 아래 정의된 빈들은 등록되지 않도록 합니다.
  • JobLauncherApplicationRunner 빈이 등록되지 않음: 만약 @EnableBatchProcessing이나 DefaultBatchConfiguration을 사용하게 되면, Spring은 이미 이 클래스에서 Batch 관련 빈들이 설정되었다고 판단하므로, JobLauncherApplicationRunner 및 다른 Batch 관련 빈들이 등록되지 않게 됩니다. 즉, 이러한 빈들이 필요하다면 해당 어노테이션이나 빈을 사용하지 않아야 합니다.

위에는 gpt의 도움을 받은 것인데 정리하자면 @EnableBatchProcessing이나 DefaultBatchConfiguration을 사용하게 되면 JobLauncherRunner에 있는 @ConditionalOnMissingBean에 의해 그 하위에 있는 빈은 등록되지 않아요.

따라서 JobLauncherApplicationRunner 및 빈이 등록되지 않고 yml에 추가한 배치 테이블 생성 설정도 동작하지 않는 것입니다.

그러므로 BatchConfig 작성 시 BatchAutoConfiguration 파일에 있는 jobLauncherApplicationRunner 메서드를 그대로 가져와 빈을 등록했습니다.

이를 통해 배치 테이블 생성 yml 구문이 동작하면서 자동 생성됐습니다.

DeleteImgScheduler.java

@RequiredArgsConstructor
@Component
public class DeleteImgScheduler {
    private final JobLauncher jobLauncher;
    private final Job job;

    @Async(value = "asyncExecutor")
    @Scheduled(cron = "0 0 0 * * SUN")
    public void runDeleteImgJob()
            throws JobInstanceAlreadyCompleteException, JobExecutionAlreadyRunningException, JobParametersInvalidException, JobRestartException {
        jobLauncher.run(job, new JobParameters());
    }
}

일요일 자정마다 실행하도록 주기를 설정했습니다. 그리고 Async 어노테이션을 붙여줬어요. 이때 메일 서비스와 구분하기 위해 배치 전용 async 이름을 설정했습니다.

DeleteImgTasklet.java

@Component
@RequiredArgsConstructor
@Slf4j
public class DeleteImgTasklet implements Tasklet {
    private final FileService fileService;
    private final FileMappingRepository fileMappingRepository;
    /**
     * 게시글과 매핑되지 않은 이미지 파일 삭제 처리
     * 생성된지 이틀이 지난 경우 삭제
     */
    @Override
    public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
        log.info("start deleting images : {}", LocalDateTime.now());
        List<String> deleteList = fileMappingRepository.findAllByPostAndCreateDateBefore(LocalDateTime.now().minusDays(2));
        fileService.deleteMultiFile(deleteList);
        return RepeatStatus.FINISHED;
    }
}

FileServiceImpl.java

...

 @Override
    @Transactional
    public void deleteMultiFile(List<String> imgUrlList) {
        try {
            String bucketUrl = "https://s3."+region+".amazonaws.com/"+bucket;
            fileMappingRepository.deleteAllByFileUrlIn(imgUrlList);
            for (String fileUrl : imgUrlList) {
                log.info("delete imgUrl = {}",fileUrl);
                String fileName = fileUrl.substring(bucketUrl.length() + 1);
                DeleteObjectRequest request = new DeleteObjectRequest(bucket, fileName);
                amazonS3.deleteObject(request);
            }
        }
        catch (AmazonS3Exception e) {
            throw new AmazonS3Exception("Failed to delete multiple files", e);
        }
    }

매핑되지 않아 post값이 Null이고 생성된지 2일이 지난 이미지 파일을 모두 삭제했습니다. 엔티티뿐만 아니라 s3에 업로드된 이미지 파일도 삭제합니다.

Tasklet은 RepeatStatus.FINISHED를 반환할 때까지 execute메서드를 반복 수행한다고 합니다.

SchedulerConfiguration.java

@Configuration
public class SchedulerConfiguration implements SchedulingConfigurer {
    @Override
    public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
        ThreadPoolTaskScheduler threadPoolTaskScheduler = new ThreadPoolTaskScheduler();

        threadPoolTaskScheduler.setPoolSize(2);
        threadPoolTaskScheduler.setThreadGroupName("footballgg-scheduler thread-pool");
        threadPoolTaskScheduler.setThreadNamePrefix("footballgg-scheduler");
        threadPoolTaskScheduler.initialize();

        taskRegistrar.setTaskScheduler(threadPoolTaskScheduler);
    }
}

스케줄러의 Thread Pool 사이즈 설정

AsyncConfig.java

@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
    @Override
    @Bean(name = "asyncExecutor")
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(2);
        executor.setMaxPoolSize(5);
        executor.setQueueCapacity(10);
        executor.setThreadNamePrefix("Async BatchExecutor-");
        executor.initialize();
        return executor;
    }

    @Bean(name = "mailExecutor")
    public Executor getAsyncMailExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(10);
        executor.setQueueCapacity(20);
        executor.setThreadNamePrefix("Async MailExecutor-");
        executor.initialize();
        return executor;
    }

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return AsyncConfigurer.super.getAsyncUncaughtExceptionHandler();
    }
}

제가 이메일 전송 서비스도 구현했기때문에 비동기를 적용하고자 메일과 배치 설정을 구분해서 구현했습니다.

  • Async에서 corePoolSize만큼 기본으로 스레드풀에 스레드를 생성해서 작업을 할당한다고 합니다. corePoolSize는 스레드풀에 항상 유지되어야하는 스레드의 최소 수 입니다.
  • 스레드풀의 스레드가 모두 작업 중이면 대기 큐에 넣습니다.
  • 스레드 수가 maxPoolSize인 상태로 요청을 받으면 예외를 던집니다.
    https://xxeol.tistory.com/44
    더 자세한 부분은 위 링크를 참고하세요!

ServerApplication.java

@EnableJpaAuditing // BaseTimeEntity를 사용하기 위해서 추가
@EnableScheduling
@SpringBootApplication
@PropertySource("classpath:env.properties")
public class ServerApplication {

	public static void main(String[] args) {
		SpringApplication.run(ServerApplication.class, args);
	}

}

@EnableScheduling를 추가했습니다.

profile
엘모와 도지는 즐거워

0개의 댓글