OpenCSVで自前のループを書いてCSVを取り込む方法は以前の記事で紹介しましたが、行数が数十万を超えると話が変わってきますよね。全件をメモリに載せる書き方はOutOfMemoryErrorが怖く、1行のフォーマット崩れで処理全体が落ちるのも運用上つらいところです。

Spring BatchのFlatFileItemReaderは大量CSVの取り込みに特化しています。Job/Step/Chunkの基本はすでに触れているという前提で、今回はCSV読み込みの実装だけに絞って深掘りします。

OpenCSVループとの違い

OpenCSVの手動ループは1行読んでリストに詰め、最後にまとめて書き込む形になりがちで、件数が増えるとそのリストがメモリを食います。FlatFileItemReaderはchunk単位で読み書きするためメモリ使用量が安定し、不正行の扱いもskip/retryに任せられます。

FlatFileItemReaderの基本設定

最小構成はこうなります。

@Bean
public FlatFileItemReader<Product> productItemReader() {
    return new FlatFileItemReaderBuilder<Product>()
        .name("productItemReader")
        .resource(new FileSystemResource("/data/products.csv"))
        .linesToSkip(1)
        .lineMapper(productLineMapper())
        .build();
}

resource()でファイルを指定し、linesToSkip(1)でヘッダー行を除外します。1行からオブジェクトへの変換はlineMapper()が担当します。ここでは固定パスの最小構成を示していますが、後述のStepScope版に置き換える前提の暫定コードです。

DelimitedLineTokenizerとBeanWrapperFieldSetMapperでPOJOに変換する

lineMapper()の内部では、DelimitedLineTokenizerが1行をFieldSetに分割し、BeanWrapperFieldSetMapperがそのFieldSetをPOJOに変換します。

DelimitedLineTokenizer tokenizer = new DelimitedLineTokenizer();
tokenizer.setNames("code", "name", "price", "stock");
tokenizer.setDelimiter(",");
tokenizer.setQuoteCharacter('"');

BeanWrapperFieldSetMapper<Product> fieldSetMapper = new BeanWrapperFieldSetMapper<>();
fieldSetMapper.setTargetType(Product.class);

DefaultLineMapper<Product> lineMapper = new DefaultLineMapper<>();
lineMapper.setLineTokenizer(tokenizer);
lineMapper.setFieldSetMapper(fieldSetMapper);

setNames()の列名がFieldSetのキーになり、setTargetType()のクラスのプロパティ名と一致していればセッター経由で自動的に値がセットされます。区切り文字を変えたいときはsetDelimiter()、値にカンマを含む列を囲みたいときはsetQuoteCharacter()を使います。列数がnamesの数と合わないとIncorrectTokenCountExceptionになるので、ヘッダーの列数と揃えておきましょう。

数値程度の型変換はSpringの標準機構が面倒を見てくれますが、yyyy/MM/ddのような独自フォーマットの日付はそのままではBindingExceptionになります。その場合はsetCustomEditors()でCustomDateEditorのようなPropertyEditorを登録します。

Map<Class<?>, PropertyEditor> editors = new HashMap<>();
editors.put(Date.class, new CustomDateEditor(new SimpleDateFormat("yyyy/MM/dd"), true));
fieldSetMapper.setCustomEditors(editors);

Chunk指向Stepとskip/retryで不正行に強くする

読み込んだデータの処理はいつも通りのchunk指向Stepです。型変換に失敗する行やDB接続の一時的な切断が混ざるので、faultTolerant()でskip()とretry()もあわせて設定します。

@Bean
public Step productImportStep(JobRepository jobRepository, PlatformTransactionManager tx,
        FlatFileItemReader<Product> reader, ItemWriter<Product> writer,
        ProductSkipListener skipListener) {
    return new StepBuilder("productImportStep", jobRepository)
        .chunk(500, tx)
        .reader(reader)
        .writer(writer)
        .faultTolerant()
        .skip(FlatFileParseException.class)
        .skipLimit(50)
        .retry(TransientDataAccessException.class)
        .retryLimit(3)
        .listener(skipListener)
        .build();
}

chunk(500, tx)の500が1トランザクションの件数です。大きくすると速くなりますが失敗時のロールバック範囲も広がるので、100〜1000件程度で調整しましょう。skipLimit(50)を決めておかないと、CSVがまるごと壊れていても全件スキップに気づけません。

skip()とretry()は対象の例外クラスが別物である点に注意してください。CSVの解析失敗(FlatFileParseException)はskip対象、DB接続の一時的な失敗(TransientDataAccessException)はretry対象で、両者は独立して働きます。retryLimit(3)回リトライしてもTransientDataAccessExceptionが解消しなければ、その行はskip対象ではないのでStep自体が失敗します。retry()には一時障害由来の例外だけを指定しましょう(ItemWriter側の最適化は今回は割愛します)。

SkipListenerでスキップ行を記録する

skipした行を放置すると後から追跡できないので、SkipListenerで記録し件数も集計します。

@Component
public class ProductSkipListener {
    private static final Logger log = LoggerFactory.getLogger(ProductSkipListener.class);
    private final AtomicInteger skipCount = new AtomicInteger();

    @OnSkipInRead
    public void onSkipInRead(Throwable t) {
        skipCount.incrementAndGet();
        log.warn("skip: {}", t.getMessage());
    }

    @AfterStep
    public ExitStatus afterStep(StepExecution stepExecution) {
        stepExecution.getExecutionContext().putInt("skipCount", skipCount.get());
        return stepExecution.getExitStatus();
    }
}

@OnSkipInReadでカウントし、@AfterStepでExecutionContextに書き込んでおけば、Job完了後にスキップ件数を確認できます。先ほどのStepにも.listener(skipListener)で登録済みです。

JobParametersとStepScopeでCSVファイルパスを渡す

実運用では実行ごとに違うファイルを渡したいので、JobLauncher側でJobParametersを積んで起動し、Reader側はStepScopeで受け取ります。

JobParameters params = new JobParametersBuilder()
    .addString("inputFile", "/data/products_20260930.csv")
    .toJobParameters();
jobLauncher.run(productImportJob, params);

受け取り側は、先ほど固定パスで定義したproductItemReaderを次のStepScope版に置き換えます。同じConfigurationクラスに両方残すと、Bean名が重複してアプリケーション起動時にエラーになるため、こちらのStepScope版だけを残してください。

@Bean
@StepScope
public FlatFileItemReader<Product> productItemReader(
        @Value("#{jobParameters['inputFile']}") String inputFile) {
    return new FlatFileItemReaderBuilder<Product>()
        .name("productItemReader")
        .resource(new FileSystemResource(inputFile))
        .linesToSkip(1)
        .lineMapper(productLineMapper())
        .build();
}

StepScopeがないと、このBeanはアプリ起動時に一度だけ生成されjobParametersが未確定のためエラーになります。StepScopeでBean生成がStep実行時まで遅延されるので、渡したinputFileを安全に受け取れます。

まとめ

FlatFileItemReaderとBeanWrapperFieldSetMapperを使えば、OpenCSVの手書きループよりメモリ効率よく数十万行規模のCSVを安全に取り込めます。skip/retryで不正行を仕分けし、JobParametersとStepScopeでファイルパスを実行時に渡せば、実運用に耐える取り込みJobになります。

Job/Step/Chunkをまだ整理していない方はSpring Batchの入門ガイドも、OpenCSVでのアップロード/ダウンロードAPI実装はCSV入出力ガイドも合わせて読んでみてください。