[Spring] Spring Batch로 4.5만건 데이터 삭제하기

정 승 연·2026년 7월 1일
post-thumbnail

메일 발송 서비스에 쌓인 발송 이력을 삭제하기 위해 배치를 개발했다.
JPA 를 사용하는 프로젝트에서 연관관계가 있는 3개의 엔티티 약 4.5만건의 데이터를 삭제하는데 어떤 방식이 효과적일까?

0. 연관관계가 많은 엔티티의 삭제

부모-자식 연관관계가 있는 데이터에서 부모를 먼저 삭제하면 cannot delete parent row 에러가 발생한다. 따라서 자식 - 부모 순으로 삭제 해야한다.

1. Cascade.ALL + deleteAll ()

3개의 엔티티가 얽혀 있고 1:n:n 연관관계 매핑이 있는 데이터였는데,

연관관계 있는 부모, 자식 엔티티를 fetch 조회해서 (한꺼번에 조회 ) List <부모엔티티> 를 조회한 후 deleteAll() 로 삭제하면 순조롭게 삭제될 것이라 생각했다.

물론, cascade 설정이 잘 되어있다면 문제없이 삭제 되겠지만 n만건의 대량의 데이터, 그리고 batch 로 구현하기에는 문제가 있어 보였다.

2. 단계적 삭제 + bulk 연산

음 그리고 연관관계에 있는 엔티티를 단계적으로 삭제하는게 좋아보였다.
혹시나 생길 오류로 고아 객체 (?) 고아 엔티티 (?) 가 발생한다면 데이터 삭제가 유의미하지 않을 것이기 때문이다.

따라서 최근 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 구현하기.

3. spring batch 를 이용해 배치 구현

메일 발송 이력은 한달에 한번씩 최근 1년 이력만 남겨두고 삭제해야한다. 라는 요구사항을 지니고 있다.

일정 조건에 따라 대량의 작업을 자동으로 수행하기 위해서는 batch 로 만들어야한다.

그렇다면 spring batch 는 어떻게 구성되어있을까?

1) Batch 구조

Spring Batch는 다음과 같은 계층 구조로 이루어진다.

  • Job: 배치 전체 작업 단위
  • Step: Job 내부의 실행 단계
  • Tasklet / Chunk: Step 내부에서 실제 수행되는 방식

구조 요약

  • 1 Job = N Step
  • 1 Step = 1 Tasklet 또는 1 Chunk

2) Tasklet 방식

  • 단순한 작업에 적합
  • Step 안에서 한 번 실행되고 종료됨

예시

  • 메일 전송
  • 상태 값 변경
  • 간단한 배치 처리
public class DemoTasklet implements Tasklet {

    @Override
    public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
        // 단순 작업 수행

        return RepeatStatus.FINISHED;
    }
}

3) Chunk 방식

  • 대량 데이터 처리에 적합
  • 데이터를 일정 단위로 나눠서 처리

구조

  • reader: 데이터 읽기
  • processor: 데이터 가공
  • writer: 데이터 저장

예시 흐름

  • reader → 100건 조회
  • processor → 1건씩 가공
  • writer → 100건 모아서 저장

4) batch 동작 흐름

  1. 애플리케이션 시작
    • StepScope Bean → 프록시 객체로 등록
  2. Job 실행
  3. Step 실행
  4. 프록시 객체가 실제 Bean 생성
  5. JobParameter 주입

Spring Bean은 기본적으로 애플리케이션 시작 시점에 생성된다.
하지만 Batch에서는 실행 시점에 전달되는 JobParameter를 사용해야 한다.

그럼 어떻게 해야할까?

5) @StepScope

batch 구현 중에 JobConfiguration 에 @StepScope 를 사용한다.

정확히는, 실행 시점에 늦은 바인딩으로 주입받아야 할 때, Job Parameter를 사용해 실행 시점의 바인딩으로 주입 받아야할 때,

주로, chunk 방식 구현에서 사용한다.

해결 방법

@StepScope
@Value("#{jobParameters[startDate]}")
  • Step 실행 시점에 Bean 생성하도록 해 JobParameter 를 주입 할 수 있다.

그래서 아래와 같이 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();
    }
}

6) Job 실행 (Scheduler)

@Scheduled(cron = "0 0 * * * *")
public void runJob() {
    runJob("demoJob");
}

7) 실패 시 재시작

Spring Batch는 실행 이력을 DB에 저장한다.

주요 테이블

  • BATCH_JOB_EXECUTION
  • BATCH_STEP_EXECUTION

재시작 방식

  • Step 실패 시 → 해당 Step부터 재시작

  • Tasklet 실패 시 → 전체 다시 실행

  • Chunk 실패 시 → 실패한 시점부터 다시 실행

4-1 spring batch + tasklet + while + bulk delete

설계 초반에는 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;
}

하지만 의문이 들었다.

  1. repository 단에 5천건씩 삭제한다는 비즈니스 로직이 들어가있음
  2. while (true) break; 로 삭제하는게 맞나?

그러던 중 spring batch 를 공부하던 내 블로그글을 다시 보게 되었고 아래와 같이 수정했다.

4-2. spring batch + chunk + bulk delete



@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를 수행한다.

5. 특이 사항

위 configuration 에서 chunk(1) 이 이상해보일 수있다. 여기서는 item 하나 (삭제할 ids ) 5000건의 데이터를 가진다.

처음에는 해당로직을 이해하지 못하고 chunk(5000) 로 실행했다. 그럼 약 5000*5000 건의 데이터를 기다린 것이다. ..

6. 그래도 드는 의문점

그럼 이게 과연 bulk 연산의 이점이 맞나?

다시 처음으로 돌아가서, JPA 로 대량의 데이터를 삭제하는 것이 적합하지 않아 조회 없이 조건에 맞는 것만 삭제하도록 bulk delete 를 사용했는데 ex) delete * where createdAt before ~~

근데 다시 reader 에서 삭제할 ids 조회하고 writer 에서 ids 에 맞는 값을 삭제한다 .. 이게 맞는건가?

추가 ) 이게 맞다. 왜냐하면 부모-자식 간의 정합성이 맞아야하기 때문이다. 지워야할 부모 엔티티 id 를 지정하고 그에 맞게 자식을 먼저 지우고 부모를 지워야하니까!

7. 결론

이 방식은 조회 없이 조건만으로 삭제하는 순수 Bulk Delete는 아니다. 하지만 여기서 조회하는 것은 엔티티 전체가 아니라 삭제 기준이 되는 ID 목록뿐이다. 따라서 findBy로 엔티티를 조회한 뒤 deleteAll()을 수행하는 방식과는 다르다.

deleteAll()은 엔티티를 영속성 컨텍스트에 올리고 EntityManager.remove()를 반복 수행한다. 이 과정에서 Cascade 처리, 연관 엔티티 조회, 영속성 컨텍스트 관리 비용이 발생할 수 있다. 반면 Chunk 기반 Bulk Delete는 ID만 조회한 뒤 delete where id in (...) 형태의 SQL을 실행하므로 엔티티 삭제 비용을 피할 수 있다.

운영 환경에서는 한 번에 모든 데이터를 삭제하는 것보다 5,000건 단위로 끊어 삭제하는 편이 락, 트랜잭션 크기, rollback 비용을 줄일 수 있어 더 안전하다고 판단했다.

0개의 댓글