🐾 Pet-Pass Dev Blog

바이브 코딩 개발기 · 이슈 #3 📖 약 10분

엑셀의 ? 하나, 수십 번의 시도, 그리고 원점

#인코딩 #엑셀 #SheetJS #데이터파이프라인 #바이브코딩

목차

  1. ? 가 나타났다
  2. 일단 틀어막기
  3. 근본을 고쳐보기로 했다
  4. 수많은 시도들
  5. 실체
  6. 같지만 다른 원점
  7. 그래도 남은 구멍, 그래서 격리

? 가 나타났다

어느 날 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 파싱 내부 구조 분석까지 껴들었다.

수많은 시도들

마지막 결과를 보고 잠깐 성공이라고 생각했다. 그런데 이상했다. 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로 숨겨두고 나중에 처리할 수 있는 구조는, 문제를 덮는 것이 아니라 문제가 사용자에게 닿지 않게 막는 것이다.

← 이전 글: 지도와 스크롤이 충돌하다 다음 글: 매일 알아서 돌아가게 하기 (1) →