[PART2.변수와 기본 데이터 타입(3/13)] 숫자 리터럴 표기법 — 2진수·16진수·구분자 (C# 7)
코드에 박힌 숫자를 읽기 쉽게 만드는 세 가지 장치 — 2진수 접두사, 16진수 접두사, 밑줄 구분자
목차
1. 문제 제기 — 0b_0000_0011_0000_0000 vs 768, 어느 쪽이 먼저 이해되시나요
Unity 프로젝트의 PhysicsRaycaster.cs 를 열었다가 다음 한 줄을 만납니다.
int enemyLayers = 768;
이 768 은 도대체 무엇을 의미할까요. 10 진수로 봐서는 감이 오지 않습니다. 2 진수로 바꿔보면 0000_0011_0000_0000. 즉, 8 번 비트(Enemy)와 9 번 비트(Obstacle)가 켜져 있는 LayerMask 입니다. 하지만 코드를 읽는 순간에는 이 변환을 독자가 머릿속으로 수행해야 합니다.
같은 값을 아래처럼 쓰면 의도가 그대로 드러납니다.
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 가 리터럴입니다. 변수 이름이 가리키는 값이 아니라, 코드에 문자 그대로 쓰여 있는 값을 가리킵니다.
숫자 리터럴은 "사람이 읽는 표기법" 과 "값" 이라는 두 층으로 나뉩니다. 표기법은 어떻게 쓰느냐의 문제이고, 값은 컴파일 후 결과의 문제입니다. 같은 값을 여러 표기법으로 적을 수 있습니다.

세 가지 접두사(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 은 전부 같은 값입니다 — 오직 읽는 사람의 편의만을 위한 장치입니다.
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 리터럴 타입 결정 규칙
접두사는 "진법" 을 정하고, 접미사는 "타입" 을 정합니다.
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
접미사가 없는 정수 리터럴 은 값을 담을 수 있는 가장 작은 타입부터 순서대로 선택됩니다 — int → uint → long → ulong. 255 는 int, 5_000_000_000 은 int 범위를 넘어서므로 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 로 확인합니다.
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; }
.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.i4ldc는 "load constant"(상수 로드),.i4는 "4 바이트 정수(int32)" 를 의미. 이 명령 하나로 주어진 정수 상수를 평가 스택에 올린다. 런타임에서 가장 가벼운 연산 중 하나이며 GC 부담 없음.
3. 내부 동작 — 컴파일러가 리터럴을 어떻게 상수로 바꾸는가
3.1 어휘 분석 → 상수 폴딩 → IL 상수 명령
리터럴이 IL 에 박히는 과정은 세 단계입니다.

- 어휘 분석(Lexer): 소스 텍스트를 토큰으로 쪼갠다. 이때 밑줄(
_) 은 숫자 토큰 내부에서 제거되고,0x/0b접두사로 진법이 태그된다. - 상수 폴딩(Constant Folding): 리터럴과 상수식(
Jump | Shoot,1 << 8등) 을 컴파일 타임에 미리 계산해 단일 값으로 접는다. 런타임에는 이 계산이 실행되지 않는다. - 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
각 타입이 어떤 상수 명령으로 나가는지 한번에 봅니다.
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; }
.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
}
여기서 세 가지가 드러납니다.
0xFFFF_FFFF_FFFF_FFFFUL이ldc.i4.m1 + conv.i8로 압축되었습니다. 8 바이트 전부 1 인 ulong 은 비트 패턴상 -1 을 64 비트로 확장한 값과 같습니다. 컴파일러는 9 바이트짜리ldc.i8대신 2 바이트짜리ldc.i4.m1 + conv.i8을 선택해 메서드 크기를 줄였습니다. 문법 차이가 아니라 순수 바이트 절약 최적화 입니다.float와double은 명령 자체가 다릅니다 (ldc.r4vsldc.r8). 접미사f가 없으면 컴파일러는ldc.r8을 발행하므로 float 변수에 대입할 때 암묵 변환 오류가 납니다.decimal은 CLR 의 상수 명령이 없어 생성자 호출 (newobj System.Decimal::.ctor) 로 만들어집니다. 네 개의 int32 와 한 개의 bool, 한 개의 uint8(scale) 이 생성자 인자입니다. Debug 빌드에서는 스택 할당이지만, 매 호출마다 5 개의 상수 로드가 필요해 다른 타입보다 약간 비쌉니다.
3.4 상수 폴딩이 만드는 놀라운 압축
1 << 8 | 1 << 9 처럼 "비트 시프트와 OR 연산" 이 있는 식도, 피연산자가 모두 리터럴이면 컴파일 타임에 미리 계산 됩니다.
public static int EnemyMaskShift() { return (1 << 8) | (1 << 9); }
public static int EnemyMaskBinary() { return 0b_0000_0011_0000_0000; }
.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 진수 (전통적 방식)
[System.Flags]
public enum PlayerAbilities
{
None = 0,
Jump = 1,
Sprint = 2,
Shoot = 4,
Fly = 8,
DoubleJump = 16, // ← 16이 맞는지 한번 계산해야 한다
WallRun = 32,
// ...값이 커질수록 계산이 귀찮다
}
After — 2 진수 + 구분자 (C# 7+)
[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;
}
.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 시프트 체인
// 기존 방식 — 비트 자리를 코드로 표현
int enemyAndObstacleMask = (1 << 8) | (1 << 9);
Physics.Raycast(origin, direction, out hit, 100f, enemyAndObstacleMask);
After — 2 진수 리터럴로 직접 표기
// 2진수 리터럴 — "어느 자리가 켜져 있는지" 가 한눈에 보인다
int enemyAndObstacleMask = 0b_0000_0011_0000_0000; // 8번·9번 레이어
Physics.Raycast(origin, direction, out hit, 100f, enemyAndObstacleMask);
// (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 진수
// 뭘 의미하는지 숫자만 봐서는 알 수 없다
var unityOrange = new Color32(255, 87, 34, 255);
After — 16 진수 + 구분자
// 디자이너에게 받은 #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_22 는 0xFFFF5722 와 완전히 같은 값입니다 — 구분자는 바이트 경계를 시각적으로 구분하는 용도일 뿐입니다. 실제로 바이트별 시프트가 어떻게 내려가는지 한눈에 보이므로, 디자이너 시안과 코드를 비교할 때 실수가 줄어듭니다.
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 을 받기 때문에 이 실수가 가장 자주 발생합니다.
❌ 잘못된 코드
// CS0664: 'double' 형식의 리터럴 값을 'float' 형식으로 암시적으로 변환할 수 없습니다
transform.position = new Vector3(0, 1.5, 0);
1.5 는 소수점이 포함된 리터럴이라 접미사 없으면 double 입니다. Vector3 생성자는 float 셋을 받으므로 컴파일 오류가 납니다.
✅ 올바른 코드
transform.position = new Vector3(0f, 1.5f, 0f);
// 잘못된 코드는 컴파일이 안 되므로 올바른 코드만 비교
.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 함정 ② — 소문자 l 을 long 접미사로 쓰기
l 과 숫자 1 은 대부분의 폰트에서 거의 똑같이 보입니다.
❌ 가독성 나쁜 코드
long frameBudget = 10000000l; // 0이 6개? 7개? 끝에 l 이 있는지도 안 보인다
✅ 대문자 L 과 구분자
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 변수에 넣으려고 하면 컴파일 오류가 납니다.
❌ 잘못된 코드
int flags = 0xFFFF_FFFF;
// CS0266: 'uint' 형식을 'int' 형식으로 암시적으로 변환할 수 없습니다
0xFFFF_FFFF 는 int 범위를 초과하므로 컴파일러가 uint 로 추론했고, 이걸 int 에 대입하지 못한다는 에러입니다.
✅ 해결 두 가지
// 방법 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_FFFFUL 이 ldc.i4.m1 + conv.i8 로 압축된 것과 같은 원리입니다 — "모든 비트가 1 인 값" 은 내부적으로 -1 과 동일한 비트 패턴입니다.
5.4 함정 ④ — 구분자 위치 제약
밑줄은 숫자 사이 에만 쓸 수 있습니다. 다음은 모두 컴파일 오류입니다.
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
void Update()
{
decimal price = 19_999.99m; // newobj Decimal::.ctor(...)
totalRevenue += price * playerCount; // 매 프레임 decimal 연산
}
✅ 핫패스는 float/double, 정산은 decimal
// 핫패스 — float 으로 누적
void Update()
{
revenueEstimate += 19_999.99f * playerCount;
}
// 정산 시점에만 decimal 로 재계산
public decimal CalculateExactRevenue(long totalUnits)
{
return totalUnits * 19_999.99m;
}
// 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# 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
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
// 4비트씩 끊고 싶은데 맨 앞은 3비트만 남는다 — 균일성이 깨진다
int mask = 0b110_0000_0000;
After — C# 7.2
// 모든 그룹이 정확히 4비트 — 시각적 일관성 완전
int mask = 0b_0000_0011_0000_0000;
두 코드의 IL 은 앞서 본 것처럼 완전히 동일합니다 — 가독성 개선을 위한 문법적 확장일 뿐, 동작 변화는 없습니다.
6.4 C# 11 이후 — 참고: 관련 기능 확장
이 주제의 범위를 벗어나지만 문맥상 참고:
- C# 11 —
UInt128,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_0000 을 768 로 쓰면 컴파일러는 똑같이 받아들이지만, 6 개월 뒤의 본인은 그 값이 Enemy | Obstacle 이었다는 사실을 알아내기 위해 비트를 수동으로 세야 합니다. 잘 고른 표기법은 주석 없이도 코드가 스스로 설명하게 만듭니다.