Spring Batch를 실무 흐름으로 이해하기
Spring Boot 기반 Spring Batch 5로 대용량 데이터 처리 파이프라인을 구성합니다. Job/Step/Chunk 아키텍처, ItemReader/Writer, 스케줄링, 병렬 처리, 재시작 전략까지 실전 배치 시스템을 설계합니다. 이 가이드는 개념을 나열하기보다, 실제 프로젝트에서 판단해야 하는 순서대로 내용을 따라갈 수 있게 구성했습니다.
Spring Boot 기반 Spring Batch 5로 대용량 데이터 처리 파이프라인을 구성합니다. Job/Step/Chunk 아키텍처, ItemReader/Writer, 스케줄링, 병렬 처리, 재시작 전략까지 실전 배치 시스템을 설계합니다.
Spring Boot 기반 Spring Batch 5로 대용량 데이터 처리 파이프라인을 구성합니다. Job/Step/Chunk 아키텍처, ItemReader/Writer, 스케줄링, 병렬 처리, 재시작 전략까지 실전 배치 시스템을 설계합니다. 이 가이드는 개념을 나열하기보다, 실제 프로젝트에서 판단해야 하는 순서대로 내용을 따라갈 수 있게 구성했습니다.
문법보다 요청이 들어와 검증, 처리, 저장, 응답으로 이어지는 경계를 먼저 잡습니다.
글로 읽은 내용을 머릿속에 오래 남기려면 먼저 흐름을 그림으로 잡는 편이 좋습니다. 아래 두 그림은 Spring Batch를 학습할 때 계속 되돌아볼 수 있는 기준 지도입니다.
Spring Batch를 처음 펼칠 때는 세부 명령보다 큰 그림이 먼저입니다. 이 섹션에서는 앞으로 배울 개념들이 어떤 문제를 풀기 위해 등장했는지부터 잡아봅니다.
| 개념 | 설명 |
|---|---|
| Job | 배치 작업의 최상위 단위 — 하나 이상의 Step으로 구성 |
| Step | 실제 처리 단위 — Chunk 방식 또는 Tasklet 방식 |
| Chunk | Read → Process → Write 를 N건씩 트랜잭션 단위로 처리 |
| JobRepository | Job 실행 이력, 상태를 DB에 저장 |
| JobLauncher | Job을 실행하는 진입점 |
여기서는 Spring Boot 프로젝트 설정을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
plugins {
id("java")
id("org.springframework.boot") version "3.3.5"
id("io.spring.dependency-management") version "1.1.6"
}
java { toolchain { languageVersion.set(JavaLanguageVersion.of(21)) } }
dependencies {
implementation("org.springframework.boot:spring-boot-starter-batch")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-validation")
compileOnly("org.projectlombok:lombok")
annotationProcessor("org.projectlombok:lombok")
runtimeOnly("com.h2database:h2") // 개발용
runtimeOnly("org.postgresql:postgresql") // 운영용
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.batch:spring-batch-test")
}여기서는 Job & Step 구성을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
@Configuration
@EnableBatchProcessing
@RequiredArgsConstructor
public class BatchConfig {
private final JobRepository jobRepository;
private final PlatformTransactionManager transactionManager;
private final UserItemReader userItemReader;
private final UserItemProcessor userItemProcessor;
private final UserItemWriter userItemWriter;
@Bean
public Job userMigrationJob() {
return new JobBuilder("userMigrationJob", jobRepository)
.start(userMigrationStep())
.build();
}
@Bean
public Step userMigrationStep() {
return new StepBuilder("userMigrationStep", jobRepository)
.<User, MigratedUser>chunk(100, transactionManager)
.reader(userItemReader)
.processor(userItemProcessor)
.writer(userItemWriter)
.faultTolerant()
.skipLimit(10)
.skip(Exception.class)
.build();
}
}여기서는 Chunk 처리 (Reader-Processor-Writer)을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
@Component
public class UserItemProcessor implements ItemProcessor<User, MigratedUser> {
@Override
public MigratedUser process(User user) {
// null 반환 시 해당 아이템은 Writer로 전달되지 않음 (필터링)
if (!user.isActive()) return null;
return MigratedUser.builder()
.originalId(user.getId())
.email(user.getEmail().toLowerCase())
.createdAt(LocalDateTime.now())
.build();
}
}여기서는 ItemReader 종류을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
// ── JpaCursorItemReader (대용량 추천) ────────────
@Component
@StepScope
public class UserItemReader extends JpaCursorItemReader<User> {
public UserItemReader(EntityManagerFactory emf) {
setEntityManagerFactory(emf);
setQueryString("SELECT u FROM User u WHERE u.active = true ORDER BY u.id");
setSaveState(true); // 재시작 시 중단 지점부터 재개
}
}
// ── FlatFileItemReader (CSV 읽기) ─────────────────
@Bean
@StepScope
public FlatFileItemReader<UserCsvRow> csvReader(
@Value("#{jobParameters['file']}") String file) {
return new FlatFileItemReaderBuilder<UserCsvRow>()
.name("csvReader")
.resource(new FileSystemResource(file))
.delimited().delimiter(",")
.names("id", "email", "name")
.targetType(UserCsvRow.class)
.linesToSkip(1) // 헤더 스킵
.build();
}여기서는 ItemWriter 종류을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
// ── JpaItemWriter ─────────────────────────────────
@Component
@RequiredArgsConstructor
public class UserItemWriter implements ItemWriter<MigratedUser> {
private final MigratedUserRepository repository;
@Override
public void write(Chunk<? extends MigratedUser> chunk) {
repository.saveAll(chunk.getItems());
}
}
// ── JdbcBatchItemWriter (벌크 성능 최적화) ─────────
@Bean
public JdbcBatchItemWriter<MigratedUser> jdbcWriter(DataSource dataSource) {
return new JdbcBatchItemWriterBuilder<MigratedUser>()
.dataSource(dataSource)
.sql("INSERT INTO migrated_users (original_id, email, created_at) " +
"VALUES (:originalId, :email, :createdAt)")
.beanMapped()
.build();
}여기서는 병렬 처리 & Partitioning을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
@Bean
public Step partitionedStep() {
return new StepBuilder("partitionedStep", jobRepository)
.partitioner("workerStep", new RangePartitioner(userRepository))
.step(workerStep())
.taskExecutor(new SimpleAsyncTaskExecutor())
.gridSize(4) // 4개 파티션 병렬 처리
.build();
}
// 파티셔너 — ID 범위 기준으로 분할
public class RangePartitioner implements Partitioner {
private final UserRepository repository;
@Override
public Map<String, ExecutionContext> partition(int gridSize) {
long min = repository.findMinId();
long max = repository.findMaxId();
long range = (max - min) / gridSize + 1;
Map<String, ExecutionContext> result = new HashMap<>();
for (int i = 0; i < gridSize; i++) {
ExecutionContext ctx = new ExecutionContext();
ctx.putLong("minId", min + i * range);
ctx.putLong("maxId", min + (i + 1) * range - 1);
result.put("partition" + i, ctx);
}
return result;
}
}여기서는 스케줄링 & 재시작 전략을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
@Component
@RequiredArgsConstructor
public class BatchScheduler {
private final JobLauncher jobLauncher;
private final Job userMigrationJob;
@Scheduled(cron = "0 0 2 * * *") // 매일 새벽 2시
public void runBatch() throws Exception {
JobParameters params = new JobParametersBuilder()
.addLocalDateTime("runAt", LocalDateTime.now()) // 매번 새 파라미터로 실행
.toJobParameters();
jobLauncher.run(userMigrationJob, params);
}
}
// application.yml
// spring.batch.job.enabled: false → 서버 시작 시 자동 실행 방지여기서는 배치 테스트을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
@SpringBatchTest
@SpringBootTest
class UserMigrationBatchTest {
@Autowired JobLauncherTestUtils jobLauncherTestUtils;
@Autowired JobRepositoryTestUtils jobRepositoryTestUtils;
@Autowired MigratedUserRepository migratedUserRepository;
@BeforeEach
void setUp() {
jobRepositoryTestUtils.removeJobExecutions();
}
@Test
void 배치_전체_실행_성공() throws Exception {
JobExecution execution = jobLauncherTestUtils.launchJob();
assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED);
}
@Test
void 특정_Step만_실행() throws Exception {
JobExecution execution = jobLauncherTestUtils.launchStep("userMigrationStep");
StepExecution step = execution.getStepExecutions().iterator().next();
assertThat(step.getStatus()).isEqualTo(BatchStatus.COMPLETED);
assertThat(step.getWriteCount()).isGreaterThan(0);
}
}