
메일 발송 서비스에 쌓인 발송 이력을 삭제하기 위해 배치를 개발했다.
JPA 를 사용하는 프로젝트에서 연관관계가 있는 3개의 엔티티 약 4.5만건의 데이터를 삭제하는데 어떤 방식이 효과적일까?
부모-자식 연관관계가 있는 데이터에서 부모를 먼저 삭제하면 cannot delete parent row 에러가 발생한다. 따라서 자식 - 부모 순으로 삭제 해야한다.
3개의 엔티티가 얽혀 있고 1:n:n 연관관계 매핑이 있는 데이터였는데,
연관관계 있는 부모, 자식 엔티티를 fetch 조회해서 (한꺼번에 조회 ) List <부모엔티티> 를 조회한 후 deleteAll() 로 삭제하면 순조롭게 삭제될 것이라 생각했다.
물론, cascade 설정이 잘 되어있다면 문제없이 삭제 되겠지만 n만건의 대량의 데이터, 그리고 batch 로 구현하기에는 문제가 있어 보였다.
음 그리고 연관관계에 있는 엔티티를 단계적으로 삭제하는게 좋아보였다.
혹시나 생길 오류로 고아 객체 (?) 고아 엔티티 (?) 가 발생한다면 데이터 삭제가 유의미하지 않을 것이기 때문이다.
따라서 최근 1년까지의 데이터만 남겨두고 삭제할 것이기 때문에,
자식엔티티 2 의 최근 1년 이후 삭제
자식엔티티 1의 최근 1년 이후 삭제
부모엔티티의 최근 1년 이후 삭제
방식으로 삭제하기로 했다.
이 과정에서 또한 findBy를 통해 삭제 대상 엔티티를 조회한 뒤 deleteAll()을 수행하는 방식은 데이터를 조회한 후 삭제하는 방식이므로, 대량 데이터 삭제 시 불필요한 조회와 Cascade 처리 비용이 발생할 수 있어 적합하지 않다고 생각했다.
추가로, deleteAll() 은 내부적으로EntityManager.remove(entity); 를 반복 수행한다. 그래서 대량의 데이터에서는 많은 부수적인 조회가 발생한다.
그래서 bulk delete 를 사용 했다.
bulk delete 는 SQL 문으로 보면 DELETE * FROM ~ 이다.
queryFactory
.delete(..)
.where(..)
.execute();
bulk delete 를 사용하면 delete 문이 바로 나간다.
그래서 결론, 자식-부모 순서로 bulk delete 로 삭제 tasklet 구현하기.
메일 발송 이력은 한달에 한번씩 최근 1년 이력만 남겨두고 삭제해야한다. 라는 요구사항을 지니고 있다.
일정 조건에 따라 대량의 작업을 자동으로 수행하기 위해서는 batch 로 만들어야한다.
그렇다면 spring batch 는 어떻게 구성되어있을까?
Spring Batch는 다음과 같은 계층 구조로 이루어진다.
예시
public class DemoTasklet implements Tasklet {
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
// 단순 작업 수행
return RepeatStatus.FINISHED;
}
}
구조
예시 흐름
Spring Bean은 기본적으로 애플리케이션 시작 시점에 생성된다.
하지만 Batch에서는 실행 시점에 전달되는 JobParameter를 사용해야 한다.
그럼 어떻게 해야할까?
batch 구현 중에 JobConfiguration 에 @StepScope 를 사용한다.
정확히는, 실행 시점에 늦은 바인딩으로 주입받아야 할 때, Job Parameter를 사용해 실행 시점의 바인딩으로 주입 받아야할 때,
주로, chunk 방식 구현에서 사용한다.
해결 방법
@StepScope
@Value("#{jobParameters[startDate]}")
그래서 아래와 같이 Configuration 을 구성한다.
@Configuration
public class DemoJobConfiguration {
private final JobBuilderFactory jobBuilderFactory;
private final StepBuilderFactory stepBuilderFactory;
private final DemoTasklet demoTasklet;
@Bean
public Job demoJob() {
return jobBuilderFactory.get("demoJob")
.incrementer(new RunIdIncrementer())
.start(demoStep())
.build();
}
@Bean
public Step demoStep() {
return stepBuilderFactory.get("demoStep")
.tasklet(demoTasklet)
.build();
}
}
@Scheduled(cron = "0 0 * * * *")
public void runJob() {
runJob("demoJob");
}
Spring Batch는 실행 이력을 DB에 저장한다.
주요 테이블
재시작 방식
Step 실패 시 → 해당 Step부터 재시작
Tasklet 실패 시 → 전체 다시 실행
Chunk 실패 시 → 실패한 시점부터 다시 실행
설계 초반에는 repository 단에서 while(true) 로 createdAt이 1년보다 이전일 경우 데이터를 5천건을 나누어 삭제했다. 아래와 같다.
@Transactional
public long deleteOldData() {
final int chunkSize = 5000;
long totalDeleted = 0;
while (true) {
// 1. 삭제 대상 ID를 5000건씩 조회
List<Long> ids = queryFactory
.select(demoEntity.id)
.from(demoEntity)
.where(
demoEntity.createdDate.before(targetDate)
)
.orderBy(demoEntity.id.asc())
.limit(chunkSize)
.fetch();
if (ids.isEmpty()) {
break;
}
// 2. 자식 테이블 Bulk Delete
queryFactory
.delete(demoChildEntity)
.where(demoChildEntity.demo.id.in(ids))
.execute();
// 3. 또 다른 자식 테이블 Bulk Delete
queryFactory
.delete(demoChildLanguageEntity)
.where(demoChildLanguageEntity.demo.id.in(ids))
.execute();
// 4. 부모 테이블 Bulk Delete
long deleted = queryFactory
.delete(demoEntity)
.where(demoEntity.id.in(ids))
.execute();
totalDeleted += deleted;
}
return totalDeleted;
}
하지만 의문이 들었다.
그러던 중 spring batch 를 공부하던 내 블로그글을 다시 보게 되었고 아래와 같이 수정했다.
@Slf4j
@Configuration
@RequiredArgsConstructor
public class DemoJobConfiguration {
private final JobBuilderFactory jobBuilderFactory;
private final StepBuilderFactory stepBuilderFactory;
private final JobResultListener jobResultListener;
private final StepResultListener stepResultListener;
private final Demo1Tasklet demo1Tasklet;
private final DemoRepository demoRepository;
private static final int CHUNK_SIZE = 5000;
/**
* Demo 1
* Tasklet 방식 예시
*
* 단순한 상태 업데이트, 단일 작업, 한 번에 끝나는 작업에 적합하다.
*/
@Bean
public Job demo1Job() {
return jobBuilderFactory.get("demo1Job")
.incrementer(new RunIdIncrementer())
.listener(jobResultListener)
.start(demo1Step())
.build();
}
@Bean
public Step demo1Step() {
return stepBuilderFactory.get("demo1Step")
.tasklet(demo1Tasklet)
.listener(stepResultListener)
.build();
}
/**
* Demo 2
* Chunk 방식 예시
*
* 대량 데이터를 일정 단위로 읽고, 처리하고, 쓰는 작업에 적합하다.
* 여기서는 1년이 지난 데이터를 5000건 단위로 삭제하는 예시이다.
*/
@Bean
public Job demo2Job() {
return jobBuilderFactory.get("demo2Job")
.incrementer(new RunIdIncrementer())
.listener(jobResultListener)
.start(demo2Step())
.build();
}
@Bean
public Step demo2Step() {
return stepBuilderFactory.get("demo2Step")
.<List<Long>, List<Long>>chunk(1)
.reader(demo2Reader())
.writer(demo2Writer())
.listener(stepResultListener)
.build();
}
@Bean
@StepScope
public ItemReader<List<Long>> demo2Reader() {
return () -> {
List<Long> ids = demoRepository.findDeleteTargetIds(CHUNK_SIZE);
if (ids.isEmpty()) {
return null;
}
return ids;
};
}
@Bean
@StepScope
public ItemWriter<List<Long>> demo2Writer() {
return items -> {
for (List<Long> ids : items) {
long deletedCount = demoRepository.deleteByIds(ids);
log.info(":::: demo2 deleted count = {}", deletedCount);
}
};
}
}
Demo 1은 Tasklet 방식이다.
하나의 작업을 한 번에 수행하는 구조로, 단순 상태 업데이트나 단일 로직 처리에 적합하다.
Demo 2는 Chunk 방식이다.
Reader가 삭제 대상 ID를 5000건씩 읽고, Writer가 해당 ID 기준으로 자식 테이블부터 부모 테이블까지 Bulk Delete를 수행한다.
위 configuration 에서 chunk(1) 이 이상해보일 수있다. 여기서는 item 하나 (삭제할 ids ) 5000건의 데이터를 가진다.
처음에는 해당로직을 이해하지 못하고 chunk(5000) 로 실행했다. 그럼 약 5000*5000 건의 데이터를 기다린 것이다. ..
그럼 이게 과연 bulk 연산의 이점이 맞나?
다시 처음으로 돌아가서, JPA 로 대량의 데이터를 삭제하는 것이 적합하지 않아 조회 없이 조건에 맞는 것만 삭제하도록 bulk delete 를 사용했는데 ex) delete * where createdAt before ~~
근데 다시 reader 에서 삭제할 ids 조회하고 writer 에서 ids 에 맞는 값을 삭제한다 .. 이게 맞는건가?
추가 ) 이게 맞다. 왜냐하면 부모-자식 간의 정합성이 맞아야하기 때문이다. 지워야할 부모 엔티티 id 를 지정하고 그에 맞게 자식을 먼저 지우고 부모를 지워야하니까!
이 방식은 조회 없이 조건만으로 삭제하는 순수 Bulk Delete는 아니다. 하지만 여기서 조회하는 것은 엔티티 전체가 아니라 삭제 기준이 되는 ID 목록뿐이다. 따라서 findBy로 엔티티를 조회한 뒤 deleteAll()을 수행하는 방식과는 다르다.
deleteAll()은 엔티티를 영속성 컨텍스트에 올리고 EntityManager.remove()를 반복 수행한다. 이 과정에서 Cascade 처리, 연관 엔티티 조회, 영속성 컨텍스트 관리 비용이 발생할 수 있다. 반면 Chunk 기반 Bulk Delete는 ID만 조회한 뒤 delete where id in (...) 형태의 SQL을 실행하므로 엔티티 삭제 비용을 피할 수 있다.
운영 환경에서는 한 번에 모든 데이터를 삭제하는 것보다 5,000건 단위로 끊어 삭제하는 편이 락, 트랜잭션 크기, rollback 비용을 줄일 수 있어 더 안전하다고 판단했다.