본문 바로가기
빌드노트 검색

빌드노트

한글 다운로드 파일명 대신 report.txt가 제안될 때

한글 다운로드 이름 대신 ASCII fallback이 제안될 때 filename과 filename*을 구별한다. 실제 Chrome 다운로드 여섯 조건에서 제안 이름과 동일한 27바이트 본문을 대조하고, 공백의 %20 표현과 중복 인코딩을 확인하는 방법을 설명한다.

다운로드할 파일은 월간 보고서.txt인데 브라우저가 report.txt를 제안한다면, 응답의 Content-Disposition을 먼저 확인한다. 서버가 filename="report.txt"만 보냈다면 한글 이름을 전달한 적이 없는 셈이다. ASCII 대체 이름은 filename, 한글을 포함한 UTF-8 이름은 filename*에 나눠 담는다. 본문을 UTF-8로 보내는 것만으로 이 헤더의 파일명이 바뀌지는 않는다.

예를 들어 다음은 하나의 응답에 넣을 헤더 한 줄이다.

Content-Disposition: attachment; filename="report.txt"; filename*=UTF-8''%EC%9B%94%EA%B0%84%20%EB%B3%B4%EA%B3%A0%EC%84%9C.txt

report.txt는 확장 매개변수를 이해하지 못하는 클라이언트를 위한 fallback이다. filename*을 이해하는 클라이언트는 월간 보고서.txt를 선택할 수 있다. RFC 6266 §4.3 (새 탭에서 열림)은 두 값이 함께 있으면 filename*을 우선하고 filename을 무시하도록 권고한다. 서로 다른 두 이름을 보냈다고 해서 오류인 것은 아니다.

같은 본문을 받았는데 제안 이름은 달랐다

운영 서비스 대신 127.0.0.1의 임의 포트에 작은 서버를 열었다. Windows 11, Python 3.12.10, headless Chrome 153.0.8010.36에서 일반 링크를 눌러 다운로드했다. 링크에 download 속성을 넣거나 JavaScript로 Blob 파일을 만들지 않았다. 응답은 모두 HTTP 200과 text/plain; charset=utf-8이었고, 본문은 마지막 줄바꿈을 포함한 합성 다운로드 본문\n으로 고정했다.

아래는 각 응답 헤더 조건과 브라우저가 실제로 제안한 이름이다. 제안 이름은 브라우저의 다운로드 이벤트에서 읽었으며, 자동화 도구가 정한 저장 경로에서 추측하지 않았다.

  1. filename="report.txt"만 보냄 → report.txt
  2. filename="monthly report.txt"만 보냄 → monthly report.txt
  3. 한글·공백을 percent encoding한 문자열을 filename에만 넣음 → 월간 보고서.txt
  4. filename="report.txt"와 위의 올바른 filename*을 함께 보냄 → 월간 보고서.txt
  5. 4번에서 filename*의 %20만 +로 바꿈 → 월간+보고서.txt
  6. 4번의 인코딩된 이름을 다시 인코딩해 %EC가 %25EC로 시작하게 보냄 → %EC%9B%94%EA%B0%84%20%EB%B3%B4%EA%B3%A0%EC%84%9C.txt

각 조건은 새 다운로드 폴더에 저장했다. 별도 HTTP 요청으로 받은 원문 응답과 브라우저가 받은 응답 헤더를 대조했고, 저장된 여섯 파일도 원본과 바이트 단위로 비교했다. 모두 27바이트로 같았다. 이 비교에서는 파일명 선택만 달라졌으며 본문 손상은 없었다.

3번은 특히 주의해서 읽어야 한다. 이 Chrome에서 성공한 방식이 모든 브라우저에서 같은 이름을 보장하지는 않는다. MDN의 응답 헤더 설명 (새 탭에서 열림)은 filename 안의 percent escape를 Chrome·Firefox는 풀지만 Safari는 풀지 않는다고 설명한다. Firefox와 Safari는 이번에 실행하지 않았다. filename에 인코딩된 한글만 넣는 방식보다 ASCII fallback과 filename*을 함께 보내는 형태를 택할 근거다.

filename*의 공백은 +가 아니라 %20이다

UTF-8''의 작은따옴표 두 개는 장식이 아니다. 문자 인코딩 이름, 생략한 언어 태그, 인코딩한 파일명 값을 구분한다. 언어를 생략해도 구분자는 남긴다. filename*의 값 전체를 큰따옴표로 감싸지 않는다.

RFC 8187 §3.2.1과 §3.2.3 (새 탭에서 열림)에 따르면 송신자는 UTF-8을 사용하고, 허용 문자 밖의 바이트를 %와 두 자리 16진수로 표현한다. 이 문서는 RFC 6266이 참조하는 RFC 5987을 대체했다. 월의 UTF-8 바이트는 EC 9B 94이므로 %EC%9B%94가 되고, 공백은 %20이 된다.

+는 이 문법에서 공백으로 복원하는 기호가 아니라 그대로 쓸 수 있는 문자다. 그래서 5번은 한글이 깨지거나 fallback으로 돌아간 것이 아니라, 공백 자리에 더하기 기호가 들어간 다른 이름을 전달했다. 쿼리의 form-urlencoded 인코딩에서 쓰는 공백 치환 규칙을 응답 파일명에 적용하면 이런 차이가 생긴다.

Python 3.12가 있다면 다음 내용을 UTF-8 파일 header-check.py로 저장하고, 그 폴더에서 python header-check.py를 실행해 헤더 값을 만들 수 있다. 외부 패키지는 필요 없다.

from urllib.parse import quote

name = "월간 보고서.txt"
encoded = quote(name, safe="", encoding="utf-8")
disposition = f"attachment; filename=\"report.txt\"; filename*=UTF-8''{encoded}"
print("Content-Disposition:", disposition)

Python 3.12.10에서 실행한 출력은 글 앞의 헤더 한 줄과 같았다. 서버에서는 출력 문자열 전체를 헤더 이름으로 쓰는 대신, Content-Disposition 헤더의 값으로 disposition을 전달한다. report.txt는 고정 fallback이므로 실제 파일의 의미에 맞는 ASCII 이름으로 정한다.

Python의 quote·quote_plus 문서 (새 탭에서 열림)처럼 quote는 공백을 %20으로 표현하고 quote_plus는 +로 바꾼다. 여기서는 quote를 사용한다. name에는 %EC...로 이미 인코딩한 값이 아니라 원문을 넣는다. 6번처럼 다시 인코딩하면 % 자체가 %25로 표현되어, 한 번 복원한 제안 이름에 percent 문자열이 남을 수 있다.

실제 응답과 첫 제안 이름을 함께 확인한다

자기 서버에서 확인할 때는 다운로드를 발생시키기 전에 브라우저 개발자 도구의 Network 기록을 켠다. 테스트용 작은 텍스트 첨부를 내려주는 주소에서 다음을 비교한다.

  1. 다운로드 응답이 실제로 200인지 보고, 브라우저가 받은 응답 헤더의 Content-Disposition 원문을 읽는다. 서버 코드나 서버 로그에 적힌 예정 값만으로 판단하지 않는다.
  2. 위의 한 줄처럼 ASCII filename과 UTF-8 filename*이 함께 있는지 확인한다. filename*의 UTF-8'' 접두사, 공백의 %20, %25EC처럼 중복 인코딩된 흔적을 본다.
  3. 새 빈 다운로드 폴더를 사용한다. 저장 대화상자가 뜨면 직접 이름을 고치기 전의 제안을 기록하고, 자동 저장이면 다운로드 목록의 이름을 확인한다. 기존 파일 때문에 붙는 중복 접미사는 헤더 선택 문제와 구분한다.

여전히 report.txt가 제안된다면, 먼저 실제 응답에 유효한 filename*이 도착했는지 확인한다. 도착하지 않았다면 응답을 만드는 코드와 중간 서버의 헤더 처리를 조사한다. 도착했다면 브라우저 버전과 다운로드 경로를 함께 기록한다. filename*을 지원하지 않는 클라이언트가 ASCII fallback을 택하는 것은 준비해 둔 대체 동작이다. 반대로 %EC...가 이름에 그대로 보이면 filename에만 넣었는지, 또는 filename* 값을 두 번 인코딩했는지 구별한다.

RFC 6266 (새 탭에서 열림)은 파일명을 클라이언트에 대한 제안으로 취급한다. 헤더가 최종 저장 경로나 사용자가 고친 이름까지 강제하는 것은 아니다. 이번에 확인한 범위는 Windows의 headless Chrome에서 직접 내려받은 고정 텍스트 첨부다. 다른 브라우저·운영체제, 프록시를 통과한 응답, fetch 후 Blob으로 저장하는 코드, HTML의 download 속성과의 상호작용은 검증하지 않았다.

← 전체 글 보기