들어가기에 앞서, blokus라는 보드게임을 구현하기 위해 lifecycle에 대한 큰 그림을 그려보자. 우선 플레이어는 해당 서비스에 접속 후 크게 아래와 같은 액션을 취할 수 있겠다.
개설된 room들은 메인 DB에 저장하여 불러오도록 하자. room에 들어갈 내용들은 개설자, 이름, 일시, 최대 인원(2~4) 정도가 되겠다. id는 uuid v7을 사용해보겠다. room에 참가한 후에는 참가자가 모두 ready
요청을 보내야 방장이 게임을 시작할 수 있도록 하겠다. room 내부에서의 데이터 처리는 redis, websocket을 사용할 것이다.
요청 url은 wss://{server_url}/room/{roomId}
형식으로 구성하고, roomId
를 통해 redis에 저장된 room 데이터에 접근하도록 하겠다. redis에는 참가자들과 클라이언트 간 무결성 검증을 위한 데이터와 턴 정보 및 참가자들의 준비 여부 등을 저장하겠다. websocket 요청은 putBlock
, sync
를 보낼 수 있도록 하고 receive
요청을 수신할 수 있도록 구성하여 다른 사용자들의 move를 수신하여 각자의 보드를 업데이트하도록 구성하겠다. 이 과정에서 hash된 보드 정보를 서버에서 받아 빠진 turn 정보 등이 있는지 확인하도록 하겠다. 특히 웹소켓 연결이 중단된 경우 복구하는 로직이 중요하겠다. 각 turn에 주어진 시간 이상으로 서버에서 수신되는 데이터가 없으면 재연결을 요청하는 로직 또한 필요하겠다.
SvelteKit 프로젝트에서 어떻게 WebSocketServer를 initialize할 수 있을까? 사실 이것 때문에 조금 해멨는데, 취미 겸 재미로 비는 시간에 공식 문서를 조금씩 읽다가 해답을 찾았다.
To generate a standalone Node server, use adapter-node.
...
The adapter creates two files in your build directory — index.js and handler.js. Running index.js — e.g. node build, if you use the default build directory — will start a server on the configured port.
Alternatively, you can import the handler.js file, which exports a handler suitable for use with Express, Connect or Polka (or even just the built-in http.createServer) and set up your own server:
...
import { handler } from './build/handler.js';
...
app.use(handler);
...
프로젝트를 설계 및 구현하는 과정에서 배포를 배제하고 있어서 vite나 svelte config에서 서버를 초기화해야 하는지 고민하고 있었는데, 공식문서에서 제시한 custom server를 통해 http/https의 Server 인스턴스를 얻어올 수 있었다.
if (process.env.NODE_ENV === 'production') {
const key = process.env.KEY_FILE;
const cert = process.env.CERT_FILE;
const ca = process.env.CA_FILE;
if (!key || !cert || !ca) {
console.error('files for https server not found');
process.exit(1);
}
const option = {
key: fs.readFileSync(key),
cert: fs.readFileSync(cert),
ca: fs.readFileSync(ca),
};
const server = https.createServer(option, app).listen(443);
// [CHECK]
initWebSocketServer(server);
}
if (process.env.NODE_ENV === 'development') {
const server = app.listen(3000);
// [CHECK]
initWebSocketServer(server);
}
일단 실제 서버 배포 전 변수 확인을 위해 check 주석을 남겨둔 후 위와 같이 코드를 작성했다. 이 server.js 파일은 프로젝트 루트 디렉터리에 위치시키고, 이제 vite build
를 통해 빌드된 프로젝트가 /build
에 위치하게, initWebSocketServer
함수는 빌드된 프로젝트와 별개로 위치하게(/dist
) 두겠다. 왜 번거롭게 tsc를 두 번이나 일하게 하는가?
나는 이 빌드된 청크들 중에서 내가 필요한 함수가 들어간 파일을 동적으로 찾아 import하는 방법을 모르겠다. 아는 선생님이 계시다면 댓글로 알려주시면 감사의 말씀을 전하겠다. 웹소켓 초기화 로직은 /src/websocket/index.ts
에 위치하도록 하고, build 전에 해당 디렉터리 내부만 tsc가 트랜스파일하도록 tsconfig을 분리했다(tsconfig.websocket.json).
{
...
"scripts": {
...
"prebuild": "tsc --project tsconfig.websocket.json",
"build": "vite build",
...
"prestart": "npm run build",
"start": "node --env-file=.env server.js"
},
...
}
개발자의 종특(종족 특성) 중 하나가 중복 알레르기라고 생각한다. 하지만 websocket/index.ts
에서 src/types/...
의 타입(특히 websocket 메시지 관련 타입)을 물게 되어 해당 디렉터리 내부까지 트랜스파일해버리는 참사가 발생하였다. 그래서 웹소켓 서버에서 사용할 타입을 분리하여 중복 작성해버리는 방법을 선택할 수밖에 없었다(해결 방법을 아시는 분께서는 댓글로 알려주시면 감사하겠습니다).
일반적으로 ws 패키지를 통해 웹소켓을 사용하는 경우 어떻게 구현하는지 서칭해봤다. 전역변수로 Set을 선언한 후 소켓에 id를 매기던, 유저의 id로 구분하던 소켓 풀을 생성하여 풀 내의 id가 일치하는 클라이언트들을 찾는 것이 일반적인 듯 하다. 하지만 pm2의 클러스터 등을 사용해 멀티 프로세싱 환경에서는 이 소켓 풀을 모든 프로세스가 공유할 수가 없다. 이에 내가 구현할 방식을 다음과 같이 정했다.
서비스 레이어에서 메시지를 수신하고 로그를 저장하는 행위는 요청자의 응답과 병렬로 진행하려고 한다. 만일 대규모 서비스를 처음부터 노리고 만든다면 redis 클러스터와 함께 redis streams를 사용하거나 MQ를 도입할 것 같다고 생각했다. 일단 '돌아가는 서비스'를 빠르게 만드는 것이 목표이므로 지금은 스킵하려고 했지만(웹소켓 계층과 서비스 계층 사이에 redis를 둔 이유는 웹소켓 서버 초기화 함수를 담은 디렉터리인 src/websocket/
내부와 src/lib/
를 독립적으로 위치시키려고 하기 때문) 저렇게 구현하면 누가 생각해봐도 프로세스 개수만큼 로그가 중복적으로 찍힐 것이다. 리더 프로세스를 지정해 로그를 저장하게 할지도 생각해봤지만, 차라리 redis streams를 사용하는 것이 나을 것 같다는 생각에 방향성을 틀었다.
웹소켓 메시지의 종류는 다음과 같이 구성했다.
CONNECTED
: 방에 참가 + 웹소켓 연결 수립LEAVE
: 방에서 나감 + 웹소켓 연결 해제READY
: 게임 준비START
: 게임 시작MOVE
: 착수REPORT
, CORRECTION_NEEDED
: 착수 오류 정보 전달(각각 클라이언트 -> 서버 -> 착수자)착수 오류는, 이미 클라이언트 사이드에서 putBlockOnBoard
함수를 미리 호출하고 웹소켓 메시지를 보내기 때문에 다른 클라이언트 사이드에서 putBlockOnBoard
함수를 호출할 때 에러가 검출되는 것이 이상한 케이스이다. 직접 웹소켓 연결을 수립해서 요청을 보내는 등의 경우에만 타 클라이언트들의 putBlockOnBoard
에서 오류가 발생할 수 있다고 판단하는 것이 합리적일 듯 하다.
일반적인 용도의 스키마는 위 그림과 같이 설계했다. mongodb를 사용 중이어서 rooms에 players를 배열 형태로 플레이어 인덱스 및 아이디까지 저장하도록 했다. MySQL이나 pg를 사용한다면 정규화를 한 단계 더 진행하겠지만 mongodb의 쿼리 성능을 고려해 rooms에 모두 넣었다.
플레이어를 배열 형태로 저장하는 것보다 hash의 각 필드로 저장하는 편이 성능 등의 측면에서 나을 것이라고 판단했다.
자동화된 report 기능 외에도 추후 사용자가 다른 사용자를 신고한 내용도 해당 document에 담으면 되겠다.
해당 포스트에선 웹소켓 구현 틀과 DB 스키마를 간단하게 정리해봤다. Client-side 코드들은 포스트에서 스킵하고, 전반적인 시스템 스케치와 백엔드 구현 위주로 글을 작성하게 될 것 같다. 다만 Svelte를 통한 Drag & Drop, 애니메이션 등은 구현해본 적이 없기에 해당 부분은 작성할 수도 있을 것 같다.