Spring Batch 입문 30편 — FlatFileItemWriter · LineAggregator · FieldExtractor

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

Spring Batch 입문 30편. FlatFileItemWriter 깊은 옵션 — 역방향 3단계 (FieldExtractor → LineAggregator → 파일), DelimitedLineAggregator · FormatterLineAggregator (printf 스타일), BeanWrapperFieldExtractor · PassThroughFieldExtractor, headerCallback / footerCallback, transactional output 처리, shouldDeleteIfExists 같은 restart 함정까지 정리한 학습 노트. Part 6 의 flat file 마무리.

📚 Spring Batch 입문에서 운영까지 · 30편 — FlatFileItemWriter · LineAggregator · FieldExtractor

이 글은 Spring Batch 입문에서 운영까지 시리즈 48편 중 30편이에요. 29편 FlatFileItemReader 의 대척점 — FlatFileItemWriter (배치 평문 파일 출력기). Reader 가 line → 도메인 객체 변환이었다면 Writer 는 도메인 객체 → line 으로 거꾸로 돈다. Part 6 의 flat file 시리즈를 여기서 닫는다.

Writer 의 역방향 3단계

도메인 객체 (Customer)
  ↓                                  [FieldExtractor]
Object[] {1, "Alice", "alice@..."}
  ↓                                  [LineAggregator]
"1,Alice,alice@example.com"          (한 줄 String)
  ↓                                  [FlatFileItemWriter]
파일에 write + line separator

Reader 의 Tokenizer → FieldSet → FieldSetMapper정확히 대칭으로 맞물린다.

단계 Reader Writer
1 LineTokenizer FieldExtractor
2 FieldSet Object[]
3 FieldSetMapper LineAggregator

Writer 측은 3 → 1 순서로 흐른다 — Reader 의 역방향이라 그렇다.

LineAggregator 인터페이스

public interface LineAggregator<T> {
    String aggregate(T item);
}

LineAggregator (객체를 한 줄 문자열로 합치는 추상화) 는 item 을 받아 String line 으로 바꿔준다. LineTokenizer 의 논리적 역방향에 해당한다.

LineAggregator 표준 구현 3종

구현 역할
PassThroughLineAggregator<T> item.toString() 그대로
DelimitedLineAggregator<T> delimiter 로 join (CSV/TSV)
FormatterLineAggregator<T> printf 스타일 format (fixed width)

PassThroughLineAggregator — 가장 단순

public class PassThroughLineAggregator<T> implements LineAggregator<T> {
    @Override
    public String aggregate(T item) {
        return item.toString();
    }
}

말 그대로 item.toString() 결과를 그대로 흘려보낸다. 도메인 객체가 이미 String 이거나 toString()원하는 line format 을 뱉을 때 쓴다.

@Bean
public FlatFileItemWriter<String> simpleWriter() {
    return new FlatFileItemWriterBuilder<String>()
        .name("simpleWriter")
        .resource(new FileSystemResource("out.txt"))
        .lineAggregator(new PassThroughLineAggregator<>())
        .build();
}

직접 String 생성 통제 + Spring Batch 의 transactional / restart 지원 활용. — 공식 reference

FieldExtractor 인터페이스

public interface FieldExtractor<T> {
    Object[] extract(T item);
}

FieldExtractor (도메인 객체에서 필드 값을 뽑는 추상화) 는 도메인 객체를 Object[] 로 바꿔준다. LineAggregatorObject[] 를 받아 String 으로 만드는 흐름이 자연스럽다.

FieldExtractor 표준 구현 2종

구현 역할
BeanWrapperFieldExtractor<T> getter 이름 매칭 (가장 흔함)
PassThroughFieldExtractor<T> Collection·array·FieldSet 그대로

BeanWrapperFieldExtractor — Auto-extraction

BeanWrapperFieldExtractor<Name> extractor = new BeanWrapperFieldExtractor<>();
extractor.setNames(new String[] {"first", "last", "born"});

Name n = new Name("Alan", "Turing", 1912);
Object[] values = extractor.extract(n);

assertEquals("Alan", values[0]);
assertEquals("Turing", values[1]);
assertEquals(1912, values[2]);

JavaBean 규약을 따라 getFirst()·getLast()·getBorn() 가 자동 호출된다. setter 가 아니라 getter 매칭이라는 점이 포인트.

순서가 중요하다setNames 의 순서가 곧 Object[] 의 순서다.

PassThroughFieldExtractor

PassThroughFieldExtractor<List<String>> extractor = new PassThroughFieldExtractor<>();
Object[] values = extractor.extract(List.of("a", "b", "c"));
// values = {"a", "b", "c"}

이미 Collection·array·FieldSet 형태인 데이터를 그대로 흘려보낸다. 변환 단계가 이미 끝난 데이터 를 다룰 때 쓴다.

DelimitedLineAggregator — CSV 출력

@Bean
public FlatFileItemWriter<CustomerCredit> writer(Resource resource) {
    BeanWrapperFieldExtractor<CustomerCredit> extractor = new BeanWrapperFieldExtractor<>();
    extractor.setNames(new String[]{"name", "credit"});
    extractor.afterPropertiesSet();

    DelimitedLineAggregator<CustomerCredit> aggregator = new DelimitedLineAggregator<>();
    aggregator.setDelimiter(",");
    aggregator.setFieldExtractor(extractor);

    return new FlatFileItemWriterBuilder<CustomerCredit>()
        .name("customerCreditWriter")
        .resource(resource)
        .lineAggregator(aggregator)
        .build();
}

Builder 단축 (권장)

@Bean
public FlatFileItemWriter<CustomerCredit> writer(Resource resource) {
    return new FlatFileItemWriterBuilder<CustomerCredit>()
        .name("customerCreditWriter")
        .resource(resource)
        .delimited()
            .delimiter("|")
            .names("name", "credit")
        .build();
}

.delimited() 빌더 한 줄이 BeanWrapperFieldExtractor + DelimitedLineAggregator 자동 구성을 해준다. 위 장황한 코드와 결과가 같다.

FormatterLineAggregator — Fixed Width 출력

@Bean
public FlatFileItemWriter<CustomerCredit> writer(Resource resource) {
    return new FlatFileItemWriterBuilder<CustomerCredit>()
        .name("customerCreditWriter")
        .resource(resource)
        .formatted()
            .format("%-9s%-2.0f")        // printf style
            .names("name", "credit")
        .build();
}

formatprintf 스타일 그대로다:

  • %-9s — 좌측 정렬, 9자 String
  • %-2.0f — 좌측 정렬, 소수점 없는 float
  • %5d — 우측 정렬, 5자 int

내부적으로 java.util.Formatter (Java 5+) 를 쓴다. Javadoc 참고.

자주 쓰는 format 패턴

패턴 동작 예시 입력 → 출력
%-10s 좌측 정렬, 10자 String, 공백 padding "Alice""Alice "
%10s 우측 정렬, 10자 String "Alice"" Alice"
%05d 우측 정렬, 5자 int, 0 padding 42"00042"
%-3.0f 좌측 정렬, 소수 0자리 float 42.7"43 "
%10.2f 우측 정렬, 10자 float, 소수 2자리 42.7" 42.70"
%n 줄바꿈 (platform-dependent)

Writer 의 핵심 옵션

@Bean
public FlatFileItemWriter<Customer> customerWriter() {
    return new FlatFileItemWriterBuilder<Customer>()
        .name("customerWriter")
        .resource(new FileSystemResource("out.csv"))
        .encoding("UTF-8")
        .lineSeparator("\n")
        .append(false)
        .shouldDeleteIfExists(true)
        .shouldDeleteIfEmpty(false)
        .transactional(true)
        .headerCallback(w -> w.write("# Customer Export"))
        .footerCallback(w -> w.write("# End"))
        .delimited()
            .delimiter(",")
            .names("id", "name", "email")
        .build();
}

각 옵션이 뭘 하는지 하나씩 보자.

lineSeparator

\n (Unix) 와 \r\n (Windows) 가 다르다. 플랫폼 의존을 피하려면 명시하는 게 안전하다.

append

  • true — 기존 파일 내용 보존, 끝에 추가
  • false (default) — 덮어쓰기

shouldDeleteIfExists

  • true — 시작 시 기존 파일 삭제 후 새로 생성
  • false — 기존 파일 유지 (위치 추적 + ItemStream 으로 이어서 쓰기)

shouldDeleteIfEmpty

  • true — 처리 item 0건 시 생성된 빈 파일 삭제
  • false (default) — 빈 파일 그대로

transactional

  • true (default) — chunk transaction 안에서 buffer → commit 시 flush
  • false — 즉시 파일 write (transaction rollback 후에도 파일에 남음)

headerCallback / footerCallback

FlatFileHeaderCallback·FlatFileFooterCallback 인터페이스를 구현해서 시작·끝에 metadata 를 박는다.

Transactional Output 의 의미

여기서 시험 함정이 하나 있다.

chunk 시작 (transaction 시작)
  ↓
write(items)              ← 메모리 buffer 에 누적
  ↓
transaction commit
  ↓
buffer 의 모든 line 을 파일에 flush

chunk 안에서 write 한 내용은 commit 시점에야 실제 파일에 쓰인다.

이렇게 하느냐 — rollback 시 파일 변경도 함께 취소하기 위해서다. DB 와 비슷한 일관성을 흉내내는 셈.

한계 도 분명하다 — 실제 파일 시스템 은 rollback 자체가 안 된다. Spring Batch 가 쓰는 trick 은 buffer 누적 + commit 때 flush. 단 flush 도중 실패 하면 부분 commit 위험이 남는다 (드물지만 가능).

transactional(false) 의 의미

.transactional(false)

이 옵션은 buffer 를 거치지 않고 매 write 마다 파일에 즉시 flush 한다. rollback 이 나도 파일 변경은 그대로 남는다. 그래서 재시도 시 중복 row 위험 — 단 멱등 출력 인 경우라면 OK.

File Creation · Restart 함정

File writing isn't quite so simple. — 공식 reference

핵심 문제restart 시점에 파일이 존재한다는 것 = 이전 실행의 흔적이다. 이걸 어떻게 처리하느냐.

정상 케이스 (재시작 X)

  • 파일 없음 — 새로 생성 + 처음부터 write
  • 파일 있음shouldDeleteIfExists(true) 또는 append 동작

재시작 케이스

  • 이전 실행이 부분 write 한 파일 = ExecutionContext (스텝 실행 상태 저장소) 에 위치 정보 남아 있음
  • ItemStream open()해당 위치까지 skip이어서 write

따라서 shouldDeleteIfExists(true) + restart 조합은 곧 이전 데이터 날아감 이다.

권장 조합은 shouldDeleteIfExists(false) (default) + restartable Job 이다. 재시작 시 ExecutionContext 가 마지막 write 위치를 알고 있어 그 자리에서 이어서 쓴다.

Restart 시점 흐름

1. Step 시작 → FlatFileItemWriter.open(context)
2. context 에 lastWrittenPosition 있음?
   YES → 파일 그 위치까지 truncate, 이어서 write
   NO  → shouldDeleteIfExists 검사 후 시작
3. 매 chunk commit → buffer flush + update(context, position)

ItemStream 의 update() 가 불릴 때 현재 파일 위치 가 저장된다. 24편 ItemStream 의 핵심.

도메인 예제 — CustomerCredit

public class CustomerCredit {
    private int id;
    private String name;
    private BigDecimal credit;
    // getter/setter
}

Delimited 출력

@Bean
public FlatFileItemWriter<CustomerCredit> delimitedWriter() {
    return new FlatFileItemWriterBuilder<CustomerCredit>()
        .name("customerCreditWriter")
        .resource(new FileSystemResource("credits.csv"))
        .delimited()
            .delimiter(",")
            .names("id", "name", "credit")
        .headerCallback(w -> w.write("id,name,credit"))
        .build();
}

출력:

id,name,credit
1,Alice,1234.56
2,Bob,789.00

Fixed width 출력

@Bean
public FlatFileItemWriter<CustomerCredit> fixedWriter() {
    return new FlatFileItemWriterBuilder<CustomerCredit>()
        .name("customerCreditWriter")
        .resource(new FileSystemResource("credits.txt"))
        .formatted()
            .format("%-5d%-10s%10.2f")
            .names("id", "name", "credit")
        .build();
}

출력:

1    Alice         1234.56
2    Bob            789.00

headerCallback / footerCallback 실전

Header 박기

.headerCallback(writer -> {
    writer.write("# Customer Credit Export");
    writer.newLine();
    writer.write("# Generated: " + LocalDateTime.now());
})

Writer 객체를 받아서 newLine() 으로 줄바꿈을 직접 제어한다.

Footer 에 통계

@Bean
@StepScope
public FlatFileFooterCallback footerCallback(
        @Value("#{stepExecution}") StepExecution stepExecution) {
    return writer -> writer.write(
        "# Total: " + stepExecution.getWriteCount() + " records");
}

StepExecution (현재 스텝 실행 메트릭 객체) 을 주입받으면 현재 step 통계 가 따라온다. writeCount 가 그중 하나.

Multi-resource 분할 — MultiResourceItemWriter

25편의 MultiResourceItemWriter (출력 파일을 여러 개로 쪼개는 래퍼) 와 결합해서 쓴다:

@Bean
public MultiResourceItemWriter<CustomerCredit> multiWriter(
        FlatFileItemWriter<CustomerCredit> delegate) {
    return new MultiResourceItemWriterBuilder<CustomerCredit>()
        .name("multiWriter")
        .delegate(delegate)
        .resource(new FileSystemResource("output/credits"))
        .itemCountLimitPerResource(10_000)
        .resourceSuffixCreator(index -> "-" + index + ".csv")
        .build();
}

이러면 10,000건마다 새 파일 이 생긴다. 결과는 이런 식.

output/credits-1.csv
output/credits-2.csv
output/credits-3.csv
...

대량 출력을 작은 파일로 분할 하기 좋다.

자주 만나는 사고

사고 1: 한글 깨짐

원인 — encoding 미명시.

해결.encoding("UTF-8") 명시.

사고 2: 줄바꿈 platform 의존

원인lineSeparator 미명시 → JVM default (Windows/Linux 다름).

해결.lineSeparator("\n") 명시.

사고 3: 재시작 시 파일 사라짐

원인shouldDeleteIfExists(true) + 재시작.

해결shouldDeleteIfExists(false) (default) 유지.

사고 4: 빈 결과 시 빈 파일 남음

원인shouldDeleteIfEmpty(false) (default).

해결shouldDeleteIfEmpty(true).

사고 5: rollback 후 파일에 부분 데이터

원인transactional(false) 모드.

해결transactional(true) (default) 유지. 또는 멱등성 + 후속 cleanup 패턴.

사고 6: BeanWrapperFieldExtractor 의 getter 없음

원인setNames("balance") 인데 getBalance() 메서드 없음.

해결 — getter 추가 또는 Record 사용.

사고 7: Format string 의 type mismatch

원인%d 에 String 전달 → IllegalFormatConversionException.

해결names 순서format 타입 일치 확인. 또는 Custom LineAggregator 직접 구현.

사고 8: append + headerCallback

원인append(true) 인데 매번 header 추가 → 중복 header.

해결headerCallback 안에서 파일이 비어 있을 때만 header 를 쓴다 (Spring Batch 6 에서는 자동 처리).

운영 권장 패턴

Pattern 1: 표준 CSV writer

@Bean
@StepScope
public FlatFileItemWriter<Customer> customerWriter(
        @Value("#{jobParameters['output.file']}") Resource resource) {
    return new FlatFileItemWriterBuilder<Customer>()
        .name("customerWriter")
        .resource(resource)
        .encoding("UTF-8")
        .lineSeparator("\n")
        .shouldDeleteIfEmpty(true)
        .delimited()
            .delimiter(",")
            .names("id", "name", "email")
        .headerCallback(w -> w.write("id,name,email"))
        .build();
}

Pattern 2: Fixed width 결제 파일

@Bean
public FlatFileItemWriter<Payment> paymentWriter() {
    return new FlatFileItemWriterBuilder<Payment>()
        .name("paymentWriter")
        .resource(new FileSystemResource("payments.txt"))
        .encoding("UTF-8")
        .formatted()
            .format("%-12s%-3s%010.2f%-9s")
            .names("isin", "currency", "amount", "customer")
        .build();
}

ISO 표준 결제 형식 — fixed width.

Pattern 3: 통계 footer

@Bean
@StepScope
public FlatFileItemWriter<Order> orderWriter(
        @Value("#{stepExecution}") StepExecution stepExecution) {
    return new FlatFileItemWriterBuilder<Order>()
        .name("orderWriter")
        .resource(new FileSystemResource("orders.csv"))
        .delimited()
            .delimiter(",")
            .names("id", "amount")
        .headerCallback(w -> w.write("id,amount"))
        .footerCallback(w -> w.write(
            "# Total: " + stepExecution.getWriteCount()))
        .build();
}

Pattern 4: 멱등 output

.transactional(false)              // 즉시 파일 write
.append(false)
.shouldDeleteIfExists(true)        // 매번 새로 (재시작 위험 인지)

재시작 시 처음부터 다시 쓰는 멱등 패턴이다. 단 재시작 시 데이터 손실 위험이 따라붙으니 신중하게.

시험 직전 한 번 더 — FlatFileItemWriter 함정 압축 노트

  • 역방향 3단계 = FieldExtractor → LineAggregator → 파일 write
  • Reader 대칭 — Tokenizer ↔ FieldExtractor · FieldSet ↔ Object[] · FieldSetMapper ↔ LineAggregator
  • LineAggregator 인터페이스 = aggregate(T item) → String
  • 3종 = PassThroughLineAggregator · DelimitedLineAggregator · FormatterLineAggregator
  • FieldExtractor 인터페이스 = extract(T item) → Object[]
  • 2종 = BeanWrapperFieldExtractor · PassThroughFieldExtractor
  • BeanWrapperFieldExtractor = JavaBean getter 매칭 (Reader 는 setter, Writer 는 getter)
  • setNames 순서 = Object[] 순서
  • Builder 단축.delimited() = BeanWrapper + DelimitedLineAggregator 자동, .formatted() = BeanWrapper + FormatterLineAggregator 자동
  • FormatterLineAggregator = printf 스타일 (%-9s·%5d·%10.2f·%n)
  • transactional output = chunk transaction 안 buffer 누적 + commit 시 flush
  • transactional(true) (default) = DB-like 일관성, rollback 시 파일 변경 취소
  • transactional(false) = 즉시 flush, rollback 시 파일에 부분 데이터 남음
  • shouldDeleteIfExists(true) = 시작 시 기존 파일 삭제 (재시작 데이터 손실 위험!)
  • shouldDeleteIfExists(false) (default) = ItemStream 으로 이어서
  • shouldDeleteIfEmpty(true) = 0건 처리 시 빈 파일 삭제
  • append(true) = 기존 내용 보존 + 추가
  • headerCallback·footerCallback = 파일 시작·끝 자동 작성
  • StepExecution 주입 = writeCount 활용
  • 재시작 흐름 — open 에서 ExecutionContext 의 위치 복구 + 그 위치까지 truncate
  • 함정 — 한글 깨짐 → .encoding("UTF-8")
  • 함정 — 줄바꿈 platform 의존 → .lineSeparator("\n")
  • 함정 — shouldDeleteIfExists(true) + 재시작 = 데이터 손실
  • 함정 — transactional(false) 모드의 부분 commit
  • 함정 — BeanWrapper getter 누락
  • 함정 — format %d 에 String → IllegalFormatConversionException
  • 함정 — append + header 중복 (Spring Batch 6 자동 처리)
  • 패턴 — 표준 CSV writer (encoding·lineSeparator·shouldDeleteIfEmpty)
  • 패턴 — Fixed width 결제 파일 (formatted)
  • 패턴 — 통계 footer (StepExecution writeCount)
  • 패턴 — 멱등 output (transactional false)
  • 패턴 — MultiResourceItemWriter 와 결합 (25편) — 대량 출력 분할

공식 문서: FlatFileItemWriter 에서 원문을 확인할 수 있어요.

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

이전 글:

다음 글:

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

답글 남기기

error: Content is protected !!