블로그

2026년 8월 29일 · 5분 읽기

표를 그리는 코드는 다 있었다: 열한 번째 도구가 없는 기능이 되는 이유

표 조판 계약 스물한 건이 초록인 동안 사용자는 표를 넣지 못했다. 보이는 칸 여덟 개가 기능의 존재를 판정한 자리를 코드와 실패한 테스트로 짚는다.

  • iOS
  • SwiftUI
  • Markdown
  • 테스트

메모 편집기에서 표가 그려지지 않는다는 신고를 받고 조판 코드를 열었다. 격자, 정렬, 균등 분배가 모두 있었고 표를 검사하는 테스트도 스물한 건이 초록이었다. 그런데 사용자는 "표편집 버튼자체가 안보임"이라고 적었다. 빠진 것은 표가 아니라 표로 가는 문이었다.

읽는 면과 편집면이 한 함수를 부른다

표는 두 지면에 선다. 기록을 읽는 면과 편집하는 면이다. 두 지면이 각자 격자를 그리면 같은 원문이 서로 다른 표로 보이므로, 정렬 판정은 한 함수만 갖는다.

// app/JustSend/Sources/Home/MarkdownSourceKit.swift:3409-3414
static func alignments(of rows: [Row]) -> [ColumnAlignment] {
  let columns = columnCount(of: rows)
  guard columns > 0 else { return [] }
  let declared = rows.first(where: \.isSeparator)?.alignments ?? []
  return (0..<columns).map { declared.indices.contains($0) ? declared[$0] : .center }
}

구분행이 선언한 값을 최대 열 수까지 채우고, 선언이 없는 열은 가운데로 세운다. 구분행이 본문보다 좁은 표에서도 남는 열은 임의의 정렬로 흩어지지 않는다.

격자가 틀리는 자리는 대부분 열 수 계산이다

표가 "안 그려진다"는 신고는 여섯 갈래로 갈렸다. 그중 둘은 열 수를 어디서 읽느냐의 문제였다.

증상 뿌리
표가 지면에 떠 있다 안쪽 선만 있고 바깥 테두리를 세우지 않았다
오른쪽 열의 세로선이 허공에서 끝난다 열 수를 머리행에서만 읽었다
콜론을 넣어도 정렬이 바뀌지 않는다 구분행의 콜론을 통과만 시키고 버렸다
칸 안에서 글자가 한쪽에 붙는다 여분을 좌우로 나누지 않았다
표가 지면 폭을 채우지 않는다 균등 분배가 기본이 아니었다
a | b 같은 인라인 코드가 칸을 쪼갠다 코드 안의 파이프를 경계로 읽었다

두 번째 줄의 수정은 열 수를 가장 넓은 행에서 읽는 것으로 끝났다.

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

여분을 좌우로 갈라 정렬을 만드는 값은 CellSlack이 들고 있고(app/JustSend/Sources/Home/MarkdownSourceKit.swift:3425-3428), 그 값이 맞는지는 스냅샷 계약이 검사한다(app/JustSend/Tests/TableSurfaceSnapshotTests.swift:204-232). 여기까지가 초록이었다.

그래도 사용자는 표를 넣을 수 없었다

편집기 하단 서식 바는 도구 열세 개를 담는다. 아이폰에서 한 번에 서는 칸은 여덟 개다. 표는 그 뒤, 열한 번째에 있었다.

여덟 칸 안에 있는지가 표 기능의 존재를 판정하는 갈림길

조판 검사는 이 조건을 한 번도 지나지 않는다. 검사가 MarkdownTableStyle에 마크다운을 직접 넣고 격자 좌표만 확인하기 때문이다. 사용자가 그 함수까지 걸어가는 경로는 검사 대상이 아니었다.

두 지적이 같은 자리를 가리켰다

첫 지적은 "행 열 구분도 안되고 편집모드시 균등 분배 정렬 등 잘안된다"였다. 그것은 조판 결함이어서 고쳤다. 그다음 지적이 "표편집 버튼자체가 안보임"이었다. 앞의 수정이 사실이었는데도 사용자가 보는 결과는 그대로였다. 고친 것과 닿지 않는 것이 서로 다른 층에 있었다.

여덟째 칸 뒤는 잘려도 아무 신호가 없다

바는 가로로 밀린다. 우리는 그 스크롤 표시자를 꺼 두었다. 표시자의 기본값은 Apple 문서가 이렇게 적는다.

The default value is true. The indicator is visible while tracking is underway and fades out after tracking.

기본값은 참이고, 표시자는 손이 닿는 동안에만 보인다. 끄면 정지 상태에서 더 있다는 신호가 사라진다. 여덟째 칸의 캡슐은 글리프 사이에서 잘려 서 있었고, 그 화면은 "여기까지가 전부"라고 말하는 것과 같았다.

고친 자리는 조판이 아니라 순서였다

표를 블록 구조 무리 끝으로 옮겼다. 배열 위치는 11 → 5으로 바뀐다. 체크리스트·목록·제목 다음이라 뜻으로도 그 자리가 맞고, 사진 단추까지 세어 여섯 번째이므로 좁은 기기에서도 첫 화면에 든다.

// app/JustSend/Sources/Memory/Stream/MemoComposerScreen.swift:38-43
static let toolbar: [MemoFormatToken] = [
  .checklist, .bullet, .ordered, .heading, .table,
  .bold, .italic, .strikethrough,
  .quote, .codeBlock, .inlineCode,
  .diagram, .divider,
]
static let compact = Array(toolbar.prefix(8))

compact는 좁은 세트를 읽는 비-UI 호출부의 정본이고, 화면은 언제나 toolbar 전부를 가로 스크롤로 제공한다. 순서를 바꾸는 것으로 끝내면 다음 사람이 다시 뒤로 밀 수 있으므로 그 자리를 단언으로 고정했다.

그 자리를 지키는 단언

// app/JustSend/Tests/MemoComposerScreenTests.swift:331-333
XCTAssertTrue(
  MemoFormatToken.compact.contains(.table),
  "표가 첫 화면 밖으로 밀렸다 — 넣을 문이 보이지 않는다")

이 단언은 도구의 존재가 아니라 도구의 도달을 검사한다. 수정 전 순서를 되돌려 같은 테스트를 돌리면 실패한다.

$ xcodebuild test-without-building -only-testing:JustSendTests/MemoMarkdownInsertTests/testEveryFormatTokenHasCommandAndLocalizedLabel
Test Case '-[JustSendTests.MemoMarkdownInsertTests testEveryFormatTokenHasCommandAndLocalizedLabel]' started.
MemoComposerScreenTests.swift:331: error: XCTAssertTrue failed - 표가 첫 화면 밖으로 밀렸다 — 넣을 문이 보이지 않는다
Test Case '-[JustSendTests.MemoMarkdownInsertTests testEveryFormatTokenHasCommandAndLocalizedLabel]' failed (0.283 seconds).

실패까지 0.283 s가 걸렸다. 현재 순서에서 같은 명령은 passed로 끝나고 0.003 s를 쓴다. 실패와 통과가 같은 테스트, 같은 시뮬레이터, 다른 배열 순서에서 나왔다.

초록 2039건이 증명하지 않은 것

같은 트리에서 전수 스위트는 이렇게 끝난다.

Executed 2039 tests, with 13 tests skipped and 0 failures (0 unexpected) in 144.873 (145.605) seconds
무엇을 검사했나 결과 사용자에게 닿았나
표 격자·정렬·균등 분배 계약 21건 초록 아니오
표 도구의 명령과 라벨 초록 아니오
표 도구가 첫 여덟 칸에 있는가 단언 없음

전수 실행에 144.873 s가 들었고 실패는 없었다. 세 번째 줄이 이 사건의 전부다. 스위트 2039건은 조판이 옳다는 것을 증명했고, 사용자가 그 조판에 도달할 수 있다는 것은 증명하지 않았다. 검사가 함수를 직접 부르는 동안, 그 함수 앞에 선 화면은 아무 단언도 갖지 않는다.

남은 제약

보이는 칸 수는 기기 폭과 글자 크기에 따라 달라진다. 여덟 개는 아이폰에서 관측한 값이고, 더 좁은 폭이나 더 큰 글자에서는 그보다 적을 수 있다. 지금 단언은 "표가 앞 여덟 개 안에 있다"만 고정하므로, 앞 여덟 개가 실제로 화면에 서는지는 여전히 눈으로 확인한다.

스크롤 표시자는 그대로 껐다. 표시자를 켜면 정지 상태에서도 잘림이 보이지만 서식 바가 시각적으로 시끄러워진다. 대신 첫 화면에 들어야 하는 도구를 배열 앞쪽에 고정하는 방식으로 처리했다. 이 선택은 도구가 열세 개를 넘어가는 순간 다시 검토해야 한다.