[번역] Wordgard 릴리스 0.1

Marijn Haverbeke - 2024년 10월 22일


수년 동안 이야기해 온 저의 최신 프로젝트가 드디어 첫 번째 릴리스로 세상에 나오게 되어 기쁩니다.
이 프로젝트의 이름은 Wordgard입니다. 이는 ProseMirror 스타일의 리치 텍스트 에디터 시스템의 새로운 반복(Iteration)으로, 9년 전 ProseMirror를 안정화한 이후 배운 것들을 통합한 결과물입니다. 아키텍처 측면에서는 CodeMirror 텍스트 에디터 버전 6의 재설계에서도 많은 영감을 받았습니다.
Wordgard는 (다시 한번) 브라우저 DOM을 사용하여 에디터 인터페이스를 표시하는 JavaScript 라이브러리입니다. MIT 라이선스로 배포되며, 코드는 저의 Forgejo 서버에서 확인할 수 있습니다.
제가 계속해서 새로운 에디터를 반복해서 구현하고 있다는 사실이 조금 우려되기도 합니다(제가 세어본 바로는 이번이 6번째로 규모 있는 에디터입니다). 하지만 이 작업은 왠지 아직 그 매력을 잃지 않았습니다. 매 반복마다 디자인이 더 나아지고 있다는 느낌을 받습니다. 은퇴하기 전까지 에디터 아키텍처의 해탈(Nirvana) 경지에 도달할 것이라 기대하지는 않지만, 분명 그곳에 가까워지고 있다고 확신합니다.

동기

우리는 여전히 ProseMirror를 자랑스럽게 생각하며, ProseMirror는 어디로 사라지지 않습니다. 앞으로도 계속 유지보수될 것입니다. 하지만 그 디자인 중에는 코드를 수정해야 할 때마다 움찔하게 만드는 부분들이 있습니다. 이제는 그 부분들을 다르게 처리했어야 했다는 것을 알고 있기 때문입니다.
이러한 새로운 통찰을 반영하기 위해 ProseMirror를 수정하는 대신, 새로운 이름을 가진 완전히 새로운 시스템을 만들기로 결정했습니다. 호환되지 않는 인터페이스를 가진 ProseMirror 2.0을 만드는 것은 결과적으로 같겠지만, 사람들이 ProseMirror를 언급할 때 무엇을 의미하는지 모호하게 만들 수 있습니다. 1.x 버전으로서 하위 호환성을 유지하며 기능을 덧붙이려 한다면, 타협으로 점철된 win32 스타일의 엉망진창인 결과물이 나왔을 것입니다. 저는 이제 _ProseMirror_라는 언어유희(산문(Prose)을 위한 CodeMirror라는 뜻)도 그리 좋아하지 않습니다. 그래서 완전히 백지상태에서 새로 작성(Green field full rewrite)하기로 했습니다! Wordgard에서 ProseMirror의 많은 아이디어를 발견할 수 있겠지만, 프로그래밍 인터페이스는 호환성을 고려하지 않고 처음부터 다시 구축되었습니다.
ProseMirror에서 개선되었다고 생각하는 부분들을 살펴보겠습니다.

단계(Steps) 사용 중단

"두 번째 단계를 추가할 때 첫 번째 단계로 인해 발생한 문서 이동을 보정해야 합니다." "문서의 어느 범위가 교체되었는지 확인하려면, 새 문서의 위치를 앞으로 매핑하고 이전 문서의 위치를 뒤로 매핑하면서 양방향으로 단계 시퀀스를 반복해야 합니다." "네, replace-around-step 하나 주세요." — 이런 문장들은 완전히 제정신이 아닌 사람이 꿈꾸는 것들입니다.
ProseMirror의 변경 사항 표현 방식은, 변경 사항이 변형(Transform)되더라도 그 의미적 맥락을 보존하는 문제에 몰두해 있었지만 변경 포맷에 대한 경험은 많지 않았던 사람에 의해 설계되었습니다. 단계(Steps)는 변경 사항을 각각 하나의 명확한 작업을 수행하는 원자적 단위로 분해합니다. 특정 에디터 업데이트에는 여러 단계가 포함될 수 있으며, 각 단계는 이전 단계가 생성한 문서에 작용하도록 정의됩니다. 이 방식은 목적에는 부합하지만, 다루기가 매우 까다롭습니다.
Wordgard는 CodeMirror의 변경 표현 방식에 대한 경험을 바탕으로 훨씬 단순하면서도 강력한 시스템을 사용합니다. 이는 ShareJS의 오래된 "델타(Delta)" 포맷에서 유래했습니다. CodeMirror에서 변경 사항은 섹션의 시퀀스이며, 각 섹션은 이전 문서의 일부를 유지하거나 새로운 콘텐츠로 교체합니다. 예를 들어 길이가 10인 문서에서 4번 위치에 'L'을 삽입하는 것은 [keep 4] [replace 0 with "L"] [keep 6]으로 표현되고, 처음 두 글자를 삭제하는 것은 [replace 2 with ""] [keep 8]이 됩니다.
Wordgard는 이를 수정(Modification) 섹션으로 확장합니다. 이 섹션은 구조를 유지하면서 마크(Mark, 강조, 링크 스타일, 이미지 alt 텍스트 등)를 추가하거나 제거합니다. 3번부터 6번까지의 단어를 굵게 만드는 것은 [keep 3] [update 3 +bold] [keep 4]와 같이 표현됩니다.
물론 CodeMirror의 평문(Plain text)과 달리, 리치 텍스트 콘텐츠는 단순한 평면 문자열이 아닙니다. Wordgard는 문서 위치를 위해 토큰 카운팅 인덱싱 시스템(ProseMirror와 동일한 시스템)을 사용하기 때문에, 변경 포맷은 문서를 토큰(노드 열기/닫기 토큰 및 리프 토큰)의 평면 시퀀스로 다룰 수 있으며, 그 사이에 새로운 토큰 시퀀스를 끼워 넣을 수 있습니다.
이러한 유형의 변경 사항들은 쉽게 결합될 수 있으므로, 단일 트랜잭션은 항상 단일 변경 사항과 연결되어 검사하고 추론하기 쉽습니다. 또한 제한된 형태의 운영 변환(Operational Transformation)을 지원하여, 시작 문서를 기준으로 설명된 여러 변경 사항을 병합할 수 있게 해줍니다. 이는 여러 변경 사항이 포함된 트랜잭션을 설명하는 인체공학적인 방법을 제공하며, 협업 편집과 특정 변경 사항만 취소할 수 있는 실행 취소(Undo) 히스토리를 구현할 수 있게 합니다.
하지만 문서는 실제로 토큰의 평면 시퀀스가 아닙니다. 이러한 토큰들은 잘 형성된 트리(Well-formed tree)를 구성할 때만 의미가 있습니다. 예를 들어 노드 닫기 토큰을 삭제하면 토큰의 균형이 깨지고, 적용할 수 없는 변경 사항이 생성됩니다. 따라서 변경 세트를 생성하는 코드는 평문 버전에서는 필요하지 않았던 작업, 즉 변경 사항이 유효한 문서 구조를 생성하는지 확인하고 수정하는 작업을 수행할 수 있어야 합니다.
이 문제는 운영 변환을 처리할 때도 발생합니다. 한 단계를 다른 단계 이후에 적용할 수 있도록 재해석할 때, 그 변환은 문서가 유효하지 않게 되지 않도록 하는 속성도 보존해야 합니다. 그리고 변환된 단계 A를 B 다음에 적용한 결과가 변환된 B를 A 다음에 적용한 결과와 같아야 한다면(일반적으로 유용성을 위해 필요함), 문제는 더 미묘해집니다. Wordgard의 변경 모델이 하는 일은, 변경 사항을 변환할 때 결합된 단계의 결과를 수정하는 '보정 변경(Fix-up change)'을 도출하는 것입니다. 이때 보정 생성 코드가 받는 입력을 신중하게 처리하여 A-over-B에 대해 생성하는 보정이 B-over-A에 대해 생성하는 보정과 동일하도록 만듭니다. 보정되지 않는다면, 변환 알고리즘이 제공하는 보장 덕분에 두 시퀀스 모두 동일하지만 유효하지 않을 수 있는 문서를 생성할 것입니다. 두 시퀀스를 동일한 보정 사항과 합성함으로써, 둘 다 동일하고 유효한 문서를 생성하게 됩니다. 대부분의 변경 사항은 실제로 보정이 필요하지 않지만, 이 접근 방식은 보정이 필요한 경우에도 결과가 수렴되도록 보장합니다.

스키마 구성 (Schema Composition)

ProseMirror의 문서 스펙(Schema)은 노드 간의 관계를 다소 직접적인 방식으로 지정하기 때문에, 일반적으로 수동으로 설정해야 합니다. 노드와 마크 타입은 특정 스펙 내에서만 존재합니다. 정의 객체나 그 일부를 공유할 수는 있지만, 스펙 간에 사용할 수 있는 노드 정체성(Identity)은 없습니다.
Wordgard는 다른 접근 방식을 취하여 노드와 마크 타입을 독립적인 존재로 만듭니다. 이들은 서로 다른 문서 스펙의 일부가 될 수 있습니다. 덕분에 이 객체들을 노드나 마크 타입을 대신하는 타입이 지정되고 자동 완성이 지원되는 핸들로 사용할 수 있으며, 필요한 요소를 모으는 것만으로 스펙을 더 쉽게 구성할 수 있습니다.
노드나 마크 간의 일부 관계를 직접 변경해야 하는 상황도 여전히 존재합니다. 이를 위해 스펙은 기존 요소에 대해 이러한 관계를 오버라이드할 수 있습니다. 노드나 마크의 정의는 기본 콘텐츠나 대상 타입을 지정하지만, 해당 요소를 다르게 사용하려는 스펙은 이를 변경할 수 있습니다.
이를 통해 기본 내장 노드로 훨씬 더 많은 일을 할 수 있게 되며, 따라서 해당 노드들에 대해 직접 편집 지원 확장 기능이나 시스템 통합(예: 메뉴 버튼)을 제공하는 것이 더 실용적이게 됩니다. ProseMirror는 스펙이 너무 일반적이어서 재사용 가능한 기능을 제공하기 어렵다는 문제로 고생했습니다. 코드가 특정 스펙에 종속되거나, 스펙 요소가 무엇을 의미하는지 전혀 알 수 없었기 때문입니다. Wordgard에서는 이 부분이 훨씬 더 잘 작동합니다.
ProseMirror의 또 다른 스펙 구성 문제는 노드 속성(Attribute)이 정의되는 방식이었습니다. 텍스트 정렬이나 alt 텍스트 같은 것들이 대상 노드에 직접 노드 속성으로 정의되어 해당 노드 타입과 강력하게 결합되었습니다. 이로 인해 정렬이나 텍스트 방향 같은 기능을 스펙에 추가하는 것이 고역이었습니다. 모든 텍스트 블록 노드에 해당 속성을 추가해야 했기 때문입니다.
마크를 일반화하여 이러한 용도로 사용할 수 있게 함으로써, 모듈식으로 기능을 추가하는 것이 훨씬 쉬워졌습니다. 노드 타입 자체는 어떤 마크가 자신을 대상으로 하는지 알 필요조차 없습니다.

콘텐츠 제약 (Content Constraints)

정규 표현식을 사용하여 특정 부모 노드에 허용되는 콘텐츠를 지정하는 기능은 ProseMirror의 상징적인 기능입니다. Wordgard는 더 이상 이를 지원하지 않습니다. 노드의 콘텐츠 설명은 지원하는 자식의 타입만 제한할 수 있으며, 그 순서는 제한할 수 없습니다.
여기에는 몇 가지 이유가 있습니다. 가장 큰 이유는 ProseMirror를 위한 일반적인 문서 조작 코드를 작성하는 것이 너무 어렵기 때문입니다. 코드가 특정 스펙을 위해 작성되지 않았다면, 어떤 변환이 유효한지에 대해 거의 아무것도 가정할 수 없으며, 수행하는 모든 작업을 콘텐츠 제약 조건에 대해 일일이 확인해야 합니다. 이는 매우 미묘하고 부담스러워서 저조차도 계속 실수하곤 했습니다. 시스템을 설계한 사람조차 제대로 사용할 수 없다면, 그것은 좋은 징조가 아닙니다.
그뿐만 아니라, 이러한 종류의 제약 조건으로 문서의 형태를 강력하게 고정하는 것이 종종 사용자 경험에 해가 될 수 있다는 확신을 갖게 되었습니다. 문서는 의도한 형태를 만들기 위한 일련의 작은 편집 동작들을 통해 편집됩니다. 에디터가 사용자로 하여금 문서를 일시적으로 이상한 형태로 만드는 중간 단계를 허용하지 않는다면, 사용자는 자주 좌절감을 느낄 것입니다.
ProseMirror의 콘텐츠 제약이 정말 잘 작동했던 상황도 분명히 있었습니다. 하지만 그런 경우는 대개 좋은 편집 경험을 보장하기 위해 세심한 디자인과 스크립팅이 수반되었습니다.
Wordgard는 문서 형태에 대해 덜 경직된 접근 방식을 장려하기 위해 더 완화된 시스템을 채택했습니다. 스펙 규칙이 제공하는 것 이상의 불변성(Invariants)을 지정해야 하는 경우를 위해, "보정(Correction)"이라는 추상화를 제공합니다. 이는 허용하고 싶지 않은 문서 형태를 수정하는 프로그래밍 방식입니다. 보정은 프로그램이기 때문에 콘텐츠 표현식 강제 적용보다 더 지능적이고 문맥을 인식하는 방식으로 문서를 수정할 수 있다는 장점이 있습니다. 또한 테이블이 직사각형인지 확인하는 것과 같이 ProseMirror의 제약 조건으로도 표현할 수 없었던 작업에도 작동합니다.

확장 시스템 (Extension System)

CodeMirror 6에서 구현한 방식과 비교하면, ProseMirror의 확장 시스템은 다소 투박합니다. 시스템에 영향을 줄 수 있는 플러그인이 있고, 순서를 변경하여 플러그인 우선순위에 어느 정도 영향을 줄 수 있습니다. 하지만 각 플러그인이 여러 가지 일을 수행하기 때문에, 어떤 훅(Hook)에 대해서는 낮은 우선순위가 필요하지만 다른 훅에 대해서는 높은 우선순위가 필요한 상황에 쉽게 처하게 됩니다.
패싯(Facets)에 기반한 CodeMirror 시스템은 확장을 훨씬 더 세밀하게 만들며, 모든 확장 값이 자체적인 우선순위 카테고리를 설정할 수 있게 합니다. 패싯은 타입이 지정된 확장 포인트이며, 라이브러리 자체뿐만 아니라 어떤 코드에서도 정의할 수 있습니다. 따라서 에디터 확장은 자신만의 확장 포인트를 정의할 수 있으며, 이는 놀라울 정도로 자주 유용하게 사용됩니다.
Wordgard는 상태 업데이트 및 재구성(Reconfiguration) 메커니즘을 포함하여 CodeMirror의 시스템을 거의 그대로 가져왔습니다.
이 시스템에서 설정(Configuration)은 플러그인의 배열이 아니라 확장들의 트리 구조입니다. 각 확장은 이벤트 핸들러 정의부터 에디터 속성 설정, 새로운 에디터 상태 추가에 이르기까지 무엇이든 할 수 있습니다. 특정 기능 구현은 일반적으로 원하는 동작을 생성하기 위해 함께 작동하는 확장 그룹으로 구성됩니다.
확장이 기반으로 하는 프리미티브(Primitives)들이 깔끔하게 구성될 수 있도록 정의되었기 때문에, 대부분의 경우 이러한 확장 번들을 설정에 넣기만 하면 서로 잘 작동합니다.

브라우저 의존성

사람들이 ProseMirror를 사용하며 겪는 많은 문제는 선택 영역(Selection) 동작을 브라우저의 네이티브 구현에 위임하는 방식과 관련이 있습니다. 이 접근 방식이 불합리한 것은 아닙니다. 양방향 텍스트나 잠재적으로 이상한 방식으로 스타일이 지정된 콘텐츠에서 커서 이동을 제대로 처리하는 것은 어렵습니다. 원래 아이디어는 브라우저가 하도록 내버려 두고, 브라우저가 무엇을 했는지 관찰한 다음, 그에 맞춰 우리의 선택 영역 모델을 업데이트하는 것이었습니다.
불행히도 브라우저는 기대만큼 일을 잘 해내지 못했습니다. 특정 종류의 콘텐츠 너머로 커서를 이동시키지 않으려 하거나, 때로는 커서를 전혀 그리지 않기도 하고, 엉뚱한 곳에 그리기도 하며, 마우스 선택 드래그 제스처를 조금만 이상하게 해도 오작동하곤 합니다. 그래서 Wordgard는 어려움을 감수하고 포인터 및 키보드 기반 선택 영역 처리를 거의 모두 직접 수행합니다.
여기에는 양방향 텍스트 처리(다른 용도로도 유용함) 구현, 콘텐츠 레이아웃 방식에 대한 모델 구축, 그리고 커서를 직접 그리는 작업이 포함되었습니다.
안타깝게도 이것이 불가능한 한 가지 영역은 터치 선택 영역입니다. 터치 선택을 직접 구현할 수도 있겠지만, 그렇게 하면 네이티브 컨텍스트 메뉴가 복구 불가능하게 망가지는 것 같습니다. 폰이나 태블릿 시스템에서 컨텍스트 메뉴 없이 지내기는 꽤 어렵습니다. 따라서 터치 선택은 네이티브 방식을 사용합니다. 다행히 터치 선택은 키보드 선택보다 이상한 오동작이 적은 편입니다.
지난 9년 동안 브라우저의 편집 이벤트(특히 beforeinput) 지원은 상당히 일관되게 발전했습니다. 실제 환경에서의 테스트를 통해 이것이 정말로 감당 가능한지 확인해야겠지만, 지금까지는 Wordgard가 ProseMirror의 기반이 된 트릭 없이도 잘 작동할 수 있을 것으로 보입니다. ProseMirror는 문서 DOM의 변경 사항을 모니터링하고 변경된 콘텐츠를 파싱하여 실제 문서 변경 사항을 구성하는 트릭을 사용했습니다. Wordgard는 조합(Composition) 텍스트 입력을 제외한 모든 것에 대해 beforeinput 이벤트를 처리합니다. 이를 통해 지저분한 우회책들의 상당 부분을 피할 수 있습니다.

상태

Wordgard는 이전 프로젝트들이 발표되었을 때보다 조금 더 진척된 상태입니다. 핵심 인터페이스는 제가 지원하고자 하는 거의 모든 것을 지원하며, 제 디자인이 실용적인지 확인하기 위해 여러 확장을 작성했습니다. 문서는 아직 다소 거칠지만 레퍼런스 매뉴얼 같은 것들은 완성되어 사용 가능합니다. npm 레지스트리에서 wordgard로 설치할 수 있으며, 웹사이트에서 사용법을 읽어볼 수 있습니다.
그렇긴 하지만, 사람들이 시스템을 실제 작업에 사용하기 전까지는 많은 문제가 드러나지 않는다는 것이 저의 경험입니다. 저 스스로도 시스템에 추가하고 싶은 것들이 많이 남아 있으며, 이제 공개되었으니 다른 분들도 살펴보기 시작하기를 바랍니다. 제가 만든 첫 번째 에디터는 아니지만, 더 깊은 통찰을 얻게 되면 공개 인터페이스의 일부를 다시 생각해야 할 수도 있습니다. 이 첫 번째 버전을 0.1로 릴리스하며, 피드백을 수집하고 버그를 수정하며 시스템의 거친 부분을 다듬기 위해 당분간(아마도 최소 1년 정도) 0.x 버전에 머물 것입니다.
이전 프로젝트들과 마찬가지로 코드를 MIT 라이선스로 공개합니다. 제 작업에 대해 더 많은 통제권을 갖기 위해 더 제한적인 라이선스 스타일을 사용하는 것을 진지하게 고민했지만, 결국 이 프로젝트가 널리 사용되는 것에 가장 관심이 있다는 것을 깨달았습니다. 저의 작업 모델은 '풍요'의 모델이었습니다. 세상에 충분한 가치를 제공한다면, 그 가치의 아주 일부만 저에게 돌아오더라도 생계를 유지할 수 있다는 것입니다. 인위적인 희소성을 만들거나, 문지기 노릇을 하거나, 복잡한 라이선스를 강요하는 일을 제 업으로 삼고 싶지 않습니다. 허용적인 라이선스 때문에 제가 별로 좋아하지 않는 회사들이 (대개 비용도 지불하지 않고) 이 소프트웨어를 사용하게 되는 것은 유감스러운 일이지만, 그것도 패키지의 일부입니다.
좋아하지 않는 회사 이야기가 나와서 말인데, 라이선스와 상관없이 "AI" 약탈자들이 제가 이 글을 게시하는 순간 제 코드와 그 안에 담긴 고생해서 얻은 아이디어들을 집어삼킬 것이라는 점은 명확합니다. 그들과 그들의 조잡한 기계들에게 재앙이 있기를 바랍니다. 하지만 제가 이에 대해 할 수 있는 실질적인 일은 없다고 생각합니다. 누군가 이 시스템을 실행할 수 있으려면 JavaScript 코드가 웹에 있어야 합니다. 누군가 이 시스템을 어떻게 다루는지 알아낼 수 있으려면 문서가 공개되어야 합니다. 따라서 법원이 저작권이 실제로 여전히 의미가 있다고 결정하기 전까지는, 이 코드는 모든 거대 언어 모델의 먹이가 될 것입니다.
이 소프트웨어를 만드는 데 어떤 언어 모델도 사용되지 않았습니다. 저는 제 시스템을 직접 디자인하고 구축합니다.
표준적인 오픈 소스 관행과는 다르게, Wordgard에 대해서는 풀 리퀘스트(Pull Request)를 받지 않는 실험을 하고 있습니다. 저는 그것이 유지보수 작업 중 가장 힘든 부분이라는 것을 깨달았습니다. 큰 변경 사항을 검토하고 제 기대에 맞게 조정하기 위해 협상하는 것은 일반적으로 변경 사항을 직접 구현하는 것보다 더 많은 일이 듭니다. 그리고 이제 코드를 생성하는 비용이 급격히 낮아졌기 때문에, 사람들이 저에게 코드를 던져주고 제가 그것을 검토하고 유지보수하기를 기대하는(또는 왜 원하지 않는지 설명해야 하는, 이 또한 일입니다) 구조는 더욱 매력이 없어졌습니다.
0
1

댓글

?

아직 댓글이 없습니다.

첫 번째 댓글을 작성해보세요!

Inkyu Oh님의 다른 글

더보기

유사한 내용의 글