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

최근 회사에서 데디케이트 서버(매칭된 인원만 들어가는 일시적인 세션서버)를 리눅스로 배포하는 작업을 진행하고 있다. 사용자들의 요청에 따라 일시적으로 생성되는 서버이고, 매칭될수 있는 최대 방의 갯수는 시간대에 따라 유동적이고 상한선이 없기 때문에 기존에 게임서버를 배포하는것처럼, 사전에 배정된 윈도우 서버 물리머신에서 관리자가 서버프로세스를 띄우는 방식은 불가능했다. 뭐 암튼 이유가 중요한건 아니고, “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의 재할당 과정에서 원소를 일반적인 생성자 기반으로 재배치하는 경우 std::__uninitialized_move_if_noexcept_a 함수에서 재할당 대상 타입의 이동연산자가 noexcept 한정자의 여부에 따라 선택된다. 왜냐하면 vector 재할당 도중 이동연산자에서 예외가 발생하면 기존 vector의 원소가 move에 의해 훼손되어 복구가 불가능할수 있기 때문이다.

{
  __new_finish = std::__uninitialized_move_if_noexcept_a(__old_start, __position.base(), __new_start, _M_get_Tp_allocator());
  ++__new_finish;
  __new_finish = std::__uninitialized_move_if_noexcept_a(__position.base(), __old_finish, __new_finish, _M_get_Tp_allocator());
}

해당 코드는 vector 중간에 값을 끼워 넣는 insert 위치를 기준으로 기존 원소를 새로운 메모리 영역으로 옮기는 연산이다. std::__uninitialized_move_if_noexcept_a 함수는 주어진 반복자의 iterator의 표현형에 따라 원소를 이동할지 복사할지를 결정한다. 이후 실제 원소의 생성은 아래와 같은 코드에서 수행된다.

for (; __first != __last; ++__first, (void)++__cur)
	std::_Construct(std::__addressof(*__cur), *__first);

위 코드의 _Construct 함수는 *__first 표현식이 T&& 또는 const T& 형태로 전달되는 것에 따라 아래 코드에서 이동 또는 복사 생성자를 호출하여 실제 재할당을 수행한다. *__first의 표현식은 _GLIBCXX_MAKE_MOVE_IF_NOEXCEPT_ITERATOR(__first)에 의해 결정되는데, 이것까지 다 자세히 확인하면 너무 복잡해지므로, 생략하겠다.

template<typename _Tp, typename... _Args> inline void _Construct(_Tp* __p, _Args&&... __args)
{
  ::new((void*)__p) _Tp(std::forward<_Args>(__args)...);
}

결론적으로 위 코드는 std::forward를 통해 *__first의 value category가 그대로 전달되기 때문에, 앞 단계에서 move가 선택되었다면 T&&가 전달되어 T의 이동 생성자가 호출되고, copy가 선택되었다면 const T&가 전달되어 복사 생성자가 호출된다. 즉, 실제로는 다음과 같은 형태로 생성자가 호출되는 셈이다.

// 이동이 선택된 경우
::new((void*)__p) T(std::move(source));
// → T(T&&) 호출

// 복사가 선택된 경우
::new((void*)__p) T(source);
// → T(const T&) 호출

vector memcopy 기반 재할당

실제 메모리가 최적화되어 재배치되는 로직의 시작점은 이전과 마찬가지로 _M_realloc_insert 함수 내부에서 볼수 있다. 빌드 타임에 _S_use_relocate()의 참/거짓 여부에 따라 아래 코드와같은 메모리 벌크 카피 기반 최적화 재배치 로직이 호출될 수도, 직전에 설명한 std::__uninitialized_move_if_noexcept_a함수가 호출되어 일반적인 복사/이동연산 기반 재배치가 호출될수 있다.

if _GLIBCXX17_CONSTEXPR (_S_use_relocate())
{
  __new_finish = _S_relocate(__old_start, __position.base(), __new_start, _M_get_Tp_allocator());
  ++__new_finish;
  __new_finish = _S_relocate(__position.base(), __old_finish, __new_finish, _M_get_Tp_allocator());
}

최적화된 메모리 재배치 로직의 시작 부분은 기존 방식과 마찬가지로 삽입되는 위치를 기준으로 두 구간에 대해 재배치를 진행한다. 다만 여기서는 _S_relocate()를 사용하여 기존 원소를 새로운 메모리 영역으로 재배치한다. 그렇다면 _S_use_relocate()는 어떤 기준으로 relocation 경로를 선택하는 것일까? 실제 구현을 살펴보면 다음과 같다.

static constexpr bool _S_nothrow_relocate(true_type)
{
	return noexcept(std::__relocate_a(std::declval<pointer>(),
					  std::declval<pointer>(),
					  std::declval<pointer>(),
					  std::declval<_Tp_alloc_type&>()));
}

static constexpr bool _S_nothrow_relocate(false_type)
{
  return false;
}

static constexpr bool _S_use_relocate()
{
	// Instantiating std::__relocate_a might cause an error outside the
	// immediate context (in __relocate_object_a's noexcept-specifier),
	// so only do it if we know the type can be move-inserted into *this.
	return _S_nothrow_relocate(__is_move_insertable<_Tp_alloc_type>{});
}

_S_use_relocate는 먼저 __is_move_insertable을 통해 해당 타입이 move-insert 가능한지 확인하고, 그 결과를 noexcept(std::__relocate_a(...))을 통해 relocation 과정에서 예외가 발생하지 않는지를 컴파일 타임에 확인한다. 만약 여기서 true가 나오면 다음과 같은 코드가 실행된다. 참고로 해당 코드는 구현체 부분이 너무 길어서 좀 임의로 템플릿이나 컴파일타임 매크로는 좀 정리했다.

template <typename _Tp>
inline _Tp* __relocate_a_1(_Tp* __first, _Tp* __last, _Tp* __result) noexcept
{
    ptrdiff_t __count = __last - __first;

    if (__count > 0)
    {
        __builtin_memmove(
            __result,
            __first,
            __count * sizeof(_Tp));
    }

    return __result + __count;
}

기존의 일반적인 이동/복사 기반 재배치에서는 각각의 원소에 대해 이동 생성자나 복사 생성자를 호출해야 했다. 하지만 bitwise relocation이 가능한 타입의 경우에는 원소를 하나씩 처리하는 대신, 연속된 메모리 영역 전체를 memmove를 통해 한 번에 이동할 수 있다.

예를 들어 int 타입의 원소 3개를 재배치한다면 개념적으로 다음과 같이 처리할 수 있다.

__builtin_memmove(
    new_memory,
    old_memory,
    3 * sizeof(int));

즉, 개별 원소의 이동 생성 과정을 수행하는 것이 아니라 ```3 * sizeof(int)`` 크기의 메모리 영역을 통째로 새로운 메모리 영역으로 이동시키기 때문에 훨씬 빠른 재배치가 가능하게 된다.


위와 내용 같이 힙영역의 객체를 재할당하기 위한 기법이 몇가지 있는데, 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++과 프로그래밍 기술을 실무에서 다루며 뛰어난 팀원들과 함께 성장할 수 있어 요즘 참 뿌듯한것 같다.