엑셀의 ? 하나, 수십 번의 시도, 그리고 원점
? 가 나타났다
어느 날 DB를 열어봤는데 업소명이 이상했습니다. ?커피,MOCC. 우?(WooDic). 프?츠. 앱에서도 그대로 노출되고 있었습니다. 분명히 정상적인 가게 이름인데, 특정 글자 자리에 물음표가 박혀 있었습니다.
처음엔 DB 입력 오류인 줄 알았습니다. 데이터를 따라가보니 sync 스크립트가 문제더라고요. 정부 사이트에서 엑셀 파일을 받아 SheetJS로 파싱하는 과정 어딘가에서 글자가 깨졌고, 그 상태로 DB에 저장되고 있었습니다. sync가 돌 때마다 반복됐습니다.
당시엔 정확한 원인을 몰랐습니다. 그냥 깨진다는 것만 알았습니다.
일단 틀어막기
가장 빠른 방법은 수동 패치였습니다. 깨진 이름과 올바른 이름 쌍을 맵으로 만들어서 파싱 결과에 적용했습니다. KOREAN_NAME_PATCH_MAP이라는 이름의 객체에 '?커피,MOCC': '뫀커피,MOCC' 같은 항목들을 넣었습니다. 작동은 했습니다.
불안한 건 처음부터였습니다. 어떤 글자가 앞으로 또 깨질지 알 수 없고, 새 가게가 추가될 때마다 누군가 이 맵을 직접 업데이트해야 했습니다. 데이터 양이 늘어날수록 유지가 안 될 방식이었습니다.
임시방편이라는 걸 알면서 썼습니다. 언젠가 고쳐야 할 것으로 쌓아뒀습니다...
근본을 고쳐보기로 했다
결국 그 "언젠가"가 왔습니다. 데이터가 점점 늘어나면서 맵에 없는 새 글자들이 생겼고, 근본 원인을 파악하기로 했습니다.
가설은 명확해 보였습니다. 정부 엑셀이 한국어 레거시 인코딩(EUC-KR / CP949)으로 저장되어 있는데, SheetJS가 이를 UTF-8로 잘못 읽으면서 특정 음절이 ?로 치환되는 거라고 봤습니다. 정부 시스템이라면 충분히 가능한 이야기였습니다.
여기서 수십 번의 시도가 시작됐습니다. "수십 번"이라고 썼지만 과장이 아니거든요. 코드 변경 5회 이상에, 배포-확인-재분석 사이클이 각각 반복됐고, 그 사이에 GitHub Actions cron 타이밍 문제, 안전장치 발동, XML 파싱 내부 구조 분석까지 껴들었습니다.
수많은 시도들
-
✗
시도 1 — codepage 949
SheetJS의
XLSX.read(buffer, { codepage: 949 })옵션을 추가했습니다. PR을 머지하고 다음 날 GitHub Actions cron이 돌기를 기다렸습니다.결과: 동일하게 깨짐. 나중에 알게 됐는데, codepage 옵션은 .xls(BIFF 이진 형식)에만 적용되더라고요. 정부 엑셀은 .xlsx(OOXML, 실제로는 ZIP)였고, 이 형식에서 codepage는 완전히 무시됩니다. 옵션 자체가 틀린 도구였습니다.
-
✗
시도 2 — JSZip + iconv-lite 재인코딩
.xlsx가 실제로는 ZIP 파일이라는 걸 파악했습니다. 내부의
xl/sharedStrings.xml을 꺼내서 iconv-lite로 EUC-KR 재디코딩 후 다시 패킹해 SheetJS에 넘기는 방식을 구현했습니다. 파일 형식은 magic bytes(파일 앞 4바이트)로 감지했습니다.결과: 실제 sync에서 "대량 삭제 감지 (1796건, 100%)" — 안전장치가 발동해서 중단됐습니다. DB는 무사했지만 업데이트는 0건이었습니다. 뭔가 크게 잘못됐구나 싶었습니다.
-
✗
시도 3 — 감지 조건 수정
원인을 분석했습니다. EUC-KR 재인코딩을 트리거하는 조건이
asUtf8.includes('?')였는데, XML 선언<?xml...?>에 물음표가 항상 포함되어 있어서 모든 xlsx 파일에서 false positive가 발생하고 있었습니다. 실제 깨진 문자인 U+FFFD만 검사하도록 수정했습니다.결과: 이번엔 로그에 "1800행, 컬럼: 연번, 업소명..." — 파싱 자체는 성공했습니다. 그런데 toUpsert(업데이트 대상) = 0이고 대량 삭제도 여전히 감지됐습니다.
-
✗
시도 4 — XML encoding 선언 교체
iconv로 재인코딩한 뒤 ZIP에 다시 넣을 때, XML 선언의
encoding="EUC-KR"이 그대로 남아있으면 SheetJS가 UTF-8 바이트를 EUC-KR로 재해석한다는 걸 알았습니다. 재인코딩 후 선언을encoding="UTF-8"로 교체하고, string 대신 Buffer를 명시 전달했습니다.결과: 1800행 파싱 성공, 컬럼명 정상, 대량 삭제 없음. 그런데 toUpsert = 0. DB와 엑셀이 완전히 일치한다는 의미였습니다.
마지막 결과를 보고 잠깐 성공이라고 생각했습니다. 그런데 이상했습니다. DB에는 분명히 ?커피,MOCC가 있는데, 엑셀 파싱 결과가 DB와 일치한다면 — 엑셀에도 ?커피,MOCC가 있다는 뜻이거든요.
실체
엑셀 파일을 직접 열고 물음표를 검색했습니다. 일반 검색으로는 안 걸리더라고요. Excel에서 ?는 와일드카드 문자라 리터럴 물음표를 찾으려면 ~?로 검색해야 합니다.
결과: 7개 매장. ? 잔, ? 커피,MOCC, 우 ? (WooDic), 잇 ? (IT COF.), 율 ? 당, 카페 드 조 ?, 프 ? 츠.
엑셀 파일 자체에 물음표가 들어 있었습니다. 인코딩 문제가 아니었습니다. SheetJS가 잘못 읽은 것도 아니었습니다. 정부 시스템이 특정 희귀 한글 음절을 처리하지 못하고 ?로 출력한 채로 엑셀을 내보내고 있었습니다. 원본이 이미 깨져 있었던 겁니다.
"인코딩 문제라고 확신하고 파고들었는데, 원본 데이터가 이미 깨져 있었습니다. 가설이 처음부터 틀렸던 거죠."
그러면 그동안 한 작업들은 뭐였나... JSZip 재인코딩 로직은 이 파일에 대해서는 동작조차 하지 않습니다 — U+FFFD가 감지되지 않으니 트리거 자체가 안 되거든요. 오히려 includes('?') false positive 버그 때문에 한동안 sync가 매번 파일을 망가뜨리고 있었다는 걸 나중에야 알았습니다.
다행이었던 건 안전장치였습니다. 전체 데이터의 30% 이상을 한 번에 삭제하려 하면 동기화를 중단하는 로직이 있었습니다. 그게 없었다면 DB가 통째로 비워질 뻔했습니다. 방어 코드가 실제로 작동한 거죠.
같지만 다른 원점
결말은 다시 보정 맵입니다. 처음에 만들었던 KOREAN_NAME_PATCH_MAP과 본질적으로 같습니다. 7개 매장의 깨진 이름을 올바른 이름으로 매핑합니다.
달라진 건 두 가지입니다. 첫째, 적용 위치. 이전엔 파싱 후 저장 직전에 치환했는데, 지금은 엑셀을 읽은 직후 DB 비교 전에 바꿉니다. 올바른 이름으로 diff가 계산되니까 다음 sync에서 덮어씌워지지 않거든요. 둘째, 이유. 이전엔 그냥 막았는데, 지금은 왜 이렇게 해야 하는지 알고 합니다.
이 과정에서 알게 된 것들은 생각보다 많았습니다. .xlsx가 내부적으로 ZIP이라는 것, SheetJS가 문자열을 sharedStrings.xml에서 어떻게 읽는지, magic bytes로 파일 형식을 구분하는 법, U+FFFD와 ASCII 물음표의 차이, GitHub Actions cron이 PR 머지보다 43초 먼저 실행되어 구 코드로 돌 수 있다는 것. 이 중 어느 하나라도 처음에 알았다면 시도 횟수가 절반은 줄었을 것 같습니다.
처음에 아무것도 모르고 만든 임시방편으로 돌아온 건 맞습니다. 하지만 지금은 그게 왜 임시방편일 수밖에 없는지, 그 한계 안에서 뭐가 최선인지를 알고 선택했습니다. 결과물은 비슷해 보여도, 이 차이가 적지 않다고 생각합니다.
그런데 거기서 끝내기엔 여전히 마음에 걸리는 부분이 있었습니다.
그래도 남은 구멍, 그래서 격리
보정 맵은 알려진 7개 매장에 대한 답입니다. 하지만 이 방식엔 구멍이 있거든요. 정부 엑셀에 새로운 ? 매장이 추가되면 — 다음 sync에서 그 이름 그대로 DB에 들어가고, 앱 지도에서 사용자에게 그대로 노출됩니다.
그래서 한 단계를 더 추가했습니다. verified 플래그입니다. DB에 이미 있던 컬럼이었습니다.
로직은 단순합니다. 보정 후에도 이름에 ?가 남아있으면 verified: false로 저장하고, Kakao 지오코딩 API 호출을 건너뜁니다. 좌표가 없으니 지도에 찍을 수도 없거든요. API에서는 verified: true인 매장만 응답합니다.
NAME_CORRECTIONS에 등록된 7개 매장은 보정 후 ?가 없으니 verified: true로 정상 처리됩니다. 맵에 없는 미지의 ? 매장은 DB에는 기록되되 사용자에게는 보이지 않습니다. 나중에 올바른 이름을 확인해서 맵에 추가하면, 다음 sync에서 verified: true로 자동 전환됩니다.
완벽한 해결책은 아니거든요. 근본적으로는 정부 시스템이 데이터를 제대로 내보내야 하는데, 그건 우리가 바꿀 수 없는 영역이니까요. 가능한 범위 안에서, 문제가 생겨도 사용자가 이상한 이름을 보지 않도록 격리하는 것 — 그게 제가 할 수 있는 현실적인 다음 단계였습니다.
이 문제에서 배운 것
가설을 세우기 전에 원본 데이터를 먼저 직접 확인했으면 시도 횟수가 절반이었을 겁니다. 코드에서 원인을 찾기 전에 데이터부터 의심해보는 게 맞았던 듯...
안전장치는 디버깅 중에도 작동하더라고요. 대량 삭제 방지 로직이 없었다면 DB 전체가 날아갈 뻔했습니다. 방어 코드는 정상 흐름만이 아니라 제가 실수하는 순간에도 필요했습니다.
수동 보정 맵이 나쁜 선택은 아니었습니다. 원본 데이터에 문제가 있는 이상, 애플리케이션 레벨에서 보정하는 게 현실적인 답이더라고요. 다만 왜 이게 최선인지 이해하고 쓰는 것과 그냥 막는 건 다른 일이었습니다.
데이터 품질 문제는 고칠 수 없을 때도 있습니다. 그럴 땐 격리가 차선이었습니다. verified: false로 숨겨두고 나중에 처리할 수 있는 구조는, 문제를 덮는 게 아니라 문제가 사용자에게 닿지 않게 막아두는 쪽에 가깝다고 생각합니다.