[PART3.연산자와 표현식(3/11)] 비트 연산자 — & | ^ ~ << >> >>>의 동작 원리와 Unity 실전
비트 단위 스위치 조작 / LayerMask·[Flags] Enum의 내부 / >>는 산술 시프트, >>>는 C# 11 논리 시프트 / IL and·or·xor·not·shl·shr·shr.un
목차
1. 문제 제기 — Raycast가 적(Enemy)만 맞으라고 했는데 벽까지 다 맞는다
Unity에서 근접 공격 판정을 만들어봅니다. "Enemy" 레이어에 있는 오브젝트만 맞추고 싶어서, LayerMask.NameToLayer("Enemy")로 레이어 번호를 가져와 Raycast에 넘겼습니다.
// ❌ 초보자가 가장 자주 저지르는 실수
int enemyLayer = LayerMask.NameToLayer("Enemy"); // 예: 8
if (Physics.Raycast(origin, dir, out RaycastHit hit, 5f, enemyLayer))
{
// 적이 아닌 Wall, Floor도 섞여서 hit 된다
}
돌려보면 벽·바닥까지 전부 맞습니다. enemyLayer에 8이 들어있으니 "Layer 8번만 필터링된다"고 생각한 것인데, Unity의 Physics.Raycast는 레이어 번호가 아니라 비트마스크를 요구합니다. 8을 이진수로 펼치면 0000 0000 0000 0000 0000 0000 0000 1000입니다. 이 마스크는 "Layer 0도 아니고, 3번만 켠다"는 뜻이 됩니다. 즉 Layer 3에 있는 오브젝트가 맞는 것이죠.
올바른 코드는 이렇습니다.
// ✅ 레이어 번호를 비트마스크로 변환
int enemyLayerMask = 1 << LayerMask.NameToLayer("Enemy"); // 1 << 8 = 256
if (Physics.Raycast(origin, dir, out RaycastHit hit, 5f, enemyLayerMask))
{
// 이제 "Enemy" 레이어만 맞는다
}
이 한 줄의 차이를 이해하려면 비트 연산자의 동작을 알아야 합니다. Unity 엔진 내부는 32개의 레이어를 int 하나의 32개 비트에 매핑해두고, 필터링할 때마다 여러분이 넘긴 마스크와 & 연산을 해서 0이 아닌 비트가 있는지 확인합니다. 비슷한 방식이 Camera.cullingMask, Physics2D의 Collision Matrix, [Flags] Enum, 네트워크 패킷 헤더, 그래픽스 스테이트 비트플래그 등 C#·Unity 곳곳에 박혀 있습니다.
이 글에서 답할 질문은 이렇습니다.
&,|,^,~,<<,>>,>>>는 각각 무엇을 하는가- 같은
&인데bool에 쓸 때와int에 쓸 때 의미가 왜 다른가 >>는 왜 "부호를 유지"한다고 하는가,>>>는 뭐가 다른가[Flags]Enum·LayerMask·HasFlag는 IL 레벨에서 어떤 대가를 치르는가a & b == 0을 왜(a & b) == 0으로 바꿔야 하는가- 곱셈·나눗셈을 시프트로 바꾸면 정말 빨라지는가
결론부터 말씀드리면, 비트 연산자는 CPU의 한 사이클 명령어에 1:1 대응하며 C# 컴파일러는 이를 IL 명령어 and·or·xor·not·shl·shr·shr.un으로 단순 번역합니다. 빠른 건 CPU이지 C#이 아닙니다. 그러나 HasFlag 같은 편의 API를 잘못 쓰면 박싱 두 번이 끼어듭니다. 이 본질을 알면 LayerMask도, [Flags]도, 네트워크 헤더도 손에 익게 됩니다.
2. 개념 정의 — "비트 하나하나를 스위치처럼 켜고 끄는 연산"
2.1 비유: 32개짜리 스위치 패널
int 하나를 8자리 숫자가 아니라 32개짜리 전등 스위치 패널이라고 생각해봅니다. 각 자리는 0(꺼짐)과 1(켜짐) 두 상태만 가집니다.
&(AND): 두 패널을 겹쳐서, 둘 다 켜져 있는 자리만 켜진 결과 패널을 만든다 → "공통으로 뭐가 켜져 있지?"|(OR): 두 패널을 겹쳐서, 하나라도 켜져 있는 자리는 켜진다 → "두 상태를 합치자"^(XOR): 서로 다르면 켜진다 → "뭐가 바뀌었지?" / "토글"~(NOT): 한 패널의 모든 스위치를 반대로 → "전부 뒤집기"<<,>>,>>>: 스위치 패턴을 통째로 왼쪽/오른쪽으로 n칸 밀기
Unity 개발에서 이 "32개 스위치"의 대표 사례가 LayerMask입니다. Layer 0번은 비트 0, Layer 1번은 비트 1, …, Layer 31번은 비트 31에 해당합니다.
2.2 시각화: 8비트 창에서 본 각 연산자
32비트 전체는 한눈에 안 보이니, 왼쪽 하위 8비트만 잘라서 보겠습니다. 값 예시는 a = 0b0110_1010 (=106), b = 0b1100_1100 (=204)입니다.

2.3 기본 코드: 한눈에 확인
&·|·^·~— 비트 논리 연산자 (Bitwise AND / OR / XOR / NOT) 두 피연산자의 각 비트 쌍을 따로따로 비교해 새 비트를 만든다.~는 단항으로 한 피연산자의 모든 비트를 반전한다.bool타입에도 쓸 수 있으나 정수일 때와 의미가 다르다(3장 참조).
예시:0b1010 & 0b1100→0b1000/~0b0000_0001→0xFFFF_FFFE(int에서 -2)
<<·>>·>>>— 시프트 연산자 (Left / Right / Unsigned Right Shift) 비트 패턴을 통째로 좌우로 이동시킨다. 오른쪽 시프트>>는 부호 있는 정수에서 왼쪽 빈자리를 부호 비트로 채우는 산술 시프트,>>>(C# 11)는 언제나 0으로 채우는 논리 시프트다.
예시:1 << 8→256/-8 >> 1→-4/-8 >>> 1→2147483644
int a = 0b0110_1010; // 106
int b = 0b1100_1100; // 204
Console.WriteLine($"a & b = {a & b}"); // 72 (0b0100_1000)
Console.WriteLine($"a | b = {a | b}"); // 238 (0b1110_1110)
Console.WriteLine($"a ^ b = {a ^ b}"); // 166 (0b1010_0110)
Console.WriteLine($"~a = {~a}"); // -107 (32비트 전체 반전 결과)
Console.WriteLine($"a << 2 = {a << 2}"); // 424
Console.WriteLine($"a >> 2 = {a >> 2}"); // 26
~a가 -107로 나오는 이유는, a가 int(32비트)이므로 상위 24비트까지 전부 반전되고, 그 결과를 2의 보수 해석으로 읽으면 음수가 되기 때문입니다. 이 "2의 보수"가 >>와 >>>의 차이를 이해하는 열쇠인데, 이는 3장에서 다룹니다.
2.4 IL 분석 — 거의 1:1 대응
아래 두 메서드를 컴파일하고 IL을 뜹니다.
public static int BitwiseAnd(int a, int b) => a & b;
public static int BitwiseNot(int a) => ~a;
.method public hidebysig static int32 BitwiseAnd(int32 a, int32 b)
{
IL_0000: ldarg.0 // a 로드
IL_0001: ldarg.1 // b 로드
IL_0002: and // 스택 두 값 AND → 결과 하나
IL_0003: ret
}
.method public hidebysig static int32 BitwiseNot(int32 a)
{
IL_0000: ldarg.0 // a 로드
IL_0001: not // 32비트 반전
IL_0002: ret
}
C# 연산자가 IL opcode 하나로 번역됩니다. |는 or, ^는 xor로 똑같이 단순합니다. 이 opcode들은 x86/ARM 머신코드 레벨에서도 단일 명령어에 대응하므로, CPU가 "아주 빠르게" 처리한다는 평판은 여기서 옵니다.
3. 내부 동작 — 시프트량 마스킹, 2의 보수, 산술 시프트
3.1 시프트 연산자가 가장 먼저 하는 일: 시프트량 & 0x1F

위 다이어그램은 직접 IL을 뜨면 곧바로 확인됩니다.
public static int LeftShift(int x, int n) => x << n;
IL_0000: ldarg.0
IL_0001: ldarg.1
IL_0002: ldc.i4.s 31 // 상수 31 로드
IL_0004: and // n = n & 31 → 실제 시프트량은 0~31 사이로 강제
IL_0005: shl // 그 뒤에야 시프트
IL_0006: ret
C# 언어 명세는 int와 uint에 대해 시프트량의 하위 5비트(0x1F)만 사용하고, long·ulong은 하위 6비트(0x3F)만 사용한다고 규정합니다. 이 규칙을 컴파일러가 and로 IL에 박아 넣습니다. 이 때문에 1 << 32는 1이 되고, 1 << 40은 1 << 8 = 256이 됩니다. "0으로 밀려 나간다"가 아닌, "아예 그 시프트량이 아니었다"가 정확한 설명입니다.
3.2 >>는 왜 "산술 시프트"인가 — 2의 보수 표현

C#에서 int는 2의 보수(Two's complement, 음수를 표현하는 이진 체계) 방식이라 최상위 비트(MSB)가 1이면 음수입니다. -8은 1111...1111_1000입니다. 이 상태에서 >> 1을 하면:
- 산술 시프트(
>>): 왼쪽 빈자리에 MSB를 복사 → 여전히 음수, 값은-4. "2로 나누기"가 자연스럽게 유지됩니다. - 논리 시프트(
>>>, C# 11): 왼쪽 빈자리에 무조건 0을 채움 → MSB가 0이 되면서 양수로 바뀌어2147483644. 비트 패턴 자체를 다루는 의도일 때 씁니다.
IL은 이 차이를 서로 다른 opcode로 표현합니다.
public static int ArithmeticRightShift(int x, int n) => x >> n; // signed
public static uint LogicalRightShiftUnsigned(uint x, int n) => x >> n; // unsigned
public static int UnsignedRightShiftCs11(int x, int n) => x >>> n; // C# 11
ArithmeticRightShift:
IL_0005: shr // 산술 시프트 — 부호 확장
LogicalRightShiftUnsigned:
IL_0005: shr.un // 논리 시프트 — 0으로 채움
UnsignedRightShiftCs11:
IL_0005: shr.un // C# 11의 >>>는 signed 정수에도 shr.un을 쓴다
핵심: IL 레벨에서 >>는 피연산자가 signed인지 unsigned인지에 따라 shr 또는 shr.un으로 갈립니다. C# 11이 >>>를 도입한 이유는 signed 타입이어도 "명시적으로 논리 시프트"를 적고 싶을 때, 예전처럼 (int)((uint)x >> n) 같은 이중 캐스팅을 쓰지 않기 위함입니다.
3.3 bool에 쓰는 &는 IL이 어떻게 다른가 — 같다
놀랍게도 bool &와 정수 &는 IL opcode가 동일합니다.
public static bool NonShortCircuitAnd(bool a, bool b) => a & b;
public static bool ShortCircuitAnd(bool a, bool b) => a && b;
NonShortCircuitAnd:
IL_0000: ldarg.0
IL_0001: ldarg.1
IL_0002: and // bool도 내부는 int 1비트이므로 and 하나로 끝
IL_0003: ret
ShortCircuitAnd:
IL_0000: ldarg.0
IL_0001: ldarg.1
IL_0002: and // 피연산자가 파라미터뿐이라 분기가 불필요 → IL 동일
IL_0003: ret
위 예시처럼 피연산자가 단순 bool 지역 변수·파라미터일 때는 컴파일러가 "오른쪽을 평가해도 부수효과가 없으니 분기를 만들 이유가 없다"고 판단해 &와 &&의 IL이 같아집니다. 차이가 드러나는 건 피연산자가 메서드 호출이거나 부작용 있는 식일 때입니다. 이 경우 &&는 단락 평가를 위해 brfalse.s 같은 조건부 점프를 추가해 오른쪽을 건너뛸 길을 만들고, &는 오른쪽을 무조건 평가한 뒤 비트 AND를 합니다. 즉 bool에 쓰는 &의 의미는 "IL opcode가 다르다"가 아니라 "오른쪽 식을 반드시 실행한다"는 점에 있습니다. 이 동작은 단락 평가 글(PART3 2/11)에서 자세히 다루었습니다.
4. 실전 적용 — [Flags] Enum과 Unity LayerMask
4.1 [Flags] Enum — 상태 집합을 int 하나로
Unity 기획에서 "아이템에 여러 속성이 동시에 붙을 수 있다(불속성·수속성·독속성…)"거나, "플레이어 상태가 동시에 여러 개(이동 중·점프 중·무적)"인 경우, 이를 bool 여러 개로 관리하면 메모리·직렬화·비교 모두 번거롭습니다. [Flags]가 이를 int 하나로 압축합니다.
[Flags]특성 — 열거형을 비트 플래그로 이 특성을 붙이면ToString()이"Read, Write"처럼 조합 표기를 내고, 열거형이 비트 조합용임을 명시한다. CLR이 조합을 강제하는 것은 아니며, 값 자체를 2의 거듭제곱으로 정의하는 것은 개발자 몫이다.
예시:[Flags] enum Options { None=0, Read=1<<0, Write=1<<1, Execute=1<<2 }
[Flags]
public enum EntityStatus
{
None = 0,
Moving = 1 << 0, // 1
Jumping = 1 << 1, // 2
Invincible = 1 << 2, // 4
Burning = 1 << 3, // 8
Frozen = 1 << 4, // 16
}
EntityStatus status = EntityStatus.Moving | EntityStatus.Invincible;
// 포함 확인 — 방법 1: 비트 AND (권장, 빠름)
bool canBeHit = (status & EntityStatus.Invincible) == EntityStatus.Invincible;
// 포함 확인 — 방법 2: HasFlag (편하지만 비용 있음)
bool canBeHit2 = status.HasFlag(EntityStatus.Invincible);
// 플래그 추가
status |= EntityStatus.Burning;
// 플래그 제거
status &= ~EntityStatus.Burning; // ~로 해당 비트만 0인 마스크 만든 뒤 AND
// 플래그 토글
status ^= EntityStatus.Frozen; // XOR은 "있으면 빼고 없으면 넣기"
"추가는 |=, 제거는 &= ~, 토글은 ^=, 포함 확인은 &" — 이 네 패턴이 [Flags]의 전부입니다.
4.2 IL 비교: &로 확인 vs HasFlag
public static bool HasReadViaBitwise(Options flags)
=> (flags & Options.Read) == Options.Read;
public static bool HasReadViaHasFlag(Options flags)
=> flags.HasFlag(Options.Read);
HasReadViaBitwise:
IL_0000: ldarg.0
IL_0001: ldc.i4.1 // Options.Read = 1
IL_0002: and // flags & 1
IL_0003: ldc.i4.1
IL_0004: ceq // == 1 ?
IL_0006: ret
// 총 6개 opcode, 박싱 없음.
HasReadViaHasFlag:
IL_0000: ldarg.0
IL_0001: box BitwiseSamples/Options // ★ flags를 힙에 박싱
IL_0006: ldc.i4.1
IL_0007: box BitwiseSamples/Options // ★ Options.Read 도 힙에 박싱
IL_000c: call instance bool [System.Runtime]System.Enum::HasFlag(class System.Enum)
IL_0011: ret
// 박싱 2번 + 가상 호출 — Unity Mono/IL2CPP 핫패스에서 GC 스파이크 유발
IL을 보면 HasFlag는 박싱 두 번(box BitwiseSamples/Options)을 수행합니다. 박싱(Boxing, 값 타입을 object로 감싸기 위해 힙에 복사본을 만드는 동작)은 GC 할당을 늘립니다. 예를 들어 Unity Update()에서 매 프레임 플레이어 상태를 검사하는 코드가 HasFlag라면, 60fps 기준 초당 120건의 박싱 객체가 생기고 이는 Unity Mono·IL2CPP가 기본으로 사용하는 Boehm GC(Boehm-Demers-Weiser 보수적 GC)에 누적되어 주기적 GC 스파이크를 만듭니다.
.NET Core 2.1 이후 CoreCLR의 RyuJIT(.NET의 차세대 JIT 컴파일러 내부 코드명)는Enum.HasFlag를 인트린식(intrinsic, JIT가 고유 명령으로 치환하는 최적화)으로 처리해 박싱을 제거합니다. 하지만 Unity의 Mono 런타임과 IL2CPP(Intermediate Language To C++, Unity의 AOT 백엔드)는 이 인트린식 최적화가 제한적이므로, Unity 핫패스에서는 여전히&비교가 안전한 선택입니다.
4.3 Unity LayerMask — Before / After
1장에서 언급한 LayerMask 버그를 제대로 고쳐봅니다.
// ❌ Before — 레이어 번호를 그대로 넘김
public class MeleeAttack : MonoBehaviour
{
[SerializeField] private string targetLayerName = "Enemy";
private void Attack()
{
int layer = LayerMask.NameToLayer(targetLayerName); // 예: 8
if (Physics.Raycast(transform.position, transform.forward,
out RaycastHit hit, 5f, layer))
{
// 8 = 0b0000_1000 → Layer 3번만 필터 되는 버그
hit.collider.SendMessage("OnHit");
}
}
}
// ✅ After — 비트마스크로 변환, 복수 레이어 조합까지
public class MeleeAttack : MonoBehaviour
{
[SerializeField] private LayerMask targetLayers; // Inspector에서 여러 레이어 체크 가능
private void Attack()
{
// LayerMask는 내부 value(int)를 Raycast가 그대로 받도록 암묵 변환
if (Physics.Raycast(transform.position, transform.forward,
out RaycastHit hit, 5f, targetLayers))
{
hit.collider.SendMessage("OnHit");
}
}
// 또는 코드에서 직접 조합
private static readonly int EnemyOrBreakableMask =
(1 << 8) | (1 << 9); // Enemy(8) | Breakable(9)
}
두 가지 개선이 있습니다. 첫째, [SerializeField] LayerMask를 쓰면 Inspector가 비트마스크를 체크박스 UI로 제공해 개발자가 레이어 번호를 직접 계산하지 않아도 됩니다. LayerMask는 int value 하나를 감싼 구조체라 Physics.Raycast의 int layerMask 파라미터로 암묵 변환됩니다. 둘째, 여러 레이어를 한 번에 필터링하려면 (1 << a) | (1 << b)로 OR 묶으면 됩니다.
LayerMask.GetMask("Enemy", "Breakable")라는 편의 메서드도 있는데, 이 메서드는 내부적으로 string → int → (1 << n) | 과정을 문자열 순회로 처리하므로, 매 프레임 호출하지 말고 Awake에서 한 번 계산해 캐싱해야 합니다.
// ⚠️ Update에서 매 프레임 호출하면 문자열 비교 + OR 루프가 60번/초 발생
private int _cachedMask;
private void Awake() => _cachedMask = LayerMask.GetMask("Enemy", "Breakable");
4.4 IL로 본 LayerMask 포함 검사
public static bool IsInEnemyLayer(int enemyLayerIndex, int layerMask)
=> (layerMask & (1 << enemyLayerIndex)) != 0;
IL_0000: ldarg.1 // layerMask
IL_0001: ldc.i4.1 // 1
IL_0002: ldarg.0 // enemyLayerIndex
IL_0003: ldc.i4.s 31 // 31
IL_0005: and // enemyLayerIndex & 31 (시프트량 마스킹)
IL_0006: shl // 1 << (enemyLayerIndex & 31)
IL_0007: and // layerMask & (...)
IL_0008: ldc.i4.0 // 0
IL_0009: cgt.un // 결과 > 0 (unsigned 비교)
IL_000b: ret
한 줄의 C# 코드가 shl, and, cgt.un 등 CPU 레벨 단일 명령어 몇 개로 떨어집니다. 박싱도, 힙 할당도, 메서드 호출도 없습니다. Unity Physics.Raycast 내부도 궁극적으로 이런 비트 검사로 레이어를 걸러냅니다.
5. 함정과 주의사항 — 우선순위, 시프트량, bool에 &
5.1 ❌ 연산자 우선순위 — a & b == 0 은 a & (b == 0)
// ❌ 잘못된 코드 — 컴파일 에러 또는 의도와 완전 다른 동작
if (flags & EntityStatus.Invincible != 0) { /* ... */ }
// 컴파일러가 해석하는 방식:
// if (flags & (EntityStatus.Invincible != 0))
// → EntityStatus.Invincible != 0 은 bool
// → EntityStatus & bool 은 타입 불일치 → 컴파일 에러 "Operator '&' cannot be applied..."
C#의 연산자 우선순위 표에서 ==·!=는 &·|·^보다 위입니다. 괄호 없이 a & b == 0이라고 쓰면 컴파일러는 먼저 b == 0을 계산한 뒤 그 bool 결과를 a와 AND하려 합니다. C에서는 int & bool이 암묵 변환되어 런타임 버그가 되지만, C#은 타입 체크가 엄격해 컴파일 에러로 막아줍니다. 다행이긴 하지만, 실수의 원인은 같으니 습관적으로 괄호를 칩니다.
// ✅ 올바른 코드 — 비트 연산 전체를 괄호로 묶는다
if ((flags & EntityStatus.Invincible) != 0) { /* ... */ }
5.2 ❌ 1 << 40 — 예상 밖의 값
// ❌ int 리터럴 `1`에 40 시프트를 시도
long bigMask = 1 << 40;
Console.WriteLine(bigMask); // 256 (기대는 1099511627776)
// ✅ 시프트 전에 long으로 승격
long bigMask = 1L << 40;
Console.WriteLine(bigMask); // 1099511627776
3장에서 본 시프트량 마스킹 때문입니다. 1은 int 리터럴이므로 1 << 40은 1 << (40 & 31) = 1 << 8 = 256이 됩니다. long을 의도한다면 리터럴 1L을 쓰거나 ((long)1) << 40으로 명시합니다. 32개를 넘는 플래그를 쓸 때 가장 흔한 버그입니다.
5.3 ❌ bool 조건에 &를 무심코 사용
// ❌ rb가 null일 때 크래시 — & 는 양쪽 모두 평가하기 때문
if (rb != null & rb.velocity.magnitude < 5f)
rb.AddForce(Vector3.up * 10f);
// ✅ 단락 평가 && 로 널 가드
if (rb != null && rb.velocity.magnitude < 5f)
rb.AddForce(Vector3.up * 10f);
3.3절에서 봤듯 IL 레벨에서 &와 &&는 같은 and opcode로 떨어질 수 있지만, 전자는 오른쪽 식을 반드시 실행한 뒤 AND하는 구조입니다. rb가 null일 때 rb.velocity가 NullReferenceException을 던져버립니다. 조건문에는 항상 &&·||를 쓰고, &·|는 "양쪽 부수효과를 모두 실행하고 싶다"는 의도가 명확할 때만 씁니다.
5.4 ❌ HasFlag 남용 — Update 루프에서 GC 부담
// ❌ Update 루프 한 번당 2번 박싱, 초당 120회 박싱 (60fps 기준)
private void Update()
{
if (_status.HasFlag(EntityStatus.Invincible)) return;
if (_status.HasFlag(EntityStatus.Burning)) ApplyBurnDamage();
}
// ✅ 핫패스에서는 & 비교 — 박싱 없음
private void Update()
{
if ((_status & EntityStatus.Invincible) != 0) return;
if ((_status & EntityStatus.Burning) != 0) ApplyBurnDamage();
}
4.2절에서 본 대로 Unity Mono/IL2CPP의 HasFlag는 박싱 두 번을 동반합니다. Update나 FixedUpdate처럼 매 프레임 돌아가는 메서드에서는 비트 연산으로 바꾸는 것이 안전합니다. 반면 Awake·Start·이벤트 핸들러처럼 드물게 실행되는 곳에서는 HasFlag의 가독성이 우위입니다.
5.5 ❌ 곱셈/나눗셈을 강제로 시프트로 — 가독성만 손해
// ❌ 최적화 의도 — 현대 JIT에선 무의미
int half = value >> 1; // value / 2
int eighth = value >> 3; // value / 8
int doubled = value << 1; // value * 2
// ✅ 일반 산술 연산자 — JIT가 알아서 시프트로 최적화
int half = value / 2;
int eighth = value / 8;
int doubled = value * 2;
x * 8과 x << 3의 IL은 서로 다르지만(mul vs shl), .NET 6+ RyuJIT(Windows/Linux 서버 런타임)와 Unity IL2CPP(AOT에서 Clang이 백엔드)는 둘 다 최종 머신코드에서 동일한 shl 명령으로 수렴시킵니다. 부호 있는 정수 나눗셈은 음수 반올림 처리 때문에 약간 더 복잡한 보정((x + (x >> 31 & 7)) >> 3)으로 바뀌긴 하지만, 이 역시 JIT가 자동 수행합니다. 따라서 가독성을 해치면서 시프트로 "최적화"할 실익이 거의 없습니다.
예외: 비트 플래그 정의(1 << 0, 1 << 1)나 해시 결합((a << 5) ^ b) 같이 의미 자체가 "비트 자리"인 코드에서는 시프트가 의도를 드러냅니다.
6. C# 버전별 변화 — >>>(C# 11)의 도입
C# 1.0부터 & | ^ ~ << >>는 동일하게 제공되었습니다. 가장 최근 변화는 C# 11의 >>> (Unsigned Right Shift) 입니다.
Before (C# 10 이하) — signed 정수를 논리 시프트하려면 이중 캐스팅
int color = unchecked((int)0xFF00_0000); // 알파 최상위
// 알파 채널만 최하위로 가져오고 싶다 (부호 확장되면 0xFFFFFF... 가 되어 틀림)
int alpha = (int)((uint)color >> 24); // uint로 캐스팅 후 >> → 다시 int
// color >> 24 만 쓰면 산술 시프트라 -1 이 됨
// (uint)color >> 24 의 IL
IL_0000: ldarg.0
IL_0001: conv.u4 // int → uint
IL_0002: ldc.i4.s 24
IL_0004: shr.un // 논리 시프트
IL_0005: ret
After (C# 11+) — >>> 한 문자로 명시
int color = unchecked((int)0xFF00_0000);
int alpha = color >>> 24; // signed 정수여도 논리 시프트
// color >>> 24 의 IL
IL_0000: ldarg.0
IL_0001: ldc.i4.s 24
IL_0003: shr.un // 동일한 IL, 다만 캐스팅 없이
IL_0004: ret
IL 레벨에서 두 코드는 똑같이 shr.un으로 떨어지지만, C# 소스에서 "부호 무시 시프트"라는 의도를 한 기호로 전달할 수 있게 된 것이 핵심입니다. 색상 채널 추출, 해시 믹싱, 비트 직렬화 등 이미 unsigned로 다루고 싶은 32비트 패턴이 있을 때 C# 11 이후로는 >>>를 쓰는 편이 더 읽기 좋습니다.
Unity 버전과 C# 11
Unity의 기본 C# 언어 버전은 오랫동안 C# 9에 머물러 있었습니다. C# 11의 >>>를 그대로 쓰려면 프로젝트 루트에 csc.rsp나 Directory.Build.props를 두고 <LangVersion>11.0</LangVersion> 또는 <LangVersion>latest</LangVersion>으로 명시해 올려야 합니다. 이 설정이 안 되는 구버전 Unity에서는 기존 (int)((uint)x >> n) 이중 캐스팅 패턴을 그대로 씁니다. 공식 지원 상태는 사용하는 Unity 버전의 릴리스 노트에서 꼭 확인하세요(이 글은 미검증 사항을 단정하지 않습니다).
그 외: C# 2.0 Enum.HasFlag, C# 7.0 디지트 구분자 _
- C# 2.0(2005)에서
Enum.HasFlag가 추가되었습니다. 편의성은 올라갔으나 4.2절의 박싱 이슈가 함께 따라왔습니다. - C# 7.0(2017)부터
0b1010_0101_1100_0011같은 이진 리터럴 + 디지트 구분자_를 지원합니다. 이전엔0xA5C3같은 16진수만 썼기 때문에 비트 패턴을 눈으로 확인하기 불편했습니다. 이 글의 예시들이0b0110_1010으로 깔끔하게 적히는 것도 이 덕분입니다.
7. 정리 — 체크리스트
핵심 7문장
& | ^ ~ << >> >>>는 ILand·or·xor·not·shl·shr·shr.un한 opcode로 번역되어 CPU 사이클 수준으로 빠릅니다.int시프트량은 자동으로& 0x1F(0~31 범위) 처리되므로1 << 32 == 1,1 << 40 == 256인 점을 기억합니다. 32개 넘게 쓰려면1L << n.>>는 signed 정수에서 부호 비트를 확장하는 산술 시프트,>>>(C# 11)와 unsigned 타입의>>는 0으로 채우는 논리 시프트입니다.- bool에 쓰는
&·|는 비단락 연산입니다. 조건문에는&&·||를 쓰고, 양쪽 부수효과를 모두 실행하고 싶을 때만&·|를 씁니다. [Flags]Enum의 네 가지 기본 패턴은|=(추가),&= ~(제거),^=(토글),&+== value(포함 확인)입니다.- Unity
Physics.Raycast의 layerMask는 비트마스크입니다.1 << LayerMask.NameToLayer(...)로 변환하거나[SerializeField] LayerMask를 쓰세요. - Unity 핫패스(
Update·FixedUpdate)에서Enum.HasFlag는 박싱 2번을 유발하므로(flags & Flag) != 0비트 비교로 바꿉니다. 비-핫패스에서는HasFlag가 가독성상 우위입니다.
기억해둘 함정
| 함정 | 증상 | 해결 |
|---|---|---|
Physics.Raycast(..., layerIndex) |
엉뚱한 레이어가 필터됨 | 1 << layerIndex 로 변환 |
a & b == 0 |
컴파일 에러 or bool과 정수 AND 시도 | (a & b) == 0 |
1 << 40 |
결과가 1099511627776이 아닌 256 | 1L << 40 |
if (rb != null & rb.velocity...) |
null일 때 NRE | && 사용 |
Update에서 flags.HasFlag(X) |
매 프레임 박싱 2번 | (flags & X) != 0 |
x >> 3 으로 "최적화" |
가독성만 하락 | x / 8 로 두고 JIT에 맡김 |
(int)((uint)color >> 24) |
장황함 | C# 11+ 에서 color >>> 24 |
다음 질문거리
- 같은 비트 연산인
&&·||와&·|의 동작 차이를 IL 레벨에서 본격 비교하려면: PART32. 단락 평가(Short-Circuit)글을 이어서 읽어보세요. - 비트 연산보다 더 큰 범위의 "연산자 오버로딩"(
operator +같은 사용자 정의 연산자)은 뒤에 나올 PART에서 다룹니다. BitOperations클래스(.NET 5+,System.Numerics)는PopCount,TrailingZeroCount같은 고급 비트 연산을 SIMD 인트린식으로 제공합니다. 비트 해킹이 필요한 알고리즘을 쓸 일이 있다면Enum & ~보다 더 빠른 CPU 명령을 활용할 수 있습니다.