CustomError

  • 현재 에러처리는 어느 상황이건 스택들과 오류 정보를 직접적으로 보여주고 있다!

  • 이런 경우 에러가 발생한 곳은 알 수 있어도 이유를 못찾는 경우가 허다하다..

  • 이에 특정 구간에 전용 에러를 만들어 관련 정보를 같이보여주면 디버깅하기 편하다!

  • 또한 에러가 일어날 경우, 에러에 따라 다르게 작동하는 로직을 구현하기 쉽다

Error 폴더 구조

.
├── assets                  
├── clients                 
├── README.md               
├── .env                    
└── src                         
    ├── server.js                    
    ├── config              
    ├── constants           
    ├── init                
    ├── protobuf           
    ├── utils                   // 기타 유용한 함수들 모음(공용)
    │   └── error                 // 클라이언트 요청 패킷 구조
    │       ├── customError.js      // CustomError 클래스
    │       ├── errorCodes.js       // 에러코드(분류) 상수 값
    │       └── errorHandler.js     // 에러들을 일괄적으로 처리해주는 핸들러  
    └── events                  // socket 이벤트 분리
        └── onError.js            // 소켓 통신 중 에러 발생    

CustomError 클래스

  • 예상된 에러에 필요한 정보들을 기입해주기 위해선 전용 class를 만들어 이용하는 방법이 있다!
//Error class를 상속하는 CustomError class 생성
class CustomError extends Error {
  constructor(code, message) {
    // 부모 생성자를 명시적으로 호출 = Error class의 기본값을 다 가져옴
    super(message);
    // 예상된 Error_Code(숫자) 를 받아 저장
    this.code = code;
    // 자신이 CustomError 라는 정보를 알려주기 위해 저장
    this.name = 'CustomError';
  }
}

export default CustomError;

ErrorCodes

  • CustomError 에 추가적으로 저장할 정보로 Error Code를 설정해주겠다!
// 예상되는 에러들을 매핑해둠
export const ErrorCodes = {
    SOCKET_ERROR: 10000,
    CLIENT_VERSION_MISMATCH: 10001,
    UNKNOWN_HANDLER_ID: 10002,
    PACKET_DECODE_ERROR: 10003,
    PACKET_STRUCTURE_MISMATCH: 10004,
    MISSING_FIELDS: 10005,
    USER_NOT_FOUND: 10006,
    INVALID_PACKET: 10007,
    INVALID_SEQUENCE: 10008,
    GAME_NOT_FOUND: 10009
}

ErrorHandler

  • 이제 CustomError를 이용해 던질 때 이를 처리해주는 핸들러를 만들어준다!
import { createResponse } from "../response/createResponse.js";
import { ErrorCodes } from "./errorCodes.js";

export const handlerError = (error, socket) => {
    console.error(error);

    let responseCode;
    let message;

    // 에러코드가 있을 때 == CustomError 일 때
    if (error.code) {
        // 에러코드를 응답할 때 주는 코드에 저장
        responseCode = error.code;
        // 에러메시지를 응답할 때 주는 정보에 저장
        message = error.message;
        // 콘솔에 에러 코드와 이유를 확인
        console.log(`responseCode: ${responseCode}, message: ${message}`);
    // 일반 Error 일 때
    } else {
        // 예상 범위 외의 에러이기에 SOCKET_ERROR 로 고정
        responseCode = ErrorCodes.SOCKET_ERROR;
        message = error.message;
        // 콘솔에 예상 범위 외의 에러를 표시
        console.log(`new Error - message: ${message}`);
    }

    // 저장해둔 에러값들을 클라이언트에게 반환
    const errorResponse = createResponse(-1, responseCode, {message}, null);
    socket.write(errorResponse);
}

적용

  • onError 처럼 Error가 일어날 수 있는 곳에 handleError와 CustomError 를 배치해준다!
/* onError.js */
import { removeUser } from "../session/user.session.js";
import CustomError from "../utils/error/customError.js";
import { handlerError } from "../utils/error/errorHandler.js"

export const onError = (socket) => (err) => {
    
    handlerError(new CustomError(500, `Socket Error : ${err}`), socket);
    
    // 세션에서 유저 삭제
    removeUser(socket);
}

/* 예시 */
import CustomError from "../utils/error/customError.js";
import { ErrorCodes } from "../utils/error/errorCodes.js"
import { handlerError } from "../utils/error/errorHandler.js"

try {
  /*검증 과정*/
  if(!value) throw new CustomError(ErrorCodes.GAME_NOT_FOUND,"게임을 찾지 못했습니다!")
} catch(e) {
  handleError(e)
}

DB 연동

  • 지금까지 SQL을 이용해 DB를 불러올 때 Prisma를 주로 사용하였다!

  • 이번엔 Prisma 라이브러리 대신 직접 사용하기 위해 Connection Pool을 직접 구현해주도록 한다!

  • 참고 포스트

DB 폴더 구조

.
├── .env                    
├── assets                   
├── README.md               
├── client.js                
└── src                      
    ├── server.js               
    ├── config                  // 환경변수, DB 설정등을 관리
    ├── constants               
    ├── events              
    ├── protobuf         
    ├── utils                   // 기타 유용한 함수들 모음(공용)
    │   └── db                    // db 관련 함수
    │       └── testConnection.js   // 연결상태 확인
    ├── init                    // 초기 설정 관련 폴더
    │   ├── assets.js               // assets 불러오기
    │   ├── loadProtos.js           // proto 불러오기
    │   └── index.js                // 초기 설정을 총괄   
    └── db                      // db 관련 폴더
        ├── migration             // db 기초설정(Migration)용 폴더
        │   └── createSchemas.js    // DB의 Schema(형식) 지정
        ├── sql                   // SQL Migration 용 쿼리문
        │   └── user_db.sql         // user_db의 table 생성/변경 쿼리
        ├── user                  // user_db 관련 폴더 
        │   ├── user.db.js          // user_db에 사용할 함수 모음
        │   └── user.queries.js     // 함수에 사용될 쿼리들 저장
        └── database.js           // Connection Pool 관리

DB Connection Pool 구현

  • mysql2 라이브러리를 이용하여 Connection Pool 을 만들어 DB관련 I/O를 관리해준다!
/* database.js */
import { config } from "../config/config.js";
import { formatDate } from "../utils/dateFormatter.js";
import mysql from 'mysql2/promise'

// 환경변수 모음집 config 에서 db 관련 값 가져오기
const { databases } = config;

// Connection Pool 생성 함수
const createPool = (dbConfig) => {
    // mysql2 라이브러리의 createPool() 메서드 사용
    const pool = mysql.createPool({
        // 호스트 주소 
        host: dbConfig.host,
        // 포트 번호 
        port: dbConfig.port,
        // 접속 사용자 이름
        user: dbConfig.user,
        // 비밀번호
        password: dbConfig.password,
        // DB 이름 
        database: dbConfig.name,
        // 연결 대기 동작 설정 (true = 대기가능, false = 바로 오류 반환)
        waitForConnections: true,
        // 커넥션 풀에서 최대 연결 수
        connectionLimit: 10, 
        // 연결 대기열 제한 (0일 경우 대기열 수가 무제한)
        queueLimit: 0, 
    })   
    
    // 원본 query 메서드 백업
    const originQuery = pool.query;

    // poo.query 오버라이드
    pool.query = (sql, params) => {
        // 실행된 날짜 확인
        const date = new Date();

        // 확인용 로그 띄우기
        console.log(`${formatDate(date)} / ${sql} / ${params && JSON.stringify(params)}`)

        // 백업된 기존의 query 메서드를 사용하여 query 진행 
        return originQuery.call(pool, sql, params);
    }

    //생성한 Connection Pool 반환
    return pool;
}

const pools = {
    // 사용할 DB의 Connection Pool 생성 및 저장
    GAME_DB: createPool(databases.GAME_DB),
    USER_DB: createPool(databases.USER_DB),
}

export default pools;

DB 연결 확인

  • 일단 시스템적으로 DB에 연결된 Connection Pool을 만들어 관리도록 만들었다!

  • 하지만.. DB나 Connection Pool이 제대로 작동하는지 먼저 확인할 방법이 없다!

  • 그렇기에 서버가 시작할 때 확인해주는 로직이 필요하다!

/* testConnection.js */
const testDbConnection = async (pool, dbName) => {
    try {
        // DB가 연결되어 1 + 1 이라는 연산이 가능한지 테스트
        const [rows] = await pool.query('SELECT 1 + 1 AS solution');
        console.log(`${dbName} 테스트 결과 ${rows[0].solution}`)
    } catch (e) {
        // 만약 연결이 실패한다면 에러가 발동되기에 try catch 사용
        console.error(dbName, "연결 실패",e)
    }
}

// 각 DB들을 모두 순회하는 함수
const testAllConnections = async (pools) => {
    await testDbConnection(pools.GAME_DB, 'GAME_DB');
    await testDbConnection(pools.USER_DB, 'USER_DB');
}

export { testAllConnections, testDbConnection };
  • 이제 이를 서버 시작 때 실행할 수 있도록 init 폴더에 있는 index.js에 연결해준다!
/* init/index.js */
import pools from "../db/database.js";
import { testAllConnections } from "../utils/db/testConnection.js";
import { loadGameAssets } from "./assets.js";
import { loadProtos } from "./loadProtos.js";

const initServer = async () => {
    try {
        // 게임 에셋 로드
        await loadGameAssets();
        // Proto 파일 로드 
        await loadProtos();
        // 모든 DB 연결 확인 
        await testAllConnections(pools);
    } catch (err) {
        console.error(err);
        // 오류 발생 시 시스템 강제종료
        process.exit(1);
    }
}

export default initServer;
  • initServer를 server에 매핑해준다
import net from 'net';
import initServer from './init/index.js';
import { config } from './config/config.js';
import { onConnection } from './events/onConnection.js';

const server = net.createServer(onConnection);

// 초기 서버 설정 완료 후 서버 listen(오픈)
initServer().then(() => {
    server.listen(config.server.port, config.server.host, () => {
        console.log(server.address());
    });
// 만약 과정 중 오류가 나오면 강제종료
}).catch((err) => {
    console.error(err);
    process.exit(1);
})

쿼리 실행

  • 이제 DB와 연결 테스트 및 DB를 사용할 수 있는 환경을 만들었다!

  • 그럼 이 DB에 쿼리를 날려 원하는 정보를 얻거나 수정할 수 있는 쿼리문과 함수를 지정해준다!

user.queries.js(쿼리문 모음)

  • 일단 DB를 통해 작업을 할 때, 용도에 맞는 쿼리문을 작성해준다!
export const SQL_QUERIES = {
    // connection.query(sql, [params]) 형식으로 사용할 때 ? 부분이 params가 들어감
    // 유저 찾기 쿼리
    FIND_USER_BY_ID: 'SELECT * FROM users WHERE device_id = ?',
    // 유저 추가 쿼리
    CREATE_USER: 'INSERT INTO users (id, device_id) VALUES (?, ?)',
    // 유저 로그인 업데이트 쿼리
    UPDATE_USER_LOGIN: 'UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?',
}

user.db.js(함수 모음)

  • 이제 만든 쿼리들을 이용해 함수에 매핑해준다!
import { toCamelCase } from "../../utils/transformCase.js";
import pools from "../database.js";
import { SQL_QUERIES } from "./user.queries.js";
import { v4 as uuidv4 } from 'uuid';

// 유저 찾기 함수
export const findUserByDeviceId = async (deviceId) => {
    // connection.query()와 만들어둔 쿼리문을 이용해 유저를 찾음
    const [rows] = await pools.USER_DB.query(SQL_QUERIES.FIND_USER_BY_ID, [deviceId]);
    // 기본 Snake_Case를 CamelCase로 변환 해줌 (utils 에 포함)
    return toCamelCase(rows[0]);
}

// 유저 생성 함수
export const createUser = async (deviceId) => {
    // uuid 라이브러리를 이용해 uuid생성
    const id = uuidv4();
    // uuid를 이용해 user_db에 유저 생성
    await pools.USER_DB.query(SQL_QUERIES.CREATE_USER, [id, deviceId]);
    // 생성된 값 반환
    return {id, deviceId};
}

// 유저 로그인 업데이트 함수
export const updateUserLogin = async (id) => {
    // 유저의 최근 접속시간을 수정
    await pools.USER_DB.query(SQL_QUERIES.UPDATE_USER_LOGIN, [id]);
}

DB Migration

  • 어느정도 DB를 이용하는 로직을 구현하였다!

  • 하지만 만약 DB에 테이블이 없거나 관련 컬럼이 없다면..?

  • 물론 로직은 제대로된 동작이 안될 것 이다..

  • 그렇기에 이런 테이블을 초기화 및 스키마 변경을 해주는 작업! Migration(이사) 작업이 필요하다

쿼리 작성

  • 위에서 사용한 방법처럼 쿼리문을 미리 만들어 두고 이를 사용하는 방식으로 진행한다!
-- user_db.sql

-- users 테이블이 없을 경우 초기화 설정 
CREATE TABLE IF NOT EXISTS users 
(
    id VARCHAR(36) PRIMARY KEY, 
    device_id VARCHAR(255) NOT NULL UNIQUE,
    last_login TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- game_end 테이블이 없을 경우 초기화 설정 
CREATE TABLE IF NOT EXISTS game_end
(
    id VARCHAR(36) PRIMARY KEY, 
    user_id VARCHAR(36) NOT NULL,
    start_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    end_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    score INT DEFAULT 0,
    FOREIGN KEY (user_id) REFERENCES users(id)
);

작동 함수

  • 만든 쿼리를 실행시켜주는 파일을 만들어준다!

  • 이 파일을 나중에 DB 초기화 설정 때 사용해주면 된다!

/* createSchemas.js */
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import pools from '../database.js';

//현재 파일의 절대경로 찾기
const __filename = fileURLToPath(import.meta.url);
//디렉토리 경로(현재 파일위치) 추출
const __dirname = path.dirname(__filename);

// sql file 을 읽어 변환 해주는 함수
const executeSqlFile = async (pools, filePath) => {
    // 받아온 인자 filePath 에 있는 파일 읽기
    const sql = fs.readFileSync(filePath, 'utf8');
    // 파일에서 split(';')을 통해 쿼리문을 분리
    const queries = sql.split(';')
    // 분리된 쿼리문에서 양끝의 공백을 제거
    .map((query) => query.trim())
    // 유효한 쿼리문만 사용하도록 길이 확인
    .filter(query => query.length > 0);

    for (const query of queries) {
        // DB에 쿼리 적용
        await pools.query(query);
    }
}

// 테이블 형식 생성 
const createSchemas = async () => {
    // sql 폴더 주소 추출 
    const sqlDir = path.join(__dirname, '../sql');
    try {
        // 위의 함수를 통해 테이블 생성 쿼리문 적용
        await executeSqlFile(pools.USER_DB, path.join(sqlDir, 'user_db.sql'))
    } catch(e) {
        console.error("데이터 베이스 생성 오류",e)
    }
}

// 테이블 형식 생성 함수 호출
createSchemas().then(() => {
    // 실행이 잘되었으면 출력이후 안정적 종료
    console.log("데이터 베이스 생성 완료")
    process.exit(0);
}).catch((err) => {
    // 오류 발생 시 출력이후 강제 종료
    console.error(err);
    process.exit(1);
})

한줄 평 + 개선점

  • 생각보다 정리할 양이 많아 내일로 미뤄야할 정도였다..

  • 어제는 편하게 강의만 보면서 따라했기에, 이를 정리하는 오늘이 그만큼 힘들게 바뀌었다..

  • 다음엔 대략적으로나마 정리할 구성 요소들을 생각하며 강의를 시청해야겠다!