Spring Boot における EasyExcel を用いた大規模データの効率的なインポート・エクスポート

Spring Boot 環境で大規模な Excel データを扱う際、最も懸念されるのはメモリ不足(OOM)によるサーバーダウンです。従来の Apache POI はファイル全体をメモリ上に展開するため、数万行程度のデータでもリスクが生じます。これを回避し、処理速度を最大化するための標準的なアプローチが、阿里巴巴製の EasyExcel の導入です。

EasyExcel は SAX パーサーをベースとしており、データを逐行読み書きするため、メモリ消費量がデータサイズに依存しません。以下に、このライブラリを活用した分批処理の実装パターンを示します。

1. 依存関係の定義

プロジェクトの pom.xml にて、安定版の EasyExcel を追加します。データベース連携が必要な場合は、JDBC または MyBatis 等の依存も同時に確認してください。

<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>easyexcel</artifactId>
    <version>3.3.2</version>
</dependency>

2. 大規模データのストリーミング出力

エクスポート処理において重要なのは、「全件取得しないこと」と「ディスクバッファを活用すること」です。データベースからは分页クエリで少量ずつ取得し、Excel 書き込み時は inMemory(false) を設定して临时ファイルを利用します。

データモデルの定義

<pre><code>@Data
public class StaffRecord {
    @ExcelProperty("社員 ID")
    private Long employeeId;
    
    @ExcelProperty("氏名")
    private String fullName;
    
    @ExcelProperty("連絡先メール")
    private String contactEmail;
}
</code></pre>

サービス層の実装

HTTP レスポンスストリームに直接書き込む実装例です。単一シートあたりの行数制限を超えた場合、自動的に新しいシートを生成するロジックを含めています。

<pre><code>@Service
public class BulkExportHandler {

    @Autowired
    private StaffMapper staffMapper;

    public void streamDownload(HttpServletResponse response, String reportName) throws IOException {
        response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
        response.setCharacterEncoding("utf-8");
        String safeFileName = URLEncoder.encode(reportName, "UTF-8").replaceAll("\\+", "%20");
        response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + safeFileName + ".xlsx");

        final int QUERY_LIMIT = 1000;
        final int SHEET_CAPACITY = 100000;
        long offset = 0;
        List<StaffRecord> batch;

        try (ExcelWriter writer = EasyExcel.write(response.getOutputStream(), StaffRecord.class)
                .inMemory(false)
                .build()) {
            
            while (true) {
                batch = staffMapper.fetchByOffset(offset, QUERY_LIMIT);
                
                if (batch == null || batch.isEmpty()) {
                    break;
                }

                int sheetNo = (int) (offset / SHEET_CAPACITY);
                WriteSheet sheet = EasyExcel.writerSheet(sheetNo, "Data_" + (sheetNo + 1))
                        .head(StaffRecord.class)
                        .build();

                writer.write(batch, sheet);

                // メモリ解放のヒント
                batch.clear();
                batch = null;
                
                offset += QUERY_LIMIT;
            }
        }
    }
}
</code></pre>

データアクセス層(MyBatis)

深分页による性能劣化を防ぐため、データ量が極めて多い場合は ID 範囲指定での取得を検討してください。

<pre><code><select id="fetchByOffset" resultType="com.example.StaffRecord">
    SELECT employee_id, full_name, contact_email FROM employees 
    LIMIT #{offset}, #{limit}
</select>
</code></pre>

3. リスナーを用いた分批インポート

インポート処理では、AnalysisEventListener を実装したクラスを作成します。このリスナーは行ごとに呼び出されるため、内部でバッファリングを行い、一定数溜まった時点でデータベースへ批量挿入します。

カスタムリスナー

<pre><code>public class StaffImportListener extends AnalysisEventListener<StaffRecord> {

    private List<StaffRecord> storageBuffer = new ArrayList<>(1000);
    private static final int FLUSH_THRESHOLD = 1000;
    private StaffMapper staffMapper;

    public StaffImportListener(StaffMapper mapper) {
        this.staffMapper = mapper;
    }

    @Override
    public void invoke(StaffRecord data, AnalysisContext context) {
        storageBuffer.add(data);
        
        if (storageBuffer.size() >= FLUSH_THRESHOLD) {
            flushToDatabase();
            storageBuffer.clear();
        }
    }

    @Override
    public void doAfterAllAnalysed(AnalysisContext context) {
        if (!storageBuffer.isEmpty()) {
            flushToDatabase();
            storageBuffer.clear();
        }
    }

    private void flushToDatabase() {
        try {
            staffMapper.insertBatch(storageBuffer);
        } catch (Exception e) {
            throw new RuntimeException("データ登録中にエラーが発生しました", e);
        }
    }
}
</code></pre>

コントローラーでの呼び出し

<pre><code>@PostMapping("/upload")
public ResponseEntity<String> uploadEmployeeData(@RequestParam("file") MultipartFile file) {
    if (file.isEmpty()) {
        return ResponseEntity.badRequest().body("ファイルが選択されていません");
    }

    StaffMapper mapper = applicationContext.getBean(StaffMapper.class);
    try {
        EasyExcel.read(file.getInputStream(), StaffRecord.class, new StaffImportListener(mapper))
                .sheet()
                .doRead();
        return ResponseEntity.ok("処理が完了しました");
    } catch (IOException e) {
        return ResponseEntity.status(500).body("サーバーエラー");
    }
}
</code></pre>

批量挿入 SQL

<pre><code><insert id="insertBatch">
    INSERT INTO employees (employee_id, full_name, contact_email) VALUES
    <foreach collection="list" item="item" separator=",">
        (#{item.employeeId}, #{item.fullName}, #{item.contactEmail})
    </foreach>
</insert>
</code></pre>

4. 性能最適化のチェックリスト

処理类型 重要項目 備考
エクスポート 分页取得 全件 SELECT は禁止。ループ内で LIMIT を使用。
エクスポート ディスクバッファ inMemory(false) でメモリ圧迫を防ぐ。
エクスポート シート分割 1 シート 10 万行を目安に分割し、クライアント側の表示速度を確保。
インポート リスナー活用 行ごとのコールバックを利用し、ストリーム処理を実現。
インポート 批量 INSERT 行ごとの INSERT は NG。バッファ累積後に実行。
インポート トランザクション 批量単位でコミットし、長トランザクションによるロックを回避。

5. 実装上の注意点

  • 日付型の扱い:Excel の日付はシリアル値で保持されることがあります。必要に応じて @DateTimeFormat アノテーションを用いて変換規則を定義してください。
  • ヘッダー検証:アップロード前に 1 行目を読み込み、期待されるカラム名と一致するか検証するロジックを入れると安全性が高まります。
  • トランザクション範囲:リスナー内の保存処理に @Transactional を付与する場合、バッチサイズが大きすぎると DB コネクションを長時間占有します。バッチ単位での明示的なトランザクション管理を推奨します。

タグ: EasyExcel spring-boot batch-processing Memory-Management MyBatis

6月1日 10:26 投稿