見出し画像

【Java】OOM回避!EasyExcelで100万件データを安全にエクスポートするベストプラクティス

業務システム開発において「データのエクスポート(Excelダウンロード)」は避けて通れない機能です。 しかし、データ量が10万、100万と膨れ上がると、従来のApache POIでは以下のような問題が頻発します。

  • OOM (OutOfMemoryError):全データをメモリに展開してアプリが落ちる

  • タイムアウト:処理が重すぎて画面が固まる

  • リソース枯渇:CPU/IOが専有され、他のユーザーに迷惑がかかる

今回は、Alibaba製のライブラリ**「EasyExcel」**の「ページング検索 + 分割書き込み」機能を活用して、これらの問題を解決する方法を紹介します。コピペで使えるユーティリティクラスも用意しました。


1. なぜエクスポート機能は「死ぬ」のか?


解決策:EasyExcelの「ページング書き込み」

核心となる考え方は**「分割して処理する(Divide and Conquer)」**です。

  1. ページング検索:DBから少しずつデータを取得する(例:2,000件ずつ)

  2. 分割書き込み:取得した分だけExcelに書き込む

  3. メモリ解放【重要】書き終わったListを即座に破棄する

  4. ループ:全データが終わるまで繰り返す


2. 【基本】ページング書き込みの実装(10万件〜)

まずは、ページング書き込みのロジックをカプセル化したヘルパークラスを作成します。これを使えば、どんなEntityでも汎用的に処理できます。

コアクラス:PageWriteExcelHelper.java

import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.ExcelWriter;
import com.alibaba.excel.write.metadata.WriteSheet;
import java.io.OutputStream;
import java.util.List;

/**
 * 【核心】ページング書き込みヘルパー - OOM対策の要
 */
public class PageWriteExcelHelper<T> {

    // 🎯 重要なインターフェース:ページング取得ロジック(呼び出し元で実装)
    public interface PageQuerySupplier<T> {
        List<T> getPage(int pageNum, int pageSize); // 何ページ目? サイズは?
    }

    /**
     * ページング書き込み実行
     * @param outputStream  出力ストリーム(HttpServletResponseなど)
     * @param head          データモデルClass (例: User.class)
     * @param pageSize      【重要】1回あたりの処理件数 (推奨 1000~5000)
     * @param totalCount    総データ件数
     * @param supplier      データ取得ロジック
     */
    public static <T> void writeByPage(OutputStream outputStream,
                                      Class<T> head,
                                      int pageSize,
                                      int totalCount,
                                      PageQuerySupplier<T> supplier) {
        // 🔧 1. ExcelWriterの初期化
        ExcelWriter excelWriter = EasyExcel.write(outputStream, head).build();
        WriteSheet writeSheet = EasyExcel.writerSheet("Sheet1").build();

        try {
            // 📐 2. 総ページ数の計算
            int totalPage = totalCount > 0 ? (int) Math.ceil((double) totalCount / pageSize) : 1;

            // 🔁 3. ループ処理:取得 -> 書き込み -> 解放
            for (int pageNum = 1; pageNum <= totalPage; pageNum++) {
                // 🚚 3.1 データ取得(コールバック実行)
                List<T> pageData = supplier.getPage(pageNum, pageSize);

                // ✍️ 3.2 Excelへの書き込み
                excelWriter.write(pageData, writeSheet);

                // 🗑️ 3.3 【最重要】メモリを即時解放!
                pageData.clear();
            }
        } finally {
            // 🔒 4. 【必須】リソース解放(メモリリーク防止)
            if (excelWriter != null) {
                excelWriter.finish(); 
            }
        }
    }
}

3. 【応用】即戦力ユーティリティ(ExcelExporter)

さらに使いやすくするために、レスポンスヘッダーの設定や例外処理を含めたラッパークラスを作ります。コントローラーからはこれを呼び出すだけです。

ExcelExporter.java

import com.alibaba.excel.EasyExcel;
import javax.servlet.http.HttpServletResponse;
import java.io.OutputStream;
import java.net.URLEncoder;
import java.util.List;

/**
 * 【即戦力】EasyExcel拡張ユーティリティ
 */
public class ExcelExporter {

    // ============== 【1. 大量データ用(ページング)】 ==============
    public static <T> void exportByPage(HttpServletResponse response,
                                        String fileName,    // ファイル名
                                        Class<T> dataModel, // データクラス
                                        int pageSize,       // ページサイズ
                                        int totalCount,     // 総件数
                                        PageWriteExcelHelper.PageQuerySupplier<T> pageSupplier) {

        setupResponse(response, fileName);

        try (OutputStream out = response.getOutputStream()) {
            // ヘルパーに委譲
            PageWriteExcelHelper.writeByPage(out, dataModel, pageSize, totalCount, pageSupplier);
        } catch (Exception e) {
            throw new RuntimeException("エクスポート失敗: " + e.getMessage(), e);
        }
    }

    // ============== 【2. 小規模データ用(一括)】 ==============
    public static <T> void exportSimple(HttpServletResponse response,
                                        String fileName,
                                        String sheetName,
                                        Class<T> dataModel,
                                        List<T> dataList) {
        setupResponse(response, fileName);
        try (OutputStream out = response.getOutputStream()) {
            EasyExcel.write(out, dataModel).sheet(sheetName).doWrite(dataList);
        } catch (Exception e) {
            throw new RuntimeException("エクスポート失敗", e);
        }
    }

    // ============== 【共通:レスポンスヘッダー設定】 ==============
    private static void setupResponse(HttpServletResponse response, String fileName) {
        try {
            response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
            response.setCharacterEncoding("UTF-8");
            // 日本語ファイル名の文字化け対策
            String encodedFileName = URLEncoder.encode(fileName, "UTF-8").replaceAll("\\+", "%20");
            response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + encodedFileName + ".xlsx");
        } catch (Exception e) {
            throw new RuntimeException("Header設定失敗", e);
        }
    }
}

Controllerでの使用例

これだけで、メモリ使用量を一定に保ったまま100万件のデータが出力できます。

@GetMapping("/export/users/large")
public void exportLargeUserList(HttpServletResponse response) {
    // 1. 総件数を取得
    int total = userService.countTotalUsers();

    // 2. エクスポート実行
    ExcelExporter.exportByPage(
            response,
            "全ユーザーリスト",
            User.class,
            3000,    // 1回あたり3000件処理
            total,   // 総件数
            // Lambdaでページング検索ロジックを渡す
            (pageNum, pageSize) -> userService.findByPage(pageNum, pageSize) 
    );
}

4. さらなる最適化テクニック

  1. 動的ページサイズの計算 固定値(3000件など)ではなく、1行あたりのバイト数とJVMの空きメモリから逆算して、最適なページサイズを動的に決定するとさらに安定します。

  2. 非同期処理(Async) 50万件を超えると、どうしても物理的な書き込み時間がかかります(数十秒〜分単位)。 ユーザーを待たせないために、@Async でバックグラウンド処理し、進捗状況(%)を別APIでポーリングする設計が推奨されます。

まとめ

  • 1万件以下:一括書き込みでOK(早くて簡単)。

  • 1万〜50万件:今回紹介した「ページング書き込み」が必須。

  • 50万件以上:「ページング書き込み」+「非同期処理(タスク化)」を検討。

EasyExcelのページング機構を使えば、OOMの恐怖から解放されます。ぜひプロジェクトに取り入れてみてください。

いいなと思ったら応援しよう!