본문 바로가기

설계/철학

Bit-mask를 활용한 enum(열거형) 정리

 

Enum 은 열거형이라고도 불리는 자료 형식의 일종이다1. 열거형의 사용은 크게 두 가지로 분류되는 데, 기본 형식 사용각주1과 flag 형식으로 나누어진다. 플래그 형식은 열거형에 지정된 형에 맞춰 비트 수의 제곱만큼 조합이 이루어질 수 있다각주2. 이것을 사용하여 데이터의 최적화와 여러 개의 상태를 하나의 필드로 저장할 수 있다. 일반적인 사용은 앞서 이야기한 여러 가지의 상태의 조합을 표현하고자 할 때 주로 사용되는데, 네트워크와 같이 비트 하나까지도 제어(하고 싶어) 하고, 전송되는 바이트가 중요한 경우에도 유용하게 사용된다. 

... 그렇다 비트 하나에도 목숨을 거는 이상한 사람들이 좋아한다.

이들에게 어떤 점이 비트로 빠져들게 했는가? 비트의 어떤 점이 좋은 것일까? 32비트 정수형에서는 32개의 boolean 이 존재하는 것과 같다고 생각하면 된다. bool형이 몇 바이트인지 아는가? 4비트로 4개의 on/off를 표현할 수 있는 데, 왜 7개의 비트를 낭비하는지 궁금하지 않았는가?

 

이것에 대한 답은 간단하다. CPU는 비트를 세지 못한다2. 자세히 이야기하면, 바이트보다 작은 단위에 대해 주소를 가져올 수 없으니 최소 단위가 바이트가 되는 것이다. 현대에 이르러서는 비트도 셀 수 있게 되었지만, 폭넓은 하드웨어 지원을 위해 bool 은 1바이트 크기를 갖게 되었다고 보는 것이 타당하다. 

그렇다고 bool을 사용하는 것은 프로그램의 낭비라고 생각할 수 있을까? 그것은 알 수 없다. 프로그램은 언제나 성능과 효율, 최고의 성능이냐 최고의 효율이냐 하는 외나무 징검다리를 놓고 시소를 타고 있는 상태이기 때문에 어느 것이 맞고 틀리고를 정의할 수 없다. bool을 사용하는 것은 가독성만 높이는 것뿐만이 아니라, 메모리 정렬이라는 큰 틀에서는 최적화의 의미도 가질 수 있기 때문이다. 비트를 항상 최대로 활용하는 것이 좋을까? 효율은 높아질 순 있어도 정렬 문제와 가독성 문제를 앉고 가야 할 것이다.

이 글에서는 비트를 활용하여 최고의 효율을 얻는 방법에 대해 이야기하고자 한다. 아래 예시로 4방면을 가진 2차원 사각형이 있다고 가정해 본다. 이때 한 면에 대해서 다음과 같이 정의한다.

 

public enum CardLineShape
{
    None = 0,  // 아무 상태도 아님

    Lined = 1, // 직선
    In = 2,    // 안으로 굽은 곡선
    Peek = 3,  // 바깥쪽으로 굽은 곡선
}

 

여기서 만약, 4방면을 전부 표현하고 싶으면 어떻게 해야 할까? 이때 플래그를 사용할 수 있다.

 

[Flags]
public enum CardShape
{
    None = 0,

    LeftLined = 0b0001,
    LeftIn    = 0b0010,
    LeftPeek  = 0b0100,

    TopLined = 0b0001_0000,
    TopIn    = 0b0010_0000,
    TopPeek  = 0b0100_0000,

    RightLined = 0b0001_0000_0000,
    RightIn    = 0b0010_0000_0000,
    RightPeek  = 0b0100_0000_0000,

    BottomLined = 0b0001_0000_0000_0000,
    BottomIn    = 0b0010_0000_0000_0000,
    BottomPeek  = 0b0100_0000_0000_0000,
}

 

32비트 플래그를 사용하면 32개의 상태값을 표시할 수 있다. 이것을 이용하여 4방면의 상태를 표현할 수 있다. 이렇게 정의해놓으면 사각형을 표현하기 위해 4개의 int 필드를 사용해서 표현하지 않아도 된다. 단순 계산으로 16bytes에서 4bytes로 줄었으니 벌서 12bytes 이득을 보았다.

 

sealed class Test
{
    // 4방면을 일일이 표현해본다...
    private CardLineShape left, right, top, bottom;

    // 한번에 표현해본다.
    private CardShape shape = CardShape.LeftLined | CardShape.RightLined | CardShape.TopLined |
        CardShape.BottomLined;
}

 

플래그에 대한 이해도가 올라가면, 많은 if 문을 줄여 성능의 이점을 챙길 수도 있다.

 

public static bool CanAcceptAt(this CardShape t, CardDirection dir)
{
    int from;
    switch (dir)
    {
        case CardDirection.Left:
            from   = ((int)t & 0b0111);
            break;
        case CardDirection.Top:
            from   = ((int)t     & 0b0111_0000)           >> 4;
            break;
        case CardDirection.Right:
            from   = ((int)t & 0b0111_0000_0000) >> 8;
            break;
        case CardDirection.Bottom:
            from   = ((int)t     & 0b0111_0000_0000_0000) >> 12;
            break;
        case CardDirection.None:
        default:
            throw new ArgumentOutOfRangeException(nameof(dir), dir, null);
    }

    return from != 1;
}

 

플래그는 기본적으로 비트를 사용하여 표현하기 때문에 bit shift/operation에 친숙해져야 한다. &는 같은 비트를 반환하고, >> (x)는 비트를 오른쪽으로 x 만큼 밀어서 반환한다는 의미이다. 예를 들면, t의 값이 LeftLined | TopLined | RightLined | BottomIn이라고 했을 때, CardDirection.Bottom 케이스의 from 값은 0010, 즉 2가 된다.

 

public static bool CanAcceptAt(this CardShape t,
                               CardDirection dir, CardShape shape)
{
    int from, target;

    switch (dir)
    {
        case CardDirection.Left:
            from   = ((int)t     & 0b0111);
            target = ((int)shape & 0b0111_0000_0000) >> 8;
            break;
        case CardDirection.Top:
            from   = ((int)t     & 0b0111_0000)           >> 4;
            target = ((int)shape & 0b0111_0000_0000_0000) >> 12;
            break;
        case CardDirection.Right:
            from   = ((int)t     & 0b0111_0000_0000) >> 8;
            target = ((int)shape & 0b0111);
            break;
        case CardDirection.Bottom:
            from   = ((int)t     & 0b0111_0000_0000_0000) >> 12;
            target = ((int)shape & 0b0111_0000)           >> 4;
            break;
        case CardDirection.None:
        default:
            throw new ArgumentOutOfRangeException(nameof(dir), dir, null);
    }

    if (from == 0) return true;
    if (from == 1 || target == 1) return false;
    return (from & target) == 0;
}

 

위 코드는 인접한 두 사각형을 비교하여 겹쳐진 방면에 대해 연산을 하는 메서드이다. 만약 겹쳐진 방면이 Lined 이거나 같은 경우에는 false를 반환하도록 되어있다.
 

 

열거형의 큰 장점은 사람이 읽을 수 있도록 정수형을 문자열로 표기하여 가독성을 높이는 것이다. 그런데 보통 열거형을 사용할 때 많은 값이 필요한 경우가 생각보다 없는 것 같다. 적은 값을 사용하는각주2 경우에는 플래그로 여러 형태의 값을 표현할 수 있도록 하여 불필요한 메모리를 절약할 수 있다. 메모리 절약뿐만 아니라, 비트 시프트를 이용하면 if 문도 줄일 수 있어 최적화에도 도움이 될 수 있다. 

다만, 무분별한 플래그 사용은 값의 정의가 모호해질 수 있으며, 이는 개발자로 하여금 어렵고 복잡해지는 코드가 될 수 있다. 뭐든 적당히. 적재적소에 잘 활용한다면 개발에 있어 큰 도움이 될 것이다.

 


각주

  1. 보통 int형으로 표현되는 정수 값의 열거형
  2. 열거형은 직접 정수형을 지정할 수 있다. short, ushort 과 같이 16bits, int, uint 32bits 등 비트 수 내에서만 값을 정의할 수 있다. 값을 초과하여 정의하는 경우(ex. 4bytes int형에서 TestValue = 1 << 33, 이 경우 값은 1이 되어 1 << 0의 값과 같게 된다.) 의도하지 않은 값이 나오게 된다.

참조

  1. https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/enum
  2. https://stackoverflow.com/questions/4626815/why-is-a-boolean-1-byte-and-not-1-bit-of-size

© 2024 - 2024 Vvidr - All Rights Reserved.