플래그 데이터와 이진 연산

개요

얼마 전 Justin Yoo님의 논리 연산자와 이진 연산자의 차이에 대한 블로그 포스트가 그 분의 의도와는 다르게 페이스북 ‘생활코딩’ 그룹에 콜로세움(?)을 세운 사건이 있었습니다. 저는 포함되지 않아서 아쉬운 내용이 조금 있었지만 많은 분들에게 도움이 될만한 글이며 크게 문제가 될 부분은 없다고 생각했는데 다른 의견을 가진 분들이 많이 있었습니다. 급기야 C#의 논리 연산자와 이진 연산자 내부 동작이 C/C++과 같지 않다는 오해까지 번져나갔습니다. C/C++에만 익숙한 분들이 겉모습이 비슷하긴 하지만 C#에서는 논리 연산자는 오직 System.Boolean 값 사이에만 사용 가능하며(연산자 사용자 정의는 논외로 하겠습니다. 일이 너무 커져요…) 조건식 결과 역시 System.Boolean 형식만 가능함을 알지 못했기 때문입니다. 마찬가지로 C#만 경험한 분들은 C/C++에서 if 구문과 조건부 삼항 연산자(?:)에 다양한 형식의 식이 사용된다는 점을 몰랐겠죠.

그리고 2주 쯤 전에 회사에서 코드 리뷰를 할 때 동료 사원 한 분이 플래그 열거형의 필드 값을 10진수가 아닌 16진수를 사용해 정의한 이유를 물어보셨는데 당시 시간 여건 상(점심시간이 다가오고 있었어요!) 충분한 설명을 해 드리지 못했습니다. 그래서 해당 내용과 Justin Yoo님의 포스트에 포함되지 않은 내용을 함께 정리해 봅니다. 언어는 C#을 기준으로 진행하지만 개념적인 부분은 다른 프로그래밍 언어에도 그대로 적용됩니다.

C#의 논리 연산자와 이진 연산자 내부 동작이 궁금하면 C++로 작성된 CLI 소스 코드를 확인하는 것을 권합니다.

이 글을 먼저 읽고 Justin Yoo님의 포스트를 읽으시면 더 이해가 쉬울 거라 생각됩니다.

1비트(bit) 데이터

Flags를 말 그대로 풀이하면 여러 개의 깃발입니다. 프로그래밍에서 깃발은 올려진 상태 또는 내려진 상태를 나타내는 데이터를 의미합니다. 다시 말해 0 또는 1, 참 또는 거짓, 예 또는 아니오 등의 상태를 표현하기 위해 사용됩니다. 서로 다른 두 개의 상태를 표현하기 위해 컴퓨터는 0 또는 1을 나타내는 한 개의 비트를 사용합니다. 일반적으로 컴퓨터가 데이터를 처리하는 최소 단위는 8개의 비트로 이루어진 바이트(byte)입니다. 그리고 매우 자주 사용되는 자료형인 System.Int32의 크기는 4바이트, 즉 32비트입니다. 32비트 공간에는 1개의 32비트 크기의(도메인에 정의되는) 정보를 저장할 수 있습니다. 16비트 크기의 정보는 2개를, 8비트 크기의 정보는 4개를 저장할 수 있지요. 마찬가지로 1비트 크기의 정보는 32개를 저장할 수 있습니다.

16 x  2 ++++ ++++  ++++ ++++  ---- ----  ---- ----
 8 x  4 ++++ ++++  ---- ----  ++++ ++++  ---- ----
 1 x 32 +-+- +-+-  +-+- +-+-  +-+- +-+-  +-+- +-+-

16진수

정수 데이터의 각 비트 값을 표현하는 데에는 10진수보다는 16진수가 더 다루기 쉽습니다. 이유를 설명하기 전에 16비트 데이터의 모든 비트 값을 10진수와 16진수로 나타낸 표를 먼저 확인하겠습니다.

                        Dec     Hex
------------------- ------- -------
0000 0000 0000 0001       1  0x0001
0000 0000 0000 0010       2  0x0002
0000 0000 0000 0100       4  0x0004
0000 0000 0000 1000       8  0x0008

0000 0000 0001 0000      16  0x0010
0000 0000 0010 0000      32  0x0020
0000 0000 0100 0000      64  0x0040
0000 0000 1000 0000     128  0x0080

0000 0001 0000 0000     256  0x0100
0000 0010 0000 0000     512  0x0200
0000 0100 0000 0000    1024  0x0400
0000 1000 0000 0000    2048  0x0800

0001 0000 0000 0000    4096  0x1000
0010 0000 0000 0000    8198  0x2000
0100 0000 0000 0000   16384  0x4000
1000 0000 0000 0000   32768  0x8000

10진수의 경우에는 별다른 패턴이 없지만 16진수는 4개 비트 마다 1, 2, 4, 8이 자릿수가 변경되며 반복되는 것을 볼 수 있습니다. 8진수를 사용해도 1, 2, 4가 반복되지만 3개 비트 단위로 반복되는 것보다 4개 단위로 반복되는 것이 8비트, 16비트, 32비트, 64비트 데이터를 표현하기에 좀 더 편리합니다. 그래서 4비트가 넘어가는 플래그 데이터를 정의할 때 16진수가 주로 사용됩니다.

이진 연산

플래그 데이터를 다룰 때에는 산술 연산이 아닌 이진 연산을 사용합니다. 이진 연산을 이용해 플래그를 조합하거나 제거하고 데이터에 특정 플래그가 포함되어 있는지 검사합니다.

이진 OR 연산

플래그 데이터를 조합하는 데에 이진 | 연산자를 사용합니다. 이진 OR 연산은 두 개의 데이터에 대해 각 비트 별로 논리합 연산을 수행합니다. 두 비트 중 하나 이상의 값이 1이면 결과 비트는 1이고 두 비트 모두 0이면 결과 비트는 0입니다. 이진 OR 연산을 사용해 어떻게 플래그 데이터를 조합하는지 살펴봅니다.

var a = 1;     // 1(0001)
var b = 2;     // 2(0010)

var c = a | b; // 3(0011)
var d = a + b; // 3(0011)

var e = a | c; // 3(0011)
var f = a + c; // 4(0100)

위 코드를 보면 ab는 중복되는 비트가 없는데 이런 경우는 | 연산 결과는 + 연산 결과와 같습니다. 그래서 cd는 값이 같습니다. 하지만 중복되는 비트가 있으면 두 연산 결과는 같지 않습니다. e의 경우 a가 가진 비트 값과 c가 가진 비트 값이 모두 유지되어 있지만 f는 두 개의 비트가 사라지고 하나의 비트가 생겼났습니다.

이진 AND 연산

데이터에 특정 플래그가 포함되어 있는지 검사하는 데에 & 연산자를 사용합니다. 이진 AND 연산은 두 개의 데이터를 각 비트 별로 논리곱 연산을 수행합니다. 두 비트 모두 1이면 결과 비트는 1이고 둘 중 하나 이상의 비트가 0이면 결과 비트는 0입니다. 이진 AND 연산을 사용해 플래그 포함 여부를 검사하는 방법을 살펴봅니다.

var a = 1;                // 1(0001)
var b = 2;                // 2(0010)
var c = 4;                // 4(0100)

var d = a | c;            // 5(0101)

var e = d & a;            // 1(0001)
var f = d & b;            // 0(0000)

bool hasA = (d & a) == a; // True
bool hasB = (d & b) == b; // False

a, b, c는 서로 다른 비트 값을 가지는 플래그들이고 dac의 조합입니다. 이 때 d가 특정 플래그를 포함하는지 여부를 알아보려면 대상 플래그와 논리곱 연산을 수행한 결과가 해당 플래그와 같은지 검사하면 됩니다.

이진 배타적 OR 연산

이진 배타적 OR 연산을 사용하면 데이터에서 특정 플래그를 제거할 수 있습니다. 이진 배타적 OR 연산은 두 개의 데이터에 대해 각 비트 별로 배타적 논리합 연산을 수행합니다. 두 비트의 값이 다르면 결과 비트는 1이고 두 비트의 값이 같으면 결과 비트는 0입니다. 아래 코드는 이진 배타적 OR 연산을 사용해 데이터에서 특정 플래그를 제거하는 방법을 보여줍니다.

var a = 1;           // 1(0001)
var b = 2;           // 2(0010)
var c = 4;           // 4(0100)

var d = a | b | c;   // 7(0111)

var e = (d ^ b) & d; // 5(0101)

da, b, c 플래그가 조합된 값입니다. db의 이진 배타적 OR 연산을 수행한 결과와 d의 논리곱 연산을 수행하면 d에서 b 플래그를 제거한 값을 얻을 수 있습니다. 이때 주의할 것은 마지막 논리곱 연산을 빠뜨리면 안된다는 점입니다. 위 코드의 경우는 b가 가진 모든 비트를 d가 포함하고 있기 때문에 논리곱 연산 전후 값이 같지만 만약 d가 가지지 않은 비트를 가진 데이터(예를 들어 1010 비트를 가진 데이터)의 플래그들을 d에서 제거하려할 경우 논리곱 연산을 생략하면 잘못된 값을 얻게됩니다.

이진 보수 연산

이진 보수 연산을 사용해도 데이터에서 특정 플래그를 제거할 수 있습니다. 이진 보수 연산은 앞에 설명된 연산자들과는 달리 이항 연산이 아닌 단항 연산이며 각 비트에 대해 0은 1로, 1은 0으로 변환된 값을 반환합니다.

var a = 1;         // 1(0001)
var b = 2;         // 2(0010)
var c = 4;         // 4(0100)

var d = a | b | c; // 7(0111)

var e = (d & ~b);  // 5(0101)

이진 배타적 OR 연산의 예제와 동일한 작업을 하는 코드입니다. 제거하려는 플래그를 가진 데이터의 이진 보수 연산 결과와 논리곱 연산을 수행하면 원본 데이터에서 특정 플래그를 삭제할 수 있습니다. 이진 배타적 OR 연산을 사용한 플래그 제거와 결과는 동일하지만 성능은 조금 더 높습니다.

System.FlagsAttribute

.NET Framework 코드에서 플래그를 정의할 때 System.FlagsAttribute 특성을 가진 열거형(enum)을 사용합니다. System.FlagsAttribute 특성은 열거형의 멤버가 플래그 데이터 또는 플래그 데이터의 조합을 나타냄을 의미합니다. 이런 경우 하나의 변수에 여러 개의 이진 데이터를 담기 때문에 열거형의 이름은 주로 복수형 명사가 됩니다.

다음은 추적 출력 대상을 지정하는 System.Diagnostics.TraceOptions 열거형 정의입니다.

[Flags]
public enum TraceOptions {
    None                  =    0,
    LogicalOperationStack = 0x01,
    DateTime              = 0x02,
    Timestamp             = 0x04,
    ProcessId             = 0x08,
    ThreadId              = 0x10,
    Callstack             = 0x20,
}

System.FlagsAttribute 특성을 가진 열거형 멤버의 값이 반드시 하나의 비트 플래그만 가질 필요는 없습니다. 예를 들어 All = 0x3F와 같은 멤버를 추가로 정의할 수 있습니다. 또 이런 여러 플래그를 포함하는 멤버를 정의할 때 이미 정의된 멤버를 조합하는 것도 가능합니다.

[Flags]
public enum TraceOptions {
    ...
    All = LogicalOperationStack | DateTime | Timestamp |
          ProcessId | ThreadId | Callstack
}

이 열거형에 대해 플래그 조합하고, 포함 여부를 검사하고, 그리고 특정 플래그를 제거하는 예제입니다.

TraceOptions options = TraceOptions.DateTime;

Console.WriteLine(options); // DateTime

// Add ProcessId and Callstack
options |= (TraceOptions.ProcessId | TraceOptions.Callstack);

Console.WriteLine(options); // DateTime, ProcessId, Callstack

// Remove DateTime
options &= ~TraceOptions.DateTime;

Console.WriteLine(options); // ProcessId, Callstack

Console.WriteLine((options & TraceOptions.DateTime) == TraceOptions.DateTime); // False
Console.WriteLine(options.HasFlag(TraceOptions.ProcessId)); // True

위 코드의 마지막 줄에 사용된 HasFlag() 메서드는  .NET Framework 4.0에 등장한 메서드입니다. 논리곱 연산자를 사용한 플래그 검사와 비교할 때 성능이 낮은 반면 코드를 좀 더 직관적이고 간편하게 작성하도록 도와줍니다. 아래는 HasFlag() 메서드의 내부 구현입니다.

[System.Security.SecuritySafeCritical]
public Boolean HasFlag(Enum flag) {
    if (flag == null)
        throw new ArgumentNullException("flag");
    Contract.EndContractBlock();

    if (!this.GetType().IsEquivalentTo(flag.GetType())) {
        throw new ArgumentException(Environment.GetResourceString("Argument_EnumTypeDoesNotMatch", flag.GetType(), this.GetType()));
    }

    return InternalHasFlag(flag);
}

[System.Security.SecurityCritical]
[ResourceExposure(ResourceScope.None)]
[MethodImplAttribute(MethodImplOptions.InternalCall)]
private extern bool InternalHasFlag(Enum flags);

저는 플래그 검사가 짧은 시간에 반복적으로 아주 많이 수행되지 않는다면 HasFlag() 메서드를 사용해 가독성 높은 코드를 작성하는 편을 선호합니다.

결론

이진 연산을 이해하고 플래그 데이터를 적절히 사용하면 메모리 효율을 높이고 메서드 매개 변수를 줄여주는 등 간결한 코드 작성에 도움을 줍니다. 지금보다 메모리가 많이 귀하던(물론 지금도 귀하죠!) 시절에는 하나의 비트로 표현될 수 있는 여러 개의 데이터를 각각 정수형 변수에 저장하는 것은 더더욱 피해야 할 과소비였습니다.

Advertisements

플래그 데이터와 이진 연산”에 대한 2개의 생각

  1. 핑백: 비트 플래그 및 이진연산 | gongsick

답글 남기기

아래 항목을 채우거나 오른쪽 아이콘 중 하나를 클릭하여 로그 인 하세요:

WordPress.com 로고

WordPress.com의 계정을 사용하여 댓글을 남깁니다. 로그아웃 / 변경 )

Twitter 사진

Twitter의 계정을 사용하여 댓글을 남깁니다. 로그아웃 / 변경 )

Facebook 사진

Facebook의 계정을 사용하여 댓글을 남깁니다. 로그아웃 / 변경 )

Google+ photo

Google+의 계정을 사용하여 댓글을 남깁니다. 로그아웃 / 변경 )

%s에 연결하는 중