예외 처리의 기본 개념
실행 중 예외와 기존 오류 반환 방식의 한계를 이해하고 throw·try·catch로 정상 흐름과 실패 처리를 분리합니다.
프로그램 실행 중 발생할 수 있는 예상치 못한 문제, 즉 오류(Error)나 예외(Exception)를 처리하는 기준을 다룹니다.
이 절에서는 C++의 예외 처리(Exception Handling) 메커니즘을 다룹니다. 함수 호출과 반환, 지역 객체의 수명, 앞 장의 클래스 상속을 바탕으로 읽습니다. 각 main이 있는 코드는 별도 파일로 실행하고, throw·try·catch만 보이는 짧은 형식은 해당 함수나 타입을 준비한 뒤 넣는 발췌입니다.
예외 처리는 프로그램의 안정성을 높이고, 오류 발생 시 적절하게 대응하여 프로그램이 비정상적으로 종료되는 것을 방지하는 데 필수적인 기법입니다.
예외(Exception)란 무엇인가?
프로그램이 실행되는 동안 발생할 수 있는 비정상적인 상황을 예외(Exception)라고 합니다.
이는 일반적으로 프로그램이 정상적인 흐름을 이어갈 수 없음을 의미합니다.
예외의 예시- 메모리 부족:
new연산자가 메모리 할당에 실패한 경우 (std::bad_alloc) - 파일 열기 실패: 존재하지 않는 파일을 열려고 시도하거나, 권한이 없어 열 수 없는 경우
- 배열/컨테이너 범위 초과:
std::vector::at()처럼 검사하는 API는std::out_of_range를 던집니다. 일반 배열이나operator[]범위 초과가 자동으로 C++ 예외가 되지는 않습니다. - 0으로 나누기: 0 여부를 직접 검사해 throw할 수 있습니다. 정수의 0 나눗셈 자체는 정의되지 않은 동작이며 catch로 복구하는 C++ 예외가 아닙니다.
- 네트워크 연결 끊김: 데이터를 전송하는 도중 네트워크 연결이 끊어진 경우
이러한 예외 상황이 발생했을 때 적절히 처리하지 않으면 프로그램은 비정상적으로 종료되거나, 예측 불가능한 결과를 초래할 수 있습니다. 다만 운영상의 실패가 모두 자동으로 C++ 예외가 되는 것은 아닙니다. 기본 파일 스트림은 열기·쓰기 실패를 상태 플래그로 알리고, 예외 마스크나 직접 검사해 throw하는 정책을 따로 정할 수 있습니다. 네트워크 API도 반환값과 예외 중 어떤 계약인지 확인해야 합니다.
기존 오류 처리 방식의 한계
예외 처리 메커니즘이 도입되기 전이나, 간단한 오류 처리 시에는 주로 다음과 같은 방식을 사용했습니다.
- 함수가 오류 발생 시 특정 값(예:
-1,nullptr,false)을 반환하여 호출자에게 오류를 알립니다. - 장점: 간단하고 직관적입니다.
-
한계
- 함수 반환 값이 항상 오류를 나타낼 수 있는 것은 아닙니다. (예:
int를 반환하는 함수에서-1이 유효한 결과일 수도 있음) - 호출자가 반환 값을 일일이 확인하고 처리하는 코드를 작성해야 하므로, 코드 가독성이 떨어지고 누락될 위험이 있습니다.
- 함수 호출 스택이 깊어질수록 오류 정보를 상위 호출자에게 전달하기 위해 모든 중간 함수에서 반환 값을 확인하고 다시 반환해야 하는 오류 전달 사슬이 길어집니다.
- 함수 반환 값이 항상 오류를 나타낼 수 있는 것은 아닙니다. (예:
#include <iostream>
int divide(int a, int b) {
if (b == 0) {
std::cerr << "Error: Division by zero!\n";
return -1; // 오류를 나타내는 특수 값 반환
}
return a / b;
}
int main() {
int result = divide(10, 0);
if (result == -1) {
// 오류 처리
} else {
// 정상 처리
}
return 0;
}- 오류 발생 시 전역 변수나 플래그를 설정하여 오류 상태를 알립니다.
- 장점: 함수 반환 값과 독립적으로 오류를 보고할 수 있습니다.
-
한계
- 멀티스레드 환경에서 안전하지 않을 수 있습니다.
- 어떤 함수에서 오류가 발생했는지 추적하기 어렵고, 오류가 발생한 함수 이후에도 정상 코드가 실행될 수 있습니다.
- 함수 호출 스택을 역추적하기 어렵습니다.
assert 매크로 이용- 프로그래머의 논리적인 오류(버그)를 잡아내는 데 사용됩니다. 특정 조건이 참이 아닐 경우 프로그램을 강제 종료합니다.
- 장점: 개발 단계에서 버그를 빠르게 발견할 수 있습니다.
-
한계
- 릴리스(Release) 빌드에서는 비활성화될 수 있으므로, 예상되는 런타임 예외 처리에 적합하지 않습니다.
- 프로그램을 강제 종료하므로, 사용자에게 친화적인 오류 복구 메커니즘이 아닙니다.
반환값 방식도 실패를 명확히 구분하고 호출자가 확인하는 계약이라면 유효합니다. 위 divide의 문제는 -1을 실패 표식으로 쓰면서 정상 결과 -1도 허용한다는 점입니다. assert는 개발 중의 전제 위반을 찾는 도구이며, 사용자가 낸 잘못된 입력을 catch로 복구하는 장치가 아닙니다.
예시 원 divide에 -2와 2를 주면 정수 나눗셈의 정상 결과도 -1입니다. 분모가 0인 호출은 오류 메시지를 내고 -1을 반환하지만, result만 보는 호출자는 두 경우를 구별할 수 없습니다. 아래 safeDivide는 분모가 0일 때만 std::string 예외를 던져 정상 값과 실패 경로를 나눕니다. 이 예에서는 유한한 작은 입력을 사용하며, 모든 수치 검증을 완성한 함수라고 해석하지 않습니다.
스스로 확인하기 원 반환값 divide에 (-6, 3)과 (-2, 2)를 넣었을 때 result == -1 검사는 각각 어떤 경로를 고를까요? 분모가 0인 경우와 정상적인 음수 결과를 이 반환값 하나로 항상 구별할 수 있는지도 설명하세요.
실패 표식과 정상 값의 충돌 풀이
(-6, 3)은 -2를 반환하므로 정상 처리 경로이고, (-2, 2)는 정상 계산인데도 -1을 반환해 오류 처리 경로로 갑니다. (10, 0)의 -1과 같은 값이므로 결괏값만으로는 두 원인을 구별할 수 없습니다. 실패 표식이 정상 값과 겹치지 않도록 별도 상태를 반환하거나, 예외를 쓰는 계약이라면 정상 반환과 throw 경로를 분리해야 합니다. 음수 자체를 모두 실패로 바꾸는 것은 정상 결과 범위를 임의로 줄이는 다른 정책입니다.
예외 처리의 기본 메커니즘
C++의 예외 처리 메커니즘은 다음과 같은 세 가지 키워드를 중심으로 이루어집니다.
throw- 예외 상황이 발생했음을 알리고, 예외 객체(Exception Object)를 던집니다(throw).
throw문이 실행되면 현재의 정상 실행 경로가 중단되고, 가장 가까운 활성try부터 해당 예외를 처리할 수 있는catch블록을 찾습니다. 같은 함수 안의catch가 정상적으로 끝나면 그 함수의 다음 문장으로 이어질 수 있습니다.- 어떤 타입의 객체든 던질 수 있습니다. (정수, 문자열, 사용자 정의 클래스 객체 등)
throw "Error: Division by zero!"; // C-스타일 문자열 예외
throw 404; // int 타입 예외
throw MyException("Invalid input"); // 사용자 정의 예외 객체try 블록- 예외가 발생할 가능성이 있는 코드를 포함하는 블록입니다.
try블록 내에서 예외가 발생하면, 해당 예외를 처리할catch블록을 찾습니다.
try {
// 예외가 발생할 수 있는 코드
// ...
}catch 블록try블록에서 던져진 예외를 잡아서(catch) 처리하는 블록입니다.catch키워드 뒤에 괄호 안에 처리하고자 하는 예외의 타입을 선언합니다. 이는 함수의 매개변수 선언과 유사합니다.- 던져진 예외 객체의 타입과 일치하거나, 해당 타입의 기반 클래스를 받는
catch블록이 선택됩니다.
try {
// 예외 발생 가능 코드
} catch (int ex_code) { // int 타입 예외를 잡음
std::cerr << "Caught integer exception: " << ex_code << std::endl;
// 예외 처리 로직
} catch (const char* msg) { // C-스타일 문자열 예외를 잡음
std::cerr << "Caught string exception: " << msg << std::endl;
// 예외 처리 로직
} catch (...) { // 모든 종류의 예외를 잡음 (catch-all)
std::cerr << "Caught unknown exception!" << std::endl;
// 예외 처리 로직
}#include <iostream>
#include <string>
double safeDivide(double numerator, double denominator) {
if (denominator == 0) {
throw std::string("Error: Division by zero is not allowed!"); // std::string 객체 던지기
}
return numerator / denominator;
}
int main() {
double num1 = 10.0;
double num2 = 2.0;
double num3 = 0.0;
// 1. 정상적인 경우
try {
double result = safeDivide(num1, num2);
std::cout << "Result of " << num1 << " / " << num2 << " = " << result << std::endl;
} catch (const std::string& e) { // std::string 예외를 잡음
std::cerr << "Caught exception: " << e << std::endl;
}
std::cout << "-------------------------------------------\n";
// 2. 예외가 발생하는 경우
try {
double result = safeDivide(num1, num3); // 여기서 예외가 발생
std::cout << "This line will not be executed." << std::endl; // 실행되지 않음
} catch (const std::string& e) { // 던져진 std::string 예외를 잡음
std::cerr << "Caught exception: " << e << std::endl; // 출력: Caught exception: Error: Division by zero is not allowed!
} catch (int errorCode) { // int 예외는 잡지 않음
std::cerr << "Caught integer error code: " << errorCode << std::endl;
} catch (...) { // 다른 모든 예외를 잡음
std::cerr << "Caught an unexpected exception type." << std::endl;
}
std::cout << "Program continues after exception handling.\n";
return 0;
}예외의 전파 (Exception Propagation)
예외 처리의 기본 개념은 실패 타입, 복구 범위, 자원 정리 책임을 기준으로 확인합니다.
throw 문이 실행되면, C++ 런타임 시스템은 가장 가까운 활성 try에서 호환 handler를 찾습니다. 같은 함수에 해당 catch가 있으면 먼저 그 범위에서 처리할 수 있으며, 바로 호출자에게 돌아가는 정상 return과는 다릅니다.
만약 그 호출한 곳에 해당 예외를 처리할 catch 블록이 없다면, 다시 그 함수를 호출한 곳으로 제어를 옮기는 과정을 반복합니다.
이 과정을 스택 풀기(Stack Unwinding) 또는 예외 전파(Exception Propagation)라고 합니다.
호출 스택을 역추적하면서 catch 블록을 찾다가, 최종적으로 main 함수까지 도달했는데도 적절한 catch 블록을 찾지 못하면 프로그램은 비정상적으로 종료됩니다 (std::terminate 호출).
#include <iostream>
#include <string>
void functionC() {
std::cout << "Inside functionC\n";
throw "An error occurred in functionC!"; // C-스타일 문자열 예외를 던짐
std::cout << "This line in functionC will not be executed.\n";
}
void functionB() {
std::cout << "Inside functionB\n";
// functionB에는 catch 블록이 없으므로, 예외는 functionA로 전파됨
functionC();
std::cout << "This line in functionB will not be executed.\n";
}
void functionA() {
std::cout << "Inside functionA\n";
try {
functionB(); // functionB에서 예외가 발생하여 여기로 전파됨
std::cout << "This line in functionA's try block will not be executed.\n";
} catch (const char* message) { // functionA에서 C-스타일 문자열 예외를 잡음
std::cerr << "Caught exception in functionA: " << message << std::endl;
}
std::cout << "Exiting functionA.\n";
}
int main() {
std::cout << "Starting main...\n";
functionA();
std::cout << "Main finished.\n";
return 0;
}Starting main...
Inside functionA
Inside functionB
Inside functionC
Caught exception in functionA: An error occurred in functionC!
Exiting functionA.
Main finished.functionC에서 예외가 던져진 후, functionB는 예외를 처리하지 않고 바로 functionA로 예외를 전파합니다.
functionA의 try-catch 블록에서 해당 예외를 잡고 처리하는 것을 볼 수 있습니다. 원 출력에는 cout과 cerr가 함께 있으므로 별도 출력 창에서 수집하면 표시 순서가 다를 수 있습니다. 코드가 호출한 순서와 서로 다른 스트림을 나중에 합쳐 표시한 순서를 구분하세요.
위 예제에서는 functionC와 functionB를 벗어나 functionA의 catch로 이동합니다. 이때 벗어나는 범위의 완전히 생성된 자동 객체는 생성의 역순으로 정리됩니다. functionA 전체가 끝나는 것은 아니므로, catch가 정상적으로 끝난 뒤 Exiting functionA. 출력도 실행됩니다.
이 과정을 스택 풀기(Stack Unwinding)라고 합니다.
다음은 예외 전파에 RAII 자원 정리를 더한 일반적인 흐름입니다. 위 functionA/B/C 예제에는 Guard 객체가 없으며, 그림의 service/parse는 역할을 나타내는 별도 예시입니다.
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가 예외를 처리한다. |
|
|
예외 처리는 오류를 숨기는 장치가 아니라, 실패를 복구할 수 있는 호출 경계까지 전달하고 스택을 풀며 자원을 정리하는 구조입니다.
전파 범위를 독립 판단하기 원 functionC가 throw하기 전에 같은 함수 안의 try-catch로 그 예외를 잡고 catch 뒤에서 정상 종료하도록 바뀌었다고 합시다. functionB의 호출 다음 출력과 functionA의 try 안의 호출 다음 출력은 실행될까요? 반대로 functionA에서 예외를 잡은 뒤 functionB의 중단된 지점으로 돌아가지는 않는 이유도 설명하세요.
처리한 경계와 계속할 지점 풀이
functionC 안에서 처리하고 정상 반환했다면 functionB는 호출 다음으로, 이어 정상 반환받은 functionA도 try 안의 호출 다음으로 진행합니다. 원 예제처럼 functionA에서 잡았다면 functionB·functionC의 실패한 실행 범위는 이미 벗어났으므로 그 중단 지점으로 되돌아가지 않습니다. functionA의 catch 다음 문장으로만 이어집니다. 가장 가까운 활성 handler와 handler의 선택 및 종료를 함께 확인합니다.
처리할 handler를 찾지 못하거나 noexcept 경계를 넘으면 std::terminate가 호출될 수 있습니다. 그런 종료 경로에서 모든 지역 객체의 정리가 보장된다고 가정하지 않습니다.