파트 6. 실무 배치 개발을 위한 성능 튜닝
본 문서는 실무 환경에서 대용량 데이터를 다루는 스프링 배치(Spring Batch)의 성능을 극대화하기 위한 구체적인 튜닝 기법과 코드 구현 사례를 정리한 기록입니다.
1. Chunk Size와 Fetch Size 일치화 + Cursor 스트리밍 (포인트 1, 2)
단일 스레드 대용량 처리에서 가장 효율적인 JdbcCursorItemReader 설정입니다. fetchSize를 chunk 크기와 동일하게 맞추어 오버헤드를 최소화합니다.
자바 구현 코드 예시
java
@Configuration
@RequiredArgsConstructor
public class CursorReaderConfig {
private final DataSource dataSource;
private static final int CHUNK_SIZE = 5000; // 청크 사이즈 정의
@Bean
public Step cursorStep(JobRepository jobRepository, PlatformTransactionManager transactionManager) {
return new StepBuilder("cursorStep", jobRepository)
.<UserDto, UserDto>chunk(CHUNK_SIZE, transactionManager) // 청크 사이즈 지정을 5000으로 설정
.reader(customCursorReader())
.writer(customLogWriter())
.build();
}
@Bean
public JdbcCursorItemReader<UserDto> customCursorReader() {
return new JdbcCursorItemReaderBuilder<UserDto>()
.dataSource(dataSource)
.name("customCursorReader")
.sql("SELECT id, name, email FROM users")
.fetchSize(CHUNK_SIZE) // ★ 핵심: Chunk Size와 완벽히 일치시켜 네트워크 버퍼링 최적화
.rowMapper(new BeanPropertyRowMapper<>(UserDto.class))
.build();
}
}2. JdbcBatchItemWriter를 통한 Bulk 연산 (포인트 3)
단건 INSERT/UPDATE로 인한 네트워크 병목을 제거하고, 청크 단위로 모아서 한 번에 쏘는 벌크 라이터 설정입니다.
자바 구현 코드 예시
java
@Bean
public JdbcBatchItemWriter<UserDto> customBulkWriter() {
return new JdbcBatchItemWriterBuilder<UserDto>()
.dataSource(dataSource)
.sql("INSERT INTO users_archive (id, name, email) VALUES (:id, :name, :email)") // 벌크 실행할 쿼리
.itemSqlParameterSourceProvider(new BeanPropertyItemSqlParameterSourceProvider<>())
.assertUpdates(false) // ★ 튜닝 팁: 성공 로우 수 검증 로직을 제외하여 미세한 연산 속도 추가 확보
.build(); // 내부적으로 드라이버 레벨의 executeBatch()를 호출하여 단건 통신을 제거함
}3. SQL 레벨 튜닝 및 No-Offset 페이징 전환 (포인트 4)
페이징 리더를 사용할 때, 대량의 데이터를 넘기면서 발생하는 OFFSET 부하(Deep Paging)를 방지하기 위해 마지막 처리된 ID를 조건절로 추적하는 No-Offset 구현 방식입니다.
자바 구현 코드 예시
java
@Configuration
@RequiredArgsConstructor
public class NoOffsetPagingConfig {
private final DataSource dataSource;
private static final int CHUNK_SIZE = 2000;
@Bean
@StepScope // 각 스텝 실행 시점에 파라미터를 유연하게 제어하기 위해 스텝 스코프 선언
public JdbcPagingItemReader<UserDto> noOffsetPagingReader() {
Map<String, Order> sortKeys = new HashMap<>();
sortKeys.put("id", Order.ASCENDING); // No-Offset을 위해 정렬 기준 컬럼 지정 필수
MySqlPagingQueryProvider queryProvider = new MySqlPagingQueryProvider();
queryProvider.setSelectClause("id, name, email");
queryProvider.setFromClause("from users");
// ★ 핵심: OFFSET ?, ? 방식 대신 인덱스를 타는 ID 조건절로 탐색 범위를 제한 (Deep Paging 원천 차단)
// 런타임에 배치가 실행되면서 내부적으로 최적화된 WHERE id > last_id 쿼리가 수행됨
queryProvider.setWhereClause("id > :lastProcessedId");
Map<String, Object> parameterValues = new HashMap<>();
parameterValues.put("lastProcessedId", 0); // 최초 시작 ID 값 세팅 (이후 배치가 자동 갱신 관리)
return new JdbcPagingItemReaderBuilder<UserDto>()
.dataSource(dataSource)
.name("noOffsetPagingReader")
.queryProvider(queryProvider)
.parameterValues(parameterValues)
.pageSize(CHUNK_SIZE)
.rowMapper(new BeanPropertyRowMapper<>(UserDto.class))
.build();
}
}4. 비즈니스 필터링의 DB 레이어 전임 (포인트 5)
자바 애플리케이션 메모리로 데이터를 불필요하게 긁어와서 거르는 잘못된 예시와, 이를 SQL 레이어로 이관하여 튜닝한 올바른 예시의 코드 비교입니다.
❌ AS-IS: 잘못된 방식 (ItemProcessor에서 필터링)
java
// Reader는 테이블의 모든 데이터를 무조건 다 읽어옴
@Bean
public JdbcCursorItemReader<UserDto> badReader() {
return new JdbcCursorItemReaderBuilder<UserDto>()
.dataSource(dataSource)
.name("badReader")
.sql("SELECT id, name, status FROM users") // 조건 없는 전체 조회
.rowMapper(new BeanPropertyRowMapper<>(UserDto.class))
.build();
}
// Processor에서 불필요한 데이터를 거르고 null을 반환 (메모리, 네트워크 대역폭 낭비 원인)
@Bean
public ItemProcessor<UserDto, UserDto> badProcessor() {
return item -> {
if ("INACTIVE".equals(item.getStatus())) {
return null; // 스프링 배치 규격상 null을 반환하면 해당 데이터는 Writer로 넘어가지 않음
}
return item;
};
}✅ TO-BE: 올바른 방식 (ItemReader SQL 문맥에서 필터링)
java
// ★ 개선: 네트워크 파이프라인을 타고 넘어오기 전에 DB 엔진 단에서 완벽히 필터링 후 가공 대상만 수집
@Bean
public JdbcCursorItemReader<UserDto> goodReader() {
return new JdbcCursorItemReaderBuilder<UserDto>()
.dataSource(dataSource)
.name("goodReader")
.sql("SELECT id, name, status FROM users WHERE status = 'ACTIVE'") // DB 레이어에서 필터링 완료
.fetchSize(5000)
.rowMapper(new BeanPropertyRowMapper<>(UserDto.class))
.build();
}
// Processor는 필터링 분기문 없이 오직 순수 비즈니스 변환 로직에만 집중
@Bean
public ItemProcessor<UserDto, UserDto> goodProcessor() {
return item -> {
item.archive(); // 순수 가공 태스크만 처리
return item;
};
}
댓글
GitHub 계정으로 의견이나 질문을 남길 수 있습니다.