Home ✨TIL - 삭제 배치 성능 최적화(JdbcTemplate 리팩토링)
Post
Cancel

✨TIL - 삭제 배치 성능 최적화(JdbcTemplate 리팩토링)



배치 성능 최적화 - JdbcTemplate 리팩토링

배경

(이전글)삭제배치 성능 최적화

이전 글에서 DB 데이터 정리 배치가 4시간 30분 → 30초대로 줄어든 과정을 정리했었다. ROWNUM <= CHUNK_SIZE 방식으로 네이티브 쿼리를 chunk size만큼 반복 삭제하는 방식이었고, Tasklet으로 구성했었다.

그런데 리팩토링 후에도 몇 가지 찜찜한 부분이 남아있었다.


기존 코드의 문제점

1. 트랜잭션 중첩 문제

Step에 JpaTransactionManager를 지정하면 Spring Batch가 Tasklet 실행 전후로 트랜잭션을 열고 닫는다. 그런데 Tasklet 내부에서 emf.createEntityManager()로 EntityManager를 직접 생성하고, em.getTransaction().begin()으로 트랜잭션을 또 열고 있었다.

1
2
3
4
[Spring Batch] JpaTransactionManager.begin()
    [Tasklet 내부] em.getTransaction().begin()  ← 중첩
    [Tasklet 내부] em.getTransaction().commit()
[Spring Batch] JpaTransactionManager.commit()

Spring이 관리하는 영속성 컨텍스트 밖에서 독립 트랜잭션이 열리는 구조라, 두 트랜잭션이 완전히 별개로 동작하게 된다.

이 케이스는 삭제 실패 시 롤백이 필요 없는 상황이라 실제 운영 문제는 없었지만, 구조적으로 잘못된 코드였다.

2. COMMIT_COUNT 수동 보정

1
2
stepExecution.setCommitCount(commitCount - 2);
// Step 시작 시 1번, Tasklet 완료 시 1번의 트랜잭션 커밋이 발생해 항상 +2되어 나온다.

Tasklet 내부에서 트랜잭션을 직접 관리하다 보니 Spring Batch가 카운트하는 커밋 횟수와 실제 삭제 커밋 횟수가 분리되어, 수동으로 -2 보정을 해야 했다. Spring Batch 버전에 따라 내부 커밋 횟수가 달라질 수 있어 하드코딩 보정은 불안정하다.

3. EntityManager 직접 관리

emf.createEntityManager()로 수동 생성하고 finally에서 em.close()로 닫는 방식이라, Spring의 관리 범위 밖에서 리소스를 직접 다뤄야 했다.


JdbcTemplate으로 교체

트랜잭션을 Spring Batch가 통합 관리하도록 JdbcTemplate으로 교체했다.

CustomDeleteTasklet

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
@Slf4j
public class CustomDeleteTasklet {

    /**
     * 재사용 가능한 삭제 Tasklet 생성 메소드
     *
     * @param jdbcTemplate JdbcTemplate (외부 주입)
     * @param paramDate    기준일자 (e.g. '20240601')
     * @param countQuery   삭제 대상 건수 조회 쿼리 (1번 파라미터로 기준일자 사용)
     * @param deleteQuery  실제 삭제 쿼리 (1번 파라미터: 기준일자, 2번 파라미터: chunkSize)
     * @param chunkSize    1회 삭제 처리 건수
     * @return Tasklet
     */
    public Tasklet createDeleteTasklet(JdbcTemplate jdbcTemplate, String paramDate, String countQuery, String deleteQuery, int chunkSize) {
        return (contribution, chunkContext) -> {
            long deletedTotal = 0;

            Long totalCount = jdbcTemplate.queryForObject(countQuery, Long.class, paramDate);
            totalCount = totalCount != null ? totalCount : 0L;
            log.info("삭제 대상 총 건수: {}", formatNumber(totalCount));

            int deletedCount;
            do {
                deletedCount = jdbcTemplate.update(deleteQuery, paramDate, chunkSize);
                deletedTotal += deletedCount;

                log.info("삭제 진행률: {}/{} ({}%)", formatNumber(deletedTotal), formatNumber(totalCount),
                        String.format("%.2f", (deletedTotal * 100.0) / totalCount));
            } while (deletedCount > 0);

            log.info("삭제 완료. 총 {}건 삭제됨.", formatNumber(deletedTotal));

            StepExecution stepExecution = chunkContext.getStepContext().getStepExecution();
            stepExecution.setReadCount((int) deletedTotal);
            stepExecution.setWriteCount((int) deletedTotal);

            return RepeatStatus.FINISHED;
        };
    }

    public static String formatNumber(Long number) {
        if (number == null) {
            return "0";
        }
        DecimalFormat fm = new DecimalFormat("#,###");
        return fm.format(number);
    }
}

Step / Tasklet 설정

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Bean
public Step logTableDeleteStep() {
    return sf.get(JOB_NAME_PREFIX + "LogTableDeleteStep")
            .tasklet(logTableDeleteTasklet(null))
            .transactionManager(transactionManager) // DataSourceTransactionManager
            .build();
}

@Bean
@StepScope
public Tasklet logTableDeleteTasklet(@Value("#{jobParameters[paramDelLog]}") String date) {
    String countQuery = """
            SELECT COUNT(*)
            FROM SC.LOG_TABLE
            WHERE DATE < TO_DATE(?, 'YYYYMMDD')
            """;

    String deleteQuery = """
            DELETE FROM SC.LOG_TABLE
            WHERE DATE < TO_DATE(?, 'YYYYMMDD')
            AND ROWNUM <= ?
            """;

    CustomDeleteTasklet factory = new CustomDeleteTasklet();
    return factory.createDeleteTasklet(jdbcTemplate, date, countQuery, deleteQuery, CHUNK_SIZE);
}

변경 포인트 정리

항목기존변경
데이터 접근EntityManager 직접 생성JdbcTemplate
트랜잭션 관리내부에서 직접 begin/commitSpring Batch 통합 관리
TransactionManagerJpaTransactionManagerDataSourceTransactionManager
쿼리 파라미터?1, ?2 (JPA 스타일)? (JDBC 스타일)
COMMIT_COUNT 보정commitCount - 2 수동 보정불필요
while문 구조while(true) + breakdo-while

트랜잭션을 Spring Batch가 통합 관리하게 되면서 commitCount - 2 수동 보정 코드도 제거할 수 있었다. countQuery, deleteQuery만 바꿔서 다른 로그성 테이블 삭제 배치에도 그대로 재사용 가능하다.



This post is licensed under CC BY 4.0 by the author.