데이터 실전 가이드

데이터의 핵심 개념과 실무 활용

Python중급
6시간
3개 항목
학습 진행률0 / 3 (0%)

학습 항목

1. Java
Spring|Batch|대용량|데이터|처리|마스터
퀴즈튜토리얼
2. Python
Pandas|데이터|분석|실전|예제
퀴즈튜토리얼
3. Python
Pydantic|데이터|검증|완벽|가이드
퀴즈튜토리얼
1 / 3

이미지 로딩 중...

Spring Batch 대용량 데이터 처리 마스터 - 슬라이드 1/13

Spring Batch 대용량 데이터 처리 마스터

Spring Batch를 활용한 대용량 데이터 처리의 핵심 개념과 실무 활용법을 알려드립니다. 수백만 건의 데이터를 안정적으로 처리하는 방법부터 성능 최적화, 에러 핸들링까지 실전에 바로 적용할 수 있는 내용을 담았습니다.


목차

  1. Spring Batch 기본 구조 - Job과 Step의 이해
  2. ItemReader - 데이터 읽기 전략
  3. ItemProcessor - 비즈니스 로직 처리
  4. ItemWriter - 데이터 저장 전략
  5. Chunk 기반 처리 - 트랜잭션 관리의 핵심
  6. JobParameters와 메타데이터 관리
  7. 파티셔닝과 병렬 처리
  8. 에러 핸들링과 재시도 전략
  9. 성능 최적화 기법
  10. 실무 베스트 프랙티스

1. Spring Batch 기본 구조 - Job과 Step의 이해

시작하며

여러분이 매일 밤 12시에 100만 건의 주문 데이터를 집계하거나, 대량의 회원 데이터를 다른 시스템으로 이관해야 하는 상황을 겪어본 적 있나요? 일반적인 API 요청으로는 타임아웃이 발생하고, 메모리 부족으로 애플리케이션이 다운되는 문제가 생깁니다.

이런 문제는 실제 개발 현장에서 자주 발생합니다. 대용량 데이터를 한 번에 메모리에 올리면 OutOfMemory 에러가 발생하고, 중간에 실패하면 어디서부터 다시 시작해야 할지 알 수 없게 됩니다.

또한 처리 중 예외가 발생했을 때 전체를 롤백할지, 일부만 롤백할지 판단하기도 어렵습니다. 바로 이럴 때 필요한 것이 Spring Batch입니다.

Spring Batch는 대용량 데이터를 청크(Chunk) 단위로 나누어 처리하고, 실패 지점을 기록하여 재시작할 수 있으며, 트랜잭션을 안전하게 관리해줍니다.

개요

간단히 말해서, Spring Batch는 대용량 데이터를 안정적이고 효율적으로 처리하기 위한 경량 프레임워크입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 일반적인 웹 애플리케이션은 사용자 요청에 즉시 응답하는 구조이지만, 배치 작업은 대량의 데이터를 백그라운드에서 처리하는 것이 목적입니다.

예를 들어, 매월 말 정산 작업, 대량 이메일 발송, 데이터 마이그레이션 같은 경우에 매우 유용합니다. 전통적인 방법과의 비교를 하자면, 기존에는 while 문으로 데이터베이스를 조회하고 직접 트랜잭션을 관리했다면, 이제는 Spring Batch가 제공하는 Job, Step, Chunk 구조로 선언적으로 배치 프로세스를 정의할 수 있습니다.

Spring Batch의 핵심 특징은 첫째, Job과 Step으로 계층적으로 작업을 구성할 수 있다는 점, 둘째, 실패한 작업을 재시작할 수 있는 메타데이터 관리, 셋째, 청크 단위의 트랜잭션 처리입니다. 이러한 특징들이 대용량 데이터 처리의 안정성과 유지보수성을 크게 향상시켜줍니다.

코드 예제

@Configuration
@EnableBatchProcessing
public class BatchConfig {

    @Bean
    public Job userMigrationJob(JobRepository jobRepository, Step userMigrationStep) {
        // Job은 배치 작업의 최상위 단위입니다
        return new JobBuilder("userMigrationJob", jobRepository)
                .start(userMigrationStep)  // Step을 실행합니다
                .build();
    }

    @Bean
    public Step userMigrationStep(JobRepository jobRepository,
                                  PlatformTransactionManager transactionManager) {
        // Step은 실제 작업을 수행하는 단위입니다
        return new StepBuilder("userMigrationStep", jobRepository)
                .<User, User>chunk(1000, transactionManager)  // 1000건씩 처리
                .reader(userItemReader())
                .processor(userItemProcessor())
                .writer(userItemWriter())
                .build();
    }
}

설명

이것이 하는 일: Spring Batch의 기본 구조는 하나의 Job이 여러 Step을 순차적으로 실행하면서 대용량 데이터를 처리합니다. 첫 번째로, Job 정의 부분을 살펴보겠습니다.

userMigrationJob은 배치 작업의 최상위 개념으로, 전체 배치 프로세스의 시작과 끝을 관리합니다. JobBuilder를 통해 Job의 이름을 정의하고, start() 메서드로 어떤 Step부터 시작할지 지정합니다.

이렇게 하는 이유는 하나의 Job이 여러 Step을 가질 수 있고, 조건에 따라 다른 Step으로 분기할 수도 있기 때문입니다. 그 다음으로, Step 정의 부분이 실행되면서 실제 데이터 처리가 이루어집니다.

chunk(1000, transactionManager)는 매우 중요한 설정인데, 이는 1000건씩 데이터를 묶어서 처리하고 커밋한다는 의미입니다. 내부에서는 reader가 1000번 읽고, processor가 1000번 가공하고, writer가 1000건을 한 번에 쓰는 방식으로 동작합니다.

이 과정이 하나의 트랜잭션으로 묶여서 실패 시 1000건 단위로 롤백됩니다. 마지막으로, reader, processor, writer의 체인이 연결되어 최종적으로 데이터 처리 파이프라인을 만들어냅니다.

Reader는 데이터 소스에서 데이터를 읽고, Processor는 비즈니스 로직을 적용하며, Writer는 결과를 저장합니다. 이 세 가지 컴포넌트는 독립적으로 개발하고 테스트할 수 있어서 유지보수가 용이합니다.

여러분이 이 코드를 사용하면 대용량 데이터를 메모리 효율적으로 처리할 수 있고, 실패 시 자동으로 재시작 지점을 관리할 수 있으며, 트랜잭션 관리를 프레임워크에 위임하여 안전성을 확보할 수 있습니다. 또한 Job 실행 이력이 자동으로 데이터베이스에 저장되어 모니터링과 디버깅이 훨씬 쉬워집니다.

실전 팁

💡 Job 이름은 고유해야 하며, 같은 이름의 Job에 다른 파라미터를 전달하면 새로운 JobInstance가 생성됩니다. 실무에서는 날짜나 ID를 파라미터로 전달하여 같은 Job을 반복 실행합니다.

💡 chunk 크기는 너무 작으면 커밋이 자주 발생해 성능이 떨어지고, 너무 크면 메모리 부족이나 롤백 범위가 커지는 문제가 있습니다. 보통 500~2000 사이에서 데이터 특성에 맞게 튜닝하세요.

💡 Step은 재사용 가능하게 설계하세요. 여러 Job에서 같은 Step을 공유할 수 있으며, 이를 통해 코드 중복을 줄일 수 있습니다.

💡 운영 환경에서는 JobRepository가 사용하는 메타 테이블(BATCH_JOB_EXECUTION 등)의 인덱스를 잘 관리해야 합니다. 오래된 실행 이력이 쌓이면 성능이 저하될 수 있습니다.

💡 Step 간 데이터를 공유해야 할 때는 ExecutionContext를 사용하세요. JobExecutionContext는 Job 전체에서, StepExecutionContext는 Step 내에서 데이터를 공유할 수 있습니다.


2. ItemReader - 데이터 읽기 전략

시작하며

여러분이 1억 건의 사용자 데이터를 다른 시스템으로 이관해야 하는데, 한 번에 조회하면 메모리가 터지고, 하나씩 조회하면 너무 느린 상황을 상상해보세요. 어떻게 하면 효율적으로 데이터를 읽어올 수 있을까요?

이런 문제는 배치 처리에서 가장 먼저 맞닥뜨리는 도전입니다. 잘못된 읽기 전략은 데이터베이스에 과부하를 주거나, 처리 시간을 몇 배로 늘리거나, 심지어 메모리 오류로 배치를 실패시킬 수 있습니다.

또한 데이터를 읽는 중간에 새로운 데이터가 추가되거나 수정되면 일관성 문제도 발생할 수 있습니다. 바로 이럴 때 필요한 것이 Spring Batch의 ItemReader입니다.

ItemReader는 페이징, 커서, 파일 등 다양한 방식으로 대용량 데이터를 효율적으로 읽어오며, 읽기 상태를 자동으로 관리하여 재시작 시에도 중단된 지점부터 이어서 읽을 수 있습니다.

개요

간단히 말해서, ItemReader는 배치 처리를 위해 데이터 소스로부터 한 건씩 데이터를 읽어오는 컴포넌트입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 일반적인 DAO나 Repository는 전체 결과를 List로 반환하지만, ItemReader는 Iterator처럼 한 건씩 반환하면서 내부적으로는 청크 단위로 미리 읽어둡니다.

예를 들어, 대용량 CSV 파일 파싱, 데이터베이스 테이블 전체 스캔, REST API 페이징 조회 같은 경우에 매우 유용합니다. 전통적인 방법과의 비교를 하자면, 기존에는 List<User> users = userRepository.findAll()처럼 전체를 한 번에 조회했다면, 이제는 ItemReader가 내부적으로 페이징이나 커서를 관리하면서 메모리 효율적으로 데이터를 제공합니다.

ItemReader의 핵심 특징은 첫째, 다양한 데이터 소스 지원(DB, File, XML, JSON 등), 둘째, 자동 상태 관리로 재시작 가능, 셋째, 스레드 세이프 설계로 병렬 처리 지원입니다. 이러한 특징들이 안정적이고 확장 가능한 데이터 읽기를 가능하게 합니다.

코드 예제

@Bean
public JpaPagingItemReader<User> userItemReader(EntityManagerFactory entityManagerFactory) {
    // JPA 기반 페이징 리더 - 대용량 데이터를 안전하게 읽습니다
    return new JpaPagingItemReaderBuilder<User>()
            .name("userItemReader")
            .entityManagerFactory(entityManagerFactory)
            .pageSize(1000)  // 한 번에 1000건씩 조회
            .queryString("SELECT u FROM User u WHERE u.status = :status ORDER BY u.id")
            .parameterValues(Map.of("status", "ACTIVE"))
            .build();
}

// 또는 JDBC 커서 기반 리더
@Bean
public JdbcCursorItemReader<User> userCursorReader(DataSource dataSource) {
    // 커서 방식 - 메모리 사용량이 가장 적습니다
    return new JdbcCursorItemReaderBuilder<User>()
            .name("userCursorReader")
            .dataSource(dataSource)
            .sql("SELECT id, name, email FROM users WHERE status = ?")
            .preparedStatementSetter(ps -> ps.setString(1, "ACTIVE"))
            .rowMapper(new BeanPropertyRowMapper<>(User.class))
            .build();
}

설명

이것이 하는 일: ItemReader는 데이터 소스에서 데이터를 순차적으로 읽어와 Step에 제공하며, 읽기 상태를 자동으로 추적합니다. 첫 번째로, JpaPagingItemReader 방식을 살펴보겠습니다.

이 방식은 JPQL 쿼리를 사용하여 데이터베이스에서 페이지 단위로 데이터를 읽어옵니다. pageSize(1000)은 한 번의 쿼리로 1000건을 조회한다는 의미인데, 실제로는 내부적으로 LIMIT 1000 OFFSET 0, LIMIT 1000 OFFSET 1000 같은 방식으로 페이징 쿼리가 실행됩니다.

이렇게 하는 이유는 전체 데이터를 한 번에 메모리에 올리지 않으면서도, 한 건씩 읽는 것보다는 훨씬 효율적이기 때문입니다. 그 다음으로, JdbcCursorItemReader 방식이 실행되면서 다른 접근법을 보여줍니다.

커서 방식은 데이터베이스 커서를 열어두고 한 건씩 fetch하는 방식으로, 페이징보다 메모리 사용량이 적고 대용량 데이터에 더 적합합니다. 내부에서는 ResultSet을 열어두고 next()를 호출하면서 스트리밍 방식으로 데이터를 읽습니다.

다만 커서는 데이터베이스 연결을 계속 유지해야 하므로 커넥션 관리에 주의해야 합니다. 마지막으로, 두 방식 모두 name 속성을 설정하는데, 이는 ExecutionContext에 읽기 상태를 저장하기 위한 키로 사용됩니다.

배치가 중간에 실패하면 Spring Batch는 "userItemReader가 3000번째 아이템까지 읽었음"이라는 정보를 저장하고, 재시작 시 3001번째부터 읽기를 재개합니다. 여러분이 이 코드를 사용하면 수억 건의 데이터도 안정적으로 읽을 수 있고, 장애 발생 시 처음부터 다시 읽지 않아도 되며, 데이터베이스 부하를 최소화하면서 최적의 성능을 얻을 수 있습니다.

또한 같은 인터페이스로 DB뿐만 아니라 파일, API 등 다양한 소스를 읽을 수 있어 유연성이 뛰어납니다.

실전 팁

💡 페이징 방식은 구현이 간단하지만, 읽는 동안 데이터가 변경되면 중복이나 누락이 발생할 수 있습니다. ORDER BY에 고유한 컬럼(ID)을 포함시켜 일관성을 높이세요.

💡 커서 방식은 메모리 효율적이지만 타임아웃에 주의하세요. 처리 시간이 오래 걸리면 데이터베이스 연결이 끊길 수 있으므로 적절한 타임아웃 설정이 필요합니다.

💡 대용량 테이블을 읽을 때는 인덱스가 필수입니다. WHERE 절과 ORDER BY 절에 사용되는 컬럼에 인덱스가 없으면 풀 테이블 스캔이 발생하여 성능이 극도로 저하됩니다.

💡 멀티스레드로 병렬 처리할 때는 각 스레드가 다른 범위의 데이터를 읽도록 파티셔닝하세요. 같은 ItemReader를 공유하면 동시성 문제가 발생할 수 있습니다.

💡 파일을 읽을 때는 FlatFileItemReader를 사용하고, 인코딩과 구분자를 정확히 설정하세요. 잘못된 인코딩은 한글이 깨지는 원인이 됩니다.


3. ItemProcessor - 비즈니스 로직 처리

시작하며

여러분이 읽어온 데이터를 그대로 저장하는 것이 아니라, 유효성 검증을 하고, 형식을 변환하고, 외부 API를 호출하여 데이터를 보강해야 하는 상황이라면 어떻게 하시겠습니까? 모든 로직을 Writer에 넣으면 복잡해지고, Reader에 넣으면 책임이 섞입니다.

이런 문제는 배치 처리의 복잡도가 높아질수록 더 심각해집니다. 비즈니스 로직이 여러 컴포넌트에 흩어지면 테스트가 어렵고, 재사용이 불가능하며, 유지보수 비용이 급증합니다.

또한 특정 조건에서 데이터를 필터링해야 할 때 어디서 처리해야 할지 애매해집니다. 바로 이럴 때 필요한 것이 ItemProcessor입니다.

ItemProcessor는 읽기와 쓰기 사이에서 비즈니스 로직을 처리하는 순수한 컴포넌트로, 데이터 변환, 검증, 필터링, 보강 등을 담당하며, 테스트와 재사용이 용이한 구조를 제공합니다.

개요

간단히 말해서, ItemProcessor는 ItemReader가 읽은 데이터를 받아서 가공한 후 ItemWriter에 전달하는 중간 처리 컴포넌트입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 배치 처리는 단순히 데이터를 옮기는 것이 아니라 비즈니스 규칙을 적용하는 과정입니다.

예를 들어, 사용자 데이터를 마이그레이션할 때 이메일 형식을 검증하고, 전화번호를 표준 형식으로 변환하고, 중복 데이터를 필터링하는 같은 경우에 매우 유용합니다. 전통적인 방법과의 비교를 하자면, 기존에는 Reader나 Writer 안에 비즈니스 로직을 섞어서 작성했다면, 이제는 Processor를 독립적으로 분리하여 단일 책임 원칙을 지키고 테스트 가능한 코드를 작성할 수 있습니다.

ItemProcessor의 핵심 특징은 첫째, null을 반환하면 해당 아이템을 필터링(건너뛰기)할 수 있다는 점, 둘째, 여러 Processor를 체인으로 연결할 수 있다는 점, 셋째, 예외 처리를 통해 특정 아이템만 스킵하거나 재시도할 수 있다는 점입니다. 이러한 특징들이 유연하고 강력한 데이터 처리 파이프라인을 구성할 수 있게 합니다.

코드 예제

@Component
public class UserDataProcessor implements ItemProcessor<User, UserDto> {

    private final ExternalApiClient apiClient;

    @Override
    public UserDto process(User user) throws Exception {
        // 1. 유효성 검증 - 잘못된 데이터는 필터링
        if (!isValidEmail(user.getEmail())) {
            log.warn("Invalid email for user: {}", user.getId());
            return null;  // null 반환 시 해당 아이템은 Writer에 전달되지 않음
        }

        // 2. 데이터 변환 - 형식 표준화
        String normalizedPhone = normalizePhoneNumber(user.getPhone());

        // 3. 외부 API 호출 - 데이터 보강
        AddressInfo addressInfo = apiClient.getAddressInfo(user.getZipCode());

        // 4. DTO로 변환하여 반환
        return UserDto.builder()
                .id(user.getId())
                .email(user.getEmail().toLowerCase())
                .phone(normalizedPhone)
                .address(addressInfo.getFullAddress())
                .processedAt(LocalDateTime.now())
                .build();
    }
}

설명

이것이 하는 일: ItemProcessor는 Reader로부터 받은 원본 데이터를 비즈니스 규칙에 따라 변환, 검증, 보강하여 Writer에 전달합니다. 첫 번째로, 유효성 검증 부분을 살펴보겠습니다.

isValidEmail() 메서드로 이메일 형식을 체크하고, 유효하지 않으면 null을 반환합니다. Spring Batch에서 Processor가 null을 반환하면 해당 아이템은 Writer로 전달되지 않고 필터링됩니다.

이는 매우 강력한 기능으로, 별도의 필터 로직 없이도 조건에 맞지 않는 데이터를 제외할 수 있습니다. 로그를 남겨두면 나중에 얼마나 많은 데이터가 필터링되었는지 추적할 수 있습니다.

그 다음으로, 데이터 변환과 외부 API 호출이 실행되면서 데이터가 보강됩니다. normalizePhoneNumber()는 다양한 형식의 전화번호를 표준 형식으로 통일하고, apiClient.getAddressInfo()는 우편번호로 상세 주소를 조회합니다.

이 과정에서 중요한 것은 Processor가 상태를 가지지 않는 것입니다. 즉, 이전 아이템의 처리 결과가 다음 아이템에 영향을 주지 않아야 합니다.

이렇게 해야 멀티스레드 환경에서도 안전하게 동작합니다. 마지막으로, DTO 변환을 통해 최종 결과를 만들어냅니다.

원본 엔티티(User)와 변환된 DTO(UserDto)를 분리하면, 소스와 타겟의 구조가 달라도 유연하게 대응할 수 있습니다. processedAt 같은 메타 정보를 추가하여 언제 처리되었는지도 기록할 수 있습니다.

여러분이 이 코드를 사용하면 비즈니스 로직을 명확하게 분리하여 테스트하기 쉬워지고, 데이터 품질을 향상시킬 수 있으며, 외부 시스템과의 통합도 깔끔하게 처리할 수 있습니다. 또한 Processor를 교체하거나 체인으로 연결하여 다양한 처리 파이프라인을 구성할 수 있습니다.

실전 팁

💡 Processor는 가능한 한 상태를 가지지 않도록(stateless) 설계하세요. 멤버 변수에 처리 결과를 저장하면 멀티스레드 환경에서 동시성 문제가 발생합니다.

💡 외부 API 호출이 있다면 타임아웃과 재시도 정책을 반드시 설정하세요. 외부 시스템 장애로 전체 배치가 멈추는 것을 방지할 수 있습니다.

💡 여러 Processor를 연결할 때는 CompositeItemProcessor를 사용하세요. 검증 Processor, 변환 Processor, 보강 Processor를 각각 만들고 체인으로 연결하면 재사용성이 높아집니다.

💡 Processor에서 예외가 발생하면 기본적으로 전체 청크가 롤백됩니다. 특정 아이템만 스킵하고 싶다면 Step에 skipPolicy를 설정하거나, try-catch로 예외를 잡고 null을 반환하세요.

💡 성능 최적화가 필요하다면 Processor 내부에서 캐싱을 활용하세요. 예를 들어 같은 우편번호의 주소 정보는 캐시에서 가져오면 API 호출을 줄일 수 있습니다.


4. ItemWriter - 데이터 저장 전략

시작하며

여러분이 처리한 데이터를 데이터베이스에 저장할 때, 한 건씩 INSERT하면 너무 느리고, 트랜잭션 관리도 복잡하고, 중복 키 에러 처리도 까다로운 경험을 해보셨나요? 배치 처리의 마지막 단계인 저장은 성능과 안정성이 모두 중요합니다.

이런 문제는 배치의 성공 여부를 결정짓는 핵심입니다. Writer의 성능이 떨어지면 전체 배치 시간이 길어지고, 에러 처리가 부실하면 데이터 정합성이 깨지며, 트랜잭션 설계가 잘못되면 중간에 실패했을 때 복구가 불가능해집니다.

바로 이럴 때 필요한 것이 ItemWriter입니다. ItemWriter는 청크 단위로 묶인 데이터를 한 번에 저장하여 성능을 극대화하고, Spring Batch의 트랜잭션 관리와 통합되어 안정성을 보장하며, 다양한 저장 방식(DB, File, Message Queue 등)을 지원합니다.

개요

간단히 말해서, ItemWriter는 Processor가 처리한 데이터를 청크 단위로 받아서 최종 목적지에 저장하는 컴포넌트입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 배치 처리는 대량의 데이터를 다루기 때문에 저장 성능이 전체 처리 시간을 좌우합니다.

예를 들어, 1000만 건의 데이터를 한 건씩 INSERT하면 몇 시간이 걸리지만, Batch Insert를 사용하면 몇 분으로 단축할 수 있습니다. 데이터 마이그레이션, ETL 작업, 대량 업데이트 같은 경우에 매우 유용합니다.

전통적인 방법과의 비교를 하자면, 기존에는 for문 안에서 repository.save()를 호출하여 한 건씩 저장했다면, 이제는 ItemWriter가 청크 단위로 묶어서 saveAll()이나 Batch Insert를 사용하여 훨씬 효율적으로 저장합니다. ItemWriter의 핵심 특징은 첫째, 청크 단위 일괄 처리로 성능 최적화, 둘째, Spring의 트랜잭션 관리와 완벽한 통합, 셋째, 다양한 저장 방식 지원(JPA, JDBC, File, Kafka 등)입니다.

이러한 특징들이 대용량 데이터를 빠르고 안전하게 저장할 수 있게 합니다.

코드 예제

@Bean
public JpaItemWriter<UserDto> userItemWriter(EntityManagerFactory entityManagerFactory) {
    // JPA 기반 Writer - 엔티티를 persist/merge 합니다
    JpaItemWriter<UserDto> writer = new JpaItemWriter<>();
    writer.setEntityManagerFactory(entityManagerFactory);
    return writer;
}

// 대용량 처리 시 JDBC Batch Insert 사용
@Bean
public JdbcBatchItemWriter<UserDto> userBatchWriter(DataSource dataSource) {
    // JDBC Batch Writer - 가장 빠른 성능을 제공합니다
    return new JdbcBatchItemWriterBuilder<UserDto>()
            .dataSource(dataSource)
            .sql("INSERT INTO users (id, email, phone, address, processed_at) " +
                 "VALUES (:id, :email, :phone, :address, :processedAt) " +
                 "ON CONFLICT (id) DO UPDATE SET email = :email, phone = :phone")  // 중복 시 업데이트
            .beanMapped()  // DTO 필드를 자동으로 매핑
            .build();
}

// 커스텀 Writer 예제
@Component
public class CustomUserWriter implements ItemWriter<UserDto> {
    @Override
    public void write(Chunk<? extends UserDto> chunk) throws Exception {
        // chunk에는 1000건(설정한 chunk 크기)의 데이터가 들어있습니다
        List<? extends UserDto> items = chunk.getItems();
        // 여기서 원하는 방식으로 저장 (API 호출, Kafka 전송 등)
    }
}

설명

이것이 하는 일: ItemWriter는 Processor로부터 받은 데이터 목록을 받아서 한 번에 목적지에 저장하며, 이 과정이 하나의 트랜잭션으로 관리됩니다. 첫 번째로, JpaItemWriter 방식을 살펴보겠습니다.

이 방식은 JPA의 EntityManager를 사용하여 엔티티를 저장합니다. 내부적으로 persist() 또는 merge()를 호출하는데, JPA의 쓰기 지연(Write-Behind) 기능 덕분에 여러 건의 저장 요청을 모았다가 한 번에 실행합니다.

이렇게 하는 이유는 네트워크 왕복을 줄이고 데이터베이스 성능을 향상시키기 위해서입니다. 다만 JPA는 영속성 컨텍스트를 관리하므로 대용량 처리 시 메모리 사용량에 주의해야 합니다.

그 다음으로, JdbcBatchItemWriter 방식이 실행되면서 더 빠른 성능을 제공합니다. 이 방식은 JDBC의 Batch Update 기능을 직접 사용하여 SQL을 일괄 실행합니다.

ON CONFLICT 구문(PostgreSQL) 또는 ON DUPLICATE KEY UPDATE(MySQL)를 사용하면 중복 키가 있을 때 INSERT 대신 UPDATE를 수행할 수 있어, Upsert 패턴을 쉽게 구현할 수 있습니다. 내부에서는 PreparedStatement의 addBatch()executeBatch()를 호출하여 한 번의 네트워크 호출로 여러 건을 처리합니다.

마지막으로, 커스텀 Writer를 만들면 어떤 저장 방식도 사용할 수 있습니다. `Chunk<?

extends UserDto>를 받아서 getItems()`로 목록을 꺼낸 후, REST API 호출, Kafka 메시지 전송, 파일 쓰기 등 원하는 방식으로 저장할 수 있습니다. 이 메서드는 청크 크기만큼의 데이터를 한 번에 받으므로, 반복문을 돌며 처리하되 가능하면 일괄 처리 API를 사용하는 것이 좋습니다.

여러분이 이 코드를 사용하면 대용량 데이터를 빠르게 저장할 수 있고, 트랜잭션 경계가 명확하여 안정성이 높으며, 다양한 저장 방식을 유연하게 선택할 수 있습니다. 또한 Spring Batch의 재시작 기능과 완벽하게 통합되어 실패 지점부터 이어서 저장할 수 있습니다.

실전 팁

💡 대용량 INSERT는 JpaItemWriter보다 JdbcBatchItemWriter가 훨씬 빠릅니다. JPA의 영속성 컨텍스트 오버헤드가 없기 때문입니다. 성능이 중요하다면 JDBC를 선택하세요.

💡 데이터베이스의 Batch Insert 성능을 최대로 끌어내려면 rewriteBatchedStatements=true(MySQL) 같은 JDBC 옵션을 설정하세요. 이 옵션이 없으면 Batch가 하나씩 실행될 수 있습니다.

💡 Writer에서 예외가 발생하면 전체 청크가 롤백됩니다. 일부 아이템만 실패하고 나머지는 저장하고 싶다면, Step에 skip()skipLimit()을 설정하세요.

💡 외부 API나 메시지 큐에 쓸 때는 멱등성을 보장하세요. 재시작 시 같은 데이터가 다시 전송될 수 있으므로, 중복 처리를 방지하는 로직이 필요합니다.

💡 Writer의 청크 크기는 Step의 청크 크기와 동일합니다. 너무 크면 메모리 문제나 긴 트랜잭션이 발생하고, 너무 작으면 성능이 떨어지므로 적절히 튜닝하세요.


5. Chunk 기반 처리 - 트랜잭션 관리의 핵심

시작하며

여러분이 1000만 건의 데이터를 처리하는데, 500만 번째에서 에러가 발생했다면 처음부터 다시 시작해야 할까요? 아니면 500만 건을 모두 롤백해야 할까요?

어느 쪽이든 비효율적입니다. 이런 문제는 대용량 배치 처리에서 가장 어려운 과제 중 하나입니다.

전체를 하나의 트랜잭션으로 묶으면 실패 시 롤백 시간이 너무 오래 걸리고, 한 건씩 커밋하면 성능이 형편없어집니다. 또한 어디까지 성공했는지 추적하기도 어렵습니다.

바로 이럴 때 필요한 것이 Chunk 기반 처리입니다. Chunk는 데이터를 N건씩 묶어서 하나의 트랜잭션으로 처리하는 방식으로, 성능과 안정성의 균형을 맞추며, 실패 시 마지막 성공한 청크부터 재시작할 수 있게 해줍니다.

개요

간단히 말해서, Chunk는 대용량 데이터를 적절한 크기로 나누어 처리하는 단위이며, 각 Chunk가 하나의 트랜잭션으로 관리됩니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 배치 처리는 성능과 안정성을 동시에 고려해야 합니다.

예를 들어, 청크 크기를 1000으로 설정하면 1000건을 읽고, 처리하고, 저장한 후 커밋합니다. 만약 800번째에서 에러가 발생하면 이 청크만 롤백되고, 이전에 성공한 청크들은 유지됩니다.

전통적인 방법과의 비교를 하자면, 기존에는 개발자가 직접 카운터를 두고 N건마다 커밋하는 로직을 작성했다면, 이제는 Spring Batch가 청크 크기만 지정하면 자동으로 읽기-처리-쓰기-커밋 사이클을 관리해줍니다. Chunk 기반 처리의 핵심 특징은 첫째, 페이지 단위가 아닌 트랜잭션 단위로 데이터를 처리한다는 점, 둘째, Reader는 한 건씩 읽지만 Writer는 청크 단위로 쓴다는 점, 셋째, 각 청크의 처리 상태가 메타데이터에 기록되어 재시작이 가능하다는 점입니다.

이러한 특징들이 대용량 배치의 안정성과 효율성을 보장합니다.

코드 예제

@Bean
public Step chunkBasedStep(JobRepository jobRepository,
                           PlatformTransactionManager transactionManager,
                           ItemReader<User> reader,
                           ItemProcessor<User, UserDto> processor,
                           ItemWriter<UserDto> writer) {
    return new StepBuilder("chunkBasedStep", jobRepository)
            .<User, UserDto>chunk(1000, transactionManager)  // 청크 크기 1000
            .reader(reader)
            .processor(processor)
            .writer(writer)
            .faultTolerant()  // 에러 허용 설정
            .skip(ValidationException.class)  // ValidationException은 스킵
            .skipLimit(100)  // 최대 100건까지 스킵 허용
            .retry(TransientException.class)  // TransientException은 재시도
            .retryLimit(3)  // 최대 3번 재시도
            .build();
}

// 청크 처리 과정 (내부 동작 이해용 의사 코드)
/*
for (int i = 0; i < totalCount; i += chunkSize) {
    transaction.begin();
    try {
        List<Output> outputs = new ArrayList<>();
        for (int j = 0; j < chunkSize; j++) {
            Input item = reader.read();  // 한 건 읽기
            if (item != null) {
                Output output = processor.process(item);  // 처리
                if (output != null) outputs.add(output);
            }
        }
        writer.write(outputs);  // 청크 단위 쓰기
        transaction.commit();  // 성공 시 커밋
    } catch (Exception e) {
        transaction.rollback();  // 실패 시 롤백
        // skip/retry 정책에 따라 처리
    }
}
*/

설명

이것이 하는 일: Chunk 기반 처리는 대용량 데이터를 적절한 크기로 나누어 읽기-처리-쓰기를 반복하며, 각 사이클을 독립적인 트랜잭션으로 관리합니다. 첫 번째로, 청크 크기 설정을 살펴보겠습니다.

chunk(1000, transactionManager)는 1000건을 하나의 청크로 처리한다는 의미입니다. 실제 동작을 보면, Reader가 read()를 1000번 호출하여 아이템을 하나씩 읽고, Processor가 각 아이템을 처리한 후, 처리된 1000건을 모아서 Writer의 write()를 한 번 호출합니다.

이렇게 하는 이유는 읽기는 순차적이지만 쓰기는 일괄 처리하여 성능을 극대화하기 위함입니다. 그 다음으로, faultTolerant 설정이 실행되면서 에러 처리 전략이 적용됩니다.

skip()은 특정 예외가 발생했을 때 해당 아이템만 건너뛰고 계속 진행하라는 의미이고, retry()는 일시적인 오류(네트워크 타임아웃 등)는 재시도하라는 의미입니다. 내부에서는 예외가 발생하면 스킵 정책을 먼저 확인하고, 해당 예외가 스킵 대상이면 로그를 남기고 다음 아이템으로 진행합니다.

재시도 대상이면 설정된 횟수만큼 다시 시도한 후 실패하면 청크 전체를 롤백합니다. 마지막으로, 트랜잭션 경계가 청크 단위로 설정되어 데이터 일관성을 보장합니다.

각 청크의 처리가 끝나면 transactionManager.commit()이 호출되고, 성공 여부가 BATCH_STEP_EXECUTION 테이블에 기록됩니다. 만약 5번째 청크에서 실패했다면, 재시작 시 6번째 청크부터 처리를 이어갑니다.

이미 커밋된 1~4번째 청크는 다시 처리하지 않습니다. 여러분이 이 코드를 사용하면 대용량 데이터를 안정적으로 처리할 수 있고, 실패 시 효율적으로 복구할 수 있으며, 성능과 안전성의 최적 균형을 찾을 수 있습니다.

또한 스킵과 재시도 정책을 통해 일시적인 오류에 강건한 배치를 만들 수 있습니다.

실전 팁

💡 청크 크기는 성능 테스트를 통해 결정하세요. 일반적으로 100~2000 사이가 적절하지만, 데이터 크기와 처리 복잡도에 따라 달라집니다.

💡 skip과 retry를 함께 사용할 때는 순서에 주의하세요. 재시도를 먼저 시도하고, 재시도 한계를 넘으면 스킵 정책이 적용됩니다.

💡 스킵된 아이템을 추적하려면 SkipListener를 구현하세요. 어떤 데이터가 왜 스킵되었는지 로그나 별도 테이블에 기록하여 나중에 수동으로 처리할 수 있습니다.

💡 청크 크기가 크면 메모리 사용량이 증가하고 롤백 시간이 길어집니다. 반대로 너무 작으면 커밋 횟수가 증가하여 오버헤드가 생깁니다. 모니터링을 통해 최적값을 찾으세요.

💡 트랜잭션 타임아웃을 적절히 설정하세요. 청크 처리 시간이 타임아웃보다 길면 예상치 못한 롤백이 발생할 수 있습니다.


6. JobParameters와 메타데이터 관리

시작하며

여러분이 매일 밤 같은 배치를 실행하는데, 어제 처리한 데이터와 오늘 처리할 데이터를 어떻게 구분하시겠습니까? 또한 어제 실패한 배치를 오늘 재시작할 때 처음부터 다시 실행되면 안 되겠죠?

이런 문제는 배치를 운영 환경에서 지속적으로 실행할 때 필수적으로 해결해야 합니다. 배치마다 파라미터를 전달하여 처리 범위를 지정하고, 실행 이력을 추적하며, 재시작 시 중단 지점부터 이어서 실행하는 것이 필요합니다.

바로 이럴 때 필요한 것이 JobParameters와 메타데이터 관리입니다. Spring Batch는 Job 실행 시 전달된 파라미터를 기록하고, 실행 상태를 데이터베이스에 저장하며, 같은 파라미터로는 성공한 Job을 재실행하지 않는 등의 지능적인 관리 기능을 제공합니다.

개요

간단히 말해서, JobParameters는 Job 실행 시 전달하는 파라미터이며, 메타데이터는 Job과 Step의 실행 이력과 상태를 저장하는 데이터베이스 테이블들입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 배치는 보통 스케줄러에 의해 주기적으로 실행되거나 수동으로 재실행됩니다.

예를 들어, 날짜 범위를 파라미터로 전달하여 특정 기간의 데이터만 처리하거나, 실패한 배치를 같은 파라미터로 재시작하여 중단 지점부터 이어가는 경우에 매우 유용합니다. 전통적인 방법과의 비교를 하자면, 기존에는 커맨드 라인 인자나 환경 변수로 파라미터를 전달하고 직접 파싱했다면, 이제는 Spring Batch가 JobParameters로 타입 세이프하게 관리하고 실행 컨텍스트에 자동으로 저장해줍니다.

JobParameters와 메타데이터의 핵심 특징은 첫째, 같은 파라미터의 성공한 Job은 재실행되지 않는다는 점, 둘째, 실패한 Job은 같은 파라미터로 재시작할 수 있다는 점, 셋째, 실행 이력이 모두 데이터베이스에 기록되어 모니터링과 추적이 가능하다는 점입니다. 이러한 특징들이 안정적인 배치 운영을 가능하게 합니다.

코드 예제

// Job 실행 시 파라미터 전달
@Scheduled(cron = "0 0 1 * * *")  // 매일 새벽 1시
public void runDailyBatch() {
    JobParameters jobParameters = new JobParametersBuilder()
            .addString("date", LocalDate.now().toString())  // 처리 날짜
            .addLong("timestamp", System.currentTimeMillis())  // 고유성 보장
            .toJobParameters();

    jobLauncher.run(dailyJob, jobParameters);
}

// Job 내부에서 파라미터 사용
@Bean
@StepScope  // Step 실행 시점에 파라미터를 바인딩
public JpaPagingItemReader<Order> orderReader(
        @Value("#{jobParameters['date']}") String date) {

    return new JpaPagingItemReaderBuilder<Order>()
            .name("orderReader")
            .queryString("SELECT o FROM Order o WHERE DATE(o.createdAt) = :date")
            .parameterValues(Map.of("date", LocalDate.parse(date)))
            .pageSize(1000)
            .build();
}

// 실행 컨텍스트에 상태 저장
public class OrderProcessor implements ItemProcessor<Order, OrderDto> {
    @Override
    public OrderDto process(Order order) throws Exception {
        // ExecutionContext에 진행 상황 저장 (재시작 시 활용)
        StepExecution stepExecution = getStepExecution();
        ExecutionContext context = stepExecution.getExecutionContext();
        context.putLong("lastProcessedOrderId", order.getId());

        return convertToDto(order);
    }
}

설명

이것이 하는 일: JobParameters는 Job의 실행 컨텍스트를 정의하고, Spring Batch의 메타데이터 테이블은 모든 실행 이력과 상태를 추적하여 지능적인 재시작과 중복 방지를 제공합니다. 첫 번째로, JobParameters 생성 부분을 살펴보겠습니다.

JobParametersBuilder를 통해 여러 타입의 파라미터를 추가할 수 있는데, 중요한 것은 timestamp처럼 매 실행마다 달라지는 값을 포함해야 한다는 점입니다. 왜냐하면 Spring Batch는 같은 파라미터의 Job이 COMPLETED 상태면 재실행을 거부하기 때문입니다.

이렇게 하는 이유는 같은 작업을 실수로 두 번 실행하는 것을 방지하기 위함입니다. 만약 재실행이 필요하다면 파라미터를 바꾸거나, Job을 FAILED 상태로 변경해야 합니다.

그 다음으로, @StepScope와 SpEL을 사용한 파라미터 바인딩이 실행됩니다. @Value("#{jobParameters['date']}")는 Job 실행 시 전달된 파라미터를 Step이 생성될 때 주입받는 방식입니다.

@StepScope를 사용하면 Step이 실행되는 시점에 빈이 생성되므로, 동일한 Job을 다른 파라미터로 동시에 여러 번 실행할 수 있습니다. 내부에서는 프록시 객체가 생성되고, 실제 Step 실행 시점에 파라미터 값이 바인딩됩니다.

마지막으로, ExecutionContext를 통한 상태 저장이 이루어집니다. context.putLong("lastProcessedOrderId", order.getId())는 현재 처리 중인 주문 ID를 저장하는데, 이 정보는 BATCH_STEP_EXECUTION_CONTEXT 테이블에 JSON 형태로 저장됩니다.

배치가 중간에 실패하면 재시작 시 이 값을 읽어와서 마지막 처리 지점부터 이어갈 수 있습니다. 이는 Reader의 내장 재시작 기능 외에 추가적인 상태 정보를 저장할 때 유용합니다.

여러분이 이 코드를 사용하면 배치 실행을 파라미터로 제어할 수 있고, 같은 작업의 중복 실행을 방지할 수 있으며, 실패 시 정확히 중단 지점부터 재시작하여 시간과 자원을 절약할 수 있습니다. 또한 메타데이터 테이블을 쿼리하여 배치 실행 이력을 분석하고 성능을 모니터링할 수 있습니다.

실전 팁

💡 JobParameters에는 identifying 여부를 설정할 수 있습니다. identifying=false인 파라미터는 Job 인스턴스 구분에 사용되지 않으므로, 로그 레벨 같은 부가 정보를 전달할 때 유용합니다.

💡 메타데이터 테이블은 시간이 지나면 매우 커집니다. 오래된 실행 이력은 주기적으로 아카이빙하거나 삭제하는 관리 배치를 만드세요.

💡 운영 환경에서는 JobExplorer를 사용하여 실행 중인 Job의 상태를 조회하고, JobOperator로 실행 중인 Job을 중지하거나 재시작할 수 있습니다.

💡 날짜 파라미터는 String이 아닌 LocalDate를 직렬화한 형태로 전달하세요. 타임존 문제를 피하고 타입 안정성을 높일 수 있습니다.

💡 ExecutionContext에 너무 큰 데이터를 저장하지 마세요. 이는 데이터베이스에 저장되므로 성능에 영향을 줄 수 있습니다. 큰 데이터는 파일이나 별도 테이블에 저장하고 참조만 저장하세요.


7. 파티셔닝과 병렬 처리

시작하며

여러분이 1억 건의 데이터를 처리하는데 10시간이 걸린다면, 마감 시간 내에 완료하지 못할 수도 있습니다. 서버 자원은 충분한데 배치가 단일 스레드로만 실행된다면 답답하지 않으신가요?

이런 문제는 대용량 배치 처리에서 흔히 겪는 성능 병목입니다. 아무리 청크 크기를 최적화해도 단일 스레드의 처리량에는 한계가 있습니다.

멀티 코어 CPU와 충분한 메모리가 있는데도 활용하지 못한다면 자원 낭비입니다. 바로 이럴 때 필요한 것이 파티셔닝과 병렬 처리입니다.

Spring Batch는 데이터를 여러 파티션으로 나누어 동시에 처리하는 기능을 제공하며, 각 파티션은 독립적인 Step으로 실행되어 처리 시간을 대폭 단축시킵니다.

개요

간단히 말해서, 파티셔닝은 대용량 데이터를 여러 조각으로 나누어 각각을 별도의 스레드나 프로세스에서 동시에 처리하는 기법입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 데이터를 논리적으로 분할할 수 있다면 병렬 처리를 통해 처리 시간을 크게 줄일 수 있습니다.

예를 들어, 지역별로 데이터를 나누거나, ID 범위로 나누거나, 날짜로 나누어 각 파티션을 독립적으로 처리할 수 있습니다. 대규모 ETL 작업, 전체 테이블 스캔, 대량 데이터 집계 같은 경우에 매우 유용합니다.

전통적인 방법과의 비교를 하자면, 기존에는 단일 스레드로 순차 처리하거나 수동으로 데이터를 나누어 여러 배치를 실행했다면, 이제는 Spring Batch의 파티셔닝 기능이 자동으로 데이터를 분할하고 스레드 풀에서 병렬 실행합니다. 파티셔닝의 핵심 특징은 첫째, 각 파티션이 독립적인 ExecutionContext를 가져 서로 영향을 주지 않는다는 점, 둘째, Master Step이 Worker Step들을 조율하고 모두 완료될 때까지 대기한다는 점, 셋째, 로컬 멀티스레드뿐만 아니라 원격 파티셔닝도 지원한다는 점입니다.

이러한 특징들이 스케일 아웃 가능한 고성능 배치를 구현할 수 있게 합니다.

코드 예제

@Bean
public Step masterStep(JobRepository jobRepository,
                       Step workerStep,
                       Partitioner partitioner) {
    return new StepBuilder("masterStep", jobRepository)
            .partitioner("workerStep", partitioner)  // 파티셔너 설정
            .step(workerStep)  // 각 파티션에서 실행할 Step
            .gridSize(10)  // 10개의 파티션으로 분할
            .taskExecutor(taskExecutor())  // 스레드 풀 설정
            .build();
}

@Bean
public Partitioner partitioner() {
    return gridSize -> {
        Map<String, ExecutionContext> partitions = new HashMap<>();

        // ID 범위로 파티션 분할 (예: 1~1000만을 10개로)
        int totalRecords = 10_000_000;
        int partitionSize = totalRecords / gridSize;

        for (int i = 0; i < gridSize; i++) {
            ExecutionContext context = new ExecutionContext();
            context.putLong("minId", i * partitionSize + 1);
            context.putLong("maxId", (i + 1) * partitionSize);
            partitions.put("partition" + i, context);
        }

        return partitions;
    };
}

@Bean
@StepScope
public JpaPagingItemReader<User> partitionedReader(
        @Value("#{stepExecutionContext['minId']}") Long minId,
        @Value("#{stepExecutionContext['maxId']}") Long maxId) {

    return new JpaPagingItemReaderBuilder<User>()
            .name("partitionedReader")
            .queryString("SELECT u FROM User u WHERE u.id BETWEEN :minId AND :maxId")
            .parameterValues(Map.of("minId", minId, "maxId", maxId))
            .pageSize(1000)
            .build();
}

설명

이것이 하는 일: 파티셔닝은 Master Step이 데이터를 여러 파티션으로 나누고, 각 파티션을 Worker Step에서 독립적으로 처리하며, 모든 파티션이 완료될 때까지 조율합니다. 첫 번째로, Master Step 설정을 살펴보겠습니다.

partitioner()gridSize(10)로 10개의 파티션을 생성하라고 지시합니다. 내부적으로 Master Step은 Partitioner를 호출하여 각 파티션의 ExecutionContext를 생성하고, taskExecutor()의 스레드 풀을 사용하여 Worker Step을 병렬로 실행합니다.

이렇게 하는 이유는 단일 Step의 직렬 처리보다 여러 Step의 병렬 처리가 훨씬 빠르기 때문입니다. 예를 들어 10시간 걸리던 작업이 10개 파티션으로 나누면 이론상 1시간으로 단축됩니다.

그 다음으로, Partitioner 구현이 실행되면서 각 파티션의 범위를 정의합니다. 이 예제에서는 1000만 건을 10개로 나누어 각 파티션이 100만 건씩 처리하도록 합니다.

첫 번째 파티션은 11,000,000, 두 번째는 1,000,0012,000,000 이런 식으로 범위가 할당됩니다. 각 파티션의 ExecutionContext에 minIdmaxId를 저장하면, Worker Step의 Reader가 이 값을 읽어와 해당 범위만 조회합니다.

이렇게 범위를 명확히 나누면 파티션 간 데이터 중복이나 누락이 발생하지 않습니다. 마지막으로, @StepScope를 사용한 Reader가 각 파티션의 ExecutionContext에서 범위를 읽어옵니다.

@Value("#{stepExecutionContext['minId']}")는 현재 실행 중인 파티션의 minId를 가져오는데, 이는 각 스레드마다 다른 값입니다. 파티션 0의 스레드는 11,000,000을, 파티션 1의 스레드는 1,000,0012,000,000을 조회하는 식입니다.

각 파티션은 독립적인 트랜잭션과 ExecutionContext를 가지므로, 한 파티션이 실패해도 다른 파티션에 영향을 주지 않습니다. 여러분이 이 코드를 사용하면 처리 시간을 파티션 수에 비례하여 단축할 수 있고, CPU와 메모리 자원을 최대한 활용할 수 있으며, 일부 파티션 실패 시 해당 파티션만 재실행하여 효율적으로 복구할 수 있습니다.

또한 데이터가 더 늘어나면 파티션 수를 늘려 스케일 아웃할 수 있습니다.

실전 팁

💡 파티션 수는 CPU 코어 수와 데이터 특성을 고려하여 결정하세요. 너무 많으면 컨텍스트 스위칭 오버헤드가 발생하고, 너무 적으면 병렬화 효과가 떨어집니다.

💡 TaskExecutor의 스레드 풀 크기는 파티션 수와 같거나 약간 크게 설정하세요. 스레드가 부족하면 파티션이 대기하게 됩니다.

💡 데이터베이스 커넥션 풀 크기도 함께 늘려야 합니다. 각 파티션이 동시에 DB에 접근하므로 커넥션이 부족하면 병목이 발생합니다.

💡 파티션 간 데이터 균등 분배가 중요합니다. 한 파티션에 데이터가 몰리면 해당 파티션이 늦게 끝나서 전체 시간이 길어집니다. 데이터 분포를 분석하여 적절한 파티셔닝 키를 선택하세요.

💡 원격 파티셔닝(Remote Partitioning)을 사용하면 여러 서버에서 파티션을 처리할 수 있습니다. 메시지 큐(RabbitMQ, Kafka)를 통해 Master가 Worker에게 파티션을 할당합니다.


8. 에러 핸들링과 재시도 전략

시작하며

여러분이 배치를 실행하는데 중간에 네트워크 오류로 외부 API 호출이 실패하거나, 일부 데이터에 유효성 검증 오류가 있다면 전체 배치를 실패시켜야 할까요? 아니면 해당 아이템만 건너뛰고 계속 진행해야 할까요?

이런 문제는 실제 운영 환경에서 매일 겪는 현실입니다. 배치 처리는 오래 걸리기 때문에 작은 오류로 전체를 실패시키면 시간 낭비가 크고, 그렇다고 모든 오류를 무시하면 데이터 품질이 떨어집니다.

또한 일시적인 네트워크 오류는 재시도하면 성공할 수 있는데, 즉시 실패시키는 것도 비효율적입니다. 바로 이럴 때 필요한 것이 Spring Batch의 에러 핸들링과 재시도 전략입니다.

Spring Batch는 예외 종류에 따라 스킵, 재시도, 실패를 구분하여 처리하고, 스킵된 아이템을 추적하며, 재시도 횟수와 백오프 전략을 설정할 수 있는 강력한 기능을 제공합니다.

개요

간단히 말해서, 에러 핸들링은 배치 처리 중 발생하는 예외를 어떻게 처리할지 정의하는 정책이며, 재시도 전략은 일시적인 오류를 자동으로 재시도하여 성공률을 높이는 메커니즘입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 실제 배치는 다양한 예외 상황에 직면합니다.

예를 들어, 외부 API 타임아웃, 데이터베이스 데드락, 잘못된 데이터 형식, 중복 키 오류 등이 발생할 수 있습니다. 각 예외마다 적절한 처리 방법이 다르며, 이를 명확하게 정의하지 않으면 배치가 불안정해집니다.

전통적인 방법과의 비교를 하자면, 기존에는 try-catch로 예외를 잡아서 수동으로 로그를 남기고 카운터를 증가시켰다면, 이제는 Spring Batch의 선언적 설정만으로 스킵, 재시도, 리스너를 통한 추적을 자동화할 수 있습니다. 에러 핸들링의 핵심 특징은 첫째, 예외 클래스별로 다른 정책을 적용할 수 있다는 점, 둘째, 스킵과 재시도에 각각 한계를 설정하여 무한 루프를 방지한다는 점, 셋째, 리스너를 통해 스킵/재시도/실패 시점에 커스텀 로직을 실행할 수 있다는 점입니다.

이러한 특징들이 안정적이고 복원력 있는 배치를 만들 수 있게 합니다.

코드 예제

@Bean
public Step resilientStep(JobRepository jobRepository,
                          PlatformTransactionManager transactionManager) {
    return new StepBuilder("resilientStep", jobRepository)
            .<User, UserDto>chunk(1000, transactionManager)
            .reader(reader())
            .processor(processor())
            .writer(writer())
            .faultTolerant()  // 에러 허용 모드 활성화
            // 스킵 정책
            .skip(ValidationException.class)  // 검증 오류는 스킵
            .skip(DataFormatException.class)  // 데이터 형식 오류도 스킵
            .skipLimit(100)  // 최대 100건까지만 스킵 허용
            // 재시도 정책
            .retry(TransientException.class)  // 일시적 오류는 재시도
            .retry(TimeoutException.class)  // 타임아웃도 재시도
            .retryLimit(3)  // 최대 3번 재시도
            .backOffPolicy(exponentialBackOffPolicy())  // 재시도 간격 증가
            // 리스너 등록
            .listener(skipListener())
            .build();
}

@Bean
public BackOffPolicy exponentialBackOffPolicy() {
    ExponentialBackOffPolicy policy = new ExponentialBackOffPolicy();
    policy.setInitialInterval(1000);  // 첫 재시도: 1초 후
    policy.setMaxInterval(10000);     // 최대 간격: 10초
    policy.setMultiplier(2.0);        // 매번 2배씩 증가
    return policy;
}

@Component
public class CustomSkipListener implements SkipListener<User, UserDto> {
    @Override
    public void onSkipInRead(Throwable t) {
        // Reader에서 스킵 발생
        log.error("Skipped in read: {}", t.getMessage());
    }

    @Override
    public void onSkipInProcess(User item, Throwable t) {
        // Processor에서 스킵 발생 - 문제 아이템을 별도 테이블에 저장
        log.error("Skipped item: {} due to {}", item.getId(), t.getMessage());
        errorRepository.save(new ErrorLog(item, t.getMessage()));
    }

    @Override
    public void onSkipInWrite(UserDto item, Throwable t) {
        // Writer에서 스킵 발생
        log.error("Skipped in write: {}", t.getMessage());
    }
}

설명

이것이 하는 일: Spring Batch의 에러 핸들링은 예외 발생 시 설정된 정책에 따라 자동으로 재시도하거나 스킵하며, 한계에 도달하면 배치를 실패시켜 데이터 일관성을 보장합니다. 첫 번째로, 스킵 정책을 살펴보겠습니다.

skip(ValidationException.class)는 ValidationException이 발생하면 해당 아이템을 건너뛰고 다음 아이템을 처리하라는 의미입니다. 예를 들어 이메일 형식이 잘못된 사용자 데이터가 있으면, 그 데이터만 제외하고 나머지는 정상 처리합니다.

skipLimit(100)은 안전장치로, 100건 이상 스킵이 발생하면 데이터 자체에 심각한 문제가 있다고 판단하여 배치를 중단합니다. 이렇게 하는 이유는 무한정 스킵하다가 대부분의 데이터가 누락되는 것을 방지하기 위함입니다.

그 다음으로, 재시도 정책이 실행되면서 일시적인 오류를 복구합니다. retry(TimeoutException.class)retryLimit(3)은 타임아웃 발생 시 최대 3번까지 재시도한다는 의미입니다.

내부적으로 Spring Retry 프레임워크를 사용하며, 재시도 간에 BackOffPolicy에 따라 대기 시간이 증가합니다. 예를 들어 첫 실패 후 1초 대기, 두 번째 실패 후 2초 대기, 세 번째 실패 후 4초 대기하는 식으로 Exponential Backoff가 적용됩니다.

이렇게 대기 시간을 늘리는 이유는 외부 시스템이 일시적으로 과부하 상태일 때 회복 시간을 주기 위함입니다. 마지막으로, SkipListener를 통해 스킵된 아이템을 추적합니다.

onSkipInProcess()에서는 스킵된 아이템의 정보와 예외 메시지를 별도 테이블에 저장하여, 나중에 수동으로 처리하거나 데이터 품질을 분석할 수 있습니다. 이는 매우 중요한데, 스킵된 데이터를 추적하지 않으면 어떤 데이터가 누락되었는지 알 수 없기 때문입니다.

운영 팀은 이 로그를 보고 데이터를 수정한 후 재처리할 수 있습니다. 여러분이 이 코드를 사용하면 일시적인 네트워크 오류나 시스템 오류에도 강건하게 대응할 수 있고, 잘못된 데이터로 인해 전체 배치가 실패하는 것을 방지할 수 있으며, 스킵된 데이터를 추적하여 데이터 품질을 유지할 수 있습니다.

또한 재시도 간격을 늘려 외부 시스템에 부담을 주지 않으면서도 성공률을 높일 수 있습니다.

실전 팁

💡 스킵과 재시도를 함께 사용할 때는 순서를 이해하세요. 먼저 재시도를 시도하고, 재시도 한계를 넘으면 스킵 정책을 확인합니다. 둘 다 해당되지 않으면 배치가 실패합니다.

💡 재시도 간격은 외부 시스템의 특성에 맞게 설정하세요. API 요청 제한(rate limit)이 있다면 충분한 간격을 두어야 합니다.

💡 데이터베이스 데드락은 재시도하면 대부분 해결됩니다. retry(DeadlockLoserDataAccessException.class)를 설정하여 데드락 시 자동 재시도하세요.

💡 스킵된 아이템은 반드시 로그나 별도 테이블에 기록하세요. 스킵 비율이 높아지면 알림을 보내도록 모니터링을 설정하는 것도 좋습니다.

💡 NoSkip, NoRetry 설정도 가능합니다. 특정 예외는 절대 스킵하지 않고 즉시 실패시켜야 할 때 사용합니다.


9. 성능 최적화 기법

시작하며

여러분의 배치가 예상보다 몇 배나 느리게 실행되고, 데이터베이스 CPU가 100%를 찍으며, 메모리 사용량이 계속 증가한다면 어디서부터 개선해야 할까요? 배치 성능은 여러 요소가 복합적으로 작용하기 때문에 체계적인 접근이 필요합니다.

이런 문제는 배치가 운영 환경에 배포된 후 실제 데이터량으로 테스트할 때 드러납니다. 개발 환경의 소량 데이터로는 문제가 없었지만, 수백만 건의 실 데이터를 처리할 때 성능 병목이 발생합니다.

청크 크기, 커넥션 풀, 인덱스, 페이징 방식 등 최적화할 부분이 많습니다. 바로 이럴 때 필요한 것이 체계적인 성능 최적화 기법입니다.

Spring Batch는 다양한 성능 튜닝 옵션을 제공하며, 데이터베이스 쿼리 최적화, 멀티스레드 처리, 메모리 관리 등을 통해 처리량을 몇 배에서 몇십 배까지 향상시킬 수 있습니다.

개요

간단히 말해서, 성능 최적화는 배치의 처리 시간을 단축하고 자원 사용을 효율화하기 위한 다양한 기법들의 집합입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 배치는 보통 제한된 시간 내에 완료되어야 합니다.

예를 들어, 야간 배치는 다음 날 업무 시작 전까지 끝나야 하고, 실시간에 가까운 배치는 몇 분 내에 완료되어야 합니다. 성능 최적화 없이는 증가하는 데이터량을 감당할 수 없습니다.

전통적인 방법과의 비교를 하자면, 기존에는 "느리면 서버를 업그레이드하자"는 수직 확장에 의존했다면, 이제는 코드와 설정 최적화를 통한 효율화, 파티셔닝을 통한 수평 확장, 병목 지점 제거 등 다양한 기법을 조합합니다. 성능 최적화의 핵심 특징은 첫째, 측정 없이는 최적화할 수 없다는 점(프로파일링), 둘째, 병목 지점을 찾아 집중적으로 개선한다는 점, 셋째, 데이터베이스, 네트워크, CPU, 메모리 등 다양한 레이어를 함께 고려해야 한다는 점입니다.

이러한 특징들이 체계적이고 효과적인 성능 개선을 가능하게 합니다.

코드 예제

@Bean
public Step optimizedStep(JobRepository jobRepository,
                          PlatformTransactionManager transactionManager,
                          EntityManagerFactory entityManagerFactory) {
    return new StepBuilder("optimizedStep", jobRepository)
            .<User, UserDto>chunk(2000, transactionManager)  // 최적 청크 크기 설정
            .reader(optimizedReader(entityManagerFactory))
            .processor(cachedProcessor())
            .writer(batchWriter())
            .taskExecutor(taskExecutor())  // 멀티스레드 처리
            .throttleLimit(10)  // 동시 실행 스레드 수
            .build();
}

@Bean
public JdbcBatchItemWriter<UserDto> batchWriter(DataSource dataSource) {
    return new JdbcBatchItemWriterBuilder<UserDto>()
            .dataSource(dataSource)
            .sql("INSERT INTO users (...) VALUES (...)")
            .beanMapped()
            // JDBC Batch 설정 최적화
            .assertUpdates(false)  // 업데이트 카운트 검증 비활성화로 성능 향상
            .build();
}

// 커넥션 풀 최적화 (application.yml)
/*
spring:
  datasource:
    hikari:
      maximum-pool-size: 20  # 파티션/스레드 수보다 크게
      minimum-idle: 10
      connection-timeout: 30000

spring.jpa.properties:
  hibernate:
    jdbc.batch_size: 100  # Hibernate Batch Insert 활성화
    order_inserts: true   # Insert 순서 최적화
    order_updates: true   # Update 순서 최적화
*/

// 캐싱을 활용한 Processor
@Component
public class CachedProcessor implements ItemProcessor<User, UserDto> {
    private final LoadingCache<String, AddressInfo> addressCache =
        CacheBuilder.newBuilder()
            .maximumSize(10000)
            .expireAfterWrite(10, TimeUnit.MINUTES)
            .build(new CacheLoader<String, AddressInfo>() {
                public AddressInfo load(String zipCode) {
                    return apiClient.getAddressInfo(zipCode);  // API 호출
                }
            });

    @Override
    public UserDto process(User user) throws Exception {
        // 같은 우편번호는 캐시에서 가져와 API 호출 감소
        AddressInfo address = addressCache.get(user.getZipCode());
        return convertToDto(user, address);
    }
}

설명

이것이 하는 일: 성능 최적화는 배치 처리의 각 단계(읽기, 처리, 쓰기)에서 병목을 제거하고, 자원을 효율적으로 활용하여 전체 처리 시간을 단축합니다. 첫 번째로, 청크 크기와 멀티스레드 설정을 살펴보겠습니다.

chunk(2000)은 2000건씩 묶어서 처리하는데, 이 값은 데이터 크기와 처리 복잡도에 따라 달라집니다. 일반적으로 500~2000 사이에서 테스트하며 최적값을 찾습니다.

taskExecutor()throttleLimit(10)은 10개의 스레드로 병렬 처리한다는 의미인데, 이는 단순 멀티스레드이므로 같은 Reader를 공유합니다. 이렇게 하는 이유는 파티셔닝보다 설정이 간단하면서도 성능 향상 효과가 있기 때문입니다.

다만 Reader와 Writer가 스레드 세이프해야 합니다. 그 다음으로, JDBC Batch Insert 최적화가 실행되면서 쓰기 성능이 크게 향상됩니다.

hibernate.jdbc.batch_size: 100은 Hibernate가 100개의 INSERT를 모아서 한 번에 실행한다는 의미입니다. order_inserts: true는 같은 테이블에 대한 INSERT를 그룹핑하여 Batch 효율을 높입니다.

또한 assertUpdates(false)는 각 SQL의 영향받은 행 수를 검증하지 않아 오버헤드를 줄입니다. 이 설정들을 조합하면 한 건씩 INSERT하는 것보다 수십 배 빠릅니다.

마지막으로, 캐싱을 통해 반복적인 외부 API 호출을 줄입니다. LoadingCache는 같은 우편번호에 대한 주소 정보를 캐시에 저장하여, 두 번째 요청부터는 API를 호출하지 않고 캐시에서 가져옵니다.

예를 들어 100만 명의 사용자가 1000개의 우편번호를 가진다면, API 호출이 100만 번에서 1000번으로 줄어듭니다. 캐시 크기와 만료 시간을 적절히 설정하면 메모리 사용량도 관리할 수 있습니다.

여러분이 이 코드를 사용하면 처리 시간을 몇 배에서 몇십 배까지 단축할 수 있고, 데이터베이스와 외부 시스템의 부하를 크게 줄일 수 있으며, 같은 하드웨어로 더 많은 데이터를 처리할 수 있습니다. 또한 병목 지점을 제거하여 자원 활용률을 극대화할 수 있습니다.

실전 팁

💡 성능 최적화는 반드시 측정부터 시작하세요. Spring Batch의 StepExecutionListener로 각 Step의 실행 시간을 측정하고, 데이터베이스 슬로우 쿼리 로그를 분석하세요.

💡 JPA보다 JDBC가 빠르지만, 코드 복잡도가 높아집니다. 읽기는 JPA로, 쓰기는 JDBC로 하는 하이브리드 접근도 고려하세요.

💡 인덱스가 없는 WHERE 절이나 ORDER BY는 치명적입니다. 실행 계획(EXPLAIN)을 확인하여 풀 테이블 스캔을 제거하세요.

💡 네트워크 I/O를 줄이는 것이 핵심입니다. API를 여러 번 호출하는 대신 한 번에 여러 건을 조회하거나, 캐싱을 활용하세요.

💡 메모리 누수를 주의하세요. 대용량 배치는 오래 실행되므로 작은 누수도 누적되면 OutOfMemory를 발생시킵니다. Heap 덤프를 분석하여 불필요한 객체 참조를 제거하세요.


10. 실무 베스트 프랙티스

시작하며

여러분이 지금까지 배운 Spring Batch의 개념들을 실제 프로젝트에 적용할 때, 어떻게 구조를 설계하고, 어떤 패턴을 따르며, 어떤 함정을 피해야 할까요? 이론과 실무 사이에는 항상 간극이 있습니다.

이런 문제는 처음 Spring Batch를 도입하는 팀이나 개발자가 흔히 겪는 어려움입니다. 샘플 코드는 잘 작동하지만, 실제 비즈니스 요구사항과 운영 환경에 적용하면 예상치 못한 문제들이 나타납니다.

코드 구조, 에러 처리, 모니터링, 배포 전략 등 고려할 사항이 많습니다. 바로 이럴 때 필요한 것이 검증된 베스트 프랙티스입니다.

실무에서 수많은 시행착오를 거쳐 정립된 패턴과 원칙들을 따르면, 안정적이고 유지보수하기 쉬운 배치 시스템을 구축할 수 있습니다.

개요

간단히 말해서, 베스트 프랙티스는 실무에서 검증된 설계 패턴, 코딩 규칙, 운영 노하우를 체계화한 것입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, Spring Batch는 유연하고 강력한 만큼 잘못 사용하면 복잡하고 관리하기 어려운 시스템이 될 수 있습니다.

예를 들어, Job과 Step을 어떻게 나눌지, 설정을 어디에 둘지, 에러를 어떻게 추적할지, 운영 환경에서 어떻게 모니터링할지 등의 결정이 필요합니다. 전통적인 방법과의 비교를 하자면, 기존에는 각 팀이나 개발자가 자기만의 방식으로 배치를 구현했다면, 이제는 커뮤니티에서 검증된 패턴을 따라 일관성 있고 예측 가능한 시스템을 만들 수 있습니다.

베스트 프랙티스의 핵심 특징은 첫째, 단순성과 명확성을 유지한다는 점, 둘째, 테스트 가능하고 유지보수하기 쉬운 구조라는 점, 셋째, 운영 환경에서의 모니터링과 장애 대응을 고려한다는 점입니다. 이러한 특징들이 장기적으로 안정적인 배치 시스템을 만들 수 있게 합니다.

코드 예제

// 1. Job과 Step은 명확하게 분리하고, 재사용 가능하게 설계
@Configuration
public class UserMigrationJobConfig {

    @Bean
    public Job userMigrationJob(JobRepository jobRepository,
                                Step validateStep,
                                Step migrateStep,
                                Step notifyStep) {
        return new JobBuilder("userMigrationJob", jobRepository)
                .start(validateStep)  // 1단계: 데이터 검증
                .next(migrateStep)    // 2단계: 마이그레이션
                .next(notifyStep)     // 3단계: 결과 통지
                .build();
    }

    // 각 Step은 독립적으로 테스트 가능
    @Bean
    public Step validateStep(JobRepository jobRepository,
                             PlatformTransactionManager transactionManager) {
        return new StepBuilder("validateStep", jobRepository)
                .tasklet((contribution, chunkContext) -> {
                    // 마이그레이션 전 데이터 검증
                    long invalidCount = userRepository.countInvalidUsers();
                    if (invalidCount > 100) {
                        throw new IllegalStateException("Too many invalid users: " + invalidCount);
                    }
                    return RepeatStatus.FINISHED;
                }, transactionManager)
                .build();
    }
}

// 2. 설정은 환경별로 분리 (application-{profile}.yml)
/*
application-prod.yml:
batch:
  chunk-size: 2000
  thread-pool-size: 10
  max-skip-count: 100

application-dev.yml:
batch:
  chunk-size: 100  # 개발 환경에서는 작게
  thread-pool-size: 2
  max-skip-count: 10
*/

// 3. 모니터링과 알림 설정
@Component
public class BatchMonitoringListener extends JobExecutionListenerSupport {

    @Override
    public void afterJob(JobExecution jobExecution) {
        if (jobExecution.getStatus() == BatchStatus.FAILED) {
            // 실패 시 알림 전송
            String errorMessage = jobExecution.getAllFailureExceptions().stream()
                    .map(Throwable::getMessage)
                    .collect(Collectors.joining(", "));

            slackNotifier.sendAlert(
                "Batch Failed: " + jobExecution.getJobInstance().getJobName(),
                errorMessage
            );
        }

        // 성능 메트릭 기록
        long duration = jobExecution.getEndTime().getTime() -
                       jobExecution.getStartTime().getTime();
        long processedCount = jobExecution.getStepExecutions().stream()
                .mapToLong(StepExecution::getWriteCount)
                .sum();

        metricsCollector.record("batch.duration", duration);
        metricsCollector.record("batch.processed.count", processedCount);
    }
}

// 4. 통합 테스트 작성
@SpringBatchTest
@SpringBootTest
class UserMigrationJobTest {

    @Autowired
    private JobLauncherTestUtils jobLauncherTestUtils;

    @Test
    void testUserMigrationJob() throws Exception {
        // Given: 테스트 데이터 준비
        createTestUsers(1000);

        // When: Job 실행
        JobExecution jobExecution = jobLauncherTestUtils.launchJob();

        // Then: 결과 검증
        assertEquals(BatchStatus.COMPLETED, jobExecution.getStatus());
        assertEquals(1000, jobExecution.getStepExecutions().iterator().next().getWriteCount());
    }
}

설명

이것이 하는 일: 베스트 프랙티스는 배치 시스템의 전체 라이프사이클(개발, 테스트, 배포, 운영)에서 따라야 할 원칙과 패턴을 제시하여 안정성과 유지보수성을 보장합니다. 첫 번째로, Job과 Step의 명확한 분리를 살펴보겠습니다.

하나의 복잡한 배치를 여러 Step으로 나누면 각 Step을 독립적으로 개발하고 테스트할 수 있습니다. 예제의 validateStep, migrateStep, notifyStep은 각각 검증, 마이그레이션, 통지라는 명확한 책임을 가집니다.

이렇게 하는 이유는 한 Step이 실패해도 다른 Step은 재사용할 수 있고, 순서를 바꾸거나 조건부 실행도 쉽게 구현할 수 있기 때문입니다. 또한 Tasklet을 사용한 간단한 Step과 Chunk 기반 Step을 적절히 섞어 사용하면 코드가 더 읽기 쉬워집니다.

그 다음으로, 환경별 설정 분리가 실행되면서 개발과 운영 환경의 차이를 관리합니다. 청크 크기, 스레드 수, 스킵 한계 등은 환경에 따라 달라야 합니다.

개발 환경에서는 빠른 피드백을 위해 작은 청크 크기를 사용하고, 운영 환경에서는 성능을 위해 큰 청크 크기를 사용합니다. @ConfigurationProperties를 사용하면 이러한 설정을 타입 세이프하게 주입받을 수 있습니다.

마지막으로, 모니터링과 알림 설정을 통해 운영 환경에서의 가시성을 확보합니다. JobExecutionListener는 Job 시작 전후에 실행되어 실행 시간, 처리 건수, 에러 메시지 등을 수집합니다.

배치가 실패하면 Slack이나 이메일로 즉시 알림을 보내고, 성공하더라도 처리 시간이 평소보다 오래 걸리면 경고를 보낼 수 있습니다. 또한 Micrometer나 Prometheus와 통합하여 시계열 메트릭을 수집하면 성능 추이를 분석할 수 있습니다.

여러분이 이 코드를 사용하면 배치 시스템이 체계적으로 구성되어 새로운 개발자도 쉽게 이해할 수 있고, 문제 발생 시 빠르게 원인을 파악하고 대응할 수 있으며, 지속적으로 개선하고 확장할 수 있는 기반을 갖추게 됩니다. 또한 통합 테스트를 통해 배포 전에 문제를 발견하여 운영 안정성을 높일 수 있습니다.

실전 팁

💡 Job 이름에는 버전을 포함하세요. 배치 로직이 변경되면 새로운 Job 이름으로 배포하여 기존 실행 이력과 분리하세요. (예: userMigrationV1, userMigrationV2)

💡 민감한 정보(DB 비밀번호 등)는 절대 코드에 하드코딩하지 마세요. 환경 변수나 AWS Secrets Manager, Vault 같은 보안 저장소를 사용하세요.

💡 배치 실행은 멱등성을 보장하세요. 같은 파라미터로 여러 번 실행해도 결과가 동일해야 합니다. 이를 위해 Upsert 패턴이나 중복 체크 로직을 사용하세요.

💡 대용량 배치는 점진적으로 롤아웃하세요. 먼저 일부 데이터로 테스트하고, 문제 없으면 전체로 확대합니다. 카나리 배포 전략을 배치에도 적용할 수 있습니다.

💡 배치 코드도 코드 리뷰와 버전 관리를 철저히 하세요. 배치는 한 번 실행되면 대량의 데이터에 영향을 주므로, 잘못된 로직은 큰 피해를 줄 수 있습니다. 특히 DELETE나 UPDATE 배치는 더욱 신중해야 합니다.


#Java#SpringBatch#ChunkProcessing#Partitioning#DataProcessing