표를 손본 하루 — 0.5픽셀, 사라지는 핸들, 그리고 표를 지우는 저장
더블클릭하면 글자가 꺾이고, 핸들은 누르면 사라지고, 모바일 저장은 열 폭을 지웠습니다. 전부 오늘 고쳤습니다.
표를 손본 하루 — 0.5픽셀, 사라지는 핸들, 그리고 표를 지우는 저장
오늘은 “데스크톱에서 표를 손보자”라는 한 문장으로 시작했습니다. 계획은 없었고, 제가 쓰면서 거슬렸던 것들을 하나씩 제보하면 Claude Code가 진단하고 고치는 방식으로 하루를 보냈습니다. 끝나고 보니 커밋이 열 개가 넘었고, 그중 몇 개는 글로 남길 만한 이야기였습니다.
1. auto-fit이 마지막 글자를 꺾는 이유
Fecit 에디터의 표는 컬럼 구분선을 더블클릭하면 내용에 맞춰 폭이 조절됩니다. 스프레드시트 문법이죠. 그런데 f_by_ratio라는 헤더에 맞추면 폭이 살짝 모자라서 이렇게 됐습니다.
f_by_rati
o
마지막 글자 하나가 통째로 다음 줄로 꺾입니다. “과하게 줄어든다”고 느꼈는데, 실제로 모자란 건 1픽셀도 안 됐습니다.
원인은 측정 코드의 offsetWidth였습니다. offsetWidth는 정수로 반올림된 값을 돌려줍니다. 실제 텍스트 폭이 102.4px이면 102로 잘려 기록되고, 표에서는 콘텐츠 공간이 0.4px 모자라게 됩니다. 보통이라면 티도 안 날 오차인데, ProseMirror 본문은 word-wrap: break-word라 0.4px만 모자라도 마지막 글자가 통째로 꺾입니다. 소수점 이하의 오차가 글자 하나 크기의 결함으로 증폭되는 구조였습니다.
수리는 두 줄입니다. offsetWidth 대신 getBoundingClientRect().width(소수점 그대로)로 재고, 기록할 때 Math.ceil로 올림. 이러면 기록되는 폭이 항상 콘텐츠 폭 이상이라는 게 보장됩니다. 측정용 클론에 주던 white-space: nowrap도 pre로 바꿨습니다 — nowrap은 연속 공백을 접어서 본문(pre-wrap, 공백 보존)보다 좁게 재는 부차 결함이 있었습니다.
2. 누르면 사라지는 핸들
다음 제보는 “핸들이 잘 안 눌리는데?“였습니다. 블록 왼쪽에 뜨는 ⋮⋮ 드래그 핸들 이야기입니다. 관찰을 좁혀보니 이상한 현상이 있었습니다. 누르면 순간적으로 에디터 포커스가 빠지고, 떼면 다시 돌아옵니다.
이건 사실 설계된 동작이었습니다. ⋮⋮는 네이티브 HTML5 드래그를 시작해야 해서 mousedown에 preventDefault를 걸 수 없습니다(걸면 드래그가 죽습니다). 그래서 누르는 순간 에디터가 blur되는 건 피할 수 없고, 떼는 순간 코드가 포커스를 복원해 줍니다.
문제는 blur의 연쇄 반응이었습니다.
- “포커스 밖에선 핸들 숨김” CSS가 누르고 있는 핸들 자신을
visibility: hidden으로 숨깁니다 - 핸들 거터(왼쪽 24px)를 회수하는 CSS가 발동해 본문 전체가 왼쪽으로 12px 튑니다
- 표 컨트롤 오버레이도 포커스 게이트라 함께 증발합니다
손끝 밑에서 핸들이 사라지고 잡으려던 블록이 옆으로 이동하니, “잘 안 눌린다”는 감각이 정확했던 겁니다.
수리는 blur를 막는 게 아니라(못 막습니다) 가리는 것이었습니다. press 시점에 이미 세워지는 상태 플래그를 루트에 CSS 클래스로 노출하고, press~release 동안 핸들 숨김·거터 회수·테두리 변화를 전부 예외 처리했습니다. blur는 여전히 일어나지만, 눈에 보이는 것은 아무것도 흔들리지 않습니다.
3. “됐다 안 됐다”의 정체
“표 오른쪽 끝 테두리는 더블클릭해도 안 된다”는 제보가 왔습니다. 고쳤더니 “됐던 것 같기도 한데 안 되네”라는 답이 돌아왔습니다. 이런 증상이 제일 무섭습니다. 재현이 안 되는 게 아니라, 조건부로 재현되는 겁니다.
원인은 두 겹이었습니다. 첫째, prosemirror-tables의 리사이즈 판정은 포인터가 셀 위에 있을 때만 동작합니다. 안쪽 경계선은 양쪽 셀에서 5px씩 총 10px의 인식 존이 생기지만, 표의 오른쪽 끝은 안쪽 5px뿐입니다. 선 위나 바깥을 겨냥하면 그대로 죽습니다. 사람은 보통 선 자체를 겨냥하니까 체감상 “안 되는” 게 맞았습니다.
둘째 — 이게 “됐다 안 됐다”의 정체인데 — 바깥 밴드를 보강한 제 첫 수리가 “본문 여백 위에서만” 동작하게 짜여 있었습니다. 표는 처음엔 콘텐츠 폭 100%를 채우지만, 열들을 fit하고 나면 표 자체가 좁아집니다. 그러면 표 오른쪽 바깥은 본문 여백이 아니라 표 래퍼 위가 되고, 조건이 어긋나 밴드가 죽습니다. 풀폭 표에서는 되고, 방금 fit한 표에서는 안 되는 — 정확히 사용자가 본 그 패턴입니다.
교훈: “가끔 안 된다”는 제보는 거의 항상 상태에 따라 조건이 갈리는 버그입니다. 어떤 상태에서 됐고 어떤 상태에서 안 됐는지를 찾으면 반은 푼 겁니다.
4. 핸들 클릭 = 선택, 그런데 “무엇을” 선택하나
내친김에 새 문법도 넣었습니다. ⋮⋮를 클릭하면 그 블록이 선택되는 것. Notion에 있는 그 동작입니다.
처음엔 전부 NodeSelection(객체 선택)으로 잡았습니다. 그런데 써보니 어색했습니다. 문단에 파란 프레임이 둘리는 건 “문단을 잡았다”기보다 “뭔가 박스가 생겼다”에 가깝습니다. 피드백을 반영해 노드마다 자기 고유의 선택 방법을 태우는 걸로 바꿨습니다.
- 글 블록 = 텍스트를 드래그로 쓸어 담은 것 같은 영역 선택
- 표 = 표 고유의 전체 셀 선택 (기어 메뉴가 따라와서 “테이블 삭제”까지 이어짐)
- 사진 = 사진의 선택 문법 그대로 (테두리 + 리사이즈 그립)
그리고 여기서 오늘 제일 마음에 드는 디테일이 나왔습니다. 체크리스트 항목을 영역 선택으로 잡았더니 잘라내기가 글자만 들고 가고 체크마크는 남았습니다. 체크리스트 항목의 정체성은 텍스트가 아니라 “체크 상태를 포함한 항목”인데, 텍스트 선택은 그걸 못 담습니다. 그렇다고 객체 선택으로 바꾸면 영역 선택의 자연스러운 얼굴을 잃습니다.
답은 ProseMirror 생태계에 이미 있었습니다. NodeRangeSelection — 겉보기는 텍스트 영역 선택인데, 내용물은 블록 경계로 스냅되는 선택입니다. 이걸로 바꾸니 체크리스트를 잘라내면 체크 상태까지 통째로 클립보드에 실리고, 보이는 건 여전히 글자에 딱 붙는 하이라이트입니다. 껍데기와 내용물을 분리해서 생각하게 된 순간이었습니다.
5. 표를 지우는 저장
마지막 제보는 무거운 것이었습니다. “모바일에서 데스크톱에서 열심히 만들어 놓은 표를 망가뜨리면 안 되는데, 크기 같은 걸 안 건드리게 되어 있어?”
확인해 보니 안 되어 있었습니다. 데스크톱은 열 폭을 셀의 colwidth 속성으로 저장하는데, 모바일 에디터의 셀 모델은 {텍스트, 포맷, 칩, 헤더}가 전부였습니다. 폭이라는 개념 자체가 없습니다. 모바일은 문서를 저장할 때 본문 전체를 자기 모델로 파싱했다가 다시 HTML로 재조립하는데, 모델에 없는 정보는 이 왕복에서 소리 없이 죽습니다.
무서운 건 파괴 시점입니다. 표를 건드릴 필요도 없습니다. 표 위쪽의 문장 하나만 고쳐서 저장해도 표가 무속성 <td>로 재조립되면서 열 폭이 전부 날아갑니다. 데스크톱에서 정성 들여 맞춘 표가, 모바일에서 오타 하나 고친 대가로 초기화되는 겁니다.
수리는 패스스루입니다. 셀 모델에 colwidth 필드를 추가하고, 파싱할 때 캡처해서 재직렬화할 때 그대로 재부착합니다. 모바일은 여전히 폭을 해석하지 않습니다 — 운반만 합니다. 파서와 직렬화기가 TypeScript·Swift·Kotlin 세 벌이라 세 군데를 똑같이 고쳤습니다.
이 유형의 버그에는 이름을 붙여둘 만합니다. “모델이 모르는 필드는 저장이 지운다.” 손실 없는 왕복(lossless round-trip)은 공짜가 아니고, 다른 플랫폼이 새 속성을 저장하기 시작하는 순간마다 다시 검증해야 하는 계약입니다.
오늘의 교훈
하루를 돌아보면 고친 것들의 공통점이 있습니다. 전부 기능이 없어서가 아니라 마감이 덜 되어서 생긴 결함이었습니다.
- 마감 결함은 소수점에 삽니다. 0.4px 부족이 글자 하나를 꺾고, 5px 히트존 비대칭이 “안 되는 기능”으로 체감됩니다.
- 이벤트 타이밍에도 삽니다. blur 한 번의 연쇄가 “핸들이 안 눌린다”가 되고, mouseup 시점에 읽은 상태는 press 시점과 다를 수 있습니다.
- 그리고 데이터 왕복에 삽니다. 화면에 안 보이는 속성일수록, 저장 한 번에 조용히 사라집니다.
“표를 손보자”로 시작한 하루치고는 멀리 왔습니다. 내일은 실기기에서 모바일 저장이 정말 폭을 보존하는지 확인하는 것부터 시작합니다.