PHpullh
심층 가이드/C++/에러 처리

C++ · 심층 가이드

C++ 에러 처리 완전 정리

예외와 반환값이라는 두 계열을 함께 쓰는 15개 예제로 안전한 숫자 파싱과 도메인 오류 표현, RAII 기반 정리, 복구 흐름까지 차례로 정리합니다.

주제 15개 · 예제 코드 포함 · 최종 수정 2026-09-03 · 작성 pullh 편집팀

C++에는 오류를 알리는 방식이 하나가 아닙니다. 예외, 오류 코드, std::optional, 그리고 그냥 정의되지 않은 동작까지 공존합니다. 어느 쪽을 고를지는 취향이 아니라 호출 빈도와 실패의 성격이 정합니다. 흔히 일어나는 실패는 값으로 돌려주고, 드물고 프로그램 흐름을 끊어야 하는 실패는 예외로 던지는 구분이 실무에서 가장 오래 버팁니다. finally 구문이 없는 것도 의도된 설계로, 그 자리를 소멸자가 대신합니다.

try catch 기본과 사용자 정의 오류로 예외 계층을 잡은 뒤 Result 반환을 보면 두 방식의 대비가 선명해집니다. finally 정리 항목은 문법을 찾는 편이 아니라, 자원을 쥔 객체의 소멸자에 정리를 넣는 RAII 관용구를 익히는 편입니다. 안전한 숫자 파싱과 입력 검증, 다단계 검증은 경계에서 한 번에 막는 구조를 만드는 흐름이고, fallback 값 사용과 recover 흐름이 그 뒤를 받습니다.

숫자 파싱은 함수마다 실패 처리 방식이 전부 다릅니다. std::stoi는 형식이 틀리면 예외를 던지고 범위를 넘으면 또 다른 예외를 던지지만, C 계열의 atoi는 실패해도 조용히 0을 돌려주어 유효한 입력과 구별되지 않습니다. C++17의 std::from_chars는 예외도 할당도 없이 오류 코드와 파싱이 멈춘 위치를 함께 돌려주고 로케일 영향도 받지 않아, 신뢰할 수 없는 입력에는 이쪽이 맞습니다. 그리고 소멸자에서 예외가 빠져나가면 프로그램이 그대로 종료되므로, 정리 코드 안의 예외는 반드시 그 안에서 삼켜야 합니다.

01try catch 기본

try 블록에서 std::runtime_error를 던지고, catch가 기반 클래스인 std::exception을 const 참조로 받아 what() 메시지를 출력합니다. 실패할 수 있는 호출을 감싸는 기본 골격입니다. 예외를 값으로 받으면 파생 정보가 잘려 나가는 객체 슬라이싱이 생기므로 참조로 받는 것이 정석입니다.

C++ code

// try catch 기본
#include <exception>
#include <iostream>
#include <stdexcept>

int main() {
    try { throw std::runtime_error("boom"); } catch (const std::exception& ex) { std::cout << ex.what() << "\n"; }
}
알아두면 좋은 점

여러 catch 절은 위에서부터 차례로 검사되므로, std::exception처럼 넓은 타입을 먼저 적으면 아래의 구체적인 절이 영원히 실행되지 않습니다.

자주 하는 실수

catch (...)로 모든 예외를 삼키고 기록도 남기지 않으면, 원인을 알 수 없는 상태로 실행이 이어져 훨씬 엉뚱한 지점에서 실패가 드러납니다.

02사용자 정의 오류

require_positive는 인자가 0 이하일 때만 std::runtime_error를 던지고 그 밖에는 값을 그대로 돌려주며, 여기서는 2가 들어와 예외 없이 2가 출력됩니다. 함수 안에서 계약을 확인하고 어기면 던지는 방식입니다. 실무에서는 std::runtime_error를 상속한 타입을 정의해야 호출한 쪽이 원인별로 골라 잡을 수 있습니다.

C++ code

// 사용자 정의 오류
#include <iostream>
#include <stdexcept>

int require_positive(int value) { if (value <= 0) throw std::runtime_error("positive only"); return value; }
int main() {
    std::cout << require_positive(2) << "\n";
}
알아두면 좋은 점

던지는 조건과 메시지를 한 함수에 모아 두면 호출 지점마다 같은 규칙이 적용되고, 검사식을 고칠 자리도 한 곳으로 좁혀집니다.

자주 하는 실수

예외 메시지에 실패한 값 자체를 넣지 않으면 로그만 보고는 어떤 입력이 문제였는지 알 수 없어, 재현에 훨씬 많은 시간이 듭니다.

03입력 검증

던지는 쪽과 받는 쪽이 한 함수 안에 모여 있어 예외가 어떤 경로로 전달되는지 그대로 보입니다. 값을 검사해 조건에 어긋나면 던지고 바깥에서 처리하는 구조에 그대로 대응합니다. 잘못된 인자에는 std::invalid_argument처럼 std::logic_error 계열을 쓰고, 실행 중에만 알 수 있는 실패에는 std::runtime_error 계열을 쓰는 관행이 있습니다.

C++ code

// 입력 검증
#include <exception>
#include <iostream>
#include <stdexcept>

int main() {
    try { throw std::runtime_error("boom"); } catch (const std::exception& ex) { std::cout << ex.what() << "\n"; }
}
알아두면 좋은 점

std::out_of_range와 std::invalid_argument는 모두 std::logic_error를 거쳐 상속되므로, 중간 단계 타입 하나로 묶어 잡을 수 있습니다.

자주 하는 실수

끝까지 잡히지 않은 예외는 std::terminate를 부르며, 이때 스택 되감기가 일어날지는 구현이 정하므로 소멸자가 실행되지 않을 수 있습니다.

04안전한 숫자 파싱

인자가 양수인지 확인해 아니면 던지는 함수로, 문자열을 숫자로 바꾸는 코드가 갖춰야 할 검사 지점과 같은 자리에 있습니다. std::stoi는 변환할 수 없으면 std::invalid_argument, 범위를 넘으면 std::out_of_range를 던지며, 앞부분만 숫자여도 그 부분까지만 읽고 성공으로 처리하므로 남은 문자를 직접 확인해야 합니다.

C++ code

// 안전한 숫자 파싱
#include <iostream>
#include <stdexcept>

int require_positive(int value) { if (value <= 0) throw std::runtime_error("positive only"); return value; }
int main() {
    std::cout << require_positive(4) << "\n";
}
알아두면 좋은 점

std::from_chars는 예외도 로케일도 쓰지 않고 변환이 끝난 위치까지 알려 주어, 남은 문자가 있는지 확인하기가 훨씬 간단합니다.

자주 하는 실수

std::stoi는 앞의 공백을 건너뛰고 12abc 같은 입력도 12로 받아들이므로, 예외가 없었다는 사실만으로 입력이 온전했다고 볼 수 없습니다.

05안전한 나눗셈

throw와 catch가 짝을 이뤄 실패를 메시지로 바꿔 출력합니다. 0으로 나누는 상황을 막을 때도 같은 방식으로 미리 검사해 던지게 됩니다. 정수를 0으로 나누는 연산은 C++에서 예외가 아니라 정의되지 않은 동작이라 잡을 수 없고, 부동소수점은 대개 무한대나 NaN을 만들어 조용히 흘러가므로 나누기 전에 확인해야 합니다.

C++ code

// 안전한 나눗셈
#include <exception>
#include <iostream>
#include <stdexcept>

int main() {
    try { throw std::runtime_error("boom"); } catch (const std::exception& ex) { std::cout << ex.what() << "\n"; }
}
알아두면 좋은 점

부동소수 나눗셈은 결과를 std::isfinite로 검사하는 방법도 있으며, NaN은 자기 자신과의 비교조차 거짓이라는 점을 이용할 수 있습니다.

자주 하는 실수

INT_MIN / -1은 몫이 int 범위를 넘어 0으로 나누는 것과 마찬가지로 정의되지 않은 동작이므로, 이 조합도 따로 걸러야 합니다.

06finally 정리

예외가 던져지면 함수는 중간에 빠져나가지만, 그 과정에서 이미 만들어진 지역 객체의 소멸자는 모두 호출됩니다. C++에 finally가 없는 이유가 여기에 있습니다. 파일 핸들이나 잠금은 std::unique_ptr, std::lock_guard처럼 RAII 타입에 맡기면 정상 경로와 예외 경로 모두에서 정리가 보장됩니다.

C++ code

// finally 정리
#include <iostream>
#include <stdexcept>

int require_positive(int value) { if (value <= 0) throw std::runtime_error("positive only"); return value; }
int main() {
    std::cout << require_positive(6) << "\n";
}
알아두면 좋은 점

정리 코드를 자유롭게 넣고 싶다면 소멸자에서 보관해 둔 람다를 부르는 작은 스코프 가드 타입을 만들어 두면 한 줄로 재사용할 수 있습니다.

자주 하는 실수

소멸자에서 예외를 던지면 이미 예외가 퍼지는 중일 때 std::terminate가 불리므로, 소멸자 안의 실패는 그 안에서 처리하고 기록만 남겨야 합니다.

07오류 다시 던지기

잡은 예외를 ex.what()으로 확인하고 끝내는 코드인데, 로그만 남기고 위로 올려보내야 하는 경우도 흔합니다. catch 블록 안에서 인자 없는 throw;를 쓰면 원래 예외 객체가 그대로 다시 던져져 타입과 정보가 보존됩니다. 반면 throw ex;라고 적으면 복사가 일어나 파생 타입 정보를 잃습니다.

C++ code

// 오류 다시 던지기
#include <exception>
#include <iostream>
#include <stdexcept>

int main() {
    try { throw std::runtime_error("boom"); } catch (const std::exception& ex) { std::cout << ex.what() << "\n"; }
}
알아두면 좋은 점

std::current_exception()으로 예외를 exception_ptr에 담아 두면, 다른 스레드나 나중 시점에서 std::rethrow_exception으로 다시 던질 수 있습니다.

자주 하는 실수

catch 블록 밖에서 인자 없는 throw;를 실행하면 다시 던질 예외가 없어 그 자리에서 std::terminate가 호출됩니다.

08도메인 오류 표현

이 함수는 양수라는 규칙을 코드로 못 박고, 어기면 문자열 메시지를 담은 예외로 알립니다. 다만 메시지 문자열만으로는 호출한 쪽이 원인을 구분해 처리하기 어렵습니다. std::runtime_error를 상속해 필드를 가진 타입을 만들면 catch로 종류를 갈라내고 데이터도 함께 넘길 수 있으며, 이때 소멸자는 예외를 던지지 않아야 합니다.

C++ code

// 도메인 오류 표현
#include <iostream>
#include <stdexcept>

int require_positive(int value) { if (value <= 0) throw std::runtime_error("positive only"); return value; }
int main() {
    std::cout << require_positive(8) << "\n";
}
알아두면 좋은 점

예외 타입에 코드나 필드를 담을 때는 복사 생성자가 예외를 던지지 않아야 하므로, 메시지 보관은 std::runtime_error에 맡기는 편이 안전합니다.

자주 하는 실수

오류 종류를 메시지 문자열 비교로 구분하면 문구를 다듬는 순간 처리 분기가 조용히 깨지므로, 구분은 타입이나 코드로 해야 합니다.

09fallback 값 사용

예외를 잡은 뒤 프로그램을 끝내지 않고 이어 가는 형태라, catch 블록에 대체값을 넣으면 실패해도 흐름이 유지됩니다. 다만 무엇이 실패했는지 감추지 않도록 기록은 남겨야 합니다. 실패가 예외적인 사건이 아니라 흔한 결과라면, 던지고 잡는 비용 대신 std::optional을 반환해 호출한 쪽이 대체값을 정하게 하는 편이 낫습니다.

C++ code

// fallback 값 사용
#include <exception>
#include <iostream>
#include <stdexcept>

int main() {
    try { throw std::runtime_error("boom"); } catch (const std::exception& ex) { std::cout << ex.what() << "\n"; }
}
알아두면 좋은 점

대체값을 쓰기로 했다면 원래 실패를 지표로 남겨 두어야 하며, 그러지 않으면 장애가 성능 저하로만 나타나 원인을 찾기가 어려워집니다.

자주 하는 실수

예외를 정상 흐름 제어에 쓰면 던지고 잡는 경로가 최적화되지 않은 느린 길이라, 반복문 안에서 실패가 잦아지는 순간 처리량이 급격히 떨어집니다.

10assertion

조건을 확인해 어긋나면 예외를 던지는 함수로, 실행 중에도 살아 있는 검사입니다. <cassert>의 assert와는 성격이 다릅니다. assert는 NDEBUG가 정의된 릴리스 빌드에서 통째로 사라지므로 개발 중 논리 오류를 잡는 용도이고, 외부 입력 검증에는 이 코드처럼 항상 동작하는 검사가 필요합니다.

C++ code

// assertion
#include <iostream>
#include <stdexcept>

int require_positive(int value) { if (value <= 0) throw std::runtime_error("positive only"); return value; }
int main() {
    std::cout << require_positive(10) << "\n";
}
알아두면 좋은 점

C++11의 static_assert는 컴파일 시점에 조건을 검사해 실행 비용이 전혀 없으므로, 타입 크기나 템플릿 인자 제약을 못 박을 때 적합합니다.

자주 하는 실수

파일을 여는 것처럼 부작용이 있는 식을 단언 안에 넣으면 릴리스 빌드에서 그 동작 자체가 사라져, 디버그와 릴리스가 서로 다르게 동작합니다.

11Result 반환

성공하면 값을, 실패하면 예외를 던지는 두 갈래 구조입니다. 결과와 오류를 하나의 반환값에 담고 싶다면 다른 방법이 있습니다. C++23의 std::expected<T, E>는 값 또는 오류를 담아 돌려주므로 호출한 쪽이 검사를 강제받고, 예외 전파를 쓰지 않는 환경에서도 오류 정보를 유지할 수 있습니다.

C++ code

// Result 반환
#include <exception>
#include <iostream>
#include <stdexcept>

int main() {
    try { throw std::runtime_error("boom"); } catch (const std::exception& ex) { std::cout << ex.what() << "\n"; }
}
알아두면 좋은 점

std::expected를 쓸 수 없는 환경이라면 값과 오류 코드를 담은 구조체를 [[nodiscard]]로 표시해, 결과를 무시하지 못하게 막을 수 있습니다.

자주 하는 실수

std::optional로 실패를 표현하면 실패했다는 사실만 남고 이유가 사라지므로, 원인별로 다르게 대응해야 하는 자리에는 맞지 않습니다.

12다단계 검증

검사를 통과한 값을 그대로 반환하기 때문에, 이런 함수를 require_positive(require_range(x))처럼 겹쳐 부르면 검사 단계를 이어 붙일 수 있습니다. 어느 한 단계라도 실패하면 즉시 예외로 빠져나가 뒤 단계는 실행되지 않습니다. 여러 오류를 모아 한 번에 보고해야 하는 경우라면 예외 대신 결과를 누적하는 방식이 맞습니다.

C++ code

// 다단계 검증
#include <iostream>
#include <stdexcept>

int require_positive(int value) { if (value <= 0) throw std::runtime_error("positive only"); return value; }
int main() {
    std::cout << require_positive(12) << "\n";
}
알아두면 좋은 점

검증 함수가 값을 그대로 돌려주도록 만들면 호출을 겹쳐 쓸 수 있고, 각 단계가 무엇을 보장하는지 함수 이름만으로 드러납니다.

자주 하는 실수

여러 검사를 하나의 if에 &&로 이어 붙이면 앞이 거짓일 때 뒤가 평가되지 않아, 실패 원인을 첫 번째 것밖에 알 수 없습니다.

13로깅 래퍼

catch 안에서 what()을 출력하는 부분이 사실상 최소한의 로깅입니다. 이 처리를 함수로 감싸면 호출 지점마다 같은 기록 형식을 유지할 수 있습니다. 주의할 점은 what()이 돌려주는 const char*의 수명이 예외 객체에 묶여 있어서, catch 블록을 벗어난 뒤 그 포인터를 쓰면 안 된다는 것입니다.

C++ code

// 로깅 래퍼
#include <exception>
#include <iostream>
#include <stdexcept>

int main() {
    try { throw std::runtime_error("boom"); } catch (const std::exception& ex) { std::cout << ex.what() << "\n"; }
}
알아두면 좋은 점

로그에 typeid(ex).name()을 함께 찍으면 어떤 예외 타입이었는지 구분되지만, 그 이름의 형식은 구현마다 다르므로 그대로 파싱하면 안 됩니다.

자주 하는 실수

여러 스레드가 std::cout에 동시에 로그를 흘리면 줄이 서로 섞여 나오므로, 한 줄을 먼저 조립한 뒤 한 번에 출력해야 합니다.

14recover 흐름

예외를 던지지 않고 값을 돌려주는 정상 경로를 보여 주며, 실패한 뒤 다시 이 상태로 돌아오게 만드는 것이 복구입니다. catch에서 상태를 되돌리려면 예외가 발생해도 객체가 쓸 수 있는 상태로 남는지가 관건입니다. 강한 예외 안전성은 실패 시 원래 상태를 그대로 유지한다는 뜻이며, 보통 임시 객체를 만든 뒤 swap으로 교체해 얻습니다.

C++ code

// recover 흐름
#include <iostream>
#include <stdexcept>

int require_positive(int value) { if (value <= 0) throw std::runtime_error("positive only"); return value; }
int main() {
    std::cout << require_positive(14) << "\n";
}
알아두면 좋은 점

std::vector의 push_back은 강한 예외 보장을 제공하지만, 원소의 이동 생성자가 noexcept일 때만 이동을 쓰고 아니면 복사로 되돌아갑니다.

자주 하는 실수

예외를 잡은 뒤 같은 객체를 계속 쓰려면 그 타입이 어떤 보장을 하는지 확인해야 하며, 아무 보장도 없는 상태에서 이어 쓰면 깨진 값이 그대로 퍼집니다.

15입력 가드

던지고 곧바로 잡는 구조라 예외가 함수 경계를 넘지 않습니다. 입력을 걸러 내는 코드에서는 잘못된 값을 발견한 즉시 던지고 호출한 쪽에서 받는 배치가 흔합니다. 다만 noexcept로 표시한 함수 밖으로 예외가 빠져나가면 std::terminate가 불려 잡을 기회조차 없어지므로, 던질 수 있는 함수에는 이 표시를 붙이지 않아야 합니다.

C++ code

// 입력 가드
#include <exception>
#include <iostream>
#include <stdexcept>

int main() {
    try { throw std::runtime_error("boom"); } catch (const std::exception& ex) { std::cout << ex.what() << "\n"; }
}
알아두면 좋은 점

noexcept는 이동 생성자에 붙일 때 특히 값어치가 있어서, 컨테이너가 재할당할 때 복사 대신 이동을 고르는 판단 근거가 됩니다.

자주 하는 실수

예외가 나가지 않는다고 확신해 noexcept를 붙였는데 내부 할당에서 std::bad_alloc이 발생하면, 잡을 기회도 없이 프로그램이 종료됩니다.

정리하며

  • atoi는 실패를 0으로 감추고 stoi는 예외를 던집니다. 외부 입력에는 from_chars가 안전합니다
  • 소멸자 밖으로 예외가 빠져나가면 프로그램이 종료되므로 정리 코드에서 잡아야 합니다
  • finally 대신 RAII를 씁니다. 자원 해제는 소멸자에 넣어 경로에 상관없이 실행시킵니다
  • 흔한 실패는 값으로 돌려주고 드문 실패만 예외로 던지는 기준을 팀 안에서 통일합니다

더 깊이 들어가고 싶다면 C++ 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.