우리가 만드는 게임과 프로그램들이 항상 문제없이 잘만돌아가면 좋겠지만 현실은 다양한 문제들이 발견되거나 내재되어있다.

이러한 문제들은 개발자가 의도한 정상적인 실행 로직의 범주에서 벗어난 예외라고 할 수 있다.

이 예외들을 해결하는 방법은 예외가 일어날만한 상황을 미리 생각해두어, 그 처리에 대한 구현을 추가해주는 것이다.

예외(Exception) 란?

예를들어 정수형을 받아 나눗셈을 해주는 함수 Divide가 있다고 해보자.

함수 Divide는 아래와 같다. (실행을 보기위해 콘솔 출력을 포함했다.)

float Divide(int numerator, int denominator)
{
	std::cout<<"Divide "<<numerator<<" by "<<denominator<<std::endl;
	return static_cast<float>(numerator/denominator);
}

위 코드는 얼핏보면 큰 문제가 없어보이지만, 그렇지 않다.

나눗셈을 할 때 한가지 조심해야하는 것은 0으로 나누면 안된다는 것이다.

그럼 우리는 이 코드를 아래와같이 수정해 버그를 일단 막을 수 있다.

float Divide(int numerator, int denominator)
{
	std::cout<<"Divide "<<numerator<<" by "<<denominator<<std::endl;
	//Division by Zero!
	if(denominator == 0)
		return 0.f;

	return static_cast<float>(numerator/denominator);
}

하지만, 여기서 우리는 Divide가 어찌되었든 실행되었기 때문에, 외부에서 다시 이 값이 정확한지 체크해야할 수도 있다. 근데 문제가 있다. 아래와같은 코드를 보자.

float result1 = Divide(10,2);
float result2 = Divide(10,0);
float result3 = Divide(0,2);

/*
Divide 10 by 2
Divide 10 by 0
Divide 0 by 2
*/

result1의 답은 5.f 로 실수형이 잘 나왔을 것이니 문제가 없다.

result2는 Division by Zero 문제가 발생했다. 그래서 별다른 계산 없이 0.f로 문제를 알리는 실수형 값이 나왔다

result3의 답으로는 0.f로 실수형이 나왔을 것이다. 분모가 0이 아니니까 Division by Zero 문제는 발생하지 않는다.

그런데 문제가 발생한 result2와 문제가 발생하지 않은 result3의 값이 같다.

이렇다면 어떻게 처리해야할까?

boolean을 레퍼런스로 하는 매개변수를 넣어서 아래처럼 계산결과가 정확한지 가져와야할까?

float Divide(bool& bOk, int numerator, int denominator)
{
	std::cout<<"Divide "<<numerator<<" by "<<denominator<<std::endl;
	//Division by Zero!
	if(denominator == 0)
	{
		bOk = false;
		return 0.f;
	}

	bOk=true;
	return static_cast<float>(numerator/denominator);
}

뭐… 이렇게 할 수도 있긴하다.. 근데 C++은 이러한 문제를 처리하는 방법을 제공해준다.

그것은 바로 예외(Exception) 이다.

C++에서의 예외처리

예외를 가장 쉽게 설명할 수 있는 방법은 코드로 보는 것이다.

위에서 가정한 문제를 예외를 이용해 처리한 예를 만들어보겠다.

float Divide(int numerator, int denominator)
{
	std::cout<<"Divide "<<numerator<<" by "<<denominator<<std::endl;
	//Division by Zero!
	if(denominator == 0)
		throw std::invalid_argument("Division by Zero!");
	
	return numerator/denominator;
}

아까와는 다르게 return이 아닌 throw 라는 키워드를 사용했다.

throw는 무언가를 던지다라는 뜻이다.

어디로 무엇을 던지는걸까 싶지만, 일단 invalid_argument라는 것을 통해 뭔가 잘못되었음을 알려주려고 하는 것 같아 보인다.

그럼 이제 저렇게 throw 된 문제를 밖에서 어떻게 해결해주는지 보자.

try
{
	float result1 = Divide(10, 2);
	float result2 = Divide(10, 0);
	float result3 = Divide(0, 2);
}
catch (std::exception e)
{
	std::cout<<std::endl<<e.what()<<std::endl;
}

/*
Divide 10 by 2
Divide 10 by 0

Division by Zero!
*/

위의 코드에서는 trycatch 라는 새로운 키워드가 등장했다.

try로 감싼 내부에서 exception이 발생하는지 본다.

그리고 catch로 감싼곳에서 각각의 throw된 예외들을 처리한다.

결과를 보니 Divide 10 by 0 이후 0을 2로 나누는 함수는 불리지 않았다.

그리고 발생한 예외에 대한 메시지인 Division by Zero! 가 출력되어있다.

예외처리는 잘 된 것 같다. 그런데 문제가 생긴 이후의 함수도 그냥 좀 불러주지 왜 그냥 넘어갔을까?

그 이유는 throw가 일어날 경우, 그 이후 코드로의 진행을 멈추고, 현재 함수를 호출한 외부로 나가는 스택 되감기(Stack Unwinding)가 일어나기 때문이다.

스택 되감기 (Stack Unwinding) 란?

C++ 에서 예외를 던지는 throw는 try구문 안에 있어야 하며, throw 발생 시 예외를 받아주는 catch구문으로 점프하게된다.

catch로 점프를 하기 위해서 catch를 찾아야 하는데 외부의 catch를 찾기 위해 현재의 스택정보를 정리하고 빠져나가는 스택 되감기(Stack Unwinding)가 발생하게 된다.

이 작업은 한 번만 일어나지 않을 수 있다. 에러와 대응하거나 범주에 속하는 catch를 찾을 때 까지 스택을 되감아 올라간다.

그렇기 때문에 이후에 진행되어야 할 작업들이 모두 강제로 취소되어버리게된다.

또한 스택 되감기를 위한 비용이 많이 들어간다.

아래의 사진은 위에서 일어난 상황을 대략적으로 설명해준다.

Exception 이라는 양날의 검

장점

위의 코드처럼 throw만 해주면 외부에서 문제들에 대해 각각 대응할수있다.

추가적인 메크로를 통해 코드를 더럽히지 않아도 되며, 복잡한 에러코드를 만들필요도 없이 std::exception을 상속받아 에러에 대한 정보를 구현해주면 그만이다.

그리고 예외는 Java나 Python같은 대부분의 현대적인 언어들에서 사용될 정도로 대중적이며 친화적인 방식중 하나이다.

대부분의 C++ 표준 코드들은 예외를 통해 예외를 제공해주기도 하고, 일부 서드파티 라이브러리들또한 예외를 지원해준다.

그래서 대부분의 경우 발생한 예외만 잘 처리해주면 큰 문제없이 라이브러리를 사용할 수 있을 것이다.

특별히 다른 처리를 하지 않은 이상 C++의 생성자의 예외를 처리할 수 있는 방법 또한 예외를 처리해주는 것이 유일하다.

위의 장점들만 본다면 예외를 안쓰면 바보라는 생각까지 들 수 있을 것 같다.

그런데, 우리가 쓰는 Unreal Engine은 예외를 코드에서 사용할 수 없게 해두었다.

왜일까? 왜 이렇게 쉽고 좋은 기능을 안쓴걸까?

단점

개인적으로 exception은 장점보다 단점이 많다고 생각한다.

그리고 Performance Critical한 게임에서는 하드웨어의 성능을 최대한 쥐어짜내야한다.

다양한 이유가 있겠지만 가장 먼저 경험했던 문제부터 따지고 봐보자.

위에서 사용한 아주 간략한 예제는 크게 문제될 일이 없어보일수도 있지만, 우리는 벌써 문제를 하나 경험했다.

아까 말했듯 결과를 보니 Divide 10 by 0 이후 0을 2로 나누는 함수는 불리지 않았다.

불릴것이라고 예상한 함수가 불리지 않았고, 실행 로직이 급변한 것이다.

이렇게 된다면 신경써야할 것들이 늘어난다.

Division by Zero에 대한 예외처리를 위한 예외처리를 더 해야할 수도 있다.

(물론 이와같은 꼬리에 꼬리를 무는 예외처리는 좋지 않다.)

또 하나의 예를 들어보겠다.

void AllocateWork()
{
	DynamicData = new Data;
	WorkingAndRelease(DynamicData);
}

void WorkingAndRelease(DynamicData* InDynamicData)
{
	//... Super Awesome Works
	if(bNotGood)
		throw std::logic_error("Super Awesome Error");
	
	delete InDynamicData;
}

무언가 시키는 작업을 할당하는 AllocateWork라는 함수가 있다고 하자.

이 함수는 동적 데이터를 생성해 WorkingAndRelease 라는 함수를 통해 작업을 시킨 후 해제를 명령해준다.

대부분의 경우 잘 작동하다가 특정 상황에서 throw가 일어났다면, throw 밑에 존재하는 최종 정리단계인 delete 즉 메모리 해제가 일어나지 않게 된다.

심지어 외부에는 try와 catch문도 없다.

이렇게 되면 메모리 누수 문제가 발생함과 동시에 프로그램의 안정성이 크게 흔들리게 된다

마지막예로 소멸자와 같은 곳에서 부르면 돌이킬 수 없이 위험할 수 있다.

예를 들어 호머 심슨이라는 객체가 도넛 100개를 먹는 행동을 한다고 해보자.

우리는 음식을 좋아하는 호머가 생성되어 죽기전에 도넛 100개 정도는 당연히 먹을 수 있다고 생각해 소멸자에 큰 의미를 두지 않았다.

그래도 혹시나 하는 마음에 마지막 조각을 먹는 체크를 EatLastDonut에서 하게한 뒤, 소멸자에서 예외를 내긴 내도록 처리했다고 생각해보자.

class HomerSimpson
{
public:
	HomerSimpson()
	{
		Donuts = new Donut[100];
	}
	~HomerSimpson()
	{
			EatLastDonut();
	}
	void EatLastDonut()
	{
		if (bCanEatDonut)
		{
			Donut* TargetDonut = Donuts[TargetDonutIndex];
			delete Donuts[TargetDonutIndex--];
		}
		else
		{
			throw std::overflow_error("Oops");
		}
	}
};

안타깝게도 호머는 너무 배가 불러 좋지 않아서 결국 마지막 도넛을 먹지 못했다.

그럼 이때 마지막 식사는 결국 throw std::overflow_error 를 내며 소멸자로 돌아오게 된다.

하지만 overflow_error를 처리해줄 곳은 없다.

그렇게 남은 도넛들은 해제된 객체를 두고 프로그램이 종료될 때까지 그 누구도 모른채로 남아있게 되었다.

정확히는 동적할당한 리소스가 해제되지 못한 것이다.

이처럼 throw가 한 번 일어나게 된다면 더 복잡하고 방대한 코드베이스의 경우 확인해야할 부분들이 너무나 많다. 그리고 모든 예외상황에 대해 확실한 이해를 필요로 하게 된다.

그래서 아까 말했던 것 처럼 코드의 흐름을 파악하기도 이해하기도 어려워지고, 예외처리에 대한 규칙을 만든다면, 모든 개발자가 그 예외규칙을 숙지해야하는 번거로움에 대한 비용이 발생한다.

새 프로젝트를 시작하며 예외 규칙을 확실히 정하고 모두가 숙지한 상태로 개발이 시작된다면 큰 문제가 없을 수도 있지만, 대부분의 경우 그렇지 않은게 현실이다.

기존 프로젝트에 예외를 추가하기에도 고려해야할 것들이 많아 서로 의존하는 관계를 다시 고민해서 설계를 바꿔야할 수도 있다.

throw가 발생하기 전까진 try-catch 구문은 그렇게 큰 퍼포먼스 손실은 없다.

하지만, 예외를 제대로 처리하지 못해서 추가적인 예외들이 발생하거나, 발생한 예외가 계속 히트되어 예외처리에 들어갈 경우 성능이 하락한다.

마지막으로 예외처리를 위한 코드를 처리하기 위해 컴파일이 끝난 경우에도 더 많은 코드가 발생한다. 이로 인해 프로그램의 사이즈 또한 커지게 된다.

성능 벤치마크

C++의 예외처리는 얼마나 느릴까? 한 번 테스트 해보자.

랜덤하게 생성된 분자와 분모를 만들기

static int TEST_SIZE = 10000;
//분모에 0 값만 들어가도록
static bool FORCE_ZERO_DENOMINATOR_MODE = false;
//분모에 0이 아닌 값만 들어가도록
static bool FORCE_NONE_ZERO_DENOMINATOR_MODE = true;

size_t GenerateTestData()
{
	ofstream Fout;
	Fout.open("testdata.txt");
	size_t ZeroCount = 0;
	for (int i = 0; i < TEST_SIZE; i++)
	{
		int numerator = rand();
		int denominator = FORCE_ZERO_DENOMINATOR_MODE ? 0 : (rand() % 4);
		if (FORCE_NONE_ZERO_DENOMINATOR_MODE)
			denominator + 1;

		if (denominator == 0)
			ZeroCount++;
		Fout << numerator << " " << denominator << "\\n";
	}
	Fout << std::endl;
	Fout.close();

	return ZeroCount;
}

위와같이 랜덤하게 분자와 분모를 만들어내었다.

기본적으로 분모에는 rand()%4로 0~3까지 분자의 값을 제한해두었다.

0으로 나누는 예외의 빈도수를 조절하기 위해 분모에 0만 넣거나 0을 절대 넣지 않으면서 만드는 체크도 진행한다.

테스트 코드

int main()
{
	size_t ZeroCount = GenerateTestData();

	cout << "Zero Denominator Count : " << ZeroCount << endl;

	ifstream ifs;
	ifs.open("testdata.txt");

	vector<pair<int, int>> Datas;
	int numerator, denominator;
	ifs >> numerator >> denominator;
	for (int i = 0; i < TEST_SIZE; i++)
		Datas.emplace_back(numerator, denominator);

	//If Handling Check
	chrono::system_clock::time_point IfStart = chrono::system_clock::now();
	BM_TEST_IF(Datas);
	chrono::duration<double> IfSec = chrono::system_clock::now() - IfStart;

	cout << "IF Handling : \\t\\t" << IfSec.count()*1000 << " (ms)" << endl;

	//Exception Handling Check
	chrono::system_clock::time_point ExceptionStart = chrono::system_clock::now();
	BM_TEST_EXCEPTION(Datas);
	chrono::duration<double> ExceptionSec = chrono::system_clock::now() - ExceptionStart;

	cout << "EXCEPTION Handling : \\t" << ExceptionSec.count() * 1000 << " (ms)" << endl;

	cout << endl;
	cout << endl << "Difference : " << abs(ExceptionSec.count()*1000 - IfSec.count()*1000) << " (sec)" << endl;
	cout << ((IfSec.count() < ExceptionSec.count()) ? "If is Faster" : "Exception is Faster") << endl;

	return 0;
}

테스트 방법 및 조건

  • 각각의 테스트는 서로 다른 데이터로 5회씩 진행한 결과를 작성을 낸다.
  • 테스트 사이즈는 1만 회 이다.
  • Debug x86 으로 빌드한다.
  • 분모가 특정 값으로 고정되는 랜덤 생성한 데이터의 경우 5회 평균을 내는 동안에도 값을 변경한다.
  • 결과는 ms 단위로 적는다.
  • 맨 마지막은 누가 더 빨랐는지, 얼만큼 더 빨랐는지에 대해 적는다.

분모가 0이거나 0이 아닌 경우

분모가 0인 데이터 갯수If (ms)Exception (ms)Difference (ms)Faster
5044개0.64334356.624355.98If
3352개0.64854236.814236.16If
2486개0.6614502.314501.65 If
1425개0.6624259.674259.01 If
1106개0.65654216.14215.45 If

분모가 무조건 0인 경우

분모가 0인 데이터 갯수If (ms)Exception (ms)Difference (ms)Faster
10000개0.6354178.514177.88If
10000개 0.68774172.654171.97If
10000개 0.72224096.84096.07If
10000개 0.65054209.244208.59If
10000개 0.64394126.894126.25If

분모가 무조건 0이 아닌 경우

분모가 0인 데이터 갯수If (ms)Exception (ms)Difference (ms)Faster
0개0.69030.68080.0095Exception
0개 0.6610.67480.0138If
0개 0.65590.65610.0002If
0개 0.65930.6590.0003Exception
0개 0.72210.65790.0642Exception

throw가 발생하는 이상 무조건 느려진다는 결론이 도출된다.

1만번의 테스트를 한다고 했을 때 약 6000배 정도 느리다.

throw가 한 번도 발생하지 않을 경우 퍼포먼스적인 타격은 아예 없다.

예외처리를 하면서 예외 타입에 따른 캐스팅과 스택 되감기가 일어나면서 퍼포먼스를 많이 저하시키는 것으로 보인다.

꼭 1만번의 throw가 일어나지 않더라도 성능이 느려지는 것에는 많은 차이가 있는 것 같지는 않다.

게임 프로젝트에서 C++ 예외를 사용하지 않는 이유

지금까지 설명을 들어보면 C++ 예외는 사용해도 되지만, 문제가 좀 많다는 것을 생각하게 되었을 것이다.

왠만한 게임 프로젝트 또는 게임 엔진은 거대하고 복잡한 코드들이 잔뜩 들어간다.

그리고 원활한 게임 플레이를 위해 CPU와 GPU의 성능을 최대한 쥐어 짜서 최대한 많은 프레임을 계산할 수 있도록 노력해야한다.

이러한 요구사항과 환경에서 예외를 사용하기엔 무리가 있다.

게임은 워낙 다양한 기능들과 행동들이 각각의 객체를 넘어 하드웨어 부분까지 서로 의존적으로 행동하는 경우가 많다.

하지만, 예외를 사용하는 경우 스택 되감기를 사용해야하고, 이전에 설명했듯 catch 구문을 만날 때 까지 스택이 되감아진다.

이 경우 우리가 모르는 어떤 문제를 어떻게 해결해야할지 놓치는 경우가 발생할 것이다.

그렇게 된다면 그에 따른 기술적인 유지관리 비용이 많이 들게 된다.

퍼포먼스적으로 손실이 있는 것이 일단 가장 큰 근본적인 문제이다.

말했듯 게임은 가장 그럴듯하게 보이면서도, 빨리 돌아가야한다. 하지만 throw가 발생할 경우 상당한 성능적 타격을 입게 될 것이다.

물론 throw가 한 번도 발생하지 않게 만들었다면 문제가 없을수도 있다. 하지만 과연 그럴 수 있을까…?

마지막으로, 코드는 어느정도 보는 것 만으로도 예측 가능한 상태로 작성되어야 그나마 일하기 편할 것이다.

하지만 C++의 예외를 사용하게 된다면, 바로 스택 되감기가 되며 catch를 찾을 때 까지 원래의 공간을 벗어난다. 그렇게 된다면 협업이 필요한 게임 개발을 넘어 모든 경우에서 아마 큰 기술적 비용이 발생할 것이다.

그래서 게임 프로젝트 뿐만 아니라, 대부분의 프로그램들에서는 C++의 exception을 사용하지 않는 다고 한다.

Unreal Engine의 경우

언리얼 엔진의 경우 C++의 예외처리 방식을 거의 사용하지 않고, 사용할 수 없도록 강제한다.

언리얼은 대신 Assert 를 이용할 수 있는 메크로와 시스템을 제공한다.

**https://docs.unrealengine.com/ko/ProgrammingAndScripting/ProgrammingWithCPP/Assertions/index.html**

어서트는 포인터의 null 체킹같은 간단한 것 부터, 특정 함수들에 재진입했는지 같은 복잡한 검증도 가능하다.

퍼포먼스를 위해 특정 빌드 옵션에서는 작동하지 않게 설정할 수도 있다.

런타임 어서트 매크로는 총 3가지의 카테고리가 있다.

  • 실행 중지 (DO_CHECK=1 define에 따라 컴파일)
  • 디버그 빌드에서 실행 중지 (DO_GUARD_SLOW define에 따라 컴파일)
  • 실행 중지하지 않고 오류 보고 (DO_CHECK=0)

가장 자주 사용할법한 두가지의 메크로만 소개하겠다.

check

무언가 참이여야만 하는데, 참이 아닐 경우 게임을 진행시킬 수 없어 결국 크래시를 일으켜 오류를 알려야하는 상황이 올 수 있다.

이처럼 치명적인 상황에서는 언리얼은 자동으로 콜스택을 띄워주는데, 이것을 지정해줄수도 있다.

//Initialized Failed!!! ~

check(Character != nullptr);

예를 들어 무언가 초기화에 실패한 경우처럼 위의 표현식이 거짓이 된 상태로, 캐릭터가 생성되지 않는다고 하면, 우리는 핵심 기능을 잃게 된다.

이럴경우 게임을 진행할 수 없는데, 이럴때는 위처럼 check 메크로를 이용해 크래시를 발생시킬 수 있다.

ensure

실행을 중지시켜버리지는 않아도 괜찮으나, 문제가 있을 경우 불린 콜스택을 생성해주는 메크로이다.

if (ensure( InObject != NULL ))
{
	InObject->SomethingAwesome();
}

참고자료

https://google.github.io/styleguide/cppguide.html#Exceptions

https://www.youtube.com/watch?v=_Ivd3qzgT7U

https://gamedev.net/forums/topic/674790-c-exceptions/5271464/

https://docs.microsoft.com/ko-kr/cpp/cpp/exceptions-and-stack-unwinding-in-cpp?view=msvc-160

https://docs.unrealengine.com/ko/ProgrammingAndScripting/ProgrammingWithCPP/Assertions/index.html

https://pspdfkit.com/blog/2020/performance-overhead-of-exceptions-in-cpp/

https://github.com/PSPDFKit-labs/cpp-exceptions-testing

http://www.open-std.org/jtc1/sc22/wg21/docs/TR18015.pdf (section 5.4)