개발

스케줄링, 배치로 S3 이미지 삭제 처리

모달조아 2023. 4. 5. 16:50

개요

현재 제가 진행하고 있는 프로젝트에서는 이미지 삭제를 hard delete 로 처리하고 있습니다. 주문글, 댓글 등 다른 엔티티들은 모두 soft delete로 처리하는데 이미지만 hard delete 를 하고 있는 이유가 무엇일까요?

우선 soft delete를 사용하는 이유는 삭제된 데이터를 복구해야 할 일이 생길 수도 있고, 데이터 자체를 활용하여 추후 기능 개발에 활용할 수도 있기 때문입니다. 즉, 데이터가 서비스의 자산이기 때문입니다.

현재 제 프로젝트에서는 이미지를 독립적인 엔티티로 관리하고 있는데, 이미지를 soft delete 를 해야하는 이유가 크게 없습니다. S3와 같은 스토리지에서는 삭제를 하고 DB 에서는 soft delete 된 상태로 놓아 두는 것은 의미가 없고, S3 에서도 삭제를 하지 않는다면 용량을 차지하여 비용적으로 부담이 됩니다. 그러므로 이미지는 hard delete를 하고 있습니다.

 

@TransactionalEventListener 를 사용하여 DB 와 S3 의 생명주기를 같게 하였습니다. 그런데, 생각해보면 업로드의 경우는 업로드 후 실시간으로 조회가 가능해야하므로 생명주기가 같아야하지만, 삭제의 경우는 그럴 필요가 없습니다. 오히려 실시간으로 S3 에서 삭제해준다면 삭제에 걸리는 시간만 길어집니다.

현재 코드에서는 이미지를 삭제할 시에 실시간으로 s3 에서도 업로드된 이미지를 함께 삭제해줍니다. 그래서 주문-오퍼-댓글과 같은 엔티티에서 이미지가 많이 쌓여있을수록 s3 와의 통신이 많아지니 삭제할 때 오랜 시간이 걸리게됩니다. 한번 실험을 해보았습니다.

현재 주문글에 스레드 1개, 그 스레드에 댓글이 20개 달려있는 상황입니다.

이미지는 주문글 1개에 이미지 2개, 스레드 1개에 이미지 1개, 댓글 20개에 이미지 20 개가 있으므로, 주문을 삭제하면 총 23개의 이미지를 삭제해줘야겠죠.

2.15초 가량이 걸리는 것을 볼 수 있습니다.

 

앞서 말했듯이 삭제의 경우는 S3 에서 실시간으로 삭제해줄 필요가 전혀 없습니다.

그래서 생각한 것이 삭제된 이미지들을 모아서 사용자가 없는 시간대에 S3 에서 한번에 제거해주는 방식입니다. 그렇게 하면, 엔티티를 삭제할 때 실시간으로 S3 도 같이 삭제해주는 로직이 사라지니 시간이 많이 줄어들겠죠.

다만, 현재 hard delete 로 이미지를 삭제하는 방식에서는 이렇게 할 수가 없습니다. 추후에 S3 에서 삭제하고자 해도 삭제하고 싶은 이미지에 대한 데이터가 남아있지 않기 때문이죠. 그렇기 때문에 위에서 설명한 soft delete 방식으로 삭제 방식을 변경합니다.

 

그렇다면 말한 이 기능을 어떻게 구현할 수 있을까요?

일단 삭제된 이미지들을 모아서 한번에 처리가 가능해야하고, 특정 시간대에 그것을 가능하도록 해야합니다.

삭제된 이미지들을 모아서 한번에 처리하는 로직은 Spring Batch

특정 시간대에 실행하도록 하는 것은 Spring Scheduler 를 이용해보도록 합시다.

 

일단 먼저 Spring Batch 에 대해서 알아보겠습니다.

 

Spring Batch

배치의 의미는 아래와 같습니다.

  • 데이터를 실시간으로 처리하는 것이 아니라 모아서 일괄적으로 처리하는 것
  • 사용자에게 빠른 응답이 필요하지 않을 때 사용

 

Spring Batch 는 위에서 설명한 작업을 위한 Spring 생태계의 프레임워크입니다.

Spring Batch에서 사용되는 주요 용어들을 살펴봅시다.

 

우선적으로 그림을 먼저 보고 아래 용어 설명들을 읽어본다면 도움이 많이 될 것 같습니다.

 

Job

  • Job
    • 배치 처리 과정을 하나의 단위로 만들어놓은 객체
    • 배치 처리 과정의 최상위 단위
    • 하나 이상의 Step 으로 구성
    • Job 은 실행 시점에 실행될 Step 들을 정의
  • JobInstance
    • Job 의 실행 단위
    • Job 을 실행시키면 하나의 JobInstance 가 생성
      • 4월 5일과 4월 6일의 Job 은 같은 Job 이라도 다른 JobInstance 임
  • JobParameters
    • JobInstance 의 식별자이며 JobInstance 에 전달되는 매개변수
    • JobParameter 의 데이터 타입은 아래 네 가지만 지원
      • String, Double, Long, Date
  • JobExecution
    • JobInstance 실행 시도에 대한 정보를 저장하는 객체
    • JobInstance 실행에 대한 상태, 시작시간, 종료시간, 생성시간 등의 정보를 담고 있음
      • 4월 5일에 실행한 JobInstance 가 실패하여 재실행을 하여도 동일한 JobInstance 를 실행하지만, 이 각각 두 번에 대한 JobExecution 이 따로 생김

 

Step 및 하위 객체

  • Step
    • Job 의 실행 단위
    • 하나 이상의 Task 로 구성
    • Step 은 이전 Step 이 성공적으로 완료되어야 실행됨
    • 각각의 Step 은 입력 데이터를 처리하여 결과 데이터를 생성하거나 출력할 수 있음
  • StepExecution
    • Step 실행 시도에 대한 정보를 저장하는 객체
    • Step 실행에 대한 상태, 시작시간, 종료시간, 생성시간, read 수, commit 수, skip 수 등의 정보를 저장
    • Job 이 여러 개 Step 으로 구성되어 있을 경우, 이전 단계의 Step 이 실패하게 되면 다음 단계가 실행되지 않음으로, 이후 Step 에 대한 StepExecution 은 생성되지 않음
    • JobExecution 과 동일하게 실제 Step 이 시작이 될 때만 생성
  • Tasklet
    • Step 내에서 수행되는 최소 작업 단위
    • Reader , Processor, Writer 가 Tasklet 를 대체 가능
    • 하나 이상의 Chunk 로 구성
    • Tasklet 은 처리 과정에서 일어나는 예외 상황을 처리할 수 있음
  • Chunk
    • Tasklet 내에서 처리되는 최소 단위
    • 일정한 크기의 데이터를 읽어 처리하고, 결과를 출력

 

Item

  • ItemReader
    • Step 에서 데이터를 읽어오는 인터페이스
    • ItemReader 에 대한 다양한 인터페이스가 존재, 다양한 방법으로 데이터를 읽어올 수 있음.
  • ItemWriter
    • 처리된 데이터를 write 할 때 사용.
      • 처리 결과물에 따라 Insert, Update 가능. Queue를 사용한다면 send도 가능.
    • Reader 와 동일하게 다양한 인터페이스가 존재
    • 데이터를 chunk 로 묶어서 처리
  • ItemProcessor
    • Reader 에서 읽어온 Item 데이터를 처리하는 역할
    • 배치 처리의 필수 요소는 아님
    • 데이터의 변환, 필터링, 정렬, 집계 등을 수행

 

실행 및 관리

  • ExecutionContext
    • Job 간, Step 간 데이터를 공유할 수 있는 데이터 저장소
    • 종류
      • JobExecutionContext
        • commit 시점에 저장
      • StepExecutionContext
        • 실행 사이에 저장
    • Job 실패 시 ExecutionContext 를 통해 마지막 실행 값을 재구성할 수 있음
  • JobRepository
    • 배치 처리 정보를 저장하는 컴포넌트(위에서 소개한 객체)들을 관리
    • Job이 실행되게 되면, JobRepository 에 JobExecution 과 StepExecution 을 생성하게 되며, JobRepository 에서 이러한 Execution 정보들을 저장하고 조회하며 사용함
  • JobLauncher
    • Job 과 JobParameter 를 사용하여 Job 을 실행하는 객체

 

배치의 전체적인 아키텍처는 아래 그림과 같습니다.

 

사용 예시 코드

implementation 'org.springframework.boot:spring-boot-starter-batch'

의존성을 추가해줍니다.

spring.batch.initialize-schema: never		// batch 스키마 자동 생성
spring.batch.job.enabled: false			// 시작과 동시에 실행되는건 방지

그 후 배치와 관련된 설정을 해줍니다. Spring Batch 를 사용하기 위해서는 스키마가 필요한데, 그 필요한 스키마들을 자동 생성해주는 설정이 있습니다.

개발 환경이나 테스트 환경에서는 always 로 설정해주어서 편리하게 개발할 수도 있겠지만, 운영 서버에서는 대부분 never 로 설정합니다. 어떤 배치가 실행됬고, 얼마나 Rollback 되었는지와 같은 정보를 파악하기 위해서는 데이터가 남아있어야 하기 때문입니다.

 

그렇기 때문에 저는 운영 DB 에 직접 DDL 을 통해서 넣어주었습니다.

이 스키마의 DDL 은 Spring-batch-core 에 있습니다.

External-Library 에서 위와 같은 디렉토리를 찾고, 디렉토리 내에서 sql 파일을 찾습니다.

저는 mysql 을 사용 중이기에 schema-mysql.sql 을 복사하였습니다.

내용은 아래와 같습니다.

-- Autogenerated: do not edit this file

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),
	JOB_CONFIGURATION_LOCATION VARCHAR(2500) NULL,
	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 ,
	TYPE_CD VARCHAR(6) NOT NULL ,
	KEY_NAME VARCHAR(100) NOT NULL ,
	STRING_VAL VARCHAR(250) ,
	DATE_VAL DATETIME(6) DEFAULT NULL ,
	LONG_VAL BIGINT ,
	DOUBLE_VAL DOUBLE PRECISION ,
	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,
	START_TIME DATETIME(6) NOT 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 TABLE BATCH_STEP_EXECUTION_SEQ (
	ID BIGINT NOT NULL,
	UNIQUE_KEY CHAR(1) NOT NULL,
	constraint UNIQUE_KEY_UN unique (UNIQUE_KEY)
) ENGINE=InnoDB;

INSERT INTO BATCH_STEP_EXECUTION_SEQ (ID, UNIQUE_KEY) select * from (select 0 as ID, '0' as UNIQUE_KEY) as tmp where not exists(select * from BATCH_STEP_EXECUTION_SEQ);

CREATE TABLE BATCH_JOB_EXECUTION_SEQ (
	ID BIGINT NOT NULL,
	UNIQUE_KEY CHAR(1) NOT NULL,
	constraint UNIQUE_KEY_UN unique (UNIQUE_KEY)
) ENGINE=InnoDB;

INSERT INTO BATCH_JOB_EXECUTION_SEQ (ID, UNIQUE_KEY) select * from (select 0 as ID, '0' as UNIQUE_KEY) as tmp where not exists(select * from BATCH_JOB_EXECUTION_SEQ);

CREATE TABLE BATCH_JOB_SEQ (
	ID BIGINT NOT NULL,
	UNIQUE_KEY CHAR(1) NOT NULL,
	constraint UNIQUE_KEY_UN unique (UNIQUE_KEY)
) ENGINE=InnoDB;

INSERT INTO BATCH_JOB_SEQ (ID, UNIQUE_KEY) select * from (select 0 as ID, '0' as UNIQUE_KEY) as tmp where not exists(select * from BATCH_JOB_SEQ);

 

이제 Spring Batch 를 이용한 코드를 한번 본격적으로 살펴봅시다

@Configuration
@EnableBatchProcessing
@RequiredArgsConstructor
public class BatchConfig {

	private final JobBuilderFactory jobBuilderFactory;
	private final StepBuilderFactory stepBuilderFactory;
	private final ImageRepository imageRepository;
	private final ImageStorageService imageStorageService;

	@Bean
	public Job deleteS3ImagesJob() {
		return jobBuilderFactory.get("DeleteS3ImagesJob")
				.start(deleteS3ImagesStep())
				.build();
	}

	@Bean
	public Step deleteS3ImagesStep() {
		return stepBuilderFactory.get("DeleteS3ImagesStep")
				.tasklet(((contribution, chunkContext) -> {
					List<Image> deletedImages = imageRepository.findDeletedImages();

					deletedImages
							.forEach(image -> {
								imageStorageService.delete(image.getSubPath(), image.getFilename());
							});
					imageRepository.deleteAllInBatch(deletedImages);

					return RepeatStatus.FINISHED;
				}))
				.build();
	}
}

일단 @EnableBatchProcessing 어노테이션을 통해서 Batch 처리를 해줄 수 있도록 해야합니다.

그 후, batch 파일에서 수행하고자 하는 Job 과 step 을 구성하면 됩니다.

저의 경우는 구현하려는 Job 이 S3 에서 삭제하고자 하는 단순한 로직이므로 하나의 Step 만 구성하였습니다.

그리고 Step 의 경우는 Reader , Processor, Writer 혹은 Tasklet 으로 구성할 수 있는데 간단한 로직이라 굳이 3분류로 나누지 않고 Tasklet 단위로 구성하였습니다.

Step 의 구현 내용은 soft delete 된 이미지들을 찾고, 그 이미지들을 S3 에서 다 삭제해주는 것입니다. 그리고 S3 에서 삭제되고 나면 DB 에 있는 이미지 데이터들도 쓸모가 없으므로 다 삭제해줍니다.

DB 에서 삭제할 때는 deleteAllInBatch 메서드를 이용하여 한 개의 쿼리로 삭제해줍니다.

 

잠시 배치와는 논외지만 soft delete 로 삭제해주기 위해 작성한 코드를 한번 살펴보겠습니다.

@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("UPDATE Image i SET i.deletedAt = NOW() "
		+ "WHERE i.deletedAt IS NULL AND i.referenceId = :referenceId AND i.imageType = :imageType")
void softDeleteByReferenceIdAndImageType(
		@Param("referenceId") Long referenceId,
		@Param("imageType") ImageType imageType
);

 

직접 UPDATE 쿼리문을 jpql 을 통해 작성해주었습니다. jpql 을 사용하여 DML 을 실행할 때, @Modifying 어노테이션을 붙여주지 않으면 아래와 같은 예외가 발생합니다.

org.springframework.dao.InvalidDataAccessApiUsageException: org.hibernate.hql.internal.QueryExecutionRequestException: Not supported for DML operations

그 이유는 jpql 은 영속성 컨텍스트를 거쳐 쓰기 지연으로 동작하는 것이 아닌 바로 DB 에 쿼리를 날리기 때문입니다. 그렇기 때문에 영속성 컨텍스트는 DB 데이터가 변경된 것을 알 수가 없습니다.

이 정합성을 맞춰주기 위해서 @Modifying 어노테이션을 붙여주는 것입니다.

@Modifying 을 사용할 때는 clearAutomatically=true, flushAutomatically=true 속성을 명시하는 것이 좋습니다. 이 속성들은 쿼리 실행 후 자동으로 영속성 컨텍스트를 clear 하고, 쿼리 실행 전 DB 에 flush 할 것인를 의미합니다. 기본 값은 false 입니다.

참고로, JPA 구현체 하이버네이트는 쿼리 실행 시 자동으로 flush 가 되는 설정(FlushModeType) 이 default 입니다. 그러므로 @Modifying 의 기본 값인 flushAutomatically=false 를 사용하여도 쿼리 실행 전 flush 가 됩니다. [더 자세하게 알아보기]

 

스케줄러

이제 삭제된 이미지들을 모아서 한번에 처리하는 로직은 Spring Batch 를 통해 구현하였으니 특정 시간에 실행되도록 해보겠습니다.

 

스케줄러의 기본 개념에 대해 알아봅시다

  • 정의
    • 일정한 시간 간격 혹은 일정한 시각에 특정 로직을 실행하기 위해 사용
    • Spring에서 제공하는 스케줄러
      • Spring Scheduler
      • Spring Quartz
  • 특징
    • SpringBoot Stater 에 기본 제공되므로 별도의 의존성을 추가할 필요 없음
    • Config 파일에 @EnableScheduling 어노테이션 필수
    • 스케줄링을 원하는 메서드에 @Scheduled 어노테이션을 붙여 사용
    • 스케줄링을 할 메서드는 아래 두 개의 조건을 만족해야 함
      • 반환 타입이 void일 것
      • 파라미터가 없을 것
  • 동작 방식
    • 기본적으로 thread 1개로 동기 방식으로 실행
      • 1번 스케줄이 끝나지 않으면 2번 스케줄 시작 시간이 되어도 시작되지 않음
      • 비동기 방식으로 실행하고 싶으면 @EnableAsync 어노테이션을 사용

스프링에서 제공하는 스케줄러로는 Spring Scheduler 나 Quartz 를 이용할 수 있습니다. Quartz 를 이용하면 스케줄러보다 보다 정교하게 스케줄링을 다룰 수 있지만 설정과 사용에 번거롭습니다. 그리고 무엇보다 현재 필요한 기능은 특정 시점에서 호출을 하는 간단한 로직이라 Spring Scheduler 만으로 충분하다 생각하여 Spring Scheduler 를 사용하기로 하였습니다.

 

사용 예시 코드

@EnableScheduling
@SpringBootApplication
@EnableConfigurationProperties
public class HeyCakeApplication {

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

}

@EnableScheduling 을 SpringBootApplication 에 달았습니다.

 

@Component
@RequiredArgsConstructor
public class ImageStorageScheduler {

	private final JobLauncher jobLauncher;
	private final BatchConfig batchConfig;

	@Scheduled(cron = "0 0 3 * * ?", zone = "Asia/Seoul")
	public void deleteS3Image() {
		Map<String, JobParameter> parameters = new HashMap<>();
		parameters.put("time", new JobParameter(System.currentTimeMillis()));
		JobParameters jobParameters = new JobParameters(parameters);

		try {
			jobLauncher.run(batchConfig.deleteS3ImagesJob(), jobParameters);
		} catch (
				JobExecutionAlreadyRunningException |
				JobRestartException |
				JobInstanceAlreadyCompleteException |
				JobParametersInvalidException e
		) {
			throw new RuntimeException(e);
		}
	}
}

@Scheduled 어노테이션에서 cron 속성을 이용하여 매일 오전 3시마다 메서드가 실행되도록 하였습니다.

cron

  • Cron 표현식을 사용한 작업 예약
  • cron = "* * * * * *"
  • 첫번째 부터 초(0-59) 분(0-59) 시간(0-23) 일(1-31) 월(1-12) 요일(0-7)

jobLauncher.run() 메서드는 파라미터로 Job 과 JobParameters 를 받고 있습니다.

첫번째 파라미터로 아까 구현한 이미지를 S3 에서 삭제하는 Job 을 넣어주었습니다.

JobParameters 의 역할은 반복해서 실행되는 Job 의 식별자입니다. Job 의 식별자가 될 수 있는 값을 고민하다 현재 시간 값을 식별자로 정하고 넣어주었습니다.

 

이로써 기존에는 hard delete 를 통해 이미지를 DB 에서 삭제하고 S3 에서도 삭제하는 로직을 실시간으로 처리하였다면,

실시간으로는 soft delete 를 통해서 삭제 flag 처리만 해주어 사용자가 느끼는 삭제 시간을 줄여주고, 사용자가 없는 새벽 시간에 S3 와 DB hard delete 를 해주는 것을 구현하였습니다.

 

실제로 다시 아까와 맨 처음 실험했던 이미지 23개의 삭제를 해보았을 때, 기존에 2.15초 걸리던 작업이 1.031 초로 53% 가량 줄은 것을 확인할 수 있습니다.