← 제작기

빈칸을 인쇄했다

이 사이트에 탭을 하나 더 붙였습니다. 앱을 만들면서 실제로 재 본 수만 모은 실측 페이지입니다. 성조를 몇 개나 잡아내는지, 같은 파일을 네 번 채점하면 점수가 얼마나 흔들리는지, 무료 이용량이 하루 몇 건인지 같은 수들입니다.

수를 모으는 데는 반나절이 걸렸고, 남은 시간은 전부 빈칸에 썼습니다. 채워 넣을 것이 없는 자리를 화면에 어떻게 보여 줄지가 이 페이지에서 가장 오래 붙들고 있던 문제였습니다.

열세 줄 가운데 여섯 줄은 글이 없다

각 줄에는 그 수를 어디서 자세히 읽을 수 있는지도 함께 적습니다. 열세 건 가운데 여섯 건은 연결할 글이 없었습니다. 그 수들은 저장소의 CLAUDE.md 에만 적혀 있고, 그 저장소는 공개돼 있지 않기 때문입니다.

처음에는 그 자리에 링크를 그냥 걸지 않았는데, 화면을 보니 "글은 있는데 링크를 안 걸었나 보다"로 읽혔습니다. 빈칸은 아무 말도 하지 않는 것처럼 보여도, 읽는 사람이 거기에 가장 그럴듯한 말을 채워 넣습니다. 하필 이 자리에서 가장 그럴듯한 말은 사실이 아니었습니다.

없다는 사실을 적어 두기로 했습니다. 링크가 없는 자리에 "저장소 기록"이라고 써서, 연결할 글이 없다는 사실 자체를 화면에 보여 주는 방식입니다.

[ 글이 있는 줄 ]
925행 = 691곡. 165개 작품이 악장별로 쪼개져 있었다.
  → 100점짜리 오답 →

[ 글이 없는 줄 ]
78 / 75 / 90 / 85 — 15점 폭.
  → 저장소 기록

링크처럼 보이면 안 되니 색과 굵기를 낮추고 테두리만 둘렀습니다. 누를 수 없다는 것과 거기에 무언가가 있다는 것을 함께 보여 줘야 했습니다. 그 결과 이 페이지의 절반 가까이가 "이건 아직 글로 쓰지 않았습니다"라고 말합니다.

비어 있는 자리를 그냥 비워 두는 것과 비어 있다고 적는 것은 다른 일입니다. 앞의 것은 읽는 사람에게 짐작을 떠넘기고, 뒤의 것은 짐작을 막습니다. 발음 점수를 null 로 돌려주기로 한 것도, 연도를 모르면 비워 두기로 한 것도 같은 규칙이었습니다. 그 규칙을 이 사이트 자신에게 적용한 것은 이번이 처음입니다.

형식이 곧 규칙이었다

한 줄의 모양은 세 칸으로 정했고, 그 순서가 이 페이지가 무엇을 중요하게 보는지에 대한 답이 됐습니다.

  • 결과는 가장 큰 글자로 둡니다. 이 페이지를 여는 사람이 찾는 것은 프로젝트 이름보다 숫자이기 때문입니다.
  • 잰 것은 그 수를 어떻게 얻었는지이고, 이 칸은 비워 둘 수 없습니다.
  • 그래서는 그 수가 실제로 바꾼 것입니다.

가운데 칸이 이 페이지에 무엇이 들어올지를 거릅니다. 방법을 적을 수 없는 수는 측정이라기보다 주장입니다. "성조를 잘 못 듣는 것 같다"에는 방법이 없고, "성조만 다른 최소대립쌍 열 쌍을 합성해 앱의 채점 경로에 실제 녹음으로 넣었다"에는 있습니다. 앞의 문장으로는 아무것도 바꿀 수 없지만, 뒤의 문장은 실제로 모델 선택을 되돌렸습니다.

세 번째 칸도 같은 일을 합니다. 아무것도 바꾸지 않은 수는 여기에 실을 이유가 없습니다. 재는 것 자체가 목적이 되면 결국 재기 쉬운 것만 재게 되기 때문입니다.

이름이 페이지를 채우라고 한다

만들면서 가장 불편했던 부분입니다. 페이지가 스스로 커지려고 한다는 느낌을 계속 받았습니다.

「실측」이라는 이름을 단 페이지는 스스로를 채우라고 압박합니다. 열세 줄보다 서른 줄이 그럴듯해 보이고, 그렇게 보이게 만들 재료는 얼마든지 있습니다. 문서에서 읽고 옮긴 수, 계산해서 얻은 수, 자동으로 집계되는 수는 모두 숫자이고 모두 사실일 수 있지만, 제가 재 본 것은 아닙니다.

실제로 걸러 낸 사례를 하나 적어 둡니다. 카탈로그 항목의 결과 칸은 "925행 = 691곡"입니다. 같은 날 시드를 920작품으로 늘렸는데, 그 수는 결과 칸에 넣지 않고 그래서 칸으로 내렸습니다. 920 은 앞으로 실릴 수일 뿐 아직 앱에 실린 수가 아닌데, 두 줄을 나란히 놓으면 큰 쪽이 성과처럼 보입니다. 제가 재고 확인한 것은 925 와 691 쪽입니다.

2026-09-22 — 그 '앞으로 실릴 수'가 도착했습니다. 카탈로그는 v1.10.0 에 실려 4,512행 · 878곡이 됐습니다. 920 대신 878 이 됐습니다. 표기가 달라 같은 곡이 둘로 세어지던 것을 합치자, 행 수는 그대로인 채 곡 수만 줄었기 때문입니다. 결과 칸에 920 을 적어 두었다면 오늘 878 로 내리면서 성과가 줄어든 것처럼 보였을 것입니다. 재지 않은 수를 결과로 올리지 않은 것이 여기서 제 몫을 했습니다.

이 규칙은 코드 대신 데이터 파일의 주석에 적었습니다. 다음에 이 파일을 여는 사람이 줄을 추가하기 직전에 읽게 되는 자리이기 때문입니다.

새 줄을 넣기 전에: 그 수를 내가 재서 본 것인지,
아니면 어딘가에서 읽고 옮긴 것인지 먼저 확인합니다.
후자면 여기 자리가 없습니다.

기계가 강제할 수 있는 규칙이 아니어서 검사도 테스트도 없습니다. 제약이 걸리는 곳이 코드보다 사람 쪽이라, 지킬 방법은 눈에 잘 보이는 자리에 적어 두는 것뿐입니다.

화면이 조용히 비었다

데이터를 파일 끝에 붙이다가 쉼표를 두 개 찍었습니다. 한 글자짜리 실수였는데, 그 결과는 엉뚱한 곳에서 나타났습니다.

  ],,
  measurements: [

파일 전체가 파싱에 실패했는데 화면에는 아무 오류도 없었습니다. 제목과 첫 문장이 뜨고 그 아래가 그냥 비어 있어서, 아직 항목이 없는 새 페이지처럼 보였습니다. 저는 렌더링 함수를 먼저 의심했고, 콘솔을 열고 나서야 SyntaxError 와 SITE is not defined 를 봤습니다.

이 사이트가 되풀이해서 다루는 주제가 재는 도구를 먼저 의심하라인데, 이번에는 재는 도구가 제가 방금 만든 페이지였습니다. 빈 화면은 "데이터가 없다"와 "데이터를 읽지 못했다"를 구별해 주지 않습니다. 바로 조금 전까지 빈칸이 짐작을 부른다는 이야기를 쓰고 있었는데, 그사이 제 화면의 빈칸이 저를 엉뚱한 곳으로 보냈습니다.

같은 이름이 둘

짧은 이야기를 하나 더 붙입니다. 프로젝트로 거르는 칩을 붙였더니 "오픽 리허설"이 두 개 나란히 섰습니다. 안드로이드판과 iOS 판이 별개의 카드인데 제목이 같아서 생긴 일이었습니다.

모든 칩에 플랫폼을 붙이면 겹치지 않는 것까지 이름이 길어지므로, 제목이 실제로 겹칠 때만 붙이기로 했습니다. "TSC 리허설 Android"는 같은 이름의 형제가 있어서 붙고, "옮김"은 없어서 붙지 않습니다. 구분이 필요한 자리에서만 구분하는 편이 모든 자리에 같은 표시를 다는 것보다 잘 읽힙니다.


정리하면

  • 빈칸은 침묵이 아닙니다. 읽는 사람이 가장 그럴듯한 말을 채워 넣고, 그 말이 사실이 아닐 수 있습니다.
  • 없다는 사실도 적어 둡니다. 열세 줄 가운데 여섯 줄이 "아직 글로 쓰지 않았다"고 말하는 페이지가, 그 여섯 줄을 숨긴 페이지보다 낫습니다.
  • 방법을 적을 수 없는 수는 측정이 못 되고 주장에 그칩니다. 방법 칸을 비워 둘 수 없게 만든 이유입니다.
  • 아무것도 바꾸지 않은 수는 실을 이유가 없습니다. 재는 것이 목적이 되면 재기 쉬운 것만 재게 됩니다.
  • 좋은 뜻을 담은 이름을 단 페이지는 스스로를 채우라고 압박합니다. 그 압박을 막는 규칙은 코드 대신 사람에게 거는 것이어서, 눈에 보이는 자리에 적어야 합니다.
  • 빈 화면은 "없다"와 "읽지 못했다"를 구별하지 않습니다. 콘솔부터 봅니다.

비워 두는 것을 결정으로 다룬 다른 이야기는 세지 않기로 한 것은 세어지지 않는다와 지어낸 답보다 빈칸이 낫다에 있습니다. 재는 도구가 흔들렸던 사례는 흔들린 건 곡선이 아니라 재는 자였다에 적어 두었습니다.

I added another tab to this site: Measured — a page holding only numbers I actually measured while building things. How many tones a model catches, how far a score drifts when the same file is graded four times, how many requests a day the free tier allows.

Collecting the numbers took half a day. What took the time was the blanks.

Six of thirteen have nothing written

Each row carries a pointer to where the number can be read. But six of the thirteen have no build log. They exist only in a repository's CLAUDE.md, and those repositories are not public.

At first I simply left the link off. Then I looked at the screen and saw that this read as "there is a write-up, they just didn't link it". A blank is not silence: the reader fills it with the most plausible thing. And here, the most plausible thing was not true.

So I printed the absence.

[ has a write-up ]
925 rows = 691 pieces. 165 works split across movements.
  → A Perfect Score on the Wrong Piece →

[ has none ]
78 / 75 / 90 / 85 — a 15-point spread.
  → Repository record only

It must not look like a link, so it lost its colour and weight and kept only an outline. You have to be able to see that it can't be clicked and, at the same time, that something is there. The upshot is a page where nearly half the rows say "this hasn't been written up yet".

Leaving a slot empty and writing down that it is empty are different acts. The first invites the reader to guess; the second forecloses the guess. Returning null for a pronunciation score, leaving a year blank when it isn't known — this site has been applying that rule for a while. This is the first time it applied it to itself.

The format was the rule

A row is shaped like this:

  • Result — the largest type. What people come to this page for is a number, not a project name.
  • What was measured — how it was obtained. Mandatory.
  • So — what the number actually changed.

The middle field is the gatekeeper. A number whose method cannot be written down is a claim, not a measurement. "It seems bad at hearing tones" has no method; "ten tone-minimal pairs, synthesised and fed through the app's own scoring path as recorded answers" has one. Nothing can be decided on the first. The second actually reversed a choice of model.

The third field gates too. A number that changed nothing has no reason to be here. Once measuring becomes the goal, you measure whatever is easy to measure.

The name pressures the page

This was the uncomfortable part.

A page called "Measured" pushes you to fill it. Thirty rows look better than thirteen, and there is no shortage of material to make them with: numbers read out of documentation, numbers derived by calculation, numbers tallied automatically. All of them numbers, all of them possibly true — and none of them measured by me.

One thing I actually kept out. The catalogue row's result field reads "925 rows = 691 pieces". The same day, the seed list grew to 920 works — and that number went into So, not into the result. 920 is not what ships; it is what will ship, and set side by side, the bigger figure reads as the achievement. What was counted and confirmed is 925/691.

2026-09-22 — that "what will ship" arrived. The catalogue went out in v1.10.0 at 4,512 rows across 878 pieces. Not 920, but 878: folding together entries that different spellings had split into two pieces left the row count untouched and only lowered the piece count. Had 920 gone into the result field, today's move down to 878 would have read as an achievement shrinking. Keeping an unmeasured number out of the result column is what paid off here.

So the rule went into a comment in the data file, at the spot someone reads immediately before adding a row.

Before adding a row: check whether you measured this
number yourself, or read it somewhere and copied it.
If the latter, it does not belong here.

No machine can enforce this. There is no check and no test. The constraint binds me, not the code, so the only way to keep it is to write it where it will be seen.

A mistake along the way — the screen went quietly empty

Appending the data to the end of the file, I typed two commas.

  ],,
  measurements: [

The whole file failed to parse. And the screen showed no error at all. The heading rendered, the lead paragraph rendered, and below them was nothing — exactly like a new page with no entries yet. I went looking at the render function first. Only the console showed SyntaxError and SITE is not defined.

A recurring theme on this site is suspect the instrument first — and this time the instrument was the page I had just built. An empty screen does not distinguish "there is no data" from "the data could not be read". Minutes earlier I had been writing about blanks inviting guesses, and in the meantime a blank on my own screen sent me to the wrong place.

Two chips, one name

One more, briefly. Filtering chips by project put "OPIC Rehearsal" on screen twice, side by side — the Android and iOS editions are separate cards with the same title.

Putting the platform on every chip lengthens the ones that never collide. So it is appended only where titles actually collide: "TSC Rehearsal Android" has a sibling and gets it; "Omgim" doesn't and stays as it is. Disambiguating only where ambiguity exists reads better than labelling everything the same way.


In short

  • A blank is not silence. The reader fills it with the most plausible thing, and that may not be true.
  • Print the absence. A page where six of thirteen rows say "not written up yet" beats one that hides those six.
  • A number whose method can't be written is a claim, not a measurement. Hence a mandatory method field.
  • A number that changed nothing doesn't belong. Make measuring the goal and you'll measure whatever is easy.
  • A page named after a virtue pressures you to fill it. The rule that resists binds a person, not the code — so it has to be written where it will be seen.
  • An empty screen doesn't distinguish "none" from "couldn't read". Check the console first.

Other decisions to leave something blank are in You Cannot Count What You Chose Not to Keep and Empty-Handed Beats Invented. The case of the instrument itself shaking is in It Was the Instrument Shaking, Not the Curve.