미니앱 50개의 화면 상단 여백이 좁았습니다. 고칠 곳은 파일마다 딱 한 줄이었습니다. 그래서 스크립트로 53개를 한꺼번에 고쳤고, 53개 파일의 한글이 전부 사라졌습니다.
1. 사고를 낸 두 줄
PowerShell로 파일을 읽어 치환하고 다시 쓰는, 흔한 일괄 수정입니다.
$c = Get-Content $path -Raw
[System.IO.File]::WriteAllText($path, $c.Replace($old, $new), $utf8)
여기서 Get-Content -Raw는 파일을 시스템 기본 코드페이지로 읽습니다. 제 환경에서는 cp949입니다. 파일은 UTF-8인데 cp949로 읽었으니, 한글 세 바이트가 엉뚱한 글자 두 개로 해석됩니다. 그걸 다시 UTF-8로 저장하면 원본은 사라집니다.
'세차지수' (UTF-8: ec 84 b8 ec b0 a8 ec a7 80 ec 88 98)
↓ cp949로 읽음
U+003F U+BA84 U+AC10 U+F9DE U+0080 U+003F U+003F
U+003F는 물음표입니다. cp949로 표현할 수 없는 바이트를 물음표로 치환한 자리입니다. 이 자리의 원본 바이트는 그 순간 없어집니다. 되돌릴 방법이 없습니다.
2. 검사를 통과한 손상
수정 직후 확인은 했습니다. 두 가지를 봤고, 둘 다 통과했습니다.
$ file src/pages/index.tsx
src/pages/index.tsx: Unicode text, UTF-8 text
UTF-8이 맞다고 나옵니다. 당연합니다. 깨진 글자도 UTF-8로 저장하면 올바른 UTF-8입니다. file은 형식을 보지 내용을 보지 않습니다.
두 번째로 코드포인트를 찍어 봤습니다.
CODEPOINTS: U+002F U+002F U+0020 U+2605 U+B0B4 U+BE44 U+AC8C U+C774 U+C158
EXPECT : U+002F U+002F U+0020 U+2605 U+B0B4 U+BE44 U+AC8C U+C774 U+C158
정확히 일치합니다. 그래서 넘어갔습니다.
그런데 제가 찍어 본 건 제가 그 순간 새로 써 넣은 주석 한 줄이었습니다. 그건 깨질 수가 없습니다. 방금 쓴 거니까요. 정작 봐야 했던 건 파일의 나머지 전부였고, 거기는 보지 않았습니다.
손상이 드러난 건 그다음이었습니다.
src/pages/index.tsx(31,58): error TS1002: Unterminated string literal.
3. 텍스트만 깨진 게 아니었습니다
문자열이 안 닫혔다는 오류가 이상했습니다. 따옴표는 ASCII라 안전할 텐데요.
title: '빨래지수', emoji: '👕'
↓
title: '[깨진글자], emoji: '
닫는 따옴표가 없어졌습니다. cp949는 2바이트 문자를 쓰는데, 앞 글자의 바이트 정렬이 어긋나면서 따옴표 바이트를 뒷글자의 일부로 먹어버린 것입니다.
같은 이유로 주석은 줄바꿈을 먹었습니다.
// [깨진 주석] const [w, a] = await Promise.all([
다음 줄 코드가 통째로 주석 안으로 빨려 들어갔습니다. 텍스트가 아니라 파일 구조가 무너진 것입니다.
4. 깨진 글자가 멀쩡한 한글로 보입니다
복구 도구를 만들면서, 어디가 깨졌는지 자동으로 찾으려고 했습니다. "정상 한글이 아닌 문자를 찾자"는 접근이었는데 안 됐습니다.
됱 딅 뒗 湲 肄
앞의 셋은 유효한 한글 음절입니다. 유니코드 표에서 한글 자리에 정상적으로 존재하는 글자입니다. 사람이 읽으면 말이 안 되지만, 프로그램에게는 가~힣 범위 안의 평범한 한글입니다.
그래서 "글자 종류로 손상을 판정한다"는 방법 자체가 성립하지 않았습니다. 판정 기준을 무너진 결과를 보는 쪽으로 바꿔야 했습니다. 주석 줄 안에 const가 있으면 그건 손상입니다.
5. 되돌린 방법. 추측이 아니라 재현
git이 없었습니다. 이 디렉터리만 저장소가 아니었습니다. 그게 사고를 사고로 만들었습니다.
대신 빌드 산출물이 있었습니다. 전날 밤 정상 소스로 빌드한 .ait 번들입니다. 열어 보니 압축되지 않은 JS였고, 소스 경로 주석까지 그대로였습니다.
// src/pages/index.tsx
var Route = createRoute("/", {
screenOptions: { headerShown: false, title: "세차지수" }
});
한글이 온전합니다. 원문을 되찾았습니다. 그런데 이걸 어느 자리에 넣을지가 남았습니다. 깨진 파일에서 "이 자리가 원래 세차지수였다"를 알아내야 했습니다.
순서로 맞추려다 실패했습니다. 골격(숫자·기호)으로 맞추려다 또 실패했습니다. 순수 한글 문자열은 골격이 비어 있어 구분이 안 됐습니다.
풀린 지점은 방향을 뒤집었을 때였습니다. 사고를 낸 그 API를 다시 부르면 됩니다.
[System.Text.Encoding]::Default.GetString($bytes)
→ U+003F U+BA84 U+AC10 U+F9DE U+0080 U+003F U+003F (파일과 정확히 일치)
손상은 결정론적이었습니다. 그러니 후보 원문을 같은 방식으로 일부러 손상시켜 파일과 대조하면 됩니다. 추측이 아니라 대조입니다. 맞으면 그 자리가 맞는 겁니다.
덤으로, 삼켜진 따옴표도 같이 복원됐습니다. 세차지수를 손상시킨 결과와 세차지수'를 손상시킨 결과가 같기 때문입니다. 뒤따르는 글자를 보고 어느 쪽인지 판정하면 구조까지 돌아옵니다.
6. 아낀 용량이 복구를 막았습니다
가장 씁쓸한 부분입니다.
사고 전날 밤, 번들 용량을 줄이려고 소스맵에서 원본 소스 텍스트를 뺐습니다. 번들의 69%가 소스맵이고 그 절반이 소스 텍스트라 앱당 1MB 넘게 줄었습니다. 좋은 최적화였습니다.
{ name: 'slim-sourcemap', config: { esbuild: { sourcesContent: false } } }
그 sourcesContent가 소스 원문을 통째로 담고 있는 자리입니다. 그게 살아 있었다면 복구는 파일 하나 꺼내 쓰는 일이었습니다. 하루 전에 제 손으로 지웠습니다.
백업은 "언제 쓸지 모르는 것"이라 최적화 대상으로 보입니다. 쓰게 되는 날은 하필 그 다음 날입니다.
7. 남는 것
Get-Content -Raw는 UTF-8을 읽지 않습니다. 시스템 코드페이지로 읽습니다. 읽고 쓸 거면-Encoding utf8을 붙이거나, 애초에 다른 도구를 쓰세요.- "UTF-8입니다"는 "내용이 맞습니다"가 아닙니다. 형식 검사는 깨진 파일을 전부 통과시킵니다.
- 내가 방금 쓴 줄을 검증하는 건 검증이 아닙니다. 건드린 파일 전체에 걸어야 합니다. 저는 한 줄만 보고 넘어갔습니다.
- 되돌릴 수 있는 손상과 없는 손상이 있습니다. 잘못된 인코딩 왕복은 대개 되돌릴 수 있지만, 물음표 치환이 한 번이라도 일어나면 끝입니다. 그 자리의 원본은 사라집니다.
- 깨진 글자가 유효한 한글로 보입니다. 글자 종류로 손상을 찾겠다는 계획은 세우지 마세요.
- 일괄 수정 전에
git init. 30초면 됩니다. 이번엔 그 30초가 여섯 시간이 됐습니다.
고칠 곳이 한 줄이면 위험도 한 줄일 것 같습니다. 위험한 건 고치는 내용이 아니라 고치는 방법이었습니다.
만든 것들
'Coding, Testing, Challenge' 카테고리의 다른 글
| API 키를 클라이언트에 두지 않는 법, 워커 한 대로 앱 50개를 가렸습니다 (0) | 2026.09.27 |
|---|---|
| 미니앱 광고, 배너광고에서 전면광고로 바꾸니 수익이 스무 배가 됐습니다 (0) | 2026.09.26 |
| 100MB를 15밀리초에 보냈다고 믿었습니다 (0) | 2026.09.23 |
| 열고, 아무것도 안 하고, 저장했더니 값이 바뀌었습니다 (0) | 2026.09.21 |
| API 9개가 404였는데, 서버 로그에는 아무것도 없었습니다 (1) | 2026.09.19 |