Skip to content

멀티플레이(협업 기능)방식에 대한 고찰

Youngho Kim edited this page Nov 23, 2022 · 1 revision
  • 작성자: J112_양성훈

들어가는 글

우리 서비스가 협업 툴로써 기능하기 위해서는 여러 사람들이 공동으로 화이트보드를 편집할 수 있는 멀티플레이 기능이 필수이다. 이 멀티플레이 기능을 어떤 구조로 어떻게 구현하냐는 서비스의 퍼포먼스부터 사용자 경험에까지 영향을 미치기에 매우 중요하다. 이에 유사하지만 다르게 멀티플레이 기능을 구현하고 있는 FigjamExcalidraw를 비교한 후 우리 부끄럼에서는 어떻게 적용해야할지 고민해보는 시간을 가지고자 한다.

배경지식

FigjamExcalidraw

FigjamExcalidraw는 같은 온라인 협업 화이트보드 플랫폼이다. 둘다 거의 유사한 화이트보드 기능을 제공하며 이를 공유해서 편집할 수 있는 기능도 제공하고 있다. 내부 구조도 크게 보면 비슷하다 모두 socket을 기반으로 양방향 통신을 통해 동기화한다.

둘의 가장 큰 차이는 서버의 역할이다. FIgjam은 중앙 집중식이다. 즉 서버에서 클라이언트에게 보내는 메시지를 관리하고 처리하는 로직을 따로 가진다. 반면 Excalidraw는 서버가 메시지를 클라이언트에게 전달하는 역할만 할 뿐 중앙 집중식 조정은 하지 않는다. 이에 FIgjam은 서버/클라이언트 아키텍처로 Excalidraw는 P2P로 각각 자신을 소개하고 있다.

멀티 플레이와 동기화

멀티플레이 기능의 핵심은 동기화다. A와 B가 동일한 상태의 화면을 보고 있어야하며, 편집한 내용이 A와 B 둘 다에게 반영이 되어여한다.

위 포스팅에서는 우리 서비스의 핵심 기능인 화이트보드의 동기화 구현을 위해서 고려해야할 5가지 사항을 기준으로 두 서비스를 비교하고자 한다. 5가지 기준은 아래와 같다.

  • 동기화 원칙 및 편집 충돌: 동시에 편집이 일어나는 일은 멀티플레이에서 가장 흔하게 일어나는 상황이자, 극복해야할 사항이다. 여기서는 편집 충돌을 어떤 방식으로 해결하는지 설명한다. 또한 편집 충돌을 위해 동기화 원칙을 설정하게 되므로, 동기화 원칙에 대해서도 설명한다.
  • 객체 생성: 객체가 생성될 때 동기화를 어떻게 하는지 설명한다.
  • 객체 삭제: 객체가 삭제될 때 동기화를 어떻게 하는지 설명한다.
  • 실행 취소: 사용자가 Cmd + z로 실행 취소시 동기화를 어떻게 하는지 설명한다.

Figjam

동기화 원칙

figjam은 분산 시스템에 사용하는 데이터 구조 CRDTs(몰라도 된다)를 차용하여 동기화를 진행한다. 이중 가장 크게 영향을 준 것은 last-writer-wins 레지스터 방식인데 쉽게 말해서 타임스템프를 기준으로 후자의 타임스탬프를 선택하는 방법이다. 위에서 말했 듯 Figjam은 중앙 집중식이기 때문에 분산 시스템에서 사용하는 CRDT 순수하게 사용하는게 아니라 중앙 집중식으로 바꿔서 사용한다. 이렇게 하면 오버헤드를 줄일 수 있다고 한다. 예를들어 last-writer-wins도 서버에서 이벤트의 도착 순서를 알 수 있기 때문에 타임스템프를 만들 필요가 없다.

말이 길었는데 FIgjam의 동기화 원칙을 정리하면 “늦게 도착한 놈을 기준으로 동기화한다” 이다.

편집 충돌

속성을 수정하는 상황은 크게 3가지가 있다

  1. 두 클라이언트가 동일한 객체에서 관련 없는 속성을 변경하는 경우
  2. 두 클라이언트가 관련없는 객체에서 동일한 속성을 변경하는 경우
  3. 두 클라이언트가 동일한 객체에서 동일한 속성을 변경하는 경우

Figjam은 중앙집중식으로 서버에서 상태를 변경한 후 이를 클라이언트로 전달하는 방식이기 때문에 1, 2번의 경우 충돌이 일어나지 않는다.

충돌이 없을 때 동기화 과정

3번의 경우 충돌이 발생하는데 위 동기화 원칙으로 인해 마지막으로 서버로 전송된 값으로 상태가 변경된다.

이로인해 동시 텍스트 편집이 FIgjam에서 불가능한데 텍스트 B를 client1이 AB로 client2가 BC로 변경하는 경우 최종결과는 ABC가 아닌 AB 또는 BC이기 때문이다.

깜빡임 현상 문제

여기서 하나 또 생각해볼 점이 있다. 위와 같은 상황에서 BC가 먼저 도착하고 AB가 이후에 도착한다고 생각해보자

client1은 속성을 AB로 변경한 후 바로 화면에 반영될 것이다. 그런이 BC가 먼저 도착했기 때문에 AB → BC로 상태를 업데이트 한번 하고 다시 자신이 변경한 AB가 서버에서 반영되어서 BC→AB로 변경되는 과정을 거친다. 즉 AB → BC → AB 처림 속성이 바뀌는 깜빡임 같은 현상이 발생한다. 이런 현상은 사용자 경험을 크게 떨어트릴 수 있기에 자신이 보낸 변경 사항이 반영되기 전까지 해당 속성에 관련한 변경을 버리는 식으로 깜빡임 현상을 개선한다

깜빡임 현상 방지 과정

객체 생성 및 삭제

객체와 삭제는 객체에 고유한 ID를 부여함으로써 쉽게 구현이 가능하다.

객체는 각자마다 고유하기 때문에 충돌없이 생성 및 삭제가 가능하다. 생성은 추가하면되고 삭제는 서버에서 객체에 대한 모든 데이터를 삭제하면 된다.

Figjam은 삭제한 객체를 서버에서 따로 보관하지 않고 삭제한 유저의 실행 취소 버퍼에 기록하는 방식으로 관리한다. 시간이 지날 수록 메모리가 커지는 문제를 해결할 수 있고, 객체의 생성과 삭제의 책임을 모두 클라이언트에게 돌려 더 쉽게 관리할 수 있다.

객체에 고유한 ID를 부여하는게 중요해졌는데 고유한 클라이언트 ID + 객체 ID를 조합하여 고유한 객체 ID를 생성한다.

실행 취소

실행 취소의 딜레마: 내가 편집한 객체를 다른 사람이 편집한 다음 실행 취소를 하는 경우 어떻게 해야하는가?

실행 취소 기능은 특히나 “다시 실행” 기능이 있다고 하면 더 복잡해진다. 우리 서비스에서는 다시 실행은 고려하지 않으므로 실행 취소 기능만 고려하자

client1 A → B 상태로 수정, client2 B → C 상태로 수정, client1이 실행 취소를 했을 때 A 상태로 돌아간다. 실행 취소 버퍼에 C상태가 애초에 없기 때문

(참고: 다시 실행 시 B상태가 아닌 C 상태로 돌아가는데 다시 실행 버퍼에 A → B를 A → C로 수정하는 과정을 거친다)

Excalidraw

동기화 원칙

아무런 동기화 원칙 없이 싱글 플레이어 상태에서 수정한 상태가 다른 사람에게 전달된다고 가정해보자. 객체 ABC가 있는 상태에서 client1이 새 객체 D를 생성하고, client2가 객체 B를 수정하면 둘 중 하나는 편집 내용을 완전히 잃게 된다.

Excalidraw는 각 객체의 id를 기준으로 합집합을 만드는 병합 알고리즘으로 해결한다. 자기 환경에서 ids와 받은 ids의 합집합을 구성하여 동기화를 진행한다.

편집 충돌

속성을 수정하는 상황은 크게 3가지가 있다

  1. 두 클라이언트가 동일한 객체에서 관련 없는 속성을 변경하는 경우
  2. 두 클라이언트가 관련없는 객체에서 동일한 속성을 변경하는 경우
  3. 두 클라이언트가 동일한 객체에서 동일한 속성을 변경하는 경우

Excalidraw의 병합알고리즘의 경우 3개 다 해결을 못한다

합집합 알고리즘이 어떻게 되어 있는지 모르겠지만 각 객체에 버전 번호 필드를 추가해서 병합할 때 이전 버전을 버리고 최신 버전만 유지하는 방식으로 2번을 해결한다

.
편집 충돌 2번의 상황
.

1번과 3번의 경우 둘다 동일한 객체에서 수정이 일어난다는 것인데 Excalidraw는 이러한 동시 편집을 허용하지 않는다. 즉 하나의 편집만 반영되어 통일되면 된다는 마인드

업데이트하여 버전 번호를 증가할 때 랜덤한 versionNonce 속성을 추가하여 버전 번호는 같지만 내용이 다른 2가지 상태가 오는 경우 versionNonve가 더 낮은 쪽을 선택하여 동기화한다.

객체 추가 및 삭제

기존의 병합 알고리즘으로 객체 추가가 가능하다. 새로운 객체가 추가된 상태를 전송하면 반영된다.

삭제의 경우 기존 합집합 병합 알고리즘으로는 불가능하다. 따라서 객체에 isDeleted라는 필드를 두어서 런타임에서 필터링하는 방식으로 구현한다. 이러면 메모리가 늘어나는 문제가 발생하는데

추후 DB에 기록할 때 isDeleted가 true인 객체를 날리고 저장한다.

실행 취소

아직 해결하지 못한 영역, 실행취소 스택을 가지고 있으나 새 업데이트를 받을 떄마다 실행 취소 스택을 날림

가장 최근 작업을 빠르게 실행 취소할 때만 유효, 좋은 사용자 경험이 아님

Boocrum에 적용된다면?

내 생각에는 Figjam의 방식이 우리 서비스에 더 적합한 것 같다. 둘의 가장 큰 차이는 서버에서 상태를 업데이트하냐, 클라이언트에서 상태를 업데이트 하냐 차이인데, 멀티플레이 기능의 가장 핵심은 모든 클라이언트가 같은 화면을 보는 것이라고 생각한다. 모든 클라이언트가 같은 화면을 보기 위해서는 1. 안전성, 2. 랜더링 퍼포먼스가 중요한 가치라고 생각한다. 중앙 집중식 관리는 아래와 같은 이점이 있다.

  1. 중앙(서버)에서 전체 상태를 기록하고, 업데이트 된 내용을 클라이언트 모두에게 동일하게 전송하기 때문에 안전성이 뛰어나다.
  2. 클라이언트가 상태 업데이트에 컴퓨팅 자원을 낭비하지 않고 렌더링에 모든 자원을 집중할 수 있다.(근데 구현 방식에 따라 근데 다를 듯?)

P2P 방식을 채택한다면 퍼포먼스의 이점 떄문일 것인데 일반적으로 클라이언트보다 서버가 컴퓨텅 속도가 훨씬 빠르기 때문에 잘 모르겠다.

P2P 방식을 채택한다면 퍼포먼스를 위해 websocket이 아닌 webRTC가 고려가 되어여할 것 같다. 하지만 우리 서비스는 템플릿 기능, 상태 저장 기능을 제공하기 때문에 서버와의 연결이 필수라 중앙 관리형 아키텍처가 더 적합해보인다.

참고 문헌

*Excalidraw의 서비스 초기 버전을 기준으로 작성되었음(현재 작동 방식과 다를 수 있음)

📚 그라운드 룰

✏️ 컨벤션

🧑‍🏫 멘토링

📁 애자일 프로세스

기획
데일리 스크럼
스프린트 리뷰
스프린트 회고
트러블 슈팅
기타 산출물

📖 기술문서

Week2
Week3
Week4
Week5

🗂 참고문서

Clone this wiki locally