먼저 필요한 의존성들을 설치합니다.
npm i
다음으로 환경변수들을 .env
파일에 정의합니다. 다음 변수들이 필요합니다:
# 호스트 유저정보를 저장할 DB서비스
DB_HOST=
DB_USER_PASSWORD
DB_USER_NAME
DB_DATABASE
DB_PORT
# 구글 소셜로그인을 위해 필요한 클라이언트 ID
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_CALLBACK_URL=
KAKAO_CLIENT_ID=
KAKAO_CLIENT_SECRET=
KAKAO_CALLBACK_URL=
NAVER_CLIENT_ID=
NAVER_CLIENT_SECRET=
NAVER_CALLBACK_URL=
# 로그인된 호스트들을 인가하기 위한 JWT 토큰과 관련한 정보
JWT_ACCESS_TOKEN_SECRET=
JWT_ACCESS_TOKEN_EXPIRATION_TIME=
JWT_REFRESH_TOKEN_SECRET=
JWT_REFRESH_TOKEN_EXPIRATION_TIME=
# 클라이언트 서버의 주소
CLIENT_URL=
# 프론트/백엔드 공통적으로 사용될 도메인 이름, 예를 들어 www.recre.com이 있다면, recre.com이 DOMAIN입니다
DOMAIN=
# 본 서비스가 동작할때 Listen할 포트번호
LISTEN_PORT=
그리고 다음 명령어를 통해 각각 개발용과 프로덕션용 모드로 실행할 수 있습니다. NestJS 커맨드에 대한 자세한 설명은 공식문서를 참고하세요.
npm run start:dev
npm run start:prod
NestJS는 Controller & Service 구조로 이루어져 있으며, 각각의 컴포넌트들이 Module 단위로 분리되어 있습니다. 아래 Tree는 실제 파일들의 구조를 간략하게 소개한 텍스트입니다.
src
├── app.controller.ts
├── app.module.ts
├── app.service.ts
├── auth ############################## 호스트 사용자 인증 / 인가 모듈
│ ├── auth.controller.ts
│ ├── auth.module.ts
│ └── auth.service.ts
├── main.ts
├── session ########################### 캐치마인드, 무궁화꽃이 피었습니다 게임로직 & Socket.io 인터페이스
│ ├── catch.gateway.ts
│ ├── redgreen.gateway.ts
│ ├── session.guard.ts
│ ├── session.module.ts
│ └── socket.extension.ts
├── session-info ###################### 게임, 플레이어, 호스트 상태를 관리하는 모듈
│ ├── entities
│ │ ├── catch.game.entity.ts
│ │ ├── catch.player.entitiy.ts
│ │ ├── host.entity.ts
│ │ ├── player.entity.ts
│ │ ├── redgreen.game.entity.ts
│ │ ├── redgreen.player.entity.ts
│ │ └── room.entity.ts
│ ├── session-info.module.ts
│ └── session-info.service.ts
└── user ############################## 호스트 정보를 관리하는 모듈
├── dto
│ ├── create-user.dto.ts
│ └── update-user.dto.ts
├── entities
│ └── user.entity.ts
├── user.controller.ts
├── user.module.ts
└── user.service.ts
leave_game
이벤트를 명시적으로 날려 서버가 해당 소켓을 disconnect하고 또한 호스트 클라이언트에게도 player_list_remove
를 보내어 예외처리를 수행함. (playerDisconnect
, hostDisconnect
)leave_game
이벤트가 날아가지 않는 disconnection에 한하여 서버는 주기적으로 일정시간(10분)동안 아무 이벤트도 보내지 않은 클라이언트를 식별하여 강제로 disconnection을 수행한다. (checkInactiveClients
)중간에 끊긴 소켓통신에 대한 사용자 식별 및 접속유지 프로토콜 구현 #16
관련 링크
문제상황
게임플레이에 지장을 줄 정도로 판정이 가혹했습니다. 지연시간을 생각하지 않아 stop 이벤트 이전에 발송된 run이 뒤늦게 도착해 게임오버가 되는 경우가 발생했습니다.
해결방안
지연시간이 존재하면 극복하면 되는 법. 플레이어 클라이언트가 주기적으로 서버에 ping 이벤트를 보내 서버가 응답한 acknowledgement를 받을때까지의 시간을 구합니다. 이 시간을 Round Trip Time, 줄여서 RTT라고 부릅니다. RTT는 client → server → client 2-way이기 때문에 이를 절반으로 나누어야 1-way 지연시간을 구할 수 있습니다.
Show Me the Code
client:
const start = performance.now();
socket.emit("ping", {start}, (res: {start: number}) => {
const end = performance.now();
const latency = (end - res.start) / 2;
console.log(`latency: ${latency}ms`);
});
server:
레이턴시 측정을 위해 ping 이벤트에 ack를 보내주는 루틴
@SubscribeMessage('ping')
ping(client: Socket, payload: { start: number }) {
return { start: payload.start };
}
player run 이벤트에 따른 죽음판정정책
/**
* 지연시간 기반 죽음판정 정책
*/
private doesPlayerHaveToDie(game: RedGreenGame, latency: number): boolean {
const CONSTANT_MS = 200; // stop 메시지 날아온 시간으로부터 최소 인정시간
const admitTime = game.last_killer_time + CONSTANT_MS + latency;
const currentTime = performance.now();
if (currentTime > admitTime) {
Logger.debug(`${currentTime - admitTime}ms 만큼 늦었습니다. (latency: ${latency})`, 'doesPlayerHaveToDie');
return true;
}
Logger.debug(`${admitTime - currentTime}ms 만큼 빨랐습니다. (latency: ${latency})`, 'doesPlayerHaveToDie');
return false;
}
다수의 플레이어들이 동시에 하나의 세션에서 게임을 즐기기 위해 In Memory Database를 사용했습니다. 게임을 진행시키기 위해 필요한 데이터로 Host, Game, Player가 있습니다. 처음엔 socket.io 소켓 객체와 더불어 모든 데이터를 Map 타입으로 정의하였고, 그림 맞추기 게임을 해당 규격에 맞추어 구현하였습니다. 이 방식으로 게임을 구현하니 에러가 정말 많았는데, 호스트 없는 게임, 게임 없는 플레이어와 같이 데이터 무결성 관리가 되지 않았기 때문입니다. 따라서 관계형 데이터베이스 사용이 필요해졌고, 영속성이 필요없었기 때문에 In Memory DB를 지원하는 SQLite를 도입했습니다. 호스트를 지우면 연관된 테이블의 데이터도 연쇄적으로 지우는 CASCADE 기능 덕분에 버그 발생 가능성을 줄였고, 코드 길이도 감소했습니다.
SQLite In Memory DB를 사용하여 게임의 상태를 관리하자 Map으로 관리할때는 없었던 문제가 생기기 시작했습니다. 바로 비동기 문제였습니다. 동시다발적으로 들어오는 웹 소켓 이벤트의 일부를 처리하지 못해 게임이 종료되지 못하는 버그가 있었는데, async-lock을 활용하여 이벤트 핸들러를 임계영역으로 만들어 요청들을 순차적으로 처리하도록 강제했습니다.