턴제 온라인 게임 서버 개발 회고 #Project 2103

프로젝트 소개

모바일에서 플레이하는 실시간 1:1 대전 게임의 서버를 개발했다.

게임에는 티어 기반 매칭, 실시간 대전, 기지 배치, 부품과 코어 수집 및 강화, 상자 오픈 등의 기능이 있었다. 서버에서는 유저 정보나 인벤토리처럼 DB를 주로 사용하는 기능과, 게임 중 계속 데이터를 주고받아야 하는 기능을 나눠서 구현했다.

Node.js를 사용했고 Socket.io 같은 고수준 소켓 라이브러리는 사용하지 않았다. net 모듈로 TCP 서버를 직접 만들었고, 패킷 처리부터 매칭, 인게임 룸, DB 연동, 암호화, 스케줄러까지 필요한 기능을 하나씩 붙여갔다.

처음부터 지금 생각하는 형태로 설계한 것은 아니다. 개발하면서 필요한 기능을 추가하다 보니 구조적인 문제가 하나씩 보였고, 그걸 고치는 과정에서 서버 구조에 대해 많이 배웠다.


서버 구조

전체적으로는 HTTP API와 Socket 통신을 나눴다.

HTTP API에서는 프로필, 상점, 기지 배치, 아이템, 상자 같은 데이터를 처리했다.

로비 서버에서는 소켓 연결을 유지하면서 매칭을 처리했다. 유저의 트로피를 기준으로 티어를 정하고, 같은 티어에서 기다리는 사람이 있으면 바로 매칭했다.

인게임 서버는 실제 대전 중 발생하는 데이터를 처리했다. 이 부분은 worker_threads를 사용해 여러 워커로 나눴다.

Master Server는 게임 플레이와 직접적인 관계가 없는 작업을 맡겼다. 랭킹 집계나 시간에 따라 처리해야 하는 상자 슬롯, 푸시 타이머 같은 기능을 이쪽에서 처리했다.

개발 중에는 관리자용 CLI도 만들었다. DB를 직접 열어서 값을 바꾸는 것보다 터미널에서 명령어 몇 개 치는 게 훨씬 편했기 때문이다.


Raw TCP Socket을 직접 구현

이번 프로젝트에서 가장 많이 부딪힌 부분 중 하나가 TCP 통신이었다.

처음에는 데이터를 보내면 서버에서도 그대로 하나의 데이터로 받을 것이라고 생각하기 쉽다. 실제 TCP는 그렇게 동작하지 않는다.

클라이언트에서 패킷 두 개를 연속으로 보내면 서버에서 두 패킷이 붙어서 들어올 수도 있고, 하나의 패킷이 여러 번 나눠져서 들어올 수도 있다.

그래서 서버에서 패킷의 시작과 끝을 직접 판단해야 했다.

당시에는 구현하기 간단한 문자열 프로토콜을 만들었다.

TOKEN#PACKET_TYPE#DATA~

~가 패킷의 끝이고 #을 필드 구분자로 사용했다.

수신 데이터는 별도의 버퍼에 계속 붙여놓고 완성된 패킷만 꺼내 처리했다.

let receiveDataPacket = "";

client.on("data", (msg) => {
    if (!msg) {
        return;
    }

    receiveDataPacket += msg;

    if (!receiveDataPacket.includes("~")) {
        return;
    }

    const packets = receiveDataPacket.split("~");

    for (let i = 0; i < packets.length; i++) {
        if (i === packets.length - 1) {
            receiveDataPacket = packets[i];
            continue;
        }

        const data = decryptPacket(packets[i]);

        if (!data) {
            continue;
        }

        handlePacket(data);
    }
});

이걸 직접 만들고 나서야 TCP에서 말하는 Packet이 실제 TCP Packet과는 다른 개념이라는 걸 제대로 이해했다.

당시에는 구분자를 사용하는 방식이 가장 빠르게 구현할 수 있어서 이렇게 만들었다. 지금 다시 만든다면 패킷 앞부분에 길이를 넣는 Length Prefix 방식을 사용할 가능성이 높다. 데이터 안에 구분자가 들어가는 문제도 없고, 문자열 split()에 의존할 필요도 없기 때문이다.


Worker Threads로 인게임 서버 분산

인게임 서버에서는 유저들이 계속 Tick 데이터를 주고받는다. 이걸 한 곳에서 전부 처리하는 것보다는 여러 워커로 나누는 게 낫다고 판단했다.

Node.js의 worker_threads를 사용해서 4개의 인게임 서버를 띄웠다.

if (isMainThread) {
    for (let i = 0; i < 4; i++) {
        new Worker("./InGameSocketServer.js", {
            workerData: {
                port: 20000 + i
            }
        });
    }

    return;
}

receiveHandler.setReceiveDictionary();
startSocketServer(workerData.port);

각 워커는 다른 포트를 사용했다.

Worker 0 -> 20000
Worker 1 -> 20001
Worker 2 -> 20002
Worker 3 -> 20003

매칭이 끝나면 룸을 순서대로 워커에 배정했다.

Room 1 -> Worker 0
Room 2 -> Worker 1
Room 3 -> Worker 2
Room 4 -> Worker 3
Room 5 -> Worker 0

처음에는 이 정도면 서버 부하를 분산할 수 있다고 생각했다.

그런데 워커를 나누고 나니 다른 문제가 보였다. 워커끼리는 메모리를 공유하지 않는다. 한 워커의 roomDictionary에 있는 데이터를 다른 워커가 바로 가져올 수 있는 구조가 아니다.

결국 서버를 여러 대로 늘리는 문제까지 생각하면 워커를 나누는 것만으로는 충분하지 않았다. 이때부터 서버 내부 메모리에 상태를 저장하는 방식 자체를 다시 보게 됐다.


티어 기반 Matchmaking

매칭은 트로피를 기준으로 티어를 계산한 뒤 같은 티어에서 대기 중인 유저를 찾는 방식으로 만들었다.

매칭 요청
-> 트로피 조회
-> 티어 확인
-> 같은 티어 대기 유저 확인
-> 있으면 바로 매칭
-> 없으면 대기열 등록
-> 일정 시간 대기
-> 상대가 없으면 AI 매칭

상대가 있으면 바로 룸을 만들고 두 유저에게 룸 정보를 전달했다.

상대가 없으면 5~10초 정도 기다렸다가 AI와 연결했다.

function addMatchMakingUser(
    userId,
    matchingAction,
    aiMatchingAction
) {
    getUserTrophies(userId, (state, data) => {
        if (state !== "success" || !data || data.length === 0) {
            return;
        }

        const trophies = data[0].user_trophies;

        checkUserTier(trophies, (tier) => {
            if (userId in matchingUser[tier]) {
                return;
            }

            const waitingUsers = Object.keys(
                matchingUser[tier]
            );

            if (waitingUsers.length > 0) {
                matchWithUser(
                    userId,
                    waitingUsers[0]
                );
                return;
            }

            matchingUser[tier][userId] =
                matchingAction;

            matchingUser.user[userId] =
                setTimeout(() => {
                    if (isAlreadyInRoom(userId)) {
                        return;
                    }

                    if (!(userId in matchingUser[tier])) {
                        return;
                    }

                    matchWithAI(
                        userId,
                        aiMatchingAction,
                        trophies
                    );
                }, getRandomInt(5000, 10000));
        });
    });
}

여기서도 처음 코드와 지금 코드는 꽤 다르다.

초기에는 if 안에 또 if가 들어가고 그 안에서 Callback을 호출하는 식으로 작성했다. 기능이 많아지면서 코드가 오른쪽으로 계속 밀렸다.

그래서 이후부터는 Guard Clause를 적용했다.

if (state !== "success") {
    return;
}

if (!data || data.length === 0) {
    return;
}

if (userId in matchingUser[tier]) {
    return;
}

먼저 처리할 수 없는 경우를 털어내고 나면 아래에는 실제 매칭 로직만 남는다.

이 방식이 익숙해진 이후에는 서버 코드를 작성할 때도 정상적인 흐름을 안쪽으로 밀어 넣기보다, 잘못된 상태를 먼저 반환하는 쪽을 선호하게 됐다.


패킷 암호화와 키 교환

소켓 통신에는 유저 인증 정보와 게임 데이터가 포함되기 때문에 패킷 암호화와 Token 검증을 적용했다.

전체적인 처리 순서는 다음과 같았다.

Receive
-> Packet Framing
-> Decrypt
-> Parse
-> Token Verify
-> Packet Handler

초기에는 서버와 클라이언트가 공유하는 키를 사용하는 방식으로 처리했다.

그런데 고정된 키를 계속 사용하는 것보다는 세션에서 사용할 키를 따로 만들어 사용하는 게 낫다고 판단했고, Diffie-Hellman 기반의 키 교환 모듈을 따로 만들었다.

Client Private Key
-> Client Public Key

Server Private Key
-> Server Public Key

Public Key 교환
-> Shared Secret 생성
-> Session 통신에 사용

큰 정수 연산에는 big-integer를 사용했다.

이 부분은 구현하면서 암호화 자체보다 키를 어떻게 교환하고 관리하는지가 더 복잡하다는 걸 알게 된 부분이다.

지금 다시 만든다면 직접 키 교환 구조를 만드는 것보다 검증된 TLS나 암호화 라이브러리를 먼저 선택할 것이다. 직접 구현해본 경험은 공부에는 도움이 됐지만 실제 서비스에서는 검증된 구현을 사용하는 편이 안전하다.


확률 기반 보상 시스템

게임의 상자 오픈과 부품 획득에는 확률 테이블을 기반으로 아이템을 선택하는 시스템을 만들었다.

예를 들어 다음과 같은 데이터가 있다면

A = 50
B = 30
C = 20

전체 가중치 100을 만들고 0~99 중 하나를 뽑아서 해당 아이템을 선택한다.

function randomPick(parts) {
    let totalWeight = 0;

    for (const part of parts) {
        totalWeight += part.percent;
    }

    let randomValue =
        getRandomInt(0, totalWeight - 1);

    for (const part of parts) {
        if (randomValue < part.percent) {
            return part;
        }

        randomValue -= part.percent;
    }

    return null;
}

확률을 코드에 직접 적지 않고 DB의 테이블을 기준으로 처리하도록 만들었다.

그래서 기획 단계에서 확률을 수정할 때 코드까지 수정할 필요가 없었다.

이런 시스템은 실제 확률이 제대로 적용되는지도 확인해야 했기 때문에 관리자 CLI에서 확률 테이블을 직접 확인할 수 있게 만들었다.


CLI 관리자 도구

개발하면서 DB를 직접 수정하는 일이 꽤 많았다.

처음에는 SQL을 직접 실행했지만 같은 작업을 계속하다 보니 귀찮아졌다. 그래서 간단한 관리자 CLI를 만들었다.

user
-> core
   -> show
   -> add

element
-> parts
   -> show
   -> add

예를 들면 다음과 같이 사용할 수 있었다.

user core show [user]
element parts add [id] [rank] [percent] [name]

명령어를 Dictionary 형태의 트리로 관리해서 하위 명령을 추가하기 쉽게 만들었다.

function commandHandler(line) {
    const inputData = line.split(" ");

    const command = {
        subCommandDictionary: commandDictionary
    };

    subCommandHandler(
        inputData,
        command,
        0,
        line
    );
}

개발 단계에서 필요한 관리자 기능은 대부분 이런 식으로 추가했다.

웹 페이지를 하나 더 만드는 것보다 터미널에서 명령어를 입력하는 게 빠른 경우가 많았다. 특히 테스트용 아이템 지급이나 유저 데이터 확인 같은 작업에서는 꽤 유용했다.


Callback 지옥과 코드 구조 개선

이 프로젝트에서 코드 구조 측면으로 가장 크게 바뀐 부분이다.

초기에는 이런 코드가 많았다.

GetUserData(userId, (state, data) => {
    if (state === "success") {
        if (data[0].money >= price) {
            GiveItem(userId, itemId, () => {
                UpdateUserData(userId, money, () => {
                    next("success");
                });
            });
        }
    }
});

처음에는 별 문제가 없어 보였다.

그런데 여기에 아이템 종류가 늘어나고, 추가 검증이 들어가고, 실패 처리까지 붙으면서 Callback이 계속 안쪽으로 들어갔다.

나중에는 코드를 읽으면서 실제 구매 처리가 어디서 시작되고 끝나는지 찾는 것 자체가 일이 됐다.

그래서 Guard Clause를 사용해서 조건을 위에서 먼저 처리했다.

async function purchaseItem(
    userId,
    itemId,
    itemAmount,
    productPrice,
    purchaseType
) {
    const costField =
        purchaseType === 0
            ? "user_money"
            : "user_credit";

    const user = await getUserData(userId);

    if (!user) {
        throw new Error("USER_NOT_FOUND");
    }

    if (user[costField] < productPrice) {
        throw new Error("INSUFFICIENT_FUNDS");
    }

    const remainingBalance =
        user[costField] - productPrice;

    await updateUserData(
        userId,
        costField,
        remainingBalance
    );

    if (itemId <= 0) {
        await applySpecialCurrency(
            userId,
            itemId,
            itemAmount
        );

        return {
            success: true,
            remainingBalance
        };
    }

    await giveItemToUser(
        userId,
        itemId,
        itemAmount
    );

    return {
        success: true,
        remainingBalance
    };
}

이렇게 바꾸고 나니 정상적인 처리 흐름이 상당히 단순해졌다.

이때부터 코드를 작성할 때 조건을 무작정 안쪽으로 넣는 것보다, 실패할 수 있는 조건을 먼저 확인하고 빠져나오는 방식을 많이 사용하게 됐다.


DB 정합성 문제

상점 구매 기능을 만들면서 DB 처리도 다시 생각하게 됐다.

상점에서 아이템을 구매하면 결국 두 가지 작업이 필요하다.

재화 차감
+
아이템 지급

그런데 재화만 차감되고 아이템 지급이 실패하면 문제가 생긴다.

반대로 아이템은 지급됐는데 재화 차감이 실패해도 문제가 생긴다.

그래서 이런 작업은 하나의 트랜잭션으로 처리해야 한다.

BEGIN
-> User Lock
-> Balance Check
-> Balance Update
-> Item Update
-> COMMIT

실패
-> ROLLBACK

현재 다시 구현한다면 SELECT ... FOR UPDATE를 사용해 해당 유저의 데이터를 잠그고, 재화 차감과 아이템 지급을 하나의 Transaction으로 처리할 것이다.

서버에서 API가 정상적으로 응답했다고 해서 데이터가 안전하게 처리된 것은 아니다. 여러 요청이 동시에 들어왔을 때 어떤 상태가 되는지도 봐야 한다는 것을 이 과정에서 제대로 알게 됐다.


In-Memory 상태 관리의 한계

당시에는 유저와 룸 상태를 JavaScript Object에 저장했다.

gameDataManager.userDictionary[userId];
gameDataManager.roomDictionary[roomId];
matchingUser[tier];

개발할 때는 굉장히 편했다.

접근하기도 쉽고 별도의 저장소도 필요 없었다.

문제는 서버가 죽으면 전부 사라진다는 것이다.

그리고 서버를 여러 개 띄우는 순간 더 문제가 커진다.

Server A
-> User A
-> Room A

Server B
-> User B
-> Room B

Server A에 있는 유저의 상태를 Server B가 알아야 하는 상황에서는 이런 구조로는 해결하기 어렵다.

다시 설계한다면 프로세스 내부에는 소켓 연결처럼 해당 프로세스에서만 필요한 상태를 두고, 여러 서버에서 공유해야 하는 상태는 Redis 같은 외부 저장소로 분리할 것이다.

예를 들어

Game Server
-> Connection
-> Local Runtime State

Redis
-> Matchmaking Queue
-> Session
-> Room Metadata

MySQL
-> User Data
-> Inventory
-> Game Data

정도로 역할을 나누는 구조를 생각할 수 있다.


SQL Injection 대응을 다시 본다면

당시에는 SQL Injection을 막기 위해 Query 문자열에서 특정 SQL 키워드를 제거하는 코드도 있었다.

const expData = [
    "SELECT",
    "INSERT",
    "DELETE",
    "UPDATE",
    "DROP",
    "UNION"
];

지금 보면 잘못된 방식이다.

심지어 당시 코드에는 replace()의 반환값을 다시 대입하지 않아 실제 문자열이 변경되지 않는 문제도 있었다.

query.replace(data, "");

하지만 이것보다 더 큰 문제는 SQL을 문자열 필터링으로 방어하려고 했다는 점이다.

현재라면 Prepared Statement를 사용한다.

await connection.query(
    "SELECT * FROM user_data WHERE user_id = ?",
    [userId]
);

이 경험 이후에는 보안 문제를 별도의 필터 함수 하나로 해결하려는 방식보다, 애초에 안전한 인터페이스를 사용하도록 만드는 쪽을 우선적으로 생각하게 됐다.


다시 만든다면 바꿀 부분

지금 같은 게임 서버를 다시 만든다면 당시 구조를 그대로 사용하지는 않을 것이다.

가장 먼저 바꿀 부분은 상태 관리다.

In-Memory Object
-> Redis

형태로 서버 간 공유 상태를 분리할 것이다.

DB 접근도 Transaction을 기본으로 생각하고, 모든 SQL은 Parameter Binding을 사용하는 방향으로 구성할 것이다.

패킷은 문자열 기반 프로토콜 대신 길이 기반 프레이밍을 사용하고, 실시간 데이터의 양이 많아진다면 Protobuf나 FlatBuffers 같은 바이너리 직렬화도 검토할 것이다.

전송 계층 역시 모든 데이터를 TCP로 처리하기보다 데이터 성격에 따라 나눌 것이다.

예를 들어 구매나 로그인처럼 반드시 전달되어야 하는 데이터와, 최신 상태가 중요해서 이전 데이터가 늦게 도착하는 것이 의미가 없는 게임 상태 데이터는 같은 방식으로 처리할 필요가 없다.


마치며

이 프로젝트는 처음부터 대규모 게임 서버를 목표로 설계한 프로젝트는 아니다.

일단 필요한 기능을 하나씩 구현했고, 개발하면서 문제가 생기면 그때 구조를 고치는 식으로 진행했다.

그래서 지금 보면 아쉬운 부분도 많다.

In-Memory 상태 관리도 그렇고, 문자열 기반 패킷도 그렇고, 초기의 SQL 필터링 방식이나 깊게 중첩된 Callback 코드도 다시 보면 바꾸고 싶은 부분이 명확하다.

하지만 직접 만들어봤기 때문에 왜 문제가 되는지는 알고 있다.

TCP의 패킷 경계를 직접 처리해봤고, 여러 워커로 서버를 나눠봤고, 매칭 큐를 직접 만들어봤다. DB에서 데이터가 꼬일 수 있는 상황도 생각해봤고, 서버 프로세스가 가지고 있는 상태를 여러 서버에서 공유해야 하는 문제도 겪었다.

이런 경험을 통해 서버를 설계할 때 단순히 “어떤 기술을 사용할까”보다 먼저 현재 게임에서 어떤 데이터가 실시간이고, 어떤 데이터가 영속적이며, 어떤 상태를 여러 서버가 공유해야 하는지를 구분하게 됐다.

지금 다시 설계한다면 Redis를 이용한 상태 분리, Transaction과 Row Lock을 이용한 데이터 정합성 보장, Prepared Statement, 더 명확한 Packet Protocol, 서버 확장을 고려한 구조를 처음부터 가져갈 것이다.

이 프로젝트의 의미는 완성된 아키텍처를 만들었다는 데 있지 않다.

직접 서버를 만들면서 구조적인 문제를 만나봤고, 그 문제를 해결하면서 다음 프로젝트에서 무엇을 먼저 고려해야 하는지 기준을 만들었다는 데 있다.