윈도우는 되는데 리눅스는 안된다?

최근 회사에서 데디케이트 서버(매칭된 인원만 들어가는 일시적인 세션서버)를 리눅스로 배포하는 작업을 진행하고 있다. 사용자들의 요청에 따라 일시적으로 생성되는 서버이고, 매칭될수 있는 최대 방의 갯수는 시간대에 따라 유동적이고 상한선이 없기 때문에 기존에 게임서버를 배포하는것처럼, 사전에 배정된 윈도우 서버 물리머신에서 관리자가 서버프로세스를 띄우는 방식은 불가능했다. 뭐 암튼 이유가 중요한건 아니고, “16명이 들어가는 세션 서버 프로세스를 리눅스 바이너리로 빌드하고, 도커이미지로 랩핑하여 쿠버네티스로 서빙을 하는 작업을 진행하고 있었다”

이러한 작업을 진행하면서, 매번 테스트마다 16명을 모아서 접속이 되는지 확인할수 없었기 때문에 1~2인방만 만들어서 테스트를 진행하여, 작동하는것을 확인했다. 마지막으로 개발실 내에서 모두 접속할수 있도록, 작업 내용을 공유하기 직전, 마지막으로 서버팀 인원들과 함께 16인 모드에서도 잘 동작하나 확인차 테스트를 돌렸는데 데디케이트 서버가 곧바로 크래시가 나서 죽는 현상이 발견되었다. 바뀐건 “윈도우에서 리눅스로”“1~2인방에서 16인방으로” 두개 뿐이었고, 이를 바탕으로 디버깅하고, 문제 원인을 분석한 내용을 정리해보려고 한다.




문제 원인

문제 원인은 생각보다 심플했다. 데디케이트 서버는 언리얼엔진에서 개발되는 프로세스였고, 때문에 TMap이란 컨테이너를 사용하고 있었다. 일반적으론 map이라는 자료구조는 비연속공간자료구조라 재할당이 발생하지 않는데, TMap은 아래 공식레퍼런스를 인용한 내용처럼 재할당이 될 수 있는 자료구조였고 이것이 문제였다.

However, even though TMap doesn't shuffle elements to fill gaps, pointers to map elements may still be invalidated, as the entire storage can be reallocated when it is full and new elements are added.

TMap은 일반적인 Map과 같이 generic클래스고 이번 문제에서 처리하다가 크래시가 난 제너릭 타입은 std::map이었다. 물론 처음엔 std::map은 이동연산자가 구현되어있어서 상관없을줄 알았는는데 진짜로 std::map이 문제였다. 그럼 윈도우에서 리눅스로 넘어가면서 std::map이 어떻게 프로세스를 터뜨렸을까? 에 대해서 자세히 알아보자.




TMap동작방식

언리얼 공식 문서에는 아래와 같은 내용이 나온다.

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.

간단히 말해서 언리얼의 대다수 컨테이너들은 얕은복사로 데이터를 모두 옮길수 있는 제너릭 타입만 받을 수 있다는것이다. 이는 언리얼에서 빠른속도로 데이터를 옮기기위해 연속공간 메모리에 대해 memcopy방식(실제론 memmove. 조금 다르다.)으로 컨테이너를 재할당 하기 때문이다. 이방식에 대해서는 std::vector에서 어떤식으로 재할당을 하는지 보면 쉽게 알수 있다.

vector 복사 연산 기반 재할당

실제 구현체 기반 코드 사진 갖고와서예시 및 설명

vector 이동 연산 기반 재할당

실제 구현체 기반 코드 사진 갖고와서예시 및 설명

vector memcopy 기반 재할당

실제 구현체 기반 코드 사진 갖고와서예시 및 설명

번외 memmove vs memcopy

실제 구현체 기반 코드 사진 갖고와서예시 및 설명


위와 내용 같이 힙영역의 객체를 재할당하기 위한 기법이 3개정도 있는데, TMap은 “memcopy 기반 재할당” 기법만을 제공하기 때문에 문제가 되었던 것이다. 얕은 복사는 보통 복사된 객체가 포인터를 갖고있을때 같은 공간을 바라보기 때문에 문제가 되는경우라고 배운다. 자세한 내용은 모두의 코드 블로그를 보자.

하지만 우린 재할당을 하기때문에 “복사 대상객체는 사라지고, 소멸자는 물론 복사생성자도 호출이 안하기 때문에 상관없지 않나?” 라고 생각할수도 있고, “그럼 왜 윈도우는 되는데 리눅스에선 안됐는데?” 라고 질문할수 있기 때문에, 정확히 동일한 라이브러리가 운영체제별로 어떤 구현 방법 차이가 있었길래 문제가 터졌는지 알아보자.




운영체제별 std::map 구현방법

미리 차이부터 말하면, 리눅스에서 std::map은 자기참조포인터를 갖고 있었지만, 윈도우에선 갖고있지 않았다.

그럼 자기참조포인터를 갖고있을때 얕은복사를 하면 왜 크래시가 나는건지 간단하게 설명하면 아래 그림과 같다. 간단하게 설명하기 위해 메모리 영역에서 스택영역이 0번지부터 15번지까지, 힙영영이 16번지부터 31번지까지라고 하고 그린 그림이다.

자기참조 포인터사용시 크래시 이유

결국 리눅스에선 맵의 사이즈가 0일때 실제 element들이 들어있는 컨테이너를 가르키는 포인터가 자기 자신을 참조하고 있는데, 그 데이터를 단순 복사하는경우 옮겨지 지점에서 바라보고있는 포인터가 이전에 이미 할당해제된 영역 주소를 가르키기 때문에 크래시가 나는것이다. 그럼 실제 구현체를 보자.

리눅스 std::map

실제 구현체 기반 코드 사진 갖고와서예시 및 설명

윈도우 std::map

실제 구현체 기반 코드 사진 갖고와서예시 및 설명


결론적으로 같은 std::map을 쓰더라도 구현한곳에 따라 동일한 동작에 대해 다른 결과가 나온다는것을 알 수 있다. 물론 빌드시 최적화 파라미터 옵션등에 따라 윈도우라고 무조건 std::map이 안전하다고 장담할수도 없기 때문에 결국 TMap에서 재할당이일어날수 있고, memcopy(얕은복사)기반으로 재할당이 된다는 것” 을 알고 짜야할 것 같다.




후기

사실 이번 일은 서버팀 막내인 나에게 꽤나 가슴 쓸어내리는 경험이었다. 1~2인방 테스트에서 문제없이 잘 돌았으니 16인방도 당연히 똑같이 잘 될 거라 생각하고 그냥 배포까지 달릴 뻔했는데, 시니어 개발자분께서 “배포 전에 16명 다 모아서 같이 테스트 한번 해보죠”라고 제안해 주신 덕분에 사전에 문제를 잡을 수 있었다. 만약 이 크래시를 라이브 배포 나간 뒤에 터뜨렸다면…

엄밀히 따지면 세션 서버는 언리얼 엔진을 통해 빌드할 때 클라이언트와 동일한 코드베이스에서 생성되는 구조라, 내가 직접 작성한 코드의 문제는 아니었다. 그럼에도 팀장님께서는 “이런 이슈를 그냥 넘기지 않고 깊게 파보는 게 진짜 실력을 키우는 길”이라며 직접 문제 원인을 함께 분석해 주시고 친절하게 알려주셨다.

이전 직장에서 혼자 로봇 관제 서버를 개발할 때는 느끼지 못했던, 이른바 ‘우물 안 개구리’를 벗어나는 기분이다. 최근 NC 게임서버 개발 직무로 이직한 뒤 이렇게 깊이 있는 C++과 프로그래밍 기술을 실무에서 다루며 뛰어난 팀원들과 함께 성장할 수 있어 요즘 참 뿌듯한것 같다.