이전 게시글에서 CRDT를 Yjs를 사용해서 구현했다.
하지만, 언젠가는(하겠지...?) 직접 구현해놓고 싶기때문에 라이브러리를 통해 만들어둔 기능을 언제든 내가 만든 모듈로 교체할 수 있도록 처리해둘 것이다.
문제점
실제 구현부인 handleRecieveCodeMessage, 즉 통신을 통해 다른 피어의 코드 데이터를 받았을때의 로직인데, 대부분이 Yjs의 모듈을 부르고, Yjs 객체의 메서드를 사용하는 모습을 확인할 수 있다.
사실 이런 부분이 이 함수 하나면 괜찮은데, 코드들이 굉장히 산개해있고, 많은 컴포넌트에서 이런 식으로 Yjs를 사용하고 있어 문제가 생긴다.
만약 이 상태에서 Yjs에서 다른 라이브러리로 교체하는 경우, 혹은 직접 구현하는 경우, 함수 구현부를 변경하는 과정에서 시간이 많이 소요되고, 각종 오류들을 만나게 될것이다.
모듈을 부르는부분과 Yjs객체 메서드를 감싸는 하나의 class로 한번 래핑해주면 이 의존성 문제를 해결할 수 있다.
ContextAPI를 통한 의존성 주입
ContextAPI에 대한 오해
Context API는 전역 상태 관리를 위한 API가 아니다.
전역상태를 위해 사용하면 setter함수를 구독하는 컴포넌트도 state가 업데이트되면 리렌더된다. (이건 이 게시글에서 할 이야기는 아니니 넘어간다)
상태관리라기보다는 컴포넌트 트리 전체에 데이터를 제공하기위한 주입의 개념에 가깝다.
그래서 Context API를 통해 CRDT에 필요한 객체를 만들어서 컴포넌트에 제공하는 형태로 실제 통신에서 일어나는 CRDT로직에 대한 의존성을 낮춰볼 것이다.
기존 상태
기존 상태는 Yjs객체를 ContextProvider의 value로 제공하여 컴포넌트에서 crdt객체에 접근할 수 있게 했었다.
(여기까지는 이전 게시글과 동일하다)
//crdtContext
import React from 'react';
import * as Y from 'yjs';
const crdt = new Y.Doc();
export const CRDTContext = React.createContext<Y.Doc>(crdt);
interface CRDTProviderProps {
children: React.ReactNode;
}
export function CRDTProvider({ children }: CRDTProviderProps) {
return <CRDTContext.Provider value={crdt}>{children}</CRDTContext.Provider>;
}
사용하는 컴포넌트를 감싸주고, 내부에서 아래와 같이 사용했다.
export default function EditorSection({ defaultCode }: EditorSectionProps) {
...
const crdt = useContext(CRDTContext);
...
}
여기서 value로 전해지는 객체를 변경할 수 있게 해볼 것이다.
공통 인터페이스 작성하기
value로 정해지는 CRDT로 사용할 객체의 인터페이스를 정해주고, Yjs를 클래스로 래핑해서 이 인터페이스로 맞춰줄 것이다.
나는 아래와 같이 작성했다. 기존에 Y 모듈에 있던 커서관련 메서드, crdt객체를 전송하기전 Uint8Array로 변환해주는 메서드를 CRDT인터페이스에 추가했다.
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; //글자위치기반의 커서위치
}
이제 이걸 ContextAPI의 제네릭으로 넘겨주자
export const CRDTContext = React.createContext<CRDT>(crdt);
Yjs 래핑하기
Yjs를 우리가 만든 인터페이스에 맞게 래핑해주었다.
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);
}
}
구현부 변경하기
그럼 이제 구현부는 우리가 작성한 인터페이스에 맞게 수정해주면 된다.
이제 구현부의 Yjs의존성을 모두 없애버릴 수 있다.
결론
위와 같이 CRDT인터페이스에 맞는 객체라면, 실제 구현부를 수정하지 않아도, CRDT의 핵심 로직부분을 수정할 수 있다.
래핑된 클래스의 내부 메서드만 간단히 수정해주면 되는 것이다.
예를들어 이후에 직접 CRDT를 만들어 Custom CRDT로 객체를 변경하는 경우, 인터페이스만 맞춰주면 주입부의 객체만 변경하는 형태로 쉽게 코드를 교체할 수 있다.