[C언어 50강] 36강. typedef / enum / union / bit-field: 타입 설계 도구

[C언어 50강] 36강. typedef / enum / union / bit-field: 타입 설계 도구

C언어에서 문법을 오래 공부할수록 오히려 더 중요해지는 질문이 있습니다. “이 값은 도대체 어떤 의미를 가진 타입인가?” 오늘 다룰 typedef, enum, union, bit-field는 각각 문법 자체는 짧지만, 코드의 의미를 분명하게 만들고 메모리 표현을 통제할 수 있게 해 주는 타입 설계 도구입니다. 이 네 가지를 잘 쓰면 같은 기능의 코드라도 읽기 쉬워지고, 버그가 줄고, 협업 시 오해가 크게 줄어듭니다.


핵심 개념

  • typedef는 새 타입을 “만드는” 것이 아니라 기존 타입에 의미 있는 별칭을 붙여 의도를 드러내는 도구다.
  • enum은 관련 상수 집합을 이름으로 묶어 “허용 가능한 상태 집합”을 표현하는 데 강하다.
  • union은 여러 타입이 같은 메모리 공간을 공유하도록 하며, 메모리 절약/프로토콜 해석에 유용하지만 태그 관리가 핵심이다.
  • bit-field는 구조체 멤버를 비트 단위로 선언해 플래그/하드웨어 레지스터 표현에 쓰지만, 이식성/정렬/패딩 규칙을 반드시 의식해야 한다.

개념 먼저 이해하기

초급 단계에서는 보통 “타입은 int, float, char 정도”라고 생각합니다. 그런데 실제 프로젝트에 들어가면 대부분의 버그는 단순 계산식보다 “의미가 불분명한 값”에서 발생합니다. 예를 들어 int status; 하나만 있으면 이게 HTTP 상태인지, 사용자 상태인지, 장비 상태인지 문맥이 없으면 알기 어렵습니다. 반면 enum ConnectionState state;처럼 쓰면 코드만 봐도 이 변수에 들어갈 수 있는 값의 범위와 의미가 좁아집니다. 즉 타입 설계는 단순한 문법 문제가 아니라 커뮤니케이션 품질 문제입니다.

typedef를 먼저 보면, 많은 분이 “새 타입 생성”이라고 오해합니다. C의 typedef는 본질적으로 별칭(alias)입니다. typedef unsigned int u32;를 해도 u32는 결국 unsigned int와 같은 표현입니다. 그렇다면 왜 쓰느냐? 이유는 두 가지입니다. 첫째, 긴 선언을 줄여 가독성을 높입니다(특히 함수 포인터). 둘째, 의미를 이름으로 올려서 코드 의도를 드러냅니다. 예를 들어 typedef uint32_t UserId;는 기계적으로는 정수지만, 도메인적으로 “사용자 식별자”임을 드러냅니다. 이 작은 차이가 유지보수에서 큰 차이를 만듭니다.

enum은 “숫자 상수 모음” 이상입니다. enum의 핵심 가치는 값의 집합을 제한한다는 점입니다. 상태 머신, 메뉴 선택, 파서 토큰 종류처럼 “정해진 선택지”가 있는 곳에서 enum을 쓰면 매직 넘버를 제거할 수 있습니다. if (state == 3)보다 if (state == STATE_CONNECTED)가 훨씬 안전하고 읽기 쉽습니다. 다만 C enum은 내부적으로 정수이므로, 범위를 벗어난 값이 들어오는 것을 컴파일러가 항상 막아 주지는 않습니다. 즉 enum을 쓴다고 자동으로 타입 안정성이 완성되는 건 아니고, 입력 검증과 default 처리 습관이 함께 가야 합니다.

union은 메모리 관점이 핵심입니다. 구조체는 각 멤버가 자기 공간을 갖지만, union은 멤버들이 같은 시작 주소를 공유합니다. 그래서 union 크기는 보통 “가장 큰 멤버 크기”가 됩니다. 장점은 메모리를 절약하거나 같은 바이트 배열을 서로 다른 해석으로 볼 수 있다는 점입니다(예: 네트워크 패킷 헤더 해석). 단점은 현재 어떤 멤버가 유효한지 추적하지 않으면 즉시 위험해진다는 점입니다. 그래서 실무에서는 union 단독보다 enum tag + union data 조합(태그드 유니온)을 거의 필수로 사용합니다. “지금 data가 int인지 float인지”를 tag가 설명해 주기 때문입니다.

bit-field는 “메모리를 아끼는 문법”으로만 소개되곤 하지만, 더 정확히는 비트 레벨 의미를 선언적으로 문서화하는 도구입니다. 예를 들어 권한 플래그 1비트, 상태 코드 3비트, 예약 4비트를 한 구조체로 표현할 수 있습니다. 그러나 bit-field는 컴파일러/아키텍처/엔디안/정렬 정책에 따라 배치 방식이 달라질 수 있어, 바이너리 호환성이 중요한 포맷에서는 주의가 필요합니다. 즉 내부 상태 표현(한 컴파일러/한 타깃)에는 편리하지만, 외부 파일 포맷/네트워크 프로토콜에 그대로 쓰는 건 위험할 수 있습니다. 그런 경우는 보통 비트 마스크 연산(&, |, <<)을 명시적으로 씁니다.

정리하면, 네 도구는 각각 역할이 다릅니다. typedef는 이름으로 의도를 올리고, enum은 허용 상태를 제한하며, union은 동일 메모리의 다중 해석을 제공하고, bit-field는 비트 의미를 선언합니다. 좋은 C 코드는 이 도구를 “멋있어 보여서” 쓰는 게 아니라, 읽는 사람이 실수하기 어렵게 만들기 위해 씁니다. 즉 타입 설계의 목표는 성능 이전에 정확성과 소통입니다.

기본 사용

예제 1) typedef + enum으로 상태를 명확히 표현

#include <stdio.h>
#include <stdint.h>

typedef uint32_t UserId;

typedef enum {
    CONN_DISCONNECTED = 0,
    CONN_CONNECTING,
    CONN_CONNECTED,
    CONN_ERROR
} ConnectionState;

const char* state_to_string(ConnectionState s) {
    switch (s) {
        case CONN_DISCONNECTED: return "DISCONNECTED";
        case CONN_CONNECTING:   return "CONNECTING";
        case CONN_CONNECTED:    return "CONNECTED";
        case CONN_ERROR:        return "ERROR";
        default:                return "UNKNOWN";
    }
}

int main(void) {
    UserId uid = 1001;
    ConnectionState state = CONN_CONNECTING;

    printf("uid=%u, state=%s\n", uid, state_to_string(state));
    state = CONN_CONNECTED;
    printf("uid=%u, state=%s\n", uid, state_to_string(state));
    return 0;
}

설명:

  • UserId는 단순 정수지만 의미가 살아나서 변수 오용을 줄인다.
  • enum 이름을 통해 상태 전이 코드를 읽기 쉬워진다.
  • default를 두어 예상 밖 값이 들어와도 최소한 관찰 가능성을 확보한다.

예제 2) tagged union으로 안전하게 다형 데이터 표현

#include <stdio.h>

typedef enum {
    VAL_INT,
    VAL_DOUBLE,
    VAL_CHAR
} ValueType;

typedef struct {
    ValueType type;
    union {
        int i;
        double d;
        char c;
    } as;
} Value;

void print_value(const Value *v) {
    if (!v) return;

    switch (v->type) {
        case VAL_INT:
            printf("int: %d\n", v->as.i);
            break;
        case VAL_DOUBLE:
            printf("double: %.2f\n", v->as.d);
            break;
        case VAL_CHAR:
            printf("char: %c\n", v->as.c);
            break;
        default:
            printf("unknown\n");
            break;
    }
}

int main(void) {
    Value a = {.type = VAL_INT, .as.i = 42};
    Value b = {.type = VAL_DOUBLE, .as.d = 3.14};
    Value c = {.type = VAL_CHAR, .as.c = 'Z'};

    print_value(&a);
    print_value(&b);
    print_value(&c);
    return 0;
}

설명:

  • union만 쓰면 현재 유효 멤버를 알 수 없으므로 type 태그를 같이 둔다.
  • switch(type)로 분기하면 해석 실수를 크게 줄일 수 있다.
  • JSON 파서, 설정값, VM 값 표현처럼 “값 종류가 여러 개인 데이터”에 자주 쓰인다.

예제 3) bit-field로 플래그 묶기 + 마스크 연산 비교

#include <stdio.h>
#include <stdint.h>

typedef struct {
    unsigned read  : 1;
    unsigned write : 1;
    unsigned exec  : 1;
    unsigned level : 3;
    unsigned reserved : 2;
} PermissionBits;

int main(void) {
    PermissionBits p = {0};
    p.read = 1;
    p.write = 1;
    p.exec = 0;
    p.level = 5; // 0~7

    printf("r=%u w=%u x=%u level=%u\n", p.read, p.write, p.exec, p.level);

    // 외부 포맷과 맞춰야 할 때는 보통 명시적 마스크를 함께 고려
    uint8_t packed = 0;
    packed |= (p.read  & 0x1u) << 0;
    packed |= (p.write & 0x1u) << 1;
    packed |= (p.exec  & 0x1u) << 2;
    packed |= (p.level & 0x7u) << 3;

    printf("packed=0x%02X\n", packed);
    return 0;
}

설명:

  • bit-field는 상태 의미를 코드에 직접 드러내는 데 좋다.
  • 단, 실제 비트 배치가 ABI에 의존할 수 있어 외부 호환 요구 시 마스크 방식이 더 명확하다.
  • level처럼 폭이 좁은 필드는 범위를 넘기지 않게 입력 검증이 필요하다.

자주 하는 실수

실수 1) typedef가 새 독립 타입이라고 믿고 과신

  • 원인: typedef를 C++의 강한 타입 별칭처럼 오해.
  • 해결: C typedef는 별칭이라는 점을 기억하고, 진짜 제약은 함수 인터페이스/검증 로직으로 보완한다.

실수 2) enum을 썼으니 유효성 검사는 필요 없다고 판단

  • 원인: enum 변수에 외부 입력(파일/네트워크/CLI)을 그대로 대입.
  • 해결: 정수→enum 변환 함수에서 범위 체크를 수행하고, default 분기를 남긴다.

실수 3) union에서 마지막에 쓴 멤버를 추적하지 않음

  • 원인: tag 없이 union 멤버를 교차 접근.
  • 해결: 반드시 enum tag + union 구조를 채택하고, 읽을 때 tag 기반으로만 해석한다.

실수 4) bit-field를 네트워크 패킷/파일 포맷에 그대로 저장

  • 원인: 컴파일러/타깃 간 배치 차이를 무시.
  • 해결: 외부 호환 데이터는 고정 폭 정수 + 비트 마스크/시프트로 직렬화 규약을 명시한다.

실무 패턴

  • 도메인 ID는 typedef로 이름 붙인다.
    • UserId, OrderId, SessionId처럼 의미를 타입명으로 끌어올린다.
  • 상태/이벤트 코드는 enum으로 선언한다.
    • 로그 출력 함수(to_string)를 함께 제공해 디버깅 가능성을 높인다.
  • union은 태그와 세트로만 쓴다.
    • 단독 union은 빠르게 기술 부채가 된다.
  • bit-field는 내부 상태 표현에 우선 사용한다.
    • 외부 I/O는 별도 패킹/언패킹 함수로 명시적으로 처리한다.

팀에서 자주 쓰는 최소 패턴은 아래처럼 정리할 수 있습니다.

#include <stdbool.h>

typedef enum {
    CMD_NOP = 0,
    CMD_START,
    CMD_STOP
} CommandType;

typedef struct {
    unsigned urgent : 1;
    unsigned retry  : 1;
    unsigned reserved : 6;
} CommandFlags;

typedef struct {
    CommandType type;
    CommandFlags flags;
} Command;

bool is_valid_command(CommandType t) {
    return t >= CMD_NOP && t <= CMD_STOP;
}

이 패턴의 장점은 “무슨 값이 들어오고, 어디까지 허용되는지”가 타입 선언만으로 상당 부분 드러난다는 점입니다. 코드 리뷰에서 논쟁할 지점도 줄어듭니다.

오늘의 결론

한 줄 요약: 타입 설계 도구(typedef/enum/union/bit-field)는 문법 장식이 아니라, 버그를 예방하고 팀의 공통 이해를 만드는 안전장치입니다.

C언어를 오래 쓸수록 “짧은 코드”보다 “오해하기 어려운 코드”가 더 가치 있습니다. 타입 이름과 데이터 표현을 명확히 잡아 두면, 기능이 커질수록 차이가 크게 벌어집니다.

연습문제

  1. typedef uint32_t ProductId;를 도입하고, 상품 조회 함수 시그니처를 int find_product(ProductId id); 형태로 바꿔 보세요. 변경 전/후 가독성 차이를 설명하세요.
  2. 주문 상태(CREATED, PAID, SHIPPED, CANCELED)를 enum으로 정의하고, 상태 문자열 변환 함수 order_state_to_string을 작성하세요.
  3. enum + union을 활용해 Int, Double, String(고정 배열) 3종 값을 담는 Variant 구조체를 설계하고 출력 함수를 작성하세요.
  4. 권한 플래그(read/write/exec)를 bit-field와 비트 마스크 방식 두 가지로 각각 구현한 뒤, 어떤 상황에서 어떤 방식이 더 적합한지 비교해 보세요.

이전 강의 정답

지난 35강 연습문제의 핵심은 “구조체 배열 + 포인터 기반 수정 + 검색 함수”였습니다. 대표 정답 예시는 아래와 같습니다.

#include <stdio.h>
#include <stddef.h>

struct Product {
    int id;
    int price;
    int stock;
};

int total_stock(const struct Product *arr, size_t n) {
    int sum = 0;
    for (size_t i = 0; i < n; ++i) sum += arr[i].stock;
    return sum;
}

void apply_discount(struct Product *p, int percent) {
    if (!p || percent < 0) return;
    int discounted = p->price - (p->price * percent / 100);
    p->price = (discounted < 0) ? 0 : discounted;
}

struct Product* find_by_id(struct Product *arr, size_t n, int id) {
    if (!arr) return NULL;
    for (size_t i = 0; i < n; ++i) {
        if (arr[i].id == id) return &arr[i];
    }
    return NULL;
}

int main(void) {
    struct Product items[3] = {
        {101, 10000, 5},
        {102,  7000, 3},
        {103, 12000, 2}
    };

    printf("total_stock=%d\n", total_stock(items, 3));

    struct Product *p = find_by_id(items, 3, 102);
    if (p) {
        apply_discount(p, 20);
        printf("id=%d new_price=%d\n", p->id, p->price);
    }
    return 0;
}

해설 포인트:

  • 배열 처리 함수는 포인터 + 길이 계약을 분명히 가져간다.
  • 수정 함수는 NULL/범위 검증을 먼저 수행해 방어적으로 작성한다.
  • 검색 함수는 포인터를 반환해 호출자가 즉시 수정할 수 있게 한다.

실습 환경/재현 정보

  • 컴파일러: clang 17+ 또는 gcc 13+
  • 컴파일 옵션: -std=c11 -Wall -Wextra -O2
  • 실행 환경: macOS(Apple Silicon), Linux x86_64 터미널
  • 재현 체크:
    • 예제 3개 모두 경고 없이 컴파일되는지 확인
    • enum 문자열 변환에서 default가 빠지지 않았는지 확인
    • union 사용 시 tag 없이 멤버를 읽는 코드가 없는지 점검
    • bit-field 값을 외부 저장 전에 명시적 패킹 절차가 있는지 확인