Spring BootでのExcelエクスポート時に発生するStream is closedエラーの原因と対処法
最近、バックエンド管理システムのデータエクスポート機能を実装する際、Spring BootとApache POIを使用したが、デバッグ中に予期しないエラーに直面した。ファイルのダウンロードは正常に機能し、ブラウザでも開けるものの、コンソールには「java.io.IOException: UT010029: Stream is closed」というエラーが表示される。このエラーは本質的な動作には影響を与えないが、ログが赤く染まるのは気になるところである。さらに困惑なのは、コード内で明示的にHttpServletResponseの出力ストリームを閉じているわけではないにもかかわらず、ストリームが2回閉じられているように見える点である。
同様の状況に遭遇したことがある読者や、Spring MVCのHTTP応答処理メカニズムに興味がある方は、本記事が原因究明に役立つかもしれない。このエラーの解決だけでなく、Springが裏で行っている処理や、なぜ無害に見える戻り値がこのような問題を引き起こすのかを理解することが重要である。これはファイルダウンロードやストリーム応答のインターフェースを書く上で不可欠な知識である。
1. 問題の現象と初期調査:静かなエラー
典型的なエラーシナリオを再現してみよう。以下は標準的なSpring Bootコントローラーメソッドの例である。
@RestController
@RequestMapping("/api/report")
public class ReportExportController {
@GetMapping("/download")
public ResponseModel<String> generateExcel(HttpServletResponse response) throws IOException {
// 1. レスポンスヘッダーを設定し、ブラウザにファイルダウンロードを指示
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setHeader("Content-Disposition", "attachment; filename=\"data.xlsx\"");
// 2. Excelワークブックを作成し、データを挿入
Workbook workbook = new XSSFWorkbook();
Sheet sheet = workbook.createSheet("Data");
// ... データ挿入ロジックを省略 ...
Row row = sheet.createRow(0);
row.createCell(0).setCellValue("Sample Data");
// 3. ワークブックをレスポンス出力ストリームに書き込む
ServletOutputStream out = response.getOutputStream();
workbook.write(out);
workbook.close();
// 4. 操作成功を示すJSONレスポンスを返す
return ResponseModel.success("エクスポート完了");
}
}
このコードのロジックは明確である。レスポンスヘッダーを設定し、Excelを生成し、ストリームに書き込み、成功メッセージを返す。PostmanやブラウザでこのAPIを呼び出すと、data.xlsxファイルが正常にダウンロードされる。しかしIDEのコンソールやログファイルを開くと、以下のスタックトレースが表示される。
java.io.IOException: UT010029: Stream is closed
at io.undertow.servlet.spec.ServletOutputStreamImpl.write(ServletOutputStreamImpl.java:138)
at com.fasterxml.jackson.core.json.UTF8JsonGenerator._flushBuffer(UTF8JsonGenerator.java:2171)
at com.fasterxml.jackson.core.json.UTF8JsonGenerator.flush(UTF8JsonGenerator.java:1184)
at com.fasterxml.jackson.databind.ObjectWriter.writeValue(ObjectWriter.java:1009)
at org.springframework.http.converter.json.MappingJackson2HttpMessageConverter.writeInternal(MappingJackson2HttpMessageConverter.java:456)
... [Spring MVC内部呼び出しチェーン]
スタックトレースの重要な点は、UT010029(Undertowサーバーのエラーコード)とMappingJackson2HttpMessageConverterの出現である。これは、Spring MVCがメソッドの戻り値(ResponseModel.success("エクスポート完了"))をJSONにシリアライズし、レスポンスボディに書き込もうとした際にエラーが発生したことを示している。
ここで疑問になるのは、「Excelデータはすでにresponse.getOutputStream()で書き終わっているのだから、Springがストリームに書き込もうとするのはなぜか?」という点である。
注意:このエラーはUndertowに限ったものではなく、Tomcatでは
java.io.IOException: Stream closed、Jettyでは類似のメッセージが表示される。根本原因はServlet仕様における出力ストリーム操作の約束事であり、特定のアプリケーションサーバーによるものではない。
2. 原理の深掘り:Spring MVCの応答処理フロー
この問題を理解するには、Spring MVCがコントローラーメソッドのリクエストを処理する全体的なフローを分解する必要がある。これは単に@ResponseBodyやHttpServletResponseだけでなく、HandlerMethodReturnValueHandlerという一連の処理チェーンに関係している。
2.1 戻り値ハンドラの協調処理
コントローラーメソッドが実行終了した後、Spring MVCは戻り値をどのように処理するかを決定する。この処理にはHandlerMethodReturnValueHandlerのリストが関与し、それぞれが特定の戻り値タイプや注釈に対応している。
| ハンドラタイプ | 処理対象の戻り値タイプ/注釈 | 主な動作 |
|---|---|---|
ModelAndViewMethodReturnValueHandler |
ModelAndView オブジェクト |
JSPやThymeleafなどのテンプレートをレンダリングするためにビュー名とモデルデータを解析。 |
ViewMethodReturnValueHandler |
View オブジェクト |
直接返されたViewオブジェクトを利用してレンダリング。 |
HttpEntityMethodReturnValueHandler |
HttpEntity オブジェクト |
HTTPエンティティを直接扱うための処理。 |