예외 처리의 기본 개념
실행 중 예외와 기존 오류 반환 방식의 한계를 이해하고 throw·try·catch로 정상 흐름과 실패 처리를 분리합니다.
프로그램은 메모리가 모자라거나, 열려는 파일이 없거나, 입력이 형식에 맞지 않는 등 정상적으로 이어 갈 수 없는 상황을 만납니다. 지금까지는 이런 실패를 반환값이나 스트림의 상태로 알렸는데, 호출이 깊어질수록 모든 단계가 실패를 확인하고 다시 전달해야 해서 코드가 복잡해집니다. 예외(exception)는 실패를 정상적인 반환과 다른 경로로 전달해, 실패를 처리할 수 있는 곳까지 한 번에 보냅니다.
이 장에서는 C++의 예외 처리를 다룹니다. 이 글은 기존의 오류 처리 방식이 가진 한계, 예외를 던지고 잡는 throw, try, catch의 기본 동작, 예외가 호출을 거슬러 전달되며 지역 객체를 정리하는 스택 풀기를 다룹니다.
예외가 되는 실패와 아닌 실패
표준 라이브러리는 여러 실패를 예외로 알립니다. 동적 메모리 할당에서 본 것처럼 new는 메모리가 모자라면 std::bad_alloc을, std::vector의 at()은 범위 밖의 인덱스에 std::out_of_range를 던집니다.
하지만 모든 실패가 저절로 예외가 되지는 않습니다. 배열이나 operator[]의 범위 밖 접근은 예외가 아니라 정의되지 않은 동작이고, 정수를 0으로 나누는 것도 정의되지 않은 동작이어서 catch로 잡을 수 없습니다. 스트림과 파일 입출력 개요에서 본 파일 스트림도 기본으로는 실패를 예외 대신 상태 플래그로 알립니다. 그래서 어떤 함수가 실패를 어떻게 알리는지는 그 함수의 설명을 보고 확인해야 합니다.
기존 오류 처리 방식의 한계
가장 흔한 방법은 실패할 때 특별한 반환값을 돌려주는 것입니다. 아래 divide는 0으로 나누려 하면 계산하지 않고 -1을 돌려주어 실패를 알립니다. main은 실패하는 호출과 정상적인 호출을 한 줄에 나란히 출력하므로, 두 값을 구별할 수 있는지 보세요.
#include <iostream>
int divide(int a, int b) {
if (b == 0) {
return -1; // 실패를 나타내는 값
}
return a / b;
}
int main() {
std::cout << divide(10, 0) << ' ' << divide(-2, 2) << '\n';
}-1 -10으로 나누려 한 호출과 -2를 2로 나눈 정상적인 호출이 똑같이 -1을 돌려주므로, 결과만 보고는 실패인지 구별할 수 없습니다. 이처럼 실패를 나타내는 값이 정상 결과와 겹칠 수 있고, 호출하는 쪽이 반환값을 확인하지 않고 넘어가도 컴파일러가 막지 않습니다. 호출이 깊으면 중간의 모든 함수가 실패를 확인하고 다시 돌려줘야 합니다.
| 방식 | 장점 | 한계 |
|---|---|---|
| 특별한 반환값 | 단순하고 비용이 적음 | 정상 값과 겹칠 수 있고, 확인을 빠뜨리기 쉬움 |
| 전역 오류 변수 | 반환값과 따로 알림 | 어느 함수의 실패인지 알기 어렵고, 여러 스레드에서 안전하지 않음 |
assert | 개발 중에 버그를 빨리 찾음 | 릴리스 빌드에서 빠질 수 있고, 실패하면 프로그램을 끝냄 |
반환값 방식도 실패를 정상 값과 겹치지 않게 따로 돌려준다면 좋은 방법입니다. C++17의 std::optional로 값이 없음을 나타내거나, C++23의 std::expected로 값 또는 오류를 돌려줄 수 있습니다. assert는 프로그래머가 지켜야 할 전제가 깨졌는지 개발 중에 확인하는 도구이며, 사용자의 잘못된 입력처럼 정상적으로 일어날 수 있는 실패를 처리하는 데는 쓰지 않습니다.
throw, try, catch
예외 처리는 세 키워드로 이루어집니다. throw는 예외 객체를 던져 실패를 알리고, try 블록은 예외가 생길 수 있는 코드를 감싸며, 그 뒤의 catch 블록은 지정한 타입의 예외를 잡아 처리합니다.
#include <iostream>
#include <stdexcept>
double safeDivide(double numerator, double denominator) {
if (denominator == 0) {
throw std::invalid_argument("division by zero");
}
return numerator / denominator;
}
int main() {
try {
std::cout << "10 / 2 = " << safeDivide(10.0, 2.0) << '\n';
std::cout << "10 / 0 = " << safeDivide(10.0, 0.0) << '\n';
std::cout << "not reached\n";
} catch (const std::invalid_argument& e) {
std::cout << "caught: " << e.what() << '\n';
}
std::cout << "program continues\n";
}10 / 2 = 5
10 / 0 = caught: division by zero
program continuessafeDivide(10.0, 0.0)이 예외를 던지는 순간 그 함수와 try 블록의 나머지는 실행되지 않고, 던진 객체의 타입과 맞는 catch 블록으로 바로 갑니다. 다만 연산자 우선순위에서 본 것처럼 C++17부터 <<는 왼쪽 피연산자를 먼저 계산하므로, 그 줄의 10 / 0 = 는 safeDivide를 부르기 전에 이미 출력됩니다. 그래서 그 줄은 결과와 줄바꿈 없이 끊긴 채 caught: division by zero가 바로 뒤에 이어지고, not reached는 출력되지 않습니다. catch 블록이 끝나면 try와 catch 전체의 다음 문장인 program continues로 이어집니다.
std::invalid_argument는 <stdexcept>에 있는 표준 예외 클래스이며, 생성자에 넘긴 문자열을 what()으로 돌려줍니다. C++에서는 int나 문자열 같은 어떤 타입의 값도 던질 수 있지만, 보통은 이처럼 표준 예외 클래스나 그것을 상속한 클래스를 던집니다. 무엇을 던질지는 예외 던지기에서 자세히 다룹니다.
예외의 전파와 스택 풀기
throw가 실행되면, 실행 중인 try 블록 가운데 가장 가까운 것부터 그 예외를 받을 수 있는 catch를 찾습니다. 지금 함수에 없으면 그 함수를 끝내고 호출한 곳으로 가서 다시 찾으며, 이렇게 호출을 거슬러 올라가며 처리할 곳을 찾는 것을 예외의 전파(propagation)라고 합니다. 이때 빠져나가는 블록의 지역 객체는 만들어진 순서의 역순으로 소멸자가 실행되며, 이를 스택 풀기(stack unwinding)라고 합니다.
#include <iostream>
#include <stdexcept>
#include <string>
struct Guard {
~Guard() { std::cout << "Guard released\n"; }
};
int parse(const std::string& text) {
if (text.empty()) {
throw std::invalid_argument("empty input");
}
return std::stoi(text);
}
int service(const std::string& text) {
Guard guard;
int value = parse(text);
std::cout << "parsed " << value << '\n';
return value;
}
int main() {
try {
service("42");
service("");
std::cout << "not reached\n";
} catch (const std::invalid_argument& e) {
std::cout << "caught: " << e.what() << '\n';
}
std::cout << "continue\n";
}parsed 42
Guard released
Guard released
caught: empty input
continue첫 호출은 정상적으로 끝나며 service가 반환할 때 guard의 소멸자가 실행됩니다. 아래 그림처럼 둘째 호출에서는 service가 guard를 만든 뒤 parse가 예외를 던지고, service에는 catch가 없으므로 service를 빠져나가면서 guard가 정리된 뒤 main의 catch에 도착합니다. parsed의 출력과 return은 실행되지 않지만 Guard released는 출력됩니다.
throw는 정상 흐름을 끊고 RAII 정리를 거쳐 복구 경계로 전파된다
service가 Guard를 획득하고 parse의 throw 뒤 정리·catch 전달로 이어지는 여섯 메시지의 실제 시간 순서다. 처리 handler가 있는 경로에만 한정한다.
C++ · EXCEPTION FLOW
예외를 복구 경계로 전달하며 지역 자원을 정리한다
이 그림에서는 service의 지역 Guard를 만든 뒤 parse가 예외를 던집니다. 호환 catch까지 벗어나는 범위의 완성된 자동 객체를 역순으로 정리합니다. 같은 함수에서 처리하는 catch가 있다면 함수 프레임 전체를 없애는 흐름과 구분합니다.
- 오류 발견
API 계약이 예외를 사용하는 실패에서 의미 있는 표준 예외 타입을 만듭니다.
- throw
현재 정상 경로를 즉시 중단하고 handler 탐색을 시작합니다.
- stack unwinding
완성된 자동 객체의 destructor가 역순으로 자원을 정리합니다.
- 복구 경계
구체적인 호환 catch가 처리·번역·재던지기 중 한 정책을 수행합니다.
그림은 Guard를 둔 별도 역할 예시이다. 처리할 handler가 없는 종료 경로의 정리를 보장하는 그림이 아니다. 정수 0 나눗셈과 잘못된 operator[] 접근은 자동으로 C++ 예외가 되지 않는다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | 같은 네 역할의 여섯 메시지다. 시간은 위에서 아래로 흐른다. 복구 경계가 service를 호출하고, service가 Guard 생성 후 parse에 검증을 요청한다. parse의 예외가 service로 전파되면 service의 지역 Guard가 소멸하고 호환 catch 경계로 예외가 전달된다. 화살표는 호출·정리 요청 또는 예외 제어 전달이고 정상 return 값이 아니다. 모바일도 같은 4개 역할과 6개 방향을 유지하며 service()와 호출 프레임은 줄만 나눈다. 원래 functionA/B/C와 별도 Guard 역할 예시이며 처리 handler가 있는 경로만 보인다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 자원 획득 service가 Guard를 만든 뒤 parse를 호출한다. |
|
|
| 예외 전파와 정리 parse가 throw해 service 범위를 벗어난다. |
|
|
| handler 처리 호환 catch가 예외를 처리한다. |
|
|
throw는 정상 흐름을 끊고 RAII 정리를 거쳐 복구 경계로 전파된다
service가 Guard를 획득하고 parse의 throw 뒤 정리·catch 전달로 이어지는 여섯 메시지의 실제 시간 순서다. 처리 handler가 있는 경로에만 한정한다.
C++ · EXCEPTION FLOW
예외를 복구 경계로 전달하며 지역 자원을 정리한다
이 그림에서는 service의 지역 Guard를 만든 뒤 parse가 예외를 던집니다. 호환 catch까지 벗어나는 범위의 완성된 자동 객체를 역순으로 정리합니다. 같은 함수에서 처리하는 catch가 있다면 함수 프레임 전체를 없애는 흐름과 구분합니다.
- 오류 발견
API 계약이 예외를 사용하는 실패에서 의미 있는 표준 예외 타입을 만듭니다.
- throw
현재 정상 경로를 즉시 중단하고 handler 탐색을 시작합니다.
- stack unwinding
완성된 자동 객체의 destructor가 역순으로 자원을 정리합니다.
- 복구 경계
구체적인 호환 catch가 처리·번역·재던지기 중 한 정책을 수행합니다.
그림은 Guard를 둔 별도 역할 예시이다. 처리할 handler가 없는 종료 경로의 정리를 보장하는 그림이 아니다. 정수 0 나눗셈과 잘못된 operator[] 접근은 자동으로 C++ 예외가 되지 않는다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | 같은 네 역할의 여섯 메시지다. 시간은 위에서 아래로 흐른다. 복구 경계가 service를 호출하고, service가 Guard 생성 후 parse에 검증을 요청한다. parse의 예외가 service로 전파되면 service의 지역 Guard가 소멸하고 호환 catch 경계로 예외가 전달된다. 화살표는 호출·정리 요청 또는 예외 제어 전달이고 정상 return 값이 아니다. 모바일도 같은 4개 역할과 6개 방향을 유지하며 service()와 호출 프레임은 줄만 나눈다. 원래 functionA/B/C와 별도 Guard 역할 예시이며 처리 handler가 있는 경로만 보인다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 자원 획득 service가 Guard를 만든 뒤 parse를 호출한다. |
|
|
| 예외 전파와 정리 parse가 throw해 service 범위를 벗어난다. |
|
|
| handler 처리 호환 catch가 예외를 처리한다. |
|
|
이 성질 덕분에 동적 메모리 할당에서 본 RAII 객체는 예외가 나도 자원을 정리합니다. 반대로 new로 얻은 메모리를 원시 포인터로 들고 있다가 예외가 나면, delete에 도달하지 못해 새어 나갑니다. 예외가 날 수 있는 코드에서 자원을 RAII 객체에 맡겨야 하는 이유입니다.
더 깊이: throw가 실제로 하는 일과 예외를 끈 빌드
GCC와 Clang이 따르는 Itanium C++ ABI에서 throw는 런타임 라이브러리의 함수 호출로 바뀝니다. 먼저 __cxa_allocate_exception이 예외 객체를 담을 메모리를 가능하면 힙에서 할당하고, 그 자리에 예외 객체를 만든 뒤 __cxa_throw가 타입 정보와 함께 스택 풀기를 시작합니다. 스택 풀기는 두 단계로 나뉘어, 먼저 스택을 바꾸지 않은 채 예외를 받을 catch를 찾고, 두 번째 단계에서 지역 객체를 정리하며 그 catch로 실행을 옮깁니다. 이 과정은 컴파일러가 미리 만들어 둔 풀기 정보를 읽으며 진행되므로, 예외를 던지는 일은 보통의 반환보다 훨씬 많은 일을 합니다.
일부 프로젝트는 예외를 쓰지 않으려고 GCC의 -fno-exceptions로 빌드합니다. libstdc++ 문서에 따르면 이렇게 빌드하면 사용자 코드의 try, catch, throw는 컴파일 오류가 되고, 표준 라이브러리 안에서 예외를 던지던 곳은 abort()를 부르는 도우미 함수를 거치게 됩니다. 그래서 std::vector의 at()이 범위 밖 인덱스를 만나면 예외 대신 프로그램이 끝나며, 다른 곳에서 던진 예외가 -fno-exceptions로 컴파일한 코드를 지나가려 하면 그 전파도 깨집니다. 같은 문서는 예외 처리를 켤 때 늘어나는 코드와 데이터의 크기를 7% 정도로 들고 있습니다.
처리하지 못한 예외
main까지 거슬러 올라가도 맞는 catch가 없으면 std::terminate가 호출되어 프로그램이 비정상적으로 끝납니다. 이때 스택 풀기가 일어나는지는 구현이 정하므로, 지역 객체의 소멸자가 실행된다고 기대할 수 없습니다. 위 예제에서 catch의 타입을 std::out_of_range로 바꾸면, std::invalid_argument를 받을 곳이 없어 이렇게 끝나게 됩니다.
예외는 실패를 무조건 잡으라는 것이 아니라, 그 실패를 의미 있게 처리할 수 있는 곳에서 잡으라는 것입니다. 입력을 다시 받거나, 다른 방법을 시도하거나, 사용자에게 알리고 작업을 취소할 수 있는 곳에 catch를 두고, 그런 곳이 아니라면 예외가 위로 지나가게 둡니다.
정리
- 예외는 실패를 정상적인 반환과 다른 경로로, 처리할 수 있는 곳까지 한 번에 전달합니다.
- 범위 밖의 배열 접근이나 정수의 0 나눗셈은 예외가 아니라 정의되지 않은 동작이며, 파일 스트림은 기본으로 상태 플래그로 실패를 알립니다.
- 실패를 나타내는 반환값은 정상 값과 겹치거나 확인을 빠뜨리기 쉬우며,
std::optional이나std::expected가 대안이 됩니다. throw가 실행되면 나머지 코드를 건너뛰고 타입이 맞는catch로 가며,catch가 끝나면 그 뒤로 이어집니다.- 예외가 함수를 빠져나갈 때 지역 객체는 역순으로 정리되므로, 자원은 RAII 객체에 맡깁니다.
- 맞는
catch가 없으면std::terminate로 프로그램이 끝나며,catch는 실패를 의미 있게 처리할 수 있는 곳에 둡니다.
연습 문제
1. 실패와 겹치는 정상 값
return_code.cpp의 divide에 (-6, 3)과 (-2, 2)를 넣으면 각각 무엇을 돌려주나요? 결과가 -1이면 실패로 처리하는 호출자는 두 경우를 어떻게 다루나요?
해설 보기
(-6, 3)은 -2를, (-2, 2)는 -1을 돌려줍니다. 결과가 -1인지로 실패를 판단하는 호출자는 (-2, 2)의 정상 결과를 0 나눗셈 실패로 잘못 처리합니다. 실패를 정상 값과 겹치지 않게 알리려면 std::optional<int>로 돌려주거나 예외를 던지는 식으로 경로를 나눠야 합니다.
2. 받을 곳이 없는 예외
unwinding.cpp의 main에서 service("7")과 service("")를 차례로 호출하면 무엇이 출력되나요? catch의 타입을 const std::out_of_range&로 바꾸면 어떻게 되나요?
해설 보기
parsed 7, Guard released, Guard released, caught: empty input, continue가 차례로 출력됩니다. catch를 std::out_of_range로 바꾸면 std::invalid_argument를 받을 곳이 없어 std::terminate가 호출되고 프로그램이 비정상적으로 끝납니다. 이때는 스택 풀기가 일어난다는 보장이 없으므로, 두 번째 Guard released가 출력되는지도 기대할 수 없습니다.
3. 잡을 수 없는 실패
int zero = 0; 다음에 try { int x = 10 / zero; } catch (...) { std::cout << "caught\n"; }를 실행하면 caught가 출력되나요?
해설 보기
출력된다고 기대할 수 없습니다. 정수를 0으로 나누는 것은 C++ 예외가 아니라 정의되지 않은 동작이어서, 모든 예외를 받는 catch (...)도 이를 잡지 않습니다. 많은 환경에서 프로그램이 비정상적으로 끝나지만 그것도 보장되지 않으므로, 나누기 전에 0인지 검사하고 필요하면 safeDivide처럼 직접 예외를 던집니다.
다음 글 try-catch 구문에서는 여러 catch 블록이 예외의 타입과 맞춰지는 순서, 상속 관계의 예외를 잡는 방법, 잡은 예외를 다시 던지는 방법을 다룹니다.