C++ enum class: 밑바닥 타입, 비트 플래그, 문자열 변환 패턴

이 글의 핵심

enum class와 밑바닥 타입의 관계, 스코프·암시적 변환 규칙, 로그·직렬화용 문자열 변환 패턴, 비트마스크 플래그, 저장 포맷과 ABI를 고려한 설계까지 정리합니다.

enum class란?

enum class(scoped enum)는 C++11에서 도입된 스코프 있는 열거형입니다. 기존 enum의 두 가지 문제, 즉 정수로의 암시적 변환과 열거자 이름이 바깥 스코프로 새는 문제를 막습니다.

// 비스코프 enum (C++03부터)
enum Color {
    RED,
    GREEN,
    BLUE
};

Color c = RED;  // OK
int x = RED;    // OK (암시적 변환)

// enum class (C++11)
enum class Status {
    Success,
    Error,
    Pending
};

Status s = Status::Success;                   // 스코프 지정 필수
// int y = Status::Success;                   // 에러: 암시적 변환 불가
int y = static_cast<int>(Status::Success);    // 명시적 변환

비스코프 enum의 암시적 변환이 문제를 일으키는 방식은 대개 “의도와 다른데 컴파일이 된다”는 것입니다. enum Color { RED }와 enum Fruit { APPLE }이 있을 때 if (color == APPLE)은 두 값이 모두 정수로 변환되어 비교되므로 경고만 나거나(C++20부터 서로 다른 열거형 사이의 이런 연산은 deprecated) 아무 말 없이 컴파일되고, setVolume(RED)처럼 정수를 받는 함수에 열거자를 넘겨도 통과합니다. enum class는 이런 코드를 컴파일 에러로 바꿔 줍니다.

또 비스코프 열거자는 둘러싼 스코프에 이름을 풀어 놓기 때문에, 같은 스코프의 두 열거형이 같은 이름의 열거자를 가질 수 없고, 헤더에 enum { OK, ERROR }를 선언하면 그 헤더를 include하는 모든 파일에서 OK라는 이름을 다른 용도로 쓸 수 없게 됩니다.

enum Color { RED, GREEN, BLUE };
// enum Light { RED, GREEN };        // 에러: RED 재정의

enum class Color2 { RED, GREEN, BLUE };
enum class Light2 { RED, GREEN };     // OK: Color2::RED와 Light2::RED는 별개
특징enum (비스코프)enum class (스코프)
열거자 이름바깥 스코프에 노출Enum::Enumerator로만 접근
정수로의 암시적 변환됨안 됨, static_cast 필요
다른 열거형과 비교컴파일됨(경고 가능)컴파일 에러
밑바닥 타입 미지정 시값 범위를 담는 구현 정의 정수 타입int

Scott Meyers의 Effective Modern C++ Item 10이 권하듯 새 코드에서는 enum class를 기본으로 쓰고, 레거시 C API와 맞닿는 곳에서만 비스코프 enum을 좁은 범위로 쓰는 편이 낫습니다.

기본 사용법과 switch

enum class Color { RED, GREEN, BLUE };

Color c = Color::RED;

switch (c) {
    case Color::RED:
        std::cout << "빨강\n";
        break;
    case Color::GREEN:
        std::cout << "초록\n";
        break;
    case Color::BLUE:
        std::cout << "파랑\n";
        break;
}

switch에 default를 넣지 않은 것은 의도적입니다. 열거자를 모두 나열하고 default를 빼 두면, 나중에 Color에 YELLOW를 추가했을 때 GCC·Clang의 -Wswitch(-Wall에 포함)가 enumeration value 'YELLOW' not handled in switch 경고로 처리하지 않은 모든 switch를 찾아 줍니다. 습관적으로 default:를 넣으면 이 경고가 나오지 않아, 새 값이 조용히 기본 분기로 흘러가는 버그가 됩니다. 값이 추가될 가능성이 있는 열거형이라면 default 없이 모든 케이스를 쓰고, 함수 끝에 도달할 수 없다는 표시(C++23 std::unreachable() 또는 로그 후 반환)를 switch 밖에 두는 편이 좋습니다. default가 있는 switch까지 누락을 검사하고 싶다면 -Wswitch-enum을 켭니다.

명시적 값 지정

enum class HttpStatus {
    OK = 200,
    CREATED = 201,
    BAD_REQUEST = 400,
    UNAUTHORIZED = 401,
    NOT_FOUND = 404,
    SERVER_ERROR = 500
};

int code = static_cast<int>(HttpStatus::OK);  // 200

HTTP 상태 코드처럼 값 자체에 의미가 있는 열거형은 숫자를 명시해야 합니다. 값을 생략하면 앞 열거자 + 1로 자동 매겨지므로, 중간에 열거자를 하나 끼워 넣는 순간 뒤의 모든 값이 밀립니다. 메모리 안에서만 쓰는 열거형이라면 상관없지만, DB 컬럼이나 파일·네트워크로 정수 값이 나가는 열거형이라면 이 한 줄의 수정이 기존 데이터를 모두 다른 의미로 바꿔 버립니다. 그래서 저장되는 열거형은 새 값을 끝에 추가하고 기존 값의 의미는 바꾸지 않으며, 직렬화 결과를 고정한 테스트를 두어 실수를 잡습니다.

밑바닥 타입(underlying type)

enum class는 고유한 타입이지만 저장은 밑바닥 정수 타입으로 합니다. 밑바닥 타입을 쓰지 않은 enum class의 밑바닥 타입은 표준이 int로 고정하고, : std::uint8_t처럼 지정하면 크기와 부호가 그 타입을 따릅니다.

#include <cstdint>
#include <type_traits>

enum class Code : std::uint8_t { A = 1, B = 2 };
enum class BigEnum : std::uint64_t { LARGE_VALUE = 1'000'000'000'000 };

static_assert(sizeof(Code) == 1);
static_assert(std::is_same_v<std::underlying_type_t<Code>, std::uint8_t>);

constexpr std::uint8_t raw(Code c) {
    return static_cast<std::underlying_type_t<Code>>(c);
}

std::underlying_type_t<E>와 static_cast가 “열거형 값과 정수” 사이를 오가는 표준 관용구이며, C++23부터는 같은 일을 하는 std::to_underlying(<utility>)이 있습니다. C++23 이전 코드에서 같은 이름의 헬퍼를 직접 만들었다면 프로젝트 네임스페이스 안에 두는 것이 좋습니다. 전역에 두면 using namespace std;가 있는 파일에서 C++23으로 올리는 순간 std::to_underlying과 모호해질 수 있습니다.

밑바닥 타입을 바꾸는 것은 크기·정렬이 바뀌는 변경이라 ABI와 저장 포맷에 직접 영향을 줍니다. 공유 라이브러리나 네트워크 프로토콜에 노출되는 열거형은 처음부터 고정하는 편이 안전하고, 다른 언어와 FFI로 주고받는다면 정수 크기와 부호를 문서에 적어 둡니다.

선언되지 않은 정수를 캐스팅할 때

열거자 값이 밑바닥 타입이 표현할 수 있는 범위를 벗어나면 ill-formed(컴파일 에러)입니다. static_cast로 선언되지 않은 정수를 넣는 경우는 규칙이 나뉩니다. 밑바닥 타입이 고정된 열거형(모든 enum class, : 타입을 쓴 enum)은 밑바닥 타입의 모든 값이 유효한 값이라 static_cast<Color>(7)이나 static_cast<HttpStatus>(418) 자체는 정의된 동작입니다. 반면 밑바닥 타입이 고정되지 않은 비스코프 enum에 값 범위 밖의 정수를 넣으면 C++17부터 미정의 동작입니다.

정의된 동작이라고 해서 안전한 것은 아닙니다. 즉 열거형 변수는 “선언된 열거자 중 하나”를 보장하지 않습니다. 모든 열거자를 처리한 switch는 7을 어느 케이스로도 보내지 않아 값을 반환하지 않고 함수 끝에 도달할 수 있고(값을 반환하는 함수에서는 미정의 동작), 최적화 컴파일러가 “열거자 외의 값은 없다”고 가정한 코드를 만들기도 합니다. 그래서 파일·네트워크처럼 외부에서 들어오는 정수를 열거형으로 바꿀 때는 캐스팅 전에 범위를 검증해야 합니다.

전방 선언

enum class는 밑바닥 타입이 정해져 있으므로 enum class Color;만으로 전방 선언할 수 있습니다. 비스코프 enum은 C++11부터 밑바닥 타입을 함께 쓰면 전방 선언할 수 있습니다.

enum class Forward : int;          // 선언만
enum class Forward : int { X, Y }; // 정의 (같은 밑바닥 타입)
enum Old : short;                  // 비스코프 enum의 전방 선언

전방 선언이 쓸모 있는 이유는 컴파일 의존성입니다. 헤더가 열거형을 함수 인자나 멤버 타입으로만 쓴다면 정의가 든 헤더를 include하지 않아도 되고, 열거자를 추가할 때마다 그 헤더를 포함한 수많은 파일이 다시 컴파일되는 일을 줄일 수 있습니다.

실전 예시

상태 머신: 전이 함수 한 곳에 모으기

enum class State { Idle, Running, Paused, Stopped };
enum class Event { Start, Pause, Resume, Stop };

class StateMachine {
    State state_{State::Idle};

    static bool tryTransition(State from, Event ev, State& out) {
        switch (from) {
        case State::Idle:
            if (ev == Event::Start) { out = State::Running; return true; }
            return false;
        case State::Running:
            if (ev == Event::Pause) { out = State::Paused; return true; }
            if (ev == Event::Stop)  { out = State::Stopped; return true; }
            return false;
        case State::Paused:
            if (ev == Event::Resume) { out = State::Running; return true; }
            if (ev == Event::Stop)   { out = State::Stopped; return true; }
            return false;
        case State::Stopped:
            return false;
        }
        return false;  // 선언되지 않은 값이 들어온 경우
    }

public:
    bool dispatch(Event ev) {
        State next{};
        if (!tryTransition(state_, ev, next)) return false;
        state_ = next;
        return true;
    }
    State state() const { return state_; }
};

상태마다 start(), pause() 같은 메서드에 if 검사를 흩어 두는 것보다 (현재 상태, 이벤트) → 다음 상태 규칙을 한 함수에 모으면 허용되지 않는 전이를 한눈에 볼 수 있고, dispatch가 false를 반환하는지로 무효 전이를 로그나 테스트에서 확인하기 쉽습니다. 규칙이 더 늘어나면 표 형태의 데이터로 빼거나 그래프 도구로 시각화하기도 좋습니다.

방향과 회전

enum class Direction { North, East, South, West };

Direction turnRight(Direction d) {
    return static_cast<Direction>((static_cast<int>(d) + 1) % 4);
}
Direction turnLeft(Direction d) {
    return static_cast<Direction>((static_cast<int>(d) + 3) % 4);
}

정수 연산으로 회전을 구현하는 것은 열거자가 0부터 시계 방향으로 빈틈없이 선언되어 있다는 전제에 기댑니다. 이 전제를 열거형 정의 옆에 주석으로 남겨 두지 않으면, 누군가 NorthEast를 끼워 넣는 순간 조용히 틀린 결과가 나옵니다.

로그 레벨과 순서 비교

enum class LogLevel { Debug, Info, Warning, Error, Fatal };  // 순서 중요

class Logger {
    LogLevel minLevel_ = LogLevel::Info;
public:
    void setLevel(LogLevel level) { minLevel_ = level; }
    void log(LogLevel level, const std::string& message) {
        if (level >= minLevel_) {
            std::cout << "[" << toString(level) << "] " << message << '\n';
        }
    }
private:
    static const char* toString(LogLevel level) {
        switch (level) {
            case LogLevel::Debug:   return "DEBUG";
            case LogLevel::Info:    return "INFO";
            case LogLevel::Warning: return "WARNING";
            case LogLevel::Error:   return "ERROR";
            case LogLevel::Fatal:   return "FATAL";
        }
        return "UNKNOWN";
    }
};

enum class가 막는 것은 다른 타입과의 암시적 변환이지, 같은 열거형끼리의 비교가 아닙니다. ==, !=뿐 아니라 <, >= 같은 관계 연산도 같은 enum class 값끼리는 밑바닥 정수 값 기준으로 동작하므로 level >= minLevel_을 바로 쓸 수 있습니다. 다만 이 비교는 선언된 정수 값의 순서에 의존하므로, 누군가 Trace를 목록 끝에 추가하면 Trace가 Fatal보다 심각한 레벨이 되어 버립니다. 순서에 의미가 있는 열거형이라면 값을 명시하고 주석으로 순서가 중요하다는 것을 남겨 두는 편이 안전합니다.

열거자 이름을 Error처럼 첫 글자만 대문자로 지은 데는 이유가 있습니다. Windows에서 <windows.h>가 포함된 번역 단위에서는 wingdi.h가 #define ERROR 0을 정의하므로, 열거자를 ERROR로 지으면 LogLevel::ERROR가 LogLevel::0으로 치환되어 syntax error: 'constant' 같은 이해하기 어려운 에러가 납니다. 빌드 설정에 따라 DEBUG가 매크로로 정의되어 있는 경우도 있습니다. enum class의 스코프는 전처리기 매크로에는 효과가 없으므로, 열거자 이름에 매크로와 겹치기 쉬운 전부 대문자 이름을 쓰지 않는 것이 해결책입니다. 열거자를 kError나 Error처럼 짓는 코딩 스타일이 많은 이유이기도 합니다.

문자열 변환 패턴

C++26에 정적 리플렉션이 들어오기 전까지 표준만으로는 열거자 이름을 문자열로 얻을 수 없으므로, 열거자 목록과 이름 목록을 어떻게 한 곳에서 관리하느냐가 핵심입니다. 가장 흔한 방법은 default 없는 switch로, 위 LogLevel::toString처럼 쓰면 -Wswitch가 누락을 잡아 줍니다. 열거자가 많다면 X 매크로로 목록을 한 번만 적고 열거형 정의와 문자열 테이블을 함께 생성할 수 있고, 값이 0부터 연속이라면 constexpr std::array<std::string_view, N>을 인덱스로 조회하는 방법도 있습니다. magic_enum 같은 라이브러리는 컴파일러의 __PRETTY_FUNCTION__ 출력을 파싱하는 방식이라 편리하지만, 기본 설정에서는 대략 -128~127 범위의 값만 다루는 등 제약이 있어 비트 플래그나 HTTP 코드처럼 큰 값에는 설정을 확인해야 합니다.

X 매크로는 다음과 같은 형태입니다.

#define COLOR_LIST(X) \
    X(Red)            \
    X(Green)          \
    X(Blue)

enum class Color {
#define X(name) name,
    COLOR_LIST(X)
#undef X
};

constexpr const char* toString(Color c) {
    switch (c) {
#define X(name) case Color::name: return #name;
        COLOR_LIST(X)
#undef X
    }
    return "Unknown";
}

열거자를 추가할 때 COLOR_LIST만 고치면 정의와 문자열 변환이 함께 바뀝니다. #name은 토큰을 그대로 문자열로 만들므로 결과는 "Red"입니다. 만약 case Color::Red처럼 스코프까지 포함한 토큰을 문자열화하면 "Color::Red"가 되어 설정 파일·JSON에 쓰는 이름과 맞지 않으니 주의합니다.

반대 방향(문자열에서 열거형으로)은 실패할 수 있으므로 std::optional로 표현합니다. 설정 파일이나 JSON처럼 외부 입력을 받는다면 대소문자와 별칭을 허용할지도 명세로 정해 둡니다.

#include <optional>
#include <string_view>

enum class Role { User, Admin };

constexpr std::optional<Role> parseRole(std::string_view s) {
    if (s == "User")  return Role::User;
    if (s == "Admin") return Role::Admin;
    return std::nullopt;
}

비트 플래그 열거형

여러 옵션을 한 정수에 비트로 묶을 때는 밑바닥 타입을 부호 없는 정수로 두고 열거자를 1u << k 형태로 정의합니다. enum class는 비트 연산자를 제공하지 않으므로 직접 오버로드해야 합니다.

#include <cstdint>
#include <type_traits>

namespace fs {

enum class Perms : std::uint32_t {
    None  = 0,
    Read  = 1u << 0,
    Write = 1u << 1,
    Exec  = 1u << 2,
};

constexpr Perms operator|(Perms a, Perms b) {
    using U = std::underlying_type_t<Perms>;
    return static_cast<Perms>(static_cast<U>(a) | static_cast<U>(b));
}
constexpr Perms operator&(Perms a, Perms b) {
    using U = std::underlying_type_t<Perms>;
    return static_cast<Perms>(static_cast<U>(a) & static_cast<U>(b));
}
constexpr Perms operator~(Perms a) {
    using U = std::underlying_type_t<Perms>;
    return static_cast<Perms>(~static_cast<U>(a));
}
constexpr Perms& operator|=(Perms& a, Perms b) { return a = a | b; }
constexpr Perms& operator&=(Perms& a, Perms b) { return a = a & b; }

constexpr bool has(Perms flags, Perms check) {
    return (flags & check) == check;
}

}  // namespace fs

void example() {
    fs::Perms p = fs::Perms::Read | fs::Perms::Write;
    p &= ~fs::Perms::Write;           // Write 제거
    bool canRead = fs::has(p, fs::Perms::Read);  // true
}

연산자 오버로드는 열거형과 같은 네임스페이스에 정의해야 합니다. 다른 네임스페이스에 두면 ADL(인자 의존 탐색)로 찾을 수 없어, 그 바깥에서 Perms::Read | Perms::Write를 쓰는 순간 no match for 'operator|' 에러가 납니다. 또 |만 정의하고 |=, ~를 빠뜨리면 플래그를 더하거나 빼는 코드에서 같은 에러를 만나므로, 플래그 열거형이 여러 개라면 연산자들을 한꺼번에 정의하는 매크로나 템플릿을 두는 경우가 많습니다. ~는 정의되지 않은 상위 비트까지 1로 만들기 때문에, 그 결과를 저장하거나 비교하기 전에 &로 걸러 내야 한다는 점도 기억해 둡니다.

플래그용 열거형과 “한 번에 하나의 값만 갖는 상태”용 열거형은 섞지 않아야 합니다. 플래그는 비트 OR로 합성되어 선언되지 않은 값(예: Read | Write = 3)이 정상적으로 생기므로, 앞에서 본 모든 열거자를 나열한 switch로 처리할 수 없습니다.

열거자 순회

표준에는 열거형의 모든 값을 순회하는 기능이 없습니다. 가장 단순하고 틀릴 여지가 적은 방법은 열거자를 배열로 한 번 나열해 두는 것입니다.

#include <array>

enum class Color { Red, Green, Blue };
constexpr std::array kAllColors{Color::Red, Color::Green, Color::Blue};

for (Color c : kAllColors) {
    // ...
}

Color::Count 같은 센티널 열거자를 끝에 두고 0부터 Count까지 정수로 증가시키며 캐스팅하는 방법도 많이 쓰입니다. 개수를 얻기 편하고 배열 크기로 쓰기 좋지만, 열거자 값이 0부터 빈틈없이 연속이라는 전제가 필요하고(HttpStatus처럼 값이 띄엄띄엄 있으면 선언되지 않은 값까지 순회합니다), switch에서 Count를 처리하지 않으면 -Wswitch 경고가 나며, API 사용자가 Color::Count를 실제 색으로 넘길 수도 있다는 단점이 있습니다.

참고 자료

비트 연산 자체는 Bit Manipulation 글에서 더 자세히 다룹니다.


같이 보면 좋은 글