본문으로 건너뛰기

고쳤다고 보고한 두 건이 그대로였습니다 — 코드는 바뀌었는데 동작은 아니어서

2026년 8월 24일
1 views
MathCanvas
Vue
테스트
주니어 개발
회고

들어가며

저는 초·중등 수학 수업에 쓰는 웹 도구를 만듭니다. 화면에 그래프나 표를 꺼내놓고 학생이 직접 끌고 고치는 방식인데, 그 하나하나를 사내에서는 교구라고 불러요. 종이 교구를 화면으로 옮긴 것이라고 보면 됩니다.

기획에서 그 교구들의 오류 4건을 받았습니다. 값 핸들러를 끌어 0으로 만들면 원래 값으로 되돌아간다, 2권11권을 정렬하면 11권이 먼저 온다, 정렬을 켠 상태에서도 값 수정이 된다, 범례를 켜면 빈 칸 안내 문구가 바뀐다.

넷 다 원인이 코드 한두 줄로 좁혀졌습니다. 반나절 만에 고치고 완료로 보고했어요. 그리고 dev 서버에서 눌러본 결과 2건이 그대로였습니다.

이 글은 코드는 고쳤는데 동작은 그대로였던 두 사고의 기록입니다. JavaScript의 0이 조건문에서 어떻게 사라지는지, 초기 속성값과 런타임 속성 부여가 어떻게 서로를 덮어쓰는지 — 프레임워크와 무관한 이야기라 우리 코드를 몰라도 읽힙니다.

무효였던 수정

"정렬을 켜면 값 수정을 막아라"는 요구였습니다. 셀을 그릴 때 편집 가능 여부를 정렬 상태에 묶었어요.

isContentEditable: !isSorted,   // 정렬 ON 이면 편집 불가

같은 요구를 이미 처리하고 있는 교구가 하나 있었습니다. 통계에서 쓰는 줄기와 잎 그림인데, 거기가 쓰는 방식과 제 수정이 같아 보였어요. 소스를 검사하는 테스트도 통과했고요. 그래서 완료로 보고했습니다.

dev 확인 결과, 여전히 편집됐습니다.

돌아가서 보니 단서가 코드 안에 있었어요. 셀을 그리는 함수의 기본값이 원래 false 였고, 자료와 표는 이 옵션을 아예 넘기지 않고 있었습니다. 그러니까 제 수정 전에도 셀은 contenteditable="false" 였는데 편집이 됐던 겁니다.

이 모순 하나만 붙잡았으면 "편집을 켜는 건 다른 곳"이라는 결론에 바로 갔을 텐데, 저는 그걸 보지 않고 같은 층을 고쳤습니다.

진짜 지점은 부모 클래스였습니다. 더블클릭하면 런타임에 속성을 강제로 켭니다.

if (selectedElem.clickCount === 2) {
  if (selectedElem.getIsDisabled && selectedElem.getIsDisabled()) return   // 수정 안되게 막음
  divs.forEach((div) => div.setAttribute('contenteditable', 'true'))
}

차단 훅이 이미 있었어요. 줄기와 잎 그림은 그 훅을 구현해서 막고 있었습니다.

getIsDisabled() {
  return this.isSorted
}

기획서에는 "줄기와 잎 그림에서와 같이"라고 적혀 있었습니다. 그 문장이 가리킨 건 이 훅이었는데, 저는 같은 교구의 다른 층을 보고 "같은 방식"이라고 판단했어요. 초기 마크업을 정하는 층과 실제 동작을 정하는 층이 달랐던 겁니다.

반쪽이었던 규칙

11권2권 보다 먼저 오던 건 고쳤습니다. Number('2권')NaN 이라 숫자 비교 분기를 못 타고 문자열 비교로 떨어져, 앞자리 '1' < '2' 로 판정된 거였어요. 선행 숫자를 떼어 비교하도록 분기를 하나 넣었습니다.

dev 확인에서 다음이 나왔습니다. 2231권 보다 먼저 온다.

제가 넣은 분기가 기존 분기 뒤에 있었습니다. 앞에는 이런 규칙이 있었고요.

if (isNumA && !isNumB) return -1   // 순수 숫자를 항상 먼저

223 은 순수 숫자, 1권 은 아닙니다. 여기서 결론이 나버려 제 분기는 도달조차 못 했습니다.

두 증상은 원인이 하나였어요. 자릿값을 무시한다는 것. 그런데 저는 별개로 보고 뒤에 덧붙였습니다. 규칙을 합쳐서 다시 썼습니다.

// 숫자로 시작하는 값끼리는 항상 자릿값으로 비교. 같으면 뒤에 붙은 단위·이름으로 가른다.
if (numA !== null && numB !== null) {
  if (numA !== numB) return numA - numB
  return afterLeadingNumber(a).localeCompare(afterLeadingNumber(b), 'ko')
}

223/1권2권/11권 이 같은 규칙으로 덮입니다. 부수적으로 "단위가 같을 때만"이라는 조건도 걷어냈어요. 한 열에 단위가 섞이면 숫자 크기가 사용자 기대에 더 가깝다고 판단했습니다.

0이 사라진 자리

첫 번째 건은 조건 한 줄이었습니다.

if (this.newData)              // 0 이 falsy 라 저장을 건너뛴다

JavaScript에서 0 은 falsy입니다. if (값) 은 "값이 있으면"이 아니라 "값이 truthy면"이고, 그 목록에서 0 은 빠져요. 그래서 사용자가 그래프 값을 0까지 내리면 저장을 건너뛰고, 직후 리렌더가 옛 값으로 덮어썼던 겁니다.

여기서 멈추지 않고 한 번 더 봤습니다. 예전 조건은 0을 잘못 걸렀지만 NaN 도 함께 걸러주고 있었어요. 라벨을 늘려 값 배열에 빈 슬롯이 생기면 Number(undefined)NaN 이 되는 경로가 있고, 물결 모양으로 축을 접어 그리는 그래프의 보정 루프도 undefined < 5 가 false라 그 슬롯을 못 고칩니다. != null 로만 바꿨으면 그래프 값에 NaN 이 저장될 수 있었습니다.

const committed = this.newData
if (this.handlerIndex != null && committed != null && Number.isFinite(committed)) {

0은 통과, NaN·Infinity 는 차단. 의도를 조건에 그대로 적었습니다.

공용 함수를 건드렸으니

정렬 함수는 자료와 표만 쓰는 게 아니었습니다. 대푯값·상자그림·자료와가능성도 같은 함수를 쓰고, 중앙값과 사분위수가 이 정렬 결과를 그대로 읽어요. 순서가 바뀌면 통계가 바뀝니다.

옛 규칙과 새 규칙을 나란히 두고 무작위 입력으로 대조했습니다. 순수 숫자 3,000세트와 한글·영어 단어 2,000세트, 둘 다 차이 0건이었어요. 바뀐 것은 "숫자로 시작하는 문자열" 짝뿐이었고, 통계가 쓰는 순수 숫자 경로는 완전히 동일했습니다.

같은 유형의 결함이 더 있는지도 훑었습니다. number 로 선언된 필드를 truthy로 검사하는 곳을 전부 뽑아 12건이 나왔지만 실제 결함은 없었어요. 인덱스를 검사하는 7곳은 dataset 문자열이라 "0" 이 truthy 였고, 나머지는 0을 "없음"으로 쓰는 정상 사용이었습니다.

배운 것

"고쳤다"는 코드가 아니라 동작이 바뀐 것

제 1차 수정은 코드를 바꿨고 테스트도 통과했습니다. 그런데 동작은 그대로였어요. 사이에 있던 것이 층입니다. 초기 마크업을 정하는 층과 런타임 상태를 정하는 층이 따로 있었고, 저는 전자를 고치고 후자가 결과를 덮어쓰는 걸 몰랐습니다.

고칠 자리를 정하기 전에 "이 동작을 최종적으로 결정하는 게 어디냐"를 먼저 확정합니다.

특히 초기값과 런타임 갱신이 함께 있는 구조에서는 반드시요.

모순이 곧 단서

getInputSvg 의 기본값이 false 인데 편집이 됐습니다. 이건 그냥 이상한 사실이 아니라 "내 가설이 틀렸다"는 증거였어요. 저는 그걸 보고도 지나쳤습니다.

코드에서 앞뒤가 안 맞는 걸 발견하면 거기서 멈추고 왜인지 답해야 합니다.

같은 카테고리는 한 규칙으로 덮이는지 먼저

11권/2권223/1권 은 같은 원인이었습니다. 저는 첫 건만 보고 분기를 하나 덧붙였고, 그 분기가 기존 규칙 뒤에 있어서 두 번째 건에는 닿지 못했어요.

증상을 나열하기보다 하나의 규칙으로 합칠 수 있는지, 그리고 분기를 어디에 끼워야 하는지까지 봐야 했습니다.

변이 테스트가 잡아낸 제 결함

고친 코드를 일부러 되돌려 테스트가 실패하는지 확인하는 방식입니다. 통과만 확인하면 "이 테스트가 정말 그 버그를 잡는지"는 모른 채 넘어가니까요.

이 과정에서 두 가지가 드러났습니다. 하나는 제 기대값이 틀렸던 것 — localeCompare('2권','3개') 가 단위를 비교할 거라 생각했는데 첫 글자부터 봅니다. 다른 하나는 더 미묘했어요. "단위 일치 검사"를 지워도 테스트가 통과했는데, 2권/3개 는 두 규칙의 결과가 우연히 같은 값이었습니다. 10권/3개 로 바꾸니 잡혔고요.

통과하는 테스트는 아무것도 증명하지 않습니다. 실패할 수 있는 테스트만 뭔가를 증명합니다.

의도와 기계 말 사이

요즘은 AI와 함께 개발합니다. 그래서 작업의 상당 부분이 수도코드 수준에서 끝나요. "정렬을 켜면 편집을 막아라", "0까지 내려도 값이 남아야 한다" — 의도를 말하면 코드가 나오고 대개 문제가 없습니다. 이날도 원인을 좁히는 데까지는 이 방식이 잘 통했어요. 4건 다 반나절 만에 지점을 찾았으니까요.

틀린 두 건은 의도가 틀린 게 아니었습니다. 의도를 기계가 실행하는 언어로 옮기는 지점에서 갈렸어요.

"값이 있으면 저장해라"는 newData 가 truthy 인지 검사하는 코드가 됐고, 그 목록에 0이 들어 있었습니다. "정렬이면 편집 불가로 그려라"는 초기 마크업의 속성값만 정했고, 런타임이 나중에 덮어썼고요.

둘 다 사람 말로 읽으면 맞는 문장입니다. 그래서 코드를 다시 읽어도 이상한 데가 안 보였고 테스트도 통과했어요. 실제로 동작이 갈린 곳은 사람 말과 기계 말이 갈리는 자리였습니다. 하나는 값의 의미 — 0은 값인가 빈칸인가. 하나는 실행 시점 — 선언인가 최종 결정인가.

추상 수준을 올려 일하는 건 대체로 이득입니다. 다만 의도가 기계어로 내려가는 마지막 구간은 여전히 사람이 확인해야 합니다.

이날은 그 구간을 두 번 건너뛰었고, 두 번 다 "고쳤다"고 보고한 뒤에 드러났습니다.

남은 것

글을 쓰는 시점에 dev 실화면 재확인이 남았습니다. 정렬 ON에서 더블클릭 차단 — 1차 수정이 무효였던 그 경로 — 과, 물결 그래프가 기존대로 시작값에서 멈추는지가 미확인이에요. 코드 근거는 위와 같이 확인했지만 브라우저에서 눌러본 것은 아닙니다.

어떻게 확인했나

회귀 테스트를 21건 붙였습니다. 정렬 규칙에 13건, 교구 수정 형태에 8건이에요. 변이 테스트는 8종을 돌려 전부 실패를 확인했고, 0 값 조건은 양방향으로 검증했습니다 — 느슨하게 바꿔도, 엄격하게 바꿔도 실패해야 하니까요.

옛 규칙과 새 규칙은 무작위 5,000세트로 대조해 차이 0건을 봤습니다. 타입 체크는 기존과 동일하고 단위 테스트 2,033건이 통과합니다.

TypeScript · Vue 3 · Vitest · SVG contenteditable 환경입니다.