[PART4.제어문: 분기와 반복(2/13)] switch 문 — 고전적 형태
단일 값을 여러 상수와 매칭하는 전용 문법 / 점프 테이블과 비교 사슬을 컴파일러가 어떻게 선택하는가 / fall-through를 금지하면서도 goto case 로 명시적 점프를 허용한 이유
목차
1. [문제 제기] — if-else 사슬로는 부족한 순간
Unity에서 캐릭터 상태머신을 처음 작성할 때 이런 코드가 자주 나옵니다.
void Update()
{
if (state == State.Idle) HandleIdle();
else if (state == State.Run) HandleRun();
else if (state == State.Attack) HandleAttack();
else if (state == State.Hit) HandleHit();
else if (state == State.Die) HandleDie();
}
문제는 명확하지 않습니다. 처음에는 동작합니다. 그런데 상태가 8개로 늘어나고, 매 프레임 호출되며, 모바일에서 60fps 를 유지해야 한다면 다음 질문이 떠오릅니다.
- 매 프레임마다 평균 4번씩 비교하는 게 정말 최선인가요?
- "단일 값(state)을 여러 상수와 매칭한다" 는 의도가 코드에 명확히 드러나 있나요?
- 새 상태(
State.Stun)가 추가됐을 때 어떤 조건문에 끼워 넣어야 하는지 한눈에 보이나요?
switch 문은 이런 패턴에 정확히 맞춰진 문법입니다. 컴파일러가 "단일 값을 상수들과 매칭한다" 는 제약을 이해하기 때문에, 상황에 따라 점프 테이블 같은 강력한 최적화를 적용할 수 있습니다. 이 글에서는 switch 문이 if-else 사슬과 무엇이 다른지, 컴파일러가 내부적으로 어떻게 분기를 만드는지, 그리고 Unity 핫패스에서 자주 마주치는 함정까지 살펴봅니다.
참고: 이 글의 주제는 switch 문(statement) 입니다. C# 8에서 도입된 switch 표현식(expression) 은 다음 글의 주제이며, 본문 마지막에서 짧게 비교만 합니다.
2. [개념 정의] — 단일 값을 여러 상수와 매칭하는 전용 문법
비유 — 우체국 분류 기계
택배 우체국을 떠올려 봅니다. 분류 직원이 송장을 한 장씩 들고 "1번 도시면 A 컨베이어, 2번이면 B, 3번이면 C..." 하고 일일이 비교하면 시간이 오래 걸립니다. 실제로는 분류 기계가 우편번호를 한 번 읽어서 해당 번호의 슬롯으로 곧장 떨어뜨립니다 — 비교 횟수와 무관하게 한 번에 끝납니다.
if-else 사슬은 분류 직원이고, switch 는 분류 기계입니다. switch 는 "단일 값(우편번호)" 만 받기 때문에 컴파일러가 미리 분류 기계(점프 테이블)를 만들어 둘 수 있습니다.
구조

기본 코드
public enum State { Idle, Run, Attack, Hit, Die }
public static int Handle(State s)
{
switch (s)
{
case State.Idle: return 0;
case State.Run: return 1;
case State.Attack: return 2;
case State.Hit: return 3;
case State.Die: return 4;
default: return -1;
}
}
쉬운 설명: switch (식) 의 식을 한 번 평가해 그 값과 일치하는 case 라벨로 곧장 점프합니다. 일치가 없으면 default 로 갑니다. 각 case 본문은 반드시 break/return/throw 같은 점프문으로 끝나야 합니다 — 다음 case 로 자동으로 흘러가는 동작이 컴파일 단계에서 차단되기 때문입니다.
기술 정의: switch 문은 단일 governing 표현식 의 값을 여러 컴파일 타임 상수 case 라벨 과 비교해, 일치하는 라벨이 위치한 switch section 으로 제어를 이전하는 다중 분기 구문입니다. C# 7 이전에는 governing type 이 정수형(sbyte/byte/short/ushort/int/uint/long/ulong), char, bool, string, enum, 그리고 위 타입들의 Nullable<T> 로 한정됩니다.
break— 가까운 반복문/switch 탈출 키워드 가장 가까운 switch 문 또는 반복문(for,while,do-while,foreach)의 실행을 즉시 종료하고 그다음 코드로 제어를 넘긴다. switch 문 안에서는 case 본문을 끝맺는 가장 일반적인 종결문이다.
예시:case 1: DoWork(); break;— case 1 본문 실행 후 switch 탈출
default— 일치하는 case 가 없을 때 실행되는 라벨 switch 의 어떤 case 라벨과도 일치하지 않는 값이 들어왔을 때 실행되는 폴백(fallback) 라벨. switch 안 어디에 위치해도 합법이지만 평가는 항상 마지막에 일어난다. 생략 가능하지만 안전을 위해 두는 것을 권장한다.
예시:default: throw new InvalidOperationException();— 예상 못한 값을 즉시 드러내는 방어 코드
동작 증명 — 가장 단순한 dense switch IL
위 Handle 메서드를 IL로 컴파일하면 다음과 같은 핵심 명령이 등장합니다.
.method public hidebysig static int32 Handle (valuetype State s) cil managed
{
// ...
IL_0005: ldloc.0 // s 값을 스택에 로드
IL_0006: switch (IL_0021, IL_0025, IL_0029, // ← CIL switch 명령 (점프 테이블)
IL_002d, IL_0031) // 인덱스로 5개 라벨 중 하나 선택
IL_001f: br.s IL_0035 // 범위 밖 → default 로
IL_0021: ldc.i4.0 // case Idle 본문 시작
IL_0022: stloc.2
// ... (각 case 본문 생략)
IL_0035: ldc.i4.m1 // default: -1
IL_0036: stloc.2
IL_0039: ldloc.2
IL_003a: ret
}
핵심은 IL_0006 한 줄입니다. CIL 명세(ECMA-335)는 switch opcode 를 정의해 두었는데, 스택의 정수 값을 인덱스로 사용해 N개의 점프 라벨 중 하나로 한 번에 분기합니다. enum 멤버 0~4가 연속이라 컴파일러는 이 명령 한 줄로 압축할 수 있습니다.
이 그림은 dense 케이스에 한정됩니다. 다음 섹션에서 sparse 케이스와 string case 가 어떻게 다르게 컴파일되는지 살펴봅니다.
3. [내부 동작] — 컴파일러는 어떻게 분기 전략을 고르는가
세 가지 IL 분기 전략

추정 표시: case 6개 / 밀집도 50% 같은 임계치는 Roslyn 컴파일러의 휴리스틱 추정치이며, 컴파일러 버전과 코드 형태에 따라 달라집니다. 정확한 임계치는 IL을 직접 확인하는 것이 안전합니다. 이 글의 IL 결과는 .NET SDK 10.0.103, Debug 빌드 기준입니다.
① dense 정수 — 점프 테이블
연속된 정수 case 는 컴파일러가 인덱스로 변환해 CIL switch 명령으로 압축합니다.
public static int DenseDay(int day)
{
switch (day)
{
case 1: return 10;
case 2: return 20;
case 3: return 30;
case 4: return 40;
case 5: return 50;
default: return 0;
}
}
.method public hidebysig static int32 DenseDay (int32 day) cil managed
{
.maxstack 2
.locals init ([0] int32, [1] int32, [2] int32)
IL_0005: ldloc.0
IL_0006: ldc.i4.1
IL_0007: sub // ← day - 1 로 0-base 인덱스 변환
IL_0008: switch (IL_0023, IL_0028, IL_002d, // ← 점프 테이블: 5개 라벨
IL_0032, IL_0037)
IL_0021: br.s IL_003c // 범위 밖 → default
IL_0023: ldc.i4.s 10 // case 1 본문
IL_0025: stloc.2
IL_0026: br.s IL_0040
// ... (case 2~5 생략, 패턴 동일)
IL_003c: ldc.i4.0 // default 본문
IL_003d: stloc.2
IL_0040: ldloc.2
IL_0041: ret
}
핵심:
sub한 줄로 인덱스 정규화 — case 가 1부터 시작하므로day - 1로 0-base 인덱스로 만듭니다. case 가 0부터 시작하면 이 명령조차 사라집니다.switch (...)opcode — 인덱스로 5개 라벨 중 하나로 직접 점프합니다. case 개수와 무관하게 한 번의 분기로 끝납니다.- 범위 검사 —
switchopcode 는 인덱스가 [0, N-1] 범위 밖이면 다음 명령(br.s IL_003c)으로 떨어집니다. 컴파일러는 이 동작을 default 분기로 활용합니다.
② sparse 정수 — 비교 사슬
case 값의 간격이 크면 점프 테이블을 만드는 데 메모리 낭비가 큽니다 (예: 1, 100, 10000 → 10001 슬롯 테이블 필요). 컴파일러는 비교 사슬을 선택합니다.
public static int SparseLookup(int code)
{
switch (code)
{
case 1: return 10;
case 100: return 20;
case 10000: return 30;
default: return 0;
}
}
.method public hidebysig static int32 SparseLookup (int32 code) cil managed
{
.maxstack 2
.locals init ([0] int32, [1] int32, [2] int32)
IL_0005: ldloc.0
IL_0006: ldc.i4.1
IL_0007: beq.s IL_001c // ← code == 1?
IL_000b: ldloc.0
IL_000c: ldc.i4.s 100
IL_000e: beq.s IL_0021 // ← code == 100?
IL_0012: ldloc.0
IL_0013: ldc.i4 10000
IL_0018: beq.s IL_0026 // ← code == 10000?
IL_001a: br.s IL_002b // 모두 아니면 default
IL_001c: ldc.i4.s 10 // case 1 본문
IL_001e: stloc.2
IL_001f: br.s IL_002f
// ... (case 100, 10000 동일 패턴)
IL_002b: ldc.i4.0 // default
IL_002c: stloc.2
IL_002f: ldloc.2
IL_0030: ret
}
핵심:
switchopcode 가 사라집니다 — 대신beq.s(branch-if-equal) 명령이 case 개수만큼 등장합니다.- 순차 비교 — 첫 번째 case 부터 차례대로 비교하므로 if-else 사슬과 사실상 동일한 IL 입니다.
- case 가 더 많아지면 이진 탐색 — case 가 일정 수를 넘고 분포가 불규칙하면 컴파일러는 비교 사슬을 이진 탐색 트리로 재구성합니다(추정). 이 글의 예제는 case 3개라 단순 사슬만 나옵니다.
이 결과는 중요한 시사점을 줍니다 — switch 라고 해서 항상 빠른 게 아닙니다. sparse 한 정수 case 라면 if-else 사슬과 성능 차이가 거의 없습니다.
③ string switch — 해시 기반 점프 테이블
문자열 case 가 7개 이상이면 컴파일러는 흥미로운 변환을 합니다. 우선 case 문자열들의 해시를 컴파일 타임에 계산하고, 런타임에는 입력 문자열의 해시로 일차 분기를 한 뒤 마지막에 String.Equals 로 검증합니다.
public static int StringRoute(string s)
{
switch (s)
{
case "alpha": return 1;
case "beta": return 2;
case "gamma": return 3;
case "delta": return 4;
case "epsilon": return 5;
case "zeta": return 6;
case "eta": return 7;
default: return 0;
}
}
.method public hidebysig static int32 StringRoute (string s) cil managed
{
.locals init ([0] string, [1] string, [2] uint32, [3] int32)
IL_0005: ldloc.0
IL_0006: call uint32 '<PrivateImplementationDetails>'::ComputeStringHash(string) // ← 해시 계산
IL_000b: stloc.2
// 이진 탐색으로 해시값을 좁혀 들어감
IL_000c: ldloc.2
IL_000d: ldc.i4 1795259425
IL_0012: bgt.un.s IL_0038 // ← 해시가 큰 그룹? / 작은 그룹?
IL_0014: ldloc.2
IL_0015: ldc.i4 1569418667
IL_001a: beq.s IL_0071 // → "alpha" 후보로 점프
// ... (각 해시 비교 분기 생략)
IL_0071: ldloc.0 // "alpha" 후보 검증 블록
IL_0072: ldstr "alpha"
IL_0077: call bool [System.Runtime]System.String::op_Equality(string, string)
IL_007c: brtrue.s IL_00da // ← 해시 충돌 대비 실제 비교
IL_007e: br.s IL_00f6 // 아니면 default
// ... (각 case 같은 패턴: ldstr + op_Equality)
IL_00da: ldc.i4.1 // case "alpha" 본문
IL_00db: stloc.3
IL_00dc: br.s IL_00fa
// ...
}
핵심:
- 컴파일러가 자동 생성한 헬퍼:
<PrivateImplementationDetails>::ComputeStringHash는 각 어셈블리 빌드 시 자동 주입되는 비공개 헬퍼입니다.string.GetHashCode()와 달리 빌드 간 일관된 해시를 보장합니다. - 2단계 분기: 먼저 해시값으로 후보 case 를 좁히고(
bgt.un.s로 이진 탐색), 그다음 실제 문자열 비교(op_Equality)로 해시 충돌을 방어합니다. - case 가 적으면 단순화: case 가 6개 이하라면 컴파일러는 해시 트릭을 생략하고
String.Equals호출 사슬만 만듭니다. - 장점: 평균 시간 복잡도가 O(log n) 의 해시 분기 + O(1) 의 최종 비교로, 단순 비교 사슬 O(n) 보다 케이스 수가 늘어날수록 유리합니다.
GC 관점: 위 IL 의ldstr "alpha"는 메타데이터의 인터닝된 문자열 리터럴을 가리키는 것이므로 새 힙 할당이 발생하지 않습니다. 그러나 입력 문자열s자체가 매 프레임 새로 만들어지는 경우(예:state.ToString()), 해시 분기 자체는 가벼워도 입력이 비싸진다는 점은 별개입니다.
정리 — 컴파일러 선택 기준 (관찰된 사실)
| case 형태 | IL 분기 전략 | 시간 복잡도 |
|---|---|---|
| dense 정수 (case 5개, 1~5) | switch opcode 한 줄 |
O(1) |
| sparse 정수 (case 3개, 1·100·10000) | beq 비교 사슬 |
O(n) |
| dense enum (case 5개, 0~4) | switch opcode 한 줄 |
O(1) |
| string (case 7개) | 해시 이진 탐색 + op_Equality 검증 |
O(log n) + O(1) |
이 표는 위 IL 결과로부터 직접 관찰한 사실이며, 그 외 케이스(case 가 100개 넘는 경우, 부분적으로 dense 한 경우)의 정확한 임계치는 추정치입니다.
4. [실전 적용] — Unity 핫패스에서 if-else를 switch로
Before / After — 5상태 캐릭터 상태머신
Unity 의 Update() 같은 핫패스에서 동일한 분기를 if-else 사슬과 switch 로 작성하면 IL 수준에서 어떻게 다른지 비교해 봅니다.
❌ Before — if-else 사슬
public enum CharState { Idle, Run, Attack, Hit, Die }
public static int UpdateIfElse(CharState s)
{
if (s == CharState.Idle) return 0;
else if (s == CharState.Run) return 1;
else if (s == CharState.Attack) return 2;
else if (s == CharState.Hit) return 3;
else if (s == CharState.Die) return 4;
else return -1;
}
.method public hidebysig static int32 UpdateIfElse (valuetype CharState s) cil managed
{
// Code size: 71 bytes — 비교 사슬이 펼쳐진 형태
.locals init ([0] bool, [1] int32, [2] bool, [3] bool, [4] bool, [5] bool)
IL_0001: ldarg.0
IL_0002: ldc.i4.0
IL_0003: ceq // ← s == Idle?
IL_0005: stloc.0
IL_0006: ldloc.0
IL_0007: brfalse.s IL_000d // 아니면 다음 비교로
IL_0009: ldc.i4.0 // 맞으면 0 반환
IL_000a: stloc.1
IL_000b: br.s IL_0045
IL_000d: ldarg.0
IL_000e: ldc.i4.1
IL_000f: ceq // ← s == Run?
IL_0011: stloc.2
IL_0012: ldloc.2
IL_0013: brfalse.s IL_0019
// ... (case 마다 ceq + brfalse + 본문 패턴 반복)
IL_0041: ldc.i4.m1 // 모든 비교 실패 → -1
IL_0042: stloc.1
IL_0045: ldloc.1
IL_0046: ret
}
해설:
- 코드 크기 71바이트, 로컬 변수 6개(각 비교 결과 bool 임시 변수)
- 평균 비교 횟수: 5개 case 가 균등 분포라면 (1+2+3+4+5)/5 = 3회
- Attack 같이 중간에 있는 case 는 매번 3번씩 비교
✅ After — switch 문
public static int UpdateSwitch(CharState s)
{
switch (s)
{
case CharState.Idle: return 0;
case CharState.Run: return 1;
case CharState.Attack: return 2;
case CharState.Hit: return 3;
case CharState.Die: return 4;
default: return -1;
}
}
.method public hidebysig static int32 UpdateSwitch (valuetype CharState s) cil managed
{
// Code size: 59 bytes — switch opcode 하나로 압축
.locals init ([0] valuetype CharState, [1] valuetype CharState, [2] int32)
IL_0005: ldloc.0
IL_0006: switch (IL_0021, IL_0025, IL_0029, // ← 5개 라벨로 한 번에 분기
IL_002d, IL_0031)
IL_001f: br.s IL_0035 // 범위 밖 → default
IL_0021: ldc.i4.0 // case Idle
IL_0022: stloc.2
IL_0023: br.s IL_0039
// ... (각 case 단순 ldc + stloc + br)
IL_0035: ldc.i4.m1 // default
IL_0036: stloc.2
IL_0039: ldloc.2
IL_003a: ret
}
해설:
- 코드 크기 59바이트 (-17%) —
switchopcode 한 줄과 깔끔한 점프 테이블 - 로컬 변수 3개 — 각 case 의 비교 결과를 저장할 임시 bool 이 사라졌습니다
- 분기 횟수 1회 — case 위치(앞/중/뒤)와 무관하게 항상 한 번에 분기
JIT 컴파일 관점: IL의switchopcode는 JIT가 네이티브 코드로 변환할 때 점프 테이블(jmp [base + index*8]같은 간접 점프)이나 매우 짧은 비교 시퀀스로 만들어집니다. 비교 사슬보다 분기 예측 측면에서도 유리해, Update 처럼 매 프레임 호출되는 메서드에서는 누적 효과가 있습니다.
판단 기준 — switch vs if-else
| 의도 | 권장 |
|---|---|
| "이 단일 변수가 다음 상수 중 무엇인가?" | switch |
| "여러 다른 변수를 복합 조건으로 검사" | if-else |
| 정수/enum 분기가 5개 이상 | switch (점프 테이블 효과) |
| 분기가 2~3개 + 조건이 복합 | if-else (가독성 우선) |
range 검사 (x > 10 && x < 100) |
if-else (switch 는 동등 비교 전용) |
핵심: switch 는 "단일 값 → 상수 매칭" 의 의미를 코드에 명시하고, 그 의미를 컴파일러가 알아서 최적화하게 맡기는 도구입니다.
Unity Profiler 관점
UpdateIfElse 같은 메서드를 GPU/CPU 프로파일링에 잡아 보면 단독으로는 차이가 미미합니다. 그러나 다음 상황에서는 누적 효과가 가시화됩니다.
- 수백 개 적 인스턴스 각각이 매 프레임 자기 상태를 검사하는 게임 (RTS, 풀형 슈터)
- AI tick 빈도가 높은 모바일 게임
- IL2CPP 빌드 — JIT 가 없으므로 IL 의 형태가 거의 그대로 C++ 코드로 트랜슬레이트됩니다.
switchopcode 는 C++switch문으로, 비교 사슬은 if-else 로 매핑됩니다. C++ 컴파일러(Clang)도 dense 한 switch 는 점프 테이블로 최적화하므로 이점이 보존됩니다(추정).
5. [함정과 주의사항]
5.1. enum switch 에서 default 누락 — 새 멤버 추가 시 침묵 버그
❌ 잘못된 패턴
public enum State { Idle, Run, Attack }
public static void HandleMissing(State s)
{
switch (s)
{
case State.Idle: /* idle 처리 */ break;
case State.Run: /* run 처리 */ break;
case State.Attack: /* attack 처리 */ break;
// default 없음 — 기획이 State.Die 추가하면 조용히 통과
}
}
기획에서 State.Die 가 추가됐는데 이 switch 를 누군가 업데이트하지 않으면, 죽은 상태에서 아무 처리도 안 한 채 통과합니다. 게임은 멈추지 않고 동작하지만 캐릭터는 어떤 행동도 하지 않게 됩니다 — 디버깅 시간이 가장 오래 걸리는 종류의 버그입니다.
✅ 올바른 패턴
public static void HandleSafe(State s)
{
switch (s)
{
case State.Idle: /* idle 처리 */ break;
case State.Run: /* run 처리 */ break;
case State.Attack: /* attack 처리 */ break;
default:
throw new InvalidOperationException($"unhandled state: {s}");
}
}
원칙: enum 을 쓰는 switch 는 default 에 예외를 던져 "처리되지 않은 enum 멤버" 를 즉시 드러내는 것이 안전합니다. 빌드는 통과하지만 런타임에 새 멤버가 들어오는 순간 즉시 실패합니다 — fail fast.
IL 비교 — 함정 관점에서: default 가 있든 없든 IL 의 분기 구조 자체는 거의 동일합니다(없으면 default 라벨이 switch 다음 라벨로 합쳐질 뿐). 그래서 컴파일러는 default 누락을 경고하지 않습니다. C# 7+ 의 switch 표현식만 누락 시 컴파일러 경고를 발생시킵니다.
5.2. case 라벨은 컴파일 타임 상수여야 한다
int threshold = ReadConfig(); // 런타임 변수
switch (n)
{
case threshold: ...; break; // CS0150: A constant value is expected
}
case 라벨은 다음만 허용됩니다.
- 리터럴 (
1,"hello",'a') const로 선언된 상수- enum 멤버
null(참조 타입 / Nullable<T> 의 경우)
런타임에 결정되는 값을 case 로 쓰고 싶다면 if-else 사슬이나 C# 7+ 의 case T t when ... 패턴을 써야 합니다. 그러나 when 절을 도입하면 점프 테이블 최적화가 사라지고 비교 사슬로 변환됩니다(추정).
5.3. default 위치 자유는 가독성의 적
switch (n)
{
default: // 위에 있어도 합법
Console.WriteLine("?");
break;
case 1:
Console.WriteLine("one");
break;
}
C# 명세상 default 는 switch 안 어디에도 위치할 수 있고, 평가 순서는 항상 마지막입니다. 그러나 코드를 읽는 사람은 위에서 아래로 읽기 때문에 default 가 중간에 있으면 case 1 이 default 의 폴스루로 보일 수 있습니다 — 빈 case 의 fall-through 와 혼동을 일으킵니다. default 는 항상 마지막에 놓는 것이 관습입니다.
5.4. break 누락은 컴파일 에러, 빈 case 는 OK — 헷갈리는 경계
// ❌ CS0163 — Control cannot fall through from one case label
switch (n)
{
case 1:
DoOne(); // break 없음 → 컴파일 실패
case 2:
DoTwo();
break;
}
// ✅ 빈 case 는 fall-through 허용
switch (n)
{
case 1:
case 2: // 본문 없음 → 다음 라벨로 떨어지는 게 의도임이 명백
case 3:
DoOneTwoOrThree();
break;
}
원칙: 본문이 있는 case 는 무조건 종결문(break/return/throw/goto/continue)으로 끝나야 한다. 빈 case 만 예외로, 컴파일러가 "여러 라벨 → 같은 본문" 이라는 의도라고 판단합니다.
5.5. goto case · goto default — 명시적 fall-through 의 함정
암묵적 fall-through 는 막혔지만, 명시적 점프는 허용됩니다.
public enum State { Init, Idle, Error, Done }
public static string Handle(State s)
{
switch (s)
{
case State.Init:
Console.WriteLine("init");
goto case State.Idle; // ← 명시적 fall-through
case State.Idle:
return "idle";
case State.Error:
Console.WriteLine("error logged");
goto default; // ← default 로 명시적 점프
default:
return "reset";
}
}
.method public hidebysig static string Handle (valuetype State s) cil managed
{
.locals init ([0] valuetype State, [1] valuetype State, [2] string)
IL_0006: switch (IL_0019, IL_0026, IL_002e) // 점프 테이블 (Init·Idle·Error)
IL_0017: br.s IL_003b // 범위 밖 → default
IL_0019: ldstr "init" // case Init 본문
IL_001e: call void Console::WriteLine(string)
IL_0023: nop
IL_0024: br.s IL_0026 // ← goto case Idle 의 IL: 단순 br
IL_0026: ldstr "idle" // case Idle 본문 — 위에서 br 로 진입 가능
IL_002b: stloc.2
IL_002c: br.s IL_0043
IL_002e: ldstr "error logged" // case Error 본문
IL_0033: call void Console::WriteLine(string)
IL_0038: nop
IL_0039: br.s IL_003b // ← goto default 의 IL: 단순 br
IL_003b: ldstr "reset" // default
IL_0040: stloc.2
IL_0041: br.s IL_0043
IL_0043: ldloc.2
IL_0044: ret
}
해설:
goto case/goto default는 IL 에서 단순한br.s(무조건 분기) 한 줄로 변환됩니다. 런타임 비용은 0에 가깝습니다.- 그러나 코드 흐름이 비선형이 됩니다 —
case Init본문을 읽다가 갑자기case Idle로 점프합니다. 코드 리뷰어가 머리속에서 그래프를 그려야 합니다. - 컴파일러는 무한 루프 (
case A: goto case B; case B: goto case A;) 를 막지 못합니다. 의도치 않으면 스택 오버플로우 없이 그냥 무한 반복합니다.
권장:goto case/goto default는 한 case 의 시작 부분 처리만 다르고 본문이 같은 경우에만 쓰는 것이 안전합니다. 그 외에는 별도 메서드로 추출하는 편이 가독성이 좋습니다.
5.6. Update() 에서 string switch — 매 프레임 해시 계산
// ❌ Update 안에서 string switch
void Update()
{
switch (Input.GetAxisName()) // string 반환
{
case "Horizontal": ...; break;
case "Vertical": ...; break;
case "Jump": ...; break;
// ...
}
}
문제:
- 매 프레임 해시 계산 —
ComputeStringHash가 매 프레임 호출됩니다. case 가 6개 이하라면String.Equals호출 사슬이라 더 비쌉니다. - GC 압박 가능 —
Input.GetAxisName()같은 API 가 새 string 을 반환하면 GC alloc 발생. - 오타에 취약 — 컴파일러가
"Jumb"같은 오타를 잡아주지 못합니다.
// ✅ enum 으로 변환
public enum Axis { Horizontal, Vertical, Jump }
void Update()
{
Axis axis = ResolveAxis();
switch (axis)
{
case Axis.Horizontal: ...; break;
case Axis.Vertical: ...; break;
case Axis.Jump: ...; break;
default: throw new InvalidOperationException();
}
}
enum switch 는 점프 테이블 IL 이라 매 프레임 호출에 안전하고, 오타는 컴파일 타임에 잡힙니다.
6. [C# 버전별 변화]
switch 문 자체의 핵심 의미론(case·break·default·암묵적 fall-through 금지·goto case)은 C# 1.0 부터 2026년 현재까지 변하지 않았습니다. 다만 case 라벨이 받는 형태는 단계적으로 확장되었습니다.
C# 1~6 — 고전 switch (이 글의 주제)
switch (n)
{
case 1: ...
case 2: ...
default: ...
}
- governing type: 정수형, char, bool, string, enum, 위 타입의 Nullable<T>
- case 라벨: 컴파일 타임 상수
- 컴파일러 최적화: 점프 테이블 / 비교 사슬 / 해시 분기
C# 7.0 (2017) — 패턴 매칭 도입
switch (obj)
{
case int n when n > 0: ... // ← 타입 패턴 + when 절
case string s: ...
case null: ...
default: ...
}
- governing type 제약 제거 — 모든 타입 허용
case T t when ...형태로 동적 조건 결합- 트레이드오프: when 절이나 타입 패턴이 들어가면 컴파일러는 점프 테이블 대신 비교 사슬을 만듭니다(추정). 단순 상수 case 만 있던 고전 switch 의 최적화 이점은 부분적으로 사라집니다.
- case 선언 순서가 의미를 가지게 됩니다 — 포괄적인 패턴(
case int i:) 이 구체적 패턴(case int i when i > 0:) 보다 위에 있으면 뒤 case 가 도달 불가 컴파일 에러.
C# 8.0 (2019) — switch 표현식
string label = n switch
{
1 => "one",
2 => "two",
_ => "other",
};
- statement 가 아닌 expression — 값을 반환합니다
break불필요,=>로 case → 결과 매핑- 모든 경우를 다루지 않으면 컴파일러 경고 + 런타임
SwitchExpressionException - 별도 글에서 다룹니다. 이 글의 주제는 아니지만 짧게 비교만 합니다.
| 항목 | switch 문 (이 글) | switch 식 (C# 8) |
|---|---|---|
| 위치 | statement (부수효과) | expression (값) |
| 종결자 | break/return 등 |
=> 화살표만 |
| 누락 시 | 그냥 통과 | SwitchExpressionException |
| IL 분기 | 동일한 점프 테이블/비교 사슬 전략 적용 | 동일 |
C# 9~11 — 패턴 확장 (별도 글에서)
- C# 9: 관계 패턴(
case > 0:), 논리 패턴(case > 0 and < 10:) - C# 10: 확장 속성 패턴(
case { User.Name: "..." }:) - C# 11: 리스트 패턴(
case [1, 2, ..]:)
이 패턴들은 switch 문/식 모두에서 동작합니다. 자세한 내용은 패턴 매칭 시리즈 글을 참고하세요.
7. [정리]
이 글에서 다룬 핵심을 7가지 체크리스트로 정리합니다.
- switch 는 단일 값 → 상수 매칭 전용 문법: "이 변수가 다음 중 무엇인가?" 의 의도를 코드에 명시한다. 여러 변수를 복합 조건으로 검사하는 if-else 사슬과 의미가 다르다.
- C# 은 암묵적 fall-through 를 금지한다: 본문 있는 case 는 반드시
break/return/throw/goto/continue로 끝낸다. 빈 case 만 fall-through 허용. CS0163 컴파일 에러로 잡힌다. goto case/goto default로 명시적 fall-through: IL 에서는 단순br.s한 줄. 무한 루프 위험 있고 가독성을 해치므로 신중히 사용.- 컴파일러는 case 분포에 따라 분기 전략을 바꾼다: dense 정수 → CIL
switchopcode (점프 테이블), sparse 정수 →beq비교 사슬, string (case ≥ ~7) →ComputeStringHash해시 분기 +op_Equality검증. - case 라벨은 반드시 컴파일 타임 상수: 리터럴/
const/enum 멤버/null만 허용. 런타임 변수를 쓰려면 if-else 또는 C# 7+ when 절. - enum switch 에는 항상 default 에 예외 던지기: 새 enum 멤버 추가 시 침묵 버그를 즉시 드러내는 fail-fast 패턴. 빌드는 통과해도 런타임에 즉시 실패한다.
- Unity 핫패스에서 string switch 회피:
Update()안의 string switch 는 매 프레임 해시 계산 + 잠재적 GC alloc. enum 으로 변환하면 점프 테이블 IL 로 안전.
다음 글: switch 표현식 (C# 8) — break 가 사라지고 값을 반환하는 식으로 진화한 switch
'C# 기초' 카테고리의 다른 글
| [PART4.제어문: 분기와 반복(4/13)] 패턴 매칭 기초 — is 타입 패턴 (0) | 2026.04.26 |
|---|---|
| [PART4.제어문: 분기와 반복(3/13)] switch 표현식 — 값을 돌려주는 분기 (0) | 2026.04.26 |
| [PART4.제어문: 분기와 반복(1/13)] if · else if · else (0) | 2026.04.26 |
| [PART3.연산자와 표현식(11/11)] 문자열 보간과 기본 포맷팅 — `$"..."` 한 줄이 만들어내는 차이 (0) | 2026.04.25 |
| [PART3.연산자와 표현식(10/11)] 사용자 정의 복합 할당 연산자 — `+=`·`-=` 를 직접 정의 (C# 14) (0) | 2026.04.25 |