Automerge로 멀티플레이어 팟캐스트 에디터 만들기
(adamsolove.com)- 몇년 전만 해도 실시간 멀티플레이어 데이터 동기화는 전문 인력과 기업 수준의 투자가 필요한 가장 어려운 문제였으나, 이제는
npm install한 번으로 취미 프로젝트에도 멀티플레이어 UI 구현 가능 - Automerge는 로컬 우선·멀티플레이어 안전·버전 관리를 갖춘 데이터 모델 구축 도구로, React의
useState패턴과 유사한 방식으로 데이터 영속화·이력 관리·협업자 브로드캐스트·충돌 해소를 UI가 신경 쓰지 않아도 자동 처리 - 브라우저 기반 멀티플레이어 오디오 에디터 Ducking 사례에서, 데이터 모델을 CRDT 연산에 자연스럽게 매핑되도록 설계하는 것이 핵심
- 리스트 재정렬처럼 Automerge가 보장하지 않는 경우에는 애플리케이션 계층 코드로 더 강한 불변식 직접 구현
- 과거 산업용 수준의 마법이었던 실시간 협업 편집이 이제 소수를 위한 작은 앱에도 자유롭게 적용 가능해진 점이 핵심 의미
배경 — Ducking 프로젝트
- 지난 몇 달간 파트너의 팟캐스트를 위해 브라우저 기반 멀티플레이어 오디오 에디터 Ducking 제작
- 오디오 편집이 20년 된 단일 사용자 데스크톱 앱과 파일을 주고받는 방식에 머물러 있는 현실이 이상했음
- 한 명이 클립을 편집하는 동안 다른 한 명은 트랜스크립트를 고치거나 EQ 설정을 조정하는, Google Docs나 Figma 같은 협업 워크플로우 필요
- 댓글, 이력, 변경 추적 같은 현대적 협업 도구도 함께 요구됨
- 이전 글에서 다룬 독특한 UI 디자인과 오디오 레이아웃 모델은 단일 편집기를 더 효과적으로 만들었으나, 진짜 원했던 것은 더 협업적인 워크플로우
Automerge 작업 방식
- 오디오 blob을 제외한 Ducking의 모든 데이터는 Automerge 문서에 저장
- 핵심 패턴은 React 개발자에게 익숙한 형태로, 훅으로 데이터를 가져와 렌더링하고 비동기 변경 요청을 디스패치하면 데이터 변경 후 훅이 리렌더 트리거
useDocument훅 사용 예시:const [doc, changeDoc] = useDocument<Episode>(docUrl)형태로 문서를 받아 입력값 변경 시changeDoc((d) => { d.title = e.target.value })로 갱신
- 데이터 갱신 연산은 명령형처럼 보이지만 네이티브 JS 객체·배열과 다름
- 메서드가 더 적고 즉시 변형(mutate)하지 않으며, 자체적으로 변형을 가로채 문서 이력의 changelist 항목으로 전환
- Automerge는 단순 용도에서는 필요한 것을 처리하지만 마법은 아니며, 불변식이 원하는 의미와 항상 일치하지는 않아 신중한 데이터 모델 설계 중요
- 대부분의 의미 단위 사용자 동작이 Automerge가 제공하는 단일 연산에 대응되도록 함
- 관련 데이터에 대한 별개의 사용자 동작이 해당 Automerge 연산의 불변식 관점에서 자연스럽게 해소되도록 함
- 저장되는 정규 데이터(canonical data)와 계산으로 도출되는 파생 데이터(derived data)를 명확히 분리
멀티플레이어를 위한 데이터 모델링
- Ducking의 데이터 모델에서 clip(클립) 은 변경 불가능한 기저 오디오 소스의 일부를 재생하는 창(window)으로, 재생 구간·효과 적용·타임라인 공간 점유 담당
- 가장 흔한 효과는 클립이 기저 오디오의 볼륨을 시간에 따라 조절해 크로스페이드하거나 잡음을 제거하는 것
- 초기에는 각 클립이 클립 시작 기준의 시간 인덱스 볼륨 레벨 목록을 가졌으나, 대부분의 볼륨 변경은 클립이 아닌 기저 오디오에 관한 것이라 문제 발생
- 클립 시작 시간을 조금 앞당기면 모든 볼륨 변경이 오디오의 다른 부분에 적용되는 현상
- 클립 시작 시간이 바뀔 때 모든 볼륨 타임스탬프를 갱신하는 코드를 작성하는 방식은 나쁜 선택
- 두 협업자가 동시에 클립 시작 시간을 편집하면, 각 편집이 시작 시간과 모든 볼륨 자동화 타임스탬프 변경을 함께 묶음
- Automerge는 그 변경들 사이의 인과 관계(causal relationship) 를 알 수 없어 병합 시 뒤죽박죽으로 해소될 수 있음
- 하나의 의미 단위 동작이 CRDT가 이해하지 못하는 인과적 방식으로 여러 영속 데이터를 갱신하려 할 때 발생하는 전형적 문제
- 해결책은 오디오 효과 데이터를 클립이 아닌 기저 오디오의 타임프레임 기준으로 이전(migrate)하는 것
- 클립 시작·길이 변경 시 갱신이 불필요해져, 여러 편집자가 시작 시간·볼륨 자동화·기타 효과를 바꿔도 서로 독립적이라 올바르게 병합될 가능성 높음
- 단일 사용자 UI와 멀티플레이어 UI의 차이
- 단일 사용자 UI에서는 기존 데이터 모델을 두고 쓰기 시점에 추가 계산을 더해 동작시키기도 함
- 멀티플레이어 UI에서는 모든 영속 데이터를 직교(orthogonal) 상태로 유지하기 위해 데이터 모델 이전이 훨씬 흔함
- 쓰기 시점 단순화와 읽기 시점 계산을 강하게 선호하게 됨으로써 Automerge의 자동 병합 활용 극대화
- 데이터 형태 이전(migration)에 대한 조언
- 빌드 과정에서 데이터 형태를 이전해야 함을 받아들이고, 첫 큰 이전을 두려워하지 않도록 초반에 시간을 들여 미리 연습
- 클라이언트의 읽기 시점 처리, 서버의 일괄 업그레이드 등 다양한 패턴 존재
- 이전 전후가 동일한지 확인할 편리한 불변식을 찾으면 작업이 훨씬 수월
- Ducking에서는 이전 전후 모든 프로젝트의 오디오를 내보내 오디오 지문(audio fingerprint) 으로 변경 여부를 확인, 큰 스키마 변경도 두렵지 않게 배포
리스트 재정렬 구현
- 때로는 Automerge가 제공하지 않는 보장을 위해 애플리케이션 계층 코드로 더 강한 불변식 직접 작성 필요
- Ducking의 magnetic timeline(재생할 클립의 정렬된 목록) 구현 시 문제 발생
- Automerge는 인덱스로 항목을 삭제·삽입하는 배열 연산은 제공하나, 기존 항목을 원자적으로 재정렬(atomic re-order) 하는 연산은 미제공
- 알려진 해법 존재
- Martin Kleppmann이 원자적 리스트 재정렬 연산에 관한 논문 발표
- Liangrun Da와 함께 "Extending JSON CRDTs with Move Operations" 논문도 발표
- Automerge에 추가하는 draft PR도 있으나 아직 병합되지 않음
- 단순한 재정렬 방식의 문제
- 현재 인덱스에서 객체를 삭제하고 목적지 인덱스에 다시 추가하는 방식
- 이 두 연산의 불변식을 결합해도 "동시 재정렬이 많을 때 객체가 목록에 정확히 한 번만 존재한다"는 원하는 불변식이 보장되지 않음
- 동시 삭제·추가가 여러 개면 객체가 목록 여러 위치에 존재할 수 있음 (Alice와 Bob이 각각 B를 delete+insert로 이동하면 두 삭제는 하나의 tombstone으로 합쳐지나 두 삽입은 각기 새 요소를 만들어 둘 다 살아남아 B가 두 번 등장)
- "정확히 한 번" 불변식을 애플리케이션 계층에서 제공하는 직접 구현
- 클립이 타임라인에 삽입될 때 semantic id 부여
- 재정렬 시 위와 같이 삭제·삽입 연산 트리거
- 읽기 시점에 애플리케이션이 동일 semantic id의 중복을 스캔해, 삭제되지 않은 첫 항목을 임의로 선택하고 나머지는 무시
- 이로써 객체가 목록에 한 번만 존재하고 여러 독자가 항상 동일한 최종 상태에 도달
- 리스트 재정렬은 Ducking에서 Automerge가 제공하지 않은 유일한 연산으로, PR 병합 시 애플리케이션 레벨 로직 불필요해질 전망
문서 이력 (Document history)
- 좋은 멀티플레이어 UI는 이력 관리 도구 필요로, 협업자는 떠난 사이의 변경 확인·diff 댓글·구버전 비교 및 롤백 원함
- Automerge는 문서 버전 이력을 추적하고 이력·비교를 다루는 훌륭한 기본 요소(primitive) 제공
- 단, 그 정보를 어떻게 노출하고 어떤 개념을 사용자에게 제시할지는 애플리케이션 개발자가 결정
- Ink & Switch의 Patchwork lab notes 권장
- 사용자에게 브랜치를 노출하는 작업과 universal comments 작업이 특히 흥미로움
- Ducking이 정착한 비교적 단순한 협업·이력 모델
- 사용자 정의 이름의 checkpoint가 있는 선형 버전 이력으로, checkpoint가 변경의 그룹화 단위이자 논의·diff·롤백의 단위
- 오디오의 특정 지점, 트랜스크립트의 영역, 버전 checkpoint에 연결 가능한 댓글 스레드(comment thread)
- 아직 브랜치 도입의 충분한 이유는 없었으나 향후 유용할 가능성 언급
텍스트와 marks
- 리치 텍스트 작업은 편집 가능한 텍스트 위에 커스텀 로직을 얹으려 할 때 특히 까다로운 문제
- 리치 텍스트와 멀티플레이어 소프트웨어 전반의 난점을 설명하는 Peritext 논문 권장
- Automerge의 리치 텍스트 스키마는 marks(텍스트 범위에 적용되며 텍스트 편집 중에도 일관성 유지되는 주석) 포함
- 가장 흔히 굵게·기울임 같은 서식에 사용되나, 애플리케이션 고유의 커스텀 mark 생성도 가능
- Ducking의 커스텀 mark 활용 두 가지
- 댓글 스레드의 대상이 된 트랜스크립트 영역 추적
- 트랜스크립트 단어의 타임스탬프 추적, 편집은 그대로 허용
- 트랜스크립션 서비스가 각 단어에 타이밍 정보 mark를 단 richtext 객체로 트랜스크립트를 Automerge에 저장
- 작은 오타로 단어 하나만 고치면 mark가 유지돼 모든 타이밍 정보 보존
- 문장 전체를 고치면 중간 mark 일부는 제거되나 문장 시작·끝의 mark는 유지돼 최소한의 대략적 타이밍 정보 확보
- marks의 한 가지 제약은 datum이 단순 값(일반적으로 문자열)이어야 하고 멀티플레이어 병합이 되지 않는다는 점
- 트랜스크립트 타이밍 정보처럼 작고 불변인 데이터는 JSON을 문자열로 직렬화
- 댓글 스레드처럼 더 복잡하거나 가변적인 데이터는 mark에 id만 저장하고 실제 데이터는 문서 내 다른 곳에 보관
- marks는 멀티플레이어 리치 텍스트 위에 애플리케이션 기능을 쌓는 훌륭한 토대 제공
다음 글 — 시리즈 구성
- 본 글은 Ducking 제작에 관한 3부작 중 2부
- 1부: 소프트웨어의 독특한 UI 디자인 설명
- 2부(본 글): Automerge 검토를 권하고 취미용 멀티플레이어 프로젝트 구축 가능성 제시
- 최종 3부 예정: Ducking 제작 경험 회고
- 최종 3부 관련 언급
- LLM 지원을 작업 강화가 아닌 더 많은 스케치·해먹 시간 확보 목적으로 사용
- 소수만을 만족시키면 되는 narrowcast 소프트웨어 제작의 즐거움
예상 질문
오디오 데이터는?
- 모든 멀티플레이어 데이터는 Automerge에 저장되나, 기저 오디오 blob은 빠른 재생을 위해 Automerge에 두지 않고 별도 처리 필요
- 목표는 새 협업자가 페이지 로드 후 4초 이내 청취·편집 시작으로, 데스크톱 앱 실행보다 빠르고 전체 프로젝트 파일 다운로드보다 훨씬 빠름
- 1시간 분량 에피소드는 고품질 스튜디오 녹음 4시간에 효과·배경음악을 더해 약 1기가바이트 오디오에 의존할 수 있음
- 빠른 콜드 스타트를 위한 업로드 시 오디오 서비스 작업
- 원본 오디오 백업
- 음성을 트랜스크립트 뷰용으로 전사(transcribe)
- 타임라인 뷰용 파형(waveform) 생성
- 40분 녹음 중 1분만 사용하면 대부분의 클라이언트가 한두 개 작은 조각만 받도록 짧은 창(window)으로 분할(slice)
- 조각을 압축 포맷으로 트랜스코딩해, 고품질 오디오가 백그라운드에서 다운로드되는 중에도 즉시 재생 가능한 lossy 버전 제공
- UI 데이터 계층은 사용자 의도를 따라 즉시 필요한 데이터의 빠른 버전과 실제 사용된 전체 오디오의 고품질 버전 로딩 관리
- 브라우저의 IndexedDB API가 다단계 캐싱과 content-addressable 저장에 유용하며, 자동 eviction 관리로 사용하면 남고 안 쓰면 사라짐
- 이 모든 처리와 로컬 캐싱이 끝나면 나머지 UI는 오디오에 대한 빠른 무작위 접근을 가정하고 편집 워크플로우에 집중 가능
로컬 우선 앱이 아닌 서버+브라우저 UI를 만든 이유
- 서버 없이 완전히 동작하는 Obsidian 같은 local-first 앱 선호, 특히 신뢰할 만한 탈출 경로를 제공하면서 클라우드 기반 유료 경험을 함께 갖춘 형태 선호
- 초기에는 로컬 파일시스템 저장과 선택적 서버 동기화를 갖춘 Tauri 앱 옵션으로 시작
- 서버나 로컬 앱 어느 쪽이든 공급 가능한 데이터 인터페이스 기준으로 UI 구축
- 향후 어떤 자금도 lock-in으로 앱을 더 수익화하도록 유혹하지 못하게 하는 안전장치
- 이후 이것이 SaaS가 아니라 파트너 및 소수 친구와 쓰고 싶은 것이라 판단
- 잘못 다룰 유인이 사라지고 영구 운영 비용이 낮아져 가장 쉬운 방식으로 제작 결정
- 약 3초 콜드 스타트를 달성하자 누구도 네이티브 앱 다운로드·설치에 시간 낭비하길 원치 않게 됨
- 오디오 앱이 현재의 데스크톱 전용 세계에서 동기화 옵션을 갖춘 local-first 세계로 곧장 건너뛰어, 중간의 10~20년 SaaS lock-in을 피할 수 있기를 희망
Automerge는 안전하고 web-scale인가? 스타트업에 써야 하나?
- 모른다고 즐겁게 답할수 있음, 이는 거부가 아니라 말 그대로 알 수 없다는 의미
- 입사 당시 충돌 없는 실시간 멀티플레이어 편집은 마법이었고, 10년 전에는 특정 문제의 알려진 해법이 있었으나 자금 지원 팀과 여러 분야 전문성 필요
- 오늘날에는 의존성 하나를 받아 대체로 직관적으로 UI를 만들고 친구들과 실시간 협업 가능
- 보안 측면에서 현재 Ducking은 제한된 네트워크 접근과 Automerge 서버 websocket 연결 생성 시 인가(authorization) 단계로 보호
- 사용자는 초대받지 않은 프로젝트를 발견하거나 편집할 수 없음
- 편집·댓글의 사용자 귀속은 일부만 안전하며 친구들이 못된 짓을 안 한다는 전제에 의존
- comment만 가능/edit 불가, 프로젝트 일부만 편집, 발견 가능성 제어 같은 세분화된 권한은 신중한 설계 작업 필요
- Ink & Switch가 개발 중인 Keyhive는 암호학적으로 안전한 capability 기반 접근 제어 모델 제공
- 신뢰할 수 없는 사용자에게도 Automerge 앱을 공개 공유하기 쉽게 하나 아직 준비되지 않음
Automerge가 더 나은가?
- 이 분야의 다른 해법으로 Yjs가 존재하나, 무엇이 적합한지는 대신 평가해 줄 수 없음
- 변치 않는 조언
- 문제를 깊이 고민하고, 마주칠 한계에 대해 대략적 계산(back-of-the-napkin)을 해 보며, 여러 대안으로 프로토타입을 만들어 보고, 어쩌면 자신의 문제가 그리 어렵지 않아 최신·최고급 해법이 필요 없을 수도 있음을 정직하게 인정
- Ducking의 경우 빠른 프로토타입과 문서 탐색으로 Automerge가 해당 용도에 충분히 성숙하고 성능 좋음 확인
- 더 중요하게는 Ink & Switch 생태계가 미학적으로 끌림
- Automerge가 단순 동기화·버전 관리 엔진이 아니라 소프트웨어를 더 안전·협업적·유연·즐겁고 개인적으로 만드는 더 큰 비전의 일부라는 점
- Keyhive 등의 성공을 바라며, 소수만을 위한 작지만 마법 같은 소프트웨어의 확산을 기대