-
Notifications
You must be signed in to change notification settings - Fork 4
YJS와 Websocket 그리고 React Flow
- 실시간 협업 솔루션에 대한 필요성.
- 인트라넷, 이미 존재하는 서비스 등에 실시간 협업 기능을 쉽게 추가할 수 있는 방법 필요.
-
CRDT의 기본은 position-based가 아니라 모든 캐릭터에 대해 identifier를 두는 것.
“this is some text"
와 같은 텍스트가 있다고 하자. A, B가 이를 동시에 편집 중이다.A의 커서는 맨 앞에, position
0
에 있다.B의 커서는
“some”
뒤, position12
에 있다.이 때, A는
“hello”
를, B는“nice”
를 추가하고 싶다. 결국 동시 편집 상 원하는 결과는“hello this is some nice text”
이다.이 때, 각자의 position을 기준으로 동시편집이 이루어지면 A의
“hello”
에 의해 기존 텍스트가 밀려나면서“hello this is nice some text”
와 같이 원하는 것과 다른 결과가 나올 수 있다.이를 해결하기 위해 마치 사람이 이 문제를 해결하듯이, B의
“nice”
를 position12
가 아닌,“some”
뒤에 추가하는 것이 CRDT의 기본적인 해결 방법이다.
- Operational Transformation을 사용하는 기존의 솔루션들은 서버에서 동시성을 해결해주는 중앙화된 방식으로 동작한다. 단점은 제한된 동시성이다. 서버와 클라이언트 사이에서만 동시성이 존재하기 때문에. 또 이 서버가 single point of failure가 되기도 한다.
- YJS, 혹은 CRDT는 이와 달리 좀 더 분산화된 방식을 채택하고 있다. CRDT는 변화가 어떤 순서로 들어오든지 같은 결과를 갖게 되기 때문에 중앙된 서버에 의존하지 않아도 된다. 이에 대한 장점은 중앙에 모이지 않으니 scalable하다는 것도 있고, 서버 연결이 되지 않는 비행기와 같은 공간에서도 한 팀이 같이 작업할 수 있다는 점도 있다.
- Operational Transformation에서는 해당 문서의 history가 존재하기 때문에. Undo/Redo가 어렵지 않다.
- 반면 CRDT에서는 그런 linear한 history가 없다. 트리 구조(정확히는 DAG인듯)를 띄기 때문에 Undo/Redo 시에는 이에 대한 경로와 state를 통해 해결하는데, 결론적으로는 잘 해결되어 있는 문제인 것 같다. 이를 위해 Undo Manager가 구현되어 있는데 문서의 scope와 권한의 scope를 지정해줄 수 있다.
- 결국 고려할 사항은 유저가 Undo/Redo를 했을 때, 어떤 어떤 문서까지 수정할 것인지(현재 편집 중인 문서, 존재하는 모든 문서), 다른 유저가 수정한 것을 Undo/Redo할 것인지가 된다.
Array, Map, Set 같은, 근데 자동적으로 state가 sync되는 Shared Types를 제공하는게 YJS의 핵심이다. 이 점에서 Rich Text Editor에 사용하기 적합하다.
https://github.com/ivan-topp/y-socket.io
https://github.com/yjs/y-websocket
https://github.com/yjs/y-leveldb
- Socket IO로 사용을 할 수는 있는데 일반적으로는 websocket을 사용.
- persistance를 위해서는 levelDB를 주로 사용.
import * as Y from 'yjs'
import { LeveldbPersistence } from 'y-leveldb'
const persistence = new LeveldbPersistence('./storage-location')
const ydoc = new Y.Doc()
ydoc.getArray('arr').insert(0, [1, 2, 3])
ydoc.getArray('arr').toArray() // => [1, 2, 3]
// store document updates retrieved from other clients
persistence.storeUpdate('my-doc', Y.encodeStateAsUpdate(ydoc))
// when you want to sync, or store data to a database,
// retrieve the temporary Y.Doc to consume data
const ydocPersisted = await persistence.getYDoc('my-doc')
ydocPersisted.getArray('arr') // [1, 2, 3]
- 영속화를 위해 메인 DB에도 저장하는 건 이런 방식으로 가능할 것 같다.
doc.on('update', async () => {
await this.flowService.handleDocumentUpdate(flowId, doc);
});
- 서버에서
Y.Map
을 클라이언트와 똑같이obeserve
할 수도 있다.
doc.getMap('nodes').observe(event => {
event.changes.added.forEach((item) => {
console.log('Node added:', item);
});
event.changes.deleted.forEach((item) => {
console.log('Node deleted:', item);
});
event.changes.updated.forEach((item) => {
console.log('Node updated:', item);
});
});
liveflow-screencast.1.mp4
https://github.com/djk01281/live-flow/blob/main/README.md
Shared Types를 모두 담는 root 레벨의 일종의 컨터이너, 혹은 content라고 생각할 수 있다.
const doc = new Y.Doc();
상태를 저장할 수 있는 방법은 크게 Awareness
와 Y.Map
이 있다.
일시적으로만 필요한 상태를 위해서 사용한다.
프로토타입에서는 커서 정보를 담기 위해 사용하고 있고, 마우스를 움직였을 때 이 값을 변경시켜준다. setLocalState()
로 변경시켜준다.
interface AwarenessState {
cursor: { x: number; y: number } | null;
color: string;
clientId: number;
}
// ...
const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (!provider.current?.awareness || !flowRef.current) return;
const bounds = flowRef.current.getBoundingClientRect();
const cursor = {
x: e.clientX - bounds.left,
y: e.clientY - bounds.top,
};
provider.current.awareness.setLocalState({
cursor,
color: userColor.current,
clientId: provider.current.awareness.clientID,
});
}, []);
다른 유저들은 이 정보에 접근하기 위해 awreness.on()
의 콜백 함수를 사용할 수 있다.
const [cursors, setCursors] = useState<Map<number, AwarenessState>>(
new Map()
);
// ...
wsProvider.awareness.on("change", () => {
const states = new Map(
wsProvider.awareness.getStates() as Map<number, AwarenessState>
);
setCursors(states);
});
// ...
return(
{Array.from(cursors.entries()).map(([clientId, state]) =>
<Cursor
key={clientId}
x={state.cursor.x}
y={state.cursor.y}
color={state.color}
/>
)
}
유지되어야 하는 정보를 위해 사용한다. 또, Undo/Redo를 위한 history 또한 보관한다.
프로토타입에서는 노드, 엣지 정보를 담기 위해 사용하고 있다. 프로토타입에는 아직 없지만 이 Y.Map을 위해 levelDB, y-leveldb를 사용하면 될 것 같다.
client에서 노드, 엣지의 상태는 다음과 같이 React Flow가 제공하는 hook을 통해 관리된다.
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
소켓 측에는 다음과 같이 저장한다.
const nodesMap = doc.getMap("nodes");
const edgesMap = doc.getMap("edges");
edge가 변경되면 이 client의 edges
상태와 함께 Y.Map
을 변경시킨다.
const onConnect = useCallback(
(connection: Connection) => {
if (!connection.source || !connection.target) return;
const newEdge: Edge = {
id: `e${connection.source}-${connection.target}`,
source: connection.source,
target: connection.target,
sourceHandle: connection.sourceHandle || undefined,
targetHandle: connection.targetHandle || undefined,
};
if (ydoc.current) {
ydoc.current.getMap("edges").set(newEdge.id, newEdge);
}
setEdges((eds) => addEdge(connection, eds));
},
[setEdges]
);
또, 다음과 같이 Y.Map
에 변경이 있으면 그로부터 값을 가져와 client 상태를 변경시켜준다.
edgesMap.observe(() => {
const yEdges = Array.from(edgesMap.values()) as Edge[];
setEdges(yEdges);
});
⚓️ 사용자 피드백과 버그 기록
👷🏻 기술적 도전
📖 위키와 학습정리
✏️ 에디터
Novel이란?
Novel 스타일링 문제
에디터 저장 및 고려 사항들
📠 실시간 협업, 통신
Yorkie와 Novel editor 연동
YJS와 Websocket 그리고 React-Flow
YJS와 Socket.io
WebSocket과 Socket.io에 대해 간단히 알아보기
🏗️ 인프라와 CI/CD
NCloud CI CD 구축
BE 개발 스택과 기술적 고민
private key로 원격 서버 접근
nCloud 서버, VPC 만들고 설정
monorepo로 변경
⌛ 캐시, 최적화
rabbit mq 사용법
🔑 인증, 인가, 보안
passport로 oAuth 로그인 회원가입 구현
FE 로그인 기능 구현
JWT로 인증 인가 구현
JWT 쿠키로 사용하기
refresh token 보완하기
🧸 팀원 소개
⛺️ 그라운드 룰
🍞 커밋 컨벤션
🧈 이슈, PR 컨벤션
🥞 브랜치 전략
🌤️ 데일리 스크럼
📑 회의록
1️⃣ 1주차
킥오프(10/25)
2일차(10/29)
3일차(10/30)
4일차(10/31)
2️⃣ 2주차
8일차(11/04)
9일차(11/05)
11일차(11/07)
13일차(11/09)
3️⃣ 3주차
3주차 주간계획(11/11)
16일차(11/12)
18일차(11/14)
4️⃣ 4주차
4주차 주간계획(11/18)
23일차(11/19)
24일차(11/20)
25일차(11/21)
5️⃣ 5주차
5주차 주간계획(11/25)
29일차(11/25)
32일차(11/28)
34일차(11/30)
6️⃣ 6주차
6주차 주간계획(12/2)
37일차(12/3)