홈시리즈멘토링

© 2026 정기창. All rights reserved.

본 블로그의 콘텐츠는 CC BY-NC-SA 4.0 라이선스를 따릅니다.

☕후원하기소개JSON Formatter러닝 대기질개인정보처리방침이용약관

© 2026 정기창. All rights reserved.

콘텐츠: CC BY-NC-SA 4.0

☕후원하기
소개|JSON Formatter|러닝 대기질|개인정보처리방침|이용약관

엑셀 부동소수점 — 화면은 4253.15, 저장된 값은 4253.1499999999996 이었습니다

정기창·2026년 9월 8일

인쇄된 카탈로그에 적힌 번호를 그대로 검색창에 넣었는데 0건이 나왔습니다. 그 부품은 데이터베이스에 분명히 들어 있었습니다. 다만 엑셀에서 넘어온 그 번호가 4253.15 가 아니라 4253.1499999999996 으로 저장돼 있었습니다.

제가 맡은 것은 부품 카탈로그를 통합검색하는 워드프레스 커스텀 플러그인이고, 원본은 매번 정본 엑셀 한 장으로 넘어옵니다. 외부 라이브러리 없이 ZipArchive 와 XMLReader 로 XLSX 리더를 직접 짜서 씁니다(PHP 7.3+ / MariaDB). 이 플러그인이 자기 테이블을 직접 만들어 쓰는 이야기는 커스텀 테이블 편에 적어 두었습니다.

지난번에는 그 리더가 병합셀을 읽지 않아 교차참조가 통째로 사라진 이야기를 썼습니다. 이번 것은 같은 파일, 같은 자리에서 나온 다른 종류의 손상 — 엑셀 부동소수점입니다. 그때는 저장되지 않은 값이 문제였고, 이번에는 저장은 됐는데 화면과 다른 값이 문제였습니다. 둘 다 예외를 한 건도 던지지 않습니다. 그리고 이번 쪽에는 제가 한참을 붙들고 있었던 지점이 하나 더 있었습니다. 손상은 18건이었는데 그중 8건만 증상이 났습니다.

화면의 4253.15 는 값이 아니라 numFmt 이 만든 표시였습니다

엑셀은 보여주는 값과 저장하는 값을 분리합니다. 화면에 뜬 4253.15 는 셀 서식(numFmt)이 저장된 실수를 반올림해 그려 준 결과이고, 파일 안에 실제로 적혀 있는 것은 그 실수 자체입니다. 시트를 풀어 보면 두 정보가 서로 다른 파일에 나뉘어 있습니다.

<!-- xl/worksheets/sheet1.xml : 저장된 값 -->
<c r="D412" s="12" t="n"><v>4253.1499999999996</v></c>

<!-- xl/styles.xml : 그 값을 화면에 보여주는 방법 -->
<numFmt numFmtId="176" formatCode="0.00"/>

원인은 엑셀의 버그가 아니라 IEEE 754 배정밀도 부동소수점입니다. 4253.15 는 2진 분수로 유한하게 표현되지 않아, 저장할 때 가장 가까운 배정밀도 값으로 대체됩니다. 엑셀은 그 값을 파일에 적을 때 유효숫자 17자리까지 펼쳐 쓰고, 화면에는 서식으로 반올림해 보여 줍니다. 그래서 아래 두 표기는 서로 다른 두 값이 아니라 같은 하나의 값입니다.

4253.15             ← 화면 (numFmt 이 반올림해 그린 것)
4253.1499999999996  ← <v>  (같은 배정밀도 값을 17자리로 펼친 것)

흥미로운 것은 어느 쪽도 틀린 값이 아니라는 점입니다. 틀린 것은 값이 아니라 그 값을 문자열로 옮기는 방법이었고, 그 방법을 결정하는 층은 하필 제가 구현하지 않은 층에 있었습니다.

XLSX 를 직접 파싱한다는 것은 서식 계층을 다시 구현하겠다는 뜻입니다

제 XLSX 리더는 <sheetData> 안의 <c> 와 <v> 만 스트리밍했습니다. styles.xml 은 열지 않았습니다. 열 이유가 없다고 생각했습니다. 필요한 것은 값이지 색이나 글꼴이 아니니까요. 그런데 numFmt 은 색이나 글꼴이 아니라 그 값을 사람에게 어떤 문자열로 보여 줄지를 정하는 규칙이었습니다.

병합셀 때와 정확히 같은 자리입니다. 그때는 <sheetData> 바깥의 <mergeCells> 를 안 봤고, 이번에는 styles.xml 의 numFmt 을 안 봤습니다. 돌이켜 생각해보면 스프레드시트 라이브러리를 쓰지 않고 파일을 직접 파싱하기로 한 순간, 저는 엑셀이 화면을 그릴 때 하는 일을 스스로 다시 구현하겠다고 약속한 셈이었습니다. 다만 그때 제가 스스로에게 붙인 이름은 그냥 「파일 읽기」였습니다.

그래서 데이터베이스에도, 검색 결과 화면에도 4253.1499999999996 이 그대로 들어갔습니다. 정본 엑셀 6,021 데이터행에서 읽은 숫자 셀은 2,065개였고, 그중 소수점이 들어간 번호 셀이 51개, 실제로 이런 식으로 손상된 것이 18건입니다. 두 수는 다릅니다. 나머지 33개는 저장된 표기가 화면과 우연히 일치해 무사했을 뿐, 무사한 이유가 코드 어디에도 없다는 점에서는 18건과 처지가 같았습니다.

부동소수점 손상 18건 중 8건만 증상이 났습니다

손상은 18건인데 사용자가 「검색이 안 된다」고 겪을 수 있는 것은 8건뿐이었습니다. 나머지 10건은 멀쩡히 검색되고 있었습니다. 이유는 검색이 정확 일치가 아니라 프리픽스 LIKE 매칭이었기 때문입니다.

여기서 갈리는 것은 근사의 방향입니다. 저장값이 화면값보다 아주 조금 작은 쪽으로 떨어지면 4253.15 가 4253.1499… 이 되어 접두가 어긋납니다. 반대로 아주 조금 큰 쪽으로 떨어지면 화면값이 저장값의 접두로 그대로 살아남아 LIKE 에 걸립니다.

검색어  4253.15   (인쇄된 카탈로그 번호)

아래로 떨어진 경우  4253.1499999999996  접두 어긋남  →  0건
위로 떨어진 경우    4253.1500000000002  접두 일치    →  찾힘   (값은 예시)

제가 처음 마주친 4253.15 는 아래로 떨어진 쪽, 그러니까 0건이 나오는 8건 쪽이었습니다.

구분 건수 사용자가 겪는 일
손상된 부품 번호 18 —
접두가 살아남아 검색됨 10 아무 일도 일어나지 않음
접두가 깨져 0건 8 사용자가 신고해야만 드러남

결함이 결함을 가려 주고 있었습니다

10건이 그래도 찾히고 있었다는 사실을 「그나마 다행」으로 정리하고 싶은 유혹이 있었습니다. 애석하게도 실제로는 정반대입니다. 그 10건은 프리픽스 LIKE 라는 또 다른 느슨함이 덮어 준 것이고, 그 덮개가 문제의 발견을 늦췄습니다.

손상 규모가 18건이라는 것을 알기까지 오래 걸린 이유가 이것입니다. 신고될 수 있는 것은 8건뿐이었고 나머지 10건은 신고될 이유가 없었습니다. 사용자에게 증상이 없는 손상은 존재하지 않는 손상과 구별되지 않고, 저는 드러난 8건을 전부라고 여길 뻔했습니다.

더 불편한 쪽은 그다음입니다. 언젠가 검색을 정확 일치로 조이는 개선을 한다고 가정해 봅니다. 그 순간 잠들어 있던 10건이 함께 깨어나 0건 신고가 8건에서 18건으로 늡니다. 검색을 더 정확하게 만든 개선이 장애 신고를 두 배로 만드는 셈이고, 원인을 그 개선에서 찾기 시작하면 진짜 원인에서 오히려 멀어집니다.

관대한 동작을 엄격하게 바꿀 때는, 그 관대함이 그동안 무엇을 덮고 있었는지 먼저 세어 봐야 하는 것 같습니다.

왜 조용했나 — XLSX 파서가 예외를 던질 이유가 없었습니다

이 손상은 어느 층에서도 오류가 아닙니다. <v> 안에는 문법적으로 완벽한 실수가 들어 있으니 파서가 예외를 낼 근거가 없고, 데이터 임포트 결과 카운트도 정상입니다. 손상이 개수가 아니라 값에서 났기 때문입니다. 부품은 여전히 658개였습니다. 예전에 zod 4 에서 파싱은 정상인데 메시지만 사라지는 일을 겪었는데, 조용한 실패는 대체로 이런 모양입니다.

발견은 유닛 테스트가 아니라 로컬 Docker 워드프레스에서 실데이터로 돌린 실측 검증에서 나왔습니다. 업로드 39건과 검색 67건, 합계 106건을 훑다가 인쇄된 카탈로그 번호를 그대로 넣은 검색 하나가 0건을 반환한 것이 시작이었습니다. 개수를 세는 눈으로는 보이지 않고, 실제로 그 값으로 찾아보는 수밖에 없는 종류였습니다.

손상된 18건이 데이터 전체에 흩어져 있지 않고 특정 제조사 계열에 몰려 있었다는 점도 발견을 늦춘 쪽에 가깝습니다. 그 계열의 번호 체계가 소수점을 쓰기 때문인데, 덕분에 데이터는 전체가 조금씩이 아니라 한 구석만 통째로 조용히 썩었습니다.

같은 커밋에 네 가지가 묶인 이유

수정 커밋에는 이 부동소수점 손상 말고도 세 가지가 더 들어갔습니다. 처음에는 겸사겸사 묶었다고 생각했는데, 나중에 보니 전부 같은 결함의 다른 얼굴이었습니다. 한 문장으로 줄이면 이렇습니다. 리더가 「엑셀이 보여줬을 값」 대신 「저장된 바이트」를 읽었습니다.

workbook 의 첫 시트가 항상 sheet1.xml 인 것은 아닌데 파일명으로 시트를 골랐고(시트 순서는 workbook.xml 의 선언이 정합니다), 수식 셀에 캐시값이 없으면 그 칸이 조용히 빈 값이 됐습니다. 교차참조 열 네 개의 헤더는 'BRAND_A ' 처럼 뒤에 공백을 달고 있어서, 헤더 문자열로 열을 찾는 코드가 에러 없이 4열을 통째로 놓쳤습니다. 눈에는 그냥 공백으로 보이지만 바이트가 다른 NO-BREAK SPACE(U+00A0)가 22건, 정규화하지 않으면 악센트 없는 철자로는 영영 못 찾는 É(U+00C9)가 3건 있었습니다.

앞의 둘은 파일의 구조를 잘못 읽은 것이고 뒤의 셋은 문자를 잘못 읽은 것이지만, 사용자가 겪는 증상은 다섯이 전부 같습니다. 「분명히 있는데 검색이 안 된다.」 원인 다섯이 증상 하나로 수렴하니, 거꾸로 증상에서 원인을 찾는 길은 다섯 갈래로 갈립니다. 한 갈래를 고치고 나서도 「고쳤는데 여전히 안 된다」로 돌아오기 쉬운 이유입니다. 넷을 한 커밋에 묶은 건 부지런해서가 아니라, 하나만 고쳐서는 고쳤는지 알 수 없었기 때문입니다.

처방은 셋인데, 제가 한 것은 그중 둘입니다

처방 고치는 자리 한계
numFmt 을 적용해 표시 문자열을 재구성 리더 서식 코드 해석을 직접 구현해야 해 범위가 넓습니다
서식의 소수 자릿수만큼 반올림 리더 서식이 없으면 자릿수의 근거가 없습니다
애초에 텍스트 서식으로 저장 데이터 원본 제공자의 협조가 필요합니다

정직하게 적어 두면 실제 커밋에 들어간 것은 리더 쪽 처리뿐이고, 세 번째는 요청이었지 강제가 아니었습니다. 데이터 제공자에게 그 열을 텍스트 서식으로 저장해 달라고 권고했을 뿐이고, 다음 개정본이 그 권고를 따를지는 제 손에 있지 않습니다.

그리고 회귀 테스트는 이 시점에도 없습니다. 병합셀 편에 적었던 한계가 그대로입니다. 재발 방지가 런타임 처리와 문서에만 걸려 있는데, 정작 4253.15 와 4253.1499999999996 을 고정해 두는 픽스처 한 쌍이면 끝날 일입니다.

남는 것 — 식별자를 수로 저장하지 마십시오

세 번째 처방이 가장 근본적인 이유는 단순합니다. 부품 번호는 수가 아니라 식별자입니다. 더하지도 빼지도 평균 내지도 않을 값을 수 타입에 넣는 순간, 저는 IEEE 754 에게 그 값에 개입할 권리를 넘겨준 셈이었습니다. 반올림뿐 아니라 앞자리 0 소실, 큰 수의 지수 표기, 자릿수 절단이 전부 같은 문으로 들어옵니다.

엑셀만의 문제도 아닙니다. 주문번호, 우편번호, 계좌번호, 사번처럼 형태가 숫자라는 이유로 수 타입에 들어가는 값은 어디에나 있습니다. 판단 기준은 「숫자처럼 보이는가」가 아니라 「이 값으로 연산을 할 일이 있는가」 하나면 충분한 것 같습니다. 없다면 그건 처음부터 문자열입니다.

화면이 계약이 아니라는 것도 이번에 다시 배웠습니다. 사람은 그 화면을 보고 번호를 인쇄물에 옮겨 적었고, 프로그램은 파일 안의 바이트를 읽었습니다. 둘이 갈라지는 지점에서 「분명히 여기 적혀 있는데 검색이 안 되는」 부품 8건이 생겼습니다.

다음 개정본이 오면 저는 또 그 파일을 받게 됩니다. 이번에는 화면부터 열지 않고 styles.xml 부터 열어 볼 생각입니다. 그리고 언젠가 이 검색을 정확 일치로 조이게 된다면, 그 전에 지금 몇 건이 느슨함 덕분에 찾히고 있는지부터 세어 보려 합니다. 이번에 배운 것 중 실제로 쓸모 있을 것 같은 건 그 순서 하나입니다.

엑셀 부동소수점XLSX 파싱numFmtIEEE 754데이터 임포트무음 실패워드프레스

관련 글

엑셀 병합셀로 교차참조 62%가 사라졌습니다 — 화면에는 '동기화 완료'가 떴습니다

정본 엑셀에 병합셀이 들어오자 XLSX 리더가 교차참조 10,914건 중 4,160건만 남겼습니다. 부품 수는 658개 그대로라 화면에는 "동기화 완료"가 떴고 예외도 없었습니다. 다섯 계층이 각자 자기 질문에는 답했는데, 아무도 "얼마나 남았어야 하는가"를 묻지 않았습니다.

관련도 95%

워드프레스 커스텀 테이블 — 쿼리가 성공해도 스키마는 안 바뀝니다

부품 카탈로그가 커스텀 포스트 타입으로 감당되지 않아 커스텀 테이블을 직접 만들었습니다. 그러자 스키마 마이그레이션과 상태 검증까지 제 몫이 되었고, 재실행을 막으려 걸어둔 버전 옵션 가드가 구 스키마를 영구 고착시켰습니다. 쿼리는 성공했고 로그는 깨끗했지만 컬럼은 없었습니다.

관련도 91%

급락 경보가 정작 급락에서 침묵했습니다 — GSC API가 0인 날짜를 생략하는 함정

검색 성과가 급락하면 Slack으로 알려주는 경보를 만들었는데, 정작 트래픽이 완전히 소멸하는 최악의 순간에 조용히 침묵했습니다. GSC Search Analytics API가 0인 날짜의 행을 아예 생략하는 성질과 위치 기반 슬라이스가 맞물린 역설을 한 줄씩 추적하고, 날짜 경계로 나눠 고친 버그 회고입니다.

관련도 91%