Spring Batch 입문 23편. ItemWriter 인터페이스 단 1개 메서드 write(Chunk) 의 contract — chunk 단위 batched write, empty chunk graceful 처리, flush 시점, transactional / idempotent writer 설계 패턴까지 정리한 학습 노트.
이 글은 Spring Batch 입문에서 운영까지 시리즈 48편 중 23편이에요. 22편 의 ItemReader 와 한 쌍 — ItemWriter 의 인터페이스 contract.
ItemWriter — 역시 단 1개의 메서드
public interface ItemWriter<T> {
void write(Chunk<? extends T> items) throws Exception;
}
ItemReader 와 구조가 대칭인데 차이점은 두 가지예요. 먼저 반환값이 없어요 (void) — Reader 가 item 을 돌려주는 역할이라면 Writer 는 외부로 내보내는 쪽이라 굳이 반환할 게 없죠. 그리고 chunk 단위로 받아요 (Chunk<? extends T>) — Reader 는 한 건씩 읽지만 Writer 는 모인 묶음을 한 번에 처리합니다.
write() 의 5가지 Contract
Contract 1: Chunk 단위 처리
public void write(Chunk<? extends T> items) throws Exception {
for (T item : items) {
// 처리
}
}
Chunk (commit-interval 만큼 모인 items 묶음) 의 크기는 12편의 chunk(100, tx) 에서 지정한 100 이 그대로 들어와요. commit-interval 은 한 transaction 안에 묶을 item 개수를 가리킵니다.
Writer 가 한 transaction 안에서 100건을 일괄 처리하면 DB·외부 시스템 호출 횟수가 줄고 throughput 도 그만큼 올라가요.
Contract 2: Empty Chunk Graceful 처리
// items 가 빈 chunk 일 수 있음
public void write(Chunk<? extends T> items) {
if (items.isEmpty()) {
return; // 자연스러운 종료
}
// ...
}
빈 chunk 가 들어오는 경우는 두 가지예요. 첫째, ItemProcessor (read 와 write 사이에서 변환·필터링을 담당) 가 모든 item 을 걸러내서 process 가 null 만 돌려준 경우. 둘째, fault-tolerant Step (예외 발생 시 skip·retry 로 복구하는 Step) 에서 chunk 안의 모든 item 이 skip 처리된 경우.
Spring Batch 는 빈 chunk 라도 write() 를 호출해요. 그러니 Writer 는 비어 있을 때 graceful 하게 빠져나가도록 짜야 하고, 빈 입력을 받았다고 예외를 던져서는 안 됩니다.
Contract 3: Flush 시점
public void write(Chunk<? extends Foo> items) {
Session session = sessionFactory.getCurrentSession();
for (Foo item : items) {
session.save(item);
}
session.flush(); // ★ chunk 끝에 flush
}
write() 안에서 여러 번 add·persist 한 뒤 마지막에 flush (쌓아둔 SQL 을 DB 로 한 번에 내보내기) 를 호출하는 게 batch insert 최적화의 핵심이에요.
If writing to a Hibernate DAO, multiple calls to write can be made, one for each item. The writer can then call flush on the hibernate session before returning. — 공식 reference
DAO (Data Access Object, DB 접근 전용 객체) 가 Hibernate·JPA·JDBC 어느 쪽이든 batch update 패턴은 동일합니다.
Contract 4: 예외 = write 실패 → rollback
write() 에서 예외가 던져지면 현재 chunk 의 transaction 이 rollback 돼요. 그 뒤의 처리는 14편 skip 로직, 15편 retry 로직, 18편 SkipListener 가 맡습니다.
Contract 5: 외부 시스템 호출의 멱등성 책임
Writer 는 실패 후 재시도가 일어날 수 있고, 같은 chunk 가 동일한 item 들로 다시 write 되기도 해요.
그래서 Writer 는 idempotent (같은 호출을 여러 번 해도 결과가 같은 성질) 하게 설계하는 걸 권장합니다.
- DB INSERT 보다 UPSERT 또는 INSERT ON CONFLICT
- 외부 API 호출 시 idempotency key
- 파일 append 보다 position 기반 쓰기
여기서 UPSERT (있으면 UPDATE, 없으면 INSERT) 와 idempotency key (같은 요청을 식별해 중복 처리 막는 키) 가 멱등성을 잡아주는 핵심 도구예요.
ItemWriter 의 세 부류
부류 1: Flat File Writer
@Bean
public FlatFileItemWriter<Customer> customerWriter() {
return new FlatFileItemWriterBuilder<Customer>()
.name("customerWriter")
.resource(new FileSystemResource("out.csv"))
.delimited()
.delimiter(",")
.names("id", "name", "email")
.build();
}
대표는 FlatFileItemWriter, 자세한 설명은 30편에서 다뤄요.
부류 2: XML Writer
@Bean
public StaxEventItemWriter<Customer> xmlWriter() {
return new StaxEventItemWriterBuilder<Customer>()
.name("xmlWriter")
.resource(new FileSystemResource("out.xml"))
.marshaller(jaxbMarshaller())
.rootTagName("customers")
.build();
}
대표는 StaxEventItemWriter, marshaller 는 JAXB (자바 객체-XML 변환 표준) 같은 변환기를 끼워 넣어요. 31편에서 깊이 들어갑니다.
부류 3: Database Writer
@Bean
public JdbcBatchItemWriter<Order> orderWriter(DataSource ds) {
return new JdbcBatchItemWriterBuilder<Order>()
.dataSource(ds)
.sql("INSERT INTO orders (id, amount) VALUES (:id, :amount)")
.beanMapped()
.build();
}
대표는 JdbcBatchItemWriter·JpaItemWriter·HibernateItemWriter·MongoItemWriter 네 가지, 34편에서 풀어요.
Writer 직접 구현 예제
단순 console writer
public class ConsoleItemWriter<T> implements ItemWriter<T> {
@Override
public void write(Chunk<? extends T> items) {
for (T item : items) {
System.out.println(item);
}
}
}
테스트나 debug 용으로 가볍게 쓰는 형태예요.
Logger writer
public class LoggingItemWriter<T> implements ItemWriter<T> {
private static final Logger log = LoggerFactory.getLogger(LoggingItemWriter.class);
@Override
public void write(Chunk<? extends T> items) {
log.info("Writing {} items", items.size());
items.forEach(item -> log.debug("Item: {}", item));
}
}
Idempotent DB writer
public class UpsertCustomerWriter implements ItemWriter<Customer> {
private final JdbcTemplate jdbc;
@Override
public void write(Chunk<? extends Customer> items) {
if (items.isEmpty()) return;
jdbc.batchUpdate("""
INSERT INTO customers (id, name, email)
VALUES (?, ?, ?)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
email = EXCLUDED.email
""", items.getItems(), 100, (ps, customer) -> {
ps.setLong(1, customer.getId());
ps.setString(2, customer.getName());
ps.setString(3, customer.getEmail());
});
}
}
ON CONFLICT 절이 멱등성을 잡아줘서 재시도 때 중복 INSERT 예외가 나지 않아요.
CompositeItemWriter — 여러 Writer 묶기
@Bean
public CompositeItemWriter<Customer> compositeWriter(
JdbcBatchItemWriter<Customer> dbWriter,
FlatFileItemWriter<Customer> fileWriter) {
CompositeItemWriter<Customer> composite = new CompositeItemWriter<>();
composite.setDelegates(List.of(dbWriter, fileWriter));
return composite;
}
같은 chunk 를 DB 와 파일에 동시에 저장하는 패턴이에요.
함정 — Delegate ItemStream 등록
22편 Delegate Pattern (실제 작업을 다른 객체에 위임하는 패턴) 의 함정이 그대로 적용돼요. CompositeItemWriter 자체는 delegate (위임받는 실제 작업자) 의 ItemStream (Step 라이프사이클 중 상태를 저장·복원하는 인터페이스) 메서드를 자동으로 전파하는데, 그러려면 각 delegate 가 ItemStream 을 직접 구현하고 있어야 합니다.
FlatFileItemWriter 와 JdbcBatchItemWriter 는 모두 ItemStream 을 구현하니 자동으로 잡혀요. 다만 custom writer 가 ItemStream 을 구현하지 않았다면 .stream() 으로 직접 등록해야 합니다.
ClassifierCompositeItemWriter — 조건 분기
조건에 따라 다른 Writer 로 라우팅하는 구조예요.
@Bean
public ClassifierCompositeItemWriter<Order> classifierWriter(
JdbcBatchItemWriter<Order> regularWriter,
JdbcBatchItemWriter<Order> priorityWriter) {
Classifier<Order, ItemWriter<? super Order>> classifier = order ->
order.isPriority() ? priorityWriter : regularWriter;
ClassifierCompositeItemWriter<Order> writer = new ClassifierCompositeItemWriter<>();
writer.setClassifier(classifier);
return writer;
}
각 item 을 Classifier (조건 별 delegate 를 골라주는 분류기) 가 검사해서 어울리는 delegate Writer 로 넘기는 거예요. type 별로 처리 경로를 가르는 split-by-type 패턴의 표준입니다.
Writer 의 thread-safety
표준 ItemWriter 는 대부분 thread-safe (여러 스레드가 동시에 호출해도 안전) 해요. Reader 와 다른 점입니다.
JdbcBatchItemWriter✓ (각 chunk 가 다른 thread 라도 안전)JpaItemWriter✓FlatFileItemWriter— synchronized internally
multi-threaded Step 에서 Writer 쪽은 대체로 안심해도 되지만, custom writer 만큼은 thread-safety 를 명시적으로 점검해야 해요.
자주 만나는 사고
사고 1: Empty chunk 예외
write() 진입 시 items 가 비었는데 items.get(0) 같은 호출이 들어가서 터지는 케이스예요. if (items.isEmpty()) return; 가드 한 줄이면 해결됩니다.
사고 2: 재시도 시 중복 INSERT
Writer 가 단순 INSERT 라 retry 가 일어나면 PK (Primary Key, 행을 유일하게 식별하는 키) 충돌이 나는 경우. UPSERT (ON CONFLICT·MERGE INTO) 나 idempotency key 로 풀어요.
사고 3: Hibernate batch insert 가 1건씩
flush 호출을 빼먹었거나 hibernate.jdbc.batch_size 설정을 안 잡아둔 게 원인이에요. write() 끝에 session.flush() 를 박고 Hibernate property 를 같이 맞춰주면 됩니다.
사고 4: FlatFileItemWriter 가 파일 안 만들어짐
ItemStream 이 등록되지 않아서 open 이 호출되지 않은 거예요. .writer(writer) 로 직접 등록하면 자동으로 잡히고, 그게 아니라면 .stream(writer) 로 명시해줘야 합니다.
사고 5: ClassifierCompositeItemWriter 의 delegate 누락
Classifier 가 모든 케이스를 커버하지 못해서 null 을 돌려주면 예외로 이어져요. default delegate 를 설정해두거나 모든 case branch 를 채우는 게 안전합니다.
사고 6: CompositeItemWriter 의 부분 실패
Delegate 1 (DB) 은 성공했는데 Delegate 2 (File) 가 실패해서 chunk 가 rollback 되는 상황. DB transaction 은 되돌릴 수 있지만 파일은 이미 쓰여 있죠. File writer 를 별도 Step 으로 빼서 post-commit 으로 옮기는 게 일반적이에요. 2-phase commit (XA — eXtended Architecture, 분산 transaction 표준) 도 가능은 하지만 권장하지 않습니다.
ItemWriter 의 transaction 위치
─── chunk 시작 (transaction 시작) ───
read → process → ... (chunk 채워짐)
write(items) ← transaction 안
→ DB INSERT
→ JdbcBatchItemWriter flush
─── transaction commit ───
Writer 의 모든 작업은 현재 chunk transaction 안에서 벌어져요. 외부 시스템 호출도 rollback 영향을 받습니다 — 외부 시스템 자체가 되돌려지지는 않지만 Spring Batch 의 transaction 단위에 묶여 있어서 retry 가 일어나면 재호출이 발생하죠.
그러니 외부 API 호출은 멱등성과 재시도를 같이 설계해야 합니다.
운영 권장 패턴
Pattern 1: 표준 JdbcBatchItemWriter
@Bean
public JdbcBatchItemWriter<Customer> customerWriter(DataSource ds) {
return new JdbcBatchItemWriterBuilder<Customer>()
.dataSource(ds)
.sql("""
INSERT INTO customers (id, name, email)
VALUES (:id, :name, :email)
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name
""")
.beanMapped()
.build();
}
JDBC batch 와 UPSERT 조합이 가장 흔한 운영 패턴이에요.
Pattern 2: 파일 + DB 동시 출력
@Bean
public CompositeItemWriter<Order> dualWriter(
JdbcBatchItemWriter<Order> dbWriter,
FlatFileItemWriter<Order> auditFileWriter) {
CompositeItemWriter<Order> composite = new CompositeItemWriter<>();
composite.setDelegates(List.of(dbWriter, auditFileWriter));
return composite;
}
DB 저장과 audit log 파일 기록을 한 chunk 에서 같이 가져가는 구조입니다.
Pattern 3: Type-based 분기
@Bean
public ClassifierCompositeItemWriter<Event> eventWriter(
ItemWriter<Event> highPriorityWriter,
ItemWriter<Event> normalWriter) {
Classifier<Event, ItemWriter<? super Event>> classifier =
event -> "HIGH".equals(event.getPriority()) ? highPriorityWriter : normalWriter;
ClassifierCompositeItemWriter<Event> writer = new ClassifierCompositeItemWriter<>();
writer.setClassifier(classifier);
return writer;
}
priority 나 type 별로 처리 경로를 갈라요.
Pattern 4: 외부 API Writer with retry
public class ExternalApiWriter implements ItemWriter<Notification> {
private final ApiClient client;
@Override
public void write(Chunk<? extends Notification> items) {
for (Notification n : items) {
// idempotency key 로 중복 호출 안전
client.send(n.getId(), n);
}
}
}
외부 API 호출에 idempotency key 를 얹는 형태고, 15편 retry 로직과 같이 쓰는 걸 권장합니다.
Pattern 5: 메트릭 수집 wrapper
public class MeteredItemWriter<T> implements ItemWriter<T> {
private final ItemWriter<T> delegate;
private final MeterRegistry registry;
@Override
public void write(Chunk<? extends T> items) throws Exception {
Timer.Sample sample = Timer.start(registry);
try {
delegate.write(items);
registry.counter("batch.writer.success").increment(items.size());
} catch (Exception e) {
registry.counter("batch.writer.failure").increment();
throw e;
} finally {
sample.stop(registry.timer("batch.writer.duration"));
}
}
}
성능과 실패 메트릭을 자동으로 수집하는 wrapper 라 Observability (시스템 내부 상태를 외부 신호로 파악하는 능력) 를 다루는 45편과 결합해서 씁니다.
시험 직전 한 번 더 — ItemWriter 함정 압축 노트
- ItemWriter 인터페이스 =
write(Chunk<? extends T>)메서드 1개, return void - chunk 단위 처리 (Reader 와 차이) — commit-interval 만큼 묶인 items
- Empty chunk graceful 처리 필수 —
items.isEmpty()가드, 예외 throw 금지 - empty 케이스 = ItemProcessor 모두 filter / fault-tolerant 모두 skip
- 여러 add 후 마지막에 flush 패턴 — Hibernate session·JDBC batch
- 예외 throw = chunk transaction rollback
- idempotent 설계 권장 — UPSERT·idempotency key·position 기반 쓰기
- 세 부류 = Flat File · XML · Database
- 대표 —
FlatFileItemWriter·StaxEventItemWriter·JdbcBatchItemWriter·JpaItemWriter·MongoItemWriter CompositeItemWriter= 여러 Writer 묶기, delegate 모두 ItemStream 구현 시 자동 전파ClassifierCompositeItemWriter= 조건 분기, item 별 다른 delegate- 함정 — Classifier 가 null 반환 시 예외 → default delegate 설정
- 대부분 표준 ItemWriter = thread-safe (Reader 와 차이)
- multi-threaded 환경 Writer = 비교적 안전
- Writer 모든 작업 = 현재 chunk transaction 안
- 외부 시스템 호출 = 실제 rollback 안 되지만 retry 발생 시 재호출
- 함정 — 단순 INSERT → retry 시 PK 충돌 → UPSERT 권장
- 함정 — Hibernate flush 누락 = 1건씩 query →
session.flush()+hibernate.jdbc.batch_size - 함정 —
CompositeItemWriter부분 실패 → 외부 시스템 inconsistency - 함정 — File writer 의 ItemStream 등록 누락 = 파일 미생성
- 패턴 — JdbcBatchItemWriter + UPSERT (가장 흔한 운영)
- 패턴 — Composite (DB + audit file)
- 패턴 — Classifier (priority/type 분기)
- 패턴 — 외부 API + idempotency key
- 패턴 — 메트릭 wrapper (Observability)
공식 문서: ItemWriter 에서 원문을 확인할 수 있어요.
시리즈 다른 편 (앞뒤 글 모음)
이전 글:
- 18편 — Step 라이프사이클 Listener 종합
- 19편 — TaskletStep (단발 작업의 정석)
- 20편 — Flow Control · Decision · Split · 조건 분기
- 21편 — Late Binding · @StepScope · @JobScope · SpEL
- 22편 — ItemReader 인터페이스 종합 · Delegate Pattern
다음 글: