| 구분 | 온라인 트랜잭션(OLTP) | 배치 |
|---|---|---|
| 트리거 | 사용자 요청(클릭, API 호출) | 스케줄(매일 02:00), 파일 도착, 수동 실행 |
| 처리 단위 | 요청 1건 = 트랜잭션 1건 | 수십만~수억 건을 묶음으로 |
| 응답 시간 | 수십~수백 ms, 사람이 기다림 | 분~시간, 아무도 기다리지 않음 |
| 실패 시 | 사용자에게 오류 화면, 재시도는 사용자 몫 | 운영자가 새벽에 호출됨. 재시작 설계가 필수 |
| 자원 사용 | 짧고 잦음 | 길고 무거움. DB/디스크를 오래 점유 |
| 성능 관심사 | 지연 시간(latency) | 처리량(throughput), 메모리 |
| 대표 코드 | Controller → Service → Repository | Reader → Processor → Writer |
온라인 코드에서 "for 문으로 100만 번 DB 조회"는 상상하기 어렵지만, 배치에서는 그것이 본업입니다. 그래서 배치 코드는 같은 언어를 써도 설계 감각이 다릅니다. 온라인은 "이 한 건을 얼마나 빨리 끝내나", 배치는 "전체를 얼마나 안전하게, 메모리 안 터지고, 중간에 죽어도 이어서 끝내나"를 봅니다.
| 사례 | 입력 | 처리 | 출력 | 특징 |
|---|---|---|---|---|
| 일마감 정산 | 당일 거래 내역 수백만 건 | 가맹점별 합산, 수수료 계산 | 정산 테이블, 은행 전송 파일 | 금액 정확성, 재실행 시 이중 정산 금지 |
| 대량 알림/메일 | 대상 회원 목록 | 템플릿 치환, 발송 API 호출 | 발송 결과 로그 | 외부 API 실패 → 재시도, 중복 발송 금지 |
| 데이터 마이그레이션 | 구 시스템 테이블 | 스키마 변환, 코드 매핑 | 신 시스템 테이블 | 수억 건, 검증 리포트 필수 |
| 리포트 생성 | 집계 대상 데이터 | 통계 계산 | Excel/CSV/PDF | 메모리 관리(전체를 한 번에 안 올리기) |
| 정합성 검증 | 두 시스템의 잔액/재고 | 키 기준 비교 | 불일치 목록 | 대량 조인, 차이 발생 시 알림 |
| 회원 등급 재산정 | 회원 + 1년 구매 이력 | 등급 규칙 적용 | 회원 등급 UPDATE | 매일 새벽, 전체 회원 대상 |
| 미납 알림 대상 추출 | 청구서 테이블 | 미납 & 기한 초과 필터 | 알림 큐 | 대부분 필터링되어 출력이 입력보다 훨씬 적음 |
공통점이 보입니다. 입력 → 가공 → 출력의 3단계이고, 입력이 매우 크고, 재실행이 안전해야 합니다.
List<Row> all = repository.findAll() 은 배치에서 금기어입니다.가장 단순한 배치 코드는 이렇습니다.
for (String line : Files.readAllLines(path)) { // 읽기
Member m = parse(line);
m.setGrade(calc(m)); // 가공
db.update(m); // 쓰기
}이 코드에는 세 가지 문제가 있습니다.
readAllLines 가 파일 전체를 메모리에 올립니다. 100만 줄이면 수백 MB, 1000만 줄이면 OOM 입니다.db.update 를 호출합니다. DB 왕복이 100만 번입니다. 묶어서 보내면 100배 빨라집니다.그래서 역할을 셋으로 나눕니다.
┌──────────────┐ 1건 ┌──────────────┐ 1건 ┌──────────────┐
│ ItemReader │ ────────▶ │ ItemProcessor│ ────────▶ │ (chunk 버퍼) │
│ read(): T │ │ process(I):O │ │ List<O> │
└──────────────┘ └──────────────┘ └──────┬───────┘
파일/DB/API 변환, 검증, │ N건 모이면
한 건씩 꺼냄 필터(null) ▼
┌──────────────┐
│ ItemWriter │
│ write(List) │
└──────────────┘
N건 한 번에 저장
= 커밋 포인트| 역할 | 계약 | 왜 이 형태인가 |
|---|---|---|
ItemReader<T> |
T read() — 다음 한 건, 끝이면 null |
"다음 하나를 달라". 전체 크기와 무관하게 메모리가 일정 |
ItemProcessor<I,O> |
O process(I item) — null 이면 버림 |
입력·출력 타입이 달라도 됨. null 반환이 곧 필터 |
ItemWriter<T> |
void write(List<? extends T> items) |
묶음을 받는다. batch insert·flush·bulk 호출이 효율적 |
Reader 는 "한 건씩", Writer 는 "묶음으로"라는 비대칭이 핵심입니다. 읽기는 메모리 때문에 한 건씩, 쓰기는 I/O 효율 때문에 묶음으로. 그 사이에서 묶음을 만드는 것이 청크(chunk)입니다.
Job (dailyMemberJob)
├── Step 1 (regradeMembers) reader → processor → writer, chunk=100
│ ├── chunk #1 (100건 읽고 → 가공 → 한 번에 쓰기 → 커밋)
│ ├── chunk #2
│ └── ...
├── Step 2 (extractUnpaid) chunk=200
└── Step 3 (sendNotifications)| 개념 | 의미 | 이 레슨의 구현 | Spring Batch |
|---|---|---|---|
| Job | 하나의 배치 작업 전체. 이름과 실행 이력을 가짐 | JobRunner |
Job, JobLauncher |
| Step | Job 을 구성하는 독립 단계. 순서대로 실행, 하나 실패하면 중단 | Step<I,O> |
Step, TaskletStep |
| Chunk | Step 안에서 커밋 단위가 되는 N건 묶음 | Step 의 chunkSize |
chunk(100) |
| ItemReader/Processor/Writer | 위 2.4 | 동일 이름 인터페이스 | 동일 이름 |
| StepExecution | Step 한 번의 실행 통계 | StepExecution record |
StepExecution + BATCH_STEP_EXECUTION 테이블 |
| JobExecution | Job 한 번의 실행 통계 | JobRunner.JobExecution |
JobExecution + BATCH_JOB_EXECUTION 테이블 |
Spring Batch 는 여기에 메타데이터를 DB 테이블에 자동 저장하고, 재시작·스킵·재시도·병렬화를 설정으로 제공합니다. 하지만 구조 자체는 위 표 그대로입니다. 직접 만든 골격을 이해하면 Spring Batch 문서가 "우리가 만든 것의 확장판"으로 읽힙니다.
배치는 사람이 보고 있지 않을 때 실행됩니다. 아침에 출근해서 확인할 수 있는 것은 오직 기록뿐입니다.
| 항목 | 없으면 생기는 일 |
|---|---|
| 시작/종료 시각 | "어제보다 2시간 늦게 끝났는데 왜?" 에 답 못함. 실행 시간 추세를 볼 수 없어 언젠가 시간 창을 넘김 |
| 읽은 건수 / 쓴 건수 | 입력 100만, 출력 90만이면 10만 건이 필터인지 유실인지 모름 |
| 상태(STARTED/COMPLETED/FAILED) | STARTED 로 남아 있으면 "아직 실행 중"인지 "죽었는데 기록 못 함"인지 구분. 이중 실행 방지에도 사용 |
| 종료 메시지(예외) | 실패 원인을 로그 파일 수 GB 에서 찾아야 함 |
| 커밋 횟수 / 마지막 커밋 위치 | 재시작 불가. 처음부터 다시 |
StepExecution 을 record 로 만든 이유는 이것이 불변의 실행 기록이기 때문입니다. 실행이 끝난 뒤 값이 바뀌면 안 되고, toString/equals 가 자동으로 생기며, DB 한 행에 그대로 대응됩니다.
배치는 누군가가 정해진 시각에 실행해 줘야 합니다.
| 방법 | 예 | 비고 |
|---|---|---|
| Linux cron | 0 2 * * * /opt/batch/run.sh daily |
가장 흔함. 로그 리다이렉트와 실패 알림은 스크립트가 책임 |
| Windows 작업 스케줄러 | 트리거: 매일 02:00, 동작: java -jar batch.jar daily |
GUI 설정, 이력은 이벤트 뷰어 |
| 애플리케이션 내 스케줄러 | Spring @Scheduled, Quartz |
서버 여러 대면 중복 실행 주의(분산 락 필요) |
| 파일 도착 트리거 | 특정 폴더 감시(WatchService) |
외부 기관 파일 수신 시 |
| 워크플로 도구 | Airflow, Jenkins | Job 간 의존관계, 재실행 UI |
실행 시간 창(batch window)은 배치가 돌아도 되는 시간대입니다. 새벽 2시~5시라면 3시간 안에 끝나야 하고, 처리량이 하루 5% 씩 늘면 몇 달 뒤 창을 넘깁니다. 그래서 메타데이터에 소요 시간을 남기고 추세를 봐야 합니다. 또 같은 시각에 여러 배치가 몰리면 DB 가 느려지므로 순서와 의존관계를 설계합니다(정산 배치는 거래 마감 배치 이후에).
cron 표현식 한 줄만 기억합시다.
┌──────── 분 (0-59)
│ ┌────── 시 (0-23)
│ │ ┌──── 일 (1-31)
│ │ │ ┌── 월 (1-12)
│ │ │ │ ┌ 요일 (0-6, 0=일)
0 2 * * * 매일 02:00
0 3 1 * * 매월 1일 03:00
*/10 * * * * 10분마다새 배치를 만들기 전에 답해야 할 질문입니다.
| # | 질문 | 관련 레슨 |
|---|---|---|
| 1 | 입력은 무엇이고 최대 몇 건인가? 메모리에 다 올릴 수 있는가? (거의 항상 "아니오"라고 가정) | 02, 03 |
| 2 | 커밋 단위(청크 크기)는 얼마인가? 청크 하나가 실패하면 무엇이 롤백되는가? | 03, 04 |
| 3 | 중간에 죽으면 어디서부터 재시작하는가? 그 위치를 어디에 기록하는가? | 04 |
| 4 | 같은 입력으로 두 번 실행하면 결과가 같은가? (멱등성) | 04 |
| 5 | 한 건의 데이터 오류에 전체가 죽어야 하는가, 건너뛰어야 하는가? 건너뛴 건은 어디에 남기는가? | 05 |
| 6 | 외부 시스템(API, 타 DB) 호출이 있는가? 실패 시 재시도 정책은? | 05 |
| 7 | 실행 시간 창은? 예상 소요 시간은? 병렬화가 필요한가? | 03 |
| 8 | 시작/종료/건수/상태를 어디에 기록하고 누가 보는가? 실패 시 알림은? | 01 |
| 9 | 이중 실행(어제 배치가 안 끝났는데 오늘 배치 시작)을 어떻게 막는가? | 01, 04 |
| 10 | 입력 파일 인코딩, 날짜 형식, 구분자는 확정되었는가? | 02 |