C++ 예외 처리 | try-catch-throw와 예외 vs 에러 코드, 언제 뭘 쓸지

C++ 예외 처리 | try-catch-throw와 예외 vs 에러 코드, 언제 뭘 쓸지

이 글의 핵심

C++ 예외 처리에 대해 정리한 개발 블로그 글입니다. 설정 파일을 파싱하는 코드를 작성하고 있었습니다. 파일 열기 → JSON 파싱 → 값 검증 → 객체 생성까지 함수 호출이 5단계였습니다. 쉽게 말해 에러 코드 방식은 "각 단계에서 실패하면 숫자(코드)를 돌려보내고, 호출한… 개념과 예제 코드를 단계적으로 다루며, 실무·학습에 참고할 수 있도록 구성했습니다. 관련 키워드: C++, C+…

들어가며: 에러 코드 vs 예외, 어느 쪽이 나을까?

“함수가 20단계 깊이인데 에러 코드를 계속 전달해야 하나요?”

설정 파일을 파싱하는 코드를 작성하고 있었습니다. 파일 열기 → JSON 파싱 → 값 검증 → 객체 생성까지 함수 호출이 5단계였습니다. 쉽게 말해 에러 코드 방식은 “각 단계에서 실패하면 숫자(코드)를 돌려보내고, 호출한 쪽에서 매번 확인하는 것”이고, 예외는 “한 번 throw하면 catch할 때까지 호출 스택을 타고 올라가므로, 중간 단계에서는 신경 쓰지 않아도 되는 것”입니다.

에러 코드 방식 (C 스타일)에서는 ErrorCode 열거형(enum—정수에 이름을 붙여 코드(0, 1, 2…)를 의미 있게 쓰는 타입)으로 성공(0)과 실패 종류(1, 2, 3)를 숫자로 표현합니다. loadConfig는 파일 열기 → readFileparseJsonvalidateJsoncreateConfig 순으로 호출하고, 각 단계에서 if (err != OK) return err;로 에러를 그대로 위로 전달합니다. main에서는 loadConfig의 반환값을 검사해 0이 아니면 에러 메시지를 출력하고 종료합니다. 이렇게 하면 모든 중간 함수가 에러 코드를 반환하고, 호출하는 쪽에서 매번 검사해야 합니다.

enum ErrorCode {
    OK = 0,
    FILE_NOT_FOUND = 1,
    PARSE_ERROR = 2,
    VALIDATION_ERROR = 3
};

ErrorCode loadConfig(const std::string& path, Config& out) {
    std::ifstream file(path);
    if (!file) return FILE_NOT_FOUND;
    
    std::string content;
    ErrorCode err = readFile(file, content);
    if (err != OK) return err;
    
    JsonValue json;
    err = parseJson(content, json);
    if (err != OK) return err;
    
    err = validateJson(json);
    if (err != OK) return err;
    
    err = createConfig(json, out);
    return err;
}

int main() {
    Config config;
    ErrorCode err = loadConfig("config.json", config);
    if (err != OK) {
        std::cerr << "Error: " << err << "\n";
        return 1;
    }
    // ...
}

위 코드 설명: 각 단계(readFile, parseJson, validateJson, createConfig)에서 반환된 ErrorCode를 if (err != OK) return err로 그대로 위로 전달합니다. 호출 깊이가 깊어질수록 모든 중간 함수가 에러 코드를 반환하고, 호출하는 쪽에서 매번 검사해야 해서 코드가 반복되고, 에러 메시지나 원인을 담기 어렵습니다.

문제점:

  • 모든 함수가 에러 코드를 반환해야 함
  • 중간 단계마다 if (err != OK) 체크 필요
  • 에러 메시지가 없어서 디버깅 어려움
  • 에러 코드를 무시하면 컴파일러가 경고 안 함

예외를 피하는 편이 나은 경우: 성능이 중요한 핫 루프 안에서는 예외 비용이 부담될 수 있고, 실패가 자주 발생하는 정상 경로(예: map에서 키가 없음)는 예외보다 std::optional이나 find 결과 체크가 적합합니다. 임베디드·실시간처럼 예외를 비활성화한 환경에서는 에러 코드를 씁니다. 드물게 발생하는 진짜 예외적인 오류에는 예외를 쓰고, 자주 나오는 분기에는 예외를 쓰지 않는 식으로 구분하면 됩니다.

예외를 쓰면 호출 경로의 중간에서는 에러를 처리할 필요가 없을 때만 상위로 전달할 수 있고, 에러 메시지와 타입(예: std::runtime_error)을 담을 수 있어 디버깅과 로깅이 편해집니다. 반대로, 성능이 아주 중요한 루프 안이나 실패가 “예외적”이지 않고 자주 나오는 경우(예: 키 검색 실패)에는 예외 대신 optional·에러 코드가 나을 수 있습니다.

예외가 발생하면 호출 스택을 타고 위로 전파됩니다. 아래 시퀀스 다이어그램은 createConfig 안에서 throw가 발생했을 때, 중간 함수들은 처리하지 않고 main의 catch까지 올라가는 모습을 단순화한 것입니다.

sequenceDiagram
  participant main as main()
  participant loadConfig as loadConfig()
  participant parseJson as parseJson()
  participant createConfig as createConfig()
  main->>loadConfig: loadConfig(path)
  loadConfig->>parseJson: parseJson(content)
  parseJson->>createConfig: createConfig(json)
  createConfig-->>main: throw runtime_error
  Note over main: catch에서 e.what() 출력

예외 방식으로 바꾼 뒤에는 loadConfigConfig를 곧바로 반환하고, 실패 시 throw std::runtime_error(…)로 예외를 던집니다. readFile, parseJson 등 중간 함수에서 예외가 나면 loadConfig 안에서 따로 처리하지 않아도 호출 스택을 타고 maincatch로 올라갑니다. main에서는 try 블록 안에 “정상일 때 할 일”만 두고, catch (const std::exception& e)에서 e.what()으로 에러 메시지를 받아 출력합니다. 따라서 중간 단계에는 if (err != OK) 같은 반복 코드가 필요 없습니다.

Config loadConfig(const std::string& path) {
    std::ifstream file(path);
    if (!file) {
        throw std::runtime_error("Cannot open file: " + path);
    }
    
    std::string content = readFile(file);        // 실패 시 예외
    JsonValue json = parseJson(content);         // 실패 시 예외
    validateJson(json);                          // 실패 시 예외
    return createConfig(json);                   // 실패 시 예외
}

int main() {
    try {
        Config config = loadConfig("config.json");
        // 정상 로직만 작성
    } catch (const std::exception& e) {
        std::cerr << "Error: " << e.what() << "\n";
        return 1;
    }
}

위 코드 설명: loadConfig는 정상 경로만 쓰고, 실패 시 throw std::runtime_error(...)로 예외를 던집니다. readFile·parseJson·createConfig 등에서 예외가 나면 그대로 스택을 타고 올라가 main의 catch에 도달합니다. 중간 함수에서는 if (err != OK) 같은 코드 없이, 한 곳(catch)에서만 에러 메시지(e.what())를 처리할 수 있습니다.

장점:

  • 중간 단계의 에러 체크 코드 제거
  • 에러 메시지가 예외에 포함됨
  • 깊은 호출 스택에서도 한 곳에서 처리 가능

이 경험으로 예외 처리의 필요성을 느꼈습니다.
에러 코드는 호출하는 쪽에서 매번 검사해야 하고, 깊은 호출 체인에서는 전달만 하다가 놓치기 쉽습니다. 예외는 한 곳에서 잡을 수 있지만, 성능이 중요한 경로나 소멸자에서는 쓰지 않는 것이 원칙이므로, 이 글에서 “언제 쓰고 언제 피할지”를 정리해 둡니다.

실무에서 자주 겪는 문제 시나리오

시나리오증상원인해결 방향
에러 코드 누락프로그램이 이상한 상태로 계속 실행됨if (err != OK) 체크를 빼먹음예외로 전환하면 catch 없으면 프로그램 종료
깊은 호출에서 에러 전달10단계 함수마다 return err 반복에러 코드는 매 단계 전달 필요예외는 throw 한 번으로 상위까지 전파
생성자 실패반환값 없어서 실패를 알릴 수 없음생성자는 return 값이 없음예외가 유일한 실패 전달 수단
리소스 누수예외 발생 시 메모리/파일 핸들 누수예외 경로에서 delete/close 미실행RAII로 리소스 관리
예외를 제어 흐름으로 사용성능 저하, 의도 불명확try { getNext(); return true; } catch { return false; }optional·hasNext() 등 명시적 체크

이 글을 읽으면:

  • try, catch, throw의 기본 문법을 이해할 수 있습니다.
  • 표준 예외 클래스 계층을 알 수 있습니다.
  • 예외를 언제 쓰고 언제 피해야 하는지 판단할 수 있습니다.
  • 실전에서 자주 겪는 예외 관련 실수를 피할 수 있습니다.

목차

  1. 예외란 무엇인가
  2. try-catch-throw 기본 문법
  3. 표준 예외 클래스
  4. RAII와 예외
  5. 예외를 언제 사용할까
  6. 예외를 피해야 하는 경우
  7. 자주 발생하는 문제와 해결법
  8. 베스트 프랙티스
  9. 프로덕션 패턴

1. 예외란 무엇인가

에러 처리의 두 가지 방식

프로그램에서 에러를 처리하는 방법은 크게 두 가지입니다:

1. 반환값으로 에러 코드 전달 (C 스타일)

성공/실패를 숫자로 반환하고, 결과는 참조 인자로 받는 방식입니다. 호출하는 쪽에서 매번 반환값을 검사해야 하며, 검사를 빼먹으면 에러가 조용히 전파됩니다.

// 복사해 붙여넣은 뒤: g++ -std=c++17 -o errcode errcode.cpp && ./errcode
#include <iostream>

int divide(int a, int b, int& result) {
    if (b == 0) return -1;  // 에러 코드
    result = a / b;
    return 0;  // 성공
}

int main() {
    int result;
    if (divide(10, 0, result) != 0) {
        std::cerr << "Division error\n";
    }
    return 0;
}

위 코드 설명: divide는 b가 0이면 -1을 반환하고, 결과는 참조 인자 result에 씁니다. 호출 측에서는 반환값이 0이 아닐 때만 에러로 처리하므로, 검사를 빼먹으면 에러가 조용히 무시될 수 있습니다. 에러 메시지를 담으려면 별도 버퍼나 전역 변수가 필요합니다.

실행 결과: Division error 가 stderr에 출력됩니다.

2. 예외 던지기 (C++ 스타일)

에러가 나면 throw로 예외 객체를 던지고, 호출부에서는 try-catch로 잡습니다. 정상 경로는 try 안에만 두고, 에러 처리만 catch 블록에 모을 수 있어서 가독성이 좋아집니다.

// 복사해 붙여넣은 뒤: g++ -std=c++17 -o except_divide except_divide.cpp && ./except_divide
#include <iostream>
#include <stdexcept>

int divide(int a, int b) {
    if (b == 0) throw std::invalid_argument("Division by zero");
    return a / b;
}

int main() {
    try {
        int result = divide(10, 0);
    } catch (const std::invalid_argument& e) {
        std::cerr << "Error: " << e.what() << "\n";
    }
    return 0;
}

위 코드 설명: b가 0이면 throw std::invalid_argument("...")로 예외를 던지고, 호출부의 catch에서 e.what()으로 메시지를 받아 출력합니다. 정상 경로는 try 안에만 두고, 에러 처리만 catch에 모을 수 있어 에러 코드 방식보다 가독성이 좋습니다.

실행 결과: Error: Division by zero 가 stderr에 출력됩니다.

예외의 장점

1. 에러와 정상 로직 분리

// 에러 코드 방식: 에러 체크가 로직에 섞임
bool processData() {
    if (!openFile()) return false;
    if (!readData()) return false;
    if (!validateData()) return false;
    if (!saveResult()) return false;
    return true;
}

// 예외 방식: 정상 로직만 보임
void processData() {
    openFile();      // 실패 시 예외
    readData();      // 실패 시 예외
    validateData();  // 실패 시 예외
    saveResult();    // 실패 시 예외
}

위 코드 설명: 에러 코드 방식에서는 각 단계마다 if (!…) return false가 들어가 정상 로직과 에러 체크가 섞입니다. 예외 방식에서는 실패 시 throw만 하면 되므로, 함수 본문에는 “정상일 때 할 일”만 남고, 에러는 호출 스택 위의 catch에서 한 번에 처리할 수 있습니다.

2. 깊은 호출 스택에서 에러 전파

void deepFunction() {
    // 20단계 깊이에서 에러 발생
    throw std::runtime_error("Deep error");
}

void middleFunction() {
    deepFunction();  // 에러 코드 체크 불필요
}

void topFunction() {
    try {
        middleFunction();
    } catch (const std::exception& e) {
        // 한 곳에서 처리
        std::cerr << "Caught: " << e.what() << "\n";
    }
}

위 코드 설명: deepFunction에서 throw가 나면 middleFunction은 예외를 처리하지 않고 그대로 위로 전달합니다. topFunction의 try-catch 한 곳에서만 잡으므로, 중간 단계에는 에러 코드 전달이나 if 체크가 필요 없습니다. 예외가 호출 스택을 타고 올라가는 동작을 보여줍니다.

3. 에러 메시지 포함

throw std::runtime_error("Cannot open file: config.json");
// 에러 코드보다 훨씬 자세한 정보

위 코드 설명: 예외 객체에 문자열 메시지를 담을 수 있어, 숫자 코드(1, 2, 3)만 반환하는 방식보다 디버깅과 로깅에 유리합니다. catch에서 e.what()으로 이 메시지를 꺼내 쓸 수 있습니다.


2. try-catch-throw 기본 문법

throw: 예외 던지기

void checkAge(int age) {
    if (age < 0) {
        throw std::invalid_argument("Age cannot be negative");
    }
    if (age > 150) {
        throw std::out_of_range("Age too large");
    }
    std::cout << "Age: " << age << "\n";
}

위 코드 설명: 조건에 따라 서로 다른 예외 타입(invalid_argument, out_of_range)을 던집니다. throw가 실행되면 그 시점에서 함수 실행이 중단되고, 호출 스택을 따라 올라가며 적절한 catch를 찾을 때까지 전파됩니다.

핵심:

  • throw 키워드로 예외 객체를 던짐
  • 던진 순간 함수 실행이 중단되고 호출 스택을 거슬러 올라감
  • 적절한 catch 블록을 찾을 때까지 계속 올라감

try-catch: 예외 잡기

int main() {
    try {
        checkAge(-5);
        std::cout << "This won't print\n";  // 실행 안 됨
    } catch (const std::invalid_argument& e) {
        std::cerr << "Invalid argument: " << e.what() << "\n";
    } catch (const std::out_of_range& e) {
        std::cerr << "Out of range: " << e.what() << "\n";
    } catch (const std::exception& e) {
        std::cerr << "Unknown error: " << e.what() << "\n";
    }
    
    std::cout << "Program continues\n";
}

위 코드 설명: try 안에서 checkAge(-5)가 예외를 던지면, 그 다음 줄은 실행되지 않고 바로 catch로 점프합니다. 구체적인 타입(invalid_argument, out_of_range)을 먼저 잡고, 마지막에 std::exception으로 나머지를 처리합니다. catch 후에는 “Program continues”가 출력되므로 프로그램이 종료되지 않습니다.

출력:

Invalid argument: Age cannot be negative
Program continues

catch 순서의 중요성

try {
    // ...
} catch (const std::exception& e) {
    // ❌ 모든 예외를 먼저 잡으면 아래 catch는 실행 안 됨
    std::cerr << "Generic: " << e.what() << "\n";
} catch (const std::invalid_argument& e) {
    // 도달 불가능! (컴파일러 경고)
    std::cerr << "Invalid argument\n";
}

위 코드 설명: std::exception이 모든 표준 예외의 기본 클래스이므로, 이 catch를 먼저 두면 invalid_argument 등 파생 타입도 여기서 다 잡혀 버립니다. 그래서 아래의 구체적인 catch에는 절대 도달하지 않습니다.

올바른 순서:

try {
    // ...
} catch (const std::invalid_argument& e) {
    // ✅ 구체적인 예외를 먼저
    std::cerr << "Invalid argument: " << e.what() << "\n";
} catch (const std::exception& e) {
    // ✅ 일반적인 예외는 나중에
    std::cerr << "Generic: " << e.what() << "\n";
}

위 코드 설명: 구체적인 예외 타입(invalid_argument)을 먼저 catch하고, 그 다음에 일반적인 std::exception을 잡으면, 타입별로 다른 처리를 할 수 있고 나머지는 공통 처리로 넘깁니다.

catch(…): 모든 예외 잡기

try {
    riskyOperation();
} catch (const std::exception& e) {
    std::cerr << "Standard exception: " << e.what() << "\n";
} catch (...) {
    // 모든 예외 (int, 사용자 정의 타입 등)
    std::cerr << "Unknown exception\n";
}

위 코드 설명: catch(…)는 std::exception을 상속하지 않은 예외(int, 문자열, 사용자 정의 타입 등)까지 모두 잡습니다. 이 블록에서는 예외 객체에 접근할 수 없으므로, 로그만 남기거나 프로그램을 안전하게 종료할 때 사용합니다.


3. 표준 예외 클래스

예외 계층 구조

graph TB
    EXC["std exceptionbr/━━━━━━━━━━━━━━━br/최상위 예외 클래스"]
    
    LOGIC["std logic_errorbr/━━━━━━━━━━━━━━━br/논리 에러br/프로그래머 실수"]
    RUNTIME["std runtime_errorbr/━━━━━━━━━━━━━━━br/런타임 에러br/실행 중 발생"]
    
    INV["invalid_argumentbr/잘못된 인자"]
    DOM["domain_errorbr/수학적 도메인 에러"]
    LEN["length_errorbr/길이 초과"]
    OUT["out_of_rangebr/범위 벗어남"]
    
    RNG["range_errorbr/범위 에러"]
    OVF["overflow_errorbr/오버플로우"]
    UND["underflow_errorbr/언더플로우"]
    SYS["system_errorbr/시스템 에러"]
    
    EXC --> LOGIC
    EXC --> RUNTIME
    
    LOGIC --> INV
    LOGIC --> DOM
    LOGIC --> LEN
    LOGIC --> OUT
    
    RUNTIME --> RNG
    RUNTIME --> OVF
    RUNTIME --> UND
    RUNTIME --> SYS
    
    style EXC fill:#e1f5fe,stroke:#0277bd,stroke-width:3px
    style LOGIC fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px
    style RUNTIME fill:#fff3e0,stroke:#f57c00,stroke-width:2px
    style INV fill:#e1bee7,stroke:#7b1fa2,stroke-width:1px
    style DOM fill:#e1bee7,stroke:#7b1fa2,stroke-width:1px
    style LEN fill:#e1bee7,stroke:#7b1fa2,stroke-width:1px
    style OUT fill:#e1bee7,stroke:#7b1fa2,stroke-width:1px
    style RNG fill:#ffe0b2,stroke:#f57c00,stroke-width:1px
    style OVF fill:#ffe0b2,stroke:#f57c00,stroke-width:1px
    style UND fill:#ffe0b2,stroke:#f57c00,stroke-width:1px
    style SYS fill:#ffe0b2,stroke:#f57c00,stroke-width:1px

주요 예외 클래스 사용 예시

1. std::invalid_argument

void setPort(int port) {
    if (port < 1 || port > 65535) {
        throw std::invalid_argument("Port must be 1-65535");
    }
    // ...
}

위 코드 설명: 인자가 허용 범위를 벗어나면 잘못된 인자를 의미하는 std::invalid_argument를 던집니다. 논리적으로 말이 안 되는 값(음수 포트, 범위 밖 등)에 적합한 예외 타입입니다.

2. std::out_of_range

int getElement(const std::vector<int>& vec, size_t index) {
    if (index >= vec.size()) {
        throw std::out_of_range("Index out of bounds");
    }
    return vec[index];
}

위 코드 설명: 벡터 범위를 벗어난 인덱스 접근을 막기 위해, 범위 밖이면 std::out_of_range를 던집니다. vector::at()이 하는 것과 같은 방식으로, 안전한 접근을 제공할 때 쓰는 표준 예외입니다.

3. std::runtime_error

void connectDatabase(const std::string& host) {
    if (!tryConnect(host)) {
        throw std::runtime_error("Cannot connect to " + host);
    }
}

위 코드 설명: 실행 중 외부 요인(네트워크, 파일 존재 여부 등)으로 실패할 때 std::runtime_error를 사용합니다. 코드 논리 오류가 아니라 런타임 환경 때문에 발생하는 에러에 적합합니다.

4. std::logic_error

class Stack {
    std::vector<int> data;
public:
    int pop() {
        if (data.empty()) {
            throw std::logic_error("Cannot pop from empty stack");
        }
        int value = data.back();
        data.pop_back();
        return value;
    }
};

위 코드 설명: 빈 스택에서 pop을 호출하는 것은 “호출 조건을 위반한” 논리 오류이므로 std::logic_error를 씁니다. 사전 조건(스택이 비어 있지 않아야 함) 위반을 나타낼 때 사용하는 표준 예외입니다.

예외 메시지 접근

try {
    throw std::runtime_error("Something went wrong");
} catch (const std::exception& e) {
    std::cerr << "Error: " << e.what() << "\n";
    // 출력: Error: Something went wrong
}

위 코드 설명: 표준 예외 클래스는 std::exception을 상속하며, e.what()으로 생성 시 넘긴 메시지 문자열을 얻을 수 있습니다. 로깅이나 사용자에게 보여줄 메시지를 만들 때 사용합니다.

try-catch-throw와 표준 예외 통합 예제

// 복사해 붙여넣은 뒤: g++ -std=c++17 -o except_demo except_demo.cpp && ./except_demo
#include <iostream>
#include <stdexcept>
#include <string>
#include <vector>

void validatePort(int port) {
    if (port < 1 || port > 65535) {
        throw std::invalid_argument("Port must be 1-65535, got: " + std::to_string(port));
    }
}

int safeAt(const std::vector<int>& vec, size_t idx) {
    if (idx >= vec.size()) {
        throw std::out_of_range("Index " + std::to_string(idx) + " out of range [0, " + std::to_string(vec.size()) + ")");
    }
    return vec[idx];
}

int main() {
    try {
        validatePort(0);  // 예외 발생
    } catch (const std::invalid_argument& e) {
        std::cerr << "Invalid: " << e.what() << "\n";
    }

    std::vector<int> data = {10, 20, 30};
    try {
        std::cout << safeAt(data, 5) << "\n";  // 예외 발생
    } catch (const std::out_of_range& e) {
        std::cerr << "Range: " << e.what() << "\n";
    }

    try {
        validatePort(8080);
        std::cout << "Port OK\n";
        std::cout << safeAt(data, 1) << "\n";
    } catch (const std::exception& e) {
        std::cerr << "Error: " << e.what() << "\n";
    }
    return 0;
}

위 코드 설명: validatePortinvalid_argument, safeAtout_of_range를 던집니다. main에서는 구체적인 예외를 먼저 catch하고, 마지막에 std::exception으로 폴백합니다. 정상 경로는 try 안에만 두고, 에러 처리만 catch에 모을 수 있습니다.

실행 결과:

Invalid: Port must be 1-65535, got: 0
Range: Index 5 out of range [0, 3)
Port OK
20

4. RAII와 예외

예외 발생 시 리소스 누수 방지

예외가 던져지면 함수가 즉시 종료되고, 그 이후의 코드는 실행되지 않습니다. new로 할당한 메모리나 fopen으로 연 파일은 예외 경로에서 해제되지 않아 리소스 누수가 발생합니다. RAII(Resource Acquisition Is Initialization)는 생성 시점에 리소스를 획득하고, 소멸 시점에 자동으로 해제하는 패턴으로, 예외가 나도 소멸자가 호출되므로 안전합니다.

// ❌ 나쁜 예: 예외 시 메모리 누수
void processData(const std::string& path) {
    char* buffer = new char[4096];
    std::ifstream file(path);
    if (!file) {
        throw std::runtime_error("Cannot open file");
        // buffer가 해제되지 않음!
    }
    // ... 처리 ...
    delete[] buffer;
}

// ✅ 좋은 예: RAII로 예외 안전
void processData(const std::string& path) {
    auto buffer = std::make_unique<char[]>(4096);  // RAII
    std::ifstream file(path);  // RAII
    if (!file) {
        throw std::runtime_error("Cannot open file");
        // buffer, file 소멸자가 자동 호출됨
    }
    // ... 처리 ...
}

위 코드 설명: std::unique_ptrstd::ifstream은 소멸자에서 각각 delete[]와 파일 닫기를 수행합니다. 예외가 throw되면 스택 언와인딩 과정에서 스코프를 벗어나는 객체들의 소멸자가 자동 호출되므로, 예외 경로에서도 리소스가 누수되지 않습니다.

RAII + 예외 완전 예제

#include <fstream>
#include <memory>
#include <stdexcept>
#include <string>

class FileProcessor {
    std::unique_ptr<char[]> buffer_;
    std::ifstream file_;

public:
    FileProcessor(const std::string& path, size_t buf_size = 4096)
        : buffer_(std::make_unique<char[]>(buf_size)),
          file_(path) {
        if (!file_) {
            throw std::runtime_error("Cannot open: " + path);
        }
    }

    std::string readLine() {
        std::string line;
        if (!std::getline(file_, line)) {
            if (file_.eof()) return "";
            throw std::runtime_error("Read failed");
        }
        return line;
    }
    // 소멸자에서 buffer_, file_ 자동 정리
};

int main() {
    try {
        FileProcessor fp("config.txt");
        while (true) {
            auto line = fp.readLine();
            if (line.empty()) break;
            // 처리...
        }
    } catch (const std::exception& e) {
        std::cerr << "Error: " << e.what() << "\n";
        return 1;
    }
    return 0;
}

위 코드 설명: FileProcessorunique_ptrifstream을 멤버로 사용합니다. 생성자에서 예외가 나면 이미 생성된 멤버들의 소멸자만 호출되고, 정상 경로에서도 스코프를 벗어날 때 소멸자가 호출됩니다. RAII 덕분에 예외가 발생해도 리소스가 누수되지 않습니다.


5. 예외를 언제 사용할까

✅ 예외를 쓰는 경우

1. 생성자 실패

class File {
    std::ifstream stream;
public:
    File(const std::string& path) : stream(path) {
        if (!stream) {
            throw std::runtime_error("Cannot open: " + path);
        }
    }
};

// 생성자는 반환값이 없으므로 예외가 유일한 방법
try {
    File f("data.txt");
} catch (const std::exception& e) {
    std::cerr << e.what() << "\n";
}

위 코드 설명: 생성자는 반환값이 없어서 실패를 에러 코드로 알리기 어렵습니다. 따라서 파일 열기 실패처럼 생성 중 실패할 수 있는 경우 예외를 던지는 것이 일반적인 방법입니다. 호출 측에서는 try-catch로 처리합니다.

2. 복구 불가능한 에러

void loadCriticalConfig(const std::string& path) {
    std::ifstream file(path);
    if (!file) {
        // 설정 파일 없으면 프로그램 실행 불가
        throw std::runtime_error("Critical config missing: " + path);
    }
    // ...
}

위 코드 설명: 반드시 있어야 하는 설정 파일이 없으면 복구할 수 없으므로, 이런 경우 예외를 던져 호출 스택 위에서 처리하거나 프로그램을 종료하도록 하는 것이 적절합니다. 에러 코드로 돌려보내면 호출자가 무시할 수 있어 위험할 수 있습니다.

3. 깊은 호출 스택에서 에러 전파

void parseJson() {
    // 10단계 깊이
    if (error) throw std::runtime_error("Parse error");
}

void processRequest() {
    parseJson();  // 에러 코드 체크 불필요
}

void handleClient() {
    try {
        processRequest();
    } catch (const std::exception& e) {
        sendErrorResponse(e.what());
    }
}

위 코드 설명: parseJson에서 throw가 나면 processRequest는 검사하지 않고 그대로 위로 전달되고, handleClient의 catch에서 한 번에 잡아 sendErrorResponse로 클라이언트에 에러를 보냅니다. 깊은 호출 스택에서 중간 단계 없이 한 곳에서만 처리하는 패턴입니다.

4. 라이브러리 API

// 라이브러리 함수는 예외로 에러 알림
class JsonParser {
public:
    JsonValue parse(const std::string& text) {
        if (!isValid(text)) {
            throw std::invalid_argument("Invalid JSON");
        }
        // ...
    }
};

위 코드 설명: 라이브러리 API는 반환값만으로는 실패 이유를 전달하기 어렵기 때문에, 잘못된 입력이나 파싱 실패 시 예외를 던지는 방식이 많이 쓰입니다. 호출자가 try-catch로 처리하거나, 예외가 위로 전파되도록 둘 수 있습니다.


6. 예외를 피해야 하는 경우

❌ 예외를 피하는 경우

1. 정상적인 제어 흐름

// ❌ 나쁜 예: 예외를 제어 흐름으로 사용
bool hasNext() {
    try {
        getNext();
        return true;
    } catch (...) {
        return false;
    }
}

// ✅ 좋은 예: 명시적인 체크
bool hasNext() {
    return currentIndex < data.size();
}

위 코드 설명: “다음이 있는지”는 정상적인 제어 흐름이므로 예외로 표현하면 안 됩니다. 나쁜 예는 getNext()가 예외를 던지는지로 true/false를 구하는데, 예외는 예외적인 상황용이므로 이렇게 쓰면 비용도 크고 의도도 불명확해집니다. 좋은 예처럼 명시적으로 인덱스나 상태를 검사하는 것이 맞습니다.

2. 성능 크리티컬한 루프

// ❌ 나쁜 예: 루프에서 예외
for (int i = 0; i < 1000000; ++i) {
    try {
        process(data[i]);
    } catch (...) {
        // 예외 던지기/잡기는 느림
    }
}

// ✅ 좋은 예: 사전 체크
for (int i = 0; i < 1000000; ++i) {
    if (isValid(data[i])) {
        process(data[i]);
    }
}

위 코드 설명: 루프 안에서 예외를 던지고 잡는 것은 비용이 크므로, 성능이 중요한 경로에서는 피하는 것이 좋습니다. 나쁜 예는 매 반복마다 try-catch를 두는 패턴이고, 좋은 예는 사전에 isValid로 검사한 뒤 process만 호출하는 방식입니다. 예외는 “드물게 발생하는” 경우에만 사용하는 것이 원칙입니다.

3. 예상 가능한 에러

// ❌ 나쁜 예: 파일 없음은 예외가 아님
try {
    std::ifstream file("optional.txt");
    if (!file) throw std::runtime_error("No file");
} catch (...) {
    // 파일 없는 건 정상 상황일 수 있음
}

// ✅ 좋은 예: 반환값으로 처리
std::optional<std::string> readOptionalFile(const std::string& path) {
    std::ifstream file(path);
    if (!file) return std::nullopt;
    // ...
    return content;
}

위 코드 설명: “파일이 없을 수 있다”는 예상 가능한 상황이므로 예외보다 반환값으로 처리하는 편이 낫습니다. 나쁜 예는 파일 없음을 예외로 던지는 것이고, 좋은 예는 std::optional로 “있으면 값, 없으면 nullopt”를 반환해 호출자가 if나 value_or 등으로 처리하는 방식입니다.

4. 소멸자에서

class Resource {
public:
    ~Resource() {
        // ❌ 소멸자에서 예외 던지면 프로그램 종료
        // throw std::runtime_error("Cleanup failed");
        
        // ✅ 에러를 로그만 남기고 삼킴
        try {
            cleanup();
        } catch (const std::exception& e) {
            std::cerr << "Cleanup error: " << e.what() << "\n";
        }
    }
};

위 코드 설명: 소멸자에서 예외를 던지면, 이미 다른 예외가 전파 중일 때 std::terminate가 호출되어 프로그램이 종료될 수 있으므로 C++ 표준에서 위험한 동작으로 규정합니다. 따라서 소멸자 안에서는 예외를 밖으로 던지지 말고, 실패 시에는 로그만 남기거나 삼키는 방식으로 처리하는 것이 안전합니다.


7. 자주 발생하는 문제와 해결법

문제 1: catch 순서가 잘못되어 구체적인 예외를 못 잡음

증상: invalid_argument를 던졌는데 “Unknown error”만 출력됨.

원인: std::exception을 먼저 catch하면 invalid_argument 등 파생 타입도 모두 여기서 잡힘.

// ❌ 잘못된 순서
try {
    doSomething();
} catch (const std::exception& e) {
    std::cerr << "Unknown error\n";  // 모든 예외가 여기로
} catch (const std::invalid_argument& e) {
    std::cerr << "Invalid argument\n";  // 도달 불가
}

// ✅ 올바른 순서: 구체적 → 일반
try {
    doSomething();
} catch (const std::invalid_argument& e) {
    std::cerr << "Invalid: " << e.what() << "\n";
} catch (const std::out_of_range& e) {
    std::cerr << "Range: " << e.what() << "\n";
} catch (const std::exception& e) {
    std::cerr << "Error: " << e.what() << "\n";
}

문제 2: 예외를 catch한 뒤 재던지기(rethrow) 시 throw; 사용

증상: 예외를 로그만 남기고 위로 전달하려 했는데, 원본 예외 타입이 std::exception으로 바뀜.

원인: throw e;는 객체를 복사해서 던지므로, 파생 타입이 베이스 타입으로 잘림(slicing).

// ❌ 잘못된 재던지기: slicing 발생
try {
    riskyOperation();
} catch (const std::exception& e) {
    logError(e.what());
    throw e;  // std::exception으로 복사됨
}

// ✅ 올바른 재던지기: 원본 그대로 전파
try {
    riskyOperation();
} catch (const std::exception& e) {
    logError(e.what());
    throw;  // 원본 예외 그대로 전파
}

문제 3: 예외 메시지에 e.what()만 사용

증상: 디버깅 시 “어디서” 던졌는지 알 수 없음.

원인: std::exception::what()은 생성 시 넘긴 문자열만 반환합니다.

해결법: 로그에 파일명·라인·함수명을 함께 포함하거나, 사용자 정의 예외에 스택 정보를 담는 방식 사용.

// 개선: 로그에 컨텍스트 추가
try {
    loadConfig(path);
} catch (const std::exception& e) {
    std::cerr << "[" << __FILE__ << ":" << __LINE__ << "] " << e.what() << "\n";
}

문제 4: catch(…)에서 예외 정보를 알 수 없음

증상: catch(...)에서 e.what()을 쓰려고 하면 컴파일 에러.

원인: catch(...)는 예외 객체에 접근할 수 없습니다.

// ❌ 컴파일 에러
} catch (...) {
    std::cerr << e.what() << "\n";  // e가 없음
}

// ✅ 로그만 남기고 종료
} catch (...) {
    std::cerr << "Unknown exception\n";
    throw;  // 또는 재던지기
}

문제 5: 예외를 제어 흐름으로 사용

증상: 루프마다 예외가 발생해 성능이 크게 떨어짐.

원인: map::at()이나 vector::at()이 실패 시 예외를 던지는데, “키가 없음”이 자주 발생하는 정상 경로라면 예외로 처리하면 안 됨.

// ❌ 나쁜 예: 자주 발생하는 경우에 예외
for (const auto& key : keys) {
    try {
        int value = map.at(key);
        process(value);
    } catch (const std::out_of_range&) {
        // 키가 없을 수 있음 - 정상 상황
    }
}

// ✅ 좋은 예: find로 검사
for (const auto& key : keys) {
    auto it = map.find(key);
    if (it != map.end()) {
        process(it->second);
    }
}

8. 베스트 프랙티스

1. 예외는 참조로 catch

// ❌ 값으로 catch: 불필요한 복사, slicing 가능
} catch (std::exception e) { }

// ✅ const 참조로 catch
} catch (const std::exception& e) {
    std::cerr << e.what() << "\n";
}

2. 예외 메시지는 구체적으로

// ❌ 모호한 메시지
throw std::runtime_error("Error");

// ✅ 구체적인 메시지
throw std::runtime_error("Cannot connect to " + host + ":" + std::to_string(port));

3. 적절한 예외 타입 선택

상황예외 타입
잘못된 인자 (범위, 형식)std::invalid_argument
인덱스/범위 초과std::out_of_range
사전 조건 위반 (빈 스택 pop 등)std::logic_error
런타임 환경 문제 (파일, 네트워크)std::runtime_error
수학적 도메인 오류std::domain_error

4. 생성자에서 실패 시 예외만 사용

class Database {
public:
    explicit Database(const std::string& connection_string) {
        if (!connect(connection_string)) {
            throw std::runtime_error("Connection failed: " + connection_string);
        }
    }
};

5. 소멸자는 noexcept 유지

class Resource {
public:
    ~Resource() noexcept {
        try {
            cleanup();
        } catch (...) {
            std::cerr << "Cleanup failed\n";
        }
    }
};

9. 프로덕션 패턴

패턴 1: 최상위 catch에서 로깅 및 종료

int main() {
    try {
        return runApplication();
    } catch (const std::exception& e) {
        logError("Fatal", e.what());
        return 1;
    } catch (...) {
        logError("Fatal", "Unknown exception");
        return 1;
    }
}

패턴 2: 예외 경계에서 std::current_exception 보관

// 예외를 나중에 처리해야 할 때
std::exception_ptr stored;
try {
    riskyOperation();
} catch (...) {
    stored = std::current_exception();
}
// ... 다른 작업 ...
if (stored) {
    std::rethrow_exception(stored);
}

패턴 3: 예외 경계에서 RAII 보장

void processRequests() {
    std::vector<std::unique_ptr<Connection>> connections;
    for (const auto& req : requests) {
        if (auto conn = std::make_unique<Connection>(req.host)) {
            connections.push_back(std::move(conn));
        }
    }
    // 예외 발생 시 connections의 소멸자에서 정리
}

패턴 4: 재시도와 예외

template<typename Func>
auto retry(Func&& f, int max_attempts = 3) {
    for (int i = 0; i < max_attempts; ++i) {
        try {
            return f();
        } catch (const std::exception& e) {
            if (i == max_attempts - 1) throw;
            logError("Retry", e.what());
        }
    }
    throw std::runtime_error("Max retries exceeded");
}

패턴 5: 예외 안전한 swap

void swap(Resource& a, Resource& b) noexcept {
    using std::swap;
    swap(a.data_, b.data_);  // no-throw swap
}

같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

  • C++ 예외 안전성 | “예외 발생 시 리소스 누수” Basic·Strong·Nothrow 보장
  • C++ 커스텀 예외 클래스 만들기 | 예외 성능과 Zero-Cost Exception
  • C++ RAII | “파일을 열 수 없습니다” 장애의 원인과 자동 리소스 관리

이 글에서 다루는 키워드 (관련 검색어)

C++ 예외 처리, try catch throw, 예외 기본, exception, 예외 전파, noexcept 등으로 검색하시면 이 글이 도움이 됩니다.

정리

항목내용
기본 문법try { throw X; } catch (const X& e) { }
표준 예외std::exception 계층, 구체적 타입 우선 catch
RAII예외 경로에서도 소멸자 호출 → 리소스 누수 방지
사용 시점생성자, 복구 불가 에러, 깊은 전파
피할 시점정상 흐름, 성능 루프, 소멸자
재던지기throw; (복사 없음), throw e;는 slicing
장점에러/로직 분리, 깊은 전파, 메시지 포함
단점예외 던지면 느림 (던지지 않으면 zero-cost)

자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. C++ 예외 처리(exception handling) 완벽 가이드. try-catch-throw 문법, std::exception·runtime_error·logic_error 표준 예외 클래스, 예외 vs 에러 코… 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.

한 줄 요약: try-catch로 예외를 잡고, 에러 코드 대신 예외로 일관되게 처리할 수 있습니다. 다음으로 예외 안전성(#8-2)를 읽어보면 좋습니다.

다음 글: C++ 실전 가이드 #8-2: 예외 안전성 - RAII와 예외 결합을 다룹니다.


관련 글

  • C++ 예외 안전성 |
  • C++ 커스텀 예외 클래스 만들기 | 예외 성능과 Zero-Cost Exception
  • C++ std::thread 입문 | join 누락·디태치 남용 등 자주 하는 실수 3가지와 해결법
  • C++ mutex로 race condition 해결하기 | 주문 카운터 버그부터 lock_guard까지
  • C++ 고급 멀티스레딩 | 스레드 풀·Work Stealing