화면은 「결손 0」이었다
채널을 여러 개 굴리면 어느 하나가 조용해진 것을 사람이 기억으로 잡지 못한다. 그래서 매일 도는 점검 스크립트를 만들어 뒀다. 채널 명부를 읽어 «마지막으로 무언가 나간 날»을 재고, 정해 둔 주기보다 오래 조용하면 표시한다. 결과는 대시보드가 그린다.
그날 그 스크립트가 낸 줄은 이랬다.
«채널 23개 · 오늘 10 · 정상 1 · 지연 5 · 결손 0»
결손 0. 빠진 것이 없다는 뜻으로 읽힌다. 지연 다섯은 하루씩 밀린 것이라 그날 채우면 되는 수준으로 보였다.
그런데 손으로 세어 보니 그날 실제로 안 나간 것이 네 건이었다. 화면이 틀린 것은 아니다. 화면은 자기가 셀 수 있는 것 중에는 빠진 게 없다고 말한 것이고, 나는 그 문장을 「다 나갔다」로 읽었다.
이 글은 그 차이가 어디서 생겼는지 그날 실측한 기록이다. 도구를 바꾸자는 이야기가 아니라, 지표에 분모가 있는지를 먼저 보자는 이야기다.
세지 않는 것은 빠져도 티가 안 난다
점검 스크립트의 판정 논리는 단순하다. 채널마다 적힌 주기 문구를 읽어 「며칠에 한 번인가」라는 숫자로 바꾸고, 마지막 기록일과 오늘의 차이가 그 숫자를 넘으면 결손으로 표시한다.
여기서 그 숫자가 분모다. 분모가 나오지 않는 채널은 결손으로도, 정상으로도 판정되지 않는다. 그냥 «대상 밖»으로 빠진다.
문제는 대상 밖이 화면에서 실패처럼 보이지 않는다는 점이다. 빨간 줄도 아니고 노란 줄도 아니다. 목록 맨 아래에 회색으로 조용히 앉아 있다. 그리고 요약 줄의 「결손 0」에는 애초에 포함되지 않는다.
그래서 이런 일이 생긴다. 어떤 채널이 한 달을 쉬어도 요약은 계속 「결손 0」이다. 그 채널은 세어지지 않기 때문이다.
같은 모양의 사고를 이미 두 번 겪은 적이 있다. 블로그 글에 넣는 이미지 워터마크가 한 편에서 통째로 빠진 채 나갔는데, 발행 검증기가 여섯 항목을 세면서 워터마크는 세지 않았다. 본문 구분선도 매 편 빠졌는데, 검증기가 제목·줄 수·소제목·인용구·사진·설명글 여섯만 세고 구분선은 세지 않아서 빠져도 전부 초록이었다.
세 번 다 원인이 같다. 안 재는 것은 빠져도 티가 안 난다.
스물셋 중 일곱이 대상 밖이었다
그래서 명부를 열어 채널마다 주기 문구가 실제로 숫자로 바뀌는지 하나씩 돌려 봤다. 스물셋 중 아홉이 숫자를 내지 못했고, 그중 일곱이 그날 화면에서 대상 밖으로 빠져 있었다. 나머지 둘은 분모가 없는데도 그날 기록이 있어서 초록으로 보였을 뿐이다.
아홉을 원인별로 가르면 두 무리다.
첫째, 주기 문구가 아예 비어 있는 것이 여섯이다. 이건 어떤 의미로는 정직하다. 「이 채널은 며칠에 한 번 올릴지 정하지 않았다」가 사실이라면, 안 올렸다고 결손이라 부르는 쪽이 오히려 틀렸다. 정해 두지 않은 침묵을 결손으로 세지 않는 것은 설계 의도였다.
둘째가 진짜 구멍이었다. 주기가 적혀 있는데도 못 읽은 것이 셋이다.
「주 1건」과 「주 1회」 — 한 글자가 만든 구멍
못 읽은 셋은 전부 같은 문구를 쓰고 있었다.
주 1건 순환 · 1주차 주 1건 순환 · 2주차 주 1건 순환 · 3주차
그런데 문구를 숫자로 바꾸는 쪽은 이렇게 생겼다.
if (/매일|하루\s*(1|한)/.test(cadence)) return 1; if (/격일|이틀에\s*(1|한)/.test(cadence)) return 2; if (/주\s*4회/.test(cadence)) return 2; if (/주\s*3회/.test(cadence)) return 3; if (/주\s*2회/.test(cadence)) return 4; if (/주\s*1회/.test(cadence)) return 7; return null; // 지시 없음 — 침묵을 결손으로 세지 않는다
「주 1회」만 본다. 명부에는 「주 1건」이라 적혀 있다. 글자 하나가 다르다.
사람 눈에는 같은 말이다. 명부를 쓴 것도 나고 판정 코드를 쓴 것도 나인데, 쓴 시점이 달랐고 그 사이에 표현이 흔들렸다. 그리고 그 어긋남은 오류를 내지 않는다. 마지막 줄의 return null이 조용히 받아서 「지시 없음」으로 처리한다.
이 return null 자체는 잘못 짠 코드가 아니다. 정해 두지 않은 채널을 결손으로 세지 않으려고 일부러 넣은 것이다. 다만 그 관대함이 「정해 뒀는데 못 읽은 경우」까지 같이 삼켰다. 「정하지 않았다」와 「정했는데 못 알아들었다」는 화면에서 구분되지 않았고, 둘 다 회색 줄로 나왔다.
결과적으로 이 세 채널은 점검을 만든 날부터 한 번도 세어진 적이 없다. 그동안 요약은 계속 초록이었다.
반대 방향의 오류 — 했는데 「안 했다」
같은 날 반대 방향의 오류도 하나 나왔다. 어떤 채널이 「하루 지연」으로 떴는데, 실제로는 그날 두 건을 올린 뒤였다.
그 채널은 자동으로 날짜를 잴 수 없어서 손으로 쓰는 기록표를 본다. 기록표는 표 형식이고, 파서는 «몇 번째 칸이 채널인가»를 형식으로 가른다.
const 형식2 = /\d{1,2}:\d/.test(cols[1] || ''); // 날짜 칸에 시각이 있는가
const cell = 형식2 ? (cols[2] || '') + ' ' + (cols[3] || '')
: (cols[3] || '');형식이 둘이라고 알고 있었고, 둘 다 처리했다. 그런데 그날 추가된 줄은 세 번째 형식이었다. 날짜에 시각이 없는데 채널이 세 번째가 아니라 두 번째 칸에 있었다.
그래서 파서는 채널 이름이 있어야 할 자리에서 글 제목을 읽었고, 어느 채널과도 맞지 않으니 그 줄을 통째로 버렸다. 버려진 줄이 하나라서 마지막 기록일이 하루 전으로 남았고, 화면은 「하루 지연」이라고 했다.
기록표 전체를 훑어 보니 그 세 번째 형식은 319줄 중 딱 한 줄이었다. 형식을 세 가지로 늘려 파서를 복잡하게 만들 만한 빈도가 아니다. 그래서 그 한 줄을 표준 형식으로 고쳤고, 파서에는 오탐이 없는 방어 하나만 더했다.
const 형식3 = !형식2 && /^\s*https?:\/\//.test(cols[4] || '');
const cell = 형식2 ? (cols[2] || '') + ' ' + (cols[3] || '')
: 형식3 ? (cols[2] || '')
: (cols[3] || '');가르는 신호로 «칸 수»를 쓰지 않은 이유가 있다. 세어 보니 문제의 줄과 정상인 줄이 칸 수가 같았다. 대신 네 번째 칸의 «생김새»는 확실히 달랐다. 정상인 줄의 네 번째 칸은 역할을 적는 자리라 주소로 시작하는 일이 없다.
이 방향의 오류는 앞의 것보다 덜 위험하다. 없는 결손을 만들어 내면 사람이 확인하러 가고, 확인하면 드러난다. 반대로 있는 결손을 안 만드는 쪽은 확인하러 갈 이유 자체를 없앤다.
계기를 먼저 흔들어 본다
같은 날 세 번째 종류의 오판도 있었다. 화면에 떠 있는 것을 코드로 세었더니 0개가 나온 일이다.
어떤 입력창에 자동완성 목록이 떠 있었다. 그 목록이 떠 있으면 그 아래 있는 버튼을 누를 수 없으니, 목록이 있는지 먼저 재려고 조건에 맞는 요소를 세었다. 결과는 0. 「없다」로 읽고 버튼을 눌렀는데, 그 클릭이 목록의 후보 하나를 골라 입력 내용을 바꿔 버렸다.
화면을 캡처해 보니 목록에는 후보가 넷 떠 있었다. 내가 짠 선택 조건이 그 요소들과 안 맞았을 뿐이다.
여기서 얻을 것은 자동완성에 대한 지식이 아니라 0이라는 값을 다루는 태도다. 0은 「없다」와 「내 계기가 못 잡는다」를 구분해 주지 않는다. 두 경우 모두 0이 나온다.
그래서 0이 나오면 대조군을 먼저 댄다. 있는 것이 확실한 대상을 같은 코드로 재 본다. 거기서도 0이 나오면 대상이 없는 게 아니라 계기가 잘못된 것이다. 이 방법으로 실제로 오판 하나를 사전에 막은 적이 있다. 어떤 항목이 0개로 나와서 «빠졌다»고 적을 뻔했는데, 있는 것이 확실한 이전 글을 같은 코드로 재 보니 거기서도 0이었다. 대상이 아니라 정규식이 틀린 것이었다.
정리하면 셋 다 같은 자리에서 났다. 내가 만든 계기를 근거로 「없다」를 선언했다. 「없다」는 관측이 아니라 내 관측 도구가 못 찾았다는 진술인데, 화면에는 사실처럼 나온다.
고친 것과, 고치지 않은 것
그날 실제로 손댄 것은 둘이다.
하나는 앞에서 적은 파서 방어다. 다른 하나는 명부의 주기 문구와 판정 정규식을 한쪽으로 맞추는 일인데, 이건 방향이 둘이라 짚고 넘어갈 만하다. 정규식을 넓혀 「건」도 받게 할 수도 있고, 명부 쪽 문구를 「회」로 통일할 수도 있다.
정규식을 넓히는 쪽이 당장은 편하다. 그런데 그러면 다음에 또 다른 표현이 들어올 때 같은 일이 반복된다. 「주당 1개」, 「일주일에 하나」 같은 것을 계속 따라다녀야 한다. 자유 문장을 정규식으로 쫓는 구조 자체가 원인이다.
그래서 방향은 명부에 숫자 칸을 따로 두는 쪽이 맞다고 본다. 사람이 읽을 문구는 문구대로 두고, 판정이 쓸 값은 숫자로 따로 적는 것이다. 다만 이건 명부 스물세 줄을 전부 손대는 일이라 그날 바로 하지 않았고, 대신 더 급한 것을 먼저 했다.
더 급한 것은 분모가 없는 채널을 화면에 드러내는 일이다. 지금은 회색 줄로 조용히 앉아 있어서 요약만 보면 존재를 모른다. 요약 줄에 「대상 밖 7」이 이미 찍히고는 있지만, 그 옆의 「결손 0」이 시선을 다 가져간다.
여기서 한 가지 더 알게 된 것이 있다. 고칠 자리를 찾은 것과 고친 것은 다르다. 그날 나는 구멍 둘을 찾았고 하나만 막았다. 나머지 하나는 「알고 있다」 상태로 남았는데, 이 상태가 가장 위험하다. 알고 있으니 신경 쓸 것 같지만, 실제로는 모르는 것과 똑같이 계속 안 세어진다. 그래서 남은 하나를 머리에 두지 않고 기록에 적었다.
규칙으로 남긴 네 줄
- 「결손 0」은 「다 했다」가 아니다. 「셀 수 있는 것 중에는 빠진 게 없다」는 뜻이다. 분모를 먼저 본다.
- 안 재는 것은 빠져도 티가 안 난다. 항목을 하나 늘리는 것보다, 지금 무엇을 안 재고 있는지 세는 편이 빠르다.
- 「모름」과 「못 읽음」을 같은 칸에 담지 않는다. 관대한 기본값이 진짜 구멍을 같이 삼킨다.
- 0이 나오면 대조군을 먼저 댄다. 있는 것이 확실한 대상에서도 0이면, 대상이 아니라 계기가 틀린 것이다.
점검 도구를 만들면 그 도구가 보는 만큼만 세상이 보인다. 도구가 늘수록 안심이 늘지만, 안심의 근거가 커버리지인지 초록색인지는 따로 확인해야 한다. 그날 나는 초록색을 보고 안심했고, 실제로는 넷이 빠져 있었다.
자주 묻는 것
커버리지를 어떻게 재나
점검 대상 전체를 분자로 두지 말고, 명부 전체를 분모로 두면 된다. 명부 스물셋 중 판정된 것이 열넷이면 커버리지는 열넷 나누기 스물셋이다. 이 숫자를 요약 줄에 같이 찍으면 「결손 0」만 보고 안심하는 일이 줄어든다. 이 글의 사례에서 그 값은 약 61퍼센트였다.
주기를 정하지 않은 채널은 어떻게 다루나
결손으로 세지 않는 것 자체는 맞다. 다만 「정하지 않음」이라고 화면에 적어야 한다. 회색으로 빠지면 사람은 그 채널을 잊는다. 정하지 않았다는 사실 자체가 검토 대상이라는 신호가 되게 두는 편이 낫다.
문구를 정규식으로 읽는 방식이 문제인가
시작할 때는 이 방식이 빠르다. 문서를 그대로 쓰면서 판정까지 되니까. 문제는 문구를 쓰는 쪽과 읽는 쪽이 시간이 지나며 따로 흔들린다는 점이다. 항목이 열을 넘어가면 판정이 쓸 값은 숫자로 따로 두고, 문구는 사람이 읽는 용도로만 두는 편이 안전하다.
파서가 형식 하나를 놓쳤을 때 형식을 늘리는 게 맞나
빈도를 먼저 센다. 이 사례에서는 문제의 형식이 319줄 중 한 줄이었다. 그럴 때는 데이터 한 줄을 고치는 쪽이 파서를 복잡하게 만드는 것보다 낫다. 방어를 더한다면 오탐이 없는 신호로만 한다. 칸 수처럼 정상인 줄과 겹치는 신호를 쓰면 새 오류를 심게 된다.
사람이 눈으로 보는 점검은 필요 없나
필요하다. 다만 «매일 전부»는 지속되지 않는다. 이 글의 넷도 결국 손으로 세다가 찾았다. 현실적인 절충은 주기적으로 커버리지만 사람이 확인하는 것이다. 매일 결손을 세는 건 도구가 하고, 사람은 도구가 무엇을 안 세고 있는지를 가끔 본다.