매일 새벽 2시, 은행 시스템은 수백만 개의 거래 내역을 처리한다.
이 일을 평소 짜던 웹 애플리케이션 코드 그대로 처리하면 어떻게 될까. 웹 애플리케이션 코드는 요청 하나에 필요한 만큼의 데이터만 다루도록 짠다. 그 방식으로 거래 100만 건을 한 번에 다루면 이런 일이 생긴다.
- 메모리 부족으로 멈춘다. 100만 건을 리스트 하나에 다 읽어 들이면 그 리스트가 메모리를 다 차지한다
- 오류 하나로 전체가 실패한다.
for문 안에서 한 건이 예외를 던지면 반복문이 거기서 끝난다 - 다시 돌리면 처음부터 한다. 어디까지 처리했는지 따로 적어 두지 않았으니 알 길이 없다
- 진행 상황을 알기 어렵다. 지금 몇 건째인지 남겨 둔 기록이 없다
Spring Batch는 이런 배치 작업을 안전하게 돌리려고 만든 틀이다. 그런데 처음 배우면 Job, Step, ItemReader, Listener처럼 낯선 말이 한꺼번에 쏟아진다.
이 글에서는 거래 내역 일괄 처리라는 예시 하나로 Spring Batch의 핵심 개념을 차례로 짚는다.
1. Job은 전체 작업이고 Step은 그 안의 단계다
Job은 “전체 작업의 묶음”이다
매일 새벽 2시에 거래 내역을 처리하는 과정을 떠올려 보자.
- 은행 시스템에서 거래 파일 수신
- 파일을 읽어서 데이터 검증
- 데이터베이스에 저장
- 통계 계산
- 최종 리포트 생성
이 “전체 거래 처리 과정”을 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은 다시 하지 않는다
거래 처리"] --> 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장에서 이어 본다.
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로 시작해 같은 메서드를 이어 부른다.
오류가 난 거래는 이렇게 흘러간다
거래 읽음"] --> 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)
나는 거래 처리를 예로 들었다. 다른 실무 사례가 있다면 댓글로 알려 주면 좋겠다.
자신만의 철학을 만들어가는 중입니다.
댓글남기기