Node.js MySQL Connection Pool과 Transaction 처리 구조

온라인 게임 서버에서 DB를 다룰 때 단순히 SQL을 실행하는 것만으로 끝나는 경우는 생각보다 많지 않다.

플레이어의 재화를 차감하고 아이템을 지급하거나, 우편을 삭제하면서 보상을 지급하는 것처럼 하나의 게임 로직이 여러 개의 SQL로 구성되는 경우가 많다.

이런 작업에서 중요한 것은 각각의 SQL이 정상적으로 실행되는 것이 아니라 하나의 작업 단위로 모두 성공하거나 모두 실패해야 한다는 것이다.

그래서 DB 접근 코드를 처음 구성할 때부터 일반 쿼리와 트랜잭션 쿼리를 분리했다.

전체적인 구조는 다음과 같다.

MySQL Connection Pool
-> 일반 Query / Transaction Query
-> Connection 획득
-> SQL 실행
-> Connection 반환

트랜잭션은 여기에 Commit과 Rollback이 추가된다.

Connection 획득
-> Begin Transaction
-> Query 1
-> Query 2
-> Query 3
-> Commit
-> Connection 반환

중간에 문제가 발생하면 다음과 같이 처리한다.

Connection 획득
-> Begin Transaction
-> Query 1
-> Query 2 실패
-> Rollback
-> Connection 반환

Connection Pool을 사용하는 이유

DB Connection을 요청할 때마다 새로 생성하는 방식은 서버 구조에서 효율적이지 않다.

온라인 게임 서버에서는 동시에 여러 요청이 들어올 수 있기 때문에 Connection을 미리 Pool 형태로 관리하고 필요할 때 하나를 가져오는 방식을 사용했다.

import * as mysql from "mysql";
import { ErrorLog, Log } from "../../util-module/time-log-module/time-log";
import * as config from "../../../config/mysql-config";

let mysqlConnectionPool: mysql.Pool;

export function GenerateMysqlConnectPool() {
    try {
        mysqlConnectionPool = mysql.createPool(
            config.DBConnectConfig
        );

        Log(__filename, "success created db pool");
    }
    catch (err: NodeJS.ErrnoException | any) {
        ErrorLog(__filename, err.message);
    }
}

서버 시작 과정에서 Pool을 생성해두고 실제 DB 작업이 필요할 때 Connection을 하나 가져온다.

mysqlConnectionPool.getConnection((err, conn) => {
    // DB 작업
});

작업이 끝나면 release()를 호출한다.

conn.release();

여기서 release()는 Connection을 종료하는 것이 아니다.

현재 사용한 Connection을 Pool에 반환하는 것이다.

따라서 다음 요청에서는 Pool에 반환되어 있는 Connection을 다시 사용할 수 있다.

일반 Query 처리

단일 SQL을 실행하는 경우에는 별도의 트랜잭션을 만들 필요가 없다.

그래서 일반적인 DB 접근은 별도의 함수로 분리했다.

export function Query(
    query: string,
    options: any
): Promise<any> {
    return new Promise<any>((resolve, reject) => {

        mysqlConnectionPool.getConnection((err, conn) => {

            if (err) {
                ErrorLog(__filename, err.message);
                return reject(err);
            }

            conn.query(
                query,
                options,
                (err: mysql.MysqlError | null, results: any) => {

                    conn.release();

                    if (err) {
                        ErrorLog(__filename, err.message);
                        return reject(err);
                    }

                    resolve(results);
                }
            );
        });
    });
}

사용하는 쪽에서는 async / await로 처리할 수 있다.

const result = await Query(
    `
        SELECT *
        FROM player_tb
        WHERE player_code = ?
    `,
    [playerCode]
);

DB 라이브러리 자체는 Callback 기반이지만 Promise로 감싸두었기 때문에 서버의 다른 로직에서는 비동기 흐름을 일관되게 유지할 수 있다.

왜 Transaction이 필요한가

문제는 하나의 게임 기능이 여러 SQL을 필요로 할 때 발생한다.

예를 들어 아이템 구매를 생각해보면 된다.

플레이어 골드 차감
+
인벤토리 아이템 지급

SQL로 보면 각각 별도의 작업이다.

UPDATE player_tb
SET gold = gold - ?
WHERE player_code = ?;
INSERT INTO inventory_tb
(
    player_code,
    item_code,
    amount
)
VALUES (?, ?, ?);

정상적인 상황에서는 문제가 없다.

하지만 첫 번째 SQL이 성공하고 두 번째 SQL이 실패한다면 문제가 생긴다.

골드 차감 성공
-> 아이템 지급 실패

결과적으로 플레이어의 골드는 감소했는데 아이템은 지급되지 않는다.

이런 데이터 불일치를 막기 위해 두 SQL을 하나의 Transaction으로 묶는다.

Transaction Start
-> 골드 차감
-> 아이템 지급
-> Commit

중간에 하나라도 실패하면 전체를 취소한다.

Transaction Start
-> 골드 차감
-> 아이템 지급 실패
-> Rollback

TransactionQuery 구현

기존 일반 Query와 별도로 여러 SQL을 하나의 Transaction으로 처리하는 함수를 구성했다.

export function TransactionQuery(
    queries: {
        sql: string;
        options: any;
    }[]
): Promise<any[]> {

    return new Promise<any[]>((resolve, reject) => {

        mysqlConnectionPool.getConnection((err, conn) => {

            if (err) {
                ErrorLog(__filename, err.message);
                return reject(err);
            }

            conn.beginTransaction((err) => {

                if (err) {
                    conn.release();
                    return reject(err);
                }

                executeQueries();

                async function executeQueries() {

                    try {

                        const results: any[] = [];

                        for (const query of queries) {

                            const result = await new Promise<any>(
                                (resolveQuery, rejectQuery) => {

                                    conn.query(
                                        query.sql,
                                        query.options,
                                        (
                                            queryErr,
                                            queryResult
                                        ) => {

                                            if (queryErr) {
                                                return rejectQuery(queryErr);
                                            }

                                            resolveQuery(queryResult);
                                        }
                                    );
                                }
                            );

                            results.push(result);
                        }

                        conn.commit((commitErr) => {

                            if (commitErr) {

                                conn.rollback(() => {
                                    conn.release();
                                    reject(commitErr);
                                });

                                return;
                            }

                            conn.release();
                            resolve(results);
                        });

                    }
                    catch (executeErr: any) {

                        conn.rollback(() => {

                            ErrorLog(
                                __filename,
                                `Transaction Failed: ${executeErr.message}`
                            );

                            conn.release();
                            reject(executeErr);
                        });
                    }
                }
            });
        });
    });
}

핵심은 SQL 배열을 전달받고 동일한 Connection에서 순서대로 실행한다는 것이다.

예를 들어 아이템 구매라면 다음과 같이 사용할 수 있다.

await TransactionQuery([
    {
        sql: `
            UPDATE player_tb
            SET gold = gold - ?
            WHERE player_code = ?
        `,
        options: [price, playerCode]
    },

    {
        sql: `
            INSERT INTO inventory_tb
            (
                player_code,
                item_code,
                amount
            )
            VALUES (?, ?, ?)
        `,
        options: [
            playerCode,
            itemCode,
            amount
        ]
    }
]);

서버에서는 이것을 하나의 DB 작업으로 취급할 수 있다.

Transaction에서 같은 Connection을 사용하는 이유

이 부분은 구현할 때 특히 중요하다.

Transaction은 Connection에 종속된다.

따라서 다음과 같은 구조가 되어야 한다.

Pool
-> Connection A 획득
-> Begin Transaction
-> Query 1
-> Query 2
-> Query 3
-> Commit
-> Connection A 반환

각 Query마다 새로운 Connection을 가져오면 안 된다.

예를 들어 다음과 같은 방식은 Transaction이라는 목적을 달성할 수 없다.

Query 1 -> Connection A
Query 2 -> Connection B
Query 3 -> Connection C

반드시 하나의 Connection을 잡아놓고 그 Connection에서 Transaction에 포함된 모든 SQL을 실행해야 한다.

그래서 일반 Query()TransactionQuery()의 구조가 조금 다르다.

일반 Query는 하나의 SQL을 처리하고 바로 Connection을 반환한다.

Connection 획득
-> Query
-> Connection 반환

Transaction은 여러 SQL을 처리한 후 Commit 또는 Rollback까지 끝내고 반환한다.

Connection 획득
-> Begin
-> Query
-> Query
-> Query
-> Commit / Rollback
-> Connection 반환

Query를 순차적으로 실행하는 이유

TransactionQuery에서는 for ... ofawait를 사용했다.

for (const query of queries) {

    const result = await new Promise<any>(
        (resolveQuery, rejectQuery) => {

            conn.query(
                query.sql,
                query.options,
                (queryErr, queryResult) => {

                    if (queryErr) {
                        return rejectQuery(queryErr);
                    }

                    resolveQuery(queryResult);
                }
            );
        }
    );

    results.push(result);
}

이렇게 하면 전달된 SQL 순서를 그대로 유지할 수 있다.

예를 들어 다음과 같은 작업이 있다고 하자.

재화 차감
-> 아이템 지급
-> 퀘스트 진행도 갱신

첫 번째 쿼리가 끝난 다음 두 번째 쿼리를 실행하고, 두 번째가 끝난 다음 세 번째를 실행한다.

그리고 마지막 쿼리까지 성공한 뒤에야 Commit한다.

중간에 하나라도 실패하면 Promise가 Reject되고 catch로 넘어간다.

Rollback 처리

실행 과정에서 오류가 발생하면 다음 코드가 실행된다.

catch (executeErr: any) {

    conn.rollback(() => {

        ErrorLog(
            __filename,
            `Transaction Failed: ${executeErr.message}`
        );

        conn.release();
        reject(executeErr);
    });
}

Rollback 이후에는 Connection을 반드시 Pool에 반환한다.

conn.release();

이 부분을 빠뜨리면 Connection이 Pool로 돌아가지 않기 때문에 장시간 서버를 운영했을 때 Connection 부족 문제가 발생할 수 있다.

따라서 Transaction 처리에서 중요한 것은 단순히 rollback()을 호출하는 것이 아니다.

성공하든 실패하든 Connection의 생명주기를 정상적으로 끝내야 한다.

Commit 실패도 처리해야 한다

모든 Query가 성공했다고 해서 무조건 DB 작업이 끝난 것은 아니다.

마지막에는 Commit을 수행한다.

conn.commit((commitErr) => {

    if (commitErr) {

        conn.rollback(() => {
            conn.release();
            reject(commitErr);
        });

        return;
    }

    conn.release();
    resolve(results);
});

Commit 과정에서도 오류가 발생할 수 있기 때문에 별도로 처리했다.

정상적인 경우에는 다음과 같다.

Query 1 성공
-> Query 2 성공
-> Query 3 성공
-> Commit 성공
-> Connection 반환

실패하면 다음과 같다.

Query 1 성공
-> Query 2 성공
-> Query 3 성공
-> Commit 실패
-> Rollback
-> Connection 반환

실제 게임 서버에서 Transaction이 필요한 작업

게임 서버에서는 Transaction을 사용할 만한 작업이 꽤 많다.

대표적인 예가 아이템 구매다.

골드 차감
-> 아이템 지급

아이템 강화도 마찬가지다.

강화 재료 차감
-> 강화 결과 적용

우편 보상 수령이라면 다음과 같이 볼 수 있다.

우편 상태 변경
-> 보상 지급

거래 시스템이라면 더 명확하다.

플레이어 A 자산 감소
-> 플레이어 B 자산 증가
-> 거래 상태 변경

이 작업 중 하나만 실패해서는 안 된다.

예를 들어 거래 과정에서 A의 아이템은 사라졌는데 B에게 돈이 지급되지 않았다면 게임 데이터가 깨진다.

이런 작업은 각각 독립된 SQL처럼 보이지만 게임 로직에서는 하나의 원자적인 작업으로 취급해야 한다.

모든 Query를 Transaction으로 처리하지는 않는다

반대로 모든 DB 작업을 Transaction으로 만드는 것도 좋은 구조는 아니다.

단순 조회는 일반 Query로 충분하다.

SELECT *
FROM player_tb
WHERE player_code = ?;

이런 작업까지 Transaction으로 감쌀 필요는 없다.

Transaction은 여러 DB 변경 작업의 정합성을 하나의 단위로 보장해야 할 때 사용하는 것이 목적이다.

따라서 DB 접근 계층에서는 대략 다음처럼 구분할 수 있다.

단일 Query
-> Query()

여러 변경 작업의 원자성 필요
-> TransactionQuery()

Transaction 범위는 짧게 유지한다

Transaction에서 또 하나 신경 써야 하는 부분은 범위다.

Transaction을 시작한 상태에서는 Connection을 점유하고 있기 때문에 불필요하게 오래 유지할 이유가 없다.

예를 들어 다음과 같은 구조는 피하는 것이 좋다.

Begin Transaction
-> DB Query
-> 외부 API 요청
-> 복잡한 계산
-> 다른 서버 요청
-> DB Query
-> Commit

외부 서버 응답을 기다리는 동안에도 DB Connection을 잡고 있게 된다.

동시 요청이 많아지면 Connection Pool이 빠르게 소모될 수 있다.

가능하면 필요한 검증과 계산을 먼저 처리하고 실제 DB 변경이 필요한 순간에 Transaction을 시작하는 것이 좋다.

검증
-> 데이터 계산
-> Begin Transaction
-> DB 변경
-> Commit

Transaction은 짧게 가져가는 편이 좋다.

현재 구조에서 얻을 수 있는 장점

이 구조를 만들어두면 게임 로직에서 DB 처리 방식을 매번 직접 구현할 필요가 없다.

일반적인 조회는 다음과 같이 처리한다.

const player = await Query(
    `
        SELECT *
        FROM player_tb
        WHERE player_code = ?
    `,
    [playerCode]
);

여러 데이터를 동시에 변경해야 한다면 TransactionQuery를 사용한다.

await TransactionQuery([
    {
        sql: "...",
        options: [...]
    },
    {
        sql: "...",
        options: [...]
    }
]);

DB Connection 획득과 반환, Commit, Rollback, 예외 처리는 DB 모듈 내부에서 담당한다.

게임 로직에서는 “이 작업은 하나의 Transaction이어야 한다”는 의도만 표현하면 된다.

이후 개선할 수 있는 부분

현재 구현은 단순한 서버 구조에서는 충분히 사용할 수 있지만 프로젝트가 커지면 몇 가지 개선할 수 있다.

우선 any를 줄이는 것이다.

options: any

현재는 SQL마다 파라미터의 형태가 다르기 때문에 any를 사용했지만, 반환 타입까지 구체적으로 관리한다면 TypeScript의 타입 안정성을 더 확보할 수 있다.

또한 SQL 배열을 직접 구성하는 방식도 프로젝트 규모가 커지면 한계가 있다.

예를 들어 복잡한 게임 로직에서는 다음과 같은 형태가 더 편할 수 있다.

await Transaction(async (conn) => {

    await updatePlayer(conn);

    await consumeItem(conn);

    await giveReward(conn);

});

이렇게 하면 Transaction 내부에서 실행되는 실제 게임 로직을 함수 단위로 분리할 수 있다.

다만 이것이 현재 구조보다 무조건 좋은 것은 아니다.

SQL 작업 자체를 명확하게 관리해야 하는 프로젝트라면 현재처럼 Query 목록을 전달하는 방식이 오히려 단순하다.

결국 중요한 것은 코드의 복잡도가 아니라 프로젝트에서 DB 작업을 어떤 단위로 추상화할 것인가다.

정리

이번 DB 모듈의 핵심은 복잡하지 않다.

Connection Pool을 하나 생성하고 필요한 시점에 Connection을 가져온다.

단일 SQL은 일반 Query로 처리하고, 여러 SQL이 하나의 작업 단위로 묶여야 하는 경우 TransactionQuery를 사용한다.

TransactionQuery는 동일한 Connection에서 SQL을 순서대로 실행한다.

모두 성공하면 Commit하고 하나라도 실패하면 Rollback한다.

그리고 어떤 경우에도 마지막에는 Connection을 Pool에 반환한다.

전체 흐름을 정리하면 다음과 같다.

일반 작업
-> Pool에서 Connection 획득
-> Query 실행
-> Connection 반환
Transaction 작업
-> Pool에서 Connection 획득
-> Begin Transaction
-> Query 1
-> Query 2
-> Query 3
-> Commit
-> Connection 반환

실패하는 경우에는 다음과 같다.

Pool에서 Connection 획득
-> Begin Transaction
-> Query 1
-> Query 2 실패
-> Rollback
-> Connection 반환

결국 Transaction의 목적은 단순히 여러 SQL을 묶는 것이 아니다.

게임에서 하나의 작업으로 취급해야 하는 데이터 변경을 DB에서도 하나의 원자적인 작업으로 만드는 것이다.

DB 설계에서 중요한 것은 SQL을 얼마나 많이 알고 있느냐보다 어떤 데이터 변경이 하나의 작업 단위인지 정확하게 정의하는 것이다.