-
Notifications
You must be signed in to change notification settings - Fork 4
YJS 가이드 근데 이제 Socket.io를 곁들인
example.mp4
🐙 데모 구현 코드
- Socket.io로 통신, Y.doc에 일어난 변경 값을 서버에서 확인
- 클라이언트들과 서버 간 같은 Y.doc을 공유
YJS는 상태를 충돌없이 공유할 수 있게 해주는 라이브러리라고 할 수 있습니다. 이 상태를 담아둔 공간을 Y.Doc
이라고 합니다.
동시성 해결을 위해서는 CRDT를 사용합니다.
가장 간단한 예시를 생각해보면, 다음과 같을 것 같습니다.
const toggleMap = {
toggle: true
}
이와 같은 object
에 여러 사람이 동시에 접근해야하는 상황입니다. 각 클라이언트에는 버튼이 있고, 이를 눌러서 버튼의 상태를 토글해줄 수 있습니다.
실시간으로 동작해야 하기 때문에, 다른 클라이언트에서 클릭했을 때의 변화도 바로 알 수 있고 동시에 클릭하더라도 이에 대한 충돌을 해결해줘야 합니다.
[클라이언트]
이를 위한 클라이언트 코드는 다음과 같습니다.
import { useEffect, useState, useRef } from "react";
import * as Y from "yjs";
import { SocketIOProvider } from "y-socket.io";
export default function App() {
const [toggle, setToggle] = useState(false);
const ydocRef = useRef<Y.Doc>();
const providerRef = useRef<SocketIOProvider>();
useEffect(() => {
ydocRef.current = new Y.Doc();
const provider = new SocketIOProvider(
"http://localhost:3000",
"room-name",
ydocRef.current,
{
autoConnect: true,
disableBc: false,
},
{
reconnectionDelayMax: 10000,
timeout: 5000,
transports: ["websocket", "polling"],
}
);
providerRef.current = provider;
provider.awareness.setLocalState({
user: {
name: `User-${Math.floor(Math.random() * 100)}`,
},
});
const toggleMap = ydocRef.current.getMap("toggleMap");
const updateToggleState = () => {
const newValue = toggleMap.get("toggle");
setToggle(Boolean(newValue));
};
if (!toggleMap.has("toggle")) {
toggleMap.set("toggle", false);
} else {
updateToggleState();
}
toggleMap.observe(() => {
updateToggleState();
});
return () => {
provider.destroy();
ydocRef.current?.destroy();
};
}, []);
const handleToggle = () => {
if (!ydocRef.current) return;
const toggleMap = ydocRef.current.getMap("toggleMap");
const newValue = !toggle;
toggleMap.set("toggle", newValue);
};
return (
<div
style={{
display: "flex",
flexDirection: "column",
gap: "20px",
justifyContent: "center",
alignItems: "center",
width: "100vw",
height: "100vh",
backgroundColor: "#231f20",
}}
>
<button
onClick={handleToggle}
style={{
width: "96px",
height: "96px",
borderRadius: "50%",
transition: "background-color 0.3s",
backgroundColor: toggle ? "#f7b528" : "#e9203d",
border: "none",
cursor: "pointer",
}}
/>
</div>
);
}
처음 한번만 useEffect
내부의 함수가 실행됩니다.
ydocRef.current = new Y.Doc();
const provider = new SocketIOProvider(
"http://localhost:3000",
"room-name",
ydocRef.current,
{
autoConnect: true,
disableBc: false,
},
{
reconnectionDelayMax: 10000,
timeout: 5000,
transports: ["websocket", "polling"],
}
);
providerRef.current = provider;
Y.Doc
을 만들고 이를 provider
에 연결해줍니다.
이제 toggle
상태를 담을 map
이 필요합니다.
// 1
const toggleMap = ydocRef.current.getMap("toggleMap");
// 2
const updateToggleState = () => {
const newValue = toggleMap.get("toggle");
setToggle(Boolean(newValue));
};
if (!toggleMap.has("toggle")) {
toggleMap.set("toggle", false);
} else {
updateToggleState();
}
-
“state”
라는 임의의 이름의 map을 받아옵니다. - 이
sharedMap
에toggle
이란 key가 없으면false
로 초기화해줍니다. 있다면 다른 사용자가 이미 만든 것(접속한 것)입니다.
공유하고 있는 sharedMap
에 일어난 변화를 감지할 수 있습니다. 이는 뒤에 나오겠지만 서버에서도 마찬가지로 감지할 수 있습니다.
const updateToggleState = () => {
const newValue = toggleMap.get("toggle");
setToggle(Boolean(newValue));
};
toggleMap.observe(() => {
updateToggleState();
});
변화가 감지되면 이 값을 로컬의 상태에 반영해줍니다. (React - useState
)
이제 toggle 상태에 따라 다른 색상의 버튼을 보여주고, 버튼을 클릭하면 공유되고 있는 toggle 상태를 변경해주어야 합니다.
// 1
const handleToggle = () => {
if (!ydocRef.current) return;
const toggleMap = ydocRef.current.getMap("toggleMap");
const newValue = !toggle;
toggleMap.set("toggle", newValue);
};
// 2
return (
<button
{...}
/>
);
- 버튼 클릭 시,
sharedMap
의 토글 값을 바꿔줍니다.Y.Map
에set()
해주는 것은 일반적인map
에 해주는 방식과 동일합니다. -
toggle
의 상태에 따라 다른 색상의 버튼을 보여줍니다 (생략).
[서버]
우선 소켓 서버를 만들어야 클라이언트가 이에 접속할 수 있습니다. 현재는 이를 위해 y-websocket 라이브러리를 사용합니다.
import { Injectable, OnModuleInit, Logger } from '@nestjs/common';
import * as WebSocket from 'ws';
import { setupWSConnection } from 'y-websocket/bin/utils';
@Injectable()
export class YjsService implements OnModuleInit {
private wss: WebSocket.Server;
private logger = new Logger('YjsService');
onModuleInit() {
this.wss = new WebSocket.Server({ port: 1234 });
this.wss.on('connection', (ws: WebSocket, req: any) => {
this.logger.log('Client connected');
setupWSConnection(ws, req);
});
this.logger.log('WebSocket server initialized on port 1234');
}
}
여기까지만 했을 때도 클라이언트끼리는 소켓 서버를 통해 상태를 공유할 수 있습니다.
문제는 공유된 상태을 저장해야된다는 점입니다. 클라이언트가 접속하지 않아 있고 (즉 상태를 들고 있는 사람이 없고), 서버가 재시작된다면 공유된 상태는 사라지게 됩니다.
빠른 저장을 위해 leveldb 또는 redis를 사용할 수 있고, 이를 위한 어댑터들도 존재합니다 (y-leveldb, y-redis).
또, 지금처럼 y-websocket 사용시, 공유된 상태에 접근 하기 위해서는 (결국 접근해야 처리해줄 수 있으니까) 이런 leveldb 또는 redis를 추가로 사용해야해서 현재 develop 브랜치의 yjs 모듈로는 불가능합니다.
따라서 우선 Socket.io를 사용해서 현재 공유된 상태에 접근하는 예시를 만들어봤습니다.
목적은 서버에서 공유된 상태에 접근하는 것입니다. 결국 이 상태에 변경이 일어나면 이를 저장해주는 로직이 서버로 옮겨가야 하기 때문에.
import {
OnGatewayConnection,
OnGatewayDisconnect,
OnGatewayInit,
WebSocketGateway,
WebSocketServer,
} from '@nestjs/websockets';
import { Logger } from '@nestjs/common';
import { Server } from 'socket.io';
import { YSocketIO } from 'y-socket.io/dist/server';
import * as Y from 'yjs';
@WebSocketGateway({
cors: true,
transports: ['websocket', 'polling'],
})
export class YjsGateway
implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
{
private logger = new Logger('YjsGateway');
private ysocketio: YSocketIO;
@WebSocketServer()
server: Server;
afterInit() {
this.ysocketio = new YSocketIO(this.server, {
gcEnabled: true,
});
this.ysocketio.initialize();
this.ysocketio.on('document-loaded', (doc: Y.Doc) => {
this.logger.log(`Document loaded: ${doc.guid}`);
const toggleMap = doc.getMap('toggleMap');
toggleMap.observe(() => {
const toggleState = toggleMap.get('toggle') || false;
this.logger.log('🐰 토글 상태 변경', {
toggleState,
});
});
});
}
handleConnection() {
this.logger.log('접속');
}
handleDisconnect() {
this.logger.log('접속 해제');
}
}
또, 클라이언트에서와 마찬가지로 공유된 toggle 상태를 변경할 수도 있습니다.
// 클라이언트에서와 같은 방식으로 toggle 상태 변경.
setInterval(() => {
const toggleMap = doc.getMap('toggleMap');
const currentToggle = toggleMap.get('toggle');
sharedMap.set('toggle', !currentToggle);
}, 3000);
🐙
중요한 변경사항
값이 변경되면 감지할 수 있기 때문에, 클라이언트에서 서버로 보내던 저장 요청을 이제는 서버에서 바로 처리할 수 있게 됩니다.
클라이언트에서 저장 요청을 보내면 안되는 이유는 CRDT를 사용하는 이유와 같습니다. 요청의 충돌이 나기 때문에, YJS를 통해 충돌이 해결된 값을 서버에서 observe
해서 변화가 생기면 이를 저장해주는 식으로 되어야할 것 같습니다. 이를 위해 YJS-DB 혹은 YJS-REDIS를 연결해주는 어댑터 라이브러리를 사용할 수도 있을 것 같구요..!
간단한 가이드다보니 더 넣지 못한 내용들도 있습니다.. 예를 들어 상태를 저장하기 위해 Y.Map을 사용했지만 그 외의 대부분의 데이터 구조도 지원합니다 (Array, Text). 또 유지될 필요가 없는 상태를 위해서는 Awareness
를 사용합니다.
YJS, Sockets, React Flow에 간단하게 적어두긴 했었는데.. 우선은 이정도면 더 학습해보시는데에는 큰 문제가 없을 것 같습니다. 👏🏻👏🏻
⚓️ 사용자 피드백과 버그 기록
👷🏻 기술적 도전
📖 위키와 학습정리
✏️ 에디터
Novel이란?
Novel 스타일링 문제
에디터 저장 및 고려 사항들
📠 실시간 협업, 통신
Yorkie와 Novel editor 연동
YJS, Websocket, React-Flow
YJS, Socket.io
WebSocket과 Socket.io에 대해 간단히 알아보기
YJS 가이드 근데 이제 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)