나만의 작은 도서관

[TIL][C++] 250905 MMO 서버 개발 95일차: 인벤토리 정보를 서버 메모리 → DB 저장 할 때에 대한 고민 본문

Today I Learn

[TIL][C++] 250905 MMO 서버 개발 95일차: 인벤토리 정보를 서버 메모리 → DB 저장 할 때에 대한 고민

pledge24 2025. 9. 5. 22:20
주의사항: 해당 글은 일기와 같은 기록용으로, 다듬지 않은 날것 그대로인 글입니다. 

인벤토리 정보를 서버 메모리 → DB 저장할 때에 대한 고민

 

바뀌지 않은(Unchanged) 인벤토리 슬롯 정보를 DB에 저장하는 것은 비효율적

  • 한 플레이어의 인벤토리에는 얼마나 많은 슬롯이 존재할까? MMORPG의 경우 개인 인벤토리의 크기가 큰 편이기 때문에 1,000개 이상의 슬롯이 있을 수 있다는 것을 상정해야 한다.
  • 문제는 이렇게 많은 슬롯에 대한 정보를 서버 메모리 → DB 저장 작업을 할 때마다 전부 update 한다면 이는 굉장히 비효율적이라는 것이다. 인벤토리 사용 특성상 많은 슬롯이 있음에도 변동되는 슬롯의 비율은 그리 높지 않다. 아이템을 넣을 수 있는 슬롯이 1,000개 정도 있다고 해도 1시간 동안 변동되는 슬롯은 100개도 안된다는 것이다. 그럼에도 1,000개나 되는 슬롯의 정보를 전부 DB에 UPDATE를 한다는 것은 10배 이상의 cost가 든다는 의미가 된다.

 

해결책) 변동된 슬롯만 DB에 올린다.

  • 그래서 데이터가 바뀌지 않은 슬롯에 대해서는 DB에 올리는 작업을 하지 않는다. 변동된 데이터만 DB에 올리면 불필요한 작업을 하지 않아도 되게 된다.
  • 이렇게 하면 중간중간 주기적으로 DB에 서버 메모리 데이터를 동기화시키는 스냅숏 방식을 추가해도 그 비용은 크게 올라가지 않게 된다.

 

또 다른 문제점. 변동 상태를 어떻게 처리해야 할까?

  • 슬롯에 대해서 변동 상태의 종류는 생성(Create), 수정(Modified), 삭제(Deleted)가 있다. 문제는 각 상태에 대해 SQL문을 실행해야 하는데 어떻게 할 것이냐는 것이다. 여러 방법으로 이를 서버에서 처리하려고 했지만 쓸데없이 코드가 길어지는 것 같아 맘에 드는 결론이 나지 않았는데, MSSQL에 MERGE 기능을 활용하는 것으로 깔끔하게 해결할 수 있다는 걸 알게 되었다.

 

MSSQL의 MERGE

  • MERGE문은 소스 테이블과 대상 테이블을 비교하여 INSERT, UPDATE, DELETE를 자동으로 처리해 주는 문법이다.

MERGE 기본 구조

MERGE INTO TargetTable AS T
USING SourceTable AS S
ON T.key = S.key
WHEN MATCHED THEN
    UPDATE SET T.col1 = S.col1, T.col2 = S.col2
WHEN NOT MATCHED THEN
    INSERT (col1, col2, col3)
    VALUES (S.col1, S.col2, S.col3);

 

 

재 상황에 적용 아이디어: 임시 테이블을 만들고 MERGE를 사용하자!

 

과정 1. 임시 테이블 준비

CREATE TABLE #TempItems (
    character_id BIGINT NOT NULL,
    slot_id      INT    NOT NULL,
    template_id  INT    NOT NULL,
    count        INT    NOT NULL,
    PRIMARY KEY(character_id, slot_id)
);

 

과정 2. C++ ODBC에서 배열 파라미터 바인딩으로 임시 테이블 채우기

// 예: 100행 처리
const int ROWS = 100;

// 데이터 버퍼 준비
SQLBIGINT characterIds[ROWS];
SQLINTEGER slotIds[ROWS];
SQLINTEGER templateIds[ROWS];
SQLINTEGER counts[ROWS];

// 예시 값 채우기
for (int i = 0; i < ROWS; i++) {
    characterIds[i] = 123;
    slotIds[i]      = i;
    templateIds[i]  = 1000 + i;
    counts[i]       = i * 2;
}

SQLHSTMT hstmt;
SQLAllocHandle(SQL_HANDLE_STMT, hdbc, &hstmt);

// 임시 테이블에 Insert
std::string insertSQL =
    "INSERT INTO #TempItems (character_id, slot_id, template_id, count) "
    "VALUES (?, ?, ?, ?)";
SQLPrepare(hstmt, (SQLCHAR*)insertSQL.c_str(), SQL_NTS);

// 행 집합 단위 설정
SQLSetStmtAttr(hstmt, SQL_ATTR_PARAMSET_SIZE, (SQLPOINTER)ROWS, 0);

// 바인딩 (배열 버퍼)
SQLBindParameter(hstmt, 1, SQL_PARAM_INPUT, SQL_C_SBIGINT, SQL_BIGINT,
                 0, 0, characterIds, 0, nullptr);
SQLBindParameter(hstmt, 2, SQL_PARAM_INPUT, SQL_C_LONG, SQL_INTEGER,
                 0, 0, slotIds, 0, nullptr);
SQLBindParameter(hstmt, 3, SQL_PARAM_INPUT, SQL_C_LONG, SQL_INTEGER,
                 0, 0, templateIds, 0, nullptr);
SQLBindParameter(hstmt, 4, SQL_PARAM_INPUT, SQL_C_LONG, SQL_INTEGER,
                 0, 0, counts, 0, nullptr);

// 100행 한 번에 Insert
SQLExecute(hstmt);

 

 

과정 3. MERGE 실행

  • 여기서 Source는 임시 테이블, Target은 DB 테이블이 된다. (Source를 Target에 Merge)
  • Source는 rhs, Target은 lhs이므로 MERGE에서 INTO에는 DB 테이블이, ON에는 임시 테이블이 와야 한다.
MERGE INTO CharacterItems AS T
USING #TempItems AS S
ON (T.character_id = S.character_id AND T.slot_id = S.slot_id)

-- 둘 다 행이 존재 -> UPDATE
WHEN MATCHED THEN
    UPDATE SET template_id = S.template_id, count = S.count

-- DB 테이블에 행이 없음 -> INSERT
WHEN NOT MATCHED BY TARGET THEN
    INSERT (character_id, slot_id, template_id, count)
    VALUES (S.character_id, S.slot_id, S.template_id, S.count)

-- 임시 테이블에 행이 없음 -> DELETE
WHEN NOT MATCHED BY SOURCE AND T.character_id = (?) THEN
    DELETE;

DROP TABLE #TempItems;

 

 

과정 4. 임시 테이블 삭제

SQLExecDirect(hstmt, (SQLCHAR*)"DROP TABLE #TempItems", SQL_NTS);

 

 

임시 테이블에 대한 정보

  • SQL Server의 임시 테이블은 이름 앞에 ‘#’이 붙는 테이블이다.
  • 임시 테이블은 같은 세션 및 하위 세션 또는 프로시저 및 하위 프로시저에서 접근할 수 있다. 지역 변수 생명 주기와 비슷한 메커니즘이라고 생각하면 된다. (단, 서로 다른 세션에서는 공유 안 됨)
    • C++ ODBC 기준으로 세션은 곧 SQLHDBC(Connection Handle) 객체에 해당한다.
  • 만약 글로벌 임시 테이블이 필요하다면 #을 두 번 붙이면 된다.
    • ex. ##TempItems

위 예시를 기준으로 재정리

  • #TempItems 같은 로컬 임시 테이블은 현재 연결(Connection) 안에서만 보이고 사용가능하다.
  • 다른 연결에서는 전혀 보이지 않기 때문에 같은 이름인 #TempItems를 만들어도 서로 독립적이다. 즉, DBConnectionPool에서 Pop 해서 가져온 커넥션 객체가 #TempItems를 만들었다고 다른 커넥션 객체가 해당 테이블을 사용할 수는 없다는 것이다.

 

임시 테이블 생성/삭제 비용은 무시해도 될까

  • SQL Server에서 임시 테이블 생성/삭제 비용은 메타데이터 등록/해제 작업이기 때문에 비용이 발생하기는 한다.
  • 하지만 일반적인 게임 서버에서 플레이어 인벤토리 정도(수십~수백 행)를 담고 MERGE → DROP 하는 수준은 병목의 주원인이 되지 않는 경우가 대부분이다. 따라서, 걱정할 정도는 아니라고 본다.

 

임시 테이블 생성 시 기존 테이블 열 구조만 그대로 따오기

SELECT *
INTO #TempTable
FROM
WHERE 1 = 0;
  • WHERE 1 = 0은 항상 거짓이므로, 열 구조는 가져오되 데이터는 가져오지 않는다. 쿼리 옵티마이저가 이 조건을 보고 참인 행이 있을 수 없다는 것을 인식하고 데이터를 실제로 읽지는 않는다.
  • 따라서, 원본 테이블이 수백만 개의 행이 존재하더라도 성능 저하 없이 아주 적은 비용만 발생한다.