반응형

[PART2.변수와 기본 데이터 타입(3/13)] 숫자 리터럴 표기법 — 2진수·16진수·구분자 (C# 7)

코드에 박힌 숫자를 읽기 쉽게 만드는 세 가지 장치 — 2진수 접두사, 16진수 접두사, 밑줄 구분자


1. 문제 제기 — 0b_0000_0011_0000_0000 vs 768, 어느 쪽이 먼저 이해되시나요

Unity 프로젝트의 PhysicsRaycaster.cs 를 열었다가 다음 한 줄을 만납니다.

C#
int enemyLayers = 768;

이 768 은 도대체 무엇을 의미할까요. 10 진수로 봐서는 감이 오지 않습니다. 2 진수로 바꿔보면 0000_0011_0000_0000. 즉, 8 번 비트(Enemy)와 9 번 비트(Obstacle)가 켜져 있는 LayerMask 입니다. 하지만 코드를 읽는 순간에는 이 변환을 독자가 머릿속으로 수행해야 합니다.

같은 값을 아래처럼 쓰면 의도가 그대로 드러납니다.

C#
int enemyLayers = 0b_0000_0011_0000_0000;

어느 쪽이 "8번·9번 레이어" 인지 더 빨리 이해되시나요. 답은 분명합니다.

Unity 신입 개발자가 실전에서 마주치는 숫자는 크게 세 부류입니다.

  • 비트 마스크 — LayerMask, [Flags] enum, Physics 필터
  • 색상 채널Color32 의 RGBA 바이트 조합, 셰이더 상수
  • 큰 정수 — 파일 크기, 프레임당 바이트, ID 오프셋

이 세 부류는 10 진수로는 읽기 어렵고, 16 진수나 2 진수로 써야 "비트 자리" 또는 "채널별 의미" 가 보입니다. 또한 100000000 처럼 0이 많이 붙은 숫자는 자릿수를 세다가 한 자리를 놓치기 쉽습니다. 100_000_000 처럼 밑줄로 끊으면 실수 여지가 사라집니다.

C# 7 은 정확히 이 세 불편을 한꺼번에 해결하려고 2 진수 리터럴(0b), 16 진수 리터럴(0x), 숫자 구분자(_) 를 정비했습니다. 이 글은 세 장치가 어떻게 동작하는지, Unity 실전에서 언제 써야 하는지, IL(Intermediate Language — C# 컴파일러가 생성하는 중간 언어) 수준에서는 정말로 같은 값이 되는지를 확인합니다.


2. 개념 정의 — 접두사와 구분자의 세 가지 역할

2.1 리터럴이란 무엇인가

리터럴(Literal, 고정값) 은 소스 코드에 직접 박는 값입니다. int x = 255; 에서 255 가 리터럴입니다. 변수 이름이 가리키는 값이 아니라, 코드에 문자 그대로 쓰여 있는 값을 가리킵니다.

숫자 리터럴은 "사람이 읽는 표기법" 과 "값" 이라는 두 층으로 나뉩니다. 표기법은 어떻게 쓰느냐의 문제이고, 값은 컴파일 후 결과의 문제입니다. 같은 값을 여러 표기법으로 적을 수 있습니다.

세 가지 표기법 → 같은 값 (255)

세 가지 접두사(Prefix, 숫자 앞에 붙는 표식)가 어떤 진법인지를 결정합니다.

접두사 진법 예시
없음 10 진수 255 255
0x / 0X 16 진수 0xFF 255
0b / 0B 2 진수 (C# 7.0+) 0b_1111_1111 255

8 진수 리터럴은 C# 에 없습니다. C 언어는 0777 처럼 앞자리 0 을 8 진수로 해석하지만, C# 은 앞자리 0 을 무시하고 그대로 10 진수로 처리합니다. 따라서 int x = 0755; 는 755 입니다.

2.2 숫자 구분자 _

_ 는 가독성을 위한 시각적 구분자(Digit Separator) 입니다. 컴파일러는 이 밑줄을 전부 제거한 뒤 값을 계산합니다. 따라서 1_000_000, 10_00_000, 100_0000 은 전부 같은 값입니다 — 오직 읽는 사람의 편의만을 위한 장치입니다.

C#
int million       = 1_000_000;          // 자릿수 표시용
int hexColor      = 0x_FF_FF_D1_00;     // 채널 바이트 분리
int layerMask     = 0b_0000_0011_0000_0000; // 니블(4비트) 단위 분리
double pi         = 3.141_592_653_589_793;  // 실수에도 가능
_ — 숫자 구분자 (Digit Separator) 숫자 리터럴 중간에 밑줄을 삽입해 자릿수를 시각적으로 분리한다. 컴파일러가 완전히 무시하므로 값에는 전혀 영향이 없다. 여러 개를 연속해서 써도 된다(1__000).
예시: int bytes = 1_048_576; 1_048_576 은 1048576 과 동일한 값(= 1MiB)

규칙 두 가지.

  • 숫자 사이 에만 쓸 수 있습니다. 접미사 바로 앞(100_L), 소수점 직후/직전(3._14, 3_.14) 은 불허.
  • C# 7.0 까지는 접두사 바로 뒤(0b_1010) 도 불허였으나, C# 7.2 부터 허용됩니다.

2.3 리터럴 타입 결정 규칙

접두사는 "진법" 을 정하고, 접미사는 "타입" 을 정합니다.

C#
var a = 100;       // 접미사 없음 → int
var b = 100L;      // L → long
var c = 100U;      // U → uint
var d = 100UL;     // UL → ulong
var e = 1.5;       // 소수점 포함, 접미사 없음 → double
var f = 1.5f;      // f → float
var g = 1.5m;      // m → decimal

접미사가 없는 정수 리터럴 은 값을 담을 수 있는 가장 작은 타입부터 순서대로 선택됩니다 — intuintlongulong. 255int, 5_000_000_000int 범위를 넘어서므로 long 이 됩니다.

접미사가 없는 실수 리터럴 (소수점 또는 지수 표기가 포함된 값) 은 항상 double 입니다. float 로 쓰려면 반드시 f 를 붙여야 합니다 — float pi = 3.14f;. float pi = 3.14; 는 컴파일 오류입니다.

f / m — 실수 접미사 (float / decimal suffix) f 는 리터럴을 32 비트 float 로, m 은 고정소수 decimal 로 만든다. 접미사 없이 소수점만 있으면 64 비트 double. Unity 는 Transform.position 등 대부분이 float 이므로 f 를 붙이지 않으면 double → float 묵시 변환 오류가 난다.
예시: transform.position = new Vector3(0f, 1.5f, 0f); 셋 다 f — 없으면 CS0664 에러

2.4 기본 코드와 IL 확인

네 가지 표기법이 정말로 같은 값으로 컴파일되는지를 IL 로 확인합니다.

C#
public static int AsDecimal()       { return 255; }
public static int AsHex()           { return 0xFF; }
public static int AsBinary()        { return 0b_1111_1111; }
public static int AsWithSeparator() { return 2_55; }
IL
.method public hidebysig static int32 AsDecimal () cil managed
{
    IL_0001: ldc.i4 255        // 10진수 255 → 상수 255 로 컴파일
    IL_0006: stloc.0
    IL_0009: ldloc.0
    IL_000a: ret
}

.method public hidebysig static int32 AsHex () cil managed
{
    IL_0001: ldc.i4 255        // 16진수 0xFF → 상수 255 로 컴파일 (동일)
    IL_0006: stloc.0
    IL_0009: ldloc.0
    IL_000a: ret
}

.method public hidebysig static int32 AsBinary () cil managed
{
    IL_0001: ldc.i4 255        // 2진수 0b_1111_1111 → 상수 255 로 컴파일 (동일)
    IL_0006: stloc.0
    IL_0009: ldloc.0
    IL_000a: ret
}

.method public hidebysig static int32 AsWithSeparator () cil managed
{
    IL_0001: ldc.i4 255        // 구분자 포함 2_55 → 상수 255 로 컴파일 (동일)
    IL_0006: stloc.0
    IL_0009: ldloc.0
    IL_000a: ret
}

네 메서드의 IL 이 완전히 동일합니다 (ldc.i4 255). 접두사와 구분자는 "컴파일러가 이해하는 소스 형식" 일 뿐, 런타임이나 바이너리에는 흔적이 남지 않습니다. 표기법 선택은 순수하게 가독성의 문제 이지 성능 차이가 아닙니다.

용어 정리 — ldc.i4 ldc 는 "load constant"(상수 로드), .i4 는 "4 바이트 정수(int32)" 를 의미. 이 명령 하나로 주어진 정수 상수를 평가 스택에 올린다. 런타임에서 가장 가벼운 연산 중 하나이며 GC 부담 없음.

3. 내부 동작 — 컴파일러가 리터럴을 어떻게 상수로 바꾸는가

3.1 어휘 분석 → 상수 폴딩 → IL 상수 명령

리터럴이 IL 에 박히는 과정은 세 단계입니다.

리터럴 → IL 상수 명령의 3단계
  1. 어휘 분석(Lexer): 소스 텍스트를 토큰으로 쪼갠다. 이때 밑줄(_) 은 숫자 토큰 내부에서 제거되고, 0x / 0b 접두사로 진법이 태그된다.
  2. 상수 폴딩(Constant Folding): 리터럴과 상수식(Jump | Shoot, 1 << 8 등) 을 컴파일 타임에 미리 계산해 단일 값으로 접는다. 런타임에는 이 계산이 실행되지 않는다.
  3. IL 발행(Emit): 접힌 최종 값을 가장 짧은 IL 상수 명령(ldc.i4, ldc.i4.s, ldc.i4.0..8, ldc.i4.m1, ldc.i8, ldc.r4, ldc.r8 등) 으로 발행한다.

3.2 IL 상수 명령의 변형

CLR(Common Language Runtime, .NET 런타임) 은 자주 쓰이는 작은 정수에 대해 단축 명령어를 제공합니다. 컴파일러는 값에 따라 최적 명령을 고릅니다.

값 범위 사용 명령 바이트 수
0 ~ 8 ldc.i4.0 ~ ldc.i4.8 1
-1 ldc.i4.m1 1
-128 ~ 127 ldc.i4.s 2
그 외 int ldc.i4 5
long ldc.i8 9
float ldc.r4 5
double ldc.r8 9

3.3 큰 값과 접미사의 IL

각 타입이 어떤 상수 명령으로 나가는지 한번에 봅니다.

C#
public static long    LongSuffix()    { return 10_000_000_000L; }
public static ulong   UnsignedLong()  { return 0xFFFF_FFFF_FFFF_FFFFUL; }
public static float   FloatSuffix()   { return 3.141_592f; }
public static double  DoubleDefault() { return 3.141_592; }
public static decimal DecimalSuffix() { return 19_999.99m; }
IL
.method public hidebysig static int64 LongSuffix ()
{
    IL_0001: ldc.i8 10000000000   // ldc.i8 = 8바이트 정수 상수 (long 전용)
    IL_000a: stloc.0
    IL_000e: ret
}

.method public hidebysig static uint64 UnsignedLong ()
{
    IL_0001: ldc.i4.m1            // -1 을 int32 로 로드 (0xFFFFFFFF)
    IL_0002: conv.i8              // int32 → int64 로 부호 확장 (결과: 0xFFFFFFFFFFFFFFFF)
    IL_0003: stloc.0
    IL_0007: ret
}

.method public hidebysig static float32 FloatSuffix ()
{
    IL_0001: ldc.r4 3.141592      // ldc.r4 = 4바이트 실수 상수 (float)
    IL_0006: stloc.0
    IL_000a: ret
}

.method public hidebysig static float64 DoubleDefault ()
{
    IL_0001: ldc.r8 3.141592      // ldc.r8 = 8바이트 실수 상수 (double)
    IL_000a: stloc.0
    IL_000e: ret
}

.method public hidebysig static valuetype System.Decimal DecimalSuffix ()
{
    IL_0001: ldc.i4 1999999       // decimal 은 상수 명령이 없다 — 생성자를 호출한다
    IL_0006: ldc.i4.0
    IL_0007: ldc.i4.0
    IL_0008: ldc.i4.0
    IL_0009: ldc.i4.2             // scale = 2 (소수점 둘째 자리)
    IL_000a: newobj instance void System.Decimal::.ctor(int32, int32, int32, bool, uint8)
    IL_000f: stloc.0
    IL_0013: ret
}

여기서 세 가지가 드러납니다.

  1. 0xFFFF_FFFF_FFFF_FFFFULldc.i4.m1 + conv.i8 로 압축되었습니다. 8 바이트 전부 1 인 ulong 은 비트 패턴상 -1 을 64 비트로 확장한 값과 같습니다. 컴파일러는 9 바이트짜리 ldc.i8 대신 2 바이트짜리 ldc.i4.m1 + conv.i8 을 선택해 메서드 크기를 줄였습니다. 문법 차이가 아니라 순수 바이트 절약 최적화 입니다.
  2. floatdouble 은 명령 자체가 다릅니다 (ldc.r4 vs ldc.r8). 접미사 f 가 없으면 컴파일러는 ldc.r8 을 발행하므로 float 변수에 대입할 때 암묵 변환 오류가 납니다.
  3. decimal 은 CLR 의 상수 명령이 없어 생성자 호출 (newobj System.Decimal::.ctor) 로 만들어집니다. 네 개의 int32 와 한 개의 bool, 한 개의 uint8(scale) 이 생성자 인자입니다. Debug 빌드에서는 스택 할당이지만, 매 호출마다 5 개의 상수 로드가 필요해 다른 타입보다 약간 비쌉니다.

3.4 상수 폴딩이 만드는 놀라운 압축

1 << 8 | 1 << 9 처럼 "비트 시프트와 OR 연산" 이 있는 식도, 피연산자가 모두 리터럴이면 컴파일 타임에 미리 계산 됩니다.

C#
public static int EnemyMaskShift()  { return (1 << 8) | (1 << 9); }
public static int EnemyMaskBinary() { return 0b_0000_0011_0000_0000; }
IL
.method public hidebysig static int32 EnemyMaskShift ()
{
    IL_0001: ldc.i4 768        // (1 << 8) | (1 << 9) 을 컴파일 타임에 계산 → 768
    IL_0006: stloc.0
    IL_000a: ret
}

.method public hidebysig static int32 EnemyMaskBinary ()
{
    IL_0001: ldc.i4 768        // 0b_0000_0011_0000_0000 → 768 (위와 완전 동일)
    IL_0006: stloc.0
    IL_000a: ret
}

두 메서드 IL 이 한 바이트도 다르지 않습니다. 즉 Unity 핫패스에서 (1 << 8) | (1 << 9) 를 매번 실행한다고 걱정할 필요가 없습니다 — 컴파일러가 이미 768 로 접어놓았습니다. 다만 layer 변수가 리터럴이 아니라 런타임 값이면 폴딩되지 않습니다 (1 << variableLayer 는 매 호출마다 계산됩니다).


4. 실전 적용 — [Flags] enum, 비트 마스크, Color32

4.1 Before/After — [Flags] enum 값 정의

Unity 게임의 플레이어 능력 시스템을 비트 플래그로 만들어야 한다고 가정합니다. [Flags] 어트리뷰트(Attribute, 타입에 붙이는 메타데이터) 를 붙인 enum 은 여러 값을 OR 로 조합해 한 변수에 담을 수 있는데, 이때 각 값은 1, 2, 4, 8, 16 ... 처럼 2 의 거듭제곱이어야 합니다.

Before (10진수) vs After (2진수) — 같은 값, 다른 가독성

Before — 10 진수 (전통적 방식)

C#
[System.Flags]
public enum PlayerAbilities
{
    None   = 0,
    Jump   = 1,
    Sprint = 2,
    Shoot  = 4,
    Fly    = 8,
    DoubleJump = 16,  // ← 16이 맞는지 한번 계산해야 한다
    WallRun    = 32,
    // ...값이 커질수록 계산이 귀찮다
}

After — 2 진수 + 구분자 (C# 7+)

C#
[System.Flags]
public enum PlayerAbilities
{
    None       = 0b_0000_0000,
    Jump       = 0b_0000_0001,
    Sprint     = 0b_0000_0010,
    Shoot      = 0b_0000_0100,
    Fly        = 0b_0000_1000,
    DoubleJump = 0b_0001_0000,
    WallRun    = 0b_0010_0000,
}

public static PlayerAbilities CombineFlags()
{
    return PlayerAbilities.Jump | PlayerAbilities.Shoot;
}
IL
.method public hidebysig static valuetype Program/PlayerAbilities CombineFlags ()
{
    IL_0001: ldc.i4.5          // Jump(1) | Shoot(4) = 5 — 컴파일 타임에 폴딩됨
    IL_0002: stloc.0
    IL_0006: ret
}

IL 핵심: Jump | Shoot 연산이 런타임에 수행되지 않습니다. 두 값이 모두 상수이므로 컴파일러가 이미 5 로 접어서 ldc.i4.5 한 줄로 발행했습니다. Unity Update() 루프에서 플래그 조합을 반복해서 계산해도 런타임 비용이 추가되지 않는다는 의미입니다.

Before 와 After 는 런타임 동작이 완전히 같지만, 새 플래그를 추가할 때의 실수 확률 이 크게 다릅니다. 2 진수 리터럴은 "어느 비트를 켤지" 가 자리로 보이기 때문에 9 번째 플래그 값을 헷갈리게 256 으로 적는 실수가 거의 사라집니다.

4.2 Before/After — LayerMask 비트 마스크

Unity 의 Physics.Raycast 는 LayerMask 를 받습니다. LayerMask 는 내부적으로 32 비트 정수로 된 비트 마스크 입니다 — n 번째 비트가 1 이면 n 번 레이어가 포함된다는 뜻입니다.

Before — 1 << n 시프트 체인

C#
// 기존 방식 — 비트 자리를 코드로 표현
int enemyAndObstacleMask = (1 << 8) | (1 << 9);
Physics.Raycast(origin, direction, out hit, 100f, enemyAndObstacleMask);

After — 2 진수 리터럴로 직접 표기

C#
// 2진수 리터럴 — "어느 자리가 켜져 있는지" 가 한눈에 보인다
int enemyAndObstacleMask = 0b_0000_0011_0000_0000;  // 8번·9번 레이어
Physics.Raycast(origin, direction, out hit, 100f, enemyAndObstacleMask);
IL
// (1 << 8) | (1 << 9)
.method public hidebysig static int32 EnemyMaskShift ()
{
    IL_0001: ldc.i4 768        // 시프트·OR 모두 컴파일 타임 폴딩 → 768
    IL_0006: stloc.0
    IL_000a: ret
}

// 0b_0000_0011_0000_0000
.method public hidebysig static int32 EnemyMaskBinary ()
{
    IL_0001: ldc.i4 768        // 위와 완전 동일 — 선택은 가독성 기준으로
    IL_0006: stloc.0
    IL_000a: ret
}

IL 핵심: 두 표기의 IL 이 동일하므로 성능 차이는 0 입니다. 다만 다음 상황에서는 각각 장단이 있습니다.

  • LayerMask.NameToLayer("Enemy") 처럼 런타임에 레이어 인덱스를 얻어야 하는 경우 에는 1 << layerIndex 를 그대로 쓸 수밖에 없습니다 — 폴딩이 안 되지만 인덱스가 하드코딩이 아니므로 Unity Editor 에서 레이어를 재배치해도 깨지지 않습니다.
  • 고정된 시스템 마스크(예: IgnoreRaycast 레이어만 빼는 마스크) 는 2 진수 리터럴이 의도가 명확합니다.

Unity 신입이 흔히 겪는 함정은 int mask = 8; 처럼 레이어 인덱스와 마스크를 혼동 하는 것입니다. 8 번 레이어의 마스크는 1 << 8 = 256 이지, 8 이 아닙니다. 2 진수 리터럴로 쓰면 이 혼동이 원천 차단됩니다 — 0b_0000_0001 은 0 번 레이어, 0b_0000_0001_0000_0000 은 8 번 레이어라는 게 모양으로 보입니다.

4.3 Before/After — Color32 헥사 코드

Unity 의 Color32 는 RGBA 각 1 바이트씩 총 4 바이트로 색을 표현합니다. 웹 디자이너가 주는 #FF5722 같은 헥사 코드를 코드에 담을 때 16 진수와 구분자가 결정적으로 가독성을 높입니다.

Before — 10 진수

C#
// 뭘 의미하는지 숫자만 봐서는 알 수 없다
var unityOrange = new Color32(255, 87, 34, 255);

After — 16 진수 + 구분자

C#
// 디자이너에게 받은 #FF5722 를 그대로 매핑
byte r = 0xFF;
byte g = 0x57;
byte b = 0x22;
byte a = 0xFF;
var unityOrange = new Color32(r, g, b, a);

// 또는 32비트 정수로 한꺼번에 담아 채널별로 꺼내기
uint packed = 0xFF_FF_57_22;  // _ 가 채널 경계
byte alpha = (byte)((packed >> 24) & 0xFF);
byte red   = (byte)((packed >> 16) & 0xFF);
byte green = (byte)((packed >> 8)  & 0xFF);
byte blue  = (byte)( packed        & 0xFF);

0xFF_FF_57_220xFFFF5722 와 완전히 같은 값입니다 — 구분자는 바이트 경계를 시각적으로 구분하는 용도일 뿐입니다. 실제로 바이트별 시프트가 어떻게 내려가는지 한눈에 보이므로, 디자이너 시안과 코드를 비교할 때 실수가 줄어듭니다.

4.4 IL2CPP 관점에서의 영향

Unity 모바일 빌드는 보통 IL2CPP(Intermediate Language to C++, IL 을 C++ 로 변환해 네이티브 컴파일하는 Unity 백엔드) 를 사용합니다. IL2CPP 는 IL 을 읽어 C++ 코드를 생성하므로, C# 소스의 표기법이 아니라 이미 폴딩된 IL 상수 를 본다는 사실이 중요합니다. 즉, (1 << 8) | (1 << 9)0b_0000_0011_0000_0000 이든 IL2CPP 입력은 똑같이 ldc.i4 768 이므로 생성된 네이티브 코드의 성능도 동일 합니다. 표기법 선택은 빌드 시간·런타임 성능 어디에도 영향을 주지 않습니다.


5. 함정과 주의사항 — 구분자와 접미사가 만드는 실수들

5.1 함정 ① — f 접미사 누락으로 인한 암묵 변환 오류

Unity API 의 대부분이 float 을 받기 때문에 이 실수가 가장 자주 발생합니다.

❌ 잘못된 코드

C#
// CS0664: 'double' 형식의 리터럴 값을 'float' 형식으로 암시적으로 변환할 수 없습니다
transform.position = new Vector3(0, 1.5, 0);

1.5 는 소수점이 포함된 리터럴이라 접미사 없으면 double 입니다. Vector3 생성자는 float 셋을 받으므로 컴파일 오류가 납니다.

✅ 올바른 코드

C#
transform.position = new Vector3(0f, 1.5f, 0f);
IL
// 잘못된 코드는 컴파일이 안 되므로 올바른 코드만 비교
.method public hidebysig static void SetPosition ()
{
    IL_0001: ldc.r4 0             // 0f — float 상수
    IL_0006: ldc.r4 1.5           // 1.5f — float 상수
    IL_000b: ldc.r4 0             // 0f — float 상수
    IL_0010: newobj Vector3::.ctor(float32, float32, float32)
    // ...
}

IL 핵심: ldc.r4 가 세 번 나와야 정상입니다. 접미사를 빼먹으면 ldc.r8 이 나오려 하므로 컴파일이 중단됩니다. Unity 초심자가 이 에러를 보고 당황하는 주요 원인이 "왜 그냥 숫자를 썼는데 double 이라고 하지" 라는 개념 차이 때문입니다.

5.2 함정 ② — 소문자 llong 접미사로 쓰기

l 과 숫자 1 은 대부분의 폰트에서 거의 똑같이 보입니다.

❌ 가독성 나쁜 코드

C#
long frameBudget = 10000000l;  // 0이 6개? 7개? 끝에 l 이 있는지도 안 보인다

✅ 대문자 L 과 구분자

C#
long frameBudget = 10_000_000L;  // 자릿수와 타입이 확실히 보인다

C# 컴파일러도 l 대신 L 을 쓰라는 경고 를 띄웁니다(CS0078). 둘 다 문법적으로는 유효하지만, 코드 리뷰에서 사고가 나기 쉬운 쪽을 굳이 고를 이유가 없습니다.

5.3 함정 ③ — uint 리터럴과 int 오버플로 혼동

int 범위는 -2_147_483_648 ~ 2_147_483_647 입니다. 16 진수로 최상위 비트가 1 인 값(0x8000_0000 이상) 을 int 변수에 넣으려고 하면 컴파일 오류가 납니다.

❌ 잘못된 코드

C#
int flags = 0xFFFF_FFFF;
// CS0266: 'uint' 형식을 'int' 형식으로 암시적으로 변환할 수 없습니다

0xFFFF_FFFF 는 int 범위를 초과하므로 컴파일러가 uint 로 추론했고, 이걸 int 에 대입하지 못한다는 에러입니다.

✅ 해결 두 가지

C#
// 방법 A — unchecked 블록으로 비트 패턴을 그대로 int 에 담기
int flags = unchecked((int)0xFFFF_FFFF);  // 값: -1

// 방법 B — uint 로 받기
uint flagsU = 0xFFFF_FFFF;                // 값: 4_294_967_295
unchecked — 오버플로 검사 무시 (unchecked context) 정수 연산이나 변환에서 오버플로가 발생해도 예외를 던지지 않고 비트를 그대로 자르거나 확장한다. 비트 마스크·해시·체크섬처럼 "비트 패턴 자체" 가 의미 있을 때 사용한다.
예시: int raw = unchecked((int)0xFFFF_FFFF); 결과: raw == -1 (0xFFFFFFFF 의 2 의 보수 해석)

IL 로 보면 앞서 본 0xFFFF_FFFF_FFFF_FFFFULldc.i4.m1 + conv.i8 로 압축된 것과 같은 원리입니다 — "모든 비트가 1 인 값" 은 내부적으로 -1 과 동일한 비트 패턴입니다.

5.4 함정 ④ — 구분자 위치 제약

밑줄은 숫자 사이 에만 쓸 수 있습니다. 다음은 모두 컴파일 오류입니다.

C#
int a = _1_000;      // ❌ 맨 앞 금지 (식별자로 파싱됨)
int b = 1_000_;      // ❌ 맨 뒤 금지
int c = 100_L;       // ❌ 접미사 바로 앞 금지
double d = 3._14;    // ❌ 소수점 직후 금지
double e = 3_.14;    // ❌ 소수점 직전 금지
int f = 0b_1010;     // ✅ C# 7.2+ 에서만 유효 (C# 7.0/7.1 에서는 오류)

C# 7.2 의 leading digit separator (0b_, 0x_ 직후 구분자) 허용이 중요한 이유도 여기에 있습니다. C# 7.0 당시에는 2 진수 블록을 0b1010_0110 처럼 앞자리에만 구분자를 못 붙였지만, 7.2 부터는 0b_1010_0110 처럼 균일하게 4 비트씩 끊어 쓸 수 있어 시각적 일관성이 완전해졌습니다.

5.5 함정 ⑤ — Unity 핫패스에서의 decimal

Unity 게임 코드에 decimal 을 쓸 일은 거의 없지만, 서버 로직이나 인앱 결제 금액 계산에 썼다가 그대로 Update() 루프에 들어오면 문제가 됩니다.

❌ 핫패스에 decimal

C#
void Update()
{
    decimal price = 19_999.99m;      // newobj Decimal::.ctor(...)
    totalRevenue += price * playerCount;  // 매 프레임 decimal 연산
}

✅ 핫패스는 float/double, 정산은 decimal

C#
// 핫패스 — float 으로 누적
void Update()
{
    revenueEstimate += 19_999.99f * playerCount;
}

// 정산 시점에만 decimal 로 재계산
public decimal CalculateExactRevenue(long totalUnits)
{
    return totalUnits * 19_999.99m;
}
IL
// Update 내부의 decimal 변수 하나가 매 프레임 이런 IL 을 만든다
IL_0001: ldc.i4 1999999
IL_0006: ldc.i4.0
IL_0007: ldc.i4.0
IL_0008: ldc.i4.0
IL_0009: ldc.i4.2
IL_000a: newobj instance void System.Decimal::.ctor(int32, int32, int32, bool, uint8)

IL 핵심: decimal 리터럴은 newobj 를 호출해 128 비트 struct 를 생성합니다. decimal 은 값 타입이지만 곱셈·덧셈마다 내부 메서드가 호출되어 float/double 의 단일 IL 명령과 비교할 수 없을 정도로 느립니다. 60fps Unity 게임에서 프레임당 여러 번 decimal 연산이 돌면 눈에 띄는 GC 스파이크는 없더라도 CPU 시간 이 낭비됩니다. "정확한 금액" 이 필요한 곳에만 쓰고, 시각 효과나 물리에는 float 을 씁니다.


6. C# 버전별 변화 — C# 6, 7.0, 7.2

숫자 리터럴 표기법은 C# 7 에서 거의 완성된 단계에 들어섰습니다. 6, 7.0, 7.2 에서 실제로 달라진 점을 IL 로 확인합니다.

6.1 C# 6 — 표기법의 공백기

C# 6 까지는 정수 진법 표기법이 10 진수와 16 진수(0x, C# 1.0 부터 존재) 두 가지뿐 이었고, 2 진수 리터럴과 숫자 구분자는 둘 다 없었습니다.

C#
// C# 6 이하 — LayerMask 를 표기하는 유일한 방법들
int mask1 = 768;                    // 10진수 — 의미 불명
int mask2 = 0x300;                  // 16진수 — 낫지만 비트는 여전히 안 보인다
int mask3 = (1 << 8) | (1 << 9);    // 시프트 — 의도는 명확하나 타이핑이 길다

C# 6 에서는 이 셋이 전부였습니다. 2 진수로 직접 표기하고 싶으면 Convert.ToInt32("1100000000", 2) 같은 런타임 변환을 썼는데, 이건 리터럴이 아니라 함수 호출 이라 상수 폴딩이 안 되고 매번 문자열 파싱이 실행됩니다.

6.2 C# 7.0 — 2 진수 리터럴 + 숫자 구분자 도입

C# 7.0 이 두 기능을 한 번에 추가했습니다.

After — C# 7.0

C#
int mask = 0b1100000000;       // 2진수 리터럴 — 2018년 첫 등장
int big  = 1_000_000;          // 숫자 구분자
// 단, 접두사 바로 뒤 _ 는 아직 금지
// int bad = 0b_1100_0000;    // ❌ CS8107

이 시점에 Unity 현업에서 달라진 것은 [Flags] enum 의 작성 스타일이었습니다. 10 진수로 = 1, 2, 4, 8 을 쓰던 관습에서 = 0b_0000_0001, 0b_0000_0010, ... 으로 넘어간 코드베이스가 늘었습니다.

6.3 C# 7.2 — Leading Digit Separator 허용

C# 7.2 는 접두사 바로 뒤 의 구분자를 허용했습니다. 작지만 시각적으로 중요한 변경입니다.

Before — C# 7.0/7.1

C#
// 4비트씩 끊고 싶은데 맨 앞은 3비트만 남는다 — 균일성이 깨진다
int mask = 0b110_0000_0000;

After — C# 7.2

C#
// 모든 그룹이 정확히 4비트 — 시각적 일관성 완전
int mask = 0b_0000_0011_0000_0000;

두 코드의 IL 은 앞서 본 것처럼 완전히 동일합니다 — 가독성 개선을 위한 문법적 확장일 뿐, 동작 변화는 없습니다.

6.4 C# 11 이후 — 참고: 관련 기능 확장

이 주제의 범위를 벗어나지만 문맥상 참고:

  • C# 11UInt128, Int128 타입 도입. 다만 128 비트 정수 리터럴 문법은 여전히 없어 UInt128.Parse("...") 같은 파싱 API 나 두 ulong 을 조합해서 만들어야 합니다.
  • C# 11 — UTF-8 문자열 리터럴 ("hello"u8) 처럼 "리터럴에 접미사를 붙여 표현 방식을 선택" 하는 패턴이 다른 영역으로도 확장되었습니다. 숫자 리터럴 문법 자체는 더 이상 확장되지 않았습니다.

숫자 리터럴 문법 자체는 C# 7.2 에서 사실상 완성 되었고 이후 버전에서는 더 건드리지 않고 있습니다. 신입 개발자가 외워야 할 변화도 여기까지입니다.


7. 정리

이 글에서 다룬 핵심을 체크리스트로 정리합니다.

  • [ ] 세 가지 표기법, 한 가지 값: 255, 0xFF, 0b_1111_1111, 2_55 는 IL 에서 모두 ldc.i4 255 로 동일. 선택은 가독성 기준 이지 성능 기준이 아니다.
  • [ ] 접두사는 진법, 접미사는 타입: 0x·0b 는 진법을 결정하고, L·UL·f·m 은 리터럴의 타입을 결정한다. 둘은 독립적으로 조합 가능(0xFFFFL, 0b_1010_UL).
  • [ ] 구분자는 컴파일러가 완전히 무시: _ 는 숫자 사이에만 쓸 수 있다. 맨 앞·맨 뒤·접미사 앞·소수점 직접 인접은 금지. C# 7.2 부터는 접두사 바로 뒤(0x_, 0b_) 만 예외적으로 허용.
  • [ ] Unity float 에는 반드시 f: Vector3(0, 1.5, 0) 은 CS0664. Vector3(0f, 1.5f, 0f) 가 정답. double → float 암묵 변환은 C# 이 허용하지 않는다.
  • [ ] 상수 폴딩이 런타임 비용을 없앤다: Jump | Shoot, (1 << 8) | (1 << 9) 는 컴파일 타임에 접혀서 ldc.i4.5, ldc.i4 768 한 줄로 발행된다. 핫패스에서 반복해도 공짜다.
  • [ ] [Flags] enum 은 2 진수 리터럴이 정답: 10 진수로 1, 2, 4, 8, 16 을 쓰는 대신 0b_0000_0001, 0b_0000_0010 을 쓰면 "어느 비트를 켤지" 가 눈으로 보인다. 9 번째 플래그의 값이 256 이라는 사실도 자리 세지 않고 보인다.
  • [ ] LayerMask 는 인덱스와 마스크가 다르다: 8 번 레이어의 마스크는 1 << 8 = 256 이지 8 이 아니다. 0b_0000_0001_0000_0000 으로 쓰면 이 구분이 자명해진다.
  • [ ] 비트 패턴 변환은 unchecked: 0xFFFF_FFFF 를 int 에 담으려면 unchecked((int)0xFFFF_FFFF). -1 과 같은 비트 패턴이라는 사실도 IL(ldc.i4.m1 + conv.i8) 에서 확인된다.
  • [ ] 핫패스에 decimal 금지: m 접미사 리터럴은 newobj Decimal::.ctor 를 부른다. 게임 내부 연산은 float/double 로, 정산·결제 로직만 decimal 로.
  • [ ] 표기법 선택의 기준: "이 숫자의 의미가 표기만으로 전달되는가" 를 묻는다. 비트 마스크·플래그는 2 진수, 색상·메모리 주소는 16 진수, 카운트·크기는 10 진수 + 구분자.

표기법은 코드 스타일이 아니라 의도를 전달하는 도구 입니다. 0b_0000_0011_0000_0000768 로 쓰면 컴파일러는 똑같이 받아들이지만, 6 개월 뒤의 본인은 그 값이 Enemy | Obstacle 이었다는 사실을 알아내기 위해 비트를 수동으로 세야 합니다. 잘 고른 표기법은 주석 없이도 코드가 스스로 설명하게 만듭니다.

반응형

+ Recent posts