ELKISS BLOG

윈도우에선 우연히 돌아가고 있었습니다

유리 단지 둘 사이에서 갸웃하는 지휘관. 단지마다 구슬로 엮은 작은 트리가 들었고, 왼쪽 단지에는 금이 가 있다

서버를 만들다 보면 맵을 그대로 실어 보낼 일이 많습니다. 프로토콜에 키와 값의 묶음이 필요한 자리가 계속 생기고, std::map 이면 그 자리가 한 줄로 끝나기 때문입니다.

받는 쪽도 그것을 그대로 들고 있었습니다. 구조체 하나에 맵이 들어 있고, 그 구조체들을 언리얼의 TMap 에 담아두는 식입니다.

하나일 때는 멀쩡했습니다. 여덟 번째가 되자 크래시가 났습니다. 그런데 윈도우에서 돌리던 데디케이티드 서버는 멀쩡했고, 같은 코드를 리눅스로 빌드한 쪽만 터졌습니다.

처음에는 리눅스 쪽이 뭔가 까다로운가 보다고 생각했습니다. 아니었습니다. 알아보고 나니 윈도우가 우연히 살아 있었던 것이었고, 그 우연의 정체가 이 글입니다.

원소가 늘면 컨테이너가 자리를 옮깁니다#

터지는 시점이 “몇 개째” 였다는 것이 첫 실마리였습니다. 하나일 때는 아무 일도 없다가 여덟 번째부터 터졌습니다. 하필 여덟인 이유는 여기서 따지지 않겠습니다 — 컨테이너가 처음에 몇 칸을 잡고 얼마씩 늘리는지에 달린 값이고, 그 숫자가 몇이든 이야기는 같습니다. 중요한 것은 자리를 넓히는 순간이 있다는 것입니다.

담을 곳이 모자라면 컨테이너는 더 큰 메모리를 잡고 원소를 그리로 옮깁니다. 여기까지는 어느 컨테이너나 같습니다. 갈리는 곳은 어떻게 옮기느냐입니다. 언리얼은 원소를 바이트째 복사해서 옮깁니다. 생성자도 소멸자도 부르지 않습니다.

여기서 한 번 멈췄습니다. 노드가 포인터로 이어진 자료구조를 바이트째 복사해서 옮긴다는 것은, 표준 컨테이너만 쓰던 눈에는 애초에 선택지에 없던 방식입니다. 컨테이너가 원소를 옮길 때는 옮길 줄 아는 방법을 부른다고 — 그러니까 move 생성자를 부른다고 믿고 있었고, 그래서 원인을 찾는 동안 컨테이너 쪽은 의심조차 하지 않았습니다. 언리얼 코드를 열어보기 전에 제 코드부터 열 번 봤습니다. 엔진이 왜 그렇게 만들었는지는 뒤에서 다시 봅니다.

엔진과 표준 라이브러리 안쪽은 AI 에게 묻고 되물으며 팠습니다. 헤더의 어디를 열어야 하는지조차 모르는 상태에서는 그 편이 빨랐습니다. 다만 나온 설명을 그대로 믿지는 않았습니다. 아래의 실험과 표가 그 검증입니다.

옮겨지는 것은 구조체만이 아닙니다. 구조체 안에 들어 있던 std::map 도 같이 바이트째 옮겨집니다. 그리고 맵은 자기가 옮겨졌다는 사실을 모릅니다. 아무도 알려주지 않았으니까요.

새 선반으로 단지를 옮기는 아테나. 단지에서 나온 끈이 옛 선반 빈자리의 말뚝에 묶인 채 팽팽하게 당겨져 있다

옮겨진 맵의 어디가 망가지는지를 보려면 맵 안을 직접 봐야 했습니다.

맵 안에 자기 자신을 가리키는 포인터가 있었습니다#

한 일은 간단합니다. 8바이트씩 뛰면서, 그 자리의 값이 맵 자신의 주소 범위 안을 가리키는지 보는 것이 전부입니다.

// 8바이트씩 뛰며 그 값이 range 가 가리키는 맵의 범위 안인지 본다
static void walk(void* obj, void* range, size_t size)
{
    char* base = (char*)range;
    void** word = (void**)obj;

    for (size_t i = 0; i < size / sizeof(void*); ++i)
    {
        char* value = (char*)word[i];

        printf("  +%zu  %p", i * sizeof(void*), word[i]);
        if (value >= base && value < base + size)
        {
            printf("  <- into the map at %p (+%d)",
                   range, (int)(value - base));
        }
        printf("\n");
    }
}

빈 맵으로 했습니다. 원소가 없으면 시작과 끝이 같은 자리여야 하니까, begin() == end() 라는 검증이 공짜로 딸려오기 때문입니다.

아래는 그때 본 것을 지금 같은 방법으로 다시 잰 것입니다. 리눅스에서 돌렸습니다.

libc++ 180100, sizeof(std::map<int,int>) = 24
map at 0x55a798bee2a0, copy at 0x55a798bee2c0
map:
  +0  0x55a798bee2a8  <- into the map at 0x55a798bee2a0 (+8)
  +8  (nil)
  +16  (nil)

+0 의 값이 맵 자신의 +8 을 가리킵니다. 그 +8 자리가 센티넬입니다. 값을 담지 않는 노드인데, end() 가 가리키는 곳이자 트리를 훑다가 끝에 닿았는지 판단하는 기준입니다. 그 노드가 힙이 아니라 맵 객체 안에 있습니다.

여기까지는 “그렇게 생겼다” 이고, 그것이 문제가 되는지는 옮겨봐야 압니다. 그래서 바이트를 그대로 다른 자리에 복사했습니다. 언리얼 컨테이너가 자랄 때 하는 일과 같습니다.

copy (bytes moved, the map above still alive):
  +0  0x55a798bee2a8  <- into the map at 0x55a798bee2a0 (+8)
  +8  (nil)
  +16  (nil)
copy: size() = 0, empty() = true, begin() == end() is no

복사본은 다른 주소에 있는데 +0 은 여전히 원본의 +8 을 가리킵니다. 그래서 마지막 줄이 스스로 모순을 말합니다. 비어 있다면서(empty() = true) 시작과 끝이 다릅니다(begin() == end() is no).

빈 맵이라 이 정도로 끝났습니다. 원소가 들어 있는 맵이었다면, 그리고 원본이 있던 자리가 반납되어 다른 데이터가 들어왔다면, 이 맵을 순회하는 코드는 그 자리를 노드로 읽습니다.

MSVC 는 같은 노드를 힙에 둡니다#

같은 코드를 세 구현에서 돌렸습니다.

구현sizeof맵 안을 가리키는 포인터복사본의 begin() == end()
MSVC STL (cl 19.51)16바이트0개같다
libc++ 18 (clang, 리눅스)24바이트1개다르다
libstdc++ 13 (g++, 리눅스)48바이트2개다르다

MSVC 쪽 출력은 이렇습니다.

MSVC STL 145, sizeof(std::map<int,int>) = 16
map at 0000023BF8EB21C0, copy at 0000023BF8EB24C0
  +0  0000023BF8EBEFC0
  +8  0000000000000000
copy: size() = 0, empty() = true, begin() == end() is yes

여기도 +0 에 주소가 들어 있습니다. 다만 그 주소가 맵(…21C0)에서 한참 떨어진 힙입니다. 센티넬을 따로 할당해두고 포인터로만 들고 있기 때문입니다. 그래서 맵을 통째로 다른 자리에 복사해도 그 포인터는 같은 힙 노드를 그대로 가리키고, 아무 일도 일어나지 않습니다.

libstdc++ 에서 두 개가 나오는 것은 센티넬의 왼쪽·오른쪽 포인터가 빈 트리에서 둘 다 자기 자신을 가리키기 때문입니다. 크기가 16·24·48바이트로 갈리는 것도 같은 이야기입니다. 무엇을 객체 안에 두는가가 다릅니다.

그리고 셋 다 레드블랙 트리입니다. 표준이 자료구조를 정해두지는 않지만, 요구하는 복잡도와 반복자 규칙을 맞추려면 균형 잡힌 이진 탐색 트리 말고는 마땅한 수가 없고, 세 구현 모두 그중 레드블랙 트리를 골랐습니다. 알고리즘은 교과서에 나오는 같은 것입니다.

놀란 곳이 거기였습니다. 같은 자료구조를 같은 규칙으로 굴리면서도, 그 트리의 끝을 어디에 두느냐는 구현마다 달랐습니다. 교과서에 안 나오고 표준도 말하지 않는 자리라, 각자 편한 대로 정해도 되는 부분이었던 겁니다. 그래서 같은 std::map 이 세 번 다르게 나왔습니다.

코드 전문과, 세 구현에서 한 번에 돌리는 워크플로는 저장소 에 있습니다. 위 값은 그 워크플로가 남긴 로그에서 가져왔습니다.

윈도우에서도 규칙은 똑같이 어기고 있었습니다#

언리얼은 이것을 숨기지 않습니다. TArray 문서와 TMap 문서에 거의 같은 문장이 있습니다.1

TArray (like many Unreal Engine containers) assumes that the element type is trivially relocatable, meaning that elements can safely be moved from one location in memory to another by directly copying raw bytes.

trivially relocatable 은 바이트를 그대로 복사해서 옮겨도 되는 타입이라는 뜻입니다. 언리얼 컨테이너는 원소가 그런 타입이라고 가정하고 자랍니다. 물어보지 않습니다.

그런데 std::map 은 표준이 그것을 보장해주는 타입이 아닙니다. 표준이 센티넬을 어디에 두라고 정해두지 않아서 구현마다 다르게 둘 수 있고, 그래서 되는 구현도 있고 안 되는 구현도 있습니다. MSVC 에서 되는 것도 그 구현의 선택이지 약속이 아닙니다.

그러니 윈도우 빌드도 같은 규칙을 어기고 있었습니다. 다만 증상이 안 났을 뿐입니다. 리눅스가 까다로웠던 것이 아니라 윈도우가 운이 좋았습니다.

이 판단이 혼자만의 것이 아니라는 근거도 있습니다. C++ 표준화 위원회에 trivial relocatability 를 제안한 사람이 구현별로 정리해둔 표가 있는데, list·set·map 은 libstdc++ 와 libc++ 에서 결코 바이트째 옮길 수 없고 MSVC 에서는 가능하다고 적혀 있습니다. set·map 에는 템플릿 인자에 따라서라는 조건이 붙습니다.2 센티넬을 객체 안에 두기 때문이라는 설명까지 같습니다. 앞에서 잰 값과 같은 이야기입니다.

표준 컨테이너였다면 안 터졌습니다#

객체를 한 자리에서 다른 자리로 옮기는 방법은 크게 셋입니다.

방식하는 일쓰는 곳
복사하고 지우기새 자리에 복사 생성자로 짓고 옛 것을 소멸move 가 없거나 못 쓸 때
옮겨 짓고 지우기새 자리에 move 생성자로 짓고 옛 것을 소멸std::vector 가 자랄 때
바이트째 옮기기memcpy·memmove. 생성자도 소멸자도 없음언리얼 컨테이너

앞의 둘은 타입이 정해둔 방법을 부릅니다. 마지막 하나만 타입을 건너뜁니다. 그래서 빠르고, 그래서 타입이 자기 안에 무엇을 들고 있는지 알 길이 없습니다.

같은 데이터를 std::vector<std::map<...>> 에 담아 키웠다면 아무 일도 없었을 겁니다. std::vector 도 자리가 모자라면 새 메모리로 옮기는데, 옮기기 전에 원소 타입에게 묻습니다. int 처럼 바이트째 복사해도 되는 타입이면 한 번에 바이트째 옮기고, std::map 처럼 아닌 타입이면 원소마다 move 생성자를 불러 옮긴 다음 옛 것을 소멸시킵니다.

그리고 std::map 의 move 생성자는 그 자기 참조를 새 주소에 맞게 고쳐놓습니다. 고쳐놓아야만 합니다. 옮긴 결과가 멀쩡한 맵이어야 하니까요. memcpy 에는 그 단계가 없습니다. 바이트만 건너가고 아무도 고치지 않습니다.

그러니 차이는 최적화를 하느냐가 아니라 묻느냐 가정하느냐입니다. 언리얼은 속도를 위해 기본값을 “가정한다” 쪽에 두었습니다. 대신 무엇을 넣을지는 쓰는 사람의 몫이 됩니다.

예약으로 막고, 포인터로 고쳤습니다#

받는 쪽 코드는 다른 팀의 몫이었습니다. 그래서 서버 쪽에서 당장 할 수 있는 것부터 했습니다. 컨테이너에 자리를 미리 잡아두는 것입니다. 자랄 일이 없으면 원소가 옮겨질 일도 없고, 옮겨지지 않으면 터지지도 않습니다.

이건 고친 것이 아니라 미룬 것입니다. 규칙을 어기는 코드는 그대로 있고, 언젠가 잡아둔 것보다 많이 들어오면 같은 자리에서 같은 일이 생깁니다.

실제로 고친 것은 받는 쪽이었고, 방법은 한 겹 감싸는 것이었습니다. 구조체가 맵을 값으로 들지 않고 unique_ptr 로 듭니다.

struct FValue { std::map<int, int> Data; };                   // 옮겨지면 깨진다
struct FValue { std::unique_ptr<std::map<int, int>> Data; };  // 포인터만 옮겨진다

그러면 컨테이너가 자리를 넓힐 때 바이트째 건너가는 것은 포인터 여덟 바이트뿐입니다. 맵 객체는 처음 있던 자리에 그대로 남습니다. 맵이 안 움직이니 그 안의 센티넬도 안 움직이고, 센티넬을 가리키던 포인터는 여전히 맞는 곳을 가리킵니다. 앞에서 본 모순 — 비었다면서 시작과 끝이 다른 것 — 이 생길 자리가 아예 없어집니다.

옛 선반 옆 받침돌 위에 단지가 그대로 있고, 아테나는 단지에서 이어진 끈 끝의 꼬리표만 새 선반에 건다. 끈은 느슨하게 늘어져 있다

돌아보면 원인은 하나입니다. 자기를 가리키는 포인터가 있는 것이 문제였던 것이 아니라, 그런 것을 옮긴 것이 문제였습니다. 그래서 고치는 방향도 둘뿐입니다. 자기를 안 가리키게 만들거나(MSVC 가 하는 일), 옮기지 않거나(unique_ptr 이 하는 일).

실제로 고를 수 있는 방법은 여럿이고, 전부 같은 규칙의 다른 얼굴입니다 — 엔진 컨테이너에는 엔진이 가정하는 성질을 갖춘 타입만 넣는다.

  • 한 겹 감쌉니다. 위에서 고른 길입니다. 맵을 포인터 뒤에 둡니다
  • 경계에서 옮겨 담습니다. 패킷으로 받은 std::map 을 엔진 쪽으로 넘기기 전에 TMap 으로 변환합니다. 게임 로직은 엔진 타입만 보게 되고, 값은 한 번 복사됩니다
  • 주고받는 모양을 바꿉니다. 애초에 키와 값의 쌍을 배열로 보내고, 필요한 쪽에서만 맵으로 만듭니다

컴파일러가 대신 잡아줄 수 있을까#

여기부터는 그때 한 것이 아니라 찾아본 것입니다. 고치고 나서 남은 찝찝함이 하나 있었습니다. 다음 사람이 모르고 같은 것을 또 넣으면 그때도 터져봐야 압니다. 넣는 순간에 컴파일러가 잡아줄 수는 없을까.

가장 가까운 것이 std::is_trivially_copyable 이었습니다. 넣는 자리에서 원소 타입에 이것을 물어보고, 아니면 빌드가 말을 하게 하는 식입니다.

static_assert(std::is_trivially_copyable_v<T>,
              "바이트째 옮겨지는 자리입니다. 이 타입이 그래도 되는지 확인하세요");

샘플 코드까지 써봤고, 실제 코드에 넣지는 않았습니다.

써보면서 알게 된 것이 이 절의 알맹이입니다. 이 질문은 알고 싶은 것보다 엄격합니다. 묻고 싶은 것은 “바이트째 옮겨도 되느냐” 인데 트레이트가 답해주는 것은 “바이트째 복사해도 되느냐” 입니다. std::unique_ptr 이나 TArray 처럼 옮기는 것은 괜찮지만 복사하면 안 되는 타입이 여기에 걸립니다. 방금 고친 방법이 unique_ptr 이었으니, 그 경고에는 우리가 고른 답까지 걸립니다.

그러니 이 검사는 “이건 위험하다” 가 아니라 “표준이 대신 판단해줄 수 없으니 사람이 보라” 에 가깝습니다. 왜 그런지가 마지막 절입니다.

둘 다 맞는 선택이었습니다#

글을 여기까지 쓰고 보니, 어느 쪽이 틀렸다는 이야기가 아니었습니다.

언리얼은 자기가 푸는 문제에 맞게 골랐습니다. 게임에서는 컨테이너가 프레임마다 자라고 원소가 수만 개가 되기도 합니다. 원소마다 생성자와 소멸자를 부르는 비용을 걷어내는 대신, 무엇을 넣을지는 쓰는 사람이 책임진다는 규칙을 세웠습니다. 그리고 그 규칙을 컨테이너 문서에 적어뒀습니다. 숨기지 않았습니다.

그리고 엔진이 쥐여주는 타입들은 그 가정을 지키도록 설계돼 있습니다. 엔진 안에서만 놀면 가정이 참이고, 참인 가정은 공짜로 빠릅니다. 오래 들여다보고 나서 남은 것은 “위험한 기본값” 이 아니라 자기 생태계에 정확히 맞춘 답이었고, 저는 그게 마음에 들었습니다.

단지를 품에 안은 아테나가 선반에 달린 금색 테두리 팻말을 돋보기로 읽고 있다. 어깨 위 올빼미가 눈을 감고 끄덕인다

표준 라이브러리도 자기가 푸는 문제에 맞게 골랐습니다. 어떤 타입이 들어올지 모르는 범용 도구라 타입에게 물어보는 장치를 갖췄습니다. 타입이 정해둔 방법(move 생성자)을 부르고, 타입이 “바이트째 복사해도 된다” 고 말해주면 그때는 그 빠른 길로 갑니다. 제약을 표현하는 수단을 주고, 그 표현에 따라 움직입니다.

그러니 둘은 다른 자리에서 각자 맞습니다. 이번 크래시는 그중 한쪽이 틀려서가 아니라, 두 세계의 가정이 다르다는 것을 모른 채 섞어서 생겼습니다. 경계에서 가정이 바뀌는데, 그 경계에는 아무 표시도 없었습니다.

표시를 세울 방법이 아직 마땅치 않다는 것도 알게 됐습니다. C++ 에는 “이 타입은 바이트째 옮겨도 된다” 고 말할 표준 방법이 없습니다. 표준에 넣으려는 시도가 C++26 에 한 번 들어갔다가 2025년 11월에 도로 빠졌고, 다음 기회는 C++29 입니다.3 그때까지는 문서에 적힌 한 줄과, 그것을 읽은 사람의 앎이 그 자리를 대신합니다.

그래서 남는 것은 이것입니다. 윈도우에서 잘 돌던 코드가 리눅스에서 망가진 것이 아니라, 두 세계를 모르고 섞은 자리가 한쪽에서 먼저 드러난 것입니다. 플랫폼을 옮기는 일은 새 버그를 만드는 일이 아니라 이미 있던 가정을 드러내는 일에 가깝고, 가정은 대개 내가 쓴 코드가 아니라 내가 쓰는 도구 안에 있습니다. 도구를 탓할 일도, 피할 일도 아닙니다. 무엇을 가정하는지 알고 쓰면 됩니다.


  1. 언리얼 공식 문서 Array Containers 와 Map Containers 에 같은 문장이 있습니다. 인용은 TArray 쪽이고, TMap 문서도 주어만 바뀝니다. 2026년 9월에 본 5.8 문서 기준입니다. ↩︎

  2. Arthur O’Dwyer, What library types are trivially_relocatable in practice? (2019). list·set·map 이 센티넬(글에서는 이렇게 부르지만 그 글은 end node 라고 씁니다)을 객체 안에 두기 때문이라고 적혀 있습니다. MSVC 의 list 칸은 조건 없이 되고, set·map 칸은 템플릿 인자에 따라 되는 조건부로 적혀 있습니다. ↩︎

  3. C++26 에 들어갔던 trivial relocation 은 2025년 11월 코나 회의에서 표준 초안에서 빠졌습니다. 여러 나라 대표단이 빼자는 의견을 냈고, 표준 라이브러리 구현자들이 이 모양으로는 쓸 수 없다고 봤습니다. C++29 에서 다시 다룰 것으로 보입니다 → Kona 회의 결과 정리. ↩︎