[스프링] 파일 다운로드 구현 시 Cannot forward to error page for request [] as the response has already been committed 에러
파일 다운로드 하는 부분에서 ErrorPageFilter 에러가 계속 났다.
정확한 원인은 이 부분이 아니지만 무슨 일이 있었나 정리해 보자.
먼저 이 에러를 알아보자.
Cannot forward to error page for request [] as the response has already been committed. As a result, the response may have the wrong status code. If your application is running on WebSphere Application Server you may be able to resolve this problem by setting com.ibm.ws.webcontainer.invokeFlushAfterService to false
에러 상황은 다음과 같다.
- 응답의 커밋 : HTTP 응답이 커밋된다는 것은, 헤더 정보가 이미 클라이언트에게 전송되었고, 응답 본문의 일부가 이미 스트림을 통해 보내졌다는 의미이다. 일단 이 상태가 되면, 응답 헤더나 상태 코드를 변경할 수 없다.
- 에러 페이지로의 전환 실패 : 일반적으로, 서블릿 컨테이너나 스프링 프레임워크는 처리 중 발생한 예외에 대해 설정된 에러 페이지로 요청을 전달하려고 시도한다. 그러나 이미 응답이 이미 커밋된 경우, 이러한 전환은 불가능해진다.
해결 방안은 간단하다.
에러 메시지에도 나와 있듯이 com.ibm.ws.webcontainer.invokeFlushAfterService 속성을 false로 설정하면 된다.
하지만 어떤 사이드 이펙트가 나올지 몰라 무작정 바꾸긴 어려웠다.
그래서 에러메시지에서 이미 응답이 커밋 됐다는 것을 집중해서 더 알아봤다.
다운로드 api 상황
내가 느낀 문제 상황은 다운 로드 후 컨트롤러에서 리턴을 객체로 리턴하는 경우였다.
public static ResultDto download(HttpServletResponse response) {
// ...
Path downloadFile = Paths.get("경로");
response.setHeader("Accept-Ranges", "bytes");
response.setHeader("Content-Transfer-Encoding", "binary");
response.setHeader("Content-Disposition", "attachment;filename=" + fileName + ";");
response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE);
response.setContentLength((int) downloadFile.toFile().length());
try (FileInputStream fileInputStream = new FileInputStream(downloadFile.toFile());
OutputStream outputStream = response.getOutputStream()) {
FileCopyUtils.copy(fileInputStream, outputStream);
} catch (IOException e) {
e.printStackTrace();
return // ...
}
return // ...
}
너무 많이 간추렸나..?
대충 이런 상황이었다.
1. 다운로드 내부에서 HttpServletResponse에 대한 설정을 하고 리턴으로 객체를 넘긴다.
2. @ResponseBody에 의해 컨트롤러에서 JSON 형태로 응답을 준다.
사실상 이 에러가 문제다.
No converter for [] with preset Content-Type 'application/octet-stream'
결론부터 말하면 다음과 같다.
메서드에서 HttpServletResponse의 OutputStream을 사용하여 클라이언트에게 데이터를 직접 쓰고 있다.
이 경우, 컨트롤러 메서드의 리턴 타입이 void나 ResponseEntity<Void>가 되어야 한다.
만약 이 메서드가 int나 다른 타입의 값을 리턴하도록 설계된다면, 스프링 프레임워크는 응답 본문을 이미 채웠음에도 불구하고 추가적인 처리를 시도할 수 있어 문제가 발생할 수 있다.
내가 생각하는 순서를 정리해 보면
- OutputStream을 사용해서 클라이언트에게 직접 리턴을 했다.
- 컨트롤러에 @ResponseBody에 의해 JSON으로 응답을 하려고 하니 'application/octet-stream' Content-Type을 바꿔 줄 컨버터가 없어서 에러가 났다.
- 이 에러를 스프링 프레임 워크의 ErrorPageFilter에서 처리하려고 보니 이미 커밋된 요청이다.
직접 실험은 못해봤지만
다운로드 구현시에 리턴을 void 또는 ResponseEntity<void>로 하면 HttpServletResponse를 사용해서 컨트롤이 가능한 것으로 보인다.
요즘은 HttpServletResponse를 직접 컨트롤하지 않고, ResponseEntity를 이용해서 컨트롤하려고 하는 것 같다.
추가
HttpServletResponse.getOutputStream() 을 사용하여 데이터를 클라이언트에 전송한 후 스트림을 닫으면, 그 응답에 대한 작업은 종료된 것으로 간주된다.
일단 응답 스트림을 닫으면, 같은 요청에 대해 추가적인 데이터를 전송할 수 없다고 한다.
하나의 api 요청에 다운로드와, JSON으로 응답 두개를 내려주려고 하니 문제가 생긴 것으로 보인다.