나만의 작은 도서관
[TIL][C++] 250930 MMO 서버 개발 107일차: [C++] 예외 처리 다시 정리 본문
주의사항: 해당 글은 일기와 같은 기록용으로, 다듬지 않은 날것 그대로인 글입니다.
[C++] 예외 처리 다시 정리
예외 처리를 왜 하는가?
- 개발자도 사람인지라 실수를 한다. 개발자가 하는 실수의 종류는 크게 두 가지로 나눌 수 있는데, 하나는 1) 문법적 오류, 다른 하나는 2) 논리적 오류이다.
- 문법적 오류 같은 경우, Visual Studio와 같은 IDE가 잘못된 문법을 발견할 때마다 “빨간 줄”로 표시를 해주기 때문에, (그리고 컴파일 자체가 실패하기 때문에) 런타임에 문제가 생기는 경우는 크게 없다.
- 반면 논리적 오류의 경우, 런타임에 문제가 생기는데 오류가 발생했을 경우, 크래시가 날 수도 있지만 그렇지 않은 경우도 있다. 즉, 컴퓨터가 바라보기엔 치명적인 문제는 아니지만 의도치 않은 방식으로 수행된 로직이 분명히 존재할 수 있다는 것이다.
- 그래서 크래시가 나지 않는 의도치 않은 방식으로 수행된 로직을 잡아내는 것이 필요한데, 이 때 사용하는 방법 중 하나가 바로 예외 처리를 사용하는 것이다.
기존 방식인 방어 코드의 문제점. 리턴값 활용의 제한
- 의도치 않은 방식으로 로직이 수행되는 것을 막는데 사용하는 전형적인 방식은 바로 “방어 코드”를 사용하는 것이다. 방어 코드는 로직 앞에 if문을 활용하여 잘못된 값으로 로직을 수행하지 못하도록 막는 코드를 의미한다. 예시를 들자면 아래와 같다.
void MyClass::func1(int num)
{
if(num == 0) // 방어 코드
return;
memberNum /= num;
}
- 위 예시는 0으로 나누는 상황을 방지하기 위해 방어코드를 넣어준 상황이다. 처음부터 num에 대한 값을 0을 넘겨주지 않는 것이 이상적이겠지만 실수로 넘겨주는 상황이 발생해도 문제가 생기지 않도록 하는 것도 중요하기 때문에 위와 같이 방어 코드를 사용할 수 있다.
bool MyClass::func1(int num)
{
if(num == 0) // 방어 코드
return false;
memberNum /= num;
return true
}
// ... func1 호출 부분
if(func1(0) == false)
return false;
- 만약, 계산 자체가 정상적으로 수행되었는지 호출 부분에서 확인하고 싶다면 리턴값을 bool 타입으로 하여 확인할 수 있다.
- 문제는 위와 같이 bool 값을 사용하여 작업의 실패를 알려야 하는 경우라면 호출된 함수의 깊이가 깊어질수록 로직이 굉장히 복잡해지고 읽기 어려워진다는 것이다. 예시는 아래와 같다.
bool func1(int *addr) {
if (func2(addr)) {
// Do something
}
return false;
}
bool func2(int *addr) {
if (func3(addr)) {
// Do something
}
return false;
}
bool func3(int *addr) {
addr = (int *)malloc(100000000);
if (addr == NULL) return false;
return true;
}
int main() {
int *addr;
if (func1(addr)) {
// 잘 처리됨
} else {
// 오류 발생
}
}
- 위 코드의 핵심은 호출 순서가 main → func1 → func2 → func3이며, func3에서 과도한 메모리 할당에 의해 작업이 실패하는 시나리오이다. 방어 코드를 활용한 위 예시를 보면 느껴지는 부분이 하나 있는데, 그것은 바로 func3의 성공 여부가 bool 값으로 리턴되기 때문에 **“func3의 작업이 포함되는 모든 함수들의 리턴값이 bool 타입으로 강제되며 다른 값을 리턴할 수 없게 된다”**는 것이다. 이는 func3 작업을 포함하는 작업, 즉 위 예시로는 func2나 func1이 다른 작업에 의한 결과물로 리턴값을 활용해야 하는 경우 상당히 골치가 아파진다.
예외 처리의 장점. 오류와 정상 로직의 분리(Separation Of Concerns)
- 예외 처리, 정확하게는 try-catch문과 throw를 활용하는 방식은 위와 같은 방어 코드를 활용했을때 발생하는 문제점을 획기적으로 해결시켰다. 같은 예시에 대해 try-catch문을 활용하는 방식으로 변경하면 아래와 같이 바뀐다.
void func1(int *addr) {
func2(addr);
}
void func2(int *addr) {
func3(addr);
}
void func3(int *addr) {
addr = (int *)malloc(100000000);
if (addr == NULL)
throw string("malloc fail");
}
int main() {
int *addr;
try{
func1(addr);
}
catch(const string& cause){
std::cout << cause << endl;
}
}
- try-catch문으로 변경하니 각 함수의 리턴값 활용이 자유로워지며, 전체적인 코드의 복잡도도 크게 줄어든 것을 확인할 수 있다. (함수마다 방어 코드를 넣지 않아도 되기 때문!)
- 이렇듯 try-catch문을 사용하면 정상적인 프로그램 흐름과 오류 발생 시의 처리 로직을 명확하게 분리할 수 있게 되면서, 보다 코드가 깨끗하고, 읽기 쉽고, 유지 보수하기가 쉬워진다.
throw의 특징 1. 제어 흐름의 즉각적인 이탈
- 방어 코드의 경우 깊게 중첩된 함수 호출(main → func1 → func2 → func3)중 가장 안쪽에 존재하는 함수 func3에서 치명적인 오류가 발생했을 때, 오류 정보를 호출 스택을 거슬러 올라가며(func3 → func2로, func2 → func1으로…) 매 단계마다 확인하고 처리해줘야 했었다.
- 하지만, 예외를 throw로 던져버리면 던진 시점에서부터 “즉시” 제어 흐름을 호출 스택에 따라 가장 가까운 catch문으로 “점프”한다. 그래서 throw를 던진 위치와 catch 문 사이에 존재하는 코드들은 실행되지 않는다. 예시를 들자면 아래와 같다.
void func1(int *addr) {
func2(addr);
std::cout << "func1 end" << std::endl;
}
void func2(int *addr) {
func3(addr);
std::cout << "func2 end" << std::endl;
}
void func3(int *addr) {
addr = (int *)malloc(100000000);
if (addr == NULL)
throw string("malloc fail");
std::cout << "func3 end" << std::endl;
}
int main() {
int *addr;
try{
func1(addr);
}
catch(const string& cause){
std::cout << cause << endl;
}
}
실행 결과
malloc fail
- throw를 던진 다음, 다음 코드인 출력 코드들이 실행되지 않을까 의심했지만, 실행되지 않는 것을 알 수 있다. 이유는 위에서 설명했듯 catch문으로 바로 “점프”를 하기 때문이다.
throw의 특징 2. Stack Unwinding
- throw를 던지면 가까운 catch문으로 점프한다고 했다. 그렇다면 catch문으로 점프하면서 중간에 끼어있는 함수 내부에서 사용한 메모리는 제대로 해제해 줄까?
- 그렇다. throw를 던지면 적절한 catch문을 만날 때까지 예외가 전파되는 과정에서 각 함수들에서 사용한 메모리가 해제된다. 즉, 각 함수 내부에 정적 객체를 선언했을 때 각 객체들이 알아서 해제된다는 것이다.
#include <iostream>
#include <stdexcept>
class Resource {
public:
Resource(int id) : id_(id) {}
~Resource() { std::cout << "리소스 해제 : " << id_ << std::endl; }
private:
int id_;
};
int func3() {
Resource r(3);
throw std::runtime_error("Exception from 3!\\n");
}
int func2() {
Resource r(2);
func3();
std::cout << "실행 안됨!" << std::endl;
return 0;
}
int func1() {
Resource r(1);
func2();
std::cout << "실행 안됨!" << std::endl;
return 0;
}
int main() {
try {
func1();
} catch (std::exception& e) {
std::cout << "Exception : " << e.what();
}
}
실행 결과
리소스 해제 : 3
실행!
리소스 해제 : 2
실행!
리소스 해제 : 1
- 이렇듯 throw 된 예외를 처리할 적절한 catch 핸들러를 찾기 위해 호출 스택을 따라 역순으로 함수들을 종료시키는 과정을 Stack Unwinding이라고 부른다.
추가 팁들
- try-catch문은 어디까지나 try 블록 내에 throw가 존재할때만 의미가 있다. try 블럭 내에 throw 하는 코드가 존재하지 않는다면 try-catch문으로 묶어줘도 의미가 없다. 즉, try-catch문으로 묶었다고 런타임 에러가 발생하지 않음을 보장한다거나 그러지는 않는다는 것이다.
- noexcept는 함수에 const처럼 뒤에 붙이는 용도. 컴파일러에게 알려주는 힌트다. noexcept를 넣고 throw를 던져도 컴파일 에러는 발생하지 않는다. 즉, noexcept와 throw는 공존이 가능하다는 것. → 실수가 허용된다는 말임.
- noexcept가 붙은 경우, throw는 무시된다.
