Yjs 상태 벡터와 벡터 클록은 무엇이 다른가?
둘 다 노드별 정수 배열이지만 갱신 규칙과 용도가 다르다. 벡터 클록은 송수신을 포함한 모든 사건마다 카운터를 올려 인과관계를 추적한다. Yjs 상태 벡터는 클라이언트별로 다음에 기대하는 clock만 기록해 상대가 놓친 조각을 계산하는 데만 쓰며, Yjs README도 인과관계 추적에는 쓰지 않는다고 명시한다.
복제본이 ‘어디까지 알고 있는지’ 한 줄로 적는 표. 차이만 보내는 원리와 클라이언트가 늘면 무거워지는 이유, Automerge의 다른 길
실시간 협업 편집기를 만들다 보면 한 번은 마주치는 낱말이 있다. 상태 벡터(state vector)다. Yjs 문서에도 pycrdt API에도 등장하지만 분산 시스템 교과서의 벡터 클록과 무엇이 다른지 짚어주는 글은 드물다. 클코단에서도 Yjs와 pycrdt를 써본 사람에게 이 개념을 쉽게 풀어달라는 요청이 나왔고 짧은 설명과 함께 한계와 대안까지 이야기가 이어졌다. 이 기사는 그 질문을 이어받아 상태 벡터가 정확히 무엇을 기록하는지, 왜 벡터 클록과 용도가 다른지, 어디서 힘이 빠지는지를 공식 문서와 저장소 이슈로 확인한다.
Yjs는 문서에 들어가는 모든 조각에 (clientID, clock) 한 쌍의 번호표를 붙인다. clientID는 문서를 여는 세션마다 무작위로 새로 뽑는다. clock은 그 클라이언트가 삽입한 순서대로 0부터 올라간다. Yjs 저장소의 INTERNALS 문서는 이 번호표를 램포트 타임스탬프라고 부른다.
상태 벡터는 이 번호표를 클라이언트별로 하나씩만 남긴 표다. 각 clientID에 대해 ‘다음에 받을 것으로 기대하는 clock’을 적는다. 다른 해석으로는 ‘그 클라이언트가 만든 조각이 몇 개인지’를 적은 표다. Yjs 공식 문서는 두 해석을 나란히 적어 놓았다.
방에서 나온 설명도 같은 자리를 짚었다. 상태 벡터는 복제본이 알고 있는 변경 이력의 경계선이라는 의견이었다. 사건 하나하나의 시각을 적는 게 아니라, 클라이언트별로 ‘여기까지’라는 눈금 하나만 갖는다는 점이 핵심이다.
벡터 클록도 노드별 정수 배열이다. 그래서 둘을 같은 것으로 부르는 글이 많다. 그러나 분산 시스템 문헌은 둘을 구분한다. 위키피디아 ‘버전 벡터’ 항목과 Dotted Version Vectors 논문에 따르면, 벡터 클록은 사건의 인과관계를 추적하고 버전 벡터는 복제본 데이터의 분기를 추적한다. 구조는 같지만 의미와 갱신 규칙이 다르다.
갱신 규칙의 차이는 이렇다. 벡터 클록은 메시지 송신과 수신을 포함한 모든 사건마다 자기 칸을 올린다. 버전 벡터는 데이터가 실제로 바뀔 때만 자기 칸을 올리고 두 복제본이 동기화하면 칸마다 최댓값을 취해 둘이 같은 벡터를 갖게 된다. arXiv에 올라온 ‘Causality is Graphically Simple’ 논문은 버전 벡터가 1983년에 먼저 나왔고 벡터 클록은 5년 뒤에 등장했다고 적었다.
Yjs의 상태 벡터는 이 가운데 버전 벡터 쪽에 선다. Yjs README는 상태 벡터가 버전 벡터와 비슷하지만 오직 로컬 문서의 상태를 기술해 상대가 놓친 조각을 계산하는 데만 쓰고 인과관계 추적에는 쓰지 않는다고 명시했다. 벡터 클록은 ‘A가 B보다 먼저였나’를 묻는다. 상태 벡터는 ‘너한테 뭘 보내야 하나’를 묻는다.

Yjs 문서의 Document Updates 페이지는 두 가지 동기화 방식을 나란히 보여준다. 단순한 방식은 encodeStateAsUpdate로 문서 전체를 뽑아 서로 applyUpdate하는 것이다. 절약형은 먼저 encodeStateVector로 상태 벡터를 교환한다. 상대의 상태 벡터를 encodeStateAsUpdate의 두 번째 인자로 넘겨 빠진 부분만 인코딩한다. 문서는 왕복이 한 번 늘지만 대역폭을 크게 아낄 수 있다고 적었다.
y-protocols 저장소의 PROTOCOL.md는 이 두 단계를 SyncStep1(상태 벡터 전송)과 SyncStep2(빠진 업데이트 응답)로 이름 붙였다. 한 가지 예외가 있다. SyncStep2는 삭제 정보를 항상 전부 보낸다. 상태 벡터는 삽입 clock만 세기 때문에, 상대가 어떤 삭제를 이미 알고 있는지 싸게 알릴 방법이 없다고 문서는 설명한다.
문서를 메모리에 올리지 않고도 같은 일을 할 수 있다. encodeStateVectorFromUpdate로 바이너리 업데이트에서 상태 벡터를 뽑고 diffUpdate로 차이를 만든 뒤 mergeUpdates로 합친다. 서버가 Y.Doc 인스턴스를 만들지 않고 저장된 블롭만으로 동기화를 처리하려는 경우에 쓰는 경로다.
파이썬에서는 pycrdt가 같은 API를 제공한다. pycrdt는 Yjs의 러스트 포트인 Yrs에 대한 바인딩으로, Project Jupyter 소속 David Brochart가 만들었고 PyPI 기준 2026년 8월에 0.14.4가 나왔다. API 레퍼런스에 따르면 Doc.get_state()가 상태 벡터를 돌려주고 Doc.get_update(state)가 그 상태 이후의 변경만 담은 업데이트를 만들며 apply_update로 적용한다. 변경을 실시간으로 흘려보내려면 doc.observe에 콜백을 걸고 event.update 바이트를 전송한다.
JupyterLab의 실시간 협업 확장 jupyter-collaboration이 이 스택 위에 서 있다. 서버 쪽 문서 모델은 pycrdt와 jupyter_ydoc, 웹소켓 전송은 pycrdt-websocket, 영속화는 SQLite 기반 pycrdt-store가 맡는다. 방에서 pycrdt가 Yrs 기반 파이썬 바인딩이라는 소개가 나왔는데 실제 쓰임새로는 이 노트북 협업 스택이 있다.
상태 벡터의 크기는 지금까지 문서를 건드린 clientID 수에 비례한다. Yjs FAQ는 세션마다 새 clientID를 뽑는 것이 충돌을 피하기 위한 설계라고 밝히고 같은 계정이 브라우저 창을 여러 개 열 수 있으니 clientID를 재사용하지 말라고 권한다. 새로고침 후 새 clientID로 삽입이 일어나면 상태 벡터의 항목이 하나 늘어난다는 뜻이다.
Yjs 저장소 이슈 415번은 그 결과를 수치로 보여준다. 보고자는 몇 달 쓰인 문서에 과거 클라이언트가 1,000개쯤 쌓이는 상황을 가정했다. 프로파일링 결과 트랜잭션마다 호출되는 getStateVector가 Map 전체를 복사하면서 클라이언트 수에 비례한 시간이 들었다. 방에서 상태 벡터 크기가 복제본 수에 비례해 커지는 점이 한계라는 의견이 나왔는데 저장소 이슈가 같은 증상을 기록한다.
순번 공백은 다른 종류의 문제다. 어떤 클라이언트의 clock 270까지는 받았는데 272가 먼저 도착하면, Yjs는 271이 올 때까지 그 조각을 pendingStructs에 보류한다. 이슈 406번은 mergeUpdatesV2로 합친 업데이트에 누락된 조각이 있어 보류 상태가 남는 사례를 다뤘다. 커뮤니티 포럼에는 보류 조각이 쌓이고 tldraw 화이트보드 문서가 절반만 복원됐다는 사례도 올라왔다. 러스트 포트 y-crdt의 이슈 431번은 업데이트 적용 뒤 보류 조각이 남아 있으면 SyncStep1부터 다시 시작하는 복구 방식을 제안했다.
써본 사람들 사이에서는 이 두 한계를 운영 문제로 받아들이는 분위기가 강하다. 첫 로드에 큰 상태 벡터가 오가는 것을 줄이려고 IndexedDB에 저장된 문서를 먼저 채운 뒤 SyncStep1을 보내면 SyncStep2가 가벼워진다는 팁이 돈다. 클라이언트-서버 구조라면 업데이트마다 순번을 매겨 DB에 쌓고 재접속 시 ‘내 마지막 번호 이후’만 요청하는 쪽이 단순하다는 의견도 있다. 그래서 Yjs는 동기화 엔진이 아니라 자료구조라는 말이 반복된다.
개인 개발자나 소규모 팀은 이 지점에서 선택이 갈린다. 편집기 바인딩과 전송 프로바이더가 필요하면 Yjs 생태계가 여전히 가장 넓다. PkgPulse의 2026년 비교에 따르면 주간 다운로드 규모에서 Yjs가 Automerge의 열 배를 넘는다. 다만 Yjs 14는 릴리스 페이지 기준 2026년 9월 rc.26까지 나온 릴리스 후보 단계다. clientID를 32비트에서 53비트로 넓히는 와이어 수준 변경이 들어간다. Yrs 문서는 옛 버전과 맞추려면 small-client 기능 플래그를 켜라고 안내한다. 파이썬 서버와 자바스크립트 클라이언트를 섞어 쓸 계획이면 이 호환성부터 확인해야 한다.
방에서 대안으로 언급된 Automerge는 상태 벡터를 쓰지 않는다. 변경 하나하나가 부모의 해시를 가리키는 그래프를 만든다. 후속이 없는 변경의 해시를 헤드(heads)라고 부른다. 자바스크립트 API의 getHeads가 이 값을 돌려준다. Git과 비슷하지만 동시 변경을 허용하므로 한 시점에 헤드가 여러 개일 수 있다.
동기화는 헤드 해시와 블룸 필터를 함께 보낸다. Martin Kleppmann이 2020년 블로그에 설계를 공개했다. 상대의 헤드를 내가 이미 알고 있으면 그 후속만 보내면 끝난다. 양쪽이 갈라졌을 때는 알고 있는 변경 해시를 블룸 필터에 담아 보내고 상대가 필터에 없는 변경을 골라 보낸다. 프로토타입은 변경당 10비트를 썼다. 블룸 필터의 거짓 양성 때문에 왕복이 여러 번 필요할 수 있다는 점은 automerge 저장소의 sedimentree 문서도 인정한다.
이 방식의 장점은 clientID 수와 무관하다는 것이다. 표가 길어지는 대신 해시 그래프를 유지하는 비용을 진다. Automerge는 2025년 7월 3.0에서 온디스크 컬럼 압축 포맷을 메모리에도 적용해 메모리 사용량을 10배 넘게 줄였다고 공식 블로그에 밝혔다. 모비딕 전문을 붙인 문서가 2.x에서 700MB, 3.0에서 1.3MB였다는 수치를 함께 제시했다.
Loro는 두 표현을 모두 노출한다. Loro 공식 문서의 버전 심층 해설은 doc.version()이 버전 벡터를, doc.frontiers()가 DAG 기반 프론티어를 돌려준다고 설명한다. 프론티어는 특정 연산 직후의 문서 버전을 가리키는 데 유리하고 버전 벡터는 차이 계산에 유리하다. 같은 문서는 peerId가 겹치면 OpId가 겹쳐 문서가 깨질 수 있다고 경고하면서 Automerge는 이 문제를 해결했지만 동기화 복잡도가 높아졌다고 평했다.
상태 벡터는 ‘누가 몇 개 만들었나’라는 표 하나로 차이를 계산하는 가장 싼 방법이다. 그 값을 유지하려면 clientID가 끝없이 늘지 않게 하거나, 늘어나도 감당할 저장 구조를 갖춰야 한다. Automerge는 그 대가를 해시 그래프로 치른다. 어느 쪽이 싼지는 문서 하나를 몇 명이, 몇 달 동안, 몇 번 새로고침하며 열 것인가에 달렸다. 그 숫자를 먼저 세어본 팀이 얼마나 될까.
둘 다 노드별 정수 배열이지만 갱신 규칙과 용도가 다르다. 벡터 클록은 송수신을 포함한 모든 사건마다 카운터를 올려 인과관계를 추적한다. Yjs 상태 벡터는 클라이언트별로 다음에 기대하는 clock만 기록해 상대가 놓친 조각을 계산하는 데만 쓰며, Yjs README도 인과관계 추적에는 쓰지 않는다고 명시한다.
Doc.get_state()로 상태 벡터를 얻고, 상대가 보낸 상태 벡터를 Doc.get_update(state)에 넘기면 그 이후 변경만 담은 업데이트가 나온다. 받는 쪽은 apply_update로 적용한다. 실시간 전송은 doc.observe 콜백의 event.update 바이트를 보내면 된다.
Yjs는 세션마다 새 clientID를 뽑으므로 새로고침마다 상태 벡터 항목이 늘어난다. Yjs 이슈 415번은 과거 클라이언트가 수백에서 천 개로 쌓이면 트랜잭션마다 상태 벡터를 복사하는 비용이 커져 느려진다고 보고했다. Automerge는 clientID 수와 무관한 헤드 해시와 블룸 필터 방식을 쓴다.