Node 강의/주특기 플러스

2-1 실전 게임 서버 만들기

kagan-draca 2024. 10. 21. 15:40

1. 사용할 라이브러리들

 

1) dotenv

  • dotenv는 환경 변수를 .env 파일에 저장하고 이를 Node.js 어플리이케션에 로드
  • 개발자가 코드에서 직접 환경 변수를 설정하는 대신 별도의 파일로 관리할 수 있게 해줍니다.

2) lodah

  • JavaScript의 유틸리티 라이브러리
  • 배열, 객체, 함수 등의 데이터 처리를 위한 당양한 기능을 제공
  • 선응이 뛰어나고 코드의 가독성을 높이는 데 유용

3) mysql2

  • MySQL 데이터 베이스와 상호 작용하기 위한 Node.js 클라이언트 라이브러리

4) protobufjs

  • Google의 Protocol Buffers(포로토콜 버퍼)를 사용하여 데이터 직렬화 및 역직렬화를 지원하는 라이브러리

add) 직렬화 : 메모리를 디스크에 저장하거나 네트워크 통신에 사용하기 위한 형식으로 변환하는 것

     역직렬화 : 디스크에 저장한 데이터를 읽거나, 네트워크 통신으로 받은 데이터를 메모리에 쓸 수 있도록 변환하는 것

 

5) uuid

  • 고유한 식별자(UUID)를 생성하기 위한 라이브러리
  • 다양한 버전의 UUID를 지원

 

2. 패킷 구조 + 프로토 버프

1) protobufjs 간단 사용법

 

person.proto 파일

syntax = "proto3"

message Person {
 string name = 1
 int32 id = 2;
 string email = 3
 }

 

프로토 버프를 활용해서 쉽게 패킷의 구조를 만들 수 있다.

import protobuf from 'protobufjs'

protobuf.load("person.proto").then(root => {
 // 'person.proto' 파일 로드합니다.
 
 const Person = root.lookupType("Person");
 // 'Person' 메시지 타입을 'root' 객체에서 찾습니다.
 // 이는 person.proto 파일에서 정의한 메시지 타입입니다.
 
 const message = Person.create({ name : "John Doe", id : 123, email : "johndoe@example.com"});
 // 'Person' 메시지 타입을 사용하여 새로운 메시지 객체를 생성합니다. 
 // 여기서는 name, id, email 필드를 설정합니다.
 
 const buffer = Person.encode(message).finish();
 // 생성된 메시지 객체를 바이너리 형식으로 인코딩합니다.
 // 'finish' 메서드는 최종 인코딩된 버퍼를 반환합니다.
 
 const decodedMessage = Person.decode(buffer);
 // 인코딩된 버퍼를 다시 메시지 객체로 디코딩 합니다.
 
 console.log("Original Message : ", message);
 // 원래 생성된 메시지 객체를 콘솔에 출력합니다.
 
 console.log("Encoded buffer : ", buffer);
 // 인코딩된 바이너리 버퍼를 콘솔에 출력합니다.
 
 console.log("Decoded message : ", decodedMessage):
 // 디코딩된 메시지 객체를 콘솔에 출력합니다.
 
 })

 

2) 패킷 구조 설계

이번 프로젝트에서 공통적으로 사용할 패킷의 구조를 정해봅시다.

아직까지는 TCP에코 서버와 크게 다르지 않습니다.

 

바이트 배열의 구조

필드 명 타입 설명 크가
totalLength int 메세지의 전체 길이 4 Byte
packetType int 패킷의 타입 1 Byte
protobuf protobuf 프로토콜 버퍼 구조체 가변

 

프로토 버프 구조

우리가 지금 알고 넘어가면 좋은 것은 크게 아래 2가지 입니다.

이러한 데이터들을 주로 주고 받게 될 것이라고 미리 알려드리는 것 입니다.

프로젝트를 진행하면서 천천히 파일 생성 및 로드를 해봅시다.

 

  1. common
    아래의 구조로 클라이언트 요청을 할 것이며 'payload'에 각 핸들러에 맞는 데이터가 들어가게 됩니다.
    필드 명 타입 설명
    handlerId uint32 핸들러 ID (4 바이트)
    userId string 유저 ID (UUID)
    clientVersion string 클라이언트 버전 (문자열)
    sequence uint32 유저의 호출 수 (42억)
    payload bytes 실제 데이터
    syntax = "proto3"
    
    package common;
    
    // 공통 패킷 구조
    message Packet {
     uint32 handlerId = 1;		// 핸들러 ID (4바이트)
     string userId = 2; 		// 유저 ID (UUID)
     string clientVersion = 3;  // 클라이언트 버전 (문자열)
     uint32 sequence = 4;		// 유저의 호출 수 (42억)
     bytes  payload = 5;		// 실제 데이터
    }
  2. Response
    클라이언트 요청에 대해서는 아래의 구조로 반환해주게 됩니다.
필드 명 타입 설명
handlerId uint32 핸들러 ID
responseCode uint32 응답 코드 (성공 : 0, 실패 : 에러 코드)
timestamp int64 메시지 생성 타임스탬프 (Unix 타임스탬프)
data bytes 실제 응답 데이터 (선택적 필드)
sequence uInt32 시퀀스 값
syntax = "proto3";

package response;

// 공통 응답 메시지 구조
message Response {
  uint32 handlerId = 1;      // 핸들러 ID
  uint32 responseCode = 2;   // 응답 코드 (성공: 0, 실패: 에러 코드)
  int64 timestamp = 3;       // 메시지 생성 타임스탬프 (Unix 타임스탬프)
  bytes data = 4;            // 실제 응답 데이터 (선택적 필드)
  uint32 sequence = 5;       // 시퀀스 값
}

3. 프로젝트 시작하기 + 코드 가져오기

이전에 만들었던 프로젝트에서 코드를 가져와 적용해보겠습니다.

 

1) 프로젝트 시작하기

 

  1. yarn init -y
  2. 깃허브 연동
  3. 필수 라이브러리 다운로드
  4. prettier 설정, nodemon을 활용해 package.json 에 실행 스크립트 작성
  5. 'net' 모듈을 사용한 기본 TCP 모델 src/server.js 파일에 작성

yarn add dotenv lodash mysql2 protobufjs uuid

yarn add -D nodemon prettier

 

받기

 

package.json에 

  "type": "module",

  "scripts": {
    "start": "node src/server.js",
    "dev": "nodemon src/server.js"
  },

추가하기

 

최종 수정된 package.json

{
  "name": "TcpGame",
  "version": "1.0.0",
  "main": "src/server.js",
  "license": "MIT",
  "scripts": {
    "start": "node src/server.js",
    "dev": "nodemon src/server.js"
  },
  "type": "module",
  "dependencies": {
    "dotenv": "^16.4.5",
    "lodash": "^4.17.21",
    "mysql2": "^3.11.3",
    "protobufjs": "^7.4.0",
    "uuid": "^10.0.0"
  },
  "devDependencies": {
    "nodemon": "^3.1.7",
    "prettier": "^3.3.3"
  }
}

 

.prettierrc

{
  "singleQuote": true,
  "semi": true,
  "useTabs": false,
  "tabWidth": 2,
  "trailingComma": "all",
  "printWidth": 100,
  "arrowParens": "always",
  "orderedImports": true,
  "bracketSpacing": true,
  "jsxBracketSameLine": false
}

 

2) 이전 프로젝트에서 코드 가져오기

 

assets.js 파일에 

import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';

// import.meta.url은 현재 모듈의 URL을 나타내는 문자열
// fileURLToPath는 URL 문자열을 파일 시스템의 경로로 변환

// 현재 파일의 절대 경로. 이 경로는 파일의 이름을 포함한 전체 경로
const __filename = fileURLToPath(import.meta.url);

// path.dirname() 함수는 파일 경로에서 디렉토리 경로만 추출 (파일 이름을 제외한 디렉토리의 전체 경로)
const __dirname = path.dirname(__filename);
const basePath = path.join(__dirname, '../../assets');
let gameAssets = {}; // 전역함수로 선언

const readFileAsync = (filename) => {
  return new Promise((resolve, reject) => {
    fs.readFile(path.join(basePath, filename), 'utf8', (err, data) => {
      if (err) {
        reject(err);
        return;
      }
      resolve(JSON.parse(data));
    });
  });
};

export const loadGameAssets = async () => {
  try {
    const [stages, items, itemUnlocks] = await Promise.all([
      readFileAsync('stage.json'),
      readFileAsync('item.json'),
      readFileAsync('item.unlock.json'),
    ]);
    gameAssets = { stages, items, itemUnlocks };
    return gameAssets;
  } catch (error) {
    throw new Error('Failed to load game assets: ' + error.message);
  }
};

export const getGameAssets = () => {
  return gameAssets;
};

붙여 넣기

 

3) 이전 프로젝트에서는 서버 시동 이후 게임 에셋을 로드 하는 방식이었지만,

이번 프로젝트는 서버 초기화에 여러 작업이 들어가게 될 예정이라 서버 시동 이전에 로드를 하는 것으로 작성하겠습니다.

init/index.js 파일 생성하기

index.js 파일에 

import { loadGameAssets } from './assets.js';

const initServer = async () => {
  try {
    await loadGameAssets();
  } catch (e) {
    console.error(e);
    process.exit(1);
  }
};

export default initServer;

붙여 넣기

 

server.js 파일에서 기존 서버 실행을

server.listen(PORT, () => {
  console.log(`Echo server listening on port ${PORT}`);
  console.log(server.address());
});

 

initServer()함수 실행 후, then()을 통해 실행하기

initServer()
  .then(() => {
    server.listen(PORT, () => {
      console.log(`Echo server listening on port ${PORT}`);
      console.log(server.address());
    });
  })
  .catch((error) => {
    console.error(e);
    process.exit(1);
  });

이때, catch문으로 오류 발생 시 console.error(e)와 process.exit(1)로 

오류 문구 출력 및 프로그램 종료