try-catch 구문
try에서 발생한 예외가 타입에 맞는 catch로 전달되는 과정을 익히고 여러 예외를 참조로 안전하게 처리합니다.
앞 절에서 우리는 프로그램 실행 중 발생할 수 있는 예외(Exception)의 개념과 왜 예외 처리가 중요한지에 대해 살펴보았습니다.
이제 C++에서 이러한 예외를 실제로 어떻게 잡아서(catch) 처리하는지에 대해 알아볼 시간입니다.
C++은 try, catch, throw라는 세 가지 키워드를 사용하여 예외 처리 메커니즘을 제공합니다.
이 중에서 try와 catch는 예외가 발생할 가능성이 있는 코드를 감싸고, 발생한 예외를 처리하는 데 사용되는 핵심 구문입니다.
try-catch 구문이란 무엇인가?
try-catch 구문은 다음과 같은 흐름으로 작동합니다.
try 블록: 예외가 발생할 수 있다고 예상되는 코드를 이 블록 안에 작성합니다.
throw: try 블록 안의 코드에서 예외 상황이 발생하면, throw 키워드를 사용하여 예외를 던집니다.
이때 어떤 타입의 데이터든 던질 수 있습니다. (예: 정수, 문자열, 사용자 정의 클래스 객체 등)
catch 블록: throw로 던져진 예외를 잡아서 처리하는 블록입니다.
catch 키워드 뒤에는 괄호 () 안에 처리하고자 하는 예외의 타입과 매개변수를 명시합니다.
마치 함수의 매개변수처럼, 던져진 예외 객체를 받아올 수 있습니다.
이러한 메커니즘을 통해 프로그램의 정상적인 실행 흐름과 예외 처리 흐름을 명확하게 분리하여 코드의 가독성과 유지보수성을 높일 수 있습니다.
기본 문법
try-catch 구문의 기본적인 형태는 다음과 같습니다.
try {
// 예외가 발생할 가능성이 있는 코드
// ...
// 만약 예외 상황 발생 시:
// throw 예외_객체;
} catch (예외_타입1 매개변수_이름1) {
// 예외_타입1에 해당하는 예외가 발생했을 때 실행될 코드
// ...
} catch (예외_타입2 매개변수_이름2) {
// 예외_타입2에 해당하는 예외가 발생했을 때 실행될 코드
// ...
} catch (...) { // 모든 타입의 예외를 잡는 catch 블록 (선택 사항)
// 위에서 명시된 어떤 예외 타입에도 해당하지 않는 예외가 발생했을 때 실행될 코드
// ...
}여기서 주목할 점은 여러 개의 catch 블록을 연속해서 작성할 수 있다는 것입니다.
이는 마치 if-else if-else 구문처럼, 다양한 종류의 예외에 대해 각기 다른 방식으로 대응할 수 있게 해줍니다. 단, catch의 매칭은 일반 함수 인수의 모든 암시적 변환을 적용하지 않습니다. int 예외가 double 값으로 표현 가능해도 catch(double)은 잡지 못합니다. std::string도 이름에 exception이 없을 뿐 아니라 std::exception을 상속하지 않아 그 기반 타입의 catch로 잡히지 않습니다.
예시 아래 완전한 C++17 파일은 첫 번째와 두 번째 실패 타입이 서로 다른 handler를 선택하는지 확인합니다. 숫자 값이 바뀌는 것과 예외 객체의 타입이 바뀌는 것을 구분하세요.
#include <iostream>
#include <string>
#include <exception>
int main() {
try {
throw 7;
} catch (double) {
std::cout << "double\n";
} catch (int code) {
std::cout << "int " << code << '\n';
}
try {
throw std::string("retry");
} catch (const std::exception&) {
std::cout << "standard exception\n";
} catch (const std::string& message) {
std::cout << "string " << message << '\n';
}
}출력은 int 7과 string retry입니다. throw 7.0으로 바꾸면 첫 예외 객체는 double이어서 첫 handler가 실행됩니다. 두 번째 throw를 문자열 리터럴 "retry"로만 바꾸면 const char* 예외이므로 위 두 handler 중 어느 것도 맞지 않습니다. 그 변경을 그대로 실행해 종료를 관찰할 필요는 없으며, const char* handler나 마지막 catch-all을 추가하는 계약을 먼저 정합니다.
try-catch 구문의 작동 원리
try-catch 구문은 실패 타입, 복구 범위, 자원 정리 책임을 기준으로 확인합니다. 정상 경로와 예외 경로는 같은 try-catch 다음으로 합류할 수 있지만, 예외를 잡았다는 사실만으로 실패한 작업이 성공한 값으로 바뀌지는 않습니다. catch 자체가 다시 던지거나 종료하면 아래 합류 경로로 진행하지 않습니다.
예외는 스택을 정리하며 호환 handler를 찾는다
try 실행·throw·첫 호환 catch 검색·벗어나는 범위 정리·처리 후 합류를 순서대로 강조하면 try 원위치 복귀 오해를 줄인다. 정상/전파 대안은 전체도에 남겨야 한다.
C++ · CATCH MATCHING
catch는 선언 순서의 첫 호환 핸들러를 선택한다
throw로 정상 경로를 중단하고, 복구할 수 있는 핸들러까지 지역 객체를 정리한다. catch 선택은 타입의 구체성과 함께 선언 순서에 달려 있다.
- 호출과 지역 객체 생성
try안에서 실패할 수 있는 함수를 호출한다. - throw로 현재 정상 경로 중단
throw 다음 문장과 실패한 호출 뒤의 try 문장은 실행하지 않는다.
- 호환 핸들러까지 스택 풀기
빠져나오는 범위의 완성된 자동 객체를 생성 역순으로 파괴한다.
- 선언 순서로 첫 호환 catch 선택
가장 가까운 활성 try에서 위부터 검사한다. 더 구체적인 타입은 기반 타입보다 앞에 적는다.
- 처리 뒤 다음 문장으로 합류
catch가 정상적으로 끝나면 try-catch 다음으로 이어진다. 재던지기는 바깥 핸들러를 찾는다.
본문의 다중 catch 순서
std::invalid_argument
OutOfRangeException
std::exception
...파생 예외를 먼저 처리하고, 기반 예외와 catch-all을 뒤에 둔다.
기반 타입을 먼저 두면
std::exception
std::invalid_argumentstd::invalid_argument도 앞의 기반 타입 catch에 잡힌다. 런타임이 더 구체적인 catch를 우선하지 않는다.
핸들러가 없거나 noexcept 경계를 넘는 종료 경로는 모든 지역 객체의 정리를 보장하지 않는다. 현재 예외를 다시 보낼 때는 throw;로 원래 예외를 보존한다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | 8개 노드와 8개 간선이다. try 호출→throw 조건으로 이어진다. 예외가 없으면 try-catch 다음으로 우회하고 예외가 있으면 가장 가까운 활성 try의 선언 순서 검색→호환 catch 조건으로 이어진다. 호환 catch가 있으면 벗어나는 범위 정리→catch 정상 처리→다음 문장으로 합류한다. 없으면 바깥 활성 try로 전파하며 noexcept나 handler가 없는 종료 경로는 합류하지 않는다. catch가 다시 던지는 경로는 아래 본문의 조건으로 구별한다. 같은 예외에 여러 catch가 이어 실행되거나 throw 다음의 원래 try 문장으로 되돌아오는 화살표는 없다. 모바일도 8개 노드와 8개 관계를 유지한다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| try의 호출 handler가 있는 try에서 작업을 시작한다. |
|
|
| throw와 전파 던진 타입에 맞는 첫 catch를 찾아 범위를 정리한다. |
|
|
| 처리 후 합류 catch가 정상적으로 끝난 뒤 try-catch 다음으로 간다. |
|
|
예외는 스택을 정리하며 호환 handler를 찾는다
try 실행·throw·첫 호환 catch 검색·벗어나는 범위 정리·처리 후 합류를 순서대로 강조하면 try 원위치 복귀 오해를 줄인다. 정상/전파 대안은 전체도에 남겨야 한다.
C++ · CATCH MATCHING
catch는 선언 순서의 첫 호환 핸들러를 선택한다
throw로 정상 경로를 중단하고, 복구할 수 있는 핸들러까지 지역 객체를 정리한다. catch 선택은 타입의 구체성과 함께 선언 순서에 달려 있다.
- 호출과 지역 객체 생성
try안에서 실패할 수 있는 함수를 호출한다. - throw로 현재 정상 경로 중단
throw 다음 문장과 실패한 호출 뒤의 try 문장은 실행하지 않는다.
- 호환 핸들러까지 스택 풀기
빠져나오는 범위의 완성된 자동 객체를 생성 역순으로 파괴한다.
- 선언 순서로 첫 호환 catch 선택
가장 가까운 활성 try에서 위부터 검사한다. 더 구체적인 타입은 기반 타입보다 앞에 적는다.
- 처리 뒤 다음 문장으로 합류
catch가 정상적으로 끝나면 try-catch 다음으로 이어진다. 재던지기는 바깥 핸들러를 찾는다.
본문의 다중 catch 순서
std::invalid_argument
OutOfRangeException
std::exception
...파생 예외를 먼저 처리하고, 기반 예외와 catch-all을 뒤에 둔다.
기반 타입을 먼저 두면
std::exception
std::invalid_argumentstd::invalid_argument도 앞의 기반 타입 catch에 잡힌다. 런타임이 더 구체적인 catch를 우선하지 않는다.
핸들러가 없거나 noexcept 경계를 넘는 종료 경로는 모든 지역 객체의 정리를 보장하지 않는다. 현재 예외를 다시 보낼 때는 throw;로 원래 예외를 보존한다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | 8개 노드와 8개 간선이다. try 호출→throw 조건으로 이어진다. 예외가 없으면 try-catch 다음으로 우회하고 예외가 있으면 가장 가까운 활성 try의 선언 순서 검색→호환 catch 조건으로 이어진다. 호환 catch가 있으면 벗어나는 범위 정리→catch 정상 처리→다음 문장으로 합류한다. 없으면 바깥 활성 try로 전파하며 noexcept나 handler가 없는 종료 경로는 합류하지 않는다. catch가 다시 던지는 경로는 아래 본문의 조건으로 구별한다. 같은 예외에 여러 catch가 이어 실행되거나 throw 다음의 원래 try 문장으로 되돌아오는 화살표는 없다. 모바일도 8개 노드와 8개 관계를 유지한다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| try의 호출 handler가 있는 try에서 작업을 시작한다. |
|
|
| throw와 전파 던진 타입에 맞는 첫 catch를 찾아 범위를 정리한다. |
|
|
| 처리 후 합류 catch가 정상적으로 끝난 뒤 try-catch 다음으로 간다. |
|
|
try-catch 구문이 작동하는 과정을 조금 더 자세히 들여다보겠습니다.
프로그램은 try 블록 안의 코드를 실행하기 시작합니다.
try 블록 안에서 throw 문이 실행되면, 즉시 현재 try 블록의 실행을 중단하고 throw로 던져진 예외 객체와 일치하는 catch 블록을 찾기 시작합니다.
해당하는 catch 블록을 찾으면, 그 catch 블록 안의 코드를 실행하여 예외를 처리합니다.
예외 처리가 끝나면 프로그램의 실행 흐름은 해당 try-catch 구문 다음으로 이어집니다.
만약 try 블록 안의 코드가 예외를 던지지 않고 끝나면, catch 블록은 건너뛰고 try-catch 구문 다음 코드가 실행됩니다.
중요: 던져진 예외와 일치하는 catch 블록이 없다면, 예외는 현재 함수의 호출 스택을 따라 상위 호출자로 전파됩니다.
즉 예외를 던진 함수를 호출한 함수에서 다시 try-catch 블록을 찾게 됩니다.
이 과정은 적절한 catch 블록을 찾거나 프로그램의 main 함수에 도달할 때까지 계속됩니다.
main 함수에서도 처리되지 않은 예외는 프로그램의 비정상 종료로 이어집니다. (이는 이전 장에서 설명한 예외 전파 개념과 연결됩니다.)
숫자를 0으로 나누는 상황은 흔히 발생하는 오류 중 하나입니다.
이를 try-catch 구문으로 처리하는 예시를 확인합니다.
#include <iostream>
#include <string> // 예외로 문자열을 던질 수 있습니다.
double divide(int numerator, int denominator) {
if (denominator == 0) {
// 0으로 나누는 상황은 비정상적이므로 예외를 던집니다.
// 여기서는 C-스타일 문자열을 예외로 던져보겠습니다.
throw "Error: Division by zero is not allowed!";
}
return static_cast<double>(numerator) / denominator;
}
int main() {
std::cout << "--- 예외 발생 상황 ---" << std::endl;
try {
double result = divide(10, 0); // 0으로 나누는 함수 호출
std::cout << "Result: " << result << std::endl; // 이 줄은 실행되지 않습니다.
} catch (const char* errorMessage) { // C-스타일 문자열 예외를 잡습니다.
std::cerr << "Caught exception: " << errorMessage << std::endl;
}
std::cout << "Program continues after exception handling." << std::endl;
std::cout << std::endl;
std::cout << "--- 정상 실행 상황 ---" << std::endl;
try {
double result = divide(10, 2); // 정상적인 나누기 함수 호출
std::cout << "Result: " << result << std::endl; // 이 줄은 정상적으로 실행됩니다.
} catch (const char* errorMessage) {
// 이 catch 블록은 실행되지 않습니다.
std::cerr << "Caught exception: " << errorMessage << std::endl;
}
std::cout << "Program continues normally." << std::endl;
return 0;
}--- 예외 발생 상황 ---
Caught exception: Error: Division by zero is not allowed!
Program continues after exception handling.
--- 정상 실행 상황 ---
Result: 5
Program continues normally.divide 함수는 denominator가 0인 경우 Error: Division by zero is not allowed!라는 C-스타일 문자열을 throw합니다.
main 함수의 첫 번째 try 블록에서는 divide(10, 0)을 호출하여 예외를 발생시킵니다.
throw 문이 실행되는 순간, try 블록의 남은 코드는 실행되지 않고, C-스타일 문자열을 받을 수 있는 catch (const char* errorMessage) 블록으로 제어가 넘어갑니다.
catch 블록 안에서 예외 메시지를 출력하고, 이후 try-catch 구문 밖의 코드가 정상적으로 실행됩니다.
이로써 프로그램이 비정상적으로 종료되는 것을 방지합니다.
두 번째 try 블록에서는 divide(10, 2)를 호출하며, 이 경우 예외가 발생하지 않으므로 catch 블록은 건너뛰고 try 블록 내부의 모든 코드와 그 이후의 코드가 정상적으로 실행됩니다.
여러 종류의 예외 처리하기
하나의 try 블록에서 여러 종류의 예외가 발생할 수 있습니다.
예를 들어, 0으로 나누는 예외 외에 지원하지 않는 연산자를 입력했을 때의 예외도 처리하고 싶다고 가정해 봅시다.
이때는 여러 개의 catch 블록을 사용할 수 있습니다.
#include <iostream>
#include <string>
#include <stdexcept> // 표준 예외 클래스들을 사용하기 위해 포함
// 이 예시에서 지원하지 않는 연산자를 보고할 예외 클래스 (사용자 정의 예외)
class OutOfRangeException : public std::runtime_error {
public:
// 생성자에서 부모 클래스의 생성자를 호출하여 메시지를 전달합니다.
OutOfRangeException(const std::string& msg) : std::runtime_error(msg) {}
};
double calculate(int val1, int val2, char op) {
if (op == '/') {
if (val2 == 0) {
// 0으로 나누는 경우, 표준 예외 std::invalid_argument를 던집니다.
throw std::invalid_argument("Division by zero error!");
}
} else if (op != '+' && op != '-' && op != '*' && op != '/') {
// 유효하지 않은 연산자일 경우, 사용자 정의 예외를 던집니다.
throw OutOfRangeException("Invalid operator provided.");
}
// 간단한 계산 로직 (예외 발생 조건에만 집중)
if (op == '+') return static_cast<double>(val1) + val2;
if (op == '-') return static_cast<double>(val1) - val2;
if (op == '*') return static_cast<double>(val1) * val2;
if (op == '/') return static_cast<double>(val1) / val2;
return 0.0; // 도달하지 않는 코드
}
int main() {
// 0으로 나누기 예외 상황
std::cout << "--- Case 1: Division by Zero ---" << std::endl;
try {
double result = calculate(10, 0, '/');
std::cout << "Result: " << result << std::endl;
} catch (const std::invalid_argument& e) { // std::invalid_argument 예외를 잡습니다.
std::cerr << "Error (Invalid Argument): " << e.what() << std::endl;
} catch (const OutOfRangeException& e) {
std::cerr << "Error (Out of Range): " << e.what() << std::endl;
} catch (const std::exception& e) { // 모든 표준 예외의 부모 클래스를 잡습니다.
std::cerr << "Error (General Std Exception): " << e.what() << std::endl;
} catch (...) { // 그 외 모든 예외를 잡습니다.
std::cerr << "Error (Unknown Exception)!" << std::endl;
}
std::cout << "--- End of Case 1 ---" << std::endl << std::endl;
// 유효하지 않은 연산자 예외 상황
std::cout << "--- Case 2: Invalid Operator ---" << std::endl;
try {
double result = calculate(5, 2, '%'); // 유효하지 않은 연산자
std::cout << "Result: " << result << std::endl;
} catch (const std::invalid_argument& e) {
std::cerr << "Error (Invalid Argument): " << e.what() << std::endl;
} catch (const OutOfRangeException& e) { // OutOfRangeException 예외를 잡습니다.
std::cerr << "Error (Out of Range): " << e.what() << std::endl;
} catch (const std::exception& e) {
std::cerr << "Error (General Std Exception): " << e.what() << std::endl;
} catch (...) {
std::cerr << "Error (Unknown Exception)!" << std::endl;
}
std::cout << "--- End of Case 2 ---" << std::endl << std::endl;
// 정상 실행 상황
std::cout << "--- Case 3: Normal Calculation ---" << std::endl;
try {
double result = calculate(20, 5, '*');
std::cout << "Result: " << result << std::endl;
} catch (const std::invalid_argument& e) {
std::cerr << "Error (Invalid Argument): " << e.what() << std::endl;
} catch (const OutOfRangeException& e) {
std::cerr << "Error (Out of Range): " << e.what() << std::endl;
} catch (const std::exception& e) {
std::cerr << "Error (General Std Exception): " << e.what() << std::endl;
} catch (...) {
std::cerr << "Error (Unknown Exception)!" << std::endl;
}
std::cout << "--- End of Case 3 ---" << std::endl << std::endl;
return 0;
}--- Case 1: Division by Zero ---
Error (Invalid Argument): Division by zero error!
--- End of Case 1 ---
--- Case 2: Invalid Operator ---
Error (Out of Range): Invalid operator provided.
--- End of Case 2 ---
--- Case 3: Normal Calculation ---
Result: 100
--- End of Case 3 ---calculate 함수는 연산자에 따라 두 가지 다른 종류의 예외를 던질 수 있습니다.
std::invalid_argument: 0으로 나누는 경우와 같이, 함수에 전달된 인수가 유효하지 않을 때 사용되는 표준 예외입니다.<stdexcept>헤더에 정의되어 있습니다.OutOfRangeException: 유효하지 않은 연산자가 입력되었을 때 던질 사용자 정의 예외입니다.std::runtime_error를 상속받아 만들었는데, 이는 C++ 표준 라이브러리의 예외 계층 구조를 따르는 좋은 방법입니다.what()메서드를 통해 예외 메시지를 얻을 수 있습니다.
main 함수에서는 calculate 함수 호출을 각각 다른 try 블록으로 감싸고, 그 뒤에 여러 개의 catch 블록을 두어 다양한 예외 상황에 대비합니다.
catch 블록은 위에서부터 순서대로 매칭을 시도합니다.
따라서 더 구체적인 예외 타입을 먼저 배치하고, 더 일반적인 예외 타입(예: std::exception 또는 ...)은 나중에 배치하는 것이 중요합니다.
std::invalid_argument와OutOfRangeException은std::exception의 자식 클래스이므로,std::exception을 먼저 잡는다면 구체적인 예외를 처리할 기회를 잃게 됩니다.
catch (...)는 모든 타입의 예외를 잡는 만능 catch 블록입니다.
이 블록은 일반적으로 예상치 못한 예외가 발생했을 때 프로그램이 비정상적으로 종료되는 것을 방지하기 위한 최후의 수단으로 사용됩니다.
하지만 어떤 예외가 던져졌는지 정확히 알 수 없으므로, 디버깅이 어려워질 수 있습니다.
따라서 가능한 한 구체적인 예외를 명시하여 처리하는 것이 바람직합니다.
예외 객체 참조로 받기
catch 블록에서 예외 객체를 받을 때 const 참조(const 예외_타입&)로 받는 것이 일반적입니다. 기반 타입을 값으로 받으면 기반 부분만 복사하는 slicing이 생길 수 있지만, 참조는 원 예외 객체를 가리켜 가상 what()을 통해 파생 타입의 정보를 읽습니다. throw할 때의 예외 객체 생성과 catch 매개변수를 만드는 일을 같은 복사 한 번으로 세지 않습니다.
- 성능: 예외 객체가 복사되는 것을 방지하여 불필요한 오버헤드를 줄입니다.
- 다형성: 예외 클래스가 상속 계층 구조를 가질 때, 부모 클래스 참조로 자식 클래스의 예외를 잡을 수 있게 해줍니다. (예:
catch (const std::exception& e)) - 수정 방지:
const를 사용하여 예외 객체의 내용이catch블록 내에서 변경되지 않도록 보장합니다.
throw 문 이후의 동작
throw 문이 실행되면, 해당 try 블록의 나머지 코드는 더 이상 실행되지 않습니다.
즉시 제어권이 가장 가까운 활성 try의 첫 번째 호환 catch 블록으로 넘어갑니다.
이는 함수의 return 문과 유사하게, 현재 실행 흐름을 중단하고 새로운 흐름으로 전환하는 역할을 합니다.
이 점을 이해하는 것은 예외가 발생했을 때 코드의 동작을 예측하는 데 매우 중요합니다.
try-catch는 throw 이후 코드가 건너뛰어지는 흐름과 예외 객체를 참조로 받는 이유를 같이 봐야 합니다.
스스로 handler를 고르기 원 calculate(5, 2, '%') 호출의 catch들을 std::exception 기반 타입이 가장 먼저 오도록 재배치하면 어느 메시지 경로가 실행될까요? OutOfRangeException handler도 이어서 실행되나요? 클래스 이름이 비슷한 std::out_of_range로 throw 타입이 바뀐 것은 아니라는 점도 확인하세요.
첫 호환 타입과 한 번의 선택 풀이
OutOfRangeException은 이 예제에서 만든 std::runtime_error의 public 파생 클래스이므로 앞에 둔 std::exception 참조 handler가 먼저 잡습니다. Error (General Std Exception) 경로에서 메시지를 읽고, 이후 OutOfRangeException catch를 이어 실행하지 않습니다. 런타임이 가장 구체적인 타입을 다시 찾아 주는 규칙이 아니라 선언 순서의 첫 호환 handler를 고르는 규칙입니다. 원 코드의 클래스 이름과 던지는 객체를 그대로 읽어야 하며, const 참조로 받아도 catch 순서 문제는 따로 남습니다.
처리할 handler를 찾지 못하거나 noexcept 경계를 넘으면 std::terminate가 호출될 수 있습니다. 그런 종료 경로에서 모든 지역 객체의 정리가 보장된다고 가정하지 않습니다.