Spring Batch 입문 30편. FlatFileItemWriter 깊은 옵션 — 역방향 3단계 (FieldExtractor → LineAggregator → 파일), DelimitedLineAggregator · FormatterLineAggregator (printf 스타일), BeanWrapperFieldExtractor · PassThroughFieldExtractor, headerCallback / footerCallback, transactional output 처리, shouldDeleteIfExists 같은 restart 함정까지 정리한 학습 노트. Part 6 의 flat file 마무리.
이 글은 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[] 로 바꿔준다. LineAggregator 가 Object[] 를 받아 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();
}
format 은 printf 스타일 그대로다:
%-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 시 flushfalse— 즉시 파일 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 에서 원문을 확인할 수 있어요.
시리즈 다른 편 (앞뒤 글 모음)
이전 글:
- 25편 — Reader · Writer 구현체 카탈로그
- 26편 — Custom Reader · Writer 직접 구현
- 27편 — Flat File Overview · 파싱 3총사
- 28편 — FieldSet · Flat File 의 ResultSet
- 29편 — FlatFileItemReader 깊은 옵션
다음 글: