1. 예외 안전성 보장이 왜 필요한가
프로그래밍을 막 시작했다면 예외는 단순히 “오류를 알리는 수단”처럼 보일 수 있습니다. 실제로는 예외가 디자인의 성숙도를 검사하는 도구이기도 합니다: 객체가 “나쁜 날”을 어떻게 견디는지. 예외 안전성 보장은 메서드가 예외 발생 후의 객체 상태에 대해 하는 형식적인 약속입니다. 자원뿐만 아니라 상태에 대해 무엇이 보장되는지가 핵심입니다.
당신의 객체를 작은 사무실로 상상해보세요. RAII는 패닉 후에 직원들이 다리미를 켜놓거나 열쇠를 문에 두고 나오지 않도록 보장합니다(자원이 누수되지 않음). 하지만 사무실은 엉망이 될 수 있습니다: 문서가 흩어지고, 일부 파일은 이름이 바뀌고 일부는 그대로일 수 있습니다. 예외 안전성 보장은 바로 “패닉 후 문서 정리는 남아 있는가?”라는 질문에 답합니다.
실습을 위해 우리의 가상 학습용 애플리케이션을 이어가겠습니다: 메모리 내 작은 작업 관리자인 TaskTracker는 작업, 태그, 빠른 인덱스를 저장합니다.
메서드 계약과 보장 종류
메서드를 호출할 때 보통 두 가지를 기대합니다: 메서드가 작업을 수행하거나, 실패했음을 정직하게 알립니다. 문제는 메서드가 실패했지만 약간의 변경을 이미 했을 때 발생합니다 — 객체가 중간 상태로 남습니다. 바로 이 지점에서 계약 명세가 코드의 “아름다움”보다 더 중요해집니다.
예외와 관련한 계약은 보통 이렇게 표현됩니다: “메서드가 예외를 던졌다면 객체 상태에 대해 어떤 주장들이 여전히 참인가?”. 이러한 주장들이 바로 예외 안전성 보장입니다. 보장은 철학적이거나 도의적인 것이 아니라 실무적인 것입니다: 나머지 코드를 설계할 때 의지할 수 있는 규칙입니다.
아래에서 우리는 basic guarantee, strong guarantee, no‑throw guarantee라는 용어를 사용합니다. 이들은 표준 라이브러리와 컨테이너 설계 논의에서 자주 등장합니다. 이들 없이는 왜 어떤 연산이 다른 연산보다 “더 안전한지” 설명할 수 없습니다.
보장 표: basic vs strong vs no‑throw
정의들에 빠지지 않기 위해, 의미를 표 형태로 바로 정리해봅시다. 드문 경우지만 표가 실제로 이해를 돕습니다.
| 보장 | 예외 발생 시 약속 | 변경될 수 있는 것 | “대가” |
|---|---|---|---|
| Basic guarantee | 객체는 유효한 상태로 남으며, 불변식은 깨지지 않고 자원은 누수되지 않음 | 상태는 부분적으로 변경될 수 있음(하지만 일관성 있음) | 보통 큰 비용 없음: 불변식을 신중히 유지하면 충분 |
| Strong guarantee | “전부 성공했거나, 상태는 변경되지 않았다” | 아무것도 변경되지 않음(호출 전 상태와 동일) | 종종 임시 상태 준비가 필요하며, 시간/메모리 비용이 들 수 있음 |
| No‑throw guarantee | 메서드는 예외를 밖으로 던지지 않는다 | 중요하지 않음(보통 올바름을 기대함) | 메서드 동작에 제약이 필요하거나 내부에서 예외를 잡아 처리해야 함 |
흥미로운 팩트 하나: 표준 라이브러리 논의에서도 “swap에 대한 no‑throw 보장이 없으면 명세가 모순된다” 같은 표현이 나옵니다. 즉, ‘던지지 않음’ 보장은 연산 요구사항과 올바름에 영향을 줍니다.
이제 각 보장을 간단한 말과 짧은 예제로 따로 살펴보겠습니다.
2. Basic guarantee: 객체는 살아있지만 “가구를 옮겼을” 수 있다
Basic guarantee는 예외 세계에서 최소한의 합리적 수준입니다. 이 보장은 “연산이 예외로 실패하면 객체는 계속 사용 및 파괴할 수 있고, 불변식은 지켜지지만 일부 상태는 변경될 수 있다”고 말합니다. 소박하게 들리지만 실무에서는 큰 승리입니다.
핵심 단어는 유효입니다. 유효한 객체는 반드시 “예전과 똑같은” 객체는 아닙니다. 깨지지 않은 객체란: 메서드를 계속 호출할 수 있고, 안전하게 파괴(destructor)가 실행되며 내부 모순이 없다는 뜻입니다.
보장이 없는 예: 불변식 파괴
작업 저장소를 만들고 불변식이 있다고 합시다: index_by_id_는 tasks_와 일치해야 합니다. 작업이 tasks_에 있으면 그 id로 인덱스를 찾아야 합니다.
#include <string>
#include <unordered_map>
#include <vector>
struct Task {
int id;
std::string title;
};
class TaskStorage {
public:
void add_task_no_guarantee(int id, std::string title) {
tasks_.push_back(Task{id, std::move(title)}); // 예외 발생 가능
index_by_id_.emplace(id, tasks_.size() - 1); // 예외 발생 가능
}
private:
std::vector<Task> tasks_;
std::unordered_map<int, std::size_t> index_by_id_;
};
문제는 “예외가 발생할 수 있다” 자체가 아닙니다. 문제는 emplace가 예외를 던지면 tasks_는 이미 변경되었고 index_by_id_는 변경되지 않았다는 점입니다. 불변식이 깨집니다. 이는 basic 보장조차 아닙니다 — 어쩌다 운이 좋으면 되는 코드입니다.
basic 보장 예: 한 번 한 일을 롤백한다
Basic 보장은 예외 후 객체가 유효해야 한다고 요구합니다. 즉, 한 부분을 변경하고 두번째를 변경하지 못했다면 일관된 상태로 되돌려야 합니다.
#include <string>
#include <unordered_map>
#include <vector>
struct Task {
int id;
std::string title;
};
class TaskStorage {
public:
void add_task_basic(int id, std::string title) {
tasks_.push_back(Task{id, std::move(title)}); // 1단계
try {
index_by_id_.emplace(id, tasks_.size() - 1); // 2단계
} catch (...) {
tasks_.pop_back(); // 롤백(예외 없음)
throw; // 예외 재던짐
}
}
private:
std::vector<Task> tasks_;
std::unordered_map<int, std::size_t> index_by_id_;
};
중요한 교훈: 우리는 예외를 “치료”하지 않습니다. 단지 객체가 망가진 상태로 남지 않도록 합니다. 이것이 바로 기본 예외 안전성입니다: 문제를 숨기지 않되, 상태의 일관성은 유지합니다.
4. Strong guarantee: “전부 아니면 아무것도”
Strong guarantee는 완벽주의자의 꿈처럼 들립니다: “연산이 실패하면 객체 상태는 호출 전과 정확히 같다”. 현실에서는 항상 공짜로 얻을 수는 없습니다. 그러나 이 보장은 호출자가 단순히 생각할 수 있게 해줍니다: “예외가 발생하면 아무 것도 시작하지 않았던 것과 같다”.
Strong 보장이 유용한 경우는 언제일까요? 중간 결과가 논리적으로 위험한 경우입니다. 예를 들어 여러 관련 구조를 동시에 업데이트하는데 일부만 업데이트되면 프로그램이 잘못된 결정을 내릴 수 있습니다.
간단한 예: 먼저 검사하고 그다음에 변경
때때로 strong 보장은 놀랍도록 간단합니다: 모든 검사를 상태 변경 이전에 수행합니다.
#include <stdexcept>
#include <string>
class UserName {
public:
void set(std::string n) {
if (n.empty()) {
throw std::invalid_argument("empty name");
}
name_ = std::move(n); // 드물게 예외가 난다면 이전 이름이 유지되어야 함
}
const std::string& get() const { return name_; }
private:
std::string name_{"unknown"};
};
여기서는 중요한 효과를 얻었습니다: 잘못된 입력이 상태 변화를 일으키지 않습니다. 이것은 적어도 입력 유효성 검사 관점에서 basic보다 강한 보장입니다.
이중 상태에서의 strong 보장: 태그 + 카운터
우리의 TaskStorage에 또 다른 불변식을 추가해봅시다. tag_usage_는 각 태그가 모든 작업에서 몇 번 등장하는지를 기록합니다. 불변식: 합계/카운터는 실제 태그 내용과 일치해야 합니다.
나쁜 구현(또 다시 “보장 없음”):
#include <string>
#include <unordered_map>
#include <vector>
struct Task {
std::vector<std::string> tags;
};
class TaskStorage {
public:
void add_tag_no_guarantee(std::size_t i, std::string tag) {
tasks_[i].tags.push_back(tag); // 예외 발생 가능
tag_usage_[tag] += 1; // 예외 발생 가능(삽입/rehash)
}
private:
std::vector<Task> tasks_;
std::unordered_map<std::string, int> tag_usage_;
};
만약 tag_usage_가 성공 후에 예외를 던지면, 태그는 있지만 카운터는 없습니다. 불변식이 깨집니다.
이제 두 번째 단계가 실패하면 첫 번째를 롤백하는 방식으로 최소한의 로직을 적용해 strong 동작을 달성해보겠습니다. 롤백 자체는 예외를 던지지 않아야 합니다. 그렇지 않으면 “예외 처리 중 예외”라는 악순환이 발생합니다(매우 위험).
#include <string>
#include <unordered_map>
#include <vector>
struct Task {
std::vector<std::string> tags;
};
class TaskStorage {
public:
void add_tag_strong(std::size_t i, const std::string& tag) {
tasks_[i].tags.push_back(tag); // 1단계
try {
tag_usage_[tag] += 1; // 2단계
} catch (...) {
tasks_[i].tags.pop_back(); // 1단계 롤백
throw;
}
}
private:
std::vector<Task> tasks_;
std::unordered_map<std::string, int> tag_usage_;
};
형식적으로 우리는 “태그를 추가하고 카운터를 올렸거나, 아무것도 하지 않았다”라는 동작을 얻었습니다. 이는 이 연산에 대해 우리의 불변식 관점에서 strong guarantee입니다.
그리고 이것은 commit/rollback과 비슷합니다. 다음 강의(278)에서 더 시스템적이고 확장 가능한 접근법을 다루겠습니다(특히 단계가 두 개 이상일 때). 지금은 보장 논리 자체, 즉 무엇을 약속하는가가 중요합니다.
5. No‑throw guarantee: “예외를 밖으로 내보내지 않음”
No‑throw guarantee는 “가장 강한 보장”이라는 의미가 아니라 다른 축의 보장입니다. 이 보장은 “무슨 상태가 남는가”가 아니라 “함수 경계를 넘어 예외가 나가는가”에 대답합니다. 때때로 이것은 매우 중요합니다: 예컨대 소멸자에서는 예외를 밖으로 내보내는 것이 거의 항상 금지되며, 자원 해제 함수나 일부 저수준 연산에서도 마찬가지입니다.
심리적으로 no‑throw는 교활합니다: 초보자는 “모두 no‑throw로 하면 신경 쓸 필요 없다”고 생각하기 쉽습니다. 그러나 연산이 실패할 수 있는 일을 한다면(메모리 할당, 파일 읽기, 파싱 등), “예외를 던지지 않겠다”고 약속하려면 내부에서 그 오류를 다른 방식으로 처리할 준비가 되어 있어야 합니다.
no‑throw의 자연스러운 후보: 단순 상태 조회
게터나 상태 검사 함수는 보통 위험한 일을 하지 않기 때문에 예외를 던지지 않도록 만들기 쉽습니다.
#include <vector>
class TaskStorage {
public:
std::size_t task_count() const noexcept { // noexcept에 대해서는 나중에 자세히
return tasks_.size();
}
private:
std::vector<int> tasks_;
};
여기서는 메모리 할당도 없고 복잡한 로직도 없고 파싱도 없습니다. 따라서 “예외를 던지지 않는다”는 목표가 합리적입니다.
no‑throw의 비자연스러운 후보: 문자열 생성
전형적인 함정: 멋진 to_string()을 만들고 “예외를 던지지 않는다”고 약속하고 싶지만 내부에서 문자열 생성, 연결, 포맷 등이 일어나면 메모리가 필요합니다.
이런 경우 정직하게 no‑throw를 약속하지 않거나(권장), 값이 없을 수 있는 반환을 사용하는 등 다른 오류 전략을 선택하는 것이 보통 더 낫습니다.
6. 메서드별 보장 선택: 실용적 관점
교과서에는 쉽게 “항상 strong 보장을 하라”고 쓰여 있습니다. 실제 코드에서는 이것이 비용이 들거나 불편한 경우가 많습니다. 따라서 이렇게 생각하는 것이 실용적입니다: “호출자가 프로그램을 단순하고 신뢰성 있게 유지하려면 어떤 보장이 필요한가?”
우리 TaskTracker에 대해 다음과 같은 정책을 합의할 수 있습니다(철칙이 아닌 실무적 관점):
| 메서드 | 무엇을 하는가 | 보통 적절한 보장 |
|---|---|---|
|
단순히 개수 읽음 | no‑throw (자연스러운 선택) |
|
작업 찾기 | no‑throw 또는 basic (상태 변경하지 않는다면) |
|
작업과 인덱스 추가 | 최소한 basic, 보통은 strong이 편리 |
|
작업과 카운터 변경 | 가능하면 strong이 좋음 — 그렇지 않으면 동기화 문제 발생 쉬움 |
|
입력/파일 파싱 | 예외를 던질 수 있음, 보장은 설계에 따라 다름(종종 “임시로 모아서” strong을 구현) |
중요한 점: strong 보장은 종종 단순한 아름다움 때문이 아니라 불변식 호환성 때문에 필요하다는 것입니다. 객체가 내부적으로 여러 연관된 구조를 유지하면 부분 업데이트는 지뢰가 됩니다. 형식상 basic을 선언할 수는 있지만, 예외 발생 시 구조가 일관성을 잃는다면 그것은 basic이 아니라 ‘보장 없음’입니다.
7. 미니 데모: 보장을 손으로 확인하는 방법
정의를 읽을 때는 모든 것이 명확해 보입니다. 그러나 실제로는 “여기서 예외가 발생했는데 객체가 여전히 정상이다” 또는 “여기서 예외가 발생했더니 객체가 망가졌다”라는 효과를 직접 보면 더 기억에 남습니다. 이를 위해 연산 중간에 인위적으로 예외를 발생시키는 방법을 아는 것이 유용합니다.
가장 단순한 연습은 “가끔 예외를 던지는” 함수를 만드는 것입니다. 프로덕션에서는 이렇게 하지 말아야 하지만, 데모에는 아주 유용합니다.
#include <stdexcept>
void fail_if(bool condition) {
if (condition) {
throw std::runtime_error("simulated failure");
}
}
이제 연산 내부에 원하는 지점에 fail_if(true)를 삽입해 불변식이 유지되는지, 계속 작업할 수 있는지, 객체가 무너지지 않는지 확인할 수 있습니다.
예를 들어 인덱스 추가 중간에 “가짜”로 실패를 넣어봅니다:
#include <string>
#include <unordered_map>
#include <vector>
struct Task { int id; std::string title; };
class TaskStorage {
public:
void add_task_demo(int id, std::string title, bool fail_on_index) {
tasks_.push_back(Task{id, std::move(title)});
fail_if(fail_on_index); // 단계 사이에서 실패를 시뮬레이트
index_by_id_.emplace(id, tasks_.size() - 1);
}
private:
std::vector<Task> tasks_;
std::unordered_map<int, std::size_t> index_by_id_;
};
롤백이 없다면 fail_if(true) 이후 객체는 이미 절반만 변경된 상태가 됩니다. 이때 스스로에게 묻습니다: “내가 어떤 보장을 주려 했지? 그리고 실제로 그 보장을 지키고 있나?”
8. 예외 안전성 보장 선택 시 흔한 실수들
실수 #1: RAII가 자동으로 모든 것에 대해 strong 보장을 준다고 생각하는 것.
RAII는 자원을 거의 마법처럼 보호하지만 객체의 논리적 상태를 고치지는 않습니다. 세 필드를 변경하다가 네 번째에서 실패하면 누수는 없을지 몰라도 객체는 “반만 업데이트된” 상태가 됩니다. 보장은 상태에 관한 것이지 메모리에 관한 것이 아닙니다.
실수 #2: basic 보장을 선언하고도 예외 시 불변식을 깨는 것.
Basic 보장은 객체의 유효성을 요구합니다. 객체가 관련된 구조(예: vector + unordered_map 인덱스)를 가지고 있다면 부분 업데이트는 객체를 무효화합니다. 이 경우 basic을 준다고 말할 수 없습니다. 변경을 롤백하거나 불변식을 깨지 않도록 작업 순서를 바꿔야 합니다.
실수 #3: strong 보장과 no‑throw 보장을 혼동하는 것.
Strong 보장은 “연산 실패 시 상태가 변경되지 않는다”라고 말하지만, 연산은 여전히 예외를 던질 수 있습니다. No‑throw는 “예외가 밖으로 나가지 않는다”라고 말하지만 이는 상태가 변경되지 않음을 의미하지 않습니다. 이들은 다른 약속이며 혼동하면 이상한 인터페이스가 나옵니다.
실수 #4: 기본 목표를 no‑throw로 설정하는 것.
때로는 모든 것을 no‑throw로 만들고 싶어집니다. 그러나 내부에서 메모리 할당, 파싱, I/O 등이 일어나면 예외를 숨기게 되거나 다른 오류 체계를 도입해야 합니다. no‑throw는 훌륭한 도구지만 정직하게 적용할 때만 유효합니다.
실수 #5: 롤백을 “대충” 구현하고 롤백 자체가 예외를 던질 수 있다는 점을 잊는 것.
catch에서 변경을 롤백하는데 그 롤백 연산이 다시 예외를 던지면 첫 번째 예외 처리가 실패하면서 더 큰 문제가 됩니다. 따라서 롤백은 가능한 한 단순하고 안전해야 합니다: pop_back(), 카운터를 1만큼 줄이기, 이미 준비된 상태와 swap하기 등. 이 주제는 다음 강의에서 commit/rollback과 커밋 단계로서의 swap을 통해 더 체계적으로 다루겠습니다.
GO TO FULL VERSION