페이지 불러오는 중
본 문서는 실무 환경에서 대용량 데이터를 다루는 스프링 배치(Spring Batch)의 성능을 극대화하기 위한 구체적인 튜닝 기법과 코드 구현 사례를 정리한 기록입니다.
단일 스레드 대용량 처리에서 가장 효율적인 JdbcCursorItemReader 설정입니다. fetchSize를 chunk 크기와 동일하게 맞추어 오버헤드를 최소화합니다.
@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();
}
}단건 INSERT/UPDATE로 인한 네트워크 병목을 제거하고, 청크 단위로 모아서 한 번에 쏘는 벌크 라이터 설정입니다.
@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()를 호출하여 단건 통신을 제거함
}페이징 리더를 사용할 때, 대량의 데이터를 넘기면서 발생하는 OFFSET 부하(Deep Paging)를 방지하기 위해 마지막 처리된 ID를 조건절로 추적하는 No-Offset 구현 방식입니다.
@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();
}
}자바 애플리케이션 메모리로 데이터를 불필요하게 긁어와서 거르는 잘못된 예시와, 이를 SQL 레이어로 이관하여 튜닝한 올바른 예시의 코드 비교입니다.
// 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;
};
}// ★ 개선: 네트워크 파이프라인을 타고 넘어오기 전에 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 계정으로 의견이나 질문을 남길 수 있습니다.