[P2P 프로토콜] CRDT와 P2P 협업은 왜 같이 이야기될까
PonsLink 화이트보드에서 두 사람이 같은 도형을 고친다고 상상해 본다. 한 사람은 지하철 안에서 잠깐 오프라인이 되고, 다른 사람은 데스크톱에서 선을 지운다. 몇 분 뒤 두 브라우저가 다시 만나면 화면은 하나로 합쳐져야 한다. 여기서 어려운 질문은 “누가 이겼는가?”가 아니다. 서버가 모든 순서를 정하지 못할 때, 제품은 어떤 변경을 자동으로 합치고 어떤 변경을 설명해야 할까?

CRDT가 필요해진 지점
PonsWarp 같은 파일 전송 제품에서는 CRDT를 무조건 붙이는 것보다, 상태 충돌이 실제로 생기는 위치를 먼저 보는 편이 낫다. 파일 byte는 병합 대상이 아니고, 세션 메타데이터와 재시도 상태만 충돌을 다룰 수 있으면 충분한 장면이 많다. 데이터 종류별로 보존 기간도 다르게 잡아야 한다. presence나 cursor는 오래 들고 있을수록 현재 화면을 더 헷갈리게 만들 수 있다.
반대로 stroke나 문자열 operation은 늦게 들어온 replica를 복구하려면 일정 기간 남아야 한다. 같은 협업 기능 안에서도 버려야 하는 데이터와 끝까지 보존해야 하는 데이터가 갈라진다. 이 값들이 한 trace 안에 붙어야 “P2P가 느리다”는 문의를 실제 원인으로 풀 수 있다. merge가 멈췄는지, clock 때문에 기다렸는지, offline 상태에서 사용자가 다른 선택을 했는지 구분해야 한다. 이때 사용자에게 모든 내부 상태를 보여 줄 필요는 없다.
다만 사용자가 기다리는 이유와 다시 시도해도 되는 시점은 알려 줘야 한다. “동기화 중” 한 문장으로 모든 상태를 덮으면 merge가 늦은 것인지, offline 저장소가 복구 중인지, 권한 확인이 필요한 것인지 알 수 없다. 문구를 줄이더라도 상태는 줄이면 안 된다.

처음에는 lock으로 충분할 줄 알았다
서버가 맡을 수 있는 일은 여전히 있다. 모든 operation의 순서를 정하지 않더라도 초대 권한을 확인하고, snapshot 백업을 보관하고, abuse 상황을 막고, 늦게 들어온 replica가 기존 데이터와 맞는지 검증할 수 있다. 이 역할을 부끄러워할 필요는 없다. 완전 분산을 흉내 내느라 회복 경로를 잃는 편이 더 위험하다. PonsLink에 붙인다면 첫 적용 후보는 전체 문서 편집이 아니라 화이트보드의 작은 stroke나 presence에 가깝다.
작은 단위는 실패를 관찰하기 쉽고, 자동 merge가 어색할 때 되돌리는 비용도 낮다. 반대로 파일 삭제나 권한 변경처럼 사용자의 의도가 강한 행동은 CRDT의 수렴성만으로 처리하면 안 된다. 실험은 일부러 불친절한 네트워크에서 해야 한다. 한쪽 브라우저의 시간을 늦추고, 다른 쪽은 오프라인으로 만든 뒤, 같은 stroke를 삭제하고 수정한다. 그다음 늦은 replica를 합류시켜 화면이 어떻게 흔들리는지 본다.
여기서 중요한 것은 최종 상태가 맞았는지만이 아니다. 사용자가 중간 과정에서 기능을 믿을 수 있었는지도 같이 봐야 한다. 구현 전에 아래 질문을 통과하지 못하면 CRDT 라이브러리 선택을 미루는 편이 낫다. 아직 모르는 것이 많아서가 아니라, 실패 이름이 제품 안에 들어오지 않았기 때문이다. 운영자가 읽을 로그와 사용자가 읽을 문구는 같은 사건을 다른 언어로 말해야 한다.
내부 로그에는 merge_conflict_resolved, late_replica_joined, snapshot_restored 같은 이름이 남고, 화면에는 “오프라인 변경을 맞췄습니다”처럼 다음 행동을 고를 수 있는 문장이 남는다. 두 기록이 연결되지 않으면 장애 회고가 다시 추상적인 네트워크 탓으로 흐른다.

충돌이 선명해진 순간
제품 문구도 내부 상태와 같이 가야 한다. “오프라인 변경을 합치는 중”, “다른 사용자의 수정과 맞추는 중”, “일부 변경은 확인이 필요함” 같은 표현은 CRDT라는 이름을 감추려는 문장이 아니다. 내부의 merge 상태를 사용자가 고를 수 있는 행동으로 바꾸는 일이다. 이 표를 채우면 CRDT를 쓰더라도 서버가 완전히 사라지지 않는 이유가 보인다. identity, 권한, snapshot 백업, abuse 대응은 여전히 남는다.
CRDT는 변경을 수렴시키는 규칙이고, 제품 보안과 사용자 납득까지 대신하지는 않는다. 충돌이 사라졌다는 말보다, 사용자가 납득하지 못하는 merge 결과가 어디서 생겼는지 보는 지도. 나는 CRDT를 “충돌 없음”으로 설명하지 않기로 했다. 더 정확한 문장은 “충돌을 다룰 규칙을 미리 정한다”에 가깝다. 이 차이가 작아 보이지만 제품 문구에서는 크다.
전자는 실패가 나오면 약속을 어긴 말이 되고, 후자는 어떤 규칙이 적용됐는지 설명할 수 있는 출발점이 된다. CRDT 실험을 happy path로 시작하면 놓치는 것이 많다. 먼저 같은 객체를 동시에 고친다. 오프라인 replica를 늦게 합류시킨다. 이미 삭제한 stroke에 추가 수정이 붙는 장면을 만든다. 자동 merge가 성공해도 사용자가 보기에 이상하면 실패로 적는다. 협업 기능은 알고리즘의 수렴성과 사용자의 납득 사이에서 자주 갈라진다.
마지막으로 CRDT 적용 범위를 기능 플래그와 같이 설계해야 한다. 특정 방이나 특정 데이터 타입에만 CRDT merge를 켰다가 문제가 생기면 그 범위만 끌 수 있어야 한다. operation 경로를 탄 사용자를 모르면 점진 배포도 롤백도 흐려진다. 협업 알고리즘 선택은 배포 전략과 떨어져 있지 않다. P2P를 설명할 때 서버/클라이언트/사용자 중 누구 문장으로 말하는지 먼저 고르면 논쟁이 줄었다.
협업 경계 나누기
상태 이름도 그에 맞춰 쪼개야 한다. 예를 들어 pending-operation, offered-merge, verified-clock, relayed-offline, stored-replica처럼 나누면 사용자가 보는 “동기화 중” 한 문장 뒤에 실제 원인이 붙는다. operation 준비 실패인지, merge 판단 실패인지, clock 상태 불일치인지, offline replica의 늦은 합류인지 구분할 수 있다. PonsLink 화이트보드라면 모든 협업 데이터를 같은 CRDT 규칙으로 묶지 않는 편이 낫다. presence는 가볍게, cursor는 최신 상태 중심으로, stroke는 삭제와 수정의 causal order를 보며, 결제나 권한 같은 행동은 자동 merge 바깥에 둔다.
작은 제품일수록 “무엇을 자동으로 합칠지”보다 “무엇을 자동으로 합치지 않을지”를 먼저 정해야 한다. CRDT 협업을 API 이름으로만 보면 구현은 빨라 보인다. 하지만 운영에서 필요한 질문은 더 작다. operation이 브라우저 메모리에만 있는가, 로컬 저장소에 남는가, sync log로 서버나 edge에 잠깐 보이는가, snapshot으로 다시 조립할 수 있는가. 이 구분이 없으면 이상하게 합쳐진 문장을 사용자가 버그로 신고했을 때 할 수 있는 일이 줄어든다.
네트워크 탓인지, 병합 규칙 탓인지, 저장소 복구 탓인지 모른 채 문구만 바꾸게 된다. 반대로 operation과 replica를 분리해 두면 롤백, 점진 배포, 기능 플래그도 좁게 다룰 수 있다.
구현과 운영에 남긴 조건
*협업 기능을 넣기 전에 operation, merge, replica 책임을 다시 확인하는 점검표. 체크리스트를 통과한 뒤에도 남는 질문이 있다. snapshot이 너무 커지면 초기 동기화가 느려지고, operation log를 오래 남기면 저장소와 개인정보 부담이 생긴다. 반대로 너무 빨리 버리면 늦은 replica를 설명하지 못한다. CRDT 협업은 결국 수렴성과 보존 기간, 사용자 설명 사이의 균형을 고르는 일이다. 한 가지 더 남는 것은 협업 대상의 크기다.
작은 stroke는 operation 단위가 잘게 나뉘지만, 긴 문서나 큰 캔버스는 snapshot 비용이 금방 커진다. 기능을 크게 열기 전에 어떤 데이터가 작게 되돌릴 수 있는지 먼저 골라야 한다. 이 기준으로 보면 CRDT는 협업 기능의 끝이 아니라 시작점이다. 라이브러리를 고른 뒤에도 merge 규칙, snapshot 주기, offline 보존 기간, abuse 대응, 사용자 문구를 따로 정해야 한다. 특히 P2P에서는 상대 peer가 항상 선의로 같은 규칙을 따를 것이라고 가정하면 안 된다.
operation 검증과 권한 경계는 다음 보안 노트와 이어진다. 동시 편집에서 먼저 흔들리는 것은 데이터 자체보다 순서다. 같은 문장을 두 사람이 고치거나, 이미 삭제된 drawing stroke에 늦은 수정이 붙거나, cursor와 presence가 서로 다른 시간에 도착하면 화면은 연결 성공만으로 설명되지 않는다. '[P2P 프로토콜 CRDT와 P2P 협업은 왜 같이 이야기될까'을 관통하는 질문은 이 섹션에서도 같았고, 답만 더 구체적이 됐다.
아직 열어 둔 비용
operation log를 저장한다는 말도 단순히 배열을 남긴다는 뜻이 아니다. 사용자가 탭을 닫고, 브라우저 저장소가 비워지고, 오래된 replica가 다시 접속하는 순서까지 생각해야 한다. 같은 operation set을 받으면 같은 상태로 수렴해야 한다는 조건은 멋진 문장이지만, 제품에서는 그 operation set을 어디까지 보존할지가 먼저 문제가 된다. 이전 노트인 2026-07-04-p2p-protocol-03-webtransport-quic-vs-webrtc가 연결 경로의 책임을 물었다면, 여기서는 operation과 merge의 책임을 좁혀 본다. P2P 협업에서 핵심은 “서버를 얼마나 덜 쓰는가”보다 “동시 편집의 빚을 어디에 기록하는가”에 가깝다. - 각 replica가 같은 operation set을 받으면 같은 상태로 수렴하는가? - 문자열 편집, presence, cursor, drawing stroke의 merge 규칙을 따로 적었는가? - 서버가 없어도 남는 identity, 권한, snapshot 백업, abuse 대응을 누가 맡는가? - 사용자가 “왜 내 편집이 이렇게 보이는지” 이해할 문구가 있는가? - operation log, local storage, sync log, snapshot 중 무엇을 보존하고 무엇을 버릴지 정했는가? - 실패 뒤 회복 경로가 재시도, 사용자 확인, 운영 정책 수정 중 어디로 가는가? | 대상 | 자동 merge에 가까운가 | 별도 판단이 필요한가 | 제품에서 보일 문장 | |---|---|---|---| | presence | 대체로 가볍게 합칠 수 있다 | 오래된 상태 정리 | “상대가 잠시 끊겼습니다” | | cursor | 위치 갱신은 빠르게 버려도 된다 | 늦은 cursor가 현재 편집을 가리면 혼란 | “최근 위치를 다시 맞추는 중” | | drawing stroke | operation 순서와 삭제 관계가 중요하다 | 삭제 뒤 수정, 중복 stroke | “일부 선을 다시 확인했습니다” | | 문자열 편집 | CRDT 종류와 operation 모델에 의존한다 | 사용자가 납득하지 못하는 문장 병합 | “오프라인 수정과 맞추는 중” | | 권한·파일 삭제 | 자동 merge 대상으로 두기 어렵다 | 사용자 확인이나 서버 판단 | “확인이 필요한 변경입니다” | - operation 수와 operation 전송 시간
- merge 지연과 재시도 횟수
- replica 간 divergence가 지속된 시간
- snapshot 크기와 복구 성공 여부
- 사용자가 되돌린 편집 수
- clock 전환이나 offline 검증 실패 시각
CRDT를 제품에 붙일 때 마음이 흔들리는 이유도 여기 있다. 수학적으로는 수렴하지만, 사용자가 본 화면은 과정의 일부다. 방금 그린 선이 잠깐 사라졌다가 돌아오는지, 다른 사람이 지운 객체가 다시 나타나는지, 오프라인 상태에서 입력한 문장이 어느 위치로 들어가는지에 따라 신뢰가 갈린다. 그래서 이 글의 중심은 알고리즘 이름이 아니라 사용자가 보는 회복 장면이다.
마치며
operation, replica, snapshot이 서로 다른 시간축에서 만나는 흐름을 보기 위한 노트. p2p-proto 또 하나는 clock이다. wall-clock 시각만 믿으면 네트워크가 끊긴 동안 생긴 편집 순서를 잘못 해석할 수 있다. Lamport clock이나 vector clock 같은 구현 세부를 글에서 깊게 다루지는 않더라도, “나중에 도착한 변경”과 “나중에 일어난 변경”이 항상 같지 않다는 감각은 UI와 로그에 남겨야 한다. P2P 협업에서는 상대 peer가 떠나는 순간도 정상 흐름으로 봐야 한다.
떠난 peer가 가진 operation이 나중에 돌아올 수 있고, 돌아오지 않는다면 현재 replica만으로 snapshot을 만들어야 한다. 사용자가 탭을 닫는 행동은 예외가 아니라 브라우저 제품의 기본 조건이다. 이 조건을 테스트하지 않으면 데모에서는 깔끔한 기능이 실제 사용에서는 자주 멈춘다. CRDT는 이 장면에서 마법처럼 충돌을 없애 주는 이름이 아니다. 각 replica가 operation을 만들고, causal order를 최대한 보존하고, 같은 operation set을 받으면 같은 상태로 수렴하도록 merge 규칙을 데이터 구조에 넣는 방식이다.
서버가 모든 순서를 붙잡지 않는 대신, 제품이 수렴 규칙과 예외 문구를 더 분명하게 가져야 한다. 같은 기능을 더하는 요청이 들어와도 먼저 확인할 것은 책임 경계와 재시도 한도였다. 이 문단은 2026-07-04-p2p-protocol-04-crdt-p2p-collaboration 전용 기록이며, 다른 글의 문장을 복사하지 않는다. 배포 노트에는 기능 목록 대신 깨진 가정과 고정한 규칙을 남기는 편이 나았다.