Skip to main content

열거형과 공용체

Views 16약 11분생성형 AI 활용
Summary

숫자에 이름을 붙이면 코드가 말하기 시작한다

프로그램을 만들다 보면 숫자로 상태를 표현하는 일이 자주 생깁니다. 예를 들어 게임 캐릭터의 상태를 다음처럼 정할 수 있습니다.

int state = 0;

여기서 0은 무엇일까요? 대기 상태일 수도 있고, 실행 중일 수도 있고, 오류 상태일 수도 있습니다. 코드를 처음 작성한 사람은 기억할지 몰라도, 며칠 뒤 다시 보면 금세 헷갈립니다.

if (state == 1) {
    printf("작업 중입니다.n");
}

이 코드는 컴퓨터에게는 충분히 명확합니다. state가 1이면 출력하라는 뜻이니까요. 하지만 사람에게는 친절하지 않습니다. 1이 무엇을 의미하는지 코드만 보고는 알기 어렵습니다.

프로그래밍에서 중요한 습관 중 하나는 컴퓨터가 이해하는 코드를 넘어 사람도 이해할 수 있는 코드를 쓰는 것입니다. C언어의 enum은 바로 이 지점에서 등장합니다.

enum은 정수 값에 의미 있는 이름을 붙여 주는 문법입니다. 숫자 0, 1, 2 대신 STATE_IDLE, STATE_RUNNING, STATE_ERROR 같은 이름을 사용하게 해 줍니다.

enum State {
    STATE_IDLE,
    STATE_RUNNING,
    STATE_ERROR
};

이제 상태를 다음처럼 표현할 수 있습니다.

enum State state = STATE_RUNNING;

if (state == STATE_RUNNING) {
    printf("작업 중입니다.n");
}

코드가 훨씬 자연스럽게 읽힙니다. state == 1보다 state == STATE_RUNNING이 훨씬 분명합니다. 컴퓨터 입장에서는 여전히 정수 비교지만, 사람 입장에서는 코드가 문장처럼 보이기 시작합니다.

열거형과 공용체

enum의 기본 문법

enum은 enumeration, 즉 열거를 뜻합니다. 가능한 값을 하나씩 나열한다는 의미입니다.

enum Weekday {
    MONDAY,
    TUESDAY,
    WEDNESDAY,
    THURSDAY,
    FRIDAY,
    SATURDAY,
    SUNDAY
};

위 코드는 Weekday라는 열거형을 만들고, 그 안에 요일을 표현하는 이름들을 나열합니다. 기본적으로 첫 번째 이름은 0, 두 번째 이름은 1, 세 번째 이름은 2 값을 가집니다.

#include <stdio.h>

int main(void) {
    enum Weekday today = WEDNESDAY;

    printf("today = %dn", today);

    return 0;
}

실행하면 보통 다음과 같이 출력됩니다.

today = 2

MONDAY가 0, TUESDAY가 1, WEDNESDAY가 2이기 때문입니다.

원한다면 값을 직접 지정할 수도 있습니다.

enum Menu {
    MENU_ADD = 1,
    MENU_LIST = 2,
    MENU_SEARCH = 3,
    MENU_EXIT = 0
};

이렇게 하면 사용자가 메뉴에서 1을 입력했을 때 MENU_ADD, 2를 입력했을 때 MENU_LIST처럼 의미를 붙여 처리할 수 있습니다.

#include <stdio.h>

enum Menu {
    MENU_EXIT = 0,
    MENU_ADD = 1,
    MENU_LIST = 2,
    MENU_SEARCH = 3
};

int main(void) {
    int input;

    printf("1. 추가n");
    printf("2. 목록n");
    printf("3. 검색n");
    printf("0. 종료n");
    printf("선택: ");
    scanf("%d", &input);

    switch (input) {
    case MENU_ADD:
        printf("추가 기능을 실행합니다.n");
        break;
    case MENU_LIST:
        printf("목록을 출력합니다.n");
        break;
    case MENU_SEARCH:
        printf("검색 기능을 실행합니다.n");
        break;
    case MENU_EXIT:
        printf("프로그램을 종료합니다.n");
        break;
    default:
        printf("알 수 없는 메뉴입니다.n");
        break;
    }

    return 0;
}

switch문 안에 숫자 1, 2, 3, 0을 직접 쓰는 대신 이름을 사용했습니다. 기능은 같지만 읽는 느낌은 다릅니다.

case 1:

보다

case MENU_ADD:

가 훨씬 명확합니다. 특히 프로그램이 길어질수록 이런 차이는 크게 느껴집니다.

enum은 상태를 표현할 때 빛난다

프로그램은 늘 어떤 상태에 놓여 있습니다. 게임 캐릭터는 대기 중일 수 있고, 걷는 중일 수 있고, 공격 중일 수 있습니다. 파일 다운로드 프로그램은 준비 중, 다운로드 중, 일시 정지, 완료, 오류 상태를 가질 수 있습니다. 자판기 프로그램은 동전 입력 대기, 상품 선택 대기, 상품 배출, 거스름돈 반환 상태를 가질 수 있습니다.

이처럼 가능한 상태가 몇 가지로 정해져 있을 때 enum은 아주 잘 어울립니다.

#include <stdio.h>

enum State {
    STATE_IDLE,
    STATE_RUNNING,
    STATE_PAUSED,
    STATE_FINISHED,
    STATE_ERROR
};

void print_state(enum State state) {
    switch (state) {
    case STATE_IDLE:
        printf("대기 중n");
        break;
    case STATE_RUNNING:
        printf("실행 중n");
        break;
    case STATE_PAUSED:
        printf("일시 정지n");
        break;
    case STATE_FINISHED:
        printf("완료n");
        break;
    case STATE_ERROR:
        printf("오류n");
        break;
    }
}

int main(void) {
    enum State state = STATE_IDLE;

    print_state(state);

    state = STATE_RUNNING;
    print_state(state);

    state = STATE_FINISHED;
    print_state(state);

    return 0;
}

이 예제에서 state는 아무 숫자나 담는 변수가 아니라, 프로그램의 현재 상태를 나타내는 변수입니다. 물론 C언어는 아주 엄격한 언어는 아니어서 enum State 변수에 엉뚱한 정수 값을 넣는 것을 완벽하게 막아 주지는 않습니다.

enum State state = 999;  // 문법적으로 허용될 수 있음

따라서 enum은 "절대로 다른 값이 못 들어가게 막는 장치"라기보다는, 의미 있는 이름으로 코드의 의도를 분명하게 드러내는 장치라고 이해하는 편이 좋습니다.

그래도 효과는 큽니다. 상태를 숫자로 관리하면 코드를 읽을 때마다 숫자의 의미를 기억해야 합니다. 반면 enum을 쓰면 이름 자체가 설명이 됩니다.

typedef와 함께 쓰면 더 편하다

앞의 예제에서는 변수를 선언할 때마다 enum State라고 써야 했습니다.

enum State state;

C에서는 typedef를 이용해 더 짧은 이름을 만들 수 있습니다. 이전 장에서 구조체를 다루며 typedef struct를 보았던 것처럼, 열거형에도 typedef를 사용할 수 있습니다.

typedef enum {
    RESULT_OK,
    RESULT_INVALID_INPUT,
    RESULT_FILE_NOT_FOUND,
    RESULT_OUT_OF_MEMORY
} Result;

이제 변수나 함수의 반환형에 Result라고만 쓸 수 있습니다.

Result load_file(const char *filename) {
    if (filename == NULL) {
        return RESULT_INVALID_INPUT;
    }

    return RESULT_OK;
}

전체 예제를 보겠습니다.

#include <stdio.h>

typedef enum {
    RESULT_OK,
    RESULT_INVALID_INPUT,
    RESULT_DIVIDE_BY_ZERO
} Result;

Result divide(int a, int b, int *out) {
    if (out == NULL) {
        return RESULT_INVALID_INPUT;
    }

    if (b == 0) {
        return RESULT_DIVIDE_BY_ZERO;
    }

    *out = a / b;
    return RESULT_OK;
}

int main(void) {
    int result;
    Result status = divide(10, 0, &result);

    if (status == RESULT_OK) {
        printf("결과: %dn", result);
    } else if (status == RESULT_DIVIDE_BY_ZERO) {
        printf("0으로 나눌 수 없습니다.n");
    } else if (status == RESULT_INVALID_INPUT) {
        printf("잘못된 입력입니다.n");
    }

    return 0;
}

이 예제에는 지금까지 배운 중요한 개념이 여러 개 들어 있습니다. divide 함수는 계산 결과를 포인터 매개변수 out을 통해 바깥으로 전달합니다. 그리고 함수가 성공했는지 실패했는지는 Result 열거형으로 알려 줍니다.

함수가 단순히 0이나 -1을 반환하게 만들 수도 있습니다. 하지만 RESULT_DIVIDE_BY_ZERO처럼 이름을 붙이면 실패 이유가 훨씬 분명해집니다.

실제 프로그램에서도 이런 방식은 자주 쓰입니다. 함수가 성공했는지, 입력이 잘못되었는지, 파일을 찾지 못했는지, 메모리가 부족했는지를 이름 있는 결과 코드로 표현하면 호출하는 쪽에서 코드를 읽기 쉬워집니다.

이름은 길어도 좋다

처음에는 STATE_RUNNING 같은 이름이 조금 길어 보일 수 있습니다. 그냥 RUNNING이라고 쓰면 안 될까요? 물론 가능합니다.

enum State {
    IDLE,
    RUNNING,
    ERROR
};

하지만 프로그램이 커지면 ERROR, READY, OPEN, CLOSE 같은 이름은 여러 곳에서 겹치기 쉽습니다. C의 enum 이름들은 같은 범위 안에서 일반 이름처럼 취급되므로, 너무 흔한 이름을 쓰면 충돌이 생길 수 있습니다.

그래서 실무에서는 다음처럼 앞에 접두어를 붙이는 방식을 자주 사용합니다.

enum FileState {
    FILE_STATE_CLOSED,
    FILE_STATE_OPEN,
    FILE_STATE_ERROR
};

enum NetworkState {
    NETWORK_STATE_DISCONNECTED,
    NETWORK_STATE_CONNECTED,
    NETWORK_STATE_ERROR
};

이름은 조금 길어졌지만, 의미는 훨씬 정확해졌습니다. C에서는 자동 완성 기능이 있는 편집기를 많이 사용하므로 긴 이름을 매번 손으로 다 입력할 필요도 적습니다. 중요한 것은 짧은 코드가 아니라 나중에도 오해하지 않는 코드입니다.

union은 하나의 공간을 여러 방식으로 바라본다

이제 union, 즉 공용체를 살펴보겠습니다. 이름부터 조금 낯섭니다. 구조체 struct는 여러 값을 하나로 묶는 문법이었습니다.

struct Student {
    int id;
    char name[20];
    double score;
};

이 구조체는 id, name, score를 각각 따로 저장합니다. 학생 한 명을 표현하려면 학번도 필요하고, 이름도 필요하고, 점수도 필요하기 때문입니다.

반면 union은 여러 멤버를 선언하더라도 같은 메모리 공간을 공유합니다.

union Data {
    int i;
    double d;
    char text[16];
};

겉으로 보기에는 구조체와 비슷합니다. 하지만 메모리 사용 방식은 완전히 다릅니다.

구조체는 멤버마다 자기 자리를 가집니다.

struct Data
+-----+----------+----------------+
| int | double   | char[16]       |
+-----+----------+----------------+

공용체는 멤버들이 한 공간을 함께 씁니다.

union Data
+----------------+
| int            |
| double         |
| char[16]       |
+----------------+

같은 공간을 int로 해석할 수도 있고, double로 해석할 수도 있고, 문자 배열로 해석할 수도 있는 것입니다. 하지만 동시에 세 값을 모두 보관하는 것은 아닙니다.

sizeof로 구조체와 공용체 비교하기

직접 확인해 보겠습니다.

#include <stdio.h>

struct StructData {
    int i;
    double d;
    char text[16];
};

union UnionData {
    int i;
    double d;
    char text[16];
};

int main(void) {
    printf("sizeof(struct StructData) = %zun", sizeof(struct StructData));
    printf("sizeof(union UnionData)   = %zun", sizeof(union UnionData));

    return 0;
}

실행 결과는 환경에 따라 조금 다를 수 있지만, 대체로 구조체가 공용체보다 큽니다.

sizeof(struct StructData) = 32
sizeof(union UnionData)   = 16

왜 그럴까요?

구조체는 int, double, char[16]을 모두 담아야 합니다. 그래서 각 멤버의 공간이 필요합니다. 게다가 컴퓨터가 메모리를 빠르게 읽기 위해 중간에 빈 칸을 넣는 경우도 있습니다. 이것을 패딩이라고 합니다.

반면 공용체는 세 멤버 중 가장 큰 멤버를 담을 수 있을 만큼만 공간을 준비합니다. 위 예제에서는 char text[16]이 16바이트이므로, 공용체 전체 크기는 보통 16바이트가 됩니다. 다만 정렬 규칙 때문에 환경에 따라 결과가 달라질 수 있습니다.

중요한 감각은 이것입니다.

구조체는 여러 값을 동시에 보관합니다. 공용체는 하나의 공간을 여러 타입 중 하나로 사용합니다.

주소를 찍어 보면 더 분명해진다

공용체 멤버들이 정말 같은 공간을 공유하는지 주소를 출력해 보겠습니다.

#include <stdio.h>

union Value {
    int i;
    double d;
    char c;
};

int main(void) {
    union Value v;

    printf("&v   = %pn", (void *)&v);
    printf("&v.i = %pn", (void *)&v.i);
    printf("&v.d = %pn", (void *)&v.d);
    printf("&v.c = %pn", (void *)&v.c);

    return 0;
}

출력 결과는 실행할 때마다 달라질 수 있지만, 핵심은 세 멤버의 주소가 같다는 점입니다.

&v   = 0x7ffee3b8a1c0
&v.i = 0x7ffee3b8a1c0
&v.d = 0x7ffee3b8a1c0
&v.c = 0x7ffee3b8a1c0

v.i, v.d, v.c가 서로 다른 이름을 가지고 있지만, 시작 주소는 같습니다. 같은 방을 세 사람이 같이 쓰는 셈입니다. 한 사람이 들어와 가방을 풀면, 다른 사람이 쓰던 물건은 덮어써질 수 있습니다.

다음 예제를 보겠습니다.

#include <stdio.h>
#include <string.h>

union Data {
    int i;
    double d;
    char text[16];
};

int main(void) {
    union Data data;

    data.i = 1234;
    printf("data.i = %dn", data.i);

    data.d = 3.14;
    printf("data.d = %.2fn", data.d);

    strcpy(data.text, "C language");
    printf("data.text = %sn", data.text);

    return 0;
}

이 코드는 정상적으로 보입니다. 그러나 중요한 점이 있습니다. data.i를 저장한 뒤 data.d를 저장하면, 같은 공간을 double 값으로 다시 사용합니다. 그다음 data.text를 저장하면 같은 공간을 문자열로 다시 덮어씁니다.

따라서 마지막에 이런 코드를 추가하면 어떻게 될까요?

printf("data.i = %dn", data.i);

이미 data.text가 같은 공간을 덮어썼기 때문에, data.i는 더 이상 처음 저장한 1234라고 기대하면 안 됩니다. 공용체에서는 가장 최근에 의미 있게 저장한 멤버가 무엇인지를 알고 있어야 합니다.

구조체와 공용체의 차이

구조체와 공용체는 문법이 비슷해서 처음에는 헷갈릴 수 있습니다.

struct Example {
    int i;
    double d;
};

union Example {
    int i;
    double d;
};

그러나 생각하는 방식은 다릅니다.

구조체는 "이 값들도 필요하고, 저 값들도 필요하다"는 상황에 씁니다. 학생 정보처럼 학번, 이름, 점수를 모두 동시에 가져야 한다면 구조체가 맞습니다.

공용체는 "이 값들 중 하나만 필요하다"는 상황에 씁니다. 어떤 데이터가 정수일 수도 있고 실수일 수도 있고 문자열일 수도 있지만, 한 순간에는 그중 하나의 의미만 가진다면 공용체를 고려할 수 있습니다.

다음처럼 비교할 수 있습니다.

구분구조체 struct공용체 union
메모리 사용멤버마다 공간을 가짐모든 멤버가 같은 공간을 공유
값 보관여러 값을 동시에 보관보통 한 번에 하나의 값만 의미 있게 보관
크기멤버 크기의 합과 패딩에 영향가장 큰 멤버 크기와 정렬에 영향
대표 용도학생, 좌표, 상품처럼 여러 속성을 묶기여러 타입 중 하나를 담는 값 표현

한 문장으로 정리하면 이렇습니다.

구조체는 여러 값을 함께 담는 상자이고, 공용체는 하나의 상자를 여러 이름으로 해석하는 방법입니다.

공용체만 쓰면 위험하다

공용체의 가장 큰 문제는 "지금 어떤 멤버가 유효한지" 공용체 자체만 보고는 알 수 없다는 점입니다.

union Value {
    int i;
    double d;
};

union Value v;가 있을 때, v.i가 의미 있는지 v.d가 의미 있는지 알 수 없습니다. 프로그래머가 기억해야 합니다. 하지만 사람은 실수합니다. 프로그램이 커질수록 "지금 이 공용체 안에는 int가 들어 있던가, double이 들어 있던가?"를 추적하기 어려워집니다.

그래서 실무에서는 공용체를 단독으로 쓰기보다, enum과 함께 사용하는 경우가 많습니다. enum으로 현재 저장된 값의 종류를 표시하고, union으로 실제 값을 저장하는 방식입니다.

이런 구조를 흔히 태그가 붙은 공용체라고 부릅니다. 여기서 태그는 "현재 값이 어떤 종류인지 알려 주는 표시"입니다.

#include <stdio.h>
#include <string.h>

typedef enum {
    VALUE_INT,
    VALUE_DOUBLE,
    VALUE_TEXT
} ValueType;

typedef struct {
    ValueType type;

    union {
        int i;
        double d;
        char text[32];
    } data;
} Value;

void print_value(Value v) {
    switch (v.type) {
    case VALUE_INT:
        printf("정수: %dn", v.data.i);
        break;
    case VALUE_DOUBLE:
        printf("실수: %.2fn", v.data.d);
        break;
    case VALUE_TEXT:
        printf("문자열: %sn", v.data.text);
        break;
    }
}

int main(void) {
    Value a;
    a.type = VALUE_INT;
    a.data.i = 42;

    Value b;
    b.type = VALUE_DOUBLE;
    b.data.d = 3.14;

    Value c;
    c.type = VALUE_TEXT;
    strcpy(c.data.text, "hello");

    print_value(a);
    print_value(b);
    print_value(c);

    return 0;
}

이 예제에서 Value 구조체는 두 부분으로 나뉩니다. type은 현재 값의 종류를 알려 주고, data는 실제 값을 저장합니다.

type이 VALUE_INT이면 data.i를 읽습니다. type이 VALUE_DOUBLE이면 data.d를 읽습니다. type이 VALUE_TEXT이면 data.text를 읽습니다.

이처럼 enum과 union은 함께 쓸 때 더 강력해집니다. enum은 "무엇이 들어 있는가"를 말해 주고, union은 "그 값을 어디에 담을 것인가"를 담당합니다.

작은 계산기 결과 표현하기

조금 더 실제적인 예제를 만들어 보겠습니다. 어떤 계산 함수는 성공하면 계산 결과를 반환해야 하고, 실패하면 오류 정보를 반환해야 한다고 해 봅시다.

단순한 방법은 int 하나로 모두 표현하는 것입니다.

int result = divide(10, 0);

하지만 이 경우 문제가 생깁니다. 함수가 0을 반환했을 때 이것이 정상적인 계산 결과 0인지, 오류를 의미하는 값인지 헷갈릴 수 있습니다. 그래서 결과 상태와 실제 값을 분리하면 좋습니다.

#include <stdio.h>

typedef enum {
    CALC_OK,
    CALC_DIVIDE_BY_ZERO
} CalcStatus;

typedef struct {
    CalcStatus status;

    union {
        int value;
        char message[64];
    } data;
} CalcResult;

CalcResult divide(int a, int b) {
    CalcResult result;

    if (b == 0) {
        result.status = CALC_DIVIDE_BY_ZERO;
        snprintf(result.data.message, sizeof(result.data.message),
                 "0으로 나눌 수 없습니다.");
        return result;
    }

    result.status = CALC_OK;
    result.data.value = a / b;
    return result;
}

int main(void) {
    CalcResult result = divide(10, 0);

    if (result.status == CALC_OK) {
        printf("계산 결과: %dn", result.data.value);
    } else if (result.status == CALC_DIVIDE_BY_ZERO) {
        printf("오류: %sn", result.data.message);
    }

    return 0;
}

이 구조는 아주 중요한 생각법을 보여 줍니다. 함수의 결과는 단순히 값 하나가 아닐 수 있습니다. "성공했는가?", "실패했다면 왜 실패했는가?", "성공했다면 결과 값은 무엇인가?"를 함께 표현해야 할 때가 있습니다.

여기서 struct는 상태와 데이터를 함께 묶는 역할을 합니다. enum은 상태를 이름으로 표현합니다. union은 성공했을 때의 값과 실패했을 때의 메시지 중 하나를 담습니다.

물론 이 예제에서 문자열을 공용체에 넣는 방식이 항상 최선은 아닙니다. 오류 메시지를 포인터로 관리하거나, 오류 코드를 따로 두는 방식도 가능합니다. 지금 중요한 것은 enum, struct, union이 각자 어떤 역할을 나누어 맡을 수 있는지 감각을 얻는 것입니다.

상태 머신의 첫걸음

enum이 특히 자주 등장하는 곳이 상태 머신입니다. 상태 머신은 이름이 거창하지만, 의미는 단순합니다. 프로그램이 가질 수 있는 상태들을 정하고, 입력이나 사건에 따라 상태가 바뀌도록 만드는 방식입니다.

예를 들어 간단한 문 잠금 장치를 생각해 봅시다. 문은 잠겨 있거나 열려 있거나 경고 상태일 수 있습니다.

#include <stdio.h>

typedef enum {
    DOOR_LOCKED,
    DOOR_UNLOCKED,
    DOOR_ALARM
} DoorState;

DoorState enter_password(DoorState state, int password) {
    if (state == DOOR_ALARM) {
        return DOOR_ALARM;
    }

    if (password == 1234) {
        return DOOR_UNLOCKED;
    }

    return DOOR_ALARM;
}

void print_door_state(DoorState state) {
    switch (state) {
    case DOOR_LOCKED:
        printf("문이 잠겨 있습니다.n");
        break;
    case DOOR_UNLOCKED:
        printf("문이 열렸습니다.n");
        break;
    case DOOR_ALARM:
        printf("경고! 잘못된 비밀번호입니다.n");
        break;
    }
}

int main(void) {
    DoorState state = DOOR_LOCKED;

    print_door_state(state);

    state = enter_password(state, 1111);
    print_door_state(state);

    state = enter_password(state, 1234);
    print_door_state(state);

    return 0;
}

이 프로그램은 작지만, 상태 머신의 핵심을 가지고 있습니다. 현재 상태가 있고, 입력이 들어오면 다음 상태가 결정됩니다. 게임, UI 화면, 통신 프로토콜, 임베디드 장치 제어 프로그램에서 이런 패턴은 매우 자주 사용됩니다.

숫자 0, 1, 2로 상태를 표현했다면 코드의 의미가 흐려졌을 것입니다. 하지만 DOOR_LOCKED, DOOR_UNLOCKED, DOOR_ALARM이라고 쓰면 상태의 의미가 선명해집니다.

옵션 값과 플래그에도 enum을 쓸 수 있다

enum은 상태뿐 아니라 선택 가능한 옵션을 표현할 때도 좋습니다.

typedef enum {
    SORT_BY_NAME,
    SORT_BY_SCORE,
    SORT_BY_ID
} SortOption;

학생 목록을 이름순으로 정렬할지, 점수순으로 정렬할지, 학번순으로 정렬할지를 표현할 수 있습니다.

void sort_students(SortOption option) {
    switch (option) {
    case SORT_BY_NAME:
        printf("이름순으로 정렬합니다.n");
        break;
    case SORT_BY_SCORE:
        printf("점수순으로 정렬합니다.n");
        break;
    case SORT_BY_ID:
        printf("학번순으로 정렬합니다.n");
        break;
    }
}

여기서도 핵심은 같습니다. sort_students(1);보다 sort_students(SORT_BY_SCORE);가 훨씬 읽기 쉽습니다.

다만 여러 옵션을 동시에 조합해야 한다면 enum 값에 비트 값을 지정하는 방식도 있습니다.

typedef enum {
    PERMISSION_READ  = 1 << 0,
    PERMISSION_WRITE = 1 << 1,
    PERMISSION_EXEC  = 1 << 2
} Permission;

이렇게 하면 비트 연산자를 이용해 읽기 권한과 쓰기 권한을 함께 표현할 수 있습니다.

int permission = PERMISSION_READ | PERMISSION_WRITE;

if (permission & PERMISSION_READ) {
    printf("읽기 가능n");
}

if (permission & PERMISSION_WRITE) {
    printf("쓰기 가능n");
}

비트 연산자는 앞에서 기초를 살펴보았습니다. 여기서는 깊게 들어가지 않아도 됩니다. 중요한 것은 enum이 단순한 순서 값뿐 아니라, 의미 있는 상수 이름을 제공하는 도구로도 활용된다는 점입니다.

union을 사용할 때 조심할 점

공용체는 강력하지만, 초보자에게 자주 필요한 문법은 아닙니다. 구조체나 배열, 포인터만으로도 대부분의 입문 프로그램을 만들 수 있습니다. 하지만 공용체를 이해하면 C가 메모리를 얼마나 직접적으로 다루는 언어인지 한층 더 깊게 느낄 수 있습니다.

공용체를 사용할 때는 특히 다음 점을 기억해야 합니다.

  • 여러 멤버가 같은 메모리를 공유하므로 동시에 여러 값을 보관할 수 없습니다.

  • 마지막으로 저장한 멤버와 다른 멤버를 함부로 읽으면 기대하지 않은 값이 나올 수 있습니다.

  • 현재 어떤 멤버가 유효한지 알려 주는 enum 태그를 함께 두면 안전하게 다루기 쉽습니다.

예를 들어 다음 코드는 조심해야 합니다.

union Number {
    int i;
    float f;
};

int main(void) {
    union Number n;

    n.i = 100;
    printf("%fn", n.f);  // 좋은 습관이 아님

    return 0;
}

n.i에 정수를 저장한 뒤 n.f로 읽고 있습니다. 같은 비트 배열을 float로 해석하면 어떤 값이 나올 수는 있지만, 그것이 우리가 의도한 실수 100.0이라는 뜻은 아닙니다.

정수 100을 실수 100.0f로 바꾸고 싶다면 공용체가 아니라 형 변환을 사용해야 합니다.

int i = 100;
float f = (float)i;

공용체는 값을 변환하는 도구가 아닙니다. 같은 메모리 공간을 서로 다른 멤버가 공유하도록 만드는 도구입니다. 이 차이를 분명히 이해해야 합니다.

enum과 union이 만나는 지점

지금까지 배운 내용을 다시 연결해 봅시다.

enum은 값의 종류나 상태에 이름을 붙입니다.

typedef enum {
    TOKEN_NUMBER,
    TOKEN_PLUS,
    TOKEN_MINUS
} TokenType;

union은 여러 형태 중 하나의 데이터를 담습니다.

union TokenData {
    int number;
    char operator;
};

struct는 이 둘을 하나로 묶습니다.

typedef struct {
    TokenType type;

    union {
        int number;
        char operator;
    } data;
} Token;

이 구조는 간단한 계산식 분석기, 설정 파일 파서, 명령 처리기 같은 곳에서 자연스럽게 확장될 수 있습니다. 예를 들어 123 + 45라는 문자열을 분석한다고 생각해 봅시다. 123은 숫자 토큰이고, +는 연산자 토큰입니다. 숫자 토큰은 정수 값을 가져야 하고, 연산자 토큰은 문자 값을 가지면 됩니다.

이때 모든 토큰이 항상 정수와 문자를 동시에 가질 필요는 없습니다. 숫자 토큰이면 정수만 의미 있고, 연산자 토큰이면 문자만 의미 있습니다. 바로 이런 경우에 enum과 union의 조합이 잘 어울립니다.

#include <stdio.h>

typedef enum {
    TOKEN_NUMBER,
    TOKEN_OPERATOR
} TokenType;

typedef struct {
    TokenType type;

    union {
        int number;
        char operator;
    } data;
} Token;

void print_token(Token token) {
    if (token.type == TOKEN_NUMBER) {
        printf("숫자 토큰: %dn", token.data.number);
    } else if (token.type == TOKEN_OPERATOR) {
        printf("연산자 토큰: %cn", token.data.operator);
    }
}

int main(void) {
    Token t1;
    t1.type = TOKEN_NUMBER;
    t1.data.number = 123;

    Token t2;
    t2.type = TOKEN_OPERATOR;
    t2.data.operator = '+';

    print_token(t1);
    print_token(t2);

    return 0;
}

이 예제는 작지만 중요한 설계 감각을 담고 있습니다. 데이터는 단순히 변수 몇 개를 나열한다고 끝나는 것이 아닙니다. 어떤 값들이 함께 존재해야 하는지, 어떤 값들은 서로 배타적인지, 현재 값의 의미를 어떻게 표시할지를 생각해야 합니다.

C언어는 이런 결정을 프로그래머에게 직접 맡깁니다. 그래서 어렵지만, 그만큼 컴퓨터의 작동 방식과 데이터 표현 방식을 가까이서 배울 수 있습니다.

실무에서 enum이 자주 쓰이는 순간

공용체는 상황을 가려서 사용하지만, enum은 훨씬 자주 만납니다. 특히 C로 작성된 라이브러리나 시스템 코드에서는 이름 있는 정수 상수가 매우 중요합니다.

enum이 자주 쓰이는 대표적인 상황은 다음과 같습니다.

  • 프로그램의 상태 표현: 대기, 실행, 일시 정지, 종료, 오류 같은 상태를 이름으로 관리합니다.

  • 함수의 결과 코드 표현: 성공, 잘못된 입력, 파일 없음, 메모리 부족 같은 결과를 구분합니다.

  • 제한된 옵션 표현: 정렬 기준, 출력 형식, 동작 모드처럼 정해진 선택지를 나타냅니다.

  • 상태 머신 구현: 현재 상태와 입력에 따라 다음 상태를 결정하는 프로그램 구조를 만듭니다.

  • 비트 플래그 이름 정의: 읽기, 쓰기, 실행 권한처럼 조합 가능한 옵션에 이름을 붙입니다.

처음 C를 배울 때는 enum 없이도 프로그램을 만들 수 있습니다. 그러나 코드가 길어지고 조건이 많아지면, 숫자만으로 의미를 표현하는 방식은 금세 한계에 부딪힙니다.

0은 실패일까요, 성공일까요? 메뉴 종료일까요, 첫 번째 항목일까요? 상태 없음일까요, 초기 상태일까요?

이 질문에 답하려면 이름이 필요합니다. enum은 바로 그 이름을 제공합니다.

작은 실습: 주문 상태 관리하기

마지막으로 주문 상태를 관리하는 작은 예제를 만들어 보겠습니다. 주문은 접수, 결제 완료, 배송 중, 배송 완료, 취소 상태를 가질 수 있습니다.

#include <stdio.h>

typedef enum {
    ORDER_RECEIVED,
    ORDER_PAID,
    ORDER_SHIPPING,
    ORDER_DELIVERED,
    ORDER_CANCELED
} OrderState;

void print_order_state(OrderState state) {
    switch (state) {
    case ORDER_RECEIVED:
        printf("주문이 접수되었습니다.n");
        break;
    case ORDER_PAID:
        printf("결제가 완료되었습니다.n");
        break;
    case ORDER_SHIPPING:
        printf("배송 중입니다.n");
        break;
    case ORDER_DELIVERED:
        printf("배송이 완료되었습니다.n");
        break;
    case ORDER_CANCELED:
        printf("주문이 취소되었습니다.n");
        break;
    default:
        printf("알 수 없는 주문 상태입니다.n");
        break;
    }
}

OrderState next_state(OrderState state) {
    switch (state) {
    case ORDER_RECEIVED:
        return ORDER_PAID;
    case ORDER_PAID:
        return ORDER_SHIPPING;
    case ORDER_SHIPPING:
        return ORDER_DELIVERED;
    case ORDER_DELIVERED:
    case ORDER_CANCELED:
        return state;
    default:
        return ORDER_CANCELED;
    }
}

int main(void) {
    OrderState state = ORDER_RECEIVED;

    print_order_state(state);

    state = next_state(state);
    print_order_state(state);

    state = next_state(state);
    print_order_state(state);

    state = next_state(state);
    print_order_state(state);

    return 0;
}

이 예제는 enum만 사용했지만, 상태 관리의 기본 구조를 잘 보여 줍니다. 현재 상태를 출력하고, 다음 상태로 이동합니다. 프로그램이 커지면 주문 번호, 고객 이름, 상품 목록 같은 정보는 구조체로 묶을 수 있습니다.

typedef struct {
    int id;
    char customer[32];
    OrderState state;
} Order;

이제 주문 하나는 단순한 숫자가 아니라 의미 있는 데이터가 됩니다. 구조체로 관련 정보를 묶고, enum으로 상태를 표현합니다. 여기에 필요한 경우 공용체를 더해 "주문 종류에 따라 서로 다른 추가 정보"를 저장할 수도 있습니다.

이것이 C에서 데이터를 설계하는 방식입니다. 값 하나를 선언하는 것에서 출발해, 여러 값을 구조체로 묶고, 가능한 상태를 열거형으로 이름 붙이고, 필요하다면 하나의 공간을 공용체로 나누어 씁니다.

마무리

이번 장에서는 enum과 union을 배웠습니다. 둘 다 단순히 문법만 보면 작아 보이지만, C 프로그램의 표현력을 크게 넓혀 주는 도구입니다.

enum은 숫자에 이름을 붙입니다. 그래서 0, 1, 2 같은 값이 아니라 STATE_IDLE, STATE_RUNNING, STATE_ERROR처럼 의미 있는 이름으로 상태를 표현할 수 있습니다. 메뉴, 오류 코드, 요일, 옵션, 상태 머신처럼 선택지가 정해진 상황에서 특히 유용합니다.

union은 여러 멤버가 같은 메모리 공간을 공유하게 합니다. 구조체가 여러 값을 동시에 보관하는 상자라면, 공용체는 하나의 공간을 여러 타입으로 해석할 수 있는 상자입니다. 그래서 공용체를 사용할 때는 지금 어떤 멤버가 유효한지 반드시 관리해야 합니다.

가장 실용적인 조합은 enum과 union을 구조체 안에서 함께 쓰는 방식입니다. enum이 현재 값의 종류를 알려 주고, union이 실제 데이터를 담습니다. 이 패턴을 이해하면 C에서 복잡한 데이터를 더 유연하게 표현할 수 있습니다.

다음 장에서는 프로그램 밖의 세계와 만납니다. 지금까지 변수와 메모리 안에서 다루던 값들을 파일에 저장하고, 다시 읽어 오는 방법을 배웁니다. 프로그램이 종료되어도 데이터가 남으려면 파일 입출력을 알아야 합니다. 이제 C 프로그램은 잠깐 실행되고 사라지는 계산기가 아니라, 데이터를 기록하고 불러오는 도구로 확장됩니다.

책 목록

처음 만나는 C언어: 메모리와 포인터로 배우는 컴퓨터의 원리

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기