Spring Boot에서 스트림 다운로드 시 filename이 전달되지 않는 문제 해결하기 : Content-Disposition

개요
서비스에 파일 다운로드 시나리오가 추가되었다. 과부하에 대응하기 위해 파일을 인메모리에 저장하지 않고 스트림을 통해 클라이언트에 전달하는 방식을 사용했다. 이에따라 응답에 파일 이름을 전달할 수 없게 됐고, 파일명을 다른 방식으로 전달할 수 밖에 없었다.
어떻게 처리할까 고민하다가 Content-Disposition 헤더를 알게되서 이번에 사용해봤다. 동작 자체는 잘 됐지만, 다기종과 연동하면서 생긴 문제를 함께 정리해보려고 한다.
Content-Disposition란?
일반적인 HTTP 응답에서 Content-Disposition 헤더는 컨텐츠가 브라우저에 inline 되어야 하는 웹페이지 자체이거나 웹페이지의 일부인지, 아니면 attachment 로써 다운로드 되거나 로컬에 저장될 용도록 쓰이는 것인지를 알려주는 헤더입니다.
(중략)
Content-Disposition 헤더는 광의의 MIME 맥락 속에서 정의되었는데, 그 정의에서 사용되는 파라미터 중 일부인 form-data, name 그리고 filename만이 HTTP forms와 POST 요청에 적용될 수 있습니다. 여기서 name과 filename은 필수적인 파라미터는 아닙니다.
https://developer.mozilla.org/ko/docs/Web/HTTP/Reference/Headers/Content-Disposition
정리하면 Content-Disposition 헤더는 브라우저가 콘텐츠를 어떻게 처리해야 하는지를 지시하는 헤더이다. 파일을 "보여줄지" 아니면 "다운로드할지", 그리고 "어떤 이름으로 저장할지"를 알려준다.
// 브라우저 안에서 직접 보여주기 (예: 이미지, PDF 뷰어 등)
Content-Disposition: inline
// 브라우저가 다운로드 창을 띄움
// 사용자가 파일을 다운로드할 때 기본 이름으로 example.pdf으로 전달
Content-Disposition: attachment; filename="example.pdf"
// UTF-8 인코딩된 파일명을 사용 가능 (예: 한글 처리에 적합)
// 부분 브라우저는 filename*을 우선
Content-Disposition: attachment; filename="backup.txt"; filename*=UTF-8''백업파일.txt
여기까진 응답에 대한 처리이고, 요청에서도 다음과 같이 사용된다.
------boundary
Content-Disposition: form-data; name="file"; filename="resume.pdf"
Content-Type: application/pdf
(binary data)
------boundary--
스프링에서는 굳이 파볼 필요가 없었던게 MultipartFile의 originalFilename으로 자동으로 매핑해서 제공해준다. 그래서 몰랐던 것 같다.
백엔드에서 구현
@PostMapping("/download")
override fun download(@RequestBody payload: Any, response: HttpServletResponse) {
// 단일 파일 다운로드
if (/* 단일 파일 조건 */) {
response.contentType = "application/octet-stream"
val fileName = /* 파일 이름 생성 */
response.setHeader("Content-Disposition", "attachment; filename*=UTF-8''$fileName")
// 파일 데이터 스트리밍
/* response.outputStream.write(...) */
}
// ZIP 파일 다운로드
else {
response.contentType = "application/zip"
val fileName = /* ZIP 파일 이름 생성 */
response.setHeader("Content-Disposition", "attachment; filename=\"$fileName\"")
// ZIP 데이터 스트리밍
/* response.outputStream.write(...) */
}
response.status = HttpServletResponse.SC_OK
response.flushBuffer()
}
구현 자체는 간단하다 원래 비어있던 헤더라 값을 채워서 보내주면 끝이다. 먼저 개발이 완료되었던 Android/iOS 단말에선 깔끔하게 처리 됐다. 문제는 그 다음인데...
FE에서 발생한 CORS 문제
그런데 다운로드를 실행했을 때 FE가 CORS 에러가 난다는 연락을 받았다. 처음 연락을 받았을 때, 내가 아는 CORS 에러는 서로 다른 도메인에서 요청이 왔을 때 나는건데 왜 이 에러가 났을까? 한참 고민했었다. 그래서 조금 조사를 해보니 다음과 같은 경우가 있었다.
1. 서버가 preflight OPTIONS 요청에 Access-Control-Allow-Origin을 안 준 경우
2. 서버가 Access-Control-Allow-Headers에 허용되지 않은 커스텀 요청 헤더를 사용한 경우
3. 요청이 아예 브라우저에서 차단됨 (403 수준)
Content-Disposition 헤더는 HTTP 응답에서 기본으로 제공하는 헤더가 아니다. 때문에, 서버에서는 브라우저가 이 헤더를 확인할 수 있도록 아래와 같이 헤더를 추가해줘야 한다.
response.setHeader("Access-Control-Expose-Headers", "Content-Disposition")
그런데 Content-Disposition 헤더는 커스텀 헤더도 아닌데 왜 막아뒀을까? 이는 브라우저 보안 정책(CORS)의 설계 철학 때문이라고 한다.
"교차 출처 리소스에 대해, 민감할 수도 있는 헤더는 JS에서 기본적으로 볼 수 없도록 하겠다."
Content-Disposition은 파일 이름, 첨부 상태 등의 UI/UX에 영향을 주는 정보를 포함하고 경우에 따라 민감한 정보나 사용자 파일명을 포함할 수 있기 때문에 브라우저는 이를 기본적으로 JavaScript에서 숨긴다.
Content-Disposition CORS에 대한 예시는 다음 문서에서 확인할 수 있다.
요약 정리
🔒 Content-Disposition은 표준 헤더지만 JS에서 접근하려면 expose가 필요
🛡️ 브라우저는 CORS 응답 헤더를 통해 어떤 정보가 노출될지를 엄격히 제어
💡 에러 메시지가 아니라 브라우저의 의도를 이해하는 것이 진짜 디버깅
마치며
브라우저와 Android, iOS 외의 PC 어플리케이션까지 고려할게 많은 서버 개발은 정말 어렵다.
단말들이 OK 했다고 브라우저가 잘되는건 또 아니고, 브라우저가 줄 수 있는 정보를 단말이 줄 수 없는 경우도 있다.
때문에 초기 정책에 대한 설계가 정말 중요하지만, 나도 아직 미숙해서인지 자꾸 구멍이 생긴다.
특히 이번처럼 엇박자로 문제가 생길 수 있어서 다기종 대상 개발은 어려운 것 같다...