C++ · 기본
C++ 객체와 자원 관리
C++ 예외와 오류 처리 - 스택 풀기, 예외 안전성 보장 단계, noexcept (C++ 객체와 자원 관리 9장)
예외가 던져질 때 RAII 객체가 정리되는 순서를 확인하고, 기본 보장과 강한 보장의 차이를 재고 목록 예제로 비교한다. noexcept 위반과 예외 없는 오류 처리도 본다.
개발자 · 원고 갱신
이 장에서 배우는 것
C에서는 함수가 오류 코드를 돌려주고, 호출한 쪽이 매번 검사한다. 검사를 한 번 빠뜨리면 오류는 조용히 사라진다. C++의 예외는 오류를 무시할 수 없게 만든다. 잡지 않으면 프로그램이 멈춘다. 그리고 예외가 던져질 때 지나가는 모든 지역 객체의 소멸자가 불리므로(3장), RAII로 짠 코드는 오류 경로에서도 자원을 잃지 않는다. 남는 문제는 "오류가 난 뒤 객체의 상태가 어떻게 되는가"이고, 이것을 예외 안전성이라고 부른다.
- 설정 파일 파서에서 예외가 던져지고 잡히는 동안 객체가 정리되는 순서(스택 풀기)를 찍는다.
- 예외 클래스를 만들고 참조로 잡는 방법,
catch순서를 익힌다. - 예외 안전성의 세 단계(기본·강한·실패 없음)를 재고 목록 예제로 비교한다.
noexcept함수에서 예외가 새어 나가면 어떻게 되는지, 예외를 쓰지 않는 환경에서는 무엇을 쓰는지 본다.
문제 상황
로봇의 설정 파일에서 최고 속도를 읽는다. 값이 숫자가 아니거나(fast), 범위를 벗어나면(900) 설정을 거부해야 한다. 파싱 함수는 호출 깊숙한 곳에 있고, 오류를 사용자에게 보여 주는 코드는 맨 바깥에 있다. 오류 코드로 짜면 중간 함수들이 모두 "받은 오류를 그대로 위로 전달"하는 코드를 가져야 한다.
두 번째 문제는 여러 품목을 한꺼번에 재고에 넣는 함수다. 세 개 중 두 번째가 잘못되었을 때, 첫 번째만 들어간 상태로 남으면 재고 수량이 틀어진다. "전부 들어가거나, 하나도 안 들어가거나"를 보장하고 싶다.
완성 코드
exceptions.cpp로 저장한다. Step은 생성·소멸을 찍어 스택 풀기를 보여 주는 RAII 객체다.
#include <iostream>
#include <optional>
#include <stdexcept>
#include <string>
struct Step {
std::string name;
explicit Step(std::string n) : name(std::move(n)) { std::cout << " 시작 " << name << '\n'; }
~Step() { std::cout << " 정리 " << name << '\n'; }
};
class ConfigError : public std::runtime_error {
public:
ConfigError(const std::string& key, int line)
: std::runtime_error("설정 오류: " + key + " (" + std::to_string(line) + "행)"), line_(line) {}
int line() const { return line_; }
private:
int line_;
};
int parse_speed(const std::string& text, int line) {
Step s("parse_speed");
std::size_t used = 0;
int value = std::stoi(text, &used);
if (used != text.size() || value < 0 || value > 300) throw ConfigError("max_speed", line);
return value;
}
void load_config(const std::string& speed_text) {
Step s("load_config");
int speed = parse_speed(speed_text, 12);
std::cout << " 속도 " << speed << " 적용\n";
}
std::optional<int> try_parse_speed(const std::string& text) {
try {
std::size_t used = 0;
int v = std::stoi(text, &used);
if (used != text.size()) return std::nullopt;
return v;
} catch (const std::exception&) {
return std::nullopt;
}
}
int main() {
for (std::string input : {"120", "900", "fast"}) {
std::cout << "입력 \"" << input << "\"\n";
try {
load_config(input);
} catch (const ConfigError& e) {
std::cout << " ConfigError: " << e.what() << ", line()=" << e.line() << '\n';
} catch (const std::exception& e) {
std::cout << " std::exception: " << e.what() << '\n';
}
}
std::cout << "optional 로 받기:";
for (std::string input : {"80", "8O"}) {
auto v = try_parse_speed(input);
std::cout << ' ' << input << "->" << (v ? std::to_string(*v) : "없음");
}
std::cout << '\n';
}
줄별 해설
class ConfigError : public std::runtime_error
표준 예외 계층을 상속해 내 예외 타입을 만든다. std::runtime_error의 생성자에 메시지를 넘기면 what()이 그 메시지를 돌려준다. 행 번호처럼 오류 처리에 필요한 정보는 멤버로 추가한다. 표준 예외를 상속해 두면, 내 예외를 모르는 바깥 코드도 catch (const std::exception&)로 잡아 메시지를 보여 줄 수 있다.
std::stoi(text, &used)
문자열을 정수로 바꾼다. 숫자가 전혀 없으면 std::invalid_argument, 범위를 넘으면 std::out_of_range를 던진다. 주의할 점은 "12abc"처럼 앞부분만 숫자인 문자열은 12를 돌려주고 예외를 던지지 않는다는 것이다. 두 번째 인자로 사용한 글자 수를 받아 전체를 썼는지 직접 확인해야 한다.
throw ConfigError("max_speed", line);
예외 객체는 값으로 던지고, new로 만들지 않는다. throw가 실행되면 그 아래 코드(return value;)는 실행되지 않고, 제어가 바로 가장 가까운 맞는 catch로 간다.
catch (const ConfigError& e) 다음 catch (const std::exception& e)
catch는 위에서부터 순서대로 검사해 처음 맞는 것 하나만 실행한다. 그래서 구체적인 타입을 먼저, 부모 타입을 나중에 쓴다. 그리고 항상 const 참조로 잡는다. 값으로 잡으면 예외 객체가 복사되면서 자식 클래스 부분이 잘려 나간다(슬라이싱, 연습 1).
std::optional<int> try_parse_speed
"숫자가 아닐 수 있다"는 것이 오류라기보다 흔한 결과라면 예외 대신 std::optional로 돌려주는 편이 낫다. 값이 있으면 *v로 꺼내고, 없으면 std::nullopt다. 사용자 입력 검증처럼 실패가 자주 일어나는 곳, 게임 루프처럼 매 프레임 불리는 곳에 알맞다.
실제 실행 결과
$ clang++ -std=c++20 -Wall -Wextra exceptions.cpp -o exceptions && ./exceptions
입력 "120"
시작 load_config
시작 parse_speed
정리 parse_speed
속도 120 적용
정리 load_config
입력 "900"
시작 load_config
시작 parse_speed
정리 parse_speed
정리 load_config
ConfigError: 설정 오류: max_speed (12행), line()=12
입력 "fast"
시작 load_config
시작 parse_speed
정리 parse_speed
정리 load_config
std::exception: stoi: no conversion
optional 로 받기: 80->80 8O->없음
900을 보자. parse_speed에서 예외가 나자 "속도 적용" 줄은 건너뛰고, 정리 parse_speed → 정리 load_config 순서로 소멸자가 불린 다음에 main의 catch가 실행되었다. 이것이 스택 풀기다. fast는 stoi가 던진 std::invalid_argument가 ConfigError 블록을 지나 std::exception 블록에서 잡혔다. 메시지 "stoi: no conversion"은 표준 라이브러리 구현마다 다르다. 8O(숫자 8과 영문 O)는 optional 쪽에서 "없음"으로 처리되었다.
예외 안전성의 세 단계
표 9-1. 예외 안전성 보장 단계
| 보장 | 예외가 난 뒤의 상태 | 예 |
|---|---|---|
| 실패 없음(nothrow) | 예외가 아예 나지 않는다 | 소멸자, swap, 이동 생성자(noexcept) |
| 강한 보장(strong) | 호출 전 상태 그대로(전부 아니면 전무) | vector::push_back(이동이 noexcept일 때), 복사 후 교환 대입 |
| 기본 보장(basic) | 누수 없고 객체는 유효하지만, 값은 바뀌었을 수 있다 | 대부분의 일반 함수 |
RAII만 잘 써도 기본 보장은 자동으로 얻는다. 강한 보장은 설계가 필요하다.
#include <iostream>
#include <stdexcept>
#include <string>
#include <vector>
struct Item {
std::string name;
int qty;
};
void check(const Item& item) {
if (item.qty <= 0) throw std::invalid_argument("수량 오류: " + item.name);
}
class Inventory {
public:
// 기본 보장만: 중간에 실패하면 일부만 들어간 상태로 남는다
void add_all_basic(const std::vector<Item>& batch) {
for (const auto& item : batch) {
check(item);
items_.push_back(item);
}
}
// 강한 보장: 복사본에서 작업하고, 전부 성공하면 noexcept swap 으로 바꿔 끼운다
void add_all_strong(const std::vector<Item>& batch) {
std::vector<Item> next = items_;
for (const auto& item : batch) {
check(item);
next.push_back(item);
}
items_.swap(next);
}
std::size_t size() const { return items_.size(); }
private:
std::vector<Item> items_{{"bolt", 10}};
};
int main() {
std::vector<Item> batch{{"nut", 5}, {"gear", 0}, {"spring", 3}};
Inventory a;
try { a.add_all_basic(batch); } catch (const std::exception& e) { std::cout << "basic: " << e.what() << '\n'; }
std::cout << " 실패 뒤 품목 수 " << a.size() << " (원래 1)\n";
Inventory b;
try { b.add_all_strong(batch); } catch (const std::exception& e) { std::cout << "strong: " << e.what() << '\n'; }
std::cout << " 실패 뒤 품목 수 " << b.size() << " (원래 1)\n";
}
$ clang++ -std=c++20 -Wall -Wextra strong_guarantee.cpp -o strong && ./strong
basic: 수량 오류: gear
실패 뒤 품목 수 2 (원래 1)
strong: 수량 오류: gear
실패 뒤 품목 수 1 (원래 1)
add_all_basic은 nut을 넣은 뒤 gear에서 실패해서 품목이 2개가 되었다. 누수는 없지만 절반만 반영된 상태다. add_all_strong은 복사본 next에서 모든 작업을 하고, 전부 성공했을 때만 swap으로 바꿔 끼운다. swap은 실패하지 않으므로, 예외가 나면 복사본만 버려지고 원본은 1개 그대로다. 4장의 복사 후 교환 대입과 같은 구조다. 대가는 복사 비용이므로 모든 함수에 적용하지는 않고, "절반만 반영되면 곤란한" 곳에 쓴다.
noexcept를 어기면
#include <iostream>
#include <stdexcept>
struct Logger {
~Logger() { std::cout << "Logger 정리" << std::endl; }
};
void flush_all() noexcept {
Logger log;
throw std::runtime_error("디스크 가득 참");
}
int main() {
try {
flush_all();
} catch (const std::exception& e) {
std::cout << "잡았다: " << e.what() << std::endl;
}
std::cout << "여기까지 오지 못한다" << std::endl;
}
$ clang++ -std=c++20 -Wall -Wextra noexcept_terminate.cpp -o noexcept_terminate && ./noexcept_terminate
noexcept_terminate.cpp:10:5: warning: 'flush_all' has a non-throwing exception specification but can still throw [-Wexceptions]
10 | throw std::runtime_error("디스크 가득 참");
| ^
noexcept_terminate.cpp:8:6: note: function declared non-throwing here
8 | void flush_all() noexcept {
| ^ ~~~~~~~~
1 warning generated.
libc++abi: terminating due to uncaught exception of type std::runtime_error: 디스크 가득 참
/bin/bash: line 1: 54767 Abort trap: 6 ./noexcept_terminate
clang이 컴파일 때 경고했고, 실행하면 catch가 있는데도 잡히지 않고 std::terminate가 불려 프로세스가 종료되었다(종료 코드 134). noexcept는 "예외가 나가지 않는다"는 약속이라 약속을 어기면 곧바로 종료된다. "Logger 정리"도 찍히지 않았다. 이 경우 스택 풀기를 할지 말지는 구현에 달려 있다. 소멸자는 기본으로 noexcept이므로, 소멸자에서 예외를 밖으로 내보내면 같은 일이 일어난다(2장).
예외를 쓰지 않는 코드
게임 콘솔, 일부 임베디드 펌웨어, 실시간 제어 코드는 -fno-exceptions로 예외를 끄고 빌드하는 경우가 많다. 예외 처리용 표 때문에 바이너리가 커지고, 던질 때 걸리는 시간이 예측하기 어렵기 때문이다. 이런 곳에서는 오류 코드, std::optional, 오류 정보를 함께 담는 결과 타입(C++23의 std::expected 또는 직접 만든 비슷한 타입)을 쓴다. 이 경우에도 RAII는 그대로 쓸 수 있다. 조기 return에서도 소멸자는 불리기 때문이다.
표 9-2. 오류를 알리는 방법 비교
| 방법 | 무시하기 | 추가 정보 | 알맞은 곳 |
|---|---|---|---|
오류 코드(int, bool) | 쉽다(검사 누락) | 따로 전달 | C API 경계 |
std::optional<T> | 꺼낼 때 확인 필요 | 없음(있다/없다만) | 흔한 실패, 조회 |
결과 타입(std::expected 등) | 꺼낼 때 확인 필요 | 오류 값 포함 | 예외를 끈 환경 |
| 예외 | 불가(잡지 않으면 종료) | 예외 객체에 포함 | 드문 실패, 생성자 실패 |
실무에서 자주 틀리는 것
1. 예외를 값으로 잡는다
catch (std::exception e)는 복사하면서 자식 타입 정보를 잃는다. 항상 catch (const 타입& e)다.
2. 모든 예외를 잡아서 삼킨다
catch (...) {}는 오류를 오류 코드보다 더 확실하게 숨긴다. 잡았으면 기록하고, 복구할 수 없으면 throw;로 다시 던진다(throw e;는 복사본을 던지므로 쓰지 않는다).
3. 예외를 흐름 제어에 쓴다
반복문을 빠져나가거나 "찾지 못함"을 알리려고 예외를 던지면 느리고 읽기 어렵다. 예외는 정말 예외적인 상황에 쓰고, 자주 일어나는 실패는 optional이나 반환값으로 알린다.
4. stoi 결과를 끝까지 검사하지 않는다
위에서 본 대로 "12abc"는 12가 된다. 설정 값 오타가 조용히 다른 숫자로 바뀐다. 사용한 글자 수를 확인하거나, C++17의 std::from_chars(예외를 던지지 않고 결과 구조체를 돌려줌)를 쓴다.
연습 문제
Base가std::runtime_error를 상속하고 가상 함수kind()가 "base"를, 그 자식Derived가 "derived"를 돌려준다.throw Derived("x");를catch (Base e)로 잡을 때와catch (const Base& e)로 잡을 때e.kind()는 각각 무엇인가?catch (const std::exception&)를catch (const Derived&)보다 위에 썼다.Derived를 던지면 어느 블록이 실행되는가?std::stoi("12abc", &used)는 예외를 던지는가? 결과와used는?
정답
- 값으로 잡으면
"base", 참조로 잡으면"derived"다. 값으로 잡을 때Derived객체의Base부분만 복사되어 새Base객체가 만들어지므로 가상 함수도Base것이 불린다. 검증 프로그램에서 두 값을 확인했다. std::exception블록이다. 위에서부터 처음 맞는 것 하나만 실행되고,Derived는std::exception의 자식이므로 첫 블록에 맞는다. clang은 이런 순서에 대해 "exception of type 'const Derived &' will be caught by earlier handler" 경고를 낸다. 구체적인 타입을 먼저 쓴다.- 던지지 않는다. 결과는 12,
used는 2다. 앞의 두 글자만 숫자로 읽고 멈춘다.used != text.size()로 나머지가 남았는지 검사해야 한다. 검증 프로그램에서 확인했다.
다음 장에서는 지금까지 여러 번 본 std::vector<int>, std::unique_ptr<Texture>의 꺾쇠 괄호, 즉 템플릿을 직접 만들어 본다.
READER FEEDBACK
질문·오탈자·의견
내용에 관한 질문이나 오탈자, 더 나은 설명을 위한 의견을 남겨 주세요. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.