좌충우돌 히즈넷 첨부파일 이름 되살리기
2026-09-08
Project

히즈넷에서 첨부파일을 받으면 위쪽처럼 저장된다. 아래쪽이 확장을 켠 뒤 같은 파일을 받은 것이다.
다른 파일로 예를 들면 이렇다.
Çѵ¿»ç°è °¡À» Ã¥°¥ÇÇ.png
원래 이름은 한동사계 가을 책갈피.png다. 파일을 여는 데는 지장이 없지만
다운로드 폴더에 이런 게 쌓이면 뭐가 뭔지 알 수가 없다.
사실 이걸 고치는 크롬 확장을 예전에 만들어 뒀었다. 이번에 마저 끝내려고 열어봤는데, 동작하는 줄 알았던 그 코드가 실은 아무 일도 하고 있지 않았다.
되짚으면 되는 줄 알았다
이름이 깨지는 이유는 단순하다. 히즈넷 같은 옛 시스템은 파일 이름을 EUC-KR 바이트 그대로 헤더에 싣는다.
Content-Disposition: attachment; filename="<EUC-KR 바이트>"
HTTP 헤더에는 인코딩을 알려 줄 표준 자리가 따로 있는데(RFC 5987의 filename*)
그걸 쓰지 않으니, 브라우저는 이 바이트를 서유럽 문자로 읽어 버린다.
그럼 방법도 단순해 보인다. 브라우저가 보여주는 이름을 바이트로 되돌린 다음 EUC-KR로 다시 읽으면 된다. 예전의 나도 그렇게 짜뒀다.
const bytes = new Uint8Array(str.length);
for (let i = 0; i < str.length; i++) {
bytes[i] = str.charCodeAt(i) & 0xFF; // Latin-1 이라고 가정
}
return new TextDecoder("euc-kr").decode(bytes);
돌려보니 안 됐다. 정확히는, 조용히 아무것도 하지 않았다. 실패했다는 표시조차 없었다. 디코딩 결과에 U+FFFD(�)가 하나라도 있으면 “복원 실패”로 보고 넘어가게 해뒀는데, 매번 거기에 걸리고 있었다.
브라우저에 직접 물어봤다
왜 실패하는지 짐작으로 알아내려다 포기하고, 브라우저에 직접 물어보기로 했다. 0x80부터 0xFF까지 256개 바이트를 전부 파일 이름에 실어 보내는 서버를 하나 띄우고, 확장이 받아 보는 값을 그대로 찍었다.
두 가지가 나왔다.
첫째, 브라우저는 헤더를 Latin-1이 아니라 Windows-1252로 읽는다.
0x80 -> U+20AC (€) Latin-1 이라면 U+0080 이어야 한다
0x92 -> U+2019 (')
charCodeAt(i) & 0xFF로 되돌리면 U+20AC는 0xAC가 된다. 0x80이 아니라. 0x80~0x9F
구간이 통째로 틀어진다. 게다가 U+20AC는 255보다 크니, “255 이하 문자만 있으면
깨진 이름으로 본다”는 내 판정에서 아예 후보 탈락이었다.
둘째, 되살릴 수 없게 사라지는 바이트가 있다.
| 바이트 | 왜 사라지나 | 결과 |
|---|---|---|
| 0x81 0x8D 0x8F 0x90 0x9D | cp1252에 배정이 없다 | _ |
| 0xAD | 소프트하이픈이라 파일명 정리 단계에서 지워진다 | _ |
마지막 줄이 치명적이었다. 0xAD는 강(B0 AD)의 뒷바이트다. 수강, 강의, 강좌.
학교에서 오는 파일 이름에 안 들어가기가 어렵다.
수강신청 = BC F6 B0 AD BD C5 C3 BB
받은 것 = BC F6 B0 5F BD C5 C3 BB
강의 뒷바이트 0xAD가 _(0x5F)로 바뀌었다. B0 5F는 EUC-KR에서 아무 글자도
아니라서 디코딩하면 그 자리에 U+FFFD가 나오고, 내 코드는 그걸 보고 포기했다.
내 손에 값이 들어온 시점에 이미 원본 바이트가 없었으니, 아무리 잘 되짚어도
복원이 안 되는 게 당연했다.
브라우저가 이름을 짓기 전에 가로채기
방향을 바꿨다. 브라우저가 오독한 결과를 되돌리려 하지 말고, 오독하기 전의 원본을 가져오자.
확장은 webRequest로 응답 헤더를 볼 수 있다. 그리고 여기에 반가운 성질이 하나
있다. 헤더 값이 유효한 UTF-8이 아니면 브라우저는 문자열 대신 바이트 배열로
넘겨준다.
const bytes = h.binaryValue ? new Uint8Array(h.binaryValue) : h.value;
0xAD가 그대로 살아서 온다. 오독도 유실도 겪지 않은 값이다. 이걸 직접 파싱해서
downloads.onDeterminingFilename에서 이름으로 넘기면 된다. 이 훅은 브라우저가
파일을 디스크에 쓰기 전에 부르는 자리라, 받아놓고 이름을 바꾸는 게 아니라
처음부터 제대로 된 이름으로 저장된다.
통과한 테스트에 확장이 없었다
고치고 나서 테스트를 짰다. 히즈넷 같은 서버가 보낼 법한 헤더 아홉 가지를 실제 크롬에 물려 내려받고, 디스크에 저장된 파일 이름을 확인하는 방식이다.
9개 중 5개만 통과했다. 한참을 들여다보다 확인해 보니, 그 테스트는 확장 없이 돌고 있었다. 크롬 137부터 커맨드라인으로 확장을 불러오는 것을 막아뒀는데, 내 검사가 “확장 서비스 워커가 하나라도 있으면 로드된 것”이었다. 브라우저 내장 확장도 서비스 워커를 갖고 있어서 그 검사를 그냥 통과해 버렸다.
이걸 겪고 나서 대조군을 하나 만들어 뒀다. 확장 없이 같은 파일들을 받아 보는 모드다. (케이스는 그 뒤로 열한 개까지 늘었다)
E2E_NO_EXTENSION=1 npm run test:e2e # 5/11
npm run test:e2e # 11/11
차이가 나는 여섯 건이 이 확장이 실제로 하는 일이다. 테스트가 통과한다는 것과 테스트가 무언가를 검증한다는 것은 다른 이야기였다.
파이어폭스는 정반대로
크롬판이 되고 나니 파이어폭스에서도 쓰고 싶어졌다. 그런데 파이어폭스에는
downloads.onDeterminingFilename이 아예 없다. 다운로드 이름을 확장이
바꾸는 API 자체가 없다.
대신 파이어폭스는 응답 헤더를 고쳐 쓰는 것을 아직 허용한다. 그래서 반대로 갔다. 브라우저가 헤더를 읽기 전에 아예 제대로 된 헤더로 바꿔 놓는 것이다.
attachment; filename="<EUC-KR 바이트>"
-> attachment; filename*=UTF-8''2026%ED%95%99%EB%85%84%EB%8F%84%20...
그러면 이름 짓는 일은 파이어폭스가 알아서 제대로 한다. 크롬판이 “브라우저가 지은 이름을 고치는” 쪽이라면 파이어폭스판은 “애초에 제대로 짓게 만드는” 쪽이다. 같은 문제를 정반대 방향에서 푼 셈인데, 판단 로직은 한 파일을 그대로 공유한다.
그런데 진짜 히즈넷에서는 안 됐다
여기까지 전부 통과했다. 그런데 실제 히즈넷에서 첨부파일을 받아보니 여전히 깨진 이름으로 저장됐다.
이상한 건, 확장 팝업에는 바뀐 이름이 멀쩡히 떠 있었다는 점이다. 이 둘은 다른 이야기다. 팝업 기록은 내가 이름을 만들어 넘겼다는 증거일 뿐, 크롬이 그걸 받아들였다는 증거가 아니다.
그래서 확장이 제안한 이름과 실제로 저장된 이름을 같이 기록하게 만들고, 헤더 원본도 16진수로 남기게 했다. 다시 받아보니 이게 나왔다.
attachment; filename= "한동사계 가을 책갈피.png
= 뒤에 공백이 있고, 따옴표를 열기만 하고 닫지 않는다. 헤더는 .png에서
그냥 끝난다. 내 파서는 따옴표 짝이 맞을 때만 벗기게 돼 있었으니, 이름 앞에 "가
그대로 붙었다.
제안한 이름: "한동사계 가을 책갈피.png
실제 저장된 이름: Çѵ¿»ç°è °¡À» Ã¥°¥ÇÇ (1).png
따옴표 하나 때문에 크롬이 제안을 통째로 버렸다. 경고도, 오류도 없이 원래 이름을 그대로 썼다.
정말 따옴표가 원인인지 확인하려고 세 가지 조합을 돌려봤다.
| 제안한 이름 | 디스크에 저장된 것 |
|---|---|
"2026학년도 … .hwp |
2026Çг⵵ … .hwp — 제안이 버려짐 |
_2026학년도 … .hwp |
_2026학년도 … .hwp — 채택됨 |
2026학년도 … .hwp |
2026학년도 … .hwp |
따옴표 하나만 _로 바꿔도 크롬이 받아들인다. "는 윈도우에서 파일 이름에 못
쓰는 문자다. 어느 운영체제에서도 쓸 수 있는 이름이 아니면 크롬은 그 제안을 조용히
버린다.
그래서 두 군데를 고쳤다. 한쪽만 있는 따옴표를 처리하고, " * : < > ? | \ /를
전부 _로 바꾼다. 크롬이 거부할 이름을 애초에 만들지 않는 것이 핵심이다.
마지막으로, 잘 된 파일에 뜬 경고
고치고 나니 이름은 제대로 저장되는데 팝업에 빨간 경고가 떴다. “크롬이 이 제안을 쓰지 않았습니다.”
제안한 이름과 저장된 이름을 문자열로 그냥 비교한 게 문제였다. 성공해도 달라지는 경우가 둘 있다.
- 같은 이름이 이미 있으면 크롬이 ` (1)`을 붙인다
- 맥이 돌려주는 경로는 한글이 분해형(NFD)이다. 내가 만든 쪽은 결합형(NFC)이라 눈에는 똑같아 보여도 문자열로는 다르다
둘 다 걸러내도록 고쳤다. 지금은 팝업이 이렇게 나온다.

진단을 만들어 놓고 그 진단을 또 고치는 꼴이었지만, 어차피 이 진단 덕분에 앞의 따옴표 문제를 찾은 거라 본전은 뽑았다.
결과
크롬·엣지·웨일·Brave는 저장소 폴더를 그대로 불러오면 되고, 파이어폭스와 Floorp는 빌드해서 넣으면 된다. 히즈넷에서 받은 파일만 손대고, 파일 내용은 건드리지 않고 이름만 바꾼다.
단위 44/44 · 크롬 11/11 · 파이어폭스 11/11 · 대조군 5/11
코드는 깃허브에 있다. 설치 방법과, 여기 적은 관측들을 재현하는 방법은 README에 정리해 뒀다.
배운 것
“안 된다”와 “아무 일도 안 한다”는 다르다. 예전 코드는 실패했다고 말하지 않고 그냥 조용히 지나갔다. 실패를 삼키는 코드는 없는 것보다 나쁘다. 있다고 믿게 만들기 때문이다.
남의 브라우저가 뭘 하는지는 물어봐서 알아내는 게 빠르다. 0x80부터 0xFF까지 전부 실어 보내는 데 20분 걸렸고, 그 20분이 며칠짜리 추측을 끝냈다.
그리고 “고쳤다”는 증거는 내 로그가 아니라 디스크에 있는 파일이다. 팝업에는 바뀐 이름이 떠 있었지만 다운로드 폴더에는 깨진 이름이 있었다. 내가 무엇을 했는지가 아니라 결과가 어떻게 됐는지를 봐야 했다.