-
Notifications
You must be signed in to change notification settings - Fork 5
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에 사용하기 적합하다.
팟캐스트 1시간 동안 들었는데 뒷부분에서는 크게 뭐 얻어갈 건 없었네요ㅠㅁㅠ
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를 주로 사용하는 것 같다, y-leveldb 있어서 그냥 yjs의 일부라고 생각하고 그냥 써도 될듯..
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로는 현재 사용 중인 postgresql를 그대로 써도 될듯.. 하다. 무언가 검색할 일 생길 때도 좋을 것 같고..
- 변화가 생기면 메인 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);
});
});아니 YJS 엄청 좋네
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);
});무언가 실시간으로 상태를 공유할 때, YJS를 통해 어떻게든 할 수 있겠다는 생각이 듬. 예를 들어 현재 편집 중인 edge도 공유할 수는 있을 것 같다. 다만, 이를 어떻게 svg와 bezier curve로 표현할 수 있는지는 모르겠다. (양 끝점은 알아도 control point 정보가 없는데 어떻게 해야할지..) 아니면 투명한 노드를 만들어야할까?
라이브 커서를 구현한다고 했을 때, 문제가 될 만한 걸 발견했다. Canvas의 zoom, panning 상태다. 이를 통해 canvas 상의 x, y 위치를 계산해야 한다..! 악몽이 다시 떠오른다..
levelDB 자체는 그냥 설정만 해주고 persistance를 위해서만 쓰면 될 것 같음. 문제는 그것보다는 서비스의 어떤 부분에서 같은 ws를 쓸 것인지를 결정해야할 것 같음.
⚓️ 사용자 피드백과 버그 기록
👷🏻 기술적 도전
📖 위키와 학습정리
✏️ 에디터
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)