동시편집 중 서버에서 일어나는 일

도큐먼트 동시편집 개발기 EP.1 기존 문서 시스템을 동시편집 구조로 바꾸기

Clo • Backend Engineer

  • Backend

안녕하세요, 채널톡 백엔드 개발자 클로입니다.

도큐먼트는 채널톡 내의 지식 관리 기능입니다. 문서를 직접 편집해서 웹사이트에 공개할 수 있고, 상담 에이전트인 ALF가 작성된 문서를 참고하여 보다 정확히 응대할 수도 있습니다. 한번 작성해 둔 문서는 사내 구성원이 AI 비서실장인 CoS에게 물어보는 등 온보딩에도 활용할 수 있습니다.

이런 도큐먼트에 작년 8월 동시편집 기능이 추가되었는데요. 1편에서는 제가 기존 문서 시스템을 동시편집 구조로 바꾼 이야기를, 2편에서는 티모가 기존 에디터에 동시편집을 연결하며 해결한 문제들을 다뤄보려 합니다.

왜 동시편집이 필요했을까요?

동시편집이 도입되기 이전, 도큐먼트는 버전을 관리할 수 있는 다국어 문서 시스템이었습니다. 하나의 아티클을 언어별로 관리할 수 있고, 그중 특정 버전을 퍼블리시하여 도큐먼트 웹사이트에 배포하거나 ALF, CoS 같은 에이전트가 RAG에 활용할 수 있었어요. 채널톡의 사용자 가이드도 도큐먼트 웹사이트를 사용하고 있습니다.

도큐먼트가 사용되는 곳이 많은 만큼, 문서를 관리하고 활용하는 경험이 매끄럽게 이어질 때 가장 큰 효과를 낼 수 있어요. 그러나 기존 도큐먼트는 혼자 문서를 관리할 때는 괜찮았지만, 여러 팀 멤버가 같은 문서를 수정하는 상황에서는 불편한 부분이 있었어요. 같은 문서를 서로 다른 팀 멤버가 수정할 때는 충돌을 막기 위해, git으로 치면 브랜치를 나눠서 각자 다른 버전을 바라보며 편집하는 방식이었는데요. 이런 동작 방식은 비개발자 팀 멤버가 이해하기 어려웠어요.

신뢰도가 떨어지는 문서는 쉽게 가치를 잃어버립니다. 도큐먼트가 잘 활용되기 위해서는 고객사가 별도 문서 서비스 없이 도큐먼트 안에서 편하게 문서를 작성/관리할 수 있도록 편집 장벽을 낮춰야 했어요.

이러한 문서 편집 장벽을 가장 쉽게 낮출 수 있는 방법은 역시 동시편집이었습니다. 도큐먼트가 SSOT로 잘 관리될 수 있다면, 채널톡에 쌓이는 상담 데이터에서 드러나는 문서 개선점(e.g., outdated된 정보)을 찾아 문서 변경을 제안해 주는 선순환까지도 만들 수 있다고 생각했어요.

어떤 기술을 선택했을까요?

1. 동시편집 동기화 기술 (CRDT vs. OT)

가장 먼저 동시편집 동기화 기술을 결정해야 했습니다. 잘 알려진 OT(Operational Transformation)와 CRDT(Conflict-free Replicated Data Type) 기술이 주요 후보였어요.

동시편집은 결국 클라이언트 A, B가 동시에 연산 op_a와 op_b를 발생시킬 때, A는 op_a → op_b 순서로 연산을 적용하고, B는 op_b → op_a 순서로 연산을 적용하는 상황에서 생기는 문서의 불일치를 어떻게 해결할 것인가, 에 대한 문제입니다. 이를 OT와 CRDT가 어떤 식으로 해결하는지 간략하게 짚고 넘어가볼게요.

OT는 동시에 발생한 연산을 서로에 맞게 변환해 충돌을 해결하는 방식입니다. 보통 중앙 서버가 중간에서 op를 transform하여 다른 클라이언트에게 전달합니다. 위 상황을 예로 들면 서버는 op_a → op_b’ = op_b → op_a’가 성립하도록 op_a’와 op_b’를 구하는 역할을 수행해요.

한 사람이 문장 앞에 글자를 삽입하고 다른 사람이 그 뒤쪽 글자를 지우는 상황에서, 삽입이 먼저 반영된 문서에서는 삭제 연산을 수행할 때 삭제되는 범위 인덱스를 그만큼 뒤로 옮겨야 하는데, 이렇게 연산을 조정하는 것이 transform의 역할입니다.

이론적으로는 중앙 서버 없는 분산 OT도 가능합니다만, 일반적으로는 중앙 서버를 사용합니다. transform 함수가 만족해야 하는 두 성질 TP1, TP2 중 TP2를 만족시키기가 매우 까다로운데, 서버가 모든 연산의 순서를 하나로 정해주면 TP1만으로 수렴을 보장할 수 있기 때문이에요[1]. 서버는 새로 들어온 op를 그사이 다른 클라이언트들이 보낸 op들에 맞춰 변환해야 해서, 동시에 편집하는 사람이 많아질수록 서버의 변환 부담도 빠르게 커집니다[2]. Google Docs가 OT를 사용하는 대표적인 서비스에요[3].

CRDT는 각자 편집한 내용을 나중에 합쳐도 최종 상태가 같아지도록 설계한 자료구조입니다. 예를 들어 op-based CRDT의 경우 별도 transform 없이도 연산 자체가 교환법칙을 만족하도록 자료구조를 설계해요.

문자열 자료구조에서 insert(idx, ch) 메소드의 정수 인덱스는 동시편집 환경에서 삽입할 위치를 안정적으로 나타내지 못하니, 문자마다 고유 ID를 부여하도록 문자열 자료구조를 재설계하고 insertAfter(nodeId, ch) 메소드를 도입해 삽입 연산 자체를 바꾸는 식이에요(이러한 ID류의 방식만 있는 건 아닙니다).

편집에 참여한 클라이언트끼리 연산을 공유할 수만 있으면 되어서 클라이언트-서버 구조, P2P 구조 등은 자유롭게 선택 가능합니다. Linear, Zed가 CRDT를 사용하는 대표적인 서비스에요[4][5].

동시편집 동기화 기술의 주요 판단 기준으로는 오픈소스 생태계, 서버 확장성, 오프라인 편집 등이 있었는데요. 인원이 적은 팀에서 빠르게 동시편집 기능을 붙이는 게 목표였던 만큼, 오픈소스 생태계가 크고 레퍼런스가 많은 CRDT 기술을 최종적으로 선택하게 되었습니다.

CRDT

OT

오픈소스 생태계 (26년 11월 기준)

큼 (yjs 23k 등)

작음 (sharedb 6.6k, ot.js 2.1k 등)

서버 확장성

서버는 op를 중계만 하면 되어 수평 확장이 쉬움

문서마다 op 순서를 확정하는 단일 지점이 필요

오프라인 편집

오프라인 기간과 무관하게 병합

서버가 op를 보관하는 기간 내에서만 병합

2. 네트워크 구조 (클라이언트-서버 vs. P2P)

이제 연산을 전파할 방법론을 결정해야 합니다. 1번에서 CRDT를 선택한 만큼 P2P도 선택할 수 있는 옵션이었어요. 클라이언트-서버 구조와 P2P 방식의 장단점은 아래와 같았습니다.

클라이언트-서버

  • 장점

    • 클라이언트는 서버 하나와만 연결을 유지하면 되고, 다른 참여자에게 변경을 전파하는 건 서버가 맡음

    • 기존 문서 접근 권한과 저장 흐름에 연결하기 쉬움

  • 단점

    • 모든 편집 트래픽이 서버를 거치므로, 서버가 동시 접속 수와 전송량을 감당해야 함

P2P

  • 장점

    • 클라이언트끼리 직접 주고받으므로 서버가 중계하는 트래픽을 줄일 수 있음

  • 단점

    • NAT나 방화벽 같은 네트워크 환경 때문에 클라이언트 간 직접 연결이 안 될 수 있어, 결국 중계 서버가 필요할 수 있음

    • 모든 참여자가 서로 직접 연결하는 구성에서는 편집자가 늘수록 클라이언트마다 유지해야 할 연결 수가 늘어남

기존 도큐먼트 시스템은 이미 중앙화된 클라이언트-서버 구조의 서비스였고, 인증과 영속화 등도 중앙 서버에서 처리하는 것이 간단했습니다. 그에 비해 P2P를 사용할 때의 유의미한 장점은 보이지 않았고, 동시 접속자 수가 늘어난다면 추가적인 고민이 필요해보였습니다.

이러한 이유로 클라이언트-서버 구조를 가져가기로 결정했습니다. 추가로 채널톡의 실시간 채팅 서버에서 쌓인 노하우가 있었기 때문에(채널톡 실시간 채팅 서버 개선 여정 블로그), 실시간 이벤트 전달은 웹소켓을 사용하여 구현하기로 결정했습니다.

3. CRDT 오픈소스

CRDT는 정말 다양한 오픈소스가 있는데요. 일차적으로 추린 후보는 Yjs, Automerge, Loro, Yorkie였습니다.

Yjs와 Automerge는 가장 인기 있고 레퍼런스가 많은 오래된 오픈소스지만 추가 릴리즈가 적극적으로 있다기보다는 유지보수 정도만 되는 느낌이었습니다. Loro는 메인테이너가 적극적이고 발빠르게 최신 CRDT 기술을 도입한다는 점이 장점이었지만 그만큼 러닝커브가 높았습니다. Yorkie는 한국 오픈소스라 한국어 커뮤니티가 잘 되어있는 편이어서 후보에 넣었습니다.

채널톡에서는 ProseMirror 기반 에디터를 사용합니다. 동시편집을 도입한다고 해도 이 에디터는 그대로 유지하려고 했기에, ProseMirror와의 연동성과 레퍼런스가 오픈소스를 선택할 때 가장 중요한 기준이었습니다.

결과적으로 생태계가 가장 크면서 y-prosemirror와 같은 ProseMirror 플러그인도 제공하는 Yjs를 선택했어요. 기초적인 수준이지만 클라이언트-서버 구조에서 참고할만한 y-websocket-server나, Yjs를 사용하는 개인이 올린 오픈소스 repo가 상당수 있던 점도 장점이었습니다.

동시편집을 위해 무엇을 추가했을까요?

Yjs Doc과 Yjs update

어떤 데이터가 필요한지 이해하기 위해, 우선 Yjs가 문서를 여러 참여자가 함께 수정할 수 있는 데이터 구조로 관리하는 방법을 알아볼게요.

Yjs에서 공유 상태를 담는 기본 단위는 Y.Doc(이하 Yjs Doc)입니다. 그 안에는 텍스트를 다루는 Y.Text, 키와 값을 다루는 Y.Map, 트리 형태의 내용을 다루는 Y.XmlFragment 등의 타입을 둘 수 있습니다.

Yjs Doc에 발생한 변경은 바이너리 데이터인 업데이트(이하 Yjs update)로 표현되고, 클라이언트는 이 Yjs update를 주고받으면서 Yjs Doc을 동기화합니다. Yjs Doc은 문서를 편집 중인 클라이언트 N개와, 그걸 중재하는 M개의 동시편집 서버 인스턴스의 메모리에 유지됩니다.

특히 Yjs update는 다음 성질을 만족하므로, 동시편집 과정에서 발생한 Yjs update를 모두 전달받아 각자의 Yjs Doc에 적용한다면 최종적으로 모든 사용자가 보는 문서 상태는 수렴하게 됩니다.

  • 교환법칙: 같은 업데이트들을 모두 적용했다면 적용 순서가 달라도 결과가 같습니다.

  • 결합법칙: 여러 업데이트를 어떤 묶음으로 합치더라도 결과가 같습니다.

  • 멱등성: 같은 업데이트를 여러 번 적용해도 변경이 중복으로 반영되지 않습니다.

변경을 다른 사용자에게 전달하기

Yjs Doc 동기화를 위해서는 클라이언트에서 생기는 Yjs update를 다른 클라이언트에게 전달해주어야 합니다. 이 역할을 위해 새로 웹소켓 서버(이하 동시편집 서버)를 추가했어요. 동시편집 서버는 동시편집 레이어에서 필요한 역할을 수행하고, 기존 도큐먼트 API 서버와 협력하여 기존 기능의 호환성을 유지합니다.

또, 각 클라이언트는 자신이 편집하는 문서의 Yjs Doc 상태를 관리합니다. 사용자가 문서를 편집하면 Yjs update가 생성되고, 클라이언트는 이 업데이트를 동시편집 서버를 통해 다른 클라이언트에 전달합니다. 업데이트를 받은 클라이언트는 자신의 Yjs Doc에 이를 적용하고, 연결된 에디터에 변경을 반영합니다. (ProseMirror 기반 에디터와 Yjs Doc의 자세한 상호작용 방식이 궁금하시다면 2편을 참고해주세요)

편집 중인 문서는 동시편집 서버에서 room 단위로 관리됩니다. 동시편집 서버는 room마다 해당하는 Yjs Doc을 바인딩해놓고, Yjs update 이벤트를 받으면 그 room의 Yjs Doc에 업데이트를 먼저 적용한 후 room에 접속해있는 다른 클라이언트에게 이벤트를 전파합니다.

클라이언트가 동시편집 서버와 최초 연결될 때, 혹은 네트워크 이슈로 연결이 끊겼다가 다시 재연결됐을 때는 그 사이에 클라이언트/서버 사이드에서 발생한 Yjs update를 다른 쪽이 모르기 때문에 별도 상태 동기화가 필요합니다. 이때는 Yjs에서 가이드하는 @y/protocols/sync 프로토콜을 따르는데요. 간략히 설명하면 아래와 같아요.

  1. 클라이언트가 자신의 Yjs Doc이 어느 Yjs update까지 알고 있는지를 나타내는 state vector라는 것을 서버에 전달합니다.

  2. 서버가 자신의 Yjs Doc을 토대로, 1에서 받은 state vector 이후에 발생한 Yjs update를 모아서 클라이언트에게 되돌려줍니다.

  3. 클라이언트가 2에서 받은 Yjs update를 자신의 Yjs Doc에 적용합니다.

이렇게 하면 네트워크가 끊겨있던 동안 서버의 Yjs Doc에만 적용됐던 업데이트를 클라이언트로도 가져올 수 있어요. 반대로 네트워크가 끊겨있던 동안 클라이언트의 Yjs Doc에만 적용됐던 업데이트를 서버가 받아와야 한다면 방금 전의 step 1을 서버가 트리거하면 됩니다. 이 방식으로 Yjs Doc 상태를 동기화하면 추후에 오프라인 편집이 추가되더라도 상태 동기화 알고리즘을 그대로 유지할 수 있어요.

동시편집 문서를 영속화하기

동시편집이 없던 시절에는 저장 API가 곧 DB 저장이고 영속화였지만, 클라이언트에서의 문서 편집이 동시편집 서버만을 거쳐 다른 클라이언트에게 전파되는 구조에서는 별도 영속화 메커니즘이 필요합니다.

영속화할만한 대상으로는 크게 Yjs Doc과 Yjs update가 있는데요. 결론만 말하면 동시편집 서버가 유효한 Yjs update를 수신할 때마다 DB에 그 값을 append하는 방식으로 동시편집 데이터를 영속화합니다. Yjs Doc은 별도로 저장하지 않습니다.

여기서 유효하다는 것은, 동시편집 서버 인메모리에 있는 Yjs Doc에 수신한 Yjs update를 적용해봤을 때 문제가 발생하지 않았음을 의미합니다. 대부분의 Yjs update는 유효합니다. 이 검증은 Yjs update의 크기가 너무 크거나, 특정 클라이언트의 Yjs Doc이 깨져서 이상한 Yjs update가 생겼을 때 그 영향이 문서 전체에 전파되지 않도록 막는 역할을 합니다.

Yjs Doc을 저장하지 않고 Yjs update만 저장하는 이유는 크게 두 가지입니다.

첫째로, durability 관점에서 Yjs update를 저장해야만 합니다. Yjs Doc을 저장하는 건 일종의 스냅샷을 반복해서 찍는다고 볼 수 있을 텐데요. Yjs update 없이 Yjs Doc만을 저장한다면 동시편집 서버가 다운되었을 때 마지막 스냅샷 이후에 발생한 편집 내역이 사라질 수밖에 없습니다.

둘째로, Yjs Doc을 저장하지 않으면 관리 복잡도를 낮출 수 있습니다. Yjs Doc은 Yjs update 리스트로부터 변환할 수 있으니 필수 정보가 아니라 관리할 필요가 없습니다. Yjs Doc에 대한 스냅샷을 주기적으로 찍는다면, 멀티 인스턴스 환경에서 어떤 인스턴스가 그 책임을 가져갈 것인지도 모호합니다.

한편, 문서 편집이 발생할 때마다 Yjs update를 DB에 append하는 구조에서는 편집이 계속될수록 row가 점점 늘어납니다. 무한정 row를 쌓을 수는 없기 때문에, 일정 개수 이상 업데이트가 쌓이면 Yjs의 mergeUpdates를 사용해 작은 Yjs update들을 하나의 큰 Yjs update로 만들어 저장하는 로직도 필요합니다.

기존 문서 형식(blocks)과는 어떻게 연결했을까요?

지금까지 동시편집을 위한 새로운 데이터인 Yjs Doc과 Yjs update를 위주로 살펴봤는데요. 동시편집 기능을 추가하는 것만큼 기존의 도큐먼트 기능을 자연스럽게 유지하는 것도 중요한 문제였어요.

기존 도큐먼트는 문서 본문을 blocks라고 부르는 내부 데이터 형식으로 저장하고 사용합니다. 동시편집을 지원하면서 기존 기능도 유지하려면 Yjs Doc ↔ blocks 간의 변환이 가능해야 해요.

blocks → Yjs Doc 변환은 기존 blocks 기반 문서를 동시편집에 연결할 때 사용해요. 아직 Yjs 데이터가 없는 문서에서는 기존 blocks 본문을 토대로 Yjs Doc을 만들어 초기화할 수 있습니다. 반대 방향인 Yjs Doc → blocks 변환은 동시편집으로 수정한 내용이 기존 문서 기능(e.g., 도큐먼트 웹사이트)에도 반영되려면 필요합니다.

실제 변환을 구현할 때는 ProseMirror의 문서 모델을 중간 매개로 사용했습니다. blocks ↔ ProseMirror 문서 모델 간의 변환은 기존 도큐먼트 시스템에서 사용하던 로직을 재사용할 수 있었고, Yjs ↔ ProseMirror 문서 모델 간의 변환은 y-prosemirror 오픈소스를 참고했어요. 최종적으로 Yjs ↔ ProseMirror ↔ blocks 흐름을 통해 서로 다른 문서 타입 간의 변환을 구현할 수 있었습니다.

이렇게 동시편집 데이터와 기존 문서 형식 간의 호환성을 유지할 수 있었고, Yjs 데이터를 편집의 SSOT로 두되 주기적으로 Yjs → blocks 방향으로 변환하여 기존 문서 형식으로도 저장하는 방식을 채택했어요. 덕분에 도큐먼트 웹사이트나 RAG처럼 도큐먼트를 활용하는 서비스들에서 동시편집 도입 이후에 별도 대응 없이 기존 기능을 제공할 수 있었습니다.

마치며

이번 글에서는 동시편집이 필요했던 이유와 선택했던 기술, 동시편집을 구현하며 추가한 요소들을 알아봤어요. 글에서는 생략됐지만 새로운 마이크로서비스가 추가되고 서비스 복잡도가 높아진 만큼, 전체 흐름을 꿰고 있어야만 해결할 수 있는 여러 문제를 만났었는데요. 분산 환경 디버깅을 위해 observability 향상을 고민하거나, Yjs 라이브러리의 내부 구현을 뜯어보는 등 여러모로 딥다이브할 기회가 많이 있었던 것 같아요.

이번 글은 서버 위주의 이야기였는데요. 클라이언트 관점에서의 내용이 궁금한 분들은 2편: 기존 에디터에 동시편집을 연결하며 해결한 문제들도 함께 읽어보는 걸 추천드립니다.

참고 자료

  1. Verifying Strong Eventual Consistency in Distributed Systems, OOPSLA, 2017. (8.1절)

  2. Collaborative Text Editing with Eg-walker: Better, Faster, Smaller, EuroSys, 2025. (1절)

  3. What’s different about the new Google Docs: Making collaboration fast, Google Drive Blog, 2010.

  4. Who is using Yjs, yjs/yjs README, GitHub.

  5. How CRDTs make multiplayer text editing part of Zed’s DNA, Zed’s Blog, 2022.

We Make a Future Classic Product