# Spring Batch 5 면접: 파티셔닝, 청크, 장애 허용 > Spring Batch 5 면접을 정복하세요. 파티셔닝, 청크 처리, 장애 허용에 관한 15가지 핵심 질문과 Java 21 예제를 제공합니다. - Published: 2026-03-22 - Updated: 2026-05-03 - Author: SharpSkill - Tags: spring batch, spring boot, java, batch processing, interview questions - Reading time: 16 min --- Spring Batch 5는 Spring 생태계의 대규모 데이터 처리 핵심입니다. 기술 면접에서는 견고하고 확장 가능하며 장애에 강한 잡을 설계하는 능력을 평가합니다. 파티셔닝, 청크 지향 처리, 장애 허용 메커니즘에 대한 숙련도가 시니어 개발자를 두드러지게 합니다. > **면접 핵심 포인트** > > 채용 담당자는 깊은 이해를 시험합니다. 왜 remote chunking 대신 partitioning을 선택해야 합니까? 청크 크기를 어떻게 산정해야 합니까? 이러한 아키텍처 결정은 실제 프로덕션 경험을 드러냅니다. ## Spring Batch 5의 핵심 아키텍처 ### 질문 1: Spring Batch의 주요 구성 요소는 무엇입니까 Spring Batch 아키텍처는 세 계층으로 구성됩니다. 애플리케이션(잡과 비즈니스 코드), Batch Core(잡을 실행하고 제어하는 런타임 클래스), 인프라(reader, writer, RetryTemplate 같은 공용 서비스)입니다. ```java // BatchJobConfig.java // Java 21 기반 Spring Batch 5 잡 설정 @Configuration public class BatchJobConfig { // JobRepository는 실행 메타데이터를 저장합니다 // 잡 재시작과 모니터링을 가능하게 합니다 private final JobRepository jobRepository; private final PlatformTransactionManager transactionManager; public BatchJobConfig(JobRepository jobRepository, PlatformTransactionManager transactionManager) { this.jobRepository = jobRepository; this.transactionManager = transactionManager; } // Job은 전체 배치 프로세스를 캡슐화합니다 // 순차적으로 실행되는 하나 이상의 Step으로 구성됩니다 @Bean public Job importUserJob(Step processUsersStep, Step cleanupStep) { return new JobBuilder("importUserJob", jobRepository) .start(processUsersStep) // 메인 처리 Step .next(cleanupStep) // 정리 Step .build(); } // Step은 독립된 작업 단위를 나타냅니다 // 두 가지 모델: Tasklet(단일 작업) 또는 Chunk(반복 처리) @Bean public Step processUsersStep(ItemReader reader, ItemProcessor processor, ItemWriter writer) { return new StepBuilder("processUsersStep", jobRepository) .chunk(100, transactionManager) // 100건마다 커밋 .reader(reader) // 원본 데이터를 읽음 .processor(processor) // 각 아이템을 변환 .writer(writer) // 100건 단위로 기록 .build(); } } ``` JobRepository는 실행 상태를 데이터베이스에 영속화합니다. 이러한 영속성 덕분에 실패한 잡을 정확히 멈춘 지점부터 재시작할 수 있고, 이미 커밋된 데이터를 다시 처리할 필요가 없습니다. ### 질문 2: Tasklet과 청크 지향 처리의 차이는 무엇입니까 **Tasklet**은 반복하지 않는 단일 동작을 수행합니다. 파일 삭제, 저장 프로시저 호출, 알림 메일 발송 등이 그 예입니다. **Chunk**는 데이터를 관리 가능한 배치로 나누어 대용량을 처리합니다. ```java // CleanupTasklet.java // Tasklet: 반복 없는 단일 동작 @Component public class CleanupTasklet implements Tasklet { private final Path tempDirectory = Path.of("/tmp/batch-work"); @Override public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception { // 처리 과정에서 생긴 임시 파일을 모두 삭제 try (var files = Files.walk(tempDirectory)) { files.filter(Files::isRegularFile) .forEach(this::deleteQuietly); } // FINISHED는 tasklet이 작업을 완료했음을 의미 // CONTINUABLE은 실행을 다시 시작(폴링에 유용) return RepeatStatus.FINISHED; } private void deleteQuietly(Path file) { try { Files.delete(file); } catch (IOException e) { // 로그만 남기고 계속 - 파일 하나로 잡을 실패시키지 않음 } } } ``` ```java // ChunkProcessingConfig.java // 청크 처리: 대용량 처리 @Configuration public class ChunkProcessingConfig { @Bean public Step processOrdersStep(JobRepository jobRepository, PlatformTransactionManager transactionManager, ItemReader reader, ItemProcessor processor, ItemWriter writer) { return new StepBuilder("processOrdersStep", jobRepository) // 500개 청크: 500건 읽기, 처리, 기록 후 커밋 .chunk(500, transactionManager) .reader(reader) .processor(processor) .writer(writer) // 진행 상황 모니터링용 리스너 .listener(new ChunkProgressListener()) .build(); } } ``` 청크 지향 처리는 중요한 이점을 제공합니다. 메모리 사용 최적화(현재 청크만 메모리에 보관), 세분화된 트랜잭션(청크 단위 커밋), 마지막 커밋된 청크에서의 장애 복구입니다. ## 청크 지향 처리 심화 ### 질문 3: 청크 라이프사이클은 어떻게 동작합니까 각 청크는 정확한 사이클을 따릅니다. 설정한 크기에 도달할 때까지 아이템을 한 건씩 읽고, 각 아이템을 개별적으로 처리한 뒤, 그룹을 한 번에 기록합니다. 트랜잭션이 청크 전체를 감쌉니다. ```java // OrderItemReader.java // ItemReader: 한 번에 한 아이템씩 읽음 @StepScope @Component public class OrderItemReader implements ItemReader { // @StepScope: Step 실행마다 새 인스턴스 // 동적인 잡 파라미터 주입을 가능하게 함 @Value("#{jobParameters['startDate']}") private LocalDate startDate; private Iterator orderIterator; @BeforeStep public void initializeReader(StepExecution stepExecution) { // Step 시작 시 데이터 로딩 List orders = fetchOrdersFromDate(startDate); this.orderIterator = orders.iterator(); } @Override public OrderRecord read() { // 데이터 끝을 알리기 위해 null 반환 // Spring Batch는 null을 받을 때까지 read()를 호출 if (orderIterator.hasNext()) { return orderIterator.next(); } return null; // 데이터셋의 끝 } private List fetchOrdersFromDate(LocalDate date) { // 데이터 소스에서 조회 return List.of(); // 실제 구현 } } ``` ```java // OrderItemProcessor.java // ItemProcessor: 각 아이템을 개별적으로 변환 @Component public class OrderItemProcessor implements ItemProcessor { private final PricingService pricingService; private final ValidationService validationService; public OrderItemProcessor(PricingService pricingService, ValidationService validationService) { this.pricingService = pricingService; this.validationService = validationService; } @Override public ProcessedOrder process(OrderRecord item) { // null 반환은 아이템을 필터링(기록되지 않음) if (!validationService.isValid(item)) { return null; // 아이템 필터링 } // 비즈니스 변환 BigDecimal finalPrice = pricingService.calculatePrice(item); return new ProcessedOrder( item.orderId(), item.customerId(), finalPrice, LocalDateTime.now() ); } } ``` ```java // OrderItemWriter.java // ItemWriter: 청크 전체를 한 번에 기록 @Component public class OrderItemWriter implements ItemWriter { private final JdbcTemplate jdbcTemplate; public OrderItemWriter(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } @Override public void write(Chunk chunk) { // 청크는 처리된 모든 아이템을 포함 // 성능 최적화를 위한 배치 기록 List items = chunk.getItems(); jdbcTemplate.batchUpdate( "INSERT INTO processed_orders (order_id, customer_id, final_price, processed_at) VALUES (?, ?, ?, ?)", items, items.size(), (ps, order) -> { ps.setLong(1, order.orderId()); ps.setLong(2, order.customerId()); ps.setBigDecimal(3, order.finalPrice()); ps.setTimestamp(4, Timestamp.valueOf(order.processedAt())); } ); } } ``` 청크 처리 중 예외가 발생하면 트랜잭션은 롤백됩니다. 이후 잡은 JobRepository에 저장된 메타데이터를 사용해 해당 청크부터 재개할 수 있습니다. ### 질문 4: 최적의 청크 크기는 어떻게 선택합니까 청크 크기는 성능과 메모리 사용에 직접 영향을 줍니다. 너무 작으면 커밋이 많아져 오버헤드가 커지고, 너무 크면 메모리를 과도하게 사용하며 장애 시 롤백 시간이 길어집니다. ```java // ChunkSizingConfig.java // 청크 크기의 동적 설정 @Configuration public class ChunkSizingConfig { // 대부분 상황에서 합리적인 기본값 private static final int DEFAULT_CHUNK_SIZE = 100; // 가벼운 아이템(필드가 적은 경우) private static final int LIGHT_ITEMS_CHUNK_SIZE = 500; // 무거운 아이템(blob, 문서) private static final int HEAVY_ITEMS_CHUNK_SIZE = 25; @Bean public Step processLightDataStep(JobRepository jobRepository, PlatformTransactionManager txManager, ItemReader reader, ItemWriter writer) { return new StepBuilder("processLightDataStep", jobRepository) // 가벼운 아이템: 커밋 횟수를 줄이기 위해 더 큰 청크 .chunk(LIGHT_ITEMS_CHUNK_SIZE, txManager) .reader(reader) .writer(writer) .build(); } @Bean public Step processDocumentsStep(JobRepository jobRepository, PlatformTransactionManager txManager, ItemReader reader, ItemProcessor processor, ItemWriter writer) { return new StepBuilder("processDocumentsStep", jobRepository) // 무거운 문서: 메모리 사용을 제한하기 위해 작은 청크 .chunk(HEAVY_ITEMS_CHUNK_SIZE, txManager) .reader(reader) .processor(processor) .writer(writer) .build(); } } ``` > **실용적인 기준** > > 청크당 100건으로 시작한 뒤 지표(커밋 시간, 메모리 사용량, 롤백 시간)에 따라 조정합니다. 리스너로 모니터링해 최적점을 찾으십시오. ## 병렬 처리를 위한 파티셔닝 ### 질문 5: 파티셔닝이란 무엇이며 언제 사용합니까 파티셔닝은 데이터셋을 독립적인 파티션으로 나누어 병렬로 처리합니다. 각 파티션은 자체 스레드(로컬) 또는 원격 worker에서 실행됩니다. 이 방식은 재시작 능력을 잃지 않으면서 처리량을 크게 늘립니다. ```java // PartitionedJobConfig.java // 파티셔닝된 잡 설정 @Configuration public class PartitionedJobConfig { private final JobRepository jobRepository; private final PlatformTransactionManager transactionManager; public PartitionedJobConfig(JobRepository jobRepository, PlatformTransactionManager transactionManager) { this.jobRepository = jobRepository; this.transactionManager = transactionManager; } @Bean public Job partitionedImportJob(Step partitionedStep) { return new JobBuilder("partitionedImportJob", jobRepository) .start(partitionedStep) .build(); } // 매니저 Step: 파티션을 조율 @Bean public Step partitionedStep(Partitioner partitioner, Step workerStep, TaskExecutor taskExecutor) { return new StepBuilder("partitionedStep", jobRepository) // Partitioner를 통해 작업 분할 .partitioner("workerStep", partitioner) // 각 파티션마다 실행할 Step .step(workerStep) // 8개 병렬 스레드 .taskExecutor(taskExecutor) // 생성할 파티션 수 .gridSize(8) .build(); } // 병렬 실행을 위한 TaskExecutor @Bean public TaskExecutor batchTaskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(8); executor.setMaxPoolSize(16); executor.setQueueCapacity(50); executor.setThreadNamePrefix("batch-partition-"); executor.initialize(); return executor; } } ``` ```java // RangePartitioner.java // ID 범위 기반 Partitioner @Component public class RangePartitioner implements Partitioner { private final JdbcTemplate jdbcTemplate; public RangePartitioner(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } @Override public Map partition(int gridSize) { // 데이터셋의 경계를 조회 Long minId = jdbcTemplate.queryForObject( "SELECT MIN(id) FROM orders WHERE status = 'PENDING'", Long.class); Long maxId = jdbcTemplate.queryForObject( "SELECT MAX(id) FROM orders WHERE status = 'PENDING'", Long.class); if (minId == null || maxId == null) { return Map.of(); // 처리할 데이터 없음 } // 각 파티션의 크기를 계산 long range = (maxId - minId) / gridSize + 1; Map partitions = new HashMap<>(); for (int i = 0; i < gridSize; i++) { ExecutionContext context = new ExecutionContext(); long start = minId + (i * range); long end = Math.min(start + range - 1, maxId); // 각 파티션이 자신의 경계를 받음 context.putLong("minId", start); context.putLong("maxId", end); context.putInt("partitionNumber", i); partitions.put("partition" + i, context); } return partitions; } } ``` 파티셔닝은 아이템이 서로 독립적인 큰 데이터셋에 적합합니다. 느린 파티션이 전체 잡을 지연시키지 않도록 균형이 필요합니다. ### 질문 6: 로컬 파티셔닝과 원격 파티셔닝의 차이는 무엇입니까 **로컬 파티셔닝**은 동일 JVM의 스레드 풀에서 모든 파티션을 실행합니다. **원격 파티셔닝**은 메시징 미들웨어를 통해 여러 JVM(worker)으로 파티션을 분산합니다. ```java // RemotePartitioningConfig.java // 메시징을 활용한 원격 파티셔닝 설정 @Configuration public class RemotePartitioningConfig { @Bean public Step managerStep(JobRepository jobRepository, Partitioner partitioner, MessageChannelPartitionHandler partitionHandler) { return new StepBuilder("managerStep", jobRepository) .partitioner("workerStep", partitioner) // 원격 worker와 통신하는 핸들러 .partitionHandler(partitionHandler) .build(); } // PartitionHandler가 ExecutionContext를 worker로 전송 @Bean public MessageChannelPartitionHandler partitionHandler( MessagingTemplate messagingTemplate, JobExplorer jobExplorer) { MessageChannelPartitionHandler handler = new MessageChannelPartitionHandler(); handler.setStepName("workerStep"); handler.setGridSize(4); handler.setMessagingOperations(messagingTemplate); handler.setJobExplorer(jobExplorer); // worker 완료 대기 타임아웃 handler.setPollInterval(5000L); return handler; } } ``` ```java // WorkerConfiguration.java // worker 측 설정 @Configuration public class WorkerConfiguration { private final JobRepository jobRepository; private final PlatformTransactionManager transactionManager; public WorkerConfiguration(JobRepository jobRepository, PlatformTransactionManager transactionManager) { this.jobRepository = jobRepository; this.transactionManager = transactionManager; } // worker는 파티션을 받아 Step을 실행 @Bean public Step workerStep(ItemReader reader, ItemProcessor processor, ItemWriter writer) { return new StepBuilder("workerStep", jobRepository) .chunk(100, transactionManager) // 파티션 파라미터를 받기 위해 @StepScope 적용 .reader(reader) .processor(processor) .writer(writer) .build(); } // 파티션 경계를 사용하는 Reader @Bean @StepScope public JdbcCursorItemReader partitionedReader( DataSource dataSource, @Value("#{stepExecutionContext['minId']}") Long minId, @Value("#{stepExecutionContext['maxId']}") Long maxId) { return new JdbcCursorItemReaderBuilder() .name("partitionedOrderReader") .dataSource(dataSource) .sql("SELECT * FROM orders WHERE id BETWEEN ? AND ? AND status = 'PENDING'") .preparedStatementSetter(ps -> { ps.setLong(1, minId); ps.setLong(2, maxId); }) .rowMapper(new OrderRecordRowMapper()) .build(); } } ``` ## 장애 허용과 복구 ### 질문 7: Spring Batch가 제공하는 장애 허용 메커니즘은 무엇입니까 Spring Batch는 상호 보완적인 세 가지 메커니즘을 제공합니다. **skip**(실패 아이템 무시), **retry**(자동 재시도), **restart**(실패한 잡 재개)입니다. 이 메커니즘은 Step 수준에서 설정합니다. ```java // FaultTolerantStepConfig.java // 완전한 장애 허용 설정 @Configuration public class FaultTolerantStepConfig { @Bean public Step faultTolerantStep(JobRepository jobRepository, PlatformTransactionManager transactionManager, ItemReader reader, ItemProcessor processor, ItemWriter writer, SkipPolicy customSkipPolicy) { return new StepBuilder("faultTolerantStep", jobRepository) .chunk(100, transactionManager) .reader(reader) .processor(processor) .writer(writer) // 장애 허용 모드 활성화 .faultTolerant() // SKIP: 검증 오류를 최대 10건까지 무시 .skipLimit(10) .skip(ValidationException.class) .skip(DataIntegrityViolationException.class) // 일부 오류는 절대 스킵하지 않음 .noSkip(FatalBatchException.class) // RETRY: 일시적 오류는 재시도 .retryLimit(3) .retry(TransientDataAccessException.class) .retry(DeadlockLoserDataAccessException.class) // 재시도 사이의 지수적 백오프 .backOffPolicy(exponentialBackOffPolicy()) // 스킵을 로깅하는 리스너 .listener(skipListener()) .build(); } @Bean public BackOffPolicy exponentialBackOffPolicy() { ExponentialBackOffPolicy policy = new ExponentialBackOffPolicy(); policy.setInitialInterval(1000); // 1초 policy.setMultiplier(2.0); // 재시도마다 두 배 policy.setMaxInterval(10000); // 최대 10초 return policy; } @Bean public SkipListener skipListener() { return new SkipListener<>() { @Override public void onSkipInRead(Throwable t) { // 읽을 수 없는 아이템 로깅 } @Override public void onSkipInProcess(DataRecord item, Throwable t) { // 처리에 실패한 아이템 로깅 } @Override public void onSkipInWrite(ProcessedRecord item, Throwable t) { // 기록에 실패한 아이템 로깅 } }; } } ``` 재시도는 일시적 오류(네트워크 타임아웃, DB 데드락)에 적합합니다. 스킵은 전체 처리를 막지 않아야 하는 개별 데이터 오류에 알맞습니다. ### 질문 8: 커스텀 SkipPolicy를 어떻게 구현합니까 커스텀 SkipPolicy는 세밀한 의사 결정을 가능하게 합니다. 예외 타입, 오류 수, 특정 비즈니스 기준에 따라 스킵할 수 있습니다. ```java // AdaptiveSkipPolicy.java // 고급 비즈니스 로직을 가진 SkipPolicy @Component public class AdaptiveSkipPolicy implements SkipPolicy { private static final int MAX_SKIP_COUNT = 100; private static final double MAX_SKIP_PERCENTAGE = 0.05; // 최대 5% private final AtomicInteger totalProcessed = new AtomicInteger(0); private final AtomicInteger skipCount = new AtomicInteger(0); @Override public boolean shouldSkip(Throwable exception, long skipCountSoFar) { // 치명적 오류는 절대 스킵하지 않음 if (exception instanceof FatalBatchException || exception instanceof OutOfMemoryError) { return false; } // 스킵 수의 절대 상한 if (skipCountSoFar >= MAX_SKIP_COUNT) { return false; // 잡 중단 } // 비율 상한 int total = totalProcessed.get(); if (total > 1000) { // 워밍업 후에만 적용 double skipPercentage = (double) skipCountSoFar / total; if (skipPercentage > MAX_SKIP_PERCENTAGE) { return false; // 비율적으로 오류가 너무 많음 } } // 검증 및 데이터 오류 스킵 return exception instanceof ValidationException || exception instanceof DataFormatException || exception instanceof IllegalArgumentException; } // 진행 추적을 위해 리스너에서 호출 public void incrementProcessed() { totalProcessed.incrementAndGet(); } } ``` ### 질문 9: 실패한 잡의 재시작은 어떻게 동작합니까 JobRepository는 각 실행 상태를 저장합니다. 재시작 시 Spring Batch는 마지막으로 커밋된 청크를 식별하고 그 지점부터 재개합니다. 성공적으로 처리된 아이템은 다시 처리되지 않습니다. ```java // JobRestartService.java // 잡 재시작 관리 서비스 @Service public class JobRestartService { private final JobLauncher jobLauncher; private final JobExplorer jobExplorer; private final JobRepository jobRepository; private final Job importJob; public JobRestartService(JobLauncher jobLauncher, JobExplorer jobExplorer, JobRepository jobRepository, @Qualifier("importJob") Job importJob) { this.jobLauncher = jobLauncher; this.jobExplorer = jobExplorer; this.jobRepository = jobRepository; this.importJob = importJob; } public JobExecution restartFailedJob(Long jobExecutionId) throws Exception { // 실패한 실행 조회 JobExecution failedExecution = jobExplorer.getJobExecution(jobExecutionId); if (failedExecution == null) { throw new IllegalArgumentException("Job execution not found: " + jobExecutionId); } // 잡 재시작 가능 여부 확인 if (!failedExecution.getStatus().equals(BatchStatus.FAILED)) { throw new IllegalStateException("Only FAILED jobs can be restarted"); } // 원래 실행과 동일한 파라미터 사용 JobParameters originalParams = failedExecution.getJobParameters(); // 잡 재실행 - 마지막 체크포인트에서 자동 재개 return jobLauncher.run(importJob, originalParams); } public List findRestartableJobs() { // 아직 재시작되지 않은 모든 FAILED 실행 나열 return jobExplorer.findJobInstancesByJobName(importJob.getName(), 0, 100) .stream() .flatMap(instance -> jobExplorer.getJobExecutions(instance).stream()) .filter(exec -> exec.getStatus() == BatchStatus.FAILED) .filter(this::isRestartable) .toList(); } private boolean isRestartable(JobExecution execution) { // 더 최신의 성공 실행이 없는지 확인 JobInstance instance = execution.getJobInstance(); return jobExplorer.getJobExecutions(instance).stream() .noneMatch(exec -> exec.getStatus() == BatchStatus.COMPLETED); } } ``` > **면접의 함정** > > 잡은 JobParameters가 동일해야만 재시작할 수 있습니다. 파라미터를 변경하면 새로운 잡 인스턴스가 생성되어 진행 이력이 사라집니다. ## 확장과 최적화 ### 질문 10: 어떤 확장 전략이 있습니까 Spring Batch는 네 가지 전략을 제공합니다. **multi-threaded step**(여러 스레드가 병렬로 읽기), **parallel steps**(독립 Step의 병렬 실행), **remote chunking**(분산 처리), **partitioning**(데이터 분산)입니다. ```java // MultiThreadedStepConfig.java // 멀티 스레드 Step: 여러 스레드가 같은 데이터셋을 처리 @Configuration public class MultiThreadedStepConfig { @Bean public Step multiThreadedStep(JobRepository jobRepository, PlatformTransactionManager transactionManager, ItemReader reader, ItemProcessor processor, ItemWriter writer, TaskExecutor taskExecutor) { return new StepBuilder("multiThreadedStep", jobRepository) .chunk(100, transactionManager) // 주의: reader는 thread-safe해야 함 .reader(synchronizedReader(reader)) .processor(processor) .writer(writer) // 4개 스레드가 청크를 병렬 처리 .taskExecutor(taskExecutor) .throttleLimit(4) .build(); } // reader를 thread-safe로 만드는 래퍼 private ItemReader synchronizedReader(ItemReader reader) { SynchronizedItemStreamReader syncReader = new SynchronizedItemStreamReader<>(); syncReader.setDelegate((ItemStreamReader) reader); return syncReader; } } ``` ```java // ParallelStepsConfig.java // 독립 Step을 병렬 실행 @Configuration public class ParallelStepsConfig { @Bean public Job parallelJob(JobRepository jobRepository, Step loadCustomersStep, Step loadProductsStep, Step loadOrdersStep, Step processDataStep) { // 병렬 플로우: customers와 products를 동시에 적재 Flow loadCustomersFlow = new FlowBuilder("loadCustomersFlow") .start(loadCustomersStep) .build(); Flow loadProductsFlow = new FlowBuilder("loadProductsFlow") .start(loadProductsStep) .build(); Flow loadOrdersFlow = new FlowBuilder("loadOrdersFlow") .start(loadOrdersStep) .build(); // Split이 플로우를 병렬 실행 return new JobBuilder("parallelJob", jobRepository) .start(new FlowBuilder("parallelLoadFlow") .split(new SimpleAsyncTaskExecutor()) .add(loadCustomersFlow, loadProductsFlow, loadOrdersFlow) .build()) // 병렬 적재 후 순차 처리 .next(processDataStep) .build() .build(); } } ``` 멀티 스레딩은 reader를 동기화할 수 있는 경우에 적합합니다. 대용량에서는 각 파티션이 자체 reader를 가지므로 경합이 없는 파티셔닝이 더 좋습니다. ### 질문 11: 잡 성능을 어떻게 모니터링합니까 Spring Batch는 리스너와 JobRepository로 메트릭을 노출합니다. Micrometer 통합으로 Prometheus, Grafana 등 다양한 모니터링 시스템에 내보낼 수 있습니다. ```java // BatchMetricsConfig.java // Micrometer 기반 모니터링 설정 @Configuration public class BatchMetricsConfig { private final MeterRegistry meterRegistry; public BatchMetricsConfig(MeterRegistry meterRegistry) { this.meterRegistry = meterRegistry; } @Bean public JobExecutionListener metricsJobListener() { return new JobExecutionListener() { private Timer.Sample jobTimer; @Override public void beforeJob(JobExecution jobExecution) { // 잡 소요 시간 타이머 시작 jobTimer = Timer.start(meterRegistry); Counter.builder("batch.job.started") .tag("job", jobExecution.getJobInstance().getJobName()) .register(meterRegistry) .increment(); } @Override public void afterJob(JobExecution jobExecution) { // 총 소요 시간 기록 jobTimer.stop(Timer.builder("batch.job.duration") .tag("job", jobExecution.getJobInstance().getJobName()) .tag("status", jobExecution.getStatus().toString()) .register(meterRegistry)); // 상태별 잡 카운터 Counter.builder("batch.job.completed") .tag("job", jobExecution.getJobInstance().getJobName()) .tag("status", jobExecution.getStatus().toString()) .register(meterRegistry) .increment(); } }; } @Bean public StepExecutionListener metricsStepListener() { return new StepExecutionListener() { @Override public void afterStep(StepExecution stepExecution) { String jobName = stepExecution.getJobExecution().getJobInstance().getJobName(); String stepName = stepExecution.getStepName(); // 처리량 메트릭 Gauge.builder("batch.step.read.count", stepExecution, StepExecution::getReadCount) .tag("job", jobName) .tag("step", stepName) .register(meterRegistry); Gauge.builder("batch.step.write.count", stepExecution, StepExecution::getWriteCount) .tag("job", jobName) .tag("step", stepName) .register(meterRegistry); Gauge.builder("batch.step.skip.count", stepExecution, StepExecution::getSkipCount) .tag("job", jobName) .tag("step", stepName) .register(meterRegistry); return null; } }; } } ``` ### 질문 12: 파티셔닝의 흔한 함정은 무엇입니까 자주 발생하는 실수에는 불균형한 파티션(한 파티션에 데이터의 90%가 몰림), thread-safe하지 않은 reader, 파티션 간 잘못된 상태 관리가 있습니다. ```java // BalancedPartitioner.java // 부하를 실제로 균형 잡는 Partitioner @Component public class BalancedPartitioner implements Partitioner { private final JdbcTemplate jdbcTemplate; public BalancedPartitioner(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } @Override public Map partition(int gridSize) { // 처리할 전체 아이템 수 계산 Integer totalCount = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM orders WHERE status = 'PENDING'", Integer.class); if (totalCount == null || totalCount == 0) { return Map.of(); } // 파티션당 목표 크기 계산 int itemsPerPartition = (int) Math.ceil((double) totalCount / gridSize); Map partitions = new HashMap<>(); // 균형 잡힌 파티션을 위해 OFFSET/LIMIT 사용 // 범위 분할보다 비용은 크지만 균형을 보장 for (int i = 0; i < gridSize; i++) { ExecutionContext context = new ExecutionContext(); context.putInt("offset", i * itemsPerPartition); context.putInt("limit", itemsPerPartition); context.putInt("partitionNumber", i); partitions.put("partition" + i, context); } return partitions; } } // OffsetBasedReader.java // 오프셋 기반 파티셔닝과 호환되는 Reader @StepScope @Component public class OffsetBasedReader implements ItemReader, ItemStream { private final JdbcTemplate jdbcTemplate; private Iterator iterator; @Value("#{stepExecutionContext['offset']}") private int offset; @Value("#{stepExecutionContext['limit']}") private int limit; public OffsetBasedReader(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } @Override public void open(ExecutionContext executionContext) { // 이 파티션에 할당된 부분만 정확히 로딩 List records = jdbcTemplate.query( "SELECT * FROM orders WHERE status = 'PENDING' ORDER BY id LIMIT ? OFFSET ?", new OrderRecordRowMapper(), limit, offset ); this.iterator = records.iterator(); } @Override public OrderRecord read() { return iterator.hasNext() ? iterator.next() : null; } @Override public void update(ExecutionContext executionContext) { // 필요 시 재시작용 상태 저장 } @Override public void close() { // 정리 } } ``` ## 시니어를 위한 고급 질문 ### 질문 13: 잡 간 의존성을 어떻게 관리합니까 Spring Batch는 잡 간 의존성을 자체적으로 관리하지 않습니다. 외부 오케스트레이터(Airflow, Kubernetes CronJob)나 JobExplorer를 활용한 커스텀 구현이 해법입니다. ```java // JobDependencyService.java // 잡 간 의존성 관리 @Service public class JobDependencyService { private final JobExplorer jobExplorer; private final JobLauncher jobLauncher; private final Map jobs; public JobDependencyService(JobExplorer jobExplorer, JobLauncher jobLauncher, Map jobs) { this.jobExplorer = jobExplorer; this.jobLauncher = jobLauncher; this.jobs = jobs; } public JobExecution runWithDependencies(String jobName, JobParameters params, List dependsOn) throws Exception { // 모든 의존성이 성공했는지 확인 for (String dependency : dependsOn) { if (!hasSuccessfulExecution(dependency, params)) { throw new JobExecutionException( "Dependency not satisfied: " + dependency); } } Job job = jobs.get(jobName); if (job == null) { throw new IllegalArgumentException("Unknown job: " + jobName); } return jobLauncher.run(job, params); } private boolean hasSuccessfulExecution(String jobName, JobParameters params) { // 동일한 비즈니스 파라미터로 COMPLETED인 실행 검색 return jobExplorer.findJobInstancesByJobName(jobName, 0, 1) .stream() .flatMap(instance -> jobExplorer.getJobExecutions(instance).stream()) .filter(exec -> exec.getStatus() == BatchStatus.COMPLETED) .anyMatch(exec -> matchesBusinessParams(exec.getJobParameters(), params)); } private boolean matchesBusinessParams(JobParameters actual, JobParameters expected) { // 비즈니스 파라미터 비교(실행 타임스탬프 무시) String actualDate = actual.getString("businessDate"); String expectedDate = expected.getString("businessDate"); return Objects.equals(actualDate, expectedDate); } } ``` ### 질문 14: Spring Batch 잡을 어떻게 효과적으로 테스트합니까 Spring Batch 잡 테스트는 계층적 접근이 필요합니다. 컴포넌트 단위 유닛 테스트(reader, processor, writer), Step의 통합 테스트, 잡 전체의 종단간 테스트입니다. ```java // OrderProcessorTest.java // 프로세서 유닛 테스트 @ExtendWith(MockitoExtension.class) class OrderProcessorTest { @Mock private PricingService pricingService; @Mock private ValidationService validationService; @InjectMocks private OrderItemProcessor processor; @Test void shouldProcessValidOrder() { // Given OrderRecord input = new OrderRecord(1L, 100L, BigDecimal.TEN); when(validationService.isValid(input)).thenReturn(true); when(pricingService.calculatePrice(input)).thenReturn(new BigDecimal("12.50")); // When ProcessedOrder result = processor.process(input); // Then assertThat(result).isNotNull(); assertThat(result.finalPrice()).isEqualTo(new BigDecimal("12.50")); } @Test void shouldFilterInvalidOrder() { // Given OrderRecord input = new OrderRecord(1L, 100L, BigDecimal.TEN); when(validationService.isValid(input)).thenReturn(false); // When ProcessedOrder result = processor.process(input); // Then - null은 필터링됨을 의미 assertThat(result).isNull(); verify(pricingService, never()).calculatePrice(any()); } } ``` ```java // ImportJobIntegrationTest.java // 잡 전체에 대한 통합 테스트 @SpringBatchTest @SpringBootTest @ActiveProfiles("test") class ImportJobIntegrationTest { @Autowired private JobLauncherTestUtils jobLauncherTestUtils; @Autowired private JobRepositoryTestUtils jobRepositoryTestUtils; @Autowired private JdbcTemplate jdbcTemplate; @BeforeEach void setup() { // 테스트 사이의 메타데이터 정리 jobRepositoryTestUtils.removeJobExecutions(); // 테스트 데이터 초기화 jdbcTemplate.execute("DELETE FROM processed_orders"); jdbcTemplate.execute("DELETE FROM orders"); } @Test void shouldCompleteJobSuccessfully() throws Exception { // Given - 테스트 데이터 insertTestOrders(100); // When JobParameters params = new JobParametersBuilder() .addLocalDate("businessDate", LocalDate.now()) .addLong("run.id", System.currentTimeMillis()) .toJobParameters(); JobExecution execution = jobLauncherTestUtils.launchJob(params); // Then assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED); assertThat(countProcessedOrders()).isEqualTo(100); } @Test void shouldHandleEmptyDataset() throws Exception { // Given - 데이터 없음 // When JobExecution execution = jobLauncherTestUtils.launchJob(); // Then - 데이터가 없어도 잡은 성공 assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED); } @Test void shouldRestartFromFailurePoint() throws Exception { // Given - 처리 도중의 오류 시뮬레이션 insertTestOrders(100); insertPoisonOrder(50); // 오류 발생 // When - 첫 실행 실패 JobExecution firstExecution = jobLauncherTestUtils.launchJob(); assertThat(firstExecution.getStatus()).isEqualTo(BatchStatus.FAILED); // 데이터 수정 removePoisonOrder(50); // When - 재시작 JobExecution restartExecution = jobLauncherTestUtils.launchJob( firstExecution.getJobParameters()); // Then - 실패 지점부터 재개 assertThat(restartExecution.getStatus()).isEqualTo(BatchStatus.COMPLETED); } private void insertTestOrders(int count) { for (int i = 1; i <= count; i++) { jdbcTemplate.update( "INSERT INTO orders (id, customer_id, amount, status) VALUES (?, ?, ?, 'PENDING')", i, i * 10, BigDecimal.valueOf(i * 10)); } } private int countProcessedOrders() { return jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM processed_orders", Integer.class); } } ``` ### 질문 15: 데이터베이스 쓰기 성능을 어떻게 최적화합니까 쓰기는 종종 병목이 됩니다. 최적화 방법으로는 JDBC 배치 인서트, 적재 동안의 제약 조건 비활성화, 스테이징 테이블 활용이 있습니다. ```java // OptimizedJdbcWriter.java // 대용량을 위한 최적화된 Writer @Component public class OptimizedJdbcWriter implements ItemWriter { private final JdbcTemplate jdbcTemplate; private final DataSource dataSource; public OptimizedJdbcWriter(JdbcTemplate jdbcTemplate, DataSource dataSource) { this.jdbcTemplate = jdbcTemplate; this.dataSource = dataSource; } @Override public void write(Chunk chunk) throws Exception { List items = chunk.getItems(); if (items.isEmpty()) { return; } // 배치와 함께 PreparedStatement 사용 try (Connection connection = dataSource.getConnection(); PreparedStatement ps = connection.prepareStatement( "INSERT INTO processed_orders (order_id, customer_id, final_price, processed_at) " + "VALUES (?, ?, ?, ?)")) { for (ProcessedOrder order : items) { ps.setLong(1, order.orderId()); ps.setLong(2, order.customerId()); ps.setBigDecimal(3, order.finalPrice()); ps.setTimestamp(4, Timestamp.valueOf(order.processedAt())); ps.addBatch(); } // 모든 INSERT를 한 번의 네트워크 호출로 실행 ps.executeBatch(); } } } // StagingTableWriter.java // 초대용량을 위한 스테이징 테이블 패턴 @Component public class StagingTableWriter implements ItemWriter, StepExecutionListener { private final JdbcTemplate jdbcTemplate; private String stagingTable; public StagingTableWriter(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } @Override public void beforeStep(StepExecution stepExecution) { // 이 Step을 위한 임시 테이블 생성 stagingTable = "staging_orders_" + stepExecution.getId(); jdbcTemplate.execute( "CREATE TEMP TABLE " + stagingTable + " (LIKE processed_orders INCLUDING ALL)"); } @Override public void write(Chunk chunk) { // 스테이징 테이블에 기록(FK 제약 없음) String sql = "INSERT INTO " + stagingTable + " (order_id, customer_id, final_price, processed_at) VALUES (?, ?, ?, ?)"; jdbcTemplate.batchUpdate(sql, chunk.getItems(), chunk.size(), (ps, order) -> { ps.setLong(1, order.orderId()); ps.setLong(2, order.customerId()); ps.setBigDecimal(3, order.finalPrice()); ps.setTimestamp(4, Timestamp.valueOf(order.processedAt())); }); } @Override public ExitStatus afterStep(StepExecution stepExecution) { if (stepExecution.getStatus() == BatchStatus.COMPLETED) { // 최종 테이블로 일괄 복사 jdbcTemplate.execute( "INSERT INTO processed_orders SELECT * FROM " + stagingTable); } // 스테이징 테이블 정리 jdbcTemplate.execute("DROP TABLE IF EXISTS " + stagingTable); return stepExecution.getExitStatus(); } } ``` ## 결론 기술 면접에서 Spring Batch 5를 정복하려면 내부 메커니즘에 대한 깊은 이해가 필요합니다. ✅ **아키텍처**: Job → Step → Chunk(Reader, Processor, Writer) ✅ **청크 처리**: 크기 산정, 라이프사이클, 트랜잭션 ✅ **파티셔닝**: 로컬 vs 원격, 파티션 균형 ✅ **장애 허용**: skip, retry, restart에 적합한 정책 적용 ✅ **확장**: 멀티 스레딩, parallel steps, remote chunking ✅ **테스트**: 유닛, 통합, 종단간 ✅ **최적화**: 배치 쓰기, 스테이징 테이블, 모니터링 고급 질문은 데이터 양, 시간 제약, 오류 허용 수준, 사용 가능한 인프라 같은 맥락에 따라 아키텍처 결정을 정당화하는 능력을 평가합니다. --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/ko/blog/spring-boot/spring-batch-5-interview-partitioning-chunks-fault-tolerance