매일 새벽 2시, 은행 시스템은 수백만 개의 거래 내역을 처리한다.

이 일을 평소 짜던 웹 애플리케이션 코드 그대로 처리하면 어떻게 될까. 웹 애플리케이션 코드는 요청 하나에 필요한 만큼의 데이터만 다루도록 짠다. 그 방식으로 거래 100만 건을 한 번에 다루면 이런 일이 생긴다.

  • 메모리 부족으로 멈춘다. 100만 건을 리스트 하나에 다 읽어 들이면 그 리스트가 메모리를 다 차지한다
  • 오류 하나로 전체가 실패한다. for 문 안에서 한 건이 예외를 던지면 반복문이 거기서 끝난다
  • 다시 돌리면 처음부터 한다. 어디까지 처리했는지 따로 적어 두지 않았으니 알 길이 없다
  • 진행 상황을 알기 어렵다. 지금 몇 건째인지 남겨 둔 기록이 없다

Spring Batch는 이런 배치 작업을 안전하게 돌리려고 만든 틀이다. 그런데 처음 배우면 Job, Step, ItemReader, Listener처럼 낯선 말이 한꺼번에 쏟아진다.

이 글에서는 거래 내역 일괄 처리라는 예시 하나로 Spring Batch의 핵심 개념을 차례로 짚는다.


1. Job은 전체 작업이고 Step은 그 안의 단계다

Job은 “전체 작업의 묶음”이다

매일 새벽 2시에 거래 내역을 처리하는 과정을 떠올려 보자.

  1. 은행 시스템에서 거래 파일 수신
  2. 파일을 읽어서 데이터 검증
  3. 데이터베이스에 저장
  4. 통계 계산
  5. 최종 리포트 생성

이 “전체 거래 처리 과정”을 Job이라고 부른다.

Step은 “Job 안의 개별 단계”다

위 과정을 묶어 보면 세 개의 Step으로 나뉜다. 파일 수신은 이 Job 밖에서 끝났다고 보고, 2번과 3번을 한 Step으로 묶었다.

Job: 거래 내역 일괄 처리
  ├─ Step 1: 거래 데이터 검증 & 저장 (Read → Validate → Write)
  ├─ Step 2: 통계 계산
  └─ Step 3: 최종 리포트 생성

각 Step은 이렇다.

  • 차례로 실행된다 (Step 1 → Step 2 → Step 3)
  • 자기 상태를 기록한다. Step이 시작했는지, 실패했는지, 완료했는지를 Spring Batch가 JobRepository(실행 기록을 DB 테이블에 남기는 저장소)에 적는다
  • 이 기록 덕분에 실패한 Step부터 다시 돌릴 수 있다. 실패한 Job을 다시 실행하면, 이미 COMPLETED(완료)로 기록된 Step은 기본으로 건너뛴다(공식 문서 Configuring a Step for Restart). Step 2에서 실패했다면 Step 1은 다시 하지 않는다
graph LR A["Job
거래 처리"] --> B["Step 1
검증 & 저장"] B --> C["Step 2
통계 계산"] C --> D["Step 3
리포트 생성"] style A fill:#2d3748,stroke:#90cdf4,stroke-width:2px,color:#e2e8f0 style B fill:#1a202c,stroke:#68d391,stroke-width:2px,color:#e2e8f0 style C fill:#1a202c,stroke:#68d391,stroke-width:2px,color:#e2e8f0 style D fill:#1a202c,stroke:#68d391,stroke-width:2px,color:#e2e8f0

이 글은 Step 1의 코드만 보여 준다. Step 2 · Step 3의 코드와, Job 하나에 Step 셋을 묶는 코드는 다루지 않는다.


2. Step 1은 읽기 → 처리 → 쓰기 세 부품으로 돈다

Step 1 “거래 데이터 검증 & 저장”은 Spring Batch의 기본 모양인 ItemReader → ItemProcessor → ItemWriter 구조를 따른다. 읽는 부품, 검증하는 부품, 저장하는 부품을 따로 만들어 Step에 끼운다.

이 예시가 다루는 거래 한 건의 모양

아래 코드가 모두 이 Transaction 클래스를 주고받는다. 글을 짧게 하려고 필드와 get/set 메서드만 적은 모양이다.

public class Transaction {
    private Long id;        // 거래 번호
    private long amount;    // 거래 금액
    private String date;    // 거래 날짜
    private String status;  // 처리 상태 (처리 끝나면 "PROCESSED")

    // getId(), getAmount(), setStatus(...) 같은 get/set 메서드
}
  • 파일 한 줄이 이 객체 하나가 된다
  • status는 파일에 없는 칸이다. 검증을 통과한 거래에 “처리 끝남” 표시를 남기려고 둔다

ItemReader는 데이터를 한 건씩 읽는다

거래 파일은 transactions.csv라는 CSV 파일이라고 하자. 한 줄이 거래 한 건이고, 칸은 쉼표로 나뉜다. 예를 들면 1,50000,2026-01-30 같은 줄이다.

@Bean
public FlatFileItemReader<Transaction> transactionReader() {
    return new FlatFileItemReaderBuilder<Transaction>()
        .name("transactionReader")
        .resource(new FileSystemResource("transactions.csv"))
        .delimited()
        .names("id", "amount", "date")
        .targetType(Transaction.class)
        .build();
}

한 줄씩 따라가 본다.

  • @Bean — 이 메서드가 돌려주는 리더 객체를 Spring이 관리하는 빈으로 등록하라는 표시다(@Bean)
  • FlatFileItemReader — 플랫 파일(줄 단위 글자 파일)을 읽는 Spring Batch의 리더다
  • new FlatFileItemReaderBuilder<Transaction>() 부터 .build() 까지 — 설정을 점으로 이어 부르다가 마지막 build()에서 리더를 완성하는 메서드 체이닝 모양이다
  • .name("transactionReader") — 리더에 이름을 붙인다. 리더는 “몇 번째 줄까지 읽었다”를 ExecutionContext에 남기는데, 이 이름이 그 기록의 열쇠(key)를 만드는 데 쓰인다(FlatFileItemReaderBuilder 소스의 설명). 다시 돌릴 때 이어 읽을 자리를 찾는 근거가 된다
  • .resource(new FileSystemResource("transactions.csv")) — 읽을 파일을 지정한다. FileSystemResource는 “내 컴퓨터 디스크에 있는 이 파일”을 Spring이 다루는 입력 모양(Resource)으로 감싼 것이다
  • .delimited() — 한 줄을 구분자로 나눠 읽겠다는 뜻이다. 구분자를 따로 정하지 않으면 쉼표로 나눈다. 줄을 칸으로 자르는 일은 리더 안에서 DelimitedLineTokenizer라는 부품이 맡고, 그 부품의 기본 구분자가 쉼표다
  • .names("id", "amount", "date") — 나눈 칸에 앞에서부터 차례로 붙이는 이름이다. 첫 칸이 id, 둘째 칸이 amount다
  • .targetType(Transaction.class) — 한 줄을 어떤 클래스의 객체로 바꿀지 정한다. Transaction.class는 클래스 그 자체를 값으로 넘기는 클래스 리터럴이다. 리더 안의 BeanWrapperFieldSetMapper라는 부품이 names로 붙인 칸 이름을 Transaction의 같은 이름 속성에 맞춰 채운다. 파일에서 읽은 글자 "50000"을 long 같은 속성 타입으로 바꾸는 일도 이 부품이 한다(BeanWrapperFieldSetMapper 소스의 설명). private 필드에 어떤 경로로 값을 넣는지까지는 확인하지 않았다. 이 글은 get/set 메서드가 있다고 가정한다

리더는 한 번에 한 건만 읽는다. “100건씩 묶는다”는 리더가 아니라 Step이 정한다. 그 묶음이 3장에서 볼 청크다.

ItemProcessor는 한 건을 검증하고 바꾼다

@Component
public class TransactionProcessor implements ItemProcessor<Transaction, Transaction> {
    @Override
    public Transaction process(Transaction transaction) throws Exception {
        // 거래 금액 검증
        if (transaction.getAmount() <= 0) {
            throw new InvalidTransactionException("유효하지 않은 금액");
        }

        // 거래 상태 설정
        transaction.setStatus("PROCESSED");
        return transaction;
    }
}

// 이 예시가 만든 예외 클래스
public class InvalidTransactionException extends RuntimeException {
    public InvalidTransactionException(String message) {
        super(message);
    }
}
  • @Component — 이 클래스를 통째로 빈으로 만들라는 표시다(@Component). @Bean은 메서드가 만든 객체를, @Component는 클래스 자체를 빈으로 만든다
  • implements ItemProcessor<Transaction, Transaction> — 제네릭의 첫 칸은 받는 타입, 둘째 칸은 돌려주는 타입이다. 여기서는 거래를 받아 같은 거래를 돌려주니 둘이 같다. 다른 클래스로 바꿔 돌려줄 때는 둘이 달라진다
  • 금액이 0 이하이면 InvalidTransactionException을 던진다. 이 예외는 이 예시가 직접 만든 클래스다
  • 통과하면 status를 "PROCESSED"로 바꿔 돌려준다. 이 값이 DB에 같이 저장돼서, 나중에 어떤 거래가 검증을 통과했는지 알 수 있다

검증에 실패해 예외가 나면 그 뒤가 어떻게 되는지는 4장에서 본다. 결론부터 말하면, 아무 설정도 없으면 Step이 실패한다.

ItemWriter는 청크 하나를 모아 저장한다

@Bean
public RepositoryItemWriter<Transaction> transactionWriter(TransactionRepository repository) {
    RepositoryItemWriter<Transaction> writer = new RepositoryItemWriter<>();
    writer.setRepository(repository);
    writer.setMethodName("save");
    return writer;
}
  • RepositoryItemWriter — Spring Batch가 주는 라이터다. 받은 아이템을 Repository에 넘겨 저장한다
  • TransactionRepository — 거래를 DB에 저장하고 꺼내는 창구다(Repository). 이 예시에서 만들었다고 가정한 것이고, save 메서드를 가진다. 그 구현이 어디서 오는지는 이 글의 범위 밖이다
  • 메서드 매개변수 repository — @Bean 메서드에 매개변수를 적으면 Spring이 그 타입의 빈을 찾아 넣어 준다(의존성 주입)
  • writer.setRepository(repository) — 저장할 때 이 Repository를 쓰라고 알려 준다
  • writer.setMethodName("save") — Repository의 어느 메서드로 저장할지 이름(문자열)으로 정한다. 라이터는 이 이름으로 메서드를 찾아 부른다 — 이름만 가지고 메서드를 부르는 자바 기능이 리플렉션이다. 이렇게 정하면 라이터는 청크 안의 아이템마다 save를 한 번씩 부른다(RepositoryItemWriter 소스)

라이터는 아이템을 하나씩 받지 않고 청크 하나(예: 100건)를 리스트로 한꺼번에 받는다. 그리고 그 청크를 저장하는 일 전체가 트랜잭션 하나로 묶인다. 이 부분은 3장에서 이어 본다.

graph LR A["ItemReader
CSV 파일에서
거래를 한 건씩 읽음"] --> B["ItemProcessor
각 거래 검증"] B --> C{검증 결과?} C -->|성공| D["ItemWriter
청크째 DB에 저장"] C -->|실패| E["예외
(4장에서 다룸)"] style A fill:#1a202c,stroke:#90cdf4,stroke-width:2px,color:#e2e8f0 style B fill:#1a202c,stroke:#90cdf4,stroke-width:2px,color:#e2e8f0 style C fill:#2d3748,stroke:#f6ad55,stroke-width:2px,color:#e2e8f0 style D fill:#1a202c,stroke:#68d391,stroke-width:2px,color:#e2e8f0 style E fill:#1a202c,stroke:#fc8181,stroke-width:2px,color:#e2e8f0

3. 청크는 한 트랜잭션에 묶어 처리하는 건수다

여기서 중요한 개념이 청크(Chunk)다.

청크는 몇 건마다 저장하고 확정할지를 정한다

Spring Batch 공식 문서는 청크 처리를 이렇게 설명한다 — 데이터를 한 건씩 읽어 묶음(청크)을 만들고, 읽은 건수가 정해 둔 수(commit interval)에 닿으면 그 청크 전체를 ItemWriter가 쓰고 트랜잭션을 커밋한다(Chunk-oriented Processing).

청크 사이즈가 100이면 이렇게 돈다.

1차: 1~100번 거래를 한 건씩 읽고 검증 → 100건을 한꺼번에 저장 → 커밋
2차: 101~200번 거래를 한 건씩 읽고 검증 → 100건을 한꺼번에 저장 → 커밋
3차: 201~300번 거래를 한 건씩 읽고 검증 → 100건을 한꺼번에 저장 → 커밋
...

청크 안의 100건을 동시에 돌리는 것이 아니다. 한 건씩 차례로 읽고 검증한 뒤, 저장과 커밋만 100건 단위로 한 번 한다.

청크 크기는 커밋 횟수와 메모리 사이에서 고른다

1건씩 처리 (청크 사이즈 1):

거래 1: Read → Process → Write → 커밋
거래 2: Read → Process → Write → 커밋
...
거래 100만: Read → Process → Write → 커밋

나쁘다. 커밋을 100만 번 한다. 거래를 처리하는 일 말고 커밋 자체에 드는 오버헤드(덤으로 드는 시간)가 100만 번 쌓인다.

100~1000건씩 청크 처리:

청크 1: 1~100번 거래를 묶어 저장 → 커밋
청크 2: 101~200번 거래를 묶어 저장 → 커밋
...

좋다. 커밋은 1만 번(100건씩일 때)으로 줄고, 메모리에는 청크 하나만 들고 있다.

10,000건씩 처리:

한 번에 10,000건을 메모리에 들고 있다가 저장

나쁘다. 라이터에 넘기기 전까지 청크 전체를 리스트로 들고 있어서 메모리 부담이 크다. 그리고 청크 하나가 트랜잭션 하나라서, 저장 도중 실패하면 그 청크 1만 건 전체가 롤백(없던 일)된다. 다시 처리해야 할 범위가 그만큼 커진다.

청크 사이즈는 Step을 만들 때 정한다

@Bean
public Step transactionStep(FlatFileItemReader<Transaction> transactionReader,
                            TransactionProcessor transactionProcessor,
                            RepositoryItemWriter<Transaction> transactionWriter) {
    return stepBuilderFactory.get("transactionStep")
        .<Transaction, Transaction>chunk(1000)  // 1000건씩 청크 처리
        .reader(transactionReader)
        .processor(transactionProcessor)
        .writer(transactionWriter)
        .build();
}
  • 매개변수 셋 — 2장에서 만든 리더 · 프로세서 · 라이터 빈을 Spring이 찾아 넣어 준다
  • stepBuilderFactory — Step을 만드는 도구다. 이 글의 @Bean 메서드들은 모두 @Configuration이 붙은 설정 클래스 하나 안에 있다고 가정한다. stepBuilderFactory는 그 클래스의 필드로 주입받는다. Spring Batch 4까지의 방식이다. Spring Batch 5.0에서 쓰지 말라는 표시(@Deprecated)가 붙었고, 대신 new StepBuilder("transactionStep", jobRepository)로 만들고 .chunk(1000, transactionManager)처럼 트랜잭션 관리자(PlatformTransactionManager — 청크마다 트랜잭션을 열고 커밋하는 객체)를 같이 넘긴다. jobRepository와 transactionManager는 메서드 매개변수로 받는다(Spring Batch 5.0 Migration Guide)
  • .get("transactionStep") — Step 이름이다. JobRepository에 실행 기록을 남길 때 이 이름으로 남는다
  • .<Transaction, Transaction>chunk(1000) — 청크 사이즈를 1000으로 정한다. 메서드 이름 앞의 <Transaction, Transaction>은 이 메서드에 쓸 제네릭 타입(읽는 타입, 쓰는 타입)을 직접 적어 준 것이다. 앞의 설명은 세기 쉽게 100건으로 했고, 코드는 1000건으로 잡았다
  • .reader(...) · .processor(...) · .writer(...) — 세 부품을 이 Step에 끼운다

권장 청크 사이즈:

  • 한 건이 작으면 (예: 간단한 숫자 몇 개) → 1000개 이상. 한 건이 작으니 1000건을 들고 있어도 메모리가 넉넉하고, 커밋 횟수를 줄이는 이득이 크다
  • 한 건이 크면 (예: 이미지, 복잡한 객체) → 100개 이하. 청크 전체를 메모리에 들고 있으니 한 건이 클수록 건수를 줄여야 한다
  • 실제 운영 환경에서 성능 테스트 후 결정

4. 검증에 실패한 거래를 기록하고 계속 가려면 skip 설정이 먼저다

100만 개의 거래 중 몇 개가 형식이 틀렸다면? 바라는 처리는 이렇다.

한 건 오류 → 그 거래를 오류 테이블에 기록하고 다음 거래를 계속 처리

Listener만 붙여서는 Step이 멈춘다

처음에는 프로세서의 오류를 알려 주는 Listener만 붙이면 될 거라고 봤다. 안 A — ItemProcessListener로 오류를 받아 기록한다.

@Component
@RequiredArgsConstructor
public class TransactionProcessListener implements ItemProcessListener<Transaction, Transaction> {

    private final ErrorLogRepository errorLogRepository;

    @Override
    public void onProcessError(Transaction item, Exception e) {
        // 오류난 거래를 별도 테이블에 저장한다
        // (ErrorLog를 만드는 코드는 아래 안 B와 같아서 생략했다)
    }
}

ItemProcessListener에는 프로세서가 처리하기 전(beforeProcess), 성공한 뒤(afterProcess), 예외가 났을 때(onProcessError) 불리는 메서드가 있다. 얻는 것은 분명하다 — 어떤 거래가 왜 실패했는지 그 자리에서 받을 수 있다.

버린 이유는 이것만으로는 다음 거래로 넘어가지 않기 때문이다. Spring Batch의 SimpleChunkProcessor 소스를 보면, 프로세서가 예외를 던지면 onProcessError를 부른 뒤 같은 예외를 다시 던진다. Listener는 알려 주기만 하고 예외를 삼키지 않는다 — catch로 잡고 끝내 버리지 않는다는 뜻이다. 그래서 아무 설정도 없으면 그 예외가 Step을 실패로 끝낸다. 공식 문서도 예외는 보통 skip이 되거나 Step 실패가 된다고 적는다(Configuring Retry Logic).

먼저 skip을 켜야 오류 난 거래를 건너뛴다

다음 거래로 넘어가게 하는 것은 Listener가 아니라 skip 설정이다. “이 예외가 난 거래는 건너뛰고 계속 가라”를 Step에 적는다.

건너뛴 거래는 SkipListener로 기록한다

skip을 켜면 기록하는 자리도 바뀐다. 안 B — SkipListener의 onSkipInProcess에서 기록한다.

@Component
@RequiredArgsConstructor
public class TransactionSkipListener implements SkipListener<Transaction, Transaction> {

    private final ErrorLogRepository errorLogRepository;

    @Override
    public void onSkipInProcess(Transaction item, Throwable t) {
        // 건너뛴 거래를 별도 테이블에 저장
        ErrorLog errorLog = ErrorLog.builder()
            .transactionId(item.getId())
            .errorMessage(t.getMessage())
            .errorTime(LocalDateTime.now())
            .status("FAILED")
            .build();

        errorLogRepository.save(errorLog);
    }
}
  • @RequiredArgsConstructor — final 필드를 받는 생성자를 Lombok이 만들어 준다(@RequiredArgsConstructor). Spring은 그 생성자로 ErrorLogRepository 빈을 넣어 준다. 이게 없으면 final 필드에 값이 들어갈 길이 없다
  • onSkipInProcess(Transaction item, Throwable t) — 안 A의 Exception 대신 Throwable을 받는다. Throwable은 Exception과 Error의 부모 클래스다(Error 계열). 인터페이스가 그렇게 정해 두었다
  • implements SkipListener<Transaction, Transaction> — 읽기 · 처리 · 쓰기 중 어디서 skip이 났는지에 따라 onSkipInRead · onSkipInProcess · onSkipInWrite 중 하나가 불린다. 이 예시는 처리(검증) 단계의 skip만 기록한다
  • ErrorLog.builder() … .build() — 오류 기록 한 건을 만든다. ErrorLog는 5장의 테이블 한 행에 대응하는 클래스이고, builder()는 @Builder를 붙여 생긴 메서드다. 필드 이름을 하나씩 붙여 가며 값을 채운다. LocalDateTime.now()는 지금 날짜와 시각을 돌려준다(LocalDateTime). ErrorLog 클래스 본문과, transactionId 필드가 transaction_id 컬럼에 맞춰지는 설정은 이 글에서 다루지 않는다
  • errorLogRepository.save(errorLog) — 그 기록을 error_log 테이블에 저장한다

고른 이유는 안 A가 못 지켜 주는 두 가지를 안 B는 지켜 주기 때문이다. 공식 문서 Intercepting Step Execution에 따르면 이렇다.

  • 읽기 · 처리 · 쓰기 Listener의 오류 메서드는 “오류가 났다”만 알려 주고 “실제로 건너뛰었다”는 알려 주지 않는다. 문서는 쓰기 쪽 onWriteError가 다시 시도해서(retry) 결국 성공한 아이템에도 불린다고 예를 든다. 이 예시는 재시도를 설정하지 않았지만, 오류 메서드가 「건너뛰었다」를 뜻하지 않는다는 점은 안 A의 onProcessError도 같다. 오류 메서드에서 기록하면 건너뛰지 않은 거래까지 오류 테이블에 남을 수 있다
  • SkipListener는 아이템 하나에 딱 한 번 불리고, 트랜잭션을 커밋하기 바로 전, 즉 라이터가 청크를 다 쓴 뒤에 불린다. 라이터 쪽 실패는 이미 지나간 뒤라서, Listener 안에서 DB에 쓴 기록이 그 실패로 롤백되지 않는다(문서가 이 순서를 둔 이유로 적은 것이다)

대가는 설정이 하나 더 는다는 것이다. skip 설정과 SkipListener를 둘 다 Step에 적어야 한다.

Step에 skip과 Listener를 등록한다

@Bean
public Step transactionStep(FlatFileItemReader<Transaction> transactionReader,
                            TransactionProcessor transactionProcessor,
                            RepositoryItemWriter<Transaction> transactionWriter,
                            TransactionSkipListener transactionSkipListener) {
    return stepBuilderFactory.get("transactionStep")
        .<Transaction, Transaction>chunk(1000)
        .reader(transactionReader)
        .processor(transactionProcessor)
        .writer(transactionWriter)
        .faultTolerant()                             // 오류를 견디는 설정을 켠다
        .skip(InvalidTransactionException.class)     // 이 예외가 난 거래는 건너뛴다
        .skipLimit(100)                              // 100건까지만 건너뛴다
        .listener(transactionSkipListener)           // 건너뛴 거래를 기록한다
        .build();
}
  • .faultTolerant() — skip이나 재시도 같은 “오류를 견디는” 설정을 쓸 수 있게 한다. 뒤의 .skip(...) · .skipLimit(...)은 이걸 부른 다음에만 쓸 수 있다
  • .skip(InvalidTransactionException.class) — 2장의 프로세서가 던지는 그 예외가 나면 그 거래 하나를 건너뛴다
  • .skipLimit(100) — 건너뛸 수 있는 최대 건수다. 넘으면 그때는 Step이 실패한다. 형식이 틀린 거래가 수천 건이라면 그건 거래 몇 건의 문제가 아니라 파일 자체가 잘못된 것이니 멈추는 게 맞다. 100은 이 예시에서 정한 값이다
  • .listener(transactionSkipListener) — 위에서 만든 SkipListener를 이 Step에 등록한다

faultTolerant() · skip(Class) · skipLimit(int) · listener(Object)는 Spring Batch 4.3 소스와 5.1 소스의 FaultTolerantStepBuilder 양쪽에서 확인했다. stepBuilderFactory를 쓰는 위 코드는 4.x 방식이고, 5.x에서는 앞에서 말한 StepBuilder로 시작해 같은 메서드를 이어 부른다.

오류가 난 거래는 이렇게 흘러간다

graph LR A["ItemReader
거래 읽음"] --> B["ItemProcessor
검증"] B --> C{검증
성공?} C -->|YES| D["ItemWriter
청크째 저장"] C -->|NO| E{skip 설정에
걸리는 예외?} E -->|YES| F["SkipListener
onSkipInProcess
ErrorLog 테이블에 기록"] E -->|NO| H["Step 실패"] D --> G["다음 청크 처리"] F --> I["같은 청크의
다음 거래 처리"] I --> B style A fill:#1a202c,stroke:#90cdf4,stroke-width:2px,color:#e2e8f0 style B fill:#1a202c,stroke:#90cdf4,stroke-width:2px,color:#e2e8f0 style C fill:#2d3748,stroke:#f6ad55,stroke-width:2px,color:#e2e8f0 style D fill:#1a202c,stroke:#68d391,stroke-width:2px,color:#e2e8f0 style E fill:#2d3748,stroke:#f6ad55,stroke-width:2px,color:#e2e8f0 style F fill:#1a202c,stroke:#fc8181,stroke-width:2px,color:#e2e8f0 style H fill:#1a202c,stroke:#fc8181,stroke-width:2px,color:#e2e8f0 style I fill:#2d3748,stroke:#90cdf4,stroke-width:2px,color:#e2e8f0 style G fill:#2d3748,stroke:#90cdf4,stroke-width:2px,color:#e2e8f0

5. 오류 로그 테이블은 실패한 거래를 다시 찾게 해 준다

4장의 Listener가 기록을 남길 테이블은 이렇게 만든다. 아래는 테이블 구조를 정하는 SQL 문(DDL)이다.

CREATE TABLE error_log (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    transaction_id BIGINT NOT NULL,
    error_message VARCHAR(500),
    error_type VARCHAR(100),
    error_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    status VARCHAR(50),
    retry_count INT DEFAULT 0,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

한 줄씩 따라가 본다.

  • CREATE TABLE error_log (...) — error_log라는 테이블을 만든다. 괄호 안 한 줄이 컬럼 하나다
  • id BIGINT PRIMARY KEY AUTO_INCREMENT — BIGINT는 8바이트 정수 타입이다(자바의 long과 같은 크기). 이 id가 행 하나하나를 구별하는 PK이고, 새 행을 넣을 때마다 DB가 번호를 1씩 늘려 채운다(AUTO_INCREMENT)
  • transaction_id BIGINT NOT NULL — 어떤 거래가 실패했는지 적는다. 비워 두고는 저장할 수 없다(NOT NULL 제약)
  • VARCHAR(500) — 최대 500자까지 담는 문자열 타입이다(VARCHAR)
  • TIMESTAMP DEFAULT CURRENT_TIMESTAMP — TIMESTAMP는 날짜와 시각을 담는 타입이다. DEFAULT는 행을 넣을 때 그 컬럼 값을 안 주면 대신 넣을 값이고, CURRENT_TIMESTAMP는 넣는 그 순간의 시각이다
  • INT DEFAULT 0 — 정수 타입이고, 값을 안 주면 0이 들어간다

각 컬럼의 의미:

  • transaction_id: 어떤 거래가 실패했는지
  • error_message: 왜 실패했는지
  • error_type: VALIDATION_ERROR, DB_ERROR 등 분류
  • retry_count: 재처리 시도 횟수 추적
  • status: FAILED, RETRYING, RESOLVED 등
  • error_time: 오류가 난 시각. Listener가 LocalDateTime.now()로 채운다
  • created_at: 이 행을 테이블에 넣은 시각. 값을 안 주니 DB가 채운다. 이 예시에서는 error_time과 거의 같은 값이 된다

4장의 Listener가 채우는 것은 transaction_id · error_message · error_time · status(FAILED)뿐이다. error_type은 비어 있고 retry_count는 기본값 0으로 남는다. 이 두 컬럼과 status를 RETRYING · RESOLVED로 바꾸는 일은 이 글의 코드가 하지 않는다. 실패한 거래를 나중에 다시 처리하는 쪽(사람이나 다른 배치)이 채우라고 미리 만들어 둔 칸이다.

이렇게 해 두면 이런 것을 할 수 있다.

  • 통계: 처리된 거래와 실패한 거래를 비교한다
  • 재처리: 실패한 거래만 골라서 다시 처리한다
  • 추적: 어떤 거래가 언제, 왜 실패했는지 남는다

6. 새벽 2시 실행부터 Job 완료까지 한 번에 본다

매일 새벽 2시, 스케줄러가 Job을 실행
  ↓
Job 시작: 거래 내역 일괄 처리
  ↓
Step 1: 거래 데이터 검증 & 저장
  ├─ ItemReader: CSV에서 한 건씩 읽어 1000건씩 청크로 묶음
  ├─ ItemProcessor: 각 거래 검증
  │   ├─ 성공 → ItemWriter가 청크째 DB 저장
  │   └─ 실패 → skip → SkipListener가 ErrorLog 테이블에 기록
  ↓
Step 2: 통계 계산
  ├─ 전체 거래 수
  ├─ 성공한 거래 수
  ├─ 실패한 거래 수
  ↓
Step 3: 최종 리포트 생성
  ├─ CSV로 내보내기
  └─ 관리자에게 메일 발송
  ↓
Job 완료
  • 새벽 2시에 Job을 실행시키는 것은 Spring Batch가 아니다. Spring Batch 공식 문서는 Spring Batch가 스케줄링 프레임워크가 아니고, 스케줄러와 함께 쓰도록 만들었다고 적는다(Spring Batch Introduction). Quartz 같은 스케줄러가 시각에 맞춰 Job을 부른다
  • Step 2 · Step 3, 그리고 메일 발송은 흐름만 적었다. 이 글은 그 코드를 다루지 않는다

7. Spring Batch가 직접 짠 반복문보다 나은 점을 표로 모은다

기능 직접 짠 반복문 Spring Batch
메모리 효율성 한 번에 다 읽어 들이기 쉽다 청크 하나만 메모리에 든다
에러 처리 하나 실패 → 반복문 전체 중단 skip 설정으로 일부 실패해도 계속 진행
재시작 처음부터 다시 처리 완료된 Step은 건너뛰고, 리더는 기록해 둔 자리부터 이어 읽는다
모니터링 직접 구현 필요 JobRepository가 실행 기록을 남긴다
스케줄링 스케줄러 필요 여기도 스케줄러 필요 (Spring Batch는 스케줄러가 아니다)

결론: 핵심 개념 요약

개념 설명 예시
Job 전체 작업 단위 거래 처리 전체 과정
Step 작업 안의 개별 단계 검증&저장, 통계, 리포트
ItemReader 데이터를 한 건씩 읽기 CSV 파일에서 거래 읽음
ItemProcessor 데이터 검증/변환 거래 금액 유효성 검증
ItemWriter 청크째 저장 DB에 거래 정보 저장
Chunk 한 트랜잭션에 묶어 저장 · 커밋하는 건수 1000건씩 묶어서 처리
skip 정해 둔 예외가 난 아이템을 건너뛰는 설정 금액이 0 이하인 거래를 건너뜀
Listener 정해진 순간(처리 전 · 오류 · skip 등)에 Spring Batch가 불러 주는 메서드(콜백) skip된 거래를 ErrorLog에 저장

다음에 배울 것은 JobRepository와 재시도다

이제 기본 개념은 잡았다. 다음에 배울 것은 이렇다.

  • 지금 배운 것: Job, Step, Reader/Processor/Writer, Chunk, skip, Listener
  • 다음에 배울 것: JobRepository, ExecutionContext, 스케줄러 연동, 재시도(retry)

나는 거래 처리를 예로 들었다. 다른 실무 사례가 있다면 댓글로 알려 주면 좋겠다.


자신만의 철학을 만들어가는 중입니다.
최상단으로 이동했습니다!
확대 이미지

댓글남기기