Home ✨TIL - 배치 성능 최적화
Post
Cancel

✨TIL - 배치 성능 최적화



배치 성능 최적화

디비 데이터 정리 배치 중 유독 한 배치만 느리게 실행된다.
다른 기존에 잘 사용하고 있던 배치를 가져다 만든 같은 코드였지만, 여러 배치가 있는데 해당 배치가 너무 오래걸려 다른 배치들이 밀리기 시작했다.

이 배치가 자정에 시작해 4시간 반 소요되어 운영 시작 시간이 오전 5시 이후에도 끝나지 않는 문제가 발생하였다.
테이블 정리 삭제배치가 운영중에도 돌기 시작한 것이다. 😨 다행이도 운영데이터가 복사된 데이터라 운영에는 문제가 없었다.

기존에는 다른 삭제 배치 코드들에도 사용하고 있던 방식인 QuerydslZeroPagingItemReader 로 read하고
writer 에서 호출할 메서드 설정해 .methodName("delete") 하는 방식으로 사용하였다.
다음은 예시 코드이다.

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
  // ----- STEP --------------------------------------------------------------------------------------------------------------------
	// 로그 테이블 삭제
  @Bean
  public Step logTableDeleteStep() {
    return sf.get(JOB_NAME_PREFIX + "LogTableDeleteStep")
        .<LogTable, LogTable>chunk(CHUNK_SIZE)
        .reader(logTableDeleteReader(null))
        .writer(logTableDeleteWriter())
        .transactionManager(new JpaTransactionManager(emf))
        .build();
  }

  // Reader
  @Bean
  @StepScope
  @SuppressWarnings("SpringElInspection")
  public QuerydslZeroPagingItemReader<LogTable> logTableDeleteReader(
      @Value("#{jobParameters[paramDelLog]}") String date) {
    return new QuerydslZeroPagingItemReader<>(emf, CHUNK_SIZE, jpaQueryFactory ->
        jpaQueryFactory.selectFrom(logTable)
            .where(logTable.date.loe(date))
    );
  }

  // Writer
  public RepositoryItemWriter<LogTable> logTableDeleteWriter() {
    return new RepositoryItemWriterBuilder<LogTable>()
        .repository(LogTableRepo).methodName("delete").build();
  }

코드는 결국 읽은 row 모두, 데이터 하나당 쿼리가 1번씩 실행하게 되는 코드이다.

그래서 이 스탭에서만 하루 평균 9만건이 삭제되는 중이어서 9만개의 delete 쿼리가 실행된것이다.

delete 쿼리 개수를 줄이는 것이 성능을 위해 좋다고 판단하였다.

chunk 사이즈는 1000으로 설정되어 있지만 delete 쿼리 실행 횟수를 줄여줄 수는 없다.


해결1

벌크 삭제

먼저 벌크 삭제를 시도하였다.

벌크 삭제는 reader는 그대로 사용하고 chunk size(1000) 만큼 읽어 in절에 해당 pk를 다 넣어주고,

그 chunk 사이즈만큼 한번에 delete 쿼리를 실행하는 방식이다.

in절에 1000개 이상의 조건이 들어가게되면 오류가 발생할수 있으니 그대로 두었다.

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
  // Writer
  @Bean
  @StepScope
  public ItemWriter<LogTable> logTableWriter() {
    return items -> {
      if (items.isEmpty()) return;
      
      EntityManager em = emf3.createEntityManager();
      EntityTransaction tx = em.getTransaction();
      
      try {
        tx.begin();
        
        // ID 목록 추출 (예: Long 타입 기준)
        List<Long> idList = items.stream()
                .map(LogTable::getId)
                .collect(Collectors.toList());
        
        // 벌크 삭제
        em.createQuery("DELETE FROM SC.LOG_TABLE l WHERE l.ID IN :ids")
                .setParameter("ids", idList)
                .executeUpdate();
        
        tx.commit();
      } catch (Exception e) {
        log.error("삭제 중 예외 발생: {}", e.getMessage(), e);
        throw e;
        // 예외가 발생해도 롤백할 필요 없음
      } finally {
        em.close();
      }
    };
  }

하루 시도 해봤지만 절반정도밖에 시간이 줄지 않았다..

2시간정도 걸렸다.


해결2

네이티브 쿼리로 rownum(chunk_size) 만큼씩 삭제

사실 이미 필요없는 2달 전 데이터라 실패해도 롤백할 필요도 없었고, 다른 기록을 남김 필요없는 테이블이라 좀 더 간단하게 삭제해도 되었다. in절로 다 넣어서 삭제하는 경우는 보통 롤백을 대비해 사용하는데, 이 경우에는 굳이 삭제한 데이터를 롤백할 필요가 없었다.

삭제 조건은 지정한 파라미터보다 이전 날짜의 데이터를 모두 삭제하는 것이어서 한꺼번에 삭제해도 되겠지만~
혹시 모를 원인으로 트랜잭션 문제나 테이블락을 우려하여 chunk 사이즈만큼씩 삭제하고, 삭제 진행률을 로그에 남기기로 했다.

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
49
50
51
52
53
54
55
56
57
58
59
 @Bean
public Step logTableDeleteStep() {
  return sf.get(JOB_NAME_PREFIX + "logTableDeleteStep")
    .<LogTable, LogTable>chunk(CHUNK_SIZE)
    .reader(new ListItemReader<>(Collections.singletonList(new LogTable()))) // 아무 작업도 하지 않지만 reader가 없으면 컴파일 에러 발생
    .writer(logTableDeleteWriter(null))
    .transactionManager(new JpaTransactionManager(emf3))
    .build();
}

// Writer
@Bean
@StepScope
public ItemWriter<LogTable> logTableDeleteWriter(@Value("#{jobParameters[paramDelLog]}") String date) {
  return items -> {
    EntityManager em = emf3.createEntityManager();

    try {
      // 총 건수 조회
      Number countResult = (Number) em.createNativeQuery("""
          SELECT COUNT(*) FROM SC.LOG_TABLE WHERE DATE < TO_DATE(?1, 'YYYYMMDD') """)
        .setParameter(1, paramDeptDt)
        .getSingleResult();

      long totalCount = countResult.longValue();
      long deletedTotal = 0;

      log.info("삭제 대상 총 건수: {}", totalCount);

      while (true) {
        EntityTransaction transaction = em.getTransaction();
        transaction.begin();

        int deletedCount = em.createNativeQuery("""
            DELETE FROM SC.LOG_TABLE WHERE DATE < TO_DATE(?1, 'YYYYMMDD') AND ROWNUM <= ?2 """)
          .setParameter(1, date)
          .setParameter(2, CHUNK_SIZE)
          .executeUpdate();
        transaction.commit();

        deletedTotal += deletedCount;

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

        if (deletedCount == 0) {
          log.info("삭제 완료. 총 {}건 삭제됨.", deletedTotal);
          break;
        }
      }

    } catch (Exception e) {
      log.error("삭제 중 예외 발생: {}", e.getMessage(), e);
      // 롤백하지 않고 예외만 던짐
      throw e;
    } finally {
      em.close();
    }
  };
}

이렇게 수정하였고, 결과는 4시간 30분대에서 에서 → 30초대로 줄어들었다!!

삭제 진행률을 표시하기 위해 전체 건수를 한번 세주고,
해당 조건의 데이터가 없을때까지 반복문으로 chunk size 만큼 삭제하는 쿼리가 실행된다. 하루 평균 9만건이라 90번 실행되기는 하지만, BATCH_STEP_EXECUTION에서 표시될 count 를 다른 배치들과의 통일성을 위해 그대로 1000으로 두었다.



다른 문제 발생

BATCH_STEP_EXECUTION 에 저장되는 READ_COUNT, WRITE_COUNT, COMMIT_COUNT 건수 비정상

그리고 이렇게 했을 때 또 문제가 배치 전용테이블에 count가 제대로 입력되고 있지 않았다. READ_COUNT, WRITE_COUNT 에는 무조건 chunk 사이즈가 입력되고 있었고,
COMMIT_COUNT에는 무조건 2가 들어가고있었다.

1
2
3
COMMIT_COUNT = 2
READ_COUNT = CHUNK_SIZE
WRITE_COUNT = CHUNK_SIZE

reader와 writer을 제대로 사용하고 있지 않아서 제대로 count 될 수 없었다.
직접 입력하는 것으로 수정을 해보니

1
2
3
COMMIT_COUNT = delete쿼리 횟수 + 2
READ_COUNT = 삭제 대상 row + CHUNK_SIZE
WRITE_COUNT = 삭제 대상 row + CHUNK_SIZE

이렇게 나오기 시작했다..

일단 reader가 필요가 없어 더미데이터로 조회되게 설정을 해서
reader에서 더이상 대상이 없을때까지 chunk사이즈 만큼 계속 조회를 시도했기때문에
더이상 삭제대상이 없을 경우에도 1번더 조회해서 CHUNK_SIZE 만큼 더 count가 되는 상황이었다.
그리고 더 찾아보니 이런경우에는 굳이 reader, writer을 사용하지 않고 tasklet 을 사용해 더 간단하게 구현할 수 있었다!

기존코드

1
2
3
4
5
6
7
8
9
  @Bean
  public Step logTableDeleteStep() {
    return sf.get(JOB_NAME_PREFIX + "LogTableDeleteStep")
            .<LogTable, LogTable>chunk(CHUNK_SIZE)
            .reader(logTableDeleteReader(null))
            .writer(logTableDeleteWriter())
            .transactionManager(new JpaTransactionManager(emf3))
            .build();
  }

변경코드

1
2
3
4
5
6
7
  @Bean
  public Step logTableDeleteStep() {
    return sf.get(JOB_NAME_PREFIX + "LogTableDeleteStep")
            .tasklet(logTableDeleteTasklet(null))
            .transactionManager(new JpaTransactionManager(emf3))
            .build();
  }

의미 없는 reader을 빼고, 기존 wirter를 tasklet으로 변경만 하면된다. 이렇게 변경하여 READ_COUNT , WRITE_COUNT 는 원하는 값이 들어가게 되었다.

1
2
3
COMMIT_COUNT = delete쿼리 횟수 + 2
READ_COUNT = 삭제 대상 row 건수(정상)
WRITE_COUNT = 삭제 대상 row 건수(정상)

COMMIT_COUNT는 Step 시작 시 1번, Tasklet 완료 시 1번의 트랜잭션 커밋이 발생해 commit_count의 수가 항상 +2되어 나오게 되는 것이었다.

그래서 이분은 그냥 일단 직접 -2 해주는 코드로 설정하였다.

1
2
3
4
5
6
7
8
9
10
11
			
    StepExecution stepExecution = chunkContext.getStepContext().getStepExecution();
    stepExecution.setReadCount((int) deletedTotal);
    stepExecution.setWriteCount((int) deletedTotal);
    stepExecution.setCommitCount(commitCount - 2); //  Step 시작 시 1번, Tasklet 완료 시 1번의 트랜잭션 커밋이 발생해 commit_count의 수가 항상 +2되어 나온다.

    StepExecution stepExecution = chunkContext.getStepContext().getStepExecution();
    stepExecution.setReadCount((int) deletedTotal);
      stepExecution.setWriteCount((int) deletedTotal);
      stepExecution.setCommitCount(commitCount - 2); //  Step 시작 시 1번, Tasklet 완료 시 1번의 트랜잭션 커밋이 발생해 commit_count의 수가 항상 +2되어 나온다.


리팩토링

이런 비슷한 로그성 테이블 삭제하는 여러 스탭에서 동일 문제 발생하여, 메소드로 생성해 사용해야 편한거 같았다.
일단 필요한 형식으로 만들긴했는데 썩 마음에 들진 않는당..

날짜 조건으로 chunk_size 만큼 삭제되는 Tasklet 메소드를 생성하고 꺼내쓰기로 했다.
필요 파라미터는 각 스탭마다 디비나 스키마가 달라 지정 EntityManager를 받고, 삭제 진행률 표시에 사용될 totalCountQuery,
삭제시 사용할 deleteQuery, 그리고 날짜 기준으로 사용될 날짜와, chunk_size가 필요하다.

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
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.StepExecution;
import org.springframework.batch.core.step.tasklet.Tasklet;
import org.springframework.batch.repeat.RepeatStatus;

import javax.persistence.EntityManager;
import javax.persistence.EntityTransaction;
import java.text.DecimalFormat;

@Slf4j
public class CustomDeleteTasklet {
	
	/**
	 * 재사용 가능한 삭제 Tasklet 생성 메소드
	 *
	 * @param em          EntityManager (외부 주입)
	 * @param paramDate   기준일자 (e.g. '20240601')
	 * @param countQuery  삭제 대상 건수 조회 쿼리 (1번 파라미터로 기준일자 사용)
	 * @param deleteQuery 실제 삭제 쿼리 (1번 파라미터: 기준일자, 2번 파라미터: chunkSize)
	 * @param chunkSize   1회 삭제 처리 건수
	 * @return Tasklet
	 */
	public Tasklet createDeleteTasklet(EntityManager em, String paramDate, String countQuery, String deleteQuery, int chunkSize) {
		return (contribution, chunkContext) -> {
			long deletedTotal = 0;
			long totalCount = 0;
			int commitCount = 0;
			
			try {
				Number countResult = (Number) em.createNativeQuery(countQuery)
						.setParameter(1, paramDate)
						.getSingleResult();
				
				totalCount = countResult.longValue();
				log.info("삭제 대상 총 건수: {}", formatNumber(totalCount));
				
				while (true) {
					EntityTransaction transaction = em.getTransaction();
					transaction.begin();
					
					int deletedCount = em.createNativeQuery(deleteQuery)
							.setParameter(1, paramDate)
							.setParameter(2, chunkSize)
							.executeUpdate();
					
					transaction.commit();
					deletedTotal += deletedCount;
					commitCount++;
					
					log.info("삭제 진행률: {}/{} ({}%)", formatNumber(deletedTotal), formatNumber(totalCount),
							String.format("%.2f", (deletedTotal * 100.0) / totalCount));
					
					if (deletedCount == 0) {
						log.info("삭제 완료. 총 {}건 삭제됨.", formatNumber(deletedTotal));
						break;
					}
				}
				
				StepExecution stepExecution = chunkContext.getStepContext().getStepExecution();
				stepExecution.setReadCount((int) deletedTotal);
				stepExecution.setWriteCount((int) deletedTotal);
				stepExecution.setCommitCount(commitCount - 2); //  Step 시작 시 1번, Tasklet 완료 시 1번의 트랜잭션 커밋이 발생해 commit_count의 수가 항상 +2되어 나온다.
				
			} catch (Exception e) {
				log.error("삭제 중 예외 발생: {}", e.getMessage(), e);
				throw e;
			}
			
			return RepeatStatus.FINISHED;
		};
	}
	
	
	public static String formatNumber(Long number) {
		if (number == null) {
			return "0";
		}
		DecimalFormat fm = new DecimalFormat("#,###");
		return fm.format(number);
	}
}

쿼리를 repository에서 불러오고싶지만, 결과가 아닌 쿼리자체가 필요한 상황이라 네이티브쿼리로 받는다.

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
  @Bean
  public Step logTableDeleteStep() {
    return sf.get(JOB_NAME_PREFIX + "LogTableDeleteStep")
            .tasklet(logTableDeleteTasklet(null))
            .transactionManager(new JpaTransactionManager(emf3))
            .build();
  }

  @Bean
  @StepScope
  public Tasklet logTableDeleteTasklet(@Value("#{jobParameters[paramDelLog]}") String date) {
    EntityManager em = emf.createEntityManager();
    
    String countQuery = """
        SELECT COUNT(*)
        FROM SC.LOG_TABLE
        WHERE DATE < TO_DATE(?1, 'YYYYMMDD')
    """;
    
    String deleteQuery = """
        DELETE FROM SC.LOG_TABLE
        WHERE DATE< TO_DATE(?1, 'YYYYMMDD')
        AND ROWNUM <= ?2
    """;
    
    CustomDeleteTasklet factory = new CustomDeleteTasklet();
    return factory.createDeleteTasklet(em, date, countQuery, deleteQuery, CHUNK_SIZE);
  }

이런식으로 countQuery , deleteQuery 쿼리만 변경해주면 사용가능하다. 그리고 다른 로그성 테이블 삭제 배치에도 적용해보았다. 역시 효과는 좋았다 ㅎㅎ
쿼리 파라미터 넣는걸 좀더 보기 쉽게 숫자가 아닌값으로 넣어주고싶었지만… 오류가 발생한다..ㅎㅎ 숫자가 제일 좋은방법이라구…
일단 이정도로 마무리해본다.

이미지


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