멀쩡한 코드를 고쳐서 깨뜨렸다 - 정규식 xlsx 파서의 self-closing 셀
CMS 대시보드에서 산학·인턴십 목표선이 어느 날 전부 사라졌다. 달성률 막대는 멀쩡한데 목표를 나타내는 점선만 없었다. 원인을 파고들어 보니, 며칠 전 내가 “칸을 한 칸 잘못 읽는 버그”라고 확신하고 고친 커밋이 범인이었다. 이 시스템은 엑셀을 SheetJS 대신 직접 만든 정규식 리더로 파싱하는데, 그 정규식이 self-closing 빈 셀(
<c r="E13"/>)을 여는 태그로 오인해 바로 뒤 칸의 값을 앞칸으로 흡수하고 있었다. 원래 코드가 col4를 읽던 건 우연이 아니라 그 흡수 동작에 맞아 있었던 것이고, 나는 “정식 파서로 보면 col5가 맞다”며 그걸 정확히 깨뜨렸다.
사건
이 CMS는 부산대 AI융합교육원이 쓰는 실서비스다. 정량실적 엑셀을 업로드하면 파싱해서 DB에 넣고, 대시보드가 그 값으로 산학협력·인턴십의 연도별 목표 대비 달성률을 그린다. 막대는 달성률, 그 위의 점선이 목표치다.
어느 날 대시보드를 보니 목표 점선이 네 연도 모두 사라져 있었다. 달성률 막대는 정상이었다. 목표만 없었다.
엑셀에는 목표 값이 분명히 들어 있었다. 그러니 “엑셀을 그대로 보여주는” 대시보드에 목표가 없다는 건 앞뒤가 안 맞았다. 나는 이 화면이 엑셀을 실시간으로 읽는 게 아니라 업로드 시점에 파싱해 DB에 저장한 값을 보여준다는 걸 알고 있었으므로, 화면이 아니라 그 사이 어딘가 - 파싱 아니면 저장 - 를 의심했다.
환경
- 앱: Next.js 14 API Route(Node 런타임), TypeScript
- 파싱: 직접 만든 xlsx 리더. 이 실적 파일은 외부 링크와 비표준 메타 때문에 SheetJS·exceljs가 워크시트를 못 읽어서, zip을 풀고 시트 XML을 정규식으로 직접 파싱한다.
- 저장·표시: PostgreSQL(Prisma). 대시보드는
year_stats테이블을 읽어 목표/달성을 그린다. - 배포: Docker 이미지, 교내 자체 서버.
첫 번째 착각 - “한 칸 밀렸다”
사실 이 목표치 문제는 이번이 처음이 아니었다. 며칠 전, 나는 이 파서가 목표 값을 잘못된 칸에서 읽는다고 판단한 적이 있었다. 확인 방법은 이랬다. 파이썬 openpyxl로 같은 엑셀을 열어 보니 목표 값 0.365가 F열(0-based로 5번째 칸)에 있었다. 그런데 코드는 4번째 칸을 읽고 있었다.
industryTargetRatio: at(row, 4), // 코드는 여기(4번째 칸)를 읽음
openpyxl은 5번째라는데 코드는 4번째를 읽으니, off-by-one이라고 확신했다. 4를 5로 바꿨다. 빌드도 통과했고, 파이썬으로 확인한 “정답”과도 맞았다. 배포하고 엑셀을 다시 올렸다.
그리고 목표 점선이 전부 사라졌다.
초점을 옮겼다 - 코드가 아니라 데이터
여기서 멈칫했다. 고쳤는데 더 나빠졌다. 이럴 땐 방금 바꾼 게 범인일 가능성이 높다.
먼저 화면이 아니라 DB부터 직접 봤다. 컨테이너 안 psql로 저장된 값을 확인했다.
SELECT year, "industryTargetRatio", "industryAchievedRatio"
FROM year_stats ORDER BY year;
year | industryTargetRatio | industryAchievedRatio
------+---------------------+-----------------------
2023 | | 0.2868421053
2024 | | 0.3620253165
2025 | | 0.3628640777
2026 | | 0.2083333333
목표 컬럼이 네 행 모두 비어 있었다(null). 달성 컬럼은 멀쩡했다. 화면 문제가 아니라 파싱이 목표 값을 못 꺼내고 있는 것이었다. 달성치(6번째 칸)는 정상이니, 파서가 통째로 죽은 것도 아니었다. 딱 목표 칸만 못 읽었다.
이 시점에 질문이 바뀌었다. “왜 openpyxl은 5번째라는데 우리 파서는 5번째에서 아무것도 못 읽나?”
두 파서가 같은 셀을 다르게 읽는다
같은 파일을 두 방식으로 읽어 비교했다. 한쪽은 openpyxl(정식 XML 파서), 다른 쪽은 앱이 실제로 쓰는 정규식 리더와 똑같은 로직.
- openpyxl: 목표 col5 =
0.365 - 앱 정규식 리더: 목표 col5 =
None, col4 =0.365
같은 셀인데 결과가 갈렸다. openpyxl은 목표를 5번째로, 앱 파서는 4번째로 본다. 내가 “정답”으로 삼았던 openpyxl과 실제 런타임 파서가 애초에 서로 다르게 읽고 있었던 것이다. 나는 런타임 파서가 아니라 엉뚱한 파서를 기준으로 멀쩡한 코드를 고쳤다.
원인을 못박으려면 원시 XML을 봐야 했다. 값이 든 행의 셀들을 그대로 꺼냈다.
<c r="E13" s="11"/><c r="F13" s="28"><v>0.365</v></c><c r="G13" s="28"><f>H13/K6</f><v>0.2083…</v></c>
여기 답이 있었다. 목표 값 0.365는 F13(F열 = 5번째)에 정상적으로 들어 있다. 그런데 그 앞 E13이 self-closing 빈 셀(<c r="E13" s="11"/>)이다.
앱 파서의 셀 추출 정규식은 이렇게 생겼다.
/<c\b([^>]*?)>([\s\S]*?)<\/c>/g
이 정규식은 <c … >본문</c> 구조를 가정한다. 그런데 <c r="E13" s="11"/>는 여는 태그 없이 혼자 닫히는 self-closing이다. 정규식은 이걸 여는 태그로 오인하고, 다음 </c>가 나올 때까지를 E13의 “본문”으로 삼는다. 그 본문 안에 바로 뒤 F13의 <v>0.365</v>가 통째로 들어간다. 결과적으로 파서는 F13의 값 0.365를 E13(4번째 칸)의 값으로 흡수한다.
한 문장으로: 빈 셀 하나가 self-closing이라, 그 옆 칸의 값이 한 칸 앞으로 당겨져 읽혔다.
그래서 원래 코드가 목표를 4번째 칸에서 읽던 건 버그가 아니라, 이 흡수 동작에 정확히 맞춰져 있던 것이다. openpyxl은 XML을 제대로 파싱하니 F13을 5번째로 본다. 나는 그 openpyxl의 관점을 “정답”이라 믿고 4를 5로 바꿨고, 그건 self-closing에 흡수돼 빈 5번째 칸을 읽는 코드였다. 그래서 목표가 전부 null이 됐다.
해결
정공법은 정규식이 self-closing 셀을 제대로 구분하게 고쳐서 목표를 진짜 위치(F/5번째)에서 읽는 것이다. 하지만 그 정규식은 프로젝트·인턴십·성과지표까지 모든 시트 파싱이 공유하는 핵심 코드였다. 실서비스에서, 그것도 다른 파싱을 건드릴 위험을 안고 바꾸는 건 지금 할 일이 아니었다.
즉시 조치는 명확했다. 내가 만든 변경을 되돌리는 것.
industryTargetRatio: at(row, 4), // 5 -> 4 원복
internshipTargetRatio: at(row, 10),
되돌리는 것으로 끝내지 않고, 왜 4인지 주석으로 못박아 뒀다. 다음에 누군가(또는 다시 나) openpyxl로 보고 “5가 맞는데?” 하며 똑같이 고치지 않도록.
// 목표치는 col 4/10 에서 읽는다(5/11 아님). 목표 셀(F/L) 앞 빈 셀이 self-closing 이라
// <c>…</c> 정규식이 그 값을 앞칸(E/K = index 4/10)으로 흡수한다. openpyxl 로는 5/11 로
// 보이지만 실제 적재값과 다르므로 5/11 로 바꾸지 말 것.
배포한 뒤 엑셀을 다시 올리자 목표 점선이 돌아왔다. 코드만 되돌린다고 이미 null로 저장된 값이 되살아나진 않는다 - 고친 파서로 한 번 더 파싱해야 채워지기 때문에, 재업로드가 마지막 단계였다.
교훈
- 검증은 실제 실행되는 코드 경로로 한다. 나는 런타임 파서가 아니라 openpyxl을 기준으로 “정답”을 정하고, 그 기준으로 멀쩡히 돌던 코드를 고쳤다. 두 파서가 같은 파일을 다르게 읽는다는 가능성을 처음부터 의심했다면 헛수고를 안 했다. 옆에 있는 편한 도구가 아니라 프로덕션이 쓰는 바로 그 코드로 확인해야 했다.
- 증상이 “고친 뒤” 나빠졌으면 내 변경부터 의심한다. 목표가 사라진 건 원래 있던 버그가 터진 게 아니라, 내가 만든 회귀였다. 고친 직후 나빠졌다는 시간 순서 자체가 가장 강한 단서였는데, 나는 그걸 늦게 봤다.
- 정규식으로 XML을 파싱하는 건 이런 함정을 깐다. self-closing 태그, 네임스페이스, 병합 셀. 이 리더는 SheetJS가 이 파일을 못 읽어서 어쩔 수 없이 손으로 만든 것이지만, 그 대가로 “빈 셀이 옆 값을 먹는” 조용한 오프셋을 안고 있었다. 고정 인덱스로 값을 뽑는 코드가 유독 이 함정에 약했다.
self-closing 하나가 값을 한 칸 당겨 읽게 만들고, 그걸 “정답”으로 착각한 내 커밋이 목표선을 지웠다. 범인은 낯선 라이브러리가 아니라, 내가 좋은 의도로 누른 커밋이었다.