환경변수 관리
-
기존에 환경변수나 상수들을 각각 파일에서 제어했기에 수정을 한번 하면 여러 곳을 수정해줘야 했다!
-
그 구조를 중앙집중식으로 변경하여 유지보수가 편하도록 하는 방법을 알아보자!
환경변수 관련 폴더 구조
.
├── assets
├── clients
├── README.md
├── .env // 중요한 환경변수(보안!)
└── src // 서버 폴더
├── server.js // 서버 실행 파일
├── config // 환경변수, DB 설정등을 관리
│ └── config.js
├── constants // 상수 관리
│ ├── env.js // env 상수
│ ├── handler.js // handler 관련 상수
│ └── header.js // header 관련 상수
└── events
- 일단 복잡한건 제쳐두고 환경변수 관리에 필요한 config 와 constants들을 확인해보겠다!
constants(상수모음)
-
고정된 값(상수/constant)들을 저장해두어 프로젝트에서 공통으로 사용하는 폴더다.
-
상수들의 역할(주로 사용되는 곳)에 따라 파일들을 나눠 두었다.
-
env.js
-
.env 파일에 저장된 변수들을 dotenv 라이브러리를 이용해 불러오는 곳!
-
값들이 누락되었을 때 디버깅/오류방지를 위해 기본 값들을 설정해준다!
import dotenv from 'dotenv'; // .env 파일 변수 가져오기 dotenv.config(); // || 연산자로 process.env.??? 가 null일 경우 뒤에 있는 기본값이 저장되도록 해준다 /* 서버 관련 정보 */ export const PORT = process.env.PORT || 5050; export const HOST = process.env.HOST || 'localhost'; export const CLIENT_VERSION = process.env.CLIENT_VERSION || '1.0'; /* DB 관련 정보 */ export const DB1_NAME = process.env.DB1_NAME || 'database1'; export const DB1_USER = process.env.DB1_USER || 'user1'; export const DB1_PASSWORD = process.env.DB1_PASSWORD || 'password1'; export const DB1_HOST = process.env.DB1_HOST || 'localhost'; export const DB1_PORT = process.env.DB1_PORT || 3306; export const DB2_NAME = process.env.DB2_NAME || 'database2'; export const DB2_USER = process.env.DB2_USER || 'user2'; export const DB2_PASSWORD = process.env.DB2_PASSWORD || 'password2'; export const DB2_HOST = process.env.DB2_HOST || 'localhost'; export const DB2_PORT = process.env.DB2_PORT || 3306;
-
-
handler.js
-
이전 handler mapping 과 비슷한 역할을 하는 친구다!
-
맵핑되는 숫자는 보안을 위해 난수로 설정해 두었다.
// 요청을 서버에서 잘 처리하였을 경우 응답에 포함될 코드 export const RESPONSE_SUCCESS_CODE = 1; // 기존 handlerMapping과 비슷한 기능이지만, handlers 폴더의 index.js와 기능이 분리되었다! // 핸들러 명칭과 매칭되는 ID(난수) export const HANDLER_IDS = { INITIAL: 52, CREATE_GAME: 462, JOIN_GAME: 325, LOCATION_UPDATE: 6306, }
-
-
header.js
-
Packet 생성 시 필요한 값들을 지정해둔 곳이다!
-
Packet의 종류도 구분해주는 역할!
// 총 길이(정보)를 담을 크기 (Bytes) export const TOTAL_LENGTH = 4; // 패킷 종류(정보)를 담을 크기 (Bytes) export const PACKET_TYPE_LENGTH = 1; // 패킷의 종류와 매칭되는 ID 정보( 0 ~ 255 범위 이내) export const PACKET_TYPE = { PING: 23, NORMAL: 13, GAME_START: 52, LOCATION: 152 }
-
config(설정)
- constants 에서 지정된 값들을 외부 파일에서 용도에 맞게 사용하기 쉽도록 묶어주는 역할이다!
import { CLIENT_VERSION,
DB1_HOST,
DB1_NAME,
DB1_PASSWORD,
DB1_PORT,
DB1_USER,
DB2_HOST,
DB2_NAME,
DB2_PASSWORD,
DB2_PORT,
DB2_USER,
HOST,
PORT
} from "../constants/env.js";
import { PACKET_TYPE_LENGTH, TOTAL_LENGTH } from "../constants/header.js";
export const config = {
server: {
port: PORT,
host: HOST,
},
client: {
version: CLIENT_VERSION,
},
packet: {
totalLength: TOTAL_LENGTH,
typeLength: PACKET_TYPE_LENGTH
},
databases: {
GAME_DB: {
name: DB1_NAME,
user: DB1_USER,
password: DB1_PASSWORD,
host: DB1_HOST,
port: DB1_PORT,
},
USER_DB: {
name: DB2_NAME,
user: DB2_USER,
password: DB2_PASSWORD,
host: DB2_HOST,
port: DB2_PORT,
},
}
}
Protobuf (Protocol Buffers)
- Google에서 개발한 구조화된 데이터를 직렬화(serialization)하기 위한 언어 중립적이고 플랫폼 중립적인 확장 가능한 메커니즘
특징
-
데이터 직렬화
-
구조화된 데이터를 컴팩트하고 효율적인 이진 형식으로 직렬화
-
JSON이나 XML보다 더 작고 빠른 데이터 교환 방식
-
-
언어 독립성
-
C++, Java, Python, Go, JavaScript 등 다양한 프로그래밍 언어 지원
-
한 언어에서 정의한 메시지 스키마를 다른 언어에서도 사용 가능
-
-
성능
-
매우 빠른 직렬화/역직렬화 속도
-
작은 메시지 크기
-
네트워크 통신과 데이터 저장에 최적화
-
직렬화 / 역직렬화
-
직렬화 (Serialization)
-
복잡한 데이터 구조(객체, 클래스 등)를 Byte Stream(연속된 바이트) 형태로 변환하는 과정
-
데이터를 저장하거나 네트워크를 통해 전송할 수 있는 형태로 변환
-
메모리나 디스크에 저장 가능한 형식으로 변환
-
-
역직렬화 (Deserialization)
-
직렬화된 바이트 데이터를 다시 원래의 객체나 데이터 구조로 복원하는 과정
-
저장되거나 전송된 데이터를 다시 프로그램에서 사용 가능한 형태로 복원
-
사용 및 구조 분리
- protobuf 라이브러리를 설치 해준다!
npm install protobufjs
# 또는 yarn
yarn add protobufjs
- protobuf 전용 폴더를 생성하여 용도와 기능에 맞게 파일들을 분리해준다!
Protobuf 관련 폴더 구조
.
├── .env
├── assets
├── README.md
├── client.js // 임시 클라이언트
└── src // 서버 폴더
├── server.js // 서버 실행 파일
├── config
├── constants
├── events
├── init // 서버 초기화
│ └── loadProtos // Proto 파일들을 읽어와 저장 및 관리하는 곳
├── protobuf // 패킷 구조
│ ├── notification // 서버의 전달 패킷 구조
│ │ └── game.notification.proto // game 관련 전달
│ ├── request // 클라이언트 요청 패킷 구조
│ │ ├── common.proto // 기본적인 요청
│ │ ├── game.proto // game 관련 요청
│ │ └── initial.proto // 첫 서버 연결 관련 요청
│ ├── response // 서버 응답의 패킷 구조
│ │ └── response.proto // 기본적인 응답
│ └── packetNames.js // Proto의 Message들을 구별하기 위해 사용
└── utils // 기타 유용한 함수들 모음(공용)
└── parser // 분석에 이용되는 함수모음
└── packetParser.js // packet 분석용 함수
Proto 작성
-
생성한 파일 내부에는 아래와 같은 기본 형식의 코드가 들어가게 된다!
// 프로토콜 버전 선언 (proto3 권장) syntax = "proto3"; // 패키지 선언 (네임스페이스 역할) package myproject; // 메시지 정의 message Person { // 필드 정의 // [타입] [필드명] = [고유 번호]; string name = 1; // 문자열 필드 int32 age = 2; // 정수 필드 bool is_student = 3; // 불리언 필드 }
-
주요 데이터 타입 모음
// 숫자 타입 int32 // 32비트 정수 (Range: -2,147,483,648 ~ 2,147,483,647) int64 // 64비트 정수 uint32 // 32비트 부호 없는 정수 (= 양의 값만 가짐) (Range: 0 ~ 4,294,967,295) uint64 // 64비트 부호 없는 정수 float // 32비트 부동소수점 double // 64비트 부동소수점 // 문자열 타입 string // 문자열 bytes // 바이너리 데이터 // 특수 타입 bool // 불리언
-
고급 필드 옵션
message Advanced { // 필수 필드 지정 (proto3에서는 거의 사용하지 않음) string required_field = 1; // 기본값 지정 int32 age = 2 [default = 0]; // 반복 필드 (배열과 유사) repeated string hobbies = 3; // 중첩 메시지 message Address { string street = 1; string city = 2; } Address home_address = 4; }
Proto 지정
-
Proto를 통해 패킷의 구조들을 설정하였다면 이를 읽어 적용시켜주는 로직이 필요하다!
-
Proto를 읽기 전에 파일 내의 여러 개의 Message들을 구분해주는 정보가 필요하다!
/* packetNames.js */
// Proto 파일(package name)과 그에 속한 Message(패킷 구조)를 역할별로 분리해줌
export const packetNames = {
/*
Proto 파일명(= package(name)): {
Message(name): 'package(name).Message(name)',
}
*/
common: {
Packet: 'common.Packet',
Ping: 'common.Ping',
},
response: {
Response: 'response.Response',
},
initial: {
Packet: 'initial.Packet',
},
game: {
CreateGamePayload: 'game.CreateGamePayload',
JoinGamePayload: 'game.JoinGamePayload',
LocationUpdatePayload: 'game.LocationUpdatePayload',
},
gameNotification: {
Start: 'gameNotification.Start',
LocationUpdate: 'gameNotification.LocationUpdate',
},
}
Proto 불러오기 + 저장
- 위에서 지정해둔 정보를 이용해 폴더구조에서 Proto 파일들을 읽어와 저장해보겠다!
import fs from 'fs';
import path from 'path';
import protobuf from 'protobufjs'
import { fileURLToPath } from 'url';
import { packetNames } from '../protobuf/packetNames.js';
//현재 파일의 절대경로 찾기
const __filename = fileURLToPath(import.meta.url);
//디렉토리 경로(현재 파일위치) 추출
const __dirname = path.dirname(__filename);
// 현재 파일위치 기준으로 protobuf 폴더 찾기
const protoDir = path.join(__dirname, '../protobuf');
// Proto 파일들 전부 불러오기
const getAllProtoFiles = (dir, fileList = []) => {
// 지정된 주소(= protobuf 폴더)를 읽음
const files = fs.readdirSync(dir);
// 폴더 내에 있는 요소들을 전부 확인
files.forEach(file => {
// 요소(파일 또는 폴더)와 현재주소(protobuf 폴더)를 묶은 주소를 저장
const filePath = path.join(dir, file);
// 폴더 내부에 폴더가 있을 경우 재귀적으로 탐색
if (fs.statSync(filePath).isDirectory()) {
fileList = getAllProtoFiles(filePath, fileList);
// 찾은 요소가 .proto 파일인 경우에만 추가 fileList에 추가
} else if (path.extname(file) === '.proto') {
fileList.push(filePath);
}
});
return fileList;
};
// 모든 protobuf 의 proto 파일들을 불러왔음!(파일만 불러옴)
const protoFiles = getAllProtoFiles(protoDir);
// 불러온 Proto 파일들을 Message 별로 분리하는 객체
const protoMessages = {};
export const loadProtos = async () => {
try {
//Protobuf Root 객체 생성
const root = new protobuf.Root();
//생성한 Protobuf Root에 파일들을 전부 로드
await Promise.all(protoFiles.map(async (file) => root.load(file)));
// packetNames에 정의된 패킷 이름으로 Message 타입을 찾아서 저장
for (const [packageName, types] of Object.entries(packetNames)) {
// packageName = proto 파일명 , types = 파일에 포함된 Message들
protoMessages[packageName] = {};
for (const [type, typeName] of Object.entries(types)) {
// Root 객체에서 typeName(='package(name).Message(name)')으로
// 찾은 Message를 protoMessages 객체에 분리하여 저장
protoMessages[packageName][type] = root.lookupType(typeName);
}
}
console.log("Protobuf 파일 로드 성공")
} catch (err) {
console.error('Failed to load Protobuf: ' + err.message);
}
}
export const getProtoMessages = () => {
// 얕은 복사 방지하며 반환
return {...protoMessages};
}
Parser
-
네트워크에서 전송된 패킷의 데이터를 분석하고 필요한 정보를 추출하는 과정!
-
Protobuf를 사용해 값을 직렬화/역직렬화 하는 방법을 알아보겠다!
-
그 전에 server나 client 에서 loadProtos를 통해 Proto 파일들을 불러와 주어야 한다!
/* server 또는 client */
import { loadProtos } from "./src/init/loadProtos.js";
await loadProtos();
직렬화(=값 보내기 전)
- 클라이언트를 기준으로 Packet 을 보낼 때 일어나는 과정을 자세히 설명하였다!
/* client.js */
import { getProtoMessages } from '../../init/loadProtos.js';
// 보낼 패킷 생성
const createPacket = (handlerId, payload, clientVersion = '1.0.0', type, name) => {
const protoMessages = getProtoMessages();
// 인자로 받은 type 과 name을 이용해 Message(구조)를 불러온다
const PayloadType = protoMessages[type][name];
if (!PayloadType) {
throw new Error('PayloadType을 찾을 수 없습니다.');
}
// Message.create() 를 통해 Message 형태로 payload 인스턴스 객체를 생성
const payloadMessage = PayloadType.create(payload);
// Messages.encode() 를 통해 직렬화(=Byte Stream 변환) 진행
// finish() => 직렬화 과정에서 생성된 임시 버퍼를 최종 고정된 버퍼로 변환
const payloadBuffer = PayloadType.encode(payloadMessage).finish();
//protoMessages.common.Packet 형식에 맞춘 packet 반환
return {
handlerId,
userId,
clientVersion,
sequence,
payload: payloadBuffer,
};
};
// Packet 을 Protobuf 형식에 맞춰 바꿔준 후 보내기
const sendPacket = (socket, packet) => {
// Messages(패킷 구조)들 가져오기
const protoMessages = getProtoMessages();
// 구조 중 필요한 기본 요청 형식만 가져오기
const Packet = protoMessages.common.Packet;
if (!Packet) {
console.error('Packet 메시지를 찾을 수 없습니다.');
return;
}
// 여기서 create()를 안써도 되는 이유는
// createPacket()에서 반환되는 값이 이미 protoMessages.common.Packet 형식에 맞춰져 있어서임
// Messages.encode() 를 통해 직렬화(=Byte Stream 변환) 진행
const buffer = Packet.encode(packet).finish();
/* 헤더 생성 영역 */
// 패킷 길이 정보를 포함한 버퍼 생성
const packetLength = Buffer.alloc(TOTAL_LENGTH);
packetLength.writeUInt32BE(buffer.length + TOTAL_LENGTH + PACKET_TYPE_LENGTH, 0);
// 패킷 타입 정보를 포함한 버퍼 생성
const packetType = Buffer.alloc(PACKET_TYPE_LENGTH);
packetType.writeUInt8(1, 0); // NORMAL TYPE
// Header(packetLength + packetType) + Payload(buffer) 합치기
const packetWithLength = Buffer.concat([packetLength, packetType, buffer]);
// 생성한 Packet 을 전송
socket.write(packetWithLength);
};
역직렬화(=값을 받은 후)
- 받아온 값을 역직렬화 해주는 서버의 함수를 이용해 설명하겠다!
/* utils/packetParser.js */
import { getProtoMessages } from '../../init/loadProtos.js';
export const packetParser = (data) => {
// Messages(패킷 구조)들 가져오기
const protoMessages = getProtoMessages();
// 구조 중 필요한 기본 요청 형식만 가져오기
const Packet = protoMessages.common.Packet;
let packet;
// Messages.decode() 를 통해 역직렬화(=Data로 변환) 진행
// 받아온 값이 구조와 알맞지 않은경우를 대비해 try catch 사용
try {
packet = Packet.decode(data);
} catch (error) {
console.error(error);
}
/* 역직렬화한 값을 분리하여 저장 후 반환 */
const handlerId = packet.handlerId;
const userId = packet.userId;
const clientVersion = packet.clientVersion;
const payload = packet.payload;
const sequence = packet.sequence;
console.log('clientVersion:', clientVersion);
return { handlerId, userId, payload, sequence };
};
한줄 평 + 개선점
- 이번 프로젝트는 구조가 복잡해보여 착실히 정리를 해두어야 나중에 트러블 슈팅이나 추가 기능을 구현할 때 편할 것 같다!