라이브러리만 붙이면 동시편집은 끝일까요?

도큐먼트 동시편집 개발기 EP2. 기존 에디터에 동시편집을 연결하며 해결한 문제들

Timo • Core Web

  • Frontend

안녕하세요, 채널톡 프론트엔드 개발자 티모입니다.

1편에서는 도큐먼트에 동시편집을 도입한 배경과 기술 선택, 변경 사항을 전파하고 저장하는 서버 구조를 소개했습니다. 이번 글에서는 클라이언트의 기존 에디터에 동시편집을 연결한 과정을 다뤄 보려고 합니다. 아직 1편을 보지 않은 분이 계신다면 먼저 보고 오시기를 추천해요.

채널톡에서는 ProseMirror 기반의 내부 라이브러리인 prosemirror-toolkit(이하 툴킷)으로 채팅 메시지 에디터와 도큐먼트 에디터를 구성하고 있습니다. 여기에 동시편집을 도입하기 위해 여러 사용자의 변경을 합쳐주는 CRDT 라이브러리인 Yjs를 사용했습니다. ProseMirror에 Yjs를 연결하는 플러그인도 함께 제공하고 있어, 기존 에디터에 동시편집을 연결하는 작업 자체는 비교적 간단했습니다.

하지만 적용하기에 앞서, 플러그인이 어떤 과정을 거쳐 두 라이브러리의 문서 상태를 맞추는지 이해할 필요가 있었습니다. 그래야 문제가 생겼을 때 어느 부분을 살펴봐야 할지도 알 수 있기 때문입니다. 먼저 로컬 에디터의 입력이 어떤 데이터로 전송되고, 다른 사람의 변경은 어떤 경로로 화면에 반영되는지 따라가 볼게요.

라이브러리의 동기화 흐름 따라가기

동시편집에는 같은 내용을 서로 다른 구조로 표현하는 두 문서 모델이 있습니다. 하나는 ProseMirror의 문서이고, 다른 하나는 Yjs의 문서입니다. 두 문서 모델을 연결하는 라이브러리가 y-prosemirror(이하 바인딩)입니다.

ProseMirror는 문서 구조와 선택 영역 등 에디터의 상태를 관리합니다. 글자를 입력하거나 지우면 문서에 적용할 변경을 담은 트랜잭션을 만들고, 이를 적용해 상태와 화면을 갱신합니다. Yjs의 Y.Doc(이하 Yjs Doc)은 여러 사용자가 함께 편집할 문서의 공유 데이터를 관리합니다. 각 클라이언트는 자신의 Yjs Doc을 가지고, 변경 정보를 바이너리 데이터인 Yjs update로 주고받으며 문서를 동기화합니다.

클라이언트 안에서 ProseMirror 문서와 Yjs Doc의 내용을 맞추는 것이 바인딩의 ySyncPlugin입니다. 에디터에서 문서를 수정하면 그 변경을 Yjs Doc에 반영하고, 반대로 Yjs Doc에 다른 사용자의 변경이 적용되면 이를 ProseMirror 문서에 반영합니다. 그리고 이 변경을 다른 클라이언트에게 전달하려면, 서버와 통신하는 모듈이 필요합니다. 이 역할을 맡는 것이 Provider입니다. 도큐먼트에서는 Provider가 웹소켓을 통해 Yjs update를 서버로 보내고, 서버에서 받은 update를 자신의 Yjs Doc에 적용합니다.

실제 흐름에서 사용자 A가 입력한 내용이 B의 화면에 표시되는 과정을 그림으로 같이 살펴볼게요. (연결과 초기 동기화를 끝낸 뒤입니다.)

1. 로컬 변경을 다른 사용자에게 보내기

사용자 A와 B가 함께 편집하는 문서에 Hello가 적혀 있다고 해보겠습니다. A가 문장 끝에 !를 입력해 Hello!로 바꾸면, 이 내용은 다음 과정을 거쳐 B에게 전달됩니다.

  • 1A - ProseMirror가 A의 입력을 문서에 적용합니다. A가 !를 입력하면 ProseMirror는 “Hello 뒤에 !를 삽입한다”는 변경을 담은 트랜잭션을 만듭니다. 이를 적용하면 A의 에디터가 관리하는 문서가 Hello!로 바뀌고, 화면에도 표시됩니다. 이 과정은 서버 응답을 기다리지 않고 진행됩니다.

  • 2A - 바인딩이 수정된 문서를 Yjs Doc에 반영합니다. 에디터가 갱신되면 ySyncPlugin의 바인딩이 ProseMirror 문서를 확인합니다. 그리고 Yjs Doc에도 !가 추가되도록 반영합니다. 이제 A의 ProseMirror 문서와 Yjs Doc이 모두 Hello!라는 내용을 나타냅니다.

  • 3A - Yjs가 다른 클라이언트에 전달할 데이터를 만듭니다. Yjs Doc에 반영된 변경은 바이너리 데이터인 Yjs update로 만들어집니다. 이 데이터에는 추가된 !와 이를 기존 문서에 합치는 데 필요한 정보가 담깁니다. ProseMirror의 트랜잭션이나 Hello!라는 문서 전체를 그대로 보내는 것은 아닙니다. Yjs는 Yjs Doc의 update 이벤트를 통해 이 데이터를 전달합니다.

  • 4A - Provider가 Yjs update를 서버로 보냅니다. update 이벤트를 구독하는 Provider가 데이터를 받아 웹소켓으로 동시편집 서버에 전송합니다. 서버는 이를 같은 문서를 편집하는 B에게 전달합니다.

2. 다른 사용자의 변경을 내 에디터에 반영하기

이번에는 B의 입장에서 살펴보겠습니다. B의 에디터에는 아직 Hello가 표시되어 있습니다. 서버에서 A가 추가한 !에 해당하는 Yjs update를 받으면 다음 과정을 거칩니다.

  • 1B - Provider가 Yjs update를 받습니다. B의 Provider는 동시편집 서버와 연결된 웹소켓을 통해 A의 변경이 담긴 데이터를 수신합니다. 이때까지 B의 에디터 화면에는 Hello가 표시되어 있습니다.

  • 2B - Provider가 받은 데이터를 Yjs Doc에 적용합니다. Provider는 Y.applyUpdate를 호출해 Yjs update를 B의 Yjs Doc에 적용합니다. Yjs가 기존 문서에 A의 변경을 반영하면, B의 Yjs Doc도 Hello!라는 내용을 나타냅니다.

  • 3B - 바인딩이 ProseMirror 문서에도 !를 추가합니다. 바인딩은 Yjs Doc의 문서 내용이 바뀐 것을 감지하고, 이를 ProseMirror 문서에 반영할 트랜잭션으로 만듭니다. 이 예시에서는 B의 문서도 Hello에서 Hello!로 바뀌도록 합니다. A가 만든 트랜잭션을 그대로 전달받는 것이 아니라, B의 바인딩이 자신의 Yjs Doc을 바탕으로 트랜잭션을 만드는 것입니다.

  • 4B - ProseMirror가 문서와 화면을 갱신합니다. 바인딩이 만든 트랜잭션을 적용하면 B의 ProseMirror 문서가 Hello!로 바뀌고, 화면에도 !가 표시됩니다.

이 예시에서는 A만 입력했지만, B도 동시에 문서를 수정했다면 Yjs가 양쪽의 변경을 반영해 각자의 Yjs Doc을 갱신합니다. 이후 바인딩을 통해 각자의 ProseMirror 문서도 같은 내용으로 바뀌고, 사용자는 그 결과를 화면에서 확인할 수 있습니다.

3. 원격 변경이 되돌아가지 않는 이유 살펴보기

이 시점에서 한 가지 의문이 생깁니다. B의 에디터에 !를 추가하기 위해 실행한 트랜잭션도 바인딩이 감지할 텐데요. 이를 B가 직접 편집한 것으로 처리한다면, 방금 받은 변경을 다시 서버로 보내고 다른 에디터에서 또 반영하는 과정이 반복되지는 않을까요?

다행히, 바인딩에는 이런 재진입을 막는 mutex가 구현되어 있습니다. 따라서 한쪽 변경을 반영하는 동안 그 결과로 호출된 반대 방향의 동기화는 건너뛸 수 있습니다.

이와 비슷하게, 서버와 변경을 주고받을 때도 같은 변경을 다시 보내지 않도록 해야 합니다. B의 Provider가 서버에서 받은 Yjs update를 Yjs Doc에 적용하면, 로컬에서 편집했을 때처럼 update 이벤트가 발생할 수 있습니다. Provider가 이 이벤트를 구분 없이 처리하면 A에게서 전달받은 변경을 다시 서버로 보내게 됩니다.

이를 방지하기 위해 Provider는 수신한 Yjs update를 적용할 때 origin에 자신의 인스턴스를 지정합니다. origin은 변경을 적용한 주체를 구분하기 위한 값으로, update 이벤트를 처리할 때도 확인할 수 있습니다. Provider는 origin이 자신의 인스턴스와 같으면 자신이 수신해 적용한 변경으로 판단하고, 서버로 다시 보내지 않아요. 반면 로컬 에디터에서 직접 편집한 내용은 바인딩이 Yjs Doc에 반영하면서 origin에 ySyncPluginKey를 지정합니다. 이 값은 Provider 인스턴스와 다르므로 서버로 전송됩니다.

실제 Provider 구현에서 수신과 전송에 해당하는 부분만 간추리면 다음과 같습니다.

TypeScript

B가 A의 변경을 수신한 경우에는 origin이 B의 Provider이므로 전송을 건너뜁니다. 반면 B가 직접 편집하고 바인딩이 Yjs Doc에 반영한 경우에는 origin이 Provider와 다르므로 서버로 전송합니다.

연결 후 발생한 런타임 오류 추적하기

동기화 흐름을 이해하고 플러그인을 연결하는 것까지는 큰 어려움이 없었습니다. 그런데 실제로 에디터를 실행하자 일부 기능에서 런타임 오류가 발생했습니다. 앱에서 툴킷을 거쳐 ProseMirror를 사용하는 코드에 바인딩과 Yjs까지 더해진 상태여서, 오류 메시지만으로는 어느 부분이 잘못됐는지 가늠하기 어려웠습니다.

그중 하나는 스키마에 정의된 노드를 찾지 못한다는 오류였습니다.

Plaintext
Schema is missing its top node type ('doc')

스키마에는 이미 doc이 정의되어 있었습니다. 그런데도 노드를 찾지 못하는 이유를 확인하기 위해, 노드 정의를 객체에 담아 ProseMirror에 전달하는 과정을 브라우저 디버거로 직접 살펴봤습니다.

오류를 일으킨 모듈 중복 확인하기

툴킷은 스키마의 노드 정의를 orderedmap 패키지의 OrderedMap 객체로 만들어 ProseMirror에 전달했습니다. 그런데 객체를 만드는 쪽과 읽는 쪽이 서로 다른 OrderedMap 생성자를 참조하고 있었습니다. orderedmap이 중복으로 로딩된 것입니다.

같은 코드라도 모듈이 별도로 로딩되면 모듈 안에서 만든 생성자는 서로 다른 객체가 됩니다. 한쪽 생성자로 만든 객체를 다른 쪽 생성자로 instanceof 검사를 하게 되면 false가 나옵니다. 두 모듈의 버전이 같아도 마찬가지에요. 이 인스턴스 판별에 실패하면서 스키마를 제대로 읽지 못했고, 결국 최상위 노드인 doc을 찾을 수 없다는 오류로 이어졌습니다.

이처럼 객체나 모듈 내부 상태를 함께 사용하는 라이브러리는 버전뿐 아니라 실제로 참조하는 모듈도 같아야 합니다. 툴킷과 바인딩 사이에서도 공유 의존성을 하나의 모듈 인스턴스, 즉 싱글턴으로 유지해야 했습니다.

툴킷 안팎의 의존성 구조 살펴보기

같은 의존성이 두 벌 생긴 이유를 확인하기 위해 툴킷과 바인딩이 모듈을 가져오는 경로를 살펴봤습니다. 기존 툴킷은 ProseMirror 관련 모듈을 번들 안에 포함해 배포했습니다. 당시에는 툴킷 밖의 다른 모듈과 ProseMirror를 공유할 필요가 없었으므로 이 구조가 문제가 되지는 않았습니다.

반면 바인딩은 툴킷을 사용하는 앱(이하 호스트)에 설치된 ProseMirror를 가져옵니다. 이 경로가 만들어진 이유는 패키지의 의존성 선언에 있습니다. JavaScript 패키지는 package.json에서 주로 다음 세 항목으로 의존성을 선언합니다.

  • dependencies: 패키지가 실행될 때 필요한 의존성입니다.

  • devDependencies: 빌드 도구나 테스트 도구처럼 패키지를 개발하는 데 필요한 의존성입니다. 이 패키지를 다른 앱에 설치할 때는 함께 설치되지 않습니다.

  • peerDependencies: 호스트에서 함께 제공해야 할 의존성과 호환 버전을 선언합니다. React 컴포넌트 라이브러리가 자신과 함께 사용할 React 버전을 명시하는 경우가 대표적입니다.

peer dependency는 호스트와 어떤 의존성을 공유할지 명시합니다. 호스트에 설치된 패키지가 요구 사항을 만족하면 이를 사용하고, 없다면 패키지 매니저의 정책에 따라 추가로 설치합니다. 예를 들어 pnpm은 autoInstallPeers 설정이 기본적으로 켜져 있어, 누락된 필수 peer dependency를 자동으로 설치합니다. 다만 패키지 간 요구 버전이 충돌하면 자동으로 설치하지 않고 경고를 표시합니다.

바인딩은 ProseMirror 관련 패키지를 peer dependency로 선언합니다. 호스트가 이 요구 사항에 맞춰 ProseMirror를 설치하더라도, 툴킷 번들 안에 이미 들어 있는 코드를 대체하지는 않습니다. 결국 툴킷은 번들 내부의 ProseMirror를, 바인딩은 호스트의 ProseMirror를 사용하면서 관련 의존성을 중복으로 로딩할 수 있는 구성이 된 것입니다.

React에서도 비슷한 문제를 만날 수 있습니다. 컴포넌트 라이브러리가 React를 자체 번들에 포함하고 앱의 React DOM은 앱에 설치된 React를 사용하면, 컴포넌트에서 호출한 Hook이 정상적으로 동작하지 않을 수 있습니다. React 문서에서도 컴포넌트와 React DOM이 같은 React 모듈을 참조해야 한다고 설명합니다.

의존성 선언과 번들 설정 함께 바꾸기

문제를 해결하려면 툴킷과 바인딩이 호스트의 의존성을 함께 사용하도록 맞춰야 했습니다. 다만 peerDependencies에 선언하는 것만으로 툴킷 번들에 들어 있던 코드가 사라지지는 않습니다. 패키지 매니저가 설치할 의존성을 정하는 일과 번들러가 배포할 코드에 무엇을 넣을지 정하는 일은 별개이기 때문입니다. 공유할 의존성은 빌드할 때도 번들에서 제외해야 했습니다.

이때 사용하는 설정이 external입니다. 지정한 모듈을 현재 번들에서 제외하고 외부 참조로 남겨, 사용하는 쪽에서 의존성을 제공하도록 합니다.

툴킷에서도 이 설정을 적용해 공유 의존성을 번들에서 제외했습니다. ProseMirror 관련 모듈을 묶은 ProseMirror 공통 패키지를 만들고, 툴킷의 peer dependency로 선언한 뒤 빌드할 때 external로 지정했습니다. 바인딩도 이 공통 패키지를 참조하도록 바꿔, 툴킷과 바인딩이 같은 ProseMirror를 사용하게 했습니다.

중복 로딩으로 오류가 발생했던 orderedmap도 같은 방식으로 정리했습니다. 툴킷과 ProseMirror 공통 패키지 양쪽 빌드에서 external로 지정해, 두 패키지가 호스트에 설치된 같은 OrderedMap 생성자를 사용하도록 했습니다.

이 구조는 호스트에서 중복 의존성을 정리하는 dedup과 번들 최적화에도 유리합니다. 패키지 매니저는 라이브러리 번들에 이미 들어간 코드까지 제거할 수 없습니다. 그러나 의존성을 외부 참조로 남겨 두면 호환되는 설치본을 공유할 수 있고, 앱의 번들러도 해당 모듈을 기준으로 중복을 줄이거나 사용하지 않는 코드를 제거할 여지가 생깁니다.

다만 external 설정만으로 싱글턴이나 번들 크기 감소가 항상 보장되는 것은 아닙니다. 요구 버전이 호환되고, 최종 앱에서도 같은 모듈을 가져와야 합니다. 이 조건에 맞춰 툴킷과 바인딩의 참조 경로와 빌드 설정을 함께 정리했습니다.

마치며

이번 작업에서는 기존 에디터에 동시편집을 연결하며 라이브러리 내부의 동작을 직접 살펴볼 수 있었습니다. 평소 앱을 개발할 때는 깊이 들여다보지 않았던 패키지 의존성 관리도 고민해볼 수 있는 기회였습니다. 의존성 오류의 원인을 따라가다 보니 패키지가 설치되고 번들에 포함되는 방식을 이해하게 됐고, 번들 최적화까지 함께 생각해 볼 수 있었습니다.

AI의 도움으로 코드를 작성하고 문제를 해결하기가 쉬워진 지금, 해결 과정에서 배울 수 있는 것들을 놓치지 않는 일도 중요하다고 생각합니다. 해결 방법이 왜 동작하는지 확인하고 관련된 개념까지 찾아보면, 하나의 문제에서도 더 많은 것을 배울 수 있습니다. 이 글이 평소 사용하는 라이브러리의 내부 동작이나 의존성을 한 번 더 들여다보는 계기가 되었으면 좋겠습니다.

We Make a Future Classic Product