-
Notifications
You must be signed in to change notification settings - Fork 3
React에서 Y.js를 사용하기
Y.js는 CRDT 구조를 기반으로 실시간 협업 애플리케이션을 만들기 위한 솔루션을 제공하는 라이브러리예요. Y.Map
, Y.Array
와 같은 Y.js만의 공유 데이터타입(Shared Datatype)이 존재하고, 이를 기반으로 여러 클라이언트들과 데이터를 동기화해요.
Y.js 자체는 ‘CRDT에 기반한 공유 데이터타입 동기화’라는 목적에만 집중하고 있어요. 네트워크 단은 직접 신경쓰지 않는다는 것이 중요해요. 대신 AbstractConnector
인터페이스를 결정해 놓고, 그 인터페이스에 맞는 Provider를 설정해줌으로써 사용할 네트워크 방식으로 손쉽게 연결할 수 있어요. 이를 Y.js에서는 Network Agnostic으로 설명하고 있어요. 저희는 서버에 데이터를 기록해야 한다는 점에서 서버-클라이언트 모델이면서 실시간 통신을 쉽게 할 수 있는 WebSocket을 사용하기로 했어요. 구현체로는 공식적으로 Y.js 팀에서 만든 y-websocket
패키지를 사용했어요.
Y.js에서는 Y.Map
, Y.Array
와 같은 공유 타입이 존재해요. 이게 바로 Y.js에서 CRDT 내부 데이터를 조작할 수 있는 인터페이스가 되기도 해요. 마치 이벤트 또는 반응형 데이터 타입을 다루듯, 이 타입들을 다룰 수 있어요. 그러면 내부적으로 CRDT 데이터에 반영이 되고, 자동으로 네트워크 요청까지 발생해요. 이런 공유 타입에는 다음 여섯 가지 타입이 있어요.
-
Y.Map
,Y.Array
,Y.Text
-
Y.XmlFragment
,Y.XmlElement
,Y.XmlText
잠깐, 공유 타입엔 없는데 Y.Doc
은 대체 뭘까요? Y.js는 공유 데이터의 루트로서 Y.Doc
이라는 데이터 구조를 사용해요. 위에 있는 모든 공유 타입이 유의미하게 동작하려면, Y.Doc
의 하위 요소로 존재해야 해요. 그리고 이 Y.Doc
객체만 Provider에 제공해서 처리돼요.
이를 기반으로 데이터를 설계해보았어요. 설계를 하며 느낀 점 중 하나가 바로 동시성 지원을 위해서는 분산 시스템처럼 모든 것이 ‘키’로 관리되는 게 유리하다는 점이었어요. 그렇기 때문에, ‘키’의 의미가 그닥 크지 않은 Edge 정보에 대해서도 모두 고유한 키 값을 부여해 관리해주었어요.
export type YSpaceData = {
contextId: string;
parentContextId?: string;
edges: Y.Map<Edge>;
nodes: Y.Map<Node>;
};
Y.Array
를 활용하면 안 되나요?처음에는
Y.Array
를 활용하고자 했어요. 하지만 배열 구조 자체를 충돌 없이 관리하는 건 꽤 큰 수고였어요.배열 구조는 인덱스 순서를 키로 하는 맵 구조처럼 생각할 수 있는데요, 이런 경우 요소가 삭제되거나 중간에 추가되면 같은 데이터를 가리킴에도 순서가 바뀌어 키가 바뀐다고 볼 수 있어요. 반면 맵은 한 번 부여된 키가 한 데이터에 대해서 바뀌지 않아요. 그래서 ‘순서 자체’가 의미있는 데이터가 아니라면, 맵을 선택하는 게 더 자연스럽다고 느꼈어요.
함수형 컴포넌트를 사용하는 모던 리액트에서, SharedTypes과 같은 타입을 어떻게 다루어야 할까요? 다음과 같은 구조가 되어야 하겠네요. SharedType은 반응형 데이터라, 업데이트될 때마다 이벤트가 발생해요. observe
와 같은 메소드를 제공하고 있죠.
flowchart LR
A["SharedType 업데이트 감지"] --> B["리액트 컴포넌트 리렌더링 트리거"]
그렇다면, 중간의 저 화살표가 나타내는 연결 동작을, 커스텀 훅으로 관리하면 정말 편하지 않을까요? 리액트에서 컴포넌트가 재렌더링되는 건, 자신 또는 상위 컴포넌트의 State가 변했을 때 또는 다시 호출되었을 때에요.
방법 #1. State로 관리하기
흠.. 그러면 SharedType의 데이터를 State로 같이 저장해놓으면 될 것 같네요. SharedType의 업데이트가 감지되면 이 State도 변화시키면 좋을 것 같아요.
방법 #2. useSyncExternalStore 도입 (React 18)
더 적합해보이는 방법을 찾는다면, useSyncExternalStore
를 도입해볼 수 있어요. 이름처럼 외부 스토어에 저장된 데이터를 리액트의 상태처럼, 활용할 수 있게 해주는 리액트의 훅이에요.
export function useSyncExternalStore<Snapshot>(
subscribe: (onStoreChange: () => void) => () => void,
getSnapshot: () => Snapshot,
getServerSnapshot?: () => Snapshot,
): Snapshot;
subscribe
subscribe
는 변화를 구독하는 함수예요.
인자로 전해지는 onStoreChange
를 호출하면 “리액트가 외부 스토어의 데이터가 바뀌었구나!”라고 생각하고 이전 값과 비교를 시작하게 돼요. 비교 결과 다르다면, 실제로 리렌더링이 트리거링돼요.
Y.js에서는 observe
라는 메소드를 제공해서 변화를 구독할 수 있다고 했는데요, 여기서 onStoreChange
를 호출해주면 되겠죠?
getSnapshot
getSnapshot
은 말 그대로 현재 상태의 ‘스냅샷’을 제공해주는 함수예요.
변화가 감지되는 동작에 대해서는 앞의 subscribe
를 통해 알 수 있었어요. 그렇다면 실제 값을 얻어오거나 계산하는 건 이 함수에서 이뤄져야 해요.
여기서 가장 주의해야 할 것이 있어요.
리액트는 기본적으로 얕은 비교만 하는데요, 여기서도 마찬가지랍니다. 따라서 오브젝트 값이라면, 레퍼런스 자체가 바뀌도록 신경써주어야 해요. 또, 반드시 캐싱을 해야 한다는 점도 중요해요. 무작정 다른 값으로 가정하고 매번 레퍼런스가 바뀌면, 무한 렌더링이 일어날 수도 있거든요. 공식 문서에서도 이 부분을 언급하고 있고, 실제로 에러 처리가 돼요.
getServerSnapshot
이건 서버사이드에서 렌더링할 시 활용돼요. 적절하게 최초 데이터를 반환할 수 있다면 큰 문제 없어요.
최종적으로는 다음과 같이 작성해보았어요. SharedType의 흔적을 지우고(어차피 파라미터로 제공돼요), 순수한 오브젝트(Json)으로 제공돼요.
function useY<T extends Y.AbstractType | undefined>(
yData: T,
): unknown {
const currentDataRef = useRef<unknown>(undefined);
const subscribe = (onStoreChanged: () => void) => {
const callback = () => {
onStoreChanged();
};
if (yData) {
yData.observe(callback);
return () => yData.unobserve(callback);
}
return () => {};
};
const snapshot = () => {
const json = yData?.toJSON();
if (equalsDeep(currentDataRef.current, json)) {
return currentDataRef.current;
}
currentDataRef.current = json;
return currentDataRef.current;
};
const initialSnapshot = () => yData?.toJSON();
const currentSnapshot = useSyncExternalStore(
subscribe,
snapshot,
initialSnapshot,
);
return currentSnapshot;
}
여기서, equalsDeep
을 굳이 넣은 이유는 무엇일까요?
위에서 가장 주의해야 한다는 얕은 비교 때문이에요. toJSON
메소드를 통해 매번 생성되기 때문에 무조건 다른 레퍼런스를 가지게 돼요. 그러니 만약 깊은 비교를 한 결과가 ‘같음’이라면, 레퍼런스를 교체하면 안 되겠네요. 만약 그렇게 한다면 불필요한 렌더링이 이뤄지게 되고 무한 루프에 빠지게 될 거예요. 그래서 Ref Object에 캐싱하는 접근을 이용해, 같은 데이터인 경우 레퍼런스를 유지하도록 작성했어요.
이를 적용해 아래처럼, 공유 타입에 변화가 생기면 렌더링 또한 이뤄지는 걸 확인할 수 있었어요.
Y.Doc
이 해당 방의 공유되는 공동의 데이터라면, Awareness는 접속한 개개인에 대한 정보예요. 여기엔 임의로 붙은 id 값도 있고, 기타 여러 정보가 있죠. Awareness 안에도 상태가 존재해요. 원하는 대로 이 안에 각자의 상태를 담아두고 활용할 수 있죠. 당연히, 이 상태들도 실시간으로 공유돼요.
아래는 이 Awareness의 상태로 각 사용자들의 커서 위치를 저장해 공유해본 거예요. 이를 활용해 커서 위치를 실시간으로 공유할 수 있어요. (물론, 네트워크 최적화를 생각한다면 업데이트하는 주기를 신경써야 할 것 같아요)
- 🎨 Canvas 라이브러리, 비교와 고민
- 💫 CSS, JS 없이도 SVG 모핑을 구현할 수 있다니
- 💥 CRDT를 프로젝트에 적용하기 - Yjs 데이터타입 정의
- 🐳 CI-CD-개선-경험-및-Docker‐compose-활용
- 🔬 FPS 테스트로 성능 최적화 고민 해결하기
- 👩🚀 Konva.js로 스페이스 줌 기능 구현하기
- 🤔 Palette메뉴 육각형 구현체에 대한 간단한 고민
- 🧑💻 React에서 Y.js를 사용하기
- 🤜 React에서 Dialog를 매끄럽게 관리하기
- 🐝 WebRTC, WebSocket, SocketIO 기술 선정의 근거와 이유
- 🥛 동시편집 마크다운 에디터 구현기
- 📱 모바일 환경을 위한 노드.간선 조작 기능을 지원해보자
- 🧱 몹프로그래밍으로 설계한 기본 컴포넌트
- 💨 백엔드 MySQL에서 MongoDB로 개선한 근거 및 Redis로 캐싱을 하지 않는 이유
- 🪄 우여곡절 재탄생한 Gooey 인터랙션
- ✨ 인터랙션 구현기: 홀드, 그리고 이동