Spring Batch 입문 29편. FlatFileItemReader 깊은 옵션 — 8가지 property, DefaultLineMapper 의 3단계 흐름, DelimitedLineTokenizer · FixedLengthTokenizer · PatternMatchingCompositeLineMapper, BeanWrapperFieldSetMapper, FlatFileParseException · IncorrectTokenCountException · IncorrectLineLengthException 함정, strict 모드까지 정리한 학습 노트.
이 글은 Spring Batch 입문에서 운영까지 시리즈 48편 중 29편이에요. 28편 의 FieldSet(파싱된 한 줄의 컬럼 모음) 까지 봤다면, 이번 29편은 그 위 단계 — FlatFileItemReader(플랫 파일 한 줄씩 읽는 Reader) 의 모든 옵션과 동작 방식을 다뤄요.
8개 핵심 property
| Property | 타입 | 역할 |
|---|---|---|
resource |
Resource |
입력 파일 |
lineMapper |
LineMapper<T> |
한 줄 → 도메인 객체 |
encoding |
String |
텍스트 인코딩 (기본 UTF-8) |
linesToSkip |
int |
건너뛸 line 수 (header 등) |
comments |
String[] |
주석 prefix |
recordSeparatorPolicy |
RecordSeparatorPolicy |
line 종료 처리 |
skippedLinesCallback |
LineCallbackHandler |
skip 된 line 후처리 |
strict |
boolean |
resource 없을 때 예외 vs log |
이 중 가장 중요한 두 가지는 resource 와 lineMapper 고, 나머지는 세밀 조정용이에요.
resource 의 다양성
new FileSystemResource("/data/customers.csv")
new ClassPathResource("samples/customers.csv")
new UrlResource("https://example.com/customers.csv")
new InputStreamResource(inputStream)
Spring Resource(추상화된 입력 소스) 는 파일 시스템 외에 클래스패스·URL·스트림도 똑같이 지원해요. 21편의 Late Binding 으로 @Value("#{jobParameters['input.file']}") 를 써서 Resource 를 주입하는 패턴이 가장 흔해요.
strict 모드
.strict(false)
기본값인 true 면 resource 가 없을 때 IllegalStateException 이 터지고, false 로 두면 warning log 만 남기고 조용히 0건 처리로 넘어가요. 일부 날짜만 존재하는 옵션 파일을 다룰 때 strict(false) 가 어울려요.
skippedLinesCallback
.skippedLinesCallback(line -> log.info("Skipped: {}", line))
linesToSkip(N) 으로 건너뛴 raw line 을 그대로 받아볼 수 있어서 header 검증이나 로깅에 쓰기 좋아요.
DefaultLineMapper — 3단계 흐름
1. File → line (String) [FlatFileItemReader]
2. line → FieldSet [LineTokenizer]
3. FieldSet → 도메인 객체 [FieldSetMapper]
이 3단계를 한 번에 묶어주는 표준 LineMapper 가 DefaultLineMapper(line→FieldSet→객체 묶음) 예요.
@Bean
public LineMapper<Player> playerLineMapper() {
DefaultLineMapper<Player> mapper = new DefaultLineMapper<>();
mapper.setLineTokenizer(playerTokenizer());
mapper.setFieldSetMapper(playerFieldSetMapper());
return mapper;
}
Builder 를 쓸 땐 자동으로 깔려요. .delimited() / .fixedLength() / .lineTokenizer() 를 호출하면 내부에서 DefaultLineMapper 가 구성돼요.
LineTokenizer 3종 — 실전
LineTokenizer(한 줄을 컬럼으로 쪼개는 역할) 의 구현체는 세 가지가 표준이에요.
DelimitedLineTokenizer — CSV/TSV
@Bean
public DelimitedLineTokenizer csvTokenizer() {
DelimitedLineTokenizer tokenizer = new DelimitedLineTokenizer();
tokenizer.setDelimiter(","); // 기본 = ","
tokenizer.setQuoteCharacter('"'); // 기본 = '"'
tokenizer.setNames("id", "name", "email"); // FieldSet name
tokenizer.setStrict(true); // tokens 수 검증
return tokenizer;
}
옵션은 네 개예요. delimiter 가 구분자, quoteCharacter 가 quoted 필드 처리, names 가 FieldSet name 배열, strict 가 tokens 수와 names 수가 다를 때 예외를 던질지 여부를 잡아요.
FixedLengthTokenizer — 고정 길이
@Bean
public FixedLengthTokenizer fixedTokenizer() {
FixedLengthTokenizer tokenizer = new FixedLengthTokenizer();
tokenizer.setNames("ISIN", "Quantity", "Price", "Customer");
tokenizer.setColumns(
new Range(1, 12), // 1~12자
new Range(13, 15), // 13~15자
new Range(16, 20), // 16~20자
new Range(21, 29) // 21~29자
);
tokenizer.setStrict(true);
return tokenizer;
}
여기서 Range 는 시작과 끝 위치를 1-based, inclusive 로 잡아요. 예를 들어 "UK21341EAH4121131.11customer1" 한 줄은 이렇게 갈라져요. ISIN(국제증권식별번호) 처럼 자릿수가 고정된 컬럼이 줄 세워진 파일에 어울려요.
| Range | 추출 |
|---|---|
| 1-12 | "UK21341EAH41" (ISIN) |
| 13-15 | "211" (Quantity) |
| 16-20 | "31.11" (Price) |
| 21-29 | "customer1" (Customer) |
PatternMatchingCompositeLineTokenizer — 줄별 다른 tokenizer
@Bean
public PatternMatchingCompositeLineTokenizer compositeTokenizer() {
Map<String, LineTokenizer> tokenizers = new HashMap<>();
tokenizers.put("HEADER*", headerTokenizer());
tokenizers.put("BODY*", bodyTokenizer());
tokenizers.put("FOOTER*", footerTokenizer());
PatternMatchingCompositeLineTokenizer tokenizer =
new PatternMatchingCompositeLineTokenizer();
tokenizer.setTokenizers(tokenizers);
return tokenizer;
}
한 파일 안에 여러 record format 이 섞여 있을 때 골라 쓰는 tokenizer 예요.
FieldSetMapper 3가지 패턴
FieldSetMapper(FieldSet→도메인 객체 변환) 를 구현하는 방식은 셋이 있어요.
1. 수동 Mapper
public class PlayerFieldSetMapper implements FieldSetMapper<Player> {
@Override
public Player mapFieldSet(FieldSet fs) {
Player player = new Player();
player.setID(fs.readString(0));
player.setLastName(fs.readString(1));
player.setFirstName(fs.readString(2));
player.setPosition(fs.readString(3));
player.setBirthYear(fs.readInt(4));
player.setDebutYear(fs.readInt(5));
return player;
}
}
가장 세밀하게 통제할 수 있고, 검증·변환 로직을 박기에도 좋아요.
2. Name 기반 Mapper (가독성 ↑)
public class PlayerMapper implements FieldSetMapper<Player> {
@Override
public Player mapFieldSet(FieldSet fs) {
if (fs == null) return null;
Player player = new Player();
player.setID(fs.readString("ID"));
player.setLastName(fs.readString("lastName"));
player.setFirstName(fs.readString("firstName"));
// ...
return player;
}
}
tokenizer 에 setNames(...) 가 들어간 경우라면 인덱스 대신 이름으로 읽을 수 있어서 컬럼 순서 변경에 강해요.
3. BeanWrapperFieldSetMapper — Auto-mapping
@Bean
public FieldSetMapper<Player> autoMapper() {
BeanWrapperFieldSetMapper<Player> mapper = new BeanWrapperFieldSetMapper<>();
mapper.setPrototypeBeanName("player");
return mapper;
}
@Bean
@Scope("prototype")
public Player player() {
return new Player();
}
JavaBean 스펙에 따라 FieldSet name 과 setter 이름이 자동으로 매칭돼서 코드를 한 줄도 더 안 짜도 돼요.
Builder 로는 한 줄로 끝나요.
.targetType(Player.class)
이 한 줄이 내부적으로 BeanWrapperFieldSetMapper 를 깔아주고, Spring Batch 5+ 부터는 Record 도 똑같이 인식해요.
RecordSeparatorPolicy — multi-line 처리
기본은 1 line = 1 record 예요. 그런데 이런 입력을 만나면 깨져요.
1,Alice,"Hello
World",alice@example.com
quoted field 안의 newline 까지 line 종료로 잘못 잡아버리거든요. 이걸 풀려면 DefaultRecordSeparatorPolicy 를 끼워요.
.recordSeparatorPolicy(new DefaultRecordSeparatorPolicy())
quote 안의 newline 을 자동으로 합쳐서 한 record 로 묶어줘요. 추가로 골라 쓸 수 있는 정책은 다음 네 가지가 있어요.
| 정책 | 동작 |
|---|---|
SimpleRecordSeparatorPolicy |
기본 (1 line = 1 record) |
DefaultRecordSeparatorPolicy |
quoted multi-line 처리 |
JsonRecordSeparatorPolicy |
JSON record 처리 |
| Custom | 도메인 특화 |
실전 예제 — Player CSV
입력
ID,lastName,firstName,position,birthYear,debutYear
AbduKa00,Abdul-Jabbar,Karim,rb,1974,1996
AbduRa00,Abdullah,Rabih,rb,1975,1999
도메인
public class Player implements Serializable {
private String ID;
private String lastName;
private String firstName;
private String position;
private int birthYear;
private int debutYear;
// getters/setters
}
Reader 구성
@Bean
@StepScope
public FlatFileItemReader<Player> playerReader(
@Value("#{jobParameters['input.file']}") Resource resource) {
return new FlatFileItemReaderBuilder<Player>()
.name("playerReader")
.resource(resource)
.encoding("UTF-8")
.linesToSkip(1)
.delimited()
.delimiter(",")
.quoteCharacter('"')
.names("ID", "lastName", "firstName", "position", "birthYear", "debutYear")
.targetType(Player.class)
.build();
}
targetType(Player.class) 한 줄이 BeanWrapperFieldSetMapper 를 자동으로 깔아줘서 별도 mapper 코드를 안 짜도 돼요.
Multi-record File — PatternMatchingCompositeLineMapper
입력 (3가지 record type)
USER;Smith;Peter;;T;20014539;F
LINEA;1044391041ABC037.49G201XX1383.12H
LINEB;2134776319DEF422.99M005LI
LineMapper 구성
@Bean
public PatternMatchingCompositeLineMapper<Object> orderFileLineMapper() {
PatternMatchingCompositeLineMapper<Object> lineMapper =
new PatternMatchingCompositeLineMapper<>();
Map<String, LineTokenizer> tokenizers = new HashMap<>();
tokenizers.put("USER*", userTokenizer());
tokenizers.put("LINEA*", lineATokenizer());
tokenizers.put("LINEB*", lineBTokenizer());
lineMapper.setTokenizers(tokenizers);
Map<String, FieldSetMapper<Object>> mappers = new HashMap<>();
mappers.put("USER*", userMapper());
mappers.put("LINE*", lineMapper()); // LINEA·LINEB 공통
lineMapper.setFieldSetMappers(mappers);
return lineMapper;
}
핵심은 pattern 마다 다른 Tokenizer 와 다른 FieldSetMapper 를 꽂아준다는 점이에요.
Exception Hierarchy
FlatFileParseException ← 최상위
├─ FlatFileFormatException ← Tokenizer 단계
│ ├─ IncorrectTokenCountException ← tokens 수 mismatch
│ └─ IncorrectLineLengthException ← fixed length 위반
IncorrectTokenCountException
tokenizer.setNames("A", "B", "C", "D"); // 4개
tokenizer.tokenize("a,b,c"); // 3개 → 예외
expectedCount=4, actualCount=3 으로 떨어져서 14편의 skip 로직으로 흡수할 수 있어요.
IncorrectLineLengthException
tokenizer.setColumns(new Range(1, 5), new Range(6, 10), new Range(11, 15));
tokenizer.tokenize("12345"); // 길이 5 ≠ 15 → 예외
expectedLength=15, actualLength=5 로 잡혀요.
Strict 끄기
tokenizer.setStrict(false);
이렇게 두면 길이가 부족할 때 빈 값으로 padding 한 뒤 진행해서 예외가 안 떠요. 옵션 컬럼이 끼는 파일에 잘 맞아요.
자주 만나는 사고
사고 1: header 가 데이터로 파싱
linesToSkip(1) 을 깜빡한 게 원인이에요. .linesToSkip(1) 을 박거나, header 가 # 으로 시작하면 .comments("#") 으로 잡으면 돼요.
사고 2: 한글 깨짐
encoding 을 명시하지 않아서 환경 default 를 그대로 따라가는 게 원인이에요. .encoding("UTF-8") 을 명시해 주세요.
사고 3: quoted field 안 comma
"Carol, Jr." 의 comma 가 구분자로 잘못 처리되는 거예요. quoteCharacter('"') 를 설정하면 풀려요.
사고 4: tokens 수 mismatch
header 컬럼은 6개인데 데이터는 5개라서 생기는 경우가 많아요. 데이터 자체를 검증하거나, strict(false) 로 두거나, 14편의 skip 로 흡수하면 돼요.
사고 5: BeanWrapperFieldSetMapper 의 prototype scope 누락
@Bean Player player() 만 적어 두면 singleton 으로 공유돼서 데이터가 망가져요. @Scope("prototype") 을 꼭 박거나, Builder 단축인 .targetType(Player.class) 를 쓰는 게 안전해요.
사고 6: BOM 인식 안 됨
Windows 가 저장한 UTF-8 BOM(byte order mark) 이 첫 컬럼에 형태로 끼어 들어와요. BufferedReaderFactory 를 커스터마이즈하거나 전처리 단계에서 BOM 을 strip 해 주세요.
사고 7: PatternMatching 의 default branch
pattern 들이 모든 line 을 매치하지 못하면 예외가 떨어져요. * (catch-all) 를 항상 마지막에 박아 두는 게 안전해요.
strict 모드 — 두 자리
Reader level
.strict(false) // resource 없으면 조용히 0건
Tokenizer level
tokenizer.setStrict(false) // tokens 수 mismatch 도 진행
두 strict 가 의미가 달라요. 운영에 올릴 땐 둘 다 명시적으로 잡아 주는 게 좋아요.
운영 권장 패턴
Pattern 1: 표준 CSV reader
@Bean
@StepScope
public FlatFileItemReader<Customer> customerReader(
@Value("#{jobParameters['input.file']}") Resource resource) {
return new FlatFileItemReaderBuilder<Customer>()
.name("customerReader")
.resource(resource)
.encoding("UTF-8")
.strict(true)
.linesToSkip(1)
.skippedLinesCallback(line -> log.info("Header: {}", line))
.delimited()
.delimiter(",")
.quoteCharacter('"')
.names("id", "name", "email", "createdAt")
.targetType(Customer.class)
.build();
}
Pattern 2: Fixed length reader
@Bean
@StepScope
public FlatFileItemReader<Order> orderReader(
@Value("#{jobParameters['input.file']}") Resource resource) {
return new FlatFileItemReaderBuilder<Order>()
.name("orderReader")
.resource(resource)
.fixedLength()
.columns(
new Range(1, 12),
new Range(13, 15),
new Range(16, 20),
new Range(21, 29))
.names("ISIN", "Quantity", "Price", "Customer")
.targetType(Order.class)
.build();
}
Pattern 3: Multi-record file
@Bean
public FlatFileItemReader<Object> orderFileReader() {
return new FlatFileItemReaderBuilder<Object>()
.name("orderFileReader")
.resource(new FileSystemResource("orders.txt"))
.lineMapper(orderFileLineMapper()) // PatternMatchingComposite
.build();
}
Pattern 4: Validation + skip 흡수
@Bean
public FieldSetMapper<Customer> validatingMapper() {
return fs -> {
long id = fs.readLong("id");
if (id <= 0) throw new IllegalStateException("Invalid id: " + id);
String email = fs.readString("email");
if (!email.contains("@")) {
throw new IllegalStateException("Invalid email: " + email);
}
return new Customer(id, fs.readString("name"), email);
};
}
mapper 에서 던진 예외는 14편 skip 으로 흡수되고, 운영자가 따로 모아서 분석하면 돼요.
시험 직전 한 번 더 — FlatFileItemReader 함정 압축 노트
- 8개 property = resource · lineMapper · encoding · linesToSkip · comments · recordSeparatorPolicy · skippedLinesCallback · strict
strictReader level — resource 없으면 예외 vs logstrictTokenizer level — tokens 수 mismatch 예외 vs 진행- 두 strict 가 다른 의미 — 명시적 설정 권장
linesToSkip+skippedLinesCallback= header 처리 + 검증comments= 주석 prefix arrayencoding기본 = UTF-8 (단 환경 의존성 — 명시 권장)recordSeparatorPolicy=DefaultRecordSeparatorPolicy로 quoted multi-line 처리DefaultLineMapper= LineTokenizer + FieldSetMapper 묶음- LineTokenizer 3종 = DelimitedLineTokenizer · FixedLengthTokenizer · PatternMatchingCompositeLineTokenizer
- DelimitedLineTokenizer 옵션 = delimiter · quoteCharacter · names · strict
- FixedLengthTokenizer 옵션 = columns (Range 1-based inclusive) · names · strict
- PatternMatchingCompositeLineTokenizer = pattern → tokenizer map
- FieldSetMapper 3패턴 = 수동 mapper · name 기반 mapper ·
BeanWrapperFieldSetMapper(auto-mapping) .targetType(Class)= BeanWrapperFieldSetMapper Builder 단축- Spring Batch 5+ 에서 Record 도
.targetType()자동 인식 BeanWrapperFieldSetMapperprototype scope 필수 (또는.targetType()권장)- PatternMatchingCompositeLineMapper = pattern → tokenizer + FieldSetMapper map
- Multi-record file 의 표준 패턴
- Exception hierarchy = FlatFileParseException → FlatFileFormatException → IncorrectTokenCountException · IncorrectLineLengthException
- IncorrectTokenCountException = tokens 수 mismatch
- IncorrectLineLengthException = fixed length 위반
- 함정 — header 안 skip →
linesToSkip(1) - 함정 — 한글 깨짐 →
.encoding("UTF-8") - 함정 — quoted comma →
.quoteCharacter('"') - 함정 — multi-line CSV →
DefaultRecordSeparatorPolicy - 함정 — BeanWrapper prototype 누락 → singleton 공유
- 함정 — BOM (Windows UTF-8) →
첫 컬럼 → BufferedReaderFactory 또는 전처리 - 함정 — PatternMatching default branch 누락 →
*catch-all 마지막 - 패턴 — 표준 CSV reader (encoding · linesToSkip · names · targetType)
- 패턴 — Fixed length reader (columns + names)
- 패턴 — Multi-record (PatternMatchingComposite)
- 패턴 — Validation throw + skip 흡수
공식 문서: FlatFileItemReader 에서 원문을 확인할 수 있어요.
시리즈 다른 편 (앞뒤 글 모음)
이전 글:
- 24편 — ItemStream 인터페이스 본격 풀이
- 25편 — Reader · Writer 구현체 카탈로그
- 26편 — Custom Reader · Writer 직접 구현
- 27편 — Flat File Overview · 파싱 3총사
- 28편 — FieldSet · Flat File 의 ResultSet
다음 글: