C++ 제약된 환경에서의 C++: Exception과 RTTI 없이 안전한 코드 짜기

들어가며: “예외는 켤 수 없어요”

ROM·RAM이 제한된 환경

C++이 가장 강하게 쓰이는 저수준 제어 분야(임베디드, 드라이버, 일부 게임 콘솔)에서는 예외(Exception)와 RTTI를 비활성화(-fno-exceptions, -fno-rtti)하는 경우가 많습니다. 예외는 코드 크기·런타임 오버헤드가 부담되고, RTTI는 타입 정보 테이블이 ROM을 차지하기 때문입니다.

이 모드에서도 안전하고 유지보수 가능한 코드를 쓰려면 오류를 반환값·에러 코드·std::expected 등으로 전달해야 합니다. 가상 함수는 RTTI 없이도 그대로 동작하므로 다형성 자체를 포기할 필요는 없고, 쓸 수 없게 되는 것은 dynamic_cast와 typeid뿐입니다. 이 둘이 하던 “구체 타입 확인”은 enum 태그나 std::variant로 대신합니다.

예외와 RTTI를 끄게 되는 상황

가장 흔한 이유는 코드 크기입니다. 예외를 켜면 함수마다 스택 언와인딩 테이블이 생기고, 예외 객체 할당·타입 매칭을 위한 런타임 지원 코드와 typeinfo가 링크됩니다. 수십 KB 단위 플래시를 쓰는 MCU에서는 이 차이로 이미지가 플래시에 들어가느냐가 갈리기도 합니다. 정확한 차이는 툴체인과 코드에 따라 다르므로, arm-none-eabi-size나 링커 맵 파일로 직접 비교해 보는 것이 맞습니다.

두 번째 이유는 실행 시간 예측 가능성입니다. 예외가 던져지면 언와인딩 테이블을 탐색하며 스택을 거슬러 올라가는데, 이 비용은 호출 깊이와 테이블 크기에 따라 달라 최악 실행 시간(WCET)을 분석하기 어렵습니다. 1ms 주기 제어 루프처럼 데드라인이 있는 코드에서는 모든 실패 경로가 일반적인 분기로 처리되는 편이 분석하기 쉽습니다.

예외를 끄면 곧바로 생기는 설계 문제도 있습니다. I2C 센서 드라이버의 생성자에서 버스 초기화가 실패하면, 생성자는 반환값이 없고 예외도 쓸 수 없으니 실패를 알릴 방법이 사라집니다. RTTI를 끄면 USB 호스트 스택에서 Device*로 관리하던 키보드·마우스·저장장치를 dynamic_cast로 구분할 수 없습니다. 이 글은 이런 문제를 반환값 기반 오류 처리, 팩토리 함수, 태그·variant 분기로 푸는 방법을 다룹니다.


예외 비활성화 (-fno-exceptions)

오류를 반환값으로 전달

-fno-exceptions를 쓰면 throw·try/catch를 사용할 수 없습니다. 대신 bool 반환 + out 파라미터, std::optional, std::expected(C++23) 등으로 “성공/실패 + 값”을 전달합니다.

flowchart TD
    subgraph with_exceptions[예외 사용 시]
        A1[함수 호출] --> A2{실패?}
        A2 -->|예| A3[throw]
        A3 --> A4[스택 언와인딩]
        A4 --> A5[catch]
        A2 -->|아니오| A6[값 반환]
    end
    subgraph no_exceptions[-fno-exceptions 시]
        B1[함수 호출] --> B2{실패?}
        B2 -->|예| B3[optional/expected 반환]
        B3 --> B4[호출부에서 if 검사]
        B2 -->|아니오| B5[값 반환]
    end

optional로 “값 있음/없음” 표현

-fno-exceptions 환경에서는 throw를 쓰지 않으므로, 실패 시 std::optional로 “값이 있으면 성공, 없으면 실패”를 표현합니다. loadConfig는 path가 null이거나 parse가 실패하면 std::nullopt를 반환하고, 성공하면 Config를 담은 optional을 반환합니다.

#include <optional>
struct Config {
    int baud_rate;
    int timeout_ms;
};
// 예외 없이: 실패 시 nullopt, 성공 시 Config
std::optional<Config> loadConfig(const char* path) {
    if (!path) return std::nullopt;
    Config c;
    if (!parse(path, c)) return std::nullopt;
    return c;
}
// 호출부
void useConfig() {
    if (auto opt = loadConfig("/etc/device.conf")) {
        applyConfig(*opt);
    } else {
        useDefaultConfig();
    }
}

expected로 “값 또는 에러 정보” 표현 (C++23)

std::optional은 “값이 없음”만 표현합니다. 왜 실패했는지 에러 코드나 메시지를 함께 전달하려면 std::expected를 사용합니다.

#include <expected>
#include <cstdint>
enum class SensorError : uint8_t {
    BusError,
    NoResponse,
    InvalidId,
};
struct SensorReading {
    float temperature;
    float humidity;
};
// 성공 시 SensorReading, 실패 시 SensorError
std::expected<SensorReading, SensorError> readSensor(uint8_t id) {
    if (id >= 8) return std::unexpected(SensorError::InvalidId);
    if (!i2c_probe(id)) return std::unexpected(SensorError::NoResponse);
    SensorReading r;
    if (!i2c_read(id, &r, sizeof(r))) return std::unexpected(SensorError::BusError);
    return r;
}
void pollSensors() {
    auto result = readSensor(0);
    if (result) {
        float temp = result->temperature;
        // ...
    } else {
        switch (result.error()) {   // 성공 여부는 expected 자체가 표현하므로 Ok 값은 두지 않음
            case SensorError::NoResponse: log("센서 응답 없음"); break;
            case SensorError::BusError:   log("I2C 버스 오류"); break;
            default: break;
        }
    }
}

예외를 끈 빌드에서도 expected의 value()는 쓰지 않는 것이 좋습니다. 값이 없을 때 value()는 bad_expected_access를 던지도록 되어 있어, 예외가 꺼진 libstdc++에서는 abort()로 종료됩니다. if (result)로 확인한 뒤 *result나 ->로 접근합니다.

생성자 실패: 팩토리 함수와 두 단계 초기화

생성자에서는 반환값이 없으므로 팩토리 함수로 optional<T> 또는 expected<T, E>를 반환하게 하며, 생성자 본문에서는 단순 대입만 하거나, 두 단계 초기화(생성 후 init() 호출)를 씁니다.

#include <optional>
#include <cstdint>
class UartDriver {
    volatile uint32_t* reg_base_;
    bool initialized_ = false;
public:
    // 생성자: 멤버만 초기화, 실패 처리 없음
    UartDriver() : reg_base_(nullptr), initialized_(false) {}
    // 두 단계 초기화: init()에서 실패 반환
    bool init(uint32_t base_addr) {
        if (initialized_) return true;
        reg_base_ = reinterpret_cast<volatile uint32_t*>(base_addr);
        if (!reg_base_) return false;
        // 하드웨어 초기화...
        initialized_ = true;
        return true;
    }
    void send(uint8_t byte) {
        if (!initialized_) return;
        // 레지스터 쓰기...
    }
};
// 팩토리 함수: optional 반환
std::optional<UartDriver> createUart(uint32_t base_addr) {
    UartDriver uart;
    if (!uart.init(base_addr)) return std::nullopt;
    return uart;
}
void main_loop() {
    auto uart_opt = createUart(0x40001000);
    if (!uart_opt) {
        // 초기화 실패 처리
        return;
    }
    UartDriver& uart = *uart_opt;
    uart.send(0x55);
}

bool + out 파라미터 (레거시 호환)

C 인터페이스나 레거시 코드와 연동할 때는 bool 반환 + out 파라미터 패턴이 여전히 유효합니다.

// 성공 시 true + out에 값, 실패 시 false
bool parseBaudRate(const char* str, int* out_baud) {
    if (!str || !out_baud) return false;
    int val = 0;
    while (*str >= '0' && *str <= '9') {
        val = val * 10 + (*str - '0');
        ++str;
    }
    if (*str != '\0' || val <= 0) return false;
    *out_baud = val;
    return true;
}

RTTI 비활성화 (-fno-rtti)

타입 분기 대체

-fno-rtti에서는 dynamic_cast, typeid를 쓸 수 없습니다. 다형성이 필요하면 가상 함수로 “할 수 있는 일”을 인터페이스에 두며, “구체 타입”이 필요할 때는 enum 태그 + variant 조합을 씁니다.

flowchart LR
    subgraph rtti[RTTI 사용 시]
        R1[dynamic_cast] --> R2[typeinfo 테이블]
        R2 --> R3[ROM 사용]
    end
    subgraph no_rtti[-fno-rtti 시]
        N1[variant + visit] --> N2[컴파일 타임 분기]
        N3[enum kind] --> N4[switch]
        N2 --> N5[ROM 절약]
        N4 --> N5
    end

std::variant + std::visit로 타입 안전 분기

std::variant + std::visit로 “이 타입들 중 하나”를 타입 안전하게 담으며, visit에서 타입별 처리하면 RTTI 없이 같은 효과를 낼 수 있습니다.

#include <variant>
#include <cstdint>
struct LedState {
    uint8_t brightness;
};
struct ButtonState {
    bool pressed;
};
struct SensorState {
    float value;
};
// 디바이스 상태: 이 중 정확히 하나
using DeviceState = std::variant<LedState, ButtonState, SensorState>;
// overloaded 헬퍼: 여러 람다를 하나의 방문자로
template<class... Ts>
struct overloaded : Ts... { using Ts::operator()...; };
template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;
void handleState(const DeviceState& state) {
    std::visit(overloaded{
        [](const LedState& s) {
            setLedBrightness(s.brightness);
        },
        [](const ButtonState& s) {
            if (s.pressed) onButtonPress();
        },
        [](const SensorState& s) {
            processSensorValue(s.value);
        }
    }, state);
}

enum kind()로 파생 타입 식별

가상 함수는 RTTI 없이도 동작합니다. dynamic_cast만 못 쓰므로, “파생 타입인지 확인”이 필요하면 enum kind() 같은 멤버를 두거나 variant로 타입 집합을 명시적으로 관리합니다.

enum class DeviceKind { Led, Button, Sensor };
struct IDevice {
    virtual ~IDevice() = default;
    virtual DeviceKind kind() const = 0;
    virtual void update() = 0;
};
struct LedDevice : IDevice {
    DeviceKind kind() const override { return DeviceKind::Led; }
    void update() override { /* LED 제어 */ }
    void setBrightness(uint8_t level) { brightness_ = level; }
private:
    uint8_t brightness_ = 0;
};
struct ButtonDevice : IDevice {
    DeviceKind kind() const override { return DeviceKind::Button; }
    void update() override { /* 버튼 폴링 */ }
};
void processDevice(IDevice* dev) {
    switch (dev->kind()) {
        case DeviceKind::Led:
            static_cast<LedDevice*>(dev)->setBrightness(128);  // kind()가 보장하므로 안전
            break;
        case DeviceKind::Button:
            // ...
            break;
        case DeviceKind::Sensor:
            // ...
            break;
    }
}

static_cast로의 다운캐스트는 런타임 검사가 없으므로, kind()가 실제 타입과 어긋나면 미정의 동작입니다. 파생 클래스마다 kind()를 빠짐없이 구현했는지가 안전성의 전부이므로, 새 타입을 추가할 때 switch에 default를 두지 않고 컴파일러의 -Wswitch 경고로 누락을 잡는 습관이 도움이 됩니다.


STL과 할당 전략

예외를 쓰는 연산 피하기

vector·string 등은 할당 실패 시 std::bad_alloc을 던지도록 되어 있습니다. -fno-exceptions는 내 코드에서 throw·try를 금지할 뿐, 미리 빌드된 표준 라이브러리 안의 던지는 코드까지 없애지는 않으므로, 할당 실패는 결국 std::terminate나 abort()로 이어집니다. 그래서 할당이 실패할 수 있는 지점을 설계 단계에서 줄이는 것이 핵심입니다. 시작 시점에 reserve로 필요한 용량을 한 번에 확보하고, 크기가 정해진 데이터는 std::array나 정적 버퍼에 둡니다. 많은 펌웨어는 아예 동적 할당을 초기화 단계에서만 허용하거나 전면 금지합니다.

#include <array>
#include <cstdint>
// 고정 크기 버퍼: 할당 없음, 예외 경로 없음
constexpr size_t MAX_PACKET = 256;
using PacketBuffer = std::array<uint8_t, MAX_PACKET>;
void processPacket() {
    PacketBuffer buf{};  // 스택 할당만
    size_t len = uartReceive(buf.data(), buf.size());
    parsePacket(buf.data(), len);
}
// vector 사용 시: reserve로 재할당 최소화
#include <vector>
void collectSamples(std::vector<int>& out, size_t expected_count) {
    out.clear();
    out.reserve(expected_count);  // 한 번에 확보
    for (size_t i = 0; i < expected_count; ++i) {
        out.push_back(readAdc());  // 재할당 없음
    }
}

커스텀 allocator 개요

표준 컨테이너용 allocator의 allocate는 실패 시 예외를 던져야 하며, nullptr를 반환하면 컨테이너가 그 포인터를 그대로 써서 미정의 동작이 됩니다. 그래서 “실패하면 nullptr” 정책은 표준 컨테이너가 아니라 직접 만든 자료구조에서만 쓸 수 있습니다. 아래는 그런 용도의 가장 단순한 고정 풀로, 블록 하나만 관리하는 개념 예시입니다.

#include <cstddef>
#include <cstdint>
// 단순 풀 allocator: 고정 크기 블록, 실패 시 nullptr
template<size_t PoolSize>
class PoolAllocator {
    alignas(std::max_align_t) uint8_t pool_[PoolSize];
    bool used_ = false;
public:
    void* allocate(size_t n) {
        if (used_ || n > PoolSize) return nullptr;
        used_ = true;
        return pool_;
    }
    void deallocate(void*, size_t) {
        used_ = false;
    }
};

I2C 센서 드라이버, 이벤트 디스패처, 고정 버퍼 로그 예제

예제 1: I2C 센서 드라이버 (optional + 두 단계 초기화)

// g++ -std=c++17 -fno-exceptions -fno-rtti -O2 -o sensor_driver sensor_driver.cpp
#include <optional>
#include <cstdint>
#include <cstring>
// 하드웨어 추상화 (실제로는 HAL 호출)
extern "C" bool i2c_init(uint8_t bus);
extern "C" bool i2c_write(uint8_t bus, uint8_t addr, const void* data, size_t len);
extern "C" bool i2c_read(uint8_t bus, uint8_t addr, void* data, size_t len);
enum class SensorError : uint8_t {
    Ok, BusNotReady, NoAck, InvalidParam
};
struct SensorConfig {
    uint8_t bus;
    uint8_t addr;
    uint16_t sample_interval_ms;
};
class TemperatureSensor {
    uint8_t bus_;
    uint8_t addr_;
    bool initialized_ = false;
public:
    TemperatureSensor() : bus_(0), addr_(0) {}
    bool init(const SensorConfig& cfg) {
        if (initialized_) return true;
        if (!i2c_init(cfg.bus)) return false;
        bus_ = cfg.bus;
        addr_ = cfg.addr;
        initialized_ = true;
        return true;
    }
    std::optional<float> read() {
        if (!initialized_) return std::nullopt;
        uint8_t raw[2];
        if (!i2c_read(bus_, addr_, raw, 2)) return std::nullopt;
        int16_t val = (raw[0] << 8) | raw[1];
        return val / 256.0f;
    }
};
std::optional<TemperatureSensor> createSensor(const SensorConfig& cfg) {
    TemperatureSensor s;
    if (!s.init(cfg)) return std::nullopt;
    return s;
}
int main() {
    SensorConfig cfg{0, 0x48, 100};
    auto sensor_opt = createSensor(cfg);
    if (!sensor_opt) return -1;
    auto temp = sensor_opt->read();
    if (temp) {
        // *temp 사용
    }
    return 0;
}

예제 2: 이벤트 디스패처 (variant + visit)

// g++ -std=c++17 -fno-exceptions -fno-rtti -O2 -o event_dispatch event_dispatch.cpp
#include <variant>
#include <cstdint>
struct TimerEvent { uint32_t id; };
struct GpioEvent { uint8_t pin; bool level; };
struct UartEvent { uint8_t data; };
using SystemEvent = std::variant<TimerEvent, GpioEvent, UartEvent>;
template<class... Ts>
struct overloaded : Ts... { using Ts::operator()...; };
template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;
void dispatchEvent(const SystemEvent& e) {
    std::visit(overloaded{
        [](const TimerEvent& t) {
            // 타이머 t.id 처리
        },
        [](const GpioEvent& g) {
            if (g.level) onGpioRising(g.pin);
        },
        [](const UartEvent& u) {
            uartRxHandler(u.data);
        }
    }, e);
}

예제 3: 고정 버퍼 기반 로그 (할당 없음)

#include <array>
#include <cstdarg>
#include <cstdint>
#include <cstdio>
constexpr size_t LOG_BUF_SIZE = 128;
class EmbeddedLogger {
    std::array<char, LOG_BUF_SIZE> buf_;
    size_t pos_ = 0;
public:
    void log(const char* fmt, ...) {
        const size_t room = buf_.size() - pos_;   // 남은 공간 ('\0' 자리 포함)
        if (room <= 1) return;                    // 가득 참
        va_list args;
        va_start(args, fmt);
        int n = std::vsnprintf(buf_.data() + pos_, room, fmt, args);
        va_end(args);
        if (n < 0) return;                        // 포맷 오류
        // vsnprintf는 잘렸을 때 "쓰려던 길이"를 반환하므로 실제로 쓴 만큼만 전진
        pos_ += (static_cast<size_t>(n) < room) ? static_cast<size_t>(n) : room - 1;
    }
    void flush() {
        buf_[pos_] = '\0';
        uartSend(buf_.data());
        pos_ = 0;
    }
};

임베디드용 libc의 vsnprintf는 부동소수점 지원이 기본으로 빠져 있는 경우가 많습니다(newlib-nano에서 %f를 쓰려면 -u _printf_float 링크 옵션이 필요). 또 printf 계열 자체가 코드 크기를 꽤 차지하므로, 아주 작은 MCU에서는 정수·문자열만 처리하는 경량 포매터를 따로 두기도 합니다.

예제 4: 프로토콜 파서 (expected + 고정 버퍼)

// Modbus RTU 스타일 단순 파서 — 할당 없음, 예외 없음
#include <expected>
#include <array>
#include <cstdint>
enum class ParseError { TooShort, TooLong, BadCrc, InvalidFunction };
struct ModbusFrame {
    uint8_t addr;
    uint8_t func;
    std::array<uint8_t, 252> data;
    uint8_t data_len;
};
std::expected<ModbusFrame, ParseError> parseModbus(
    const uint8_t* buf, size_t len)
{
    if (len < 4) return std::unexpected(ParseError::TooShort);    // 주소+기능코드+CRC 2바이트
    if (len > 256) return std::unexpected(ParseError::TooLong);   // RTU 프레임 최대 256바이트
    ModbusFrame f;
    f.addr = buf[0];
    f.func = buf[1];
    f.data_len = static_cast<uint8_t>(len - 4);
    for (size_t i = 0; i < f.data_len; ++i)
        f.data[i] = buf[2 + i];
    // 마지막 2바이트(CRC-16/MODBUS, 하위 바이트 먼저)를 계산값과 비교 (생략)
    return f;
}
void processModbus(const uint8_t* buf, size_t len) {
    auto result = parseModbus(buf, len);
    if (!result) {
        if (result.error() == ParseError::BadCrc) logError("CRC");
        return;
    }
    ModbusFrame& f = *result;
    handleRequest(f.addr, f.func, f.data.data(), f.data_len);
}

예제 5: 디바이스 매니저 (variant + 태그 조합)

// 여러 디바이스 타입을 하나의 컨테이너로 관리
#include <variant>
#include <array>
#include <cstdint>
template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;
struct Led { uint8_t pin; bool on; };
struct Relay { uint8_t channel; bool state; };
struct Pwm { uint8_t pin; uint8_t duty; };
using Device = std::variant<Led, Relay, Pwm>;
constexpr size_t MAX_DEVICES = 8;
class DeviceManager {
    std::array<Device, MAX_DEVICES> devices_;
    size_t count_ = 0;
public:
    bool addLed(uint8_t pin) {
        if (count_ >= MAX_DEVICES) return false;
        devices_[count_++] = Led{pin, false};
        return true;
    }
    bool addRelay(uint8_t ch) {
        if (count_ >= MAX_DEVICES) return false;
        devices_[count_++] = Relay{ch, false};
        return true;
    }
    void setAll(bool on) {
        for (size_t i = 0; i < count_; ++i) {
            std::visit(overloaded{
                [on](Led& l)   { l.on = on; setGpio(l.pin, on); },
                [on](Relay& r) { r.state = on; setRelay(r.channel, on); },
                [](Pwm&)       { /* PWM은 무시 */ }
            }, devices_[i]);
        }
    }
};

빈 optional의 value(), 예외를 던지는 STL, -fno-rtti의 dynamic_cast

문제 1: optional에 값 없는데 value() 호출

비어 있는 optional에 value()를 호출하면 std::bad_optional_access가 던져지고, 예외가 꺼진 빌드에서는 그 자리에서 abort()로 종료됩니다. *opt로 접근하면 검사 자체가 없어 미정의 동작입니다.

std::optional<int> opt = getValue();
// 잘못된 코드
int a = opt.value();      // 비어 있으면 abort (예외 꺼짐)
int b = *opt;             // 비어 있으면 미정의 동작
// 해결: 확인한 뒤 접근하거나 기본값 지정
if (opt) {
    int x = *opt;
}
int y = opt.value_or(0);

문제 2: -fno-exceptions인데 STL이 예외를 던짐

표준 라이브러리는 할당 실패를 std::bad_alloc으로 알리므로, 예외를 끈 빌드에서 메모리가 부족해지면 프로그램은 terminate로 끝납니다. 실패 지점을 줄이는 방법은 할당을 시작 시점으로 몰아 두는 것입니다.

// 위험: reserve 없이 반복 push_back
std::vector<int> vec;
for (int i = 0; i < 10000; ++i) {
    vec.push_back(i);  // 재할당 시 terminate 가능
}
// 해결: reserve로 재할당 방지
std::vector<int> vec;
vec.reserve(10000);
for (int i = 0; i < 10000; ++i) {
    vec.push_back(i);
}
// 또는 고정 크기 정적 버퍼 사용 (스택이 작은 MCU에서는 큰 배열을 지역 변수로 두지 않음)
static std::array<int, 10000> arr;

reserve 자체도 할당이므로 실패할 수 있습니다. 차이는 실패가 일어날 수 있는 시점을 시스템 시작 단계 한 곳으로 모은다는 데 있습니다. 시작 시점에 실패하면 안전하게 재부팅하거나 오류를 표시하기가 쉽고, 동작 중 아무 때나 실패하는 것보다 훨씬 다루기 쉽습니다.

문제 3: dynamic_cast를 -fno-rtti에서 사용

-fno-rtti로 빌드하면 다형 타입에 대한 dynamic_cast와 typeid는 컴파일 에러가 납니다(GCC: “‘dynamic_cast’ not permitted with ‘-fno-rtti’”). 업캐스트나 static_cast는 RTTI가 필요 없으므로 그대로 쓸 수 있습니다.

// 잘못된 코드
IDevice* dev = getDevice();
LedDevice* led = dynamic_cast<LedDevice*>(dev);  // -fno-rtti에서 에러
// 해결 1: kind() enum 사용
if (dev->kind() == DeviceKind::Led) {
    LedDevice* led = static_cast<LedDevice*>(dev);
}
// 해결 2: variant 사용
std::variant<LedDevice, ButtonDevice> dev = getDeviceVariant();
std::visit(overloaded{
    [](LedDevice&) { /* ... */ },
    [](ButtonDevice&) { /* ... */ }
}, dev);

문제 4: 생성자에서 실패 처리 시도

생성자 안에서 실패할 수 있는 하드웨어 초기화를 하면, 실패를 호출자에게 알릴 방법이 없어 반쯤 초기화된 객체가 만들어집니다. 실패할 수 있는 일은 생성자 밖으로 빼야 합니다.

// 잘못된 코드
class Sensor {
public:
    Sensor(uint8_t id) {
        if (!initHardware(id)) {
            // ??? 반환할 방법 없음
        }
    }
};
// 해결: 팩토리 함수 또는 두 단계 초기화 (Sensor에 기본 생성자와 bool init(uint8_t)를 둔다고 가정)
std::optional<Sensor> createSensor(uint8_t id) {
    Sensor s;
    if (!s.init(id)) return std::nullopt;
    return s;
}

문제 5: expected/optional 반환값 무시

반환값 기반 오류 처리의 약점은 호출자가 결과를 무시해도 컴파일된다는 것입니다. 함수에 [[nodiscard]]를 붙이면 반환값을 버렸을 때 경고가 나고, -Werror와 함께 쓰면 빌드 단계에서 누락을 잡을 수 있습니다. C++17부터는 enum class 같은 타입 선언 자체에 [[nodiscard]]를 붙여, 그 타입을 반환하는 모든 함수에 적용할 수도 있습니다.

// 위험
readConfig(path);  // 반환값 무시
// 해결: [[nodiscard]]로 강제
[[nodiscard]] std::optional<Config> readConfig(const char* path);
// 호출부에서 반드시 검사
if (auto cfg = readConfig(path)) {
    use(*cfg);
}

ROM/RAM 절약과 실행 시간 예측 가능성

두 옵션이 줄여 주는 크기는 코드 구조와 툴체인에 따라 크게 다르므로, 고정된 숫자를 기대하기보다 같은 코드를 옵션만 바꿔 빌드한 뒤 size 명령이나 링커 맵으로 비교하는 것이 정확합니다. 실행 시간 측면에서는 예외 경로가 사라지면서 모든 실패 처리가 일반 분기가 되어 WCET 분석이 쉬워지고, std::array나 고정 풀로 힙 할당을 없애면 할당 시간의 편차도 사라집니다.

빌드 플래그 예시

# ARM GCC 임베디드 타겟
arm-none-eabi-g++ -std=c++17 \
  -fno-exceptions -fno-rtti \
  -ffunction-sections -fdata-sections \
  -Wl,--gc-sections \
  -Os \
  -o firmware.elf main.cpp

-ffunction-sections -fdata-sections는 함수와 데이터를 각각 별도 섹션에 넣어, 링커의 --gc-sections가 쓰이지 않는 것을 통째로 버릴 수 있게 합니다. -Os는 속도보다 크기를 우선하는 최적화입니다. 실제 펌웨어 빌드에서는 여기에 -mcpu=cortex-m4 -mthumb 같은 대상 CPU 옵션과 링커 스크립트, --specs=nano.specs(newlib-nano) 같은 옵션이 더해집니다.

에러 처리 방식별 비교

방식ROM런타임 비용에러 정보타입 안전
예외큼 (언와인딩 테이블·런타임)실패 시 높고 변동이 큼OO
std::optional작음if 검사만X (없음만)O
std::expected작음if 검사만OO
bool + out작음최소제한적X
에러 코드 (enum)최소최소O△

Result 타입, 에러 코드 체인, variant 상태 머신, 스택 전용 컨테이너

패턴 1: Result 타입 (expected 호환)

std::expected는 C++23에서 추가되었습니다. 그 이전 표준에서는 std::expected와 거의 같은 API를 제공하는 tl::expected 같은 헤더 전용 라이브러리를 쓰거나, std::variant<T, E>로 간단히 흉내 냅니다.

#include <variant>
template<typename T, typename E>
using Result = std::variant<T, E>;   // T와 E가 같은 타입이면 쓸 수 없음
template<typename T, typename E>
bool isOk(const Result<T, E>& r) {
    return std::holds_alternative<T>(r);
}
template<typename T, typename E>
T* valueIf(Result<T, E>& r) {
    return std::get_if<T>(&r);       // 실패면 nullptr, 예외를 던지는 std::get 대신
}

std::get은 대안이 맞지 않으면 std::bad_variant_access를 던지므로, 예외가 꺼진 빌드에서는 std::get_if로 포인터를 받아 확인하는 편이 안전합니다. std::visit도 variant가 예외로 비어 버린 상태(valueless)일 때 예외를 던지게 되어 있지만, 예외가 꺼진 환경에서는 그 상태가 생길 일이 거의 없습니다.

패턴 2: 에러 코드 체인

여러 단계에서 실패할 수 있는 초기화를 체인으로 처리합니다.

bool initSystem() {
    if (!initClock()) return false;
    if (!initGpio()) return false;
    if (!initUart()) return false;
    if (!initSensors()) return false;
    return true;
}

패턴 3: 상태 머신 (variant)

유한 상태를 variant로 표현하면 타입 안전하고 exhaustive 처리됩니다.

struct Idle {};
struct Running { uint32_t start_tick; };
struct Error { uint8_t code; };
using State = std::variant<Idle, Running, Error>;
State transition(const State& s, Event e) {
    return std::visit(overloaded{
        [e](const Idle&) -> State {
            if (e == Event::Start) return Running{getTick()};
            return Idle{};
        },
        [e](const Running& r) -> State {
            if (e == Event::Stop) return Idle{};
            if (e == Event::Fault) return Error{1};
            return Running{r.start_tick};
        },
        [](const Error&) -> State { return Idle{}; }
    }, s);
}

패턴 4: 스택 전용 컨테이너

힙 할당을 피하기 위해 고정 용량 스택 컨테이너를 사용합니다.

template<typename T, size_t N>
class StaticVector {
    std::array<T, N> data_;
    size_t size_ = 0;
public:
    bool push_back(const T& v) {
        if (size_ >= N) return false;
        data_[size_++] = v;
        return true;
    }
    size_t size() const { return size_; }
    T& operator[](size_t i) { return data_[i]; }
};

std::array<T, N>을 저장소로 쓰므로 T는 기본 생성 가능해야 하고, 처음부터 원소 N개가 모두 생성됩니다. 생성 비용이 큰 타입이라면 alignas(T) std::byte storage_[sizeof(T) * N]에 placement new로 필요한 만큼만 생성하는 방식이 필요하며, ETL(Embedded Template Library)의 etl::vector가 이런 고정 용량 컨테이너를 제공합니다. C++26에는 표준 std::inplace_vector도 추가되었습니다.


같이 보면 좋은 글