글자에 새긴 계보
템플릿에서 태어난 문서는 곧 사용기록과 섞인다. 어느 글자가 템플릿에서 왔는지를 문서가 아니라 글자 단위로 기억하게 만든 하루 — 보이지 않는 마크 하나를 다섯 벌의 에디터에 같은 규칙으로 심었다.
글자에 새긴 계보
Fecit의 문서에는 두 겹이 있습니다. 템플릿은 “이 일을 어떻게 하는가”를 담고, 거기서 발화된 기록은 “이번에는 어땠는가”를 담습니다. 좋은 템플릿일수록 기록 안에서 두 겹이 잘 섞입니다 — 절차 사이사이에 이번 회차의 메모가 끼고, 안 맞는 문장은 그 자리에서 고쳐 쓰게 되죠.
그런데 이 섞임이 한 기능을 계속 괴롭혔습니다. 기록에서 고친 내용을 템플릿에 돌려보내는 개선 제안입니다. 기록 본문과 템플릿 본문을 비교해서 “절차가 이렇게 바뀌었네요, 템플릿에도 반영할까요?“를 만들어내는 흐름인데 — 비교 대상인 기록 본문에는 이미 사용기록이 잔뜩 섞여 있습니다. diff 입장에선 “템플릿을 고친 것”과 “이번 회차 메모를 적은 것”이 구분되지 않고, 그래서 제안에 노이즈가 끼었습니다. 지금까지는 휴리스틱으로 걸러 왔지만, 휴리스틱은 결국 추측입니다.
오늘은 추측을 그만두기로 했습니다.
문서가 아니라 글자가 기억하게
필요한 건 “이 문서가 템플릿에서 왔다”가 아닙니다. 그건 이미 알고 있습니다. 필요한 건 이 글자가 템플릿에서 왔다입니다. 문서 단위의 출처는 섞이는 순간 쓸모를 잃지만, 글자 단위의 출처는 섞여도 남습니다.
그래서 발화 시점에 서버가 본문 전체에 보이지 않는 인라인 마크를 입힙니다. 서식이 아니라 표식입니다 — 굵게나 밑줄처럼 보이라고 있는 게 아니라, “여기부터 여기까지는 템플릿에서 온 글자”라고 데이터로만 존재합니다. 이후 개선 제안은 이 마크가 붙은 스팬만 이어붙여 템플릿과 비교합니다. 사용기록은 애초에 비교 대상에 들어오지 않으니, 걸러낼 노이즈도 없습니다.
재미있는 건 편집 규칙이 두 줄로 끝난다는 점입니다.
첫째, 마크는 글자에 붙어 다닌다. 마크된 문장을 고치고, 옮기고, 반으로 갈라도 남은 글자의 마크는 보존됩니다. 붙여넣기와 실행 취소도 콘텐츠의 이동이지 새 글이 아니므로 마크가 따라갑니다.
둘째, 새로 타이핑한 글자에는 묻지 않는다. 마크된 스팬 한가운데에 커서를 두고 타이핑해도, 새 글자는 무마크 — 사용기록입니다. 서식이라면 정반대로 동작해야 자연스럽죠. 굵은 글씨 사이에 타이핑하면 굵게 이어지길 기대하니까요. 계보는 반대입니다. 출처는 상속되지 않습니다.
같은 규칙, 다섯 벌
Fecit의 본문 에디터는 한 벌이 아닙니다. 데스크톱은 웹 에디터, iOS와 Android는 각자의 네이티브 텍스트 엔진, 서버는 자체 파서, 그리고 공유 직렬화 계층이 있습니다. 두 줄짜리 규칙을 다섯 군데에 같은 의미로 심어야 했습니다.
엔진마다 성격이 달라서, 같은 규칙이 정반대의 코드가 됩니다. iOS의 텍스트 엔진은 커스텀 속성을 타이핑에 자동으로 복사하지 않습니다 — “새 글자에 안 묻는다”가 공짜로 성립합니다. Android는 반대로 스팬이 내부 삽입에서 자동으로 늘어나는 쪽이라, 새 입력 범위에서 스팬을 갈라내는 로직을 직접 짜야 했습니다. 데스크톱 에디터는 그 중간 — 경계에서는 안 번지게 선언할 수 있지만 스팬 한가운데 삽입은 마크를 물려받아서, 새 입력 범위를 추적했다가 벗겨내는 플러그인을 붙였습니다. 한글 IME 조합 중에는 어느 쪽도 건드리지 않고, 조합이 끝난 뒤에 정리합니다. 조합 중의 텍스트 조작은 한글 입력을 깨뜨리는 지름길이니까요.
구버전 앱 문제도 있습니다. 마크를 모르는 구클라이언트가 본문을 받으면 태그가 글자 그대로 노출될 테니, 신클라이언트만 “나는 마크를 보존할 수 있다”고 요청에 선언하고, 선언이 없는 요청에는 서버가 응답에서 마크를 걷어냅니다. 구클라가 그 문서를 재저장하면 그 문서의 마크만 유실되고, 개선 제안은 종전의 휴리스틱으로 물러납니다. 깨지는 대신 퇴화하도록요.
마크가 데이터로만 있으면 사용자에게는 아무것도 안 보입니다. 그래서 토글을 하나 얹었습니다 — 발화된 문서에서 켜면 템플릿 몫이 은은하게 하이라이트되고, 켠 채로 타이핑하면 이번엔 새 글자가 템플릿 몫이 됩니다. “이 문서에서 템플릿은 어디까지인가”를 보는 눈이자, 템플릿 몫을 손보는 손입니다.
보이지 않는 데이터는 조용히 죽는다
저녁에 오늘 커밋들을 리뷰하다가 구멍을 하나 찾았습니다.
에디터에는 사용자의 타이핑 말고도 본문이 통째로 다시 주입되는 경로들이 있습니다. 다른 기기에서 저장한 내용이 실시간 동기화로 들어올 때, 편집을 되돌릴 때, 치워둔 초안을 복원할 때. 이 경로들은 모두 “문서 전체를 지우고 새로 넣기”라서, 마크 정리 플러그인의 눈에는 거대한 새 타이핑으로 보였습니다. 규칙 둘째 조항이 성실하게 발동해서, 방금 들어온 본문의 마크를 전부 벗겨냈죠. 그 상태에서 한 글자라도 고치고 저장하면 계보는 영구히 사라집니다.
서식이었다면 바로 알아챘을 겁니다. 동기화 한 번에 굵은 글씨가 전부 풀리면 누구든 5초 안에 버그를 신고하니까요. 하지만 이 마크는 보이지 않는 게 설계입니다. 화면은 멀쩡하고, 데이터만 조용히 죽습니다. 눈에 안 보이는 데이터는 사용자가 지켜주지 않으므로, 코드가 지켜야 합니다 — 프로그램적 주입 경로에 “이건 타이핑이 아니다”라는 표식을 달아, 정리 플러그인이 비켜가게 했습니다.
오늘 배운 것이 이겁니다. 보이지 않는 데이터를 설계했다면, 그 데이터가 죽는 경로도 보이지 않는다는 것. 화면으로 검증할 수 없는 불변식은 경로를 하나하나 짚는 수밖에 없고, 그래서 저녁의 커밋 리뷰가 기능 하나를 살렸습니다. 계보를 글자에 새기는 일의 절반은 새기는 코드였고, 나머지 절반은 지우는 코드를 찾아내는 일이었습니다.