[CRDT 구현하기] 3차 시도: 라이브러리

HBSPS·2023년 12월 15일
1

AlgoITNi

목록 보기
13/13

직접 만든 CRDT는 제대로 된 기능을 하지만 실시간성에서 부족함이 있었다.

정해진 일정이 있었고 일정에 맞춰서 기능을 완성해야 했다.
이를 위해 라이브러리를 도입하기로 했다.

라이브러리

CRDT 알고리즘을 제공하는 라이브러리는 여러가지가 있다.

대표적으로 YjsAutoMerge 등이 있으며 각각의 장단점이 있다.

우리는 결론적으로 Yjs를 도입하기로 했다.
Yjs를 도입한 가장 큰 이유는 우리와 같은 고민을 했던 것 이었다.

우리는 커서에 대한 고민을 했고 이를 어떻게 유지할 수 있을지 고민했다.
그 과정에서 상대 위치를 갖는 경우를 고려하게 되었다.
절대값인 index를 기억하는 경우 커서의 위치가 밀리게 되는 문제가 발생하므로 현재 문자열에서 상대적인 위치를 기억하기로 한 것이었다.
상대적인 위치를 기억하기 때문에 병합 이후에도 해당 위치를 찾을 수 있었다.

Yjs에서 제공하는 여러가지 메소드 중 현재 문서에서 상대적인 커서 위치를 가져오는 메소드가 있었다.
이것을 다시 index로 변환하는 메소드도 제공했다.
또한, 공식 문서를 참고하여 우리의 생각과 동일한 생각을 하고 있음을 알게 되었고 이를 바탕으로 Yjs를 도입하기로 했다.

한글 입력의 문제

Yjs를 사용한다고 모든 문제가 사라지지는 않았다.
한글의 경우 영어와 다르게 자음과 모음이 한 글자를 만들기 때문에 다르게 처리를 해야 했다.
예를 들어 hello를 입력하는 경우 이벤트가 5번 발생하는 것이 맞지만 안녕을 입력하는 경우 이벤트가 2번 발생하는 것을 의도했지만 실제로는 6번 발생하게 된다. (ㅇ, ㅏ, ㄴ, ㄴ, ㅕ, ㅇ)

이를 해결하기 위해서는 한글의 한 글자가 모두 완성되었는지 판단해야 한다.
한글이 아직 작성 중인 경우라면 데이터 채널을 이용해 전송하지 않도록 해야 한다.
onCompositionEnd를 사용하여 글자의 조합이 완료되었을 때 전송하도록 작성했다.

const handleCompositionEnd = (event: React.CompositionEvent<HTMLTextAreaElement>) => {
    crdt.insert(cursorPosition - 1, event.data);
    sendMessageDataChannels(codeDataChannel, crdt.encodeData());
};

<textarea
	...
    onCompositionEnd={handleCompositionEnd}
    ...
/>

위와 같이 글자의 조합이 완료되었을 때 CRDT 적용하고 데이터 채널을 통해 전송할 수 있도록 했다.

또한, 기존 onChange 이벤트 핸들러를 사용하여 데이터 채널에 전송하고 있었다.
만약, 위와 같이 핸들러를 추가하면 한글을 입력할 때 onCompositionEndonChange에 의해 두 번 전송되는 문제점이 있다.
이를 해결하기 위해서는 onChange 이벤트 핸들러에 한글을 입력하는 경우에 대해 데이터 전송을 하지 않도록 해줘야 했다.

const handleChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
    const newText = event.target.value;
    const newCursor = event.target.selectionStart; // 연산 이후의 최종 위치

    setPlainCode(newText);
    setCursorPosition(newCursor);

    const changedLength = plainCode.length - newText.length;
    const isAdded = changedLength < 0;

    if (isAdded) {
        const addedText = newText.slice(newCursor - Math.abs(changedLength), newCursor);
        const isOneLetter = addedText.length === 1;
        const isKorean = addedText.match(/[ㄱ-ㅎ|ㅏ-ㅣ|가-힣]/);

        if (isOneLetter && isKorean) return;

        crdt.insert(newCursor - Math.abs(changedLength), addedText);
    } else {
        const removedLength = Math.abs(changedLength);
        crdt.delete(newCursor, removedLength);
    }

    sendMessageDataChannels(codeDataChannel, crdt.encodeData());
};

위의 과정을 통해 한글을 사용할 수 있게 되었다.

앞으로

Yjs를 우선 사용해서 기능을 구현하기로 했으나, 혹시나 언젠가 직접 완벽한 CRDT를 만드는 경우에 대해서도 고려하기로 했다.
Yjs를 사용하는 부분을 서비스 코드에 그대로 넣게 된다면 이는 강한 결합도를 갖는다고 판단했고 추후 새로운 CRDT 객체를 사용하는데 어려움이 많을 것이라 생각했다.

이를 해결하기 위해 Yjs를 한 번 더 클래스로 감싸고 CRDT 클래스를 추상화 하여 interface로 정의했다.

import * as Y from 'yjs';

const TEXT_DATA = 'sharedText';

interface RelativePositon extends Y.RelativePosition {}
interface AbsolutePosition extends Y.AbsolutePosition {}

export interface CRDT {
  encodeData: () => Uint8Array;
  insert: (start: number, data: string) => void;
  delete: (start: number, removeLength: number) => void;
  update: (update: Uint8Array) => void;
  getRelativePosition: (position: number) => RelativePositon;
  getAbsolutePosition: (relativePositioni: RelativePositon) => AbsolutePosition | null;
}

export default class YjsCRDT implements CRDT {
  context: Y.Doc;

  constructor() {
    this.context = new Y.Doc();
  }

  encodeData() {
    return Y.encodeStateAsUpdate(this.context);
  }

  insert(start: number, data: string) {
    this.context.getText(TEXT_DATA).insert(start, data);
  }

  delete(start: number, removeLength: number) {
    this.context.getText(TEXT_DATA).delete(start, removeLength);
  }

  update(update: Uint8Array) {
    Y.applyUpdate(this.context, update);
  }

  toString() {
    return this.context.getText(TEXT_DATA).toString();
  }

  getRelativePosition(position: number): RelativePositon {
    return Y.createRelativePositionFromTypeIndex(this.context.getText(TEXT_DATA), position);
  }

  getAbsolutePosition(relativePosition: RelativePositon): AbsolutePosition | null {
    return Y.createAbsolutePositionFromRelativePosition(relativePosition, this.context);
  }
}

CRDT라는 인터페이스를 정의하고 Yjs를 사용한 CRDT인 YjsCRDT에서 해당 인터페이스를 구현했다.
그리고 현재 CRDT 인스턴스는 ContextAPI를 통해 각 컴포넌트에서 사용하고 있었다.

import React from 'react';
import YjsCRDT, { CRDT } from '@/services/crdt';

const crdt = new YjsCRDT();

export const CRDTContext = React.createContext<CRDT>(crdt);

interface CRDTProviderProps {
  children: React.ReactNode;
}

export function CRDTProvider({ children }: CRDTProviderProps) {
  return <CRDTContext.Provider value={crdt}>{children}</CRDTContext.Provider>;
}

이를 수정하여 위와 같이 context가 YjsCRDT에 직접 의존하는 것이 아니라 의존성 역전의 원칙개방 페쇄의 원칙을 적용하여 추상화 된 CRDT 인터페이스에 의존하도록 구성했다.

만약 우리가 새롭게 만드는 (또는, 다른 라이브러리를 사용하는) CustomCRDT라는 클래스가 생기더라도 인터페이스를 통해 결합도를 낮췄기 때문에 CustomCRDT를 사용하는데 문제가 없을 것이다.
(물론 CustomCRDT는 CRDT에 대해 기대하는 클라이언트의 기대를 충족하기 위해 리스코프-치환의 원칙을 만족해야 할 것이다)

결과

Yjs를 사용했지만 앞으로 발전 가능성을 고려하여 Yjs에 강하게 결합되지 않도록 구성했다.

오랫동안 궁금하고 꼭 배우고 싶었던 객체 지향의 원칙을 직접 적용하여 그 장점을 느낄 수 있었다.
(부스트 캠프에서도 멘토님을 붙잡고 객체 지향을 자주 여쭤봤었다... 또한, 위와 같이 두 가지 패러다임을 섞어서 사용해도 되는지는 잘 모르겠다. 개인적인 의견이 포함되어 있는 글이니 참고만 할 것을 부탁한다)

완전한 CRDT를 구현하지 못한 것은 아쉽지만 아직까지 활발히 연구되고 있는 CRDT에 대해 어느정도 기능을 하는 CRDT를 만든 것에 만족하며 그 과정에서 많은 것을 배우고 느낄 수 있었다.

profile
대체로 맑음

0개의 댓글