Spring Batch 입문 29편 — FlatFileItemReader 깊은 옵션

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

Spring Batch 입문 29편. FlatFileItemReader 깊은 옵션 — 8가지 property, DefaultLineMapper 의 3단계 흐름, DelimitedLineTokenizer · FixedLengthTokenizer · PatternMatchingCompositeLineMapper, BeanWrapperFieldSetMapper, FlatFileParseException · IncorrectTokenCountException · IncorrectLineLengthException 함정, strict 모드까지 정리한 학습 노트.

📚 Spring Batch 입문에서 운영까지 · 29편 — FlatFileItemReader 깊은 옵션

이 글은 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

이 중 가장 중요한 두 가지는 resourcelineMapper 고, 나머지는 세밀 조정용이에요.

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
  • strict Reader level — resource 없으면 예외 vs log
  • strict Tokenizer level — tokens 수 mismatch 예외 vs 진행
  • 두 strict 가 다른 의미 — 명시적 설정 권장
  • linesToSkip + skippedLinesCallback = header 처리 + 검증
  • comments = 주석 prefix array
  • encoding 기본 = 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() 자동 인식
  • BeanWrapperFieldSetMapper prototype 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 에서 원문을 확인할 수 있어요.

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

이전 글:

다음 글:

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

답글 남기기

error: Content is protected !!