예외가 존재하는 이유
이전 페이지에서는 enum class를 사용해 오류 상태에 의미 있는 이름을 붙였습니다. 함수가 예상하고 호출자가 확인해야 하는 결과에는 이것이 아주 좋습니다. 하지만 어떤 실패는 다릅니다. 호출 스택 깊숙한 곳에 있는 함수가 파일이 열리지 않거나 인수가 전혀 말이 안 된다는 것을 발견해도, 그 함수는 프로그램이 그에 대해 무엇을 해야 할지 전혀 알지 못합니다. 오류 코드를 반환하는 방식은 체인에 있는 모든 호출자가 그것을 확인하고 위로 전달하는 것을 잊지 않을 때만 작동합니다. 단 하나의 확인이라도 놓치면 프로그램은 쓰레기 데이터를 안고 그대로 항해를 계속합니다.
예외는 이를 해결합니다. 무언가 잘못되면 throw로 객체를 던집니다. 실행은 즉시 멈추고, 스택이 풀리며(throw와 핸들러 사이의 모든 지역 객체의 소멸자가 실행되고) 제어는 가장 가까운 일치하는 catch로 점프합니다. 처리되지 않은 예외는 조용히 무시될 수 없습니다. 아무것도 잡지 않으면 프로그램은 std::terminate를 호출하고 중단됩니다.
이 페이지는 던지는 쪽, 즉 오류 객체 자체에 초점을 맞춥니다. 다음 페이지에서는 try/catch의 메커니즘을 자세히 파고듭니다.
throw와 what() 메시지
기술적으로는 어떤 값이든 throw할 수 있습니다. throw 42;나 throw "oops";도 합법입니다. 하지만 그렇게 하지 마세요. 모두가 따르는 관례는 std::exception에서 파생된 객체를 던지는 것입니다. 이 기본 클래스는 문제에 대한 const char* 설명을 반환하는 단 하나의 가상 메서드 what()을 선언합니다. 관례를 지키면 단 하나의 catch (const std::exception& e)로 무엇이든 처리할 수 있습니다.
<stdexcept> 헤더는 생성자가 메시지를 받는 미리 만들어진 타입들을 제공합니다.
what()이 예외를 생성할 때 사용한 문자열을 그대로 반환한다는 점에 주목하세요. 또한 runtime_error를 던졌는데도 const exception&로 잡았다는 점에도 주목하세요. 이것이 작동하는 이유는 runtime_error가 std::exception이기 때문입니다(상속 페이지에서 익숙해진 관계입니다).
표준 예외 계층 구조
자신만의 예외 타입을 작성하기 전에, 표준 라이브러리에 이미 맞는 것이 있는지 확인하세요. 이들은 모두 std::exception을 상속하며 <stdexcept> 안에서 두 갈래로 나뉩니다.
logic_error— 원칙적으로 실행 전에 잡을 수 있는, 프로그램 로직상의 버그. 하위 타입으로invalid_argument,out_of_range,domain_error,length_error가 있습니다.runtime_error— 실행 시점에만 드러나며 그 자체로는 프로그래밍 실수라고 보기 어려운 실패. 하위 타입으로range_error,overflow_error,underflow_error가 있습니다.
많은 라이브러리 함수가 이들을 대신 던져 줍니다. 예를 들어 std::vector::at()은 경계 검사를 수행하고, 끝을 넘어 읽게 두는 대신 out_of_range를 던집니다.
이 at()은 v[9]의 안전한 대응물입니다. 평범한 operator[]는 경계 검사를 전혀 하지 않습니다. 여기서 v[9]를 읽는 것은 예외가 아니라 정의되지 않은 동작입니다. at()을 선택하는 것은 조용한 손상을 잡을 수 있는 오류로 바꾸는 방법입니다.
오류를 설명하는 타입을 고르세요. 호출자가 말이 안 되는 것을 전달할 때는 invalid_argument, 인덱스/키 문제에는 out_of_range, "바깥 세상이 나를 실패하게 만들었다"는 경우에는 runtime_error를 씁니다.
자신만의 예외 타입 작성하기
어떤 표준 타입도 맞지 않을 때——추가 데이터를 붙이고 싶거나, 다른 것은 두고 자신의 오류만 콕 집어 catch하고 싶을 때——std::exception(또는 그 하위 타입 중 하나)을 상속하는 클래스를 정의하고 what()을 재정의하세요. std::runtime_error를 상속하는 것이 가장 쉬운 길입니다. 이미 메시지를 저장하고 what()을 대신 구현해 주기 때문입니다.
NetworkError는 상태 코드를 담고 있기 때문에 핸들러가 그에 반응할 수 있습니다. 5xx면 재시도하고, 4xx면 포기하는 식입니다. 단순한 오류 문자열로는 그렇게 할 수 없습니다. 사용자 정의 타입은 또한 catch (const NetworkError&)가 네트워크 문제만 잡고 나머지는 모두 그 아래에 있는 더 일반적인 핸들러에 맡기도록 해 줍니다.
언젠가 std::exception에서 (runtime_error가 아니라) 직접 상속하게 된다면, what()을 직접 재정의하고 기본 클래스의 시그니처에 맞게 noexcept로 표시하는 것을 잊지 마세요.
class ParseError : public std::exception {
public:
const char* what() const noexcept override {
return "failed to parse input";
}
};
값으로 던지고, 참조로 받아라
이것은 C++ 예외에서 가장 중요한 규칙이며, 초보자가 틀리는 부분입니다. 객체는 값으로 던지고 const 참조로 받으세요.
throw runtime_error("oops"); // 값으로 - 올바름
catch (const runtime_error& e) { ... } // const 참조로 - 올바름
대신 값으로 받으면——catch (std::exception e)——예외가 기본 클래스 객체로 복사되면서 파생 부분이 잘려 나갑니다(슬라이싱). 슬라이싱 이후 e.what()은 재정의한 것이 아니라 기본 구현을 호출하므로, 정성껏 만든 메시지가 사라집니다.
try {
throw NetworkError(503, "service unavailable");
} catch (std::exception e) { // 값으로 - 객체 슬라이싱!
std::cout << e.what(); // 일반적인 메시지, status()는 사라짐
}
참조(&)는 실제 동적 타입을 보존하므로 가상 what()이 올바르게 디스패치되고 파생 멤버에도 여전히 접근할 수 있습니다. 예외를 수정하지 않고 읽기만 하므로 const를 붙이세요. 절대 포인터를 던지지 마세요(throw new runtime_error(...)). 받는 쪽이 그것을 delete해야 하는데, 그것도 어느 실행 경로에서요? 그것이야말로 예외가 막아야 할 바로 그 누수입니다.
다음: try-catch
이제 잘 구성된 예외를 만들어 throw하고 각 실패에 맞는 올바른 표준 타입을 고를 수 있습니다. 이야기의 나머지 절반은 받는 쪽입니다. 다음 페이지는 try/catch를 빠짐없이 다룹니다. 여러 catch 블록을 가장 구체적인 것에서 가장 일반적인 것 순으로 배치하기, 모든 것을 받는 catch (...), 맨 throw;로 다시 던지기, 그리고 RAII(스마트 포인터를 떠올려 보세요)가 스택이 풀리는 동안 리소스 해제를 어떻게 보장하는지입니다.
자주 묻는 질문
C++에서 예외란 무엇인가요?
예외는 현재 함수가 혼자서는 처리할 수 없는 오류를 알리는 객체입니다. 이를 throw로 던지면 스택이 풀리면서(그 과정에서 지역 객체들이 소멸되고) 위쪽에 있는 일치하는 catch 블록이 처리를 넘겨받습니다. 이렇게 하면 문제를 감지하는 코드와 그 문제에 어떻게 대응할지 결정하는 코드를 분리할 수 있습니다.
오류 처리에서 throw와 return의 차이는 무엇인가요?
return 값은 호출자가 확인해야 하는데, 이를 잊기 쉽습니다. 그러면 프로그램은 잘못된 데이터를 가진 채 그대로 계속 진행해 버립니다. 던져진 예외는 무시할 수 없습니다. 아무도 잡지 않으면 프로그램이 종료됩니다. 예외는 진짜 실패(파일이 열리지 않거나 입력이 유효하지 않은 경우 등)를 위한 것입니다. 반환 값은 예상된 "찾을 수 없음" 경우를 포함한 일반적인 결과에는 여전히 적합합니다.
C++ 예외에서 what() 메서드는 무엇을 하나요?
std::exception에서 파생된 모든 클래스는 오류를 설명하는 const char*를 반환하는 가상 메서드 what()을 제공합니다. 예외를 잡았을 때 e.what()을 호출하면 로그로 남기거나 출력할 수 있는, 사람이 읽을 수 있는 메시지를 얻습니다. 표준 예외 타입들은 생성자에 전달한 문자열로부터 이 메시지를 설정합니다.