Spring Batch 입문 23편 — ItemWriter 인터페이스 종합

2026-05-17Spring Batch 입문에서 운영까지

Spring Batch 입문 23편. ItemWriter 인터페이스 단 1개 메서드 write(Chunk) 의 contract — chunk 단위 batched write, empty chunk graceful 처리, flush 시점, transactional / idempotent writer 설계 패턴까지 정리한 학습 노트.

📚 Spring Batch 입문에서 운영까지 · 23편 — ItemWriter 인터페이스 종합

이 글은 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 을 직접 구현하고 있어야 합니다.

FlatFileItemWriterJdbcBatchItemWriter 는 모두 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
  • FlatFileItemWritersynchronized 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 에서 원문을 확인할 수 있어요.

시리즈 다른 편 (앞뒤 글 모음)

이전 글:

다음 글:

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!