엑셀 병합셀로 교차참조 62%가 사라졌습니다 — 화면에는 '동기화 완료'가 떴습니다
임포트가 끝나자 화면에 초록색 알림이 떴습니다. 전량 동기화 완료 · 부품 658개. 숫자도 맞았습니다. 제가 받은 정본 엑셀에 들어 있는 부품이 정확히 658개였으니까요. 그런데 그 순간 데이터베이스에는 있어야 할 교차참조 10,914건 중 4,160건만 들어가 있었습니다. 62%가 사라졌는데 예외는 한 건도 던져지지 않았고, 트랜잭션은 정상 커밋됐고, PR 코드 리뷰도 critical 0 / high 0 으로 통과한 뒤였습니다.
제가 맡은 것은 부품 카탈로그를 통합검색하는 워드프레스 커스텀 플러그인이고, 원본은 매번 엑셀 한 장으로 넘어옵니다. 원인은 나중에 보면 단순합니다. 그 파일에 엑셀 병합셀이 들어왔고, 제가 직접 만든 XLSX 리더가 그 병합을 읽지 않았습니다.
다만 오래 붙들고 있었던 것은 원인이 아니라 그 다음 질문이었습니다. 검증이 여러 겹 있었는데 왜 하나도 울리지 않았을까. 답은 "검증이 없었다"가 아니었습니다. 조용한 데이터 유실을 잡으려고 일부러 만들어 둔 계측기까지 있었는데도, 그 검증들이 볼 수 있는 축에서는 정말로 아무 일도 일어나지 않았습니다. 환경은 PHP 7.3+ / MariaDB 에 외부 라이브러리 없이 ZipArchive + XMLReader 로 짠 자체 리더, 사고 버전 v0.2.5, 수정 v0.4.0 입니다.
엑셀 병합셀은 값이 앵커에만 있는 것입니다
엑셀 화면에서 병합된 셀은 하나의 큰 칸으로 보이지만, OOXML 이 저장하는 형태는 그렇지 않습니다. 병합 범위의 첫 셀(앵커)에만 값이 있고 나머지 셀에는 값 자체가 없습니다. 병합 범위 A1:D1 을 풀어 보면 이렇습니다.
<!-- sheetData 안: 앵커에만 값이 있습니다 -->
<c r="A1" s="27" t="s"><v>8</v></c>
<c r="B1" s="27"/>
<c r="C1" s="27"/>
<c r="D1" s="27"/>
<!-- sheetData 바깥: 병합 사실은 여기에만 있습니다 -->
<mergeCells count="3">
<mergeCell ref="A1:D1"/>
</mergeCells>
B1 이하에는 <v> 가 아예 없고 서식 정보만 남습니다. "이 넷이 묶여 있다"는 사실은 <sheetData> 바깥의 <mergeCells> 라는 별도 요소에만 있습니다. 값이 있는 자리와 병합 사실이 있는 자리가 파일 안에서 떨어져 있어서, 시트 본문만 훑는 파서는 병합을 지나칠 뿐 아니라 지나쳤다는 것조차 알 수 없습니다.
파서가 병합을 잊으면 아래 계층은 복원하지 못합니다
제 리더가 정확히 그 파서였습니다. 플러그인 전체에서 mergeCells 를, 단수형 mergeCell 까지 넓혀 grep 해도 0건이었습니다. class-catalog-xlsx-reader.php 는 <row> 와 <c> 만 스트리밍했고, 값이 없는 셀은 이 두 줄에서 빈 문자열로 확정됩니다.
$v = $c->getElementsByTagName('v')->item(0);
$lit = $v !== null ? $v->textContent : ''; // → $cells[$col] = ''
중요한 것은 이 두 줄이 틀렸다는 게 아닙니다. 이 두 줄은 맞습니다. 문제는 이 시점에 병합 정보가 이미 파서 안에 존재하지 않는다는 사실입니다. 병합 하위 셀이 만든 빈 문자열과 운영자가 정말로 비워 둔 셀이 만든 빈 문자열은 여기서 바이트 단위로 동일합니다. 잃어버린 정보는 잃어버린 계층에서 복원할 수 없고, 이 인과가 나중에 수정 지점을 결정했습니다.
병합셀이 있어도 부품 수는 한 개도 줄지 않았습니다
빈 문자열이 된 값은 임포터로 넘어갑니다. 임포터는 부품 번호가 비어 있는 행을 카운터도 경고도 없이 버렸습니다. 그 판단 자체는 나쁘지 않았습니다. 부품 번호가 없으면 어느 부품에 붙일지 알 수 없으니까요. 그런데도 화면의 부품 개수는 한 개도 줄지 않았습니다. 병합 블록의 첫 행에는 부품 번호가 그대로 남기 때문입니다.
| 시나리오 | 부품 | 교차참조 | 총계 |
|---|---|---|---|
| 정상 파일 | 658 | 10,256 | 10,914 |
| 병합 파일 | 658 (변화 없음) | 3,502 | 4,160 |
비율로는 0.3812 입니다. 흥미로운 것은 행과 교차참조의 손실률이 다르다는 점입니다. 6,021 데이터행 중 5,363행(89%) 이 스킵됐는데 교차참조는 62%만 잃었습니다. 살아남은 658개 앵커 행이 교차참조 3,502개를 물고 있어 행당 5.32개, 파일 전체 평균은 1.70개 — 병합 블록의 첫 행이 평균의 3.1배로 조밀했습니다. 중요한 값을 첫 행에 몰아 적는 편집 습관이 숫자로 드러난 셈입니다.
그리고 살아남은 4,160행은 전부 문법적으로 올바르고 제약 위반도 없어, 데이터베이스가 거부할 이유가 없었습니다.
왜 조용했나 — 데이터 임포트 검증이 침묵한 다섯 층
이 사고를 "검증이 없어서 생긴 일"로 정리하면 편하지만, 코드를 다시 읽어보니 그렇게 말할 수가 없었습니다. 관여한 계층이 다섯인데 다섯 모두 자기 질문에는 정확히 답하고 있었습니다.
층 1 — 빈 셀은 오류가 아니라 합법값입니다
<v> 없는 셀은 OOXML 에서 완벽히 정상입니다. 리더가 예외를 던질 근거가 애초에 없습니다.
층 2 — 이미 있던 무음 유실 탐지기가 이 케이스만 구조적으로 비껴갔습니다
이 층이 제일 뼈아팠습니다. 리더에는 조용한 데이터 유실을 잡으려고 만든 계측기가 이미 있었습니다. 값이 비었으면서 수식이 걸린 셀을 "캐시값 없는 수식 셀"로 세어 경고하는 장치입니다. 그런데 병합 하위 셀에는 수식 요소가 없어 그 조건이 거짓이 되고, 카운터가 아예 돌지 않습니다. 조용한 유실을 잡으려고 만든 장치가, 발동 조건이 하필 수식 존재였다는 이유로 정확히 이 케이스만 빗나갔습니다.
층 3 — 스킵 경로에 관측점이 없었습니다
임포터는 파싱한 행 수는 보고했지만 버린 행 수는 아무도 세지 않았습니다. 5,363행이 사라지는 동안 그 숫자는 프로그램 어디에도 존재하지 않았습니다.
층 4 — 빈 파일 가드가 있었는데 임계가 0 이었습니다
파싱 행이 0이거나 부품 목록이 비면 막는 검사가 있었는데, 이번 경우 파싱 행 658, 부품 658 입니다. 가드는 정상 통과가 맞습니다. 이 가드가 던지는 질문은 "행이 있나?"였지 "행이 있어야 할 만큼 있나?" 가 아니었습니다. 둘은 전혀 다른 질문인데 코드에서는 한 글자 차이로 보입니다.
층 5 — 성공 판정 기준이 하필 손상되지 않는 축이었습니다
화면에는 "부품 658개"가 떴는데, 부품 수는 이 사고가 보존하는 유일한 값입니다. 앵커 행은 전부 살아남으니까요. 성공 지표와 실패 축이 직교해 있어서, 운영자가 화면을 아무리 들여다봐도 그 숫자에서는 사고의 흔적을 볼 수 없었습니다.
어느 계층도 버그가 없었습니다. 각자 자기 질문에는 정확히 답했습니다. 아무도 "얼마나 남았어야 하는가"를 묻지 않았을 뿐입니다.
고친 자리는 리더가 아니라 임포터였습니다
발견은 유닛 테스트가 아니라 차등 대조로 했습니다. Playwright 로 업로드 39건 · 검색 67건, 합계 106건을 로컬 Docker 워드프레스에서 돌리며 같은 파일의 정상본과 병합본을 각각 넣고 결과 개수를 비교했습니다. 절대값만 보면 4,160도 그럴듯한 숫자입니다. 옆에 10,914가 놓여야 비로소 이상해집니다.
수정은 리더에 넣을 수 없었습니다. 리더에 도착한 시점에 병합 정보는 이미 사라진 뒤이기 때문입니다. 그래서 임포터에 forward-fill 을, 부품 번호가 빈 행을 바로 위 부품의 행으로 이어붙이는 복구를 넣었습니다.
if ($prev_part !== null && (!empty($xrefvals) || $has_vehicle)) {
$part = $prev_part; $carried_rows++;
} else { $skipped_rows++; return; }
조건 두 개는 각각 다른 오작동을 막습니다. 앞 조건은 헤더보다 앞선 잡행이 엉뚱하게 붙는 것을, 뒤 조건은 그 행에 실제로 이어붙일 내용이 있는지를 봅니다. 조건 없이 이어붙이면 이번엔 없던 데이터를 만들어내는 반대쪽 사고가 됩니다.
뒤 조건은 나중에 한 번 더 손봤습니다. 원래 !empty($xrefvals) 단독이었는데 v0.8.0 에서 차량 정보 3열이 추가되며 교차참조는 비었고 차량 정보만 찬 연속행이 등장했고, 그대로 뒀다면 같은 실패 모드가 새 컬럼을 타고 반쯤 되살아났을 것입니다.
그리고 복구한 사실을 화면에 올렸습니다. "부품 번호가 비어 있는 N개 행을 바로 위 부품의 행으로 이어붙였습니다(병합셀로 보입니다)" 같은 문장을 이월·스킵 건수와 함께 노출합니다. 고쳐놓고 알리지 않으면 다음 사고는 "왜 이월됐는지 아무도 모르는" 형태로 옵니다.
가드는 자기가 지키는 계층을 신뢰하지 않습니다
여기까지만 해도 이 사고는 다시 안 납니다. 그런데 forward-fill 하나로 끝내면 방어가 이 실패 모드에만 걸립니다. 그래서 층을 하나 올려, 교차참조 개수가 이전 대비 일정 비율 아래로 떨어지면 임포트 자체를 막는 급감 가드를 넣었습니다.
| 구분 | forward-fill | 급감 가드 |
|---|---|---|
| 층 | 행 해석 | 임포트 전체 결과 |
| 아는 것 | 병합셀의 존재 | 아무것도 모름 (개수 비율만 봄) |
| 막는 범위 | 이 실패 모드 | 원인 무관, 결과가 급감하면 |
핵심은 급감 가드가 병합셀을 전혀 모른다는 데 있습니다. 그래서 forward-fill 이 회귀하든, 리더가 다른 이유로 행을 잃든, 운영자가 엑셀에서 실수로 행을 지우든 같은 그물에 걸립니다. 반대로 두 방어를 같은 층에 놓았다면 그건 방어가 아니라 중복이었을 겁니다. 같은 가정 위에 선 두 장치는 그 가정이 깨질 때 함께 무너집니다.
검사는 트랜잭션에 진입하기 전에 돕니다. 워드프레스의 쿼리 함수는 SQL 오류 시 예외가 아니라 false 를 반환해 실패가 조용히 흘러갈 수 있고, 기존 데이터를 지운 뒤 삽입이 실패해도 커밋되면 빈 카탈로그가 '성공'으로 확정되기 때문입니다. 그래서 이 가드의 사용자 메시지는 "(기존 데이터는 그대로 유지됩니다)"로 끝납니다. 안심시키려는 문구가 아니라 아키텍처 불변식의 선언입니다.
짖지 말아야 할 때 짖는 가드는 결국 꺼지므로 오탐도 막아야 했습니다. 부품 20개 미만·행 100개 미만인 파일은 비교를 건너뛰고, 비교 대상도 양쪽에 겹치는 부품으로 한정했습니다. 임계값 0.7 역시 임의값이 아니라 실제 사고(0.38)는 걸리고 정상 개정(1.0048)은 통과하도록 양쪽 실데이터로 맞춘 값입니다.
그리고 위험 목록 문서의 "하지 말 것" 절에 완화 금지를 명문화했습니다. 실제로 "정본 전환 시 가드가 반드시 발동할 것"이라던 제 예상이 실측 0.9418 로 반증되며 가드를 손댈 뻔했는데, 완화하면 과거 실제 사고의 감지기를 영구히 끄게 된다는 그 한 줄이 막았습니다. 가드는 코드에만 있으면 언젠가 꺼집니다. 끄려는 사람이 악의를 가져서가 아니라, 그때는 대체로 끄는 게 합리적으로 보이기 때문입니다.
아직 막지 못한 것
첫째, 급감 가드는 교차참조 축만 봅니다. 차량 적용정보는 4,129행이 따로 있는데 대응 가드가 없습니다. 코드 주석에 이미 적어둔 그대로, 차량 축만 무너지면 교차참조 비율은 1.0 근처로 나와 무사통과합니다. 이번 사고의 진짜 교훈은 병합셀이 아니라 축이 늘면 가드도 같이 늘어야 한다는 쪽인데, 저는 아직 그걸 안 했습니다.
둘째, 회귀 테스트가 없습니다. 테스트 디렉토리도 픽스처도 없어 재발 방지가 런타임 가드와 문서에 100% 의존합니다. 다만 파싱 함수가 데이터베이스에 전혀 손대지 않도록 설계돼 있어 xlsx 파일만으로 테스트가 가능하고, 골든 파일 두 개(정상본·병합본)로 10,914 와 4,160 을 고정하면 그대로 회귀 테스트가 됩니다. 해야 할 일이 명확한데 아직 안 한 상태라고 적어두는 편이 정확하겠습니다.
남는 것 — 무음 실패는 설계로만 막힙니다
손상이 아니라 절단이면 무결성 제약은 원리적으로 못 잡습니다. 남은 데이터가 전부 문법적으로 올바르기 때문입니다. 그래서 "이상한 값이 있나"를 묻는 검사와 별개로 "있어야 할 만큼 있나"를 묻는 검사가 따로 필요합니다. 전자는 타입과 제약이 해주지만 후자는 아무도 대신 해주지 않습니다.
성공 지표가 손상 축과 직교하면 사고는 영원히 안 보입니다. 화면에 뜨는 숫자가 하필 그 사고가 보존하는 값이라면, 그 화면은 사고 중에도 정상으로 보입니다. 성공 지표를 고를 때 그 지표가 어떤 실패에 반응하지 않는지도 같이 적어두면 좋겠다는 생각이 들었습니다.
검증의 실패 모드는 대상의 실패 모드와 독립이어야 합니다. 탐지기를 하나 더 만들기보다, 기존 탐지기가 무엇을 못 보는지를 적어보는 편이 빠를 때가 많습니다.
돌이켜 생각해보면 이 사고는 예전에 커스텀 테이블을 다루면서 겪었던 쿼리가 성공해도 스키마는 안 바뀐다는 이야기와 형태가 같습니다. 그때는 실패하지 않았다는 사실을 반영됐다는 뜻으로 읽었고, 이번에는 예외가 없다는 사실을 데이터가 온전하다는 뜻으로 읽었습니다. 오류가 없다는 것은 대체로 오류가 없다는 뜻일 뿐이고, 그 이상은 제가 따로 물어봐야 하는 것 같습니다.
관련 글
워드프레스 커스텀 테이블 — 쿼리가 성공해도 스키마는 안 바뀝니다
부품 카탈로그가 커스텀 포스트 타입으로 감당되지 않아 커스텀 테이블을 직접 만들었습니다. 그러자 스키마 마이그레이션과 상태 검증까지 제 몫이 되었고, 재실행을 막으려 걸어둔 버전 옵션 가드가 구 스키마를 영구 고착시켰습니다. 쿼리는 성공했고 로그는 깨끗했지만 컬럼은 없었습니다.
급락 경보가 정작 급락에서 침묵했습니다 — GSC API가 0인 날짜를 생략하는 함정
검색 성과가 급락하면 Slack으로 알려주는 경보를 만들었는데, 정작 트래픽이 완전히 소멸하는 최악의 순간에 조용히 침묵했습니다. GSC Search Analytics API가 0인 날짜의 행을 아예 생략하는 성질과 위치 기반 슬라이스가 맞물린 역설을 한 줄씩 추적하고, 날짜 경계로 나눠 고친 버그 회고입니다.
삭제한 기능이 남긴 참조가 새 빌드를 죽였습니다
서버를 옮기며 오래된 코드를 새로 빌드하자, 잘 돌던 화면이 첫 렌더부터 하얗게 죽었습니다. 5개월 동안 배포하지 않은 main에 숨어 있던, 삭제된 기능이 남긴 dangling 참조를 추적한 기록입니다.