개발/SPRING

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

애쿠 2025. 5. 27. 10:17

GPT 썸네일이 어그로를 끌었다

개요

서비스에 파일 다운로드 시나리오가 추가되었다. 과부하에 대응하기 위해 파일을 인메모리에 저장하지 않고 스트림을 통해 클라이언트에 전달하는 방식을 사용했다. 이에따라 응답에 파일 이름을 전달할 수 없게 됐고, 파일명을 다른 방식으로 전달할 수 밖에 없었다.

 

어떻게 처리할까 고민하다가 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에 대한 예시는 다음 문서에서 확인할 수 있다.

https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS/Errors/CORSMissingAllowHeaderFromPreflight

 

요약 정리

🔒 Content-Disposition은 표준 헤더지만 JS에서 접근하려면 expose가 필요
🛡️ 브라우저는 CORS 응답 헤더를 통해 어떤 정보가 노출될지를 엄격히 제어
💡 에러 메시지가 아니라 브라우저의 의도를 이해하는 것이 진짜 디버깅

 

마치며

브라우저와 Android, iOS 외의 PC 어플리케이션까지 고려할게 많은 서버 개발은 정말 어렵다.

 

단말들이 OK 했다고 브라우저가 잘되는건 또 아니고, 브라우저가 줄 수 있는 정보를 단말이 줄 수 없는 경우도 있다.

 

때문에 초기 정책에 대한 설계가 정말 중요하지만, 나도 아직 미숙해서인지 자꾸 구멍이 생긴다.

 

특히 이번처럼 엇박자로 문제가 생길 수 있어서 다기종 대상 개발은 어려운 것 같다...