블로그

2026년 8월 29일 · 6분 읽기

380이냐 390이냐: 축을 잘못 잡으면 멀쩡한 것도 고치게 된다

표가 지면을 채우지 않는다는 판단은 줄 상자 폭에서 나왔다. 그려진 픽셀로 다시 재니 이미 채우고 있었다. 같은 종류의 축 오류가 세 자리에서 났다.

  • iOS
  • 측정
  • CoreText
  • 테스트

"읽기 화면에서 표가 지면을 안 채운다"는 지적을 받고 폭을 쟀다. 390 pt 지면에서 380 pt가 나왔다. 380 → 390으로 10 pt가 비어 있으니 조판을 고칠 일이라고 판단했다. 그 판단의 근거부터 틀렸다.

채우지 않는다고 판단한 근거가 틀렸다

측정에 쓴 값은 텍스트 줄 상자의 폭이었다. 그 값이 380 pt였다. 그런데 화면을 구워 실제로 잉크가 닿은 가장 오른쪽 자리를 재면 390.0 pt였다. 지면 폭과 같다.

무엇을 쟀나 결론
줄 상자 폭 380 pt 표가 좁다
그려진 픽셀 390 pt 이미 채운다

같은 표를 같은 순간에 다른 축으로 잰 것이다. 앞의 값으로는 없는 문제를 고치러 가고, 뒤의 값으로는 고칠 자리가 다른 곳이라는 것을 안다.

플랫폼은 두 값을 따로 준다

버그가 아니라 문서화된 성질이다. 줄을 어떤 폭으로 만들었는지와 그 줄의 끝 공백이 얼마인지는 별개 값이고, 플랫폼은 후자를 읽는 함수를 따로 준다.

Creating a line for a width can result in a line that is actually longer than the desired width due to trailing whitespace. This function can be used to determine what amount of a line's width is due to trailing whitespace.

표의 칸은 끝에 여백을 둔다. 마지막 칸의 남는 자리와 닫는 파이프의 자간이 줄 끝 공백에 해당하므로, 줄 상자 폭에서는 그만큼이 빠진다. 빠진 값을 지면 폭과 비교하면 언제나 좁게 나온다.

무엇을 재는가가 결론을 만든다

그래서 측정 지표를 바꿨다. 사용자가 보는 것은 글자 상자가 아니라 그림이므로 그림을 잰다.

축이 결론을 만든다 — 같은 표, 다른 답

// app/JustSend/Tests/TableSurfaceSnapshotTests.swift:363-368
/// **줄 상자(`usedRect`)로 재면 안 된다.** 칸의 끝 글자는 여백(` `)이고 CoreText는
/// 줄 끝 공백을 사용 폭에서 뺀다 — 마지막 칸의 남는 자리와 닫는 파이프의 kern이
/// 통째로 빠져 실제보다 좁게 나온다(실측 2026-08-29: 그림은 지면 끝에 닿는데
/// `usedRect`는 380/390이었다). 사용자가 보는 것은 그림이므로 그림을 잰다.

주석이 값까지 적어 두는 이유가 있다. 다음 사람이 "왜 굳이 화면을 구워서 재나"라고 물을 때, 380과 390이 그 답이다.

축을 바꾸면 하니스도 바뀐다

줄 상자를 읽는 계약은 순수 함수 호출 한 번으로 끝난다. 그려진 픽셀을 읽으려면 화면을 만들고, 색 공간을 정하고, 배경색을 결정하고, 잉크가 닿은 마지막 열을 찾아야 한다. 하니스가 하는 일이 늘어난 만큼 계약이 무엇을 증명하는지도 바뀐다 — 이제 그것은 "조판 함수가 옳게 계산한다"가 아니라 "사용자가 보는 지면이 채워진다"다.

바꿔 말하면 축을 고르는 일은 지표를 고르는 일이 아니라 무엇을 약속할지 고르는 일이다. 픽셀을 재는 계약은 조판을 바꿔도, 글꼴을 바꿔도, 배율을 바꿔도 같은 것을 지킨다.

같은 오류가 열 수에서도 났다

폭만의 문제가 아니었다. 표의 열 수를 머리행에서만 읽고 있었다. 본문이 더 넓은 표에서는 그 열의 세로선이 서지 않는다.

// app/JustSend/Sources/Home/MarkdownSourceKit.swift:3418-3421
/// 이 표의 열 수. **가장 넓은 행**을 따른다 — 머리행만 보면 본문이 더 넓은 표에서
/// 그 열의 세로선이 서지 않는다(실측 2026-08-29).
static func columnCount(of rows: [Row]) -> Int {
  rows.map(\.cells.count).max() ?? 0
}

이 한 줄을 머리행만 읽는 판으로 되돌리면 계약이 바로 걸린다.

$ xcodebuild test-without-building -only-testing:JustSendTests/TableSurfaceSnapshotTests   # 열 수를 머리행에서만 읽는 판
TableSurfaceSnapshotTests.swift:469: error: -[JustSendTests.TableSurfaceSnapshotTests testColumnsBeyondSeparatorDefaultToCenter] : XCTAssertEqual failed: ("2") is not equal to ("4")
Test Case 'testColumnsBeyondSeparatorDefaultToCenter' failed (0.030 seconds).
	 Executed 21 tests, with 1 failure (0 unexpected) in 0.654 (0.660) seconds

실패까지 0.030 s가 걸렸고 스위트 21건은 0.654 s에 끝난다. 열 수를 2로 세면 구분행 밖의 두 열이 아예 없는 것이 된다. 정렬 기본값도 그 열에는 적용되지 않는다. 축 하나가 격자와 정렬을 함께 무너뜨린다.

테스트 수도 축이 둘이다

세 번째 자리는 보고 수치였다. 회귀를 적을 때 통과 건수를 썼다. 그런데 통과 건수는 건너뛴 건수에 따라 흔들린다. 기기와 환경이 바뀌면 skip이 늘고, 그러면 passed도 함께 준다. 비교해야 하는 축은 실행 총계다.

Executed 2039 tests, with 13 tests skipped and 0 failures (0 unexpected) in 144.873 (145.605) seconds

전수 실행은 144.873 s가 걸린다. 이 한 줄에서 보고할 값은 2039와 0이다. 통과 건수는 2039에서 13을 뺀 파생값이고, 두 판을 비교할 때 파생값을 쓰면 원인이 코드인지 환경인지 가릴 수 없다.

세 자리에 같은 모양이 있다

폭은 상자를 재고 픽셀을 놓쳤다. 열 수는 머리행을 재고 본문을 놓쳤다. 회귀는 통과 건수를 재고 실행 총계를 놓쳤다. 세 번 모두 "재기 쉬운 값"과 "판정하려는 값"이 달랐고, 재기 쉬운 값이 먼저 손에 잡혔다.

자리 재기 쉬운 값 판정하려는 값
지면 폭 줄 상자 폭 잉크가 닿은 자리
열 수 머리행의 칸 수 가장 넓은 행의 칸 수
회귀 통과 건수 실행 총계

이 표가 이 글의 전부다. 축을 적어 두지 않으면 다음 사람도 같은 순서로 틀린다.

대가

그려진 픽셀로 재는 계약은 공짜가 아니다. 화면을 실제로 구워야 하므로 순수 함수 계약보다 느리고, 렌더 환경에 묶인다. 글꼴이나 배율이 바뀌면 값도 바뀐다.

그래서 이 방식은 폭처럼 눈에 보이는 결과가 판정 대상일 때만 쓴다. 정렬 기호나 열 수처럼 원문에서 결정되는 값은 순수 함수로 검사한다. 축을 고르는 규칙은 하나다 — 사용자가 보는 것이 픽셀이면 픽셀을 재고, 사용자가 보는 것이 원문이면 원문을 잰다.

다음에 같은 실수를 피하는 법

지표를 고를 때 두 질문을 먼저 적는다. 첫째, 이 값이 사용자가 보는 것과 같은 단위인가. 둘째, 이 값이 다른 값의 파생인가. 두 질문에 답을 적어 두면 축을 고르는 근거가 코드 리뷰에 남고, 다음 사람이 같은 지표를 다시 고르지 않는다.

이번에는 그 답을 테스트 주석에 적었다. 값까지 함께 적어 둔 것이 중요하다. "그림을 잰다"만 적으면 규칙이 되고, "그림은 지면 끝에 닿는데 줄 상자는 380 / 390이었다"까지 적으면 근거가 된다. 규칙은 잊히고 근거는 다시 계산할 수 있다.

마지막으로, 축이 틀렸다는 것을 알아차린 계기는 새 도구가 아니었다. 같은 대상을 두 방법으로 재 본 것이다. 하나만 재면 그 값이 사실처럼 보인다. 값이 이상할 때 지표를 의심하는 순서를 습관으로 만들면, 없는 문제를 고치는 데 쓰는 시간이 줄어든다. 한 가지 더. 이 세 자리는 모두 "값이 이상하다"에서 시작했고, 세 번 다 값이 아니라 지표가 이상했다. 그런데 지표를 의심하는 데 걸린 시간은 매번 달랐다 — 폭은 하루, 열 수는 한 시간, 회귀 수치는 몇 분이었다. 앞의 경험이 다음 의심을 빠르게 만든다.

그래서 이 글은 결론보다 순서를 남긴다. 값이 예상과 다르면 먼저 같은 대상을 다른 방법으로 한 번 더 재고, 두 값이 다르면 코드를 고치기 전에 지표를 고른다. 이 순서를 지키면 없는 문제를 고치는 커밋이 줄어든다. 축을 적어 두는 비용은 문장 두 줄이다. 그 두 줄이 없으면 다음 사람은 같은 값을 다시 재고, 같은 결론에 도달하고, 같은 커밋을 쓴다. 이 저장소에서 그 비용을 이미 세 번 냈다.

반대로 축을 적어 두면 리뷰가 가능해진다. "왜 픽셀을 재나"라는 질문에 답이 코드 안에 있으므로, 리뷰어는 지표를 두고 논의할 수 있다. 값이 아니라 지표를 논의하는 것이 이런 결함을 앞에서 막는 유일한 자리다.