Menu

C++ try-catch: 예외를 올바르게 처리하기

위험한 코드를 try로 감싸고 catch에서 대응하세요. 예외를 const 참조로 잡는 법, 여러 핸들러의 순서, catch (...) 사용법, 그리고 리소스를 누수 없이 다시 던지는 법을 배웁니다.

이 페이지에는 실행 가능한 에디터가 있습니다 - 편집하고 실행하면 결과를 바로 볼 수 있습니다.

던지기에서 처리하기로

이전 페이지에서는 무언가 잘못되었을 때 예외를 throw(던지기)하는 방법을 배웠습니다. 던지기는 이야기의 절반일 뿐입니다. 결코 잡히지 않은 예외는 std::terminate를 호출하여 프로그램을 크래시시킵니다. try/catch 문은 던져진 것을 _처리_하고 계속 실행하게 하는 방법입니다.

형태는 간단합니다. 위험한 코드를 try 블록 안에 넣고, 그 뒤에 특정 오류 타입에 반응하는 하나 이상의 catch 블록을 둡니다. try 블록이 문제없이 실행되면 모든 catch는 건너뜁니다. 무언가 던져지는 순간, 제어는 곧장 처음으로 일치하는 catch로 점프합니다.

"after"가 결코 출력되지 않는다는 점에 주목하세요. throw가 발동되는 즉시 try 블록의 나머지는 버려지고, 실행은 일치하는 catch 안에서 재개됩니다. catch가 끝나면 프로그램은 그 아래에서 정상적으로 계속됩니다.

const 참조로 잡기

C++ 오류 처리에서 가장 중요한 습관은 예외를 값이 아니라 const 참조로 잡는 것입니다.

값으로 잡으면 예외가 복사되고, 더 나쁘게는 _슬라이싱_됩니다. 표준 예외는 계층 구조를 이루며(runtime_errorlogic_error는 둘 다 std::exception에서 파생됨), 따라서 파생 예외를 기반 값으로 잡으면 파생 부분이 잘려 나갑니다. 참조로 잡으면 객체가 온전하고 다형적으로 유지됩니다.

여기서는 out_of_range를 던지지만 const exception&로 잡습니다. out_of_rangeexception에서 파생되었으므로 기반 클래스 핸들러가 일치하고, 참조이기에 e.what()은 여전히 진짜 메시지를 반환합니다. 만약 catch (exception e)(값으로)라고 썼다면 객체가 단순한 exception으로 슬라이싱되어 구체적인 메시지를 잃을 수 있었습니다.

여러 catch 블록

하나의 try 뒤에는 각기 다른 예외 타입을 위한 여러 catch 블록이 올 수 있습니다. C++는 이를 위에서 아래로 시도하며 처음으로 일치하는 것을 실행합니다. 따라서 가장 구체적인 것에서 가장 일반적인 것 순으로 배치하세요.

invalid_argumentexception보다 구체적이므로 먼저 와야 합니다. 순서를 뒤집어 catch (const exception&)를 맨 위에 두면 모든 예외를 삼켜 버려서, 그 아래의 invalid_argument 핸들러는 결코 실행될 수 없는 죽은 코드가 됩니다. 많은 컴파일러가 이를 경고하지만, 언어가 막아 주지는 않습니다.

catch (...)와 재던지기

때로는 예상하지 못한 모든 것에 대비한 안전망이 필요합니다. 모든 것을 잡는 핸들러 catch (...)std::exception에서 파생되지 않은 것을 포함해 모든 예외 타입과 일치합니다(누군가 throw 42;throw "oops";를 쓸 수 있습니다).

함정은 객체가 주어지지 않는다는 점입니다. 들여다볼 e가 없습니다. 그래서 catch (...)는 최후의 수단으로 쓰는 것이 가장 좋습니다. 무언가 실패했음을 로그로 남기거나, 정리한 뒤 다시 던지는 데 사용합니다.

현재 예외를 다시 던지려면(지역적인 정리나 로깅을 한 뒤 바깥쪽 핸들러로 넘기려면) 피연산자 없는 맨몸의 throw;를 사용하세요. 이는 슬라이싱된 복사본을 다시 던지는 throw e;와 달리, 원래 예외(진짜 타입과 메시지)를 보존합니다.

안쪽 핸들러가 로그를 남기고 다시 던지면, main의 바깥쪽 핸들러가 그것을 처리합니다. 이를 위해서는 맨몸의 throw;를 사용하고, 결코 throw e;를 쓰지 마세요.

스택 풀기와 RAII

예외가 try 블록 밖으로 전파될 때, C++는 스택 풀기(stack unwinding)를 수행합니다. throw와 일치하는 catch 사이의 모든 지역 객체는 생성의 역순으로 소멸자가 호출됩니다. 바로 이것이 예외를 안전하게 만드는 요소입니다. 스택 객체가 보유한 리소스는 자동으로 해제됩니다.

바로 이 때문에 리소스는 수동 new/delete 대신 RAII 타입(std::vector, std::string, 스마트 포인터 등)으로 보유해야 합니다. 예외가 수동 할당을 가로지를 때 무슨 일이 일어나는지 보세요.

void leaky() {
    int* buffer = new int[1000];
    mightThrow();        // 이것이 던지면 다음 줄은 결코 실행되지 않고...
    delete[] buffer;     // ...버퍼가 누수된다
}

throwdelete[]를 건너뛰기 때문에 메모리가 사라집니다. 스마트 포인터가 이를 공짜로 고쳐 줍니다. 그 소멸자가 풀기 도중에 실행되기 때문입니다.

void safe() {
    auto buffer = std::make_unique<int[]>(1000);
    mightThrow();   // 이것이 던져도 buffer의 소멸자가 여전히 메모리를 해제한다
}                   // 수동 delete 없음, 누수 없음, 예외 경로에서도 마찬가지

핵심은 이것입니다. 무언가를 delete하기 위해서만 예외를 catch하려 하지 마세요. 정리는 소멸자에 맡기고, catch는 어떻게 복구할지 결정하는 용도로 남겨 두세요.

흔한 실수와 함정

몇 가지 함정이 반복해서 등장합니다.

예외를 일반적인 제어 흐름에 사용하지 마세요. 던지고 풀어내는 것은 단순한 if보다 훨씬 느립니다. 예외는 진정으로 예외적인 오류 상황을 위해 남겨 두고, "사용자가 빈 문자열을 입력했다" 같은 경우에는 쓰지 마세요.

catch 블록은 버그를 숨깁니다. 오류를 묵살하려고 catch (...) {}를 쓰면 실패가 흔적도 없이 사라집니다. 최소한 문제를 로그로 남기세요. 보통은 다시 던지거나 제대로 처리해야 합니다.

던지는 소멸자는 위험합니다. 소멸자가 스택 풀기 _도중_에(이미 다른 예외가 진행 중일 때) 던지면 프로그램은 std::terminate를 호출합니다. 현대 C++에서 소멸자는 암묵적으로 noexcept입니다. 결코 예외가 소멸자에서 빠져나가게 하지 마세요.

catchtry가 덮는 것만 봅니다. try에 진입하기 _전_에 던져진 예외나, 그 안의 호출 경로에 없는 다른 함수에서 던져진 예외는 여기서 잡히지 않습니다. catch는 자신의 try 블록 안에서 실행되는 코드(직접, 또는 그것이 호출하는 함수에서)만 보호합니다.

다음: 미정의 동작

예외는 무언가 잘못되었음을 C++가 알려 주는 정의된 방식입니다. 던지고, 잡으면, 동작은 예측 가능합니다. 그러나 C++에는 언어가 아무것도 보장하지 않는 더 어두운 구석도 있습니다. 댕글링 포인터의 역참조, 배열 끝을 넘어선 읽기, 부호 있는 정수 오버플로 같은 것들입니다. 다음 페이지에서는 미정의 동작을 다룹니다. 무엇이 그것을 유발하는지, 왜 재앙적으로 동작을 멈추기 직전까지 "잘 되는" 것처럼 보일 수 있는지, 그리고 그것을 코드에서 몰아내는 방법을 설명합니다.

자주 묻는 질문

C++에서 try-catch는 어떻게 동작하나요?

예외를 던질 수 있는 코드를 try { } 블록 안에 넣습니다. 예외가 던져지면 프로그램은 try 블록의 나머지 실행을 멈추고, 처음으로 일치하는 catch 블록으로 점프하여 거기서 오류를 처리합니다. 아무것도 던져지지 않으면 catch 블록은 전부 건너뜁니다.

C++에서 예외를 const 참조로 잡아야 하는 이유는 무엇인가요?

참조로 잡으면(catch (const std::exception& e)) 예외 객체의 복사를 피하고, 무엇보다 다형성을 보존합니다. 따라서 기반 타입으로 잡힌 파생 예외도 여전히 올바른 what()을 호출합니다. 값으로 잡으면(catch (std::exception e)) 파생 부분이 잘려 나가(슬라이싱) 정보를 잃을 수 있습니다.

C++에서 모든 예외를 잡으려면 어떻게 하나요?

catch (...)를 사용하세요. 생략 부호는 타입에 상관없이 모든 예외를 잡습니다. 최후의 수단으로 유용한 핸들러이지만, 들여다볼 객체가 주어지지 않으므로 구체적인 catch 블록들 뒤에 두고 주로 로그를 남기거나 다시 던지는 용도로 사용하세요.

Coddy programming languages illustration

Coddy로 코딩 배우기

시작하기