티스토리 뷰

반응형

How Figma's multiplayer technology works를 번역한 글입니다.


 

Overview

  • 2015년, 멀티 플레이어 기능을 솔루션에 도입하고자 함.
  • 다른 디자인 툴들에서는 이 기능이 없었던 상황이고, 구글 독스에서 사용하는 멀티 플레이어 알고리즘의 표준격인 Operational Transforms (OT)를 사용하고 싶지는 않았음. 빠르게 피쳐를 전달해야하는 우리 시스템에 OT는 불필요하게 복잡했음. 그래서 더 심플하고 사용하기 쉽게 새로 만들고자 함.
  • 주변에서 멀티 플레이어 기능에 대한 부정적인 의견이 있었지만, 진행했음. 이 기능을 통해 export, sync, email copy 등의 작업을 제거할 것이고, 더 많은 사람들이 디자인 프로세스에 포함될 수 있을 것이라고 생각. 단순히 하나의 링크를 통해 디자인 프로젝트의 현재 상태를 알 수 있도록 구성하고자 했음.
  • 요즘에 들어서는 디자인 뿐만 아니라 모든 웹 생산성 툴에 Multiplayer 기능은 필수임인 것이 자명함. 하지만 그 Multiplayer 기능에 대한 사례 자료는 보기 어려움.
  • 그래서 우리가 Figma를 어떻게 구성했는지 공유하고자 함.

 

 

Background context: Figma’s setup, OTs, and more

  • Figma 클라이언트는 WebSocket을 이용하여 서버 클러스터와 소통하는 웹 페이지로 구성되어진다. 서버는 각 Multiplayer 문서마다 별도의 프로세스를 실행해서 처리하게 된다. 자세한 내용은 이 아티클을 참조 (Rust를 사용해 서버 구성하였음).
  • 문서를 열면, 클라이언트는 파일의 복사본을 다운로드하기 시작한다. 그 후부터는 WebSocket을 통해 양방향으로 계속 싱크를 맞춰감.
  • 오프라인으로 작업하다가 다시 온라인으로 돌아오면, 문서의 최신 복사본을 내려받고, 내려받은 최신의 상태 본에 오프라인 작업 부분을 적용한다. 그리고나서 WebSocket을 통해 싱크를 맞춰나감. 즉, 연결과 재연결 과정이 매우 간단함.
  • 우리는 Figma 문서에 대한 변화를 싱크하는데 오로지 multiplayer만을 사용하는데 이것에 주목할 만한 가치가 있음. 우리는 또한 코멘트, 유저, 팀, 프로젝트 등 같은 다른 데이들도 다루는데 이 데이터들은 Multiplayer system가 아닌 Postgres에 저장. 이는 완전히 다른 시스템을 통해서 싱크가 맞춰짐 (이 문서에서는 이를 다루지 않는다).
    • 두 시스템이 비슷해보이지만, 성능/오프라인 사용가능성/보안 같은 특정 특징들에 대한 다른 트레이드 오프를 가졌기에 다른 구현체로 만들어서 사용했음.
  • 처음부터 이렇게 접근했던 것은 아님. 이 접근으로 확고히 하기전에 빠르게 여러 실험적인 과정을 반복해가는 것이 중요했음. 이것이 우리가 처음부터 바로 리얼 제품을 생성해나가려고 일하는 것이 아닌 여러 아이디어를 테스트 하기 위한 프로토타입 환경을 만들어가는 이유임.
    • 프로토타입으로 서버에 연결하는 3개의 클라이언트를 두고 시스템 상태를 시각화한 웹 페이지를 구성했음. 오프라인 클라이언트/대역폭 제한 등 다양한 시나리오를 테스트 할 수 있었음.
     

 

 

How OTs and CRDTs informed our multiplayer approach

  • Multiplayer 기술은 풍부한 역사가 있으며, 적어도 1968년 Douglas Engelbart's demo 이후부터 존재해왔음.
  • OT(Operational Transforms)는 구글 독스와 같은 대부분의 텍스트 기반 협업 툴에 사용되어져 왔음.
  • 제일 잘 알려진 기술이었지만, 조사해보니 우리의 목표를 이루기엔 과도하다는 것을 빠르게 깨달을 수 있었음. 적은 메모리/성능 오버헤드로 긴 텍스트 기반의 문서 수정 프로세스를 다룰 수 있는 아주 좋은 방법이지만, 매우 복잡했고 올바르게 구현하기 너무 어려웠음. 이걸 사용하면 추론하기 매우 어려운 상태의 combinatorial explosion(조합 폭발)을 초래했음.
    • combinatorial explosion(조합 폭발)은 조합 횟수에 따라 시간 복잡도가 폭발적으로 증가하는 현상을 의미함.
  • Mulitplayer 시스템을 디자인할 때 우리의 주요 목표는 복잡성을 줄이는 것이었음. 심플한 시스템일수록 구현(implement), 디버그(debug), 테스트(test), 유지보수(maintain) 하기 쉬움. Figma는 텍스트 기반 에디터도 아니였기에 OT의 장점이 필요 없었음.
  • 대신 Figma의 기술은 Conflict-free Replicated Data Types을 의미하는 CRDTs로 부터 영감을 받았음. CRDTs는 분산 시스템에서 사용되는 다양한 데이터 구조들의 모음을 의미함. CRDTs에는 많은 타입이 존재함 (Overview는 이 리스트를 참조).
    • 예를 들어보면, Last-writer-wins register가 있음. 하나의 값을 위한 컨테이너로 값/타임스탬프/Peer ID로 업데이트 될 수 있음. 최신 업데이트 값을 가져와 레지스터 값을 확인할 수 있음.
    • 이름 그대로 이해하면 됨. 마지막에 작성한 사람이 우승한다 → 마지막에 작성한 값만을 사용한다.
  • 모든 CRDTs는 Eventual Consistency를 보장함. 만약 업데이트가 더 이상 없다면, 결국엔 데이터 구조에 접근하는 모든 사람이 같은 것을 볼 수 있다는 것.
  • Figma는 진정한 CRDTs를 사용하고 있지는 않음. CRDTs는 단일 중앙 체제가 없이 최종 상태를 결정할 수 있는 분산 시스템에 맞게 디자인되었는데, 이건 성능 및 메모리 오버헤드를 발생시켰음. Figma는 중앙화 된 서버가 있기 떄문에, 앞선 오버헤드를 제거함(분산연산 같은?)으로써 단순하게 시스템을 구성하고 더 빠르고 간결한 구현하는 이득을 볼 수 있었음.
  • Figma는 데이터 구조로 단일 CRDT를 사용하는 것은 아님. 문서의 최종상태를 생성하기 위해 여러 개의 분리된 CRDTs를 조합하여 사용함.

 

 

How a Figma document is structured

  • 모든 Figma 문서는 HTML DOM 구조와 유사하게 오브젝트들의 트리 구조로 이루어져 있음.
    • 하나의 Root 오브젝트는 전체 문서를 표현함.
    • Root 오브젝트 아래 Page 오브젝트들이 있고, Page 오브젝트 아래 페이지 내 컨텐츠를 표현하는 오브젝트들의 계층이 있음.
    • 이 트리는 Figma 에디터의 왼쪽에 존재하는 레이어 판넬(Layer-panel)에 표현됨.
  • 각 오브젝트는 ID와 속성-값(Property-Value)의 컬렉션을 가짐. 코드로 생각해보면 Map<ObjectID, Map<Property, Value>> 로 표현할 수도 있고, DB로 생각해보면 (ObjectID, Property, Value) tuple을 저장하는 row들로 표현할 수 있음. 즉, Figma에 새로운 Feature을 추가하는 것은 오브젝트에 새로운 속성을 추가하는 것을 의미함.

 

 

The details of Figma’s multiplayer system

Syncing object properties

  • Figma의 Multiplayer 서버는 클라이언트가 보내는 특정 오브젝트에 대한 특정 속성의 최신 값을 계속 트랙킹함.
  • 이는 아래의 경우 충돌이 나지 않는다는 것을 의미함.
    • 두 명의 클라이언트가 같은 오브젝트에 대하여 다른 속성 값을 변경하는 경우.
    • 두 명의 클라이언트가 다른 오브젝트에 대하여 같은 속성 값을 변경하는 경우.
  • 한 가지의 경우. 두 명의 클라이언트가 같은 오브젝트에 대해 같은 속성 값을 변경하는 경우만 충돌이 발생함. 이런 경우, 문서는 서버에 전달된 마지막 값으로 설정될 것임.
    • 이 접근 방식은 CRDT의 last-writer-wins register 방식과 유시함. 서버가 이벤트 순서를 정의할 수 있기에 timestamp가 필요하지 않다는 차이가 있음 (서버에 들어온 순서대로 이벤트 순서를 정의할 수 있으니).
  • 위 내용을 통해 알 수 있는 중요한 것은 변화는 속성 값 바운더리 내에서 원자적이라는 것. 속성의 값은 결국 항상 클라이언트 중 한 명이 보낸 값으로 설정된다는 것임.
    • 텍스트 속성 값이 B인데, 누군가는 이를 AB로, 또 누군가는 BC로 변경하려고 한다고 치면, 그 텍스트 속성 값은 AB 또는 BC로 변경될 것. 근데 절대 ABC로 변경되지는 않음.
    • Figma는 텍스트 에디터가 아니고 디자인 도구이므로 속성 바운더리 내에 존재하는 값 중 하나로 설정되는 것은 괜찮음.
  • 변화가 충돌하는 경우를 다뤄야하는 것이 가장 복잡한 부분임. Figma는 가능한 반응성 있게 느끼도록 하기 위해서 클라이언트가 변화를 만들면 서버로부터 acknowledgement(승인) 없이 바로 그 즉시 적용함. 만약 어떤 변화를 만들었는데 서버로부터 들어오는 변화가 같은 것이여서 충돌이나면 일시적으로 기존 값들을 새로운 값들이 덮어쓰는 ‘flicker’ 현상이 발생함. 우리는 이러한 ‘flicker’ 현상을 피하길 원했음.
  • 그래서 사용자가 속성을 변경했지만 아직 서버로부터 ack(승인)이 오지 않은 상태에서 서버로부터 다른 변경 사항들이 들어온다면, 들어오는 변경사항들은 제거했음. Last-to-the-server 순서에 대해 사용자가 아는 것이 가장 최근 변화였기에 사용자가 변화시킨 것이 최고의 예측 값임.
    • (의역) 사용자가 아는 현재 상태가 서버로부터 받은 가장 최신의 상태이고, 거기다 변화를 준 상황. 다른 사용자가 먼저 변화(B)를 줬고 그 변경 사항(B)이 내가 변경한 사항(A)이 승인되기 이전에 왔다면, 내가 변경한 사항(A)이 서버에 반영되어 최신의 값이 됬을 것이기에 아예 다른 사용자가 변경한 사항(B)을 제거한 것.
    • 그냥 냅둬도 내가 변경한 사항(A)가 승인되면 다시 최신 값으로 되지 않아?라고 의문을 가질 수 있지만, Figma 팀은 Flicker를 제거하길 원했기에 위처럼 구현하여 그 문제를 해결한 것임.
  • https://cdn.sanity.io/files/599r6htc/localized/e8b3d7dd4745e9fec062586d91f3eba62c2052be.mp4

 

 

Syncing object creation and removal

  • Figma에서 오브젝트 생성은 CRDT의 last-writer-wins set과 가장 유사함. 즉, 오브젝트가 존재하는지 안하는지 확인하려면 해당 오브젝트에 대한 last-writer-wins boolean 속성 값만 확인하면 됨.
  • 하지만 완전히 같은 모델은 아님. Figma의 경우, 삭제된 객체의 속성을 서버에 저장하지 않음. 대신 데이터를 삭제한 클라이언트의 실행취소 버퍼(undo buffer)에 저장함 (로컬). 삭제를 실행 취소하려는 경우, 복원할 책임도 클라이언트에게 존재하는 것. 이렇게 함으로써 오래 보관하면서 편집하는 문서의 용량이 계속해서 커지는 것을 방지할 수 있음.
  • 설명한 시스템의 경우, 클라이언트가 고유성을 보장하는 새 오브젝트 ID를 생성할 수 있어야 함. 모든 클라이언트에게 고유한 클라이언트 ID를 할당하고, 클라이언트가 생성하는 오브젝트 ID에 클라이언트 ID를 포함시켜 만들면 앞선 목적을 달성할 수 있음. Figma는 오프라인에서 작업도 지원해야하므로 서버에서 ID를 생성하는 방식은 적합하지 않다고 판단했음.

 

 

Syncing trees of objects

  • Eventually-consistent 트리구조에서 오브젝트들을 배열(Arrange)하는 것은 Multiplayer 시스템에서 가장 복잡한 부분임. 특히, 오브젝트의 부모 변경 작업(오브젝트를 한 부모에서 다른 부모로 이동시키는 작업)을 어떻게 처리할 것인지가 복잡함.
  • 이 트리구조 설계 시에 2가지 주요 목표를 두었음.
    • 오브젝트의 부모 변경 작업은 해당 오브젝트의 관련 없는 속성 값 변경과 충돌하지 않아야 함. 예를 들어, A 사람이 객체의 부모를 변경하는 동안 B 사람이 객체의 색상을 바꾸고 있었다면 이 두 작업은 다 성공해야 함.
    • 동일한 오브젝트에 대해 두 사람이 동시(concurrent) 부모 변경작업을 한다고 해서 트리의 서로 다른 위치에 해당 오브젝트가 두 개 생성되어져서는 안됨.
  • 대부분 부모 변경 작업에 대해 접근할 때, 기존 오브젝트를 제거하고 새 ID를 다른 곳에 생성하는 것으로 생각함. 하지만 오브젝트의 ID가 변경되면 동시 편집이 중단되기에(앞선 첫 번째 목표) 적합하지 않았음.
  • 그래서 우리는 부모에 대한 링크를 자식 오브젝트의 속성으로 저장하여 부모-자식 관계를 표현했음. 이렇게 하면 부모 변경작업에도 오브젝트 ID가 유지됨. 또한, 만약 부모가 자식 오브젝트 링크를 저장하면 자식이 여러 부모를 가지게 되는 문제가 발생할 수도 있어 이런 것을 방지할 수 있었음. 앞선 구조에서는 자식이 무조건 한 부모만을 바라보게되므로.

https://cdn.sanity.io/files/599r6htc/localized/280f28f6e620d747f00ef024058310d07e151eff.mp4

  • 하지만 이렇게 하면 사이클 있는 트리구조를 만들 수 있는 문제가 발생함. 한 클라이언트가 A → B 구조로 만들었는데 다른 클라이언트가 B → A 구조로 동시편집한 경우. Figma의 Muliplayer 서버는 사이클을 만들 수 있는 구조인 경우 프로퍼티 업데이트를 거부함. 즉, 서버에서는 이러한 문제가 발생할 수 없음. 문서의 최종 형태는 서버에서 관리하므로 최종적으로는 클라이언트에게 앞선 사이클이 생길 수 있는 경우가 생기지 않을 것. 하지만 그 동시 편집 후 서버에게 최종 결과를 받아오기 전까지는 이러한 문제가 발생할 수 있음.
    • Figma의 해결책은 서버가 최종 결과를 내려주기까지(사이클이 생기는 변경을 거부하고 오브젝트를 기존 위치로 다시 돌이키는데까지) 일시적으로 사이클이 존재하는 구조로 지정하고 트리에서 제거하는 것이였음. 즉, 코드 상에선 서로 간 부모-자식 관계이지만 트리에서는 제거되어있는 형태. 이렇게 하면 잠시 오브젝트들이 사라지기 때문에 사용자로써 좋지 않을 수 있지만 매우 드문 현상이고 아주 일시적인 현상이기에 이렇게 간단하게 구현하기로 했음. (복잡한 작업을 시도할 필요성을 못느꼈다고 함)

https://cdn.sanity.io/files/599r6htc/localized/e7db3c4428336425aebca94ba6914b692b5e8a25.mp4

  • 트리를 구성하려면 부모에 대한 자식들의 순서를 결정하는 방법도 필요한데, Figma에서는 “Fractional Indexing”을 이용했음. 부모의 자식 배열에서 객체의 위치를 0과 1 사이의 분수로 표시함. 오브젝트의 자식 순서는 위치에 따라 정렬하여 결정됨. 새로운 오브젝트를 삽입하는 경우, 새로운 오브젝트의 위치 값을 다른 두 오브젝트 위치의 평균 값으로 설정.
  • 여기서 놓치면 안될 부분은 자식이 바라보는 부모 링크와 이 위치 값이 단일 프로퍼티로 저장되어야 한다는 것임(원자 단위로 업데이트 되어야함). 부모가 변경될 때 기존 위치 값을 사용하는 것은 의미가 없기에.
  • 이에 대한 자세한 내용은 이 아티클을 참조.

 

 

Implementing undo

  • Mulitplayer 환경에서 실행취소는 본질적으로 혼란스러움.
    • 다른 사람이 내가 편집한 것과 동일한 오브젝트를 편집했다가 실행취소를 했다고 하자. 어떻게 될까? 내가 편집한 내용이 그 사람이 취소한 작업 이전의 오브젝트 형태에 적용되어야 하나? 다시 실행하면 또 어떻게 되는가?

https://cdn.sanity.io/files/599r6htc/localized/190727fe7a910cf7753edaa289825dd0cda3c3a1.mp4

  • 실행을 취소하고 다시 실행하는 경우, 문서가 기존 그대로 있어야 한다 (변경되지 않아야 한다)는 원칙을 정함.
  • Single player 환경에서의 실행취소는 “내가 한 일을 되돌리기”를 의미하기에 주의하지 않으면 다른사람이 다음에 한 일을 덮어버릴 수 있음. 의역하자면, 내가 a 상태에 있는 오브젝트에 대해 A 작업을 했고 다른 사람이 B 작업을 추가적으로 한 상황에서 내가 실행 취소를 하고 다시 복구하면 다른 사람이 작업한 B 작업을 덮어버릴 수 있음.
  • 그렇기에 Figma에서는 실행 취소 작업은 취소 시점의 재실행 기록을 수정하고, 마찬가지로 재실행 작업은 재실행 시점의 취소 기록을 수정함.
    • 쉽게 말하면 취소작업을 수행하면 재실행 버퍼에 재실행 기록을 저장할 때, 취소한 작업을 그대로 기록하는 것이 아니라 현재 상황과 비교하여 다시 돌아가야 하는 값을 알맞게 저장하는 식으로 구현한 것 (영상 참조)

 

 

The big takeaways

  • CRDT에 대해 추상적으로 배우는 것과 실제 생산 시스템에서 어떻게 작동하는가를 알아보는 것은 완전히 다른 문제였음.
  • 주요 내용을 요약하면
    • 탈중앙화 시스템을 만들지 않더라도 CRDT은 관련성 있을 수 있음.
    • Figma 같은 비쥬얼 에디터를 위한 Multiplayer는 생각한 것보단 어렵진 않았음.
    • 초기에 시간을 들여 연구하고 여러 프로토타입을 만들어본 것이 큰 도움이 되었음.
320x100
반응형
댓글
반응형
250x250
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
글 보관함