[PART4.제어문: 분기와 반복(3/13)] switch 표현식 — 값을 돌려주는 분기
분기 자체가 값을 돌려주는 식(expression)으로 변신했다 / 모든 입력에 대해 결과를 정의해야 한다는 완전성(exhaustiveness) 검사 / 패턴 매칭과 결합해 if-else 사슬을 한 식으로 압축
목차
1. [문제 제기] — switch 문에서 한 발만 더 나갔으면 좋겠다
이전 글([switch 문 — 고전적 형태](../2.%20switch%20%EB%AC%B8%20%E2%80%94%20%EA%B3%A0%EC%A0%84%EC%A0%81%20%ED%98%95%ED%83%9C/result.md))에서 살펴본 switch 문은 "단일 값 → 상수 매칭" 의도를 정확히 표현했습니다. 그런데 Unity 프로젝트에서 이 패턴이 자주 등장합니다.
public enum State { Idle, Run, Attack, Hit, Die }
public float GetSpeed(State s)
{
float speed;
switch (s)
{
case State.Idle: speed = 0f; break;
case State.Run: speed = 6f; break;
case State.Attack: speed = 0.5f; break;
case State.Hit: speed = 1f; break;
case State.Die: speed = 0f; break;
default: speed = 0f; break;
}
return speed;
}
들여다보면 어색한 구석이 있습니다.
- 메서드는 결국 하나의 값(
float)을 반환할 뿐인데, 그 값을 담을 임시 변수speed를 선언해야 합니다. - 모든 case 가 같은 일을 합니다 —
speed = X; break;. 의미상의 노이즈입니다. default가 빠지면 컴파일은 통과하지만speed가 초기화되지 않은 채 반환되는 위험이 생깁니다(컴파일러가 잡아주긴 하지만, 그건 별개 안전망입니다).
이런 "분기가 결국 값 하나를 만들기 위한 것" 인 패턴은 함수형 언어에서는 식(expression) 으로 자연스럽게 표현됩니다. C# 8.0(2019) 은 같은 의도를 위해 switch 표현식(switch expression) 을 도입했습니다.
public float GetSpeed(State s) => s switch
{
State.Idle => 0f,
State.Run => 6f,
State.Attack => 0.5f,
State.Hit => 1f,
State.Die => 0f,
_ => 0f,
};
같은 의미, 절반의 코드, 그리고 "이 분기는 값을 만든다" 는 의도가 명시적입니다. 이 글에서는 이 새 형태가 단순한 syntactic sugar 인지, 아니면 의미론까지 바뀐 별개 도구인지, IL 레벨에서는 switch 문과 어떻게 다른지, 어떤 함정이 새로 생겼는지 살펴봅니다.
참고: 이 글의 주제는 switch 표현식(C# 8) 입니다. switch 문(statement)과의 비교가 본문 곳곳에 등장하지만, switch 문 자체의 IL 분기 전략(점프 테이블 vs 비교 사슬)은 [이전 글](../2.%20switch%20%EB%AC%B8%20%E2%80%94%20%EA%B3%A0%EC%A0%84%EC%A0%81%20%ED%98%95%ED%83%9C/result.md)에서 자세히 다뤘습니다.
2. [개념 정의] — 분기가 식이 되었다
비유 — 자판기와 명령서
이전 글의 우체국 분류 기계 비유를 확장해 봅니다. switch 문은 분류 기계에게 "1번이면 A 컨베이어, 2번이면 B 컨베이어" 라는 명령서를 주는 것입니다 — 기계는 명령을 수행하지만 결과물을 손에 쥐어주지는 않습니다(직접 컨베이어를 보러 가야 합니다).
switch 표현식은 자판기입니다. 동전(입력값) 한 개를 넣으면 음료수(결과값) 한 개가 떨어집니다 — 입력 → 출력의 매핑 자체가 거래의 본질입니다. 그래서 모든 동전에 대해 어떤 음료가 나올지 정의되어 있어야 합니다(그렇지 않으면 자판기는 동전을 삼키고 침묵합니다 — 이게 바로 SwitchExpressionException입니다).
구조

기본 문법
var result = subject switch
{
pattern1 => value1,
pattern2 => value2,
_ => defaultValue,
};
subject— 검사할 입력값. switch 문의switch(s)와 다르게 subject 가 먼저 옵니다. 영어 어순처럼 "이 값이, 다음 패턴들 중 하나면" 으로 읽힙니다.pattern => value— 패턴이 매치되면 그 표현식이 결과가 됩니다.:와break가 모두 사라졌습니다.- 분기들은 쉼표(
,)로 구분됩니다. 마지막 분기에도 trailing comma 를 허용합니다. _— discard 패턴. switch 문의default:에 해당하지만, "값을 받지만 쓰지 않는다" 는 패턴 의미가 더 정확합니다.
switch 문과의 5가지 차이 — 한눈에
| 항목 | switch 문 (statement) | switch 표현식 (expression) |
|---|---|---|
| 위치 | 문(statement) — 부수효과 실행 | 식(expression) — 값으로 평가 |
| 종결자 | break/return/throw/goto |
=> 화살표 — 종결자 없음 |
| 분기 구분 | case L: 라벨 |
, (쉼표) |
| 누락 처리 | default 없어도 OK — 그냥 통과 |
컴파일러 경고 + 런타임 SwitchExpressionException |
| null 입력 | subject 가 null 이면 분기에 들어가기 전 대부분 NRE |
null 패턴으로 명시적으로 받을 수 있음 |
핵심: switch 표현식은 "분기를 식으로 한 등급 올린" 형태입니다. 식은 값을 만들기 때문에, "모든 입력에 대해 무엇을 돌려줄지" 를 컴파일러가 검사할 수 있는 토대가 마련됩니다.
3. [동작 원리] — 컴파일러는 결국 분기 IL 을 만든다
핵심 질문 — switch 표현식은 새로운 IL 명령인가?
아닙니다. switch 표현식은 컴파일러 단계에서 풀리는 신택스입니다. CLR 에는 "switch expression" 이라는 별도 opcode 가 없습니다. 컴파일러는 switch 표현식을 보고 내부적으로 switch 문 또는 if-else 사슬과 동등한 IL 을 생성합니다 — 단, 결과를 변수에 저장해서 식의 위치로 흘려보내는 점만 다릅니다.
이 사실이 중요한 이유는, "어떤 형태가 더 빠른가" 에 대한 답이 단순하기 때문입니다 — 같은 케이스 분포라면 IL 도 같습니다.
검증 — dense enum 에서의 IL 비교
다음 두 메서드를 같은 enum 으로 구현하고 IL 을 디컴파일했습니다.
public enum State { Idle, Run, Attack, Hit, Die }
// (A) switch 문
public static int HandleStmt(State s)
{
int result;
switch (s)
{
case State.Idle: result = 0; break;
case State.Run: result = 1; break;
case State.Attack: result = 2; break;
case State.Hit: result = 3; break;
case State.Die: result = 4; break;
default: result = -1; break;
}
return result;
}
// (B) switch 표현식
public static int HandleExpr(State s) => s switch
{
State.Idle => 0,
State.Run => 1,
State.Attack => 2,
State.Hit => 3,
State.Die => 4,
_ => -1,
};
ilspycmd 로 Release 빌드의 IL 을 추출한 결과(둘 다 코드 크기 52바이트, .maxstack 1, 로컬 변수 1개로 동일):
// (A) HandleStmt 의 IL
.method public hidebysig static int32 HandleStmt (valuetype Demo/State s) cil managed
{
.locals init ([0] int32)
IL_0000: ldarg.0
IL_0001: switch (IL_001c, IL_0020, IL_0024, IL_0028, IL_002c) // ← 점프 테이블
IL_001a: br.s IL_0030 // 범위 밖 → default
IL_001c: ldc.i4.0
IL_001d: stloc.0
IL_001e: br.s IL_0032
// ... (case Run, Attack, Hit, Die 동일 패턴)
IL_0030: ldc.i4.m1 // default
IL_0031: stloc.0
IL_0032: ldloc.0
IL_0033: ret
}
// (B) HandleExpr 의 IL — 바이트 단위로 동일
.method public hidebysig static int32 HandleExpr (valuetype Demo/State s) cil managed
{
.locals init ([0] int32)
IL_0000: ldarg.0
IL_0001: switch (IL_001c, IL_0020, IL_0024, IL_0028, IL_002c) // ← 동일한 점프 테이블
IL_001a: br.s IL_0030
IL_001c: ldc.i4.0
IL_001d: stloc.0
IL_001e: br.s IL_0032
// ... (HandleStmt 와 완전히 동일)
IL_0030: ldc.i4.m1
IL_0031: stloc.0
IL_0032: ldloc.0
IL_0033: ret
}
관찰된 사실:
- 두 메서드의 IL 은 바이트 단위로 동일합니다. 코드 크기 52바이트, 같은 점프 테이블, 같은 라벨 구조.
- switch 표현식이 점프 테이블 최적화를 잃는 게 아닌가 하는 흔한 우려는 dense enum 에서는 사실이 아닙니다. 컴파일러는 같은 분포에 같은 분기 전략을 적용합니다.
- 다른 점은 오직 소스 표현뿐입니다 — 한 쪽은 임시 변수
result와break를 명시적으로 썼고, 다른 쪽은 컴파일러가 자동으로 같은 임시 변수([0] int32)를 만들었습니다.
JIT 관점: 두 메서드는 IL 이 같으므로 JIT 가 만들어내는 네이티브 코드도 같습니다. 즉 switch 표현식 vs switch 문의 성능 차이는 dense 정수/enum 에서는 0 입니다. 차이가 생긴다면 그건 case 분포(sparse, 패턴 매칭, when 절)나 컴파일러 버전 때문이지, 표현식/문 자체의 차이가 아닙니다.
누락된 케이스 — SwitchExpressionException 의 IL
다음 코드는 int 의 가능한 모든 값을 다루지 않으므로 컴파일러가 CS8509 경고를 발생시킵니다.
public static string DescribeMissing(int x) => x switch
{
1 => "one",
2 => "two",
// ⚠️ CS8509: switch 식에서 입력 형식의 가능한 값을 모두 처리하지는 않습니다(전체 아님).
// 예를 들어 '0' 패턴은 포함되지 않습니다.
};
.method public hidebysig static string DescribeMissing (int32 x) cil managed
{
.locals init ([0] string)
IL_0000: ldarg.0
IL_0001: ldc.i4.1
IL_0002: beq.s IL_000a // x == 1 ? → "one"
IL_0004: ldarg.0
IL_0005: ldc.i4.2
IL_0006: beq.s IL_0012 // x == 2 ? → "two"
IL_0008: br.s IL_001a // 둘 다 아니면 throw 블록으로
IL_000a: ldstr "one"
IL_000f: stloc.0
IL_0010: br.s IL_0025
IL_0012: ldstr "two"
IL_0017: stloc.0
IL_0018: br.s IL_0025
IL_001a: ldarg.0
IL_001b: box [System.Runtime]System.Int32 // ← 입력값을 object 로 박싱
IL_0020: call void '<PrivateImplementationDetails>'::ThrowSwitchExpressionException(object)
// ← 자동 주입된 헬퍼 호출
IL_0025: ldloc.0
IL_0026: ret
}
// 컴파일러가 같은 어셈블리에 자동 추가하는 헬퍼:
.method assembly hidebysig static void ThrowSwitchExpressionException (object unmatchedValue)
{
IL_0000: ldarg.0
IL_0001: newobj instance void [System.Runtime]System.Runtime.CompilerServices.SwitchExpressionException::.ctor(object)
IL_0006: throw
}
핵심:
- 컴파일러가 자동으로 throw 블록을 삽입합니다. 소스 코드에는 어디에도
throw가 없지만, IL 의IL_001a부터가 자동 주입된 종결 처리입니다. <PrivateImplementationDetails>::ThrowSwitchExpressionException은 컴파일러가 어셈블리에 추가하는 비공개 헬퍼 메서드입니다. 같은 어셈블리에 switch 표현식이 여러 개 있어도 이 헬퍼는 한 번만 생성되어 공유됩니다(추정 — 어셈블리 단위 코드 크기 절약 목적).- 입력값은
box명령으로 박싱되어 예외에 첨부됩니다.int같은 값 타입을object매개변수로 넘기기 위함이며, 디버깅 시SwitchExpressionException.UnmatchedValue로 확인 가능합니다. - switch 문에서는 이 자동 throw 가 없습니다 — case 가 다 안 맞으면 그냥 switch 블록을 통과하고 끝입니다. 이게 switch 표현식의 안전성을 만드는 가장 큰 차이입니다.
패턴 매칭 — 점프 테이블이 풀리는 순간
dense enum 처럼 case 가 모두 상수면 컴파일러는 점프 테이블을 만듭니다. 하지만 패턴 매칭(타입 패턴, 속성 패턴, 관계 패턴)이 들어오면 점프 테이블 최적화는 더 이상 가능하지 않습니다 — 각 패턴이 단순 정수 비교가 아니기 때문입니다.
public abstract record Shape;
public record Circle(double Radius) : Shape;
public record Rect(double W, double H) : Shape;
public static double Area(Shape s) => s switch
{
Circle { Radius: > 0 } c => Math.PI * c.Radius * c.Radius,
Rect { W: > 0, H: > 0 } r => r.W * r.H,
null => 0,
_ => -1,
};
.method public hidebysig static float64 Area (class Demo/Shape s) cil managed
{
// Code size: 146 bytes — 비교 사슬
.locals init ([0] class Demo/Circle, [1] class Demo/Rect, [2] float64)
IL_0000: ldarg.0
IL_0001: isinst Demo/Circle // ← 타입 검사 (Circle?)
IL_0006: stloc.0
IL_0007: ldloc.0
IL_0008: brfalse.s IL_001d // 아니면 다음 패턴
IL_000a: ldloc.0
IL_000b: callvirt instance float64 Demo/Circle::get_Radius()
IL_0010: ldc.r8 0.0
IL_0019: bgt.s IL_0050 // ← 관계 패턴 (Radius > 0)
IL_001b: br.s IL_0086 // 실패 → default
IL_001d: ldarg.0
IL_001e: isinst Demo/Rect // ← 다음 타입 검사 (Rect?)
IL_0023: stloc.1
IL_0024: ldloc.1
IL_0025: brfalse.s IL_004b // 아니면 null 검사로
// ... (W > 0 && H > 0 검사 — bgt.s · ble.un.s 사슬)
IL_004b: ldarg.0
IL_004c: brfalse.s IL_007a // ← null 패턴 — s == null?
IL_004e: br.s IL_0086 // 셋 다 아니면 default
IL_0050: ldc.r8 3.141592653589793 // Circle 본문
// ... (각 패턴의 결과 표현식)
IL_0086: ldc.r8 -1 // _ (default)
IL_008f: stloc.2
IL_0090: ldloc.2
IL_0091: ret
}
관찰된 사실:
- 코드 크기 146바이트 — dense enum 케이스(52바이트)의 약 3배. 패턴 매칭은 점프 테이블을 만들 수 없으므로 비교 사슬로 풀어집니다.
isinst+brfalse.s— 타입 패턴.s is Circle c와 동일한 IL.bgt.s/ble.un.s— 관계 패턴(> 0). 부동소수점 비교는un.s변형(NaN 안전).- null 검사는 마지막 분기 —
brfalse.s한 줄. switch 문에서는 입력이 null 이면 분기 시작 전 NRE 가 났지만, switch 표현식은 패턴으로 명시적으로 받을 수 있습니다.
핵심 정리: switch 표현식의 IL 비용은 케이스의 종류에 따라 달라집니다.
| 케이스 종류 | IL 분기 전략 | switch 문 대비 비용 |
|---|---|---|
| dense 정수/enum (case n개, 0~n-1) | switch opcode (점프 테이블) |
동일 |
| sparse 정수 (case가 멀리 떨어짐) | beq 비교 사슬 |
동일 |
| string (case ≥ ~7) | 해시 분기 + op_Equality |
동일 |
| 타입/속성/관계 패턴 결합 | isinst + 조건부 분기 사슬 |
패턴 수에 비례한 추가 비용 |
| 누락된 분기 | ThrowSwitchExpressionException 자동 주입 |
switch 문에는 없는 IL |
이 표는 위 IL 결과로부터 직접 관찰한 사실입니다. 케이스 수가 매우 많거나 분포가 복잡한 경우의 정확한 임계치는 추정치입니다.
패턴 평가 순서 — 위에서 아래로, 첫 매치만
switch 표현식의 분기들은 소스에 작성된 순서대로 평가되며, 첫 번째로 매치되는 분기 하나만 실행됩니다. 이는 IL 의 비교 사슬에 그대로 반영됩니다 — 각 패턴은 brfalse.s/bgt.s 같은 분기로 풀려 위에서부터 차례로 검사됩니다.

이 순서 의존성은 5. 함정과 주의사항 의 도달 불가 패턴 절에서 자세히 다룹니다.
4. [실전 적용] — switch 문에서 표현식으로 옮기는 기준
변환이 자연스러운 경우
switch 가 단일 값을 반환하기 위한 분기 라면 표현식이 거의 항상 더 명확합니다.
Before — switch 문
public string GetGrade(int score)
{
string grade;
if (score >= 90) grade = "A";
else if (score >= 80) grade = "B";
else if (score >= 70) grade = "C";
else if (score >= 60) grade = "D";
else grade = "F";
return grade;
}
After — switch 표현식 + 관계 패턴
public string GetGrade(int score) => score switch
{
>= 90 => "A",
>= 80 => "B",
>= 70 => "C",
>= 60 => "D",
_ => "F",
};
해설:
- 임시 변수 제거 —
grade가 사라졌습니다. - 관계 패턴(C# 9+) —
>= 90자체가 패턴입니다. switch 문에서는case >= 90:도 허용되지만, 표현식의=>와 결합할 때 의미가 더 자연스럽습니다. - 순서 의존성 명시 — 위에서 아래로 검사되므로 큰 값부터 작은 값 순으로 작성합니다. 90 부터 검사하므로
>= 60분기는 60~69 만 매치합니다.
변환하지 말아야 할 경우
각 분기가 여러 동작을 수행하면 switch 문이 더 적합합니다.
// ✅ switch 문이 적절 — 각 case 가 여러 부수효과를 실행
switch (command)
{
case "start":
_logger.Log("서비스 시작");
StartService();
UpdateStatus(ServiceStatus.Running);
NotifyClients();
break;
case "stop":
_logger.Log("서비스 중지");
StopService();
UpdateStatus(ServiceStatus.Stopped);
break;
default:
_logger.LogWarning($"알 수 없는 명령: {command}");
break;
}
이를 표현식으로 강제 변환하려면 모든 분기를 람다로 감싸거나 결과를 던져버려야 합니다 — 의도가 흐려집니다.
판단 기준:
| 의도 | 권장 형태 |
|---|---|
| "이 분기는 결국 값 하나를 만든다" | switch 표현식 |
| "각 분기마다 여러 부수효과(로깅·이벤트 발행·상태 변경)" | switch 문 |
goto case / goto default 가 필요 |
switch 문 (표현식은 불가능) |
| 패턴 매칭이 본격적으로 들어감 | switch 표현식 (=> 와 어울림) |
default 가 명시적으로 통과여야 함 |
switch 문 |
Unity 핫패스 — 상태→속도, 상태→애니메이션
public enum PlayerState { Idle, Walk, Run, Jump, Attack, Hit, Die }
public class PlayerController : MonoBehaviour
{
private PlayerState _state;
private Animator _animator;
void Update()
{
// 상태 → 이동 속도 매핑
float speed = _state switch
{
PlayerState.Idle => 0f,
PlayerState.Walk => 2.5f,
PlayerState.Run => 6f,
PlayerState.Jump => 3f,
PlayerState.Attack => 0.5f,
PlayerState.Hit => 1f,
PlayerState.Die => 0f,
_ => throw new InvalidOperationException($"unhandled state: {_state}"),
};
transform.Translate(Vector3.forward * speed * Time.deltaTime);
}
public void SetState(PlayerState next)
{
if (_state == next) return;
_state = next;
// 상태 → 애니메이션 트리거
string trigger = next switch
{
PlayerState.Idle => "ToIdle",
PlayerState.Walk => "ToWalk",
PlayerState.Run => "ToRun",
PlayerState.Jump => "ToJump",
PlayerState.Attack => "ToAttack",
PlayerState.Hit => "ToHit",
PlayerState.Die => "ToDie",
_ => throw new InvalidOperationException($"unhandled state: {next}"),
};
_animator.SetTrigger(trigger);
}
}
핵심 패턴:
_ => throw …— 새 enum 멤버가 추가됐을 때 즉시 실패하도록 던집니다(fail-fast). switch 문에서default: throw …와 동일한 의도.- dense enum + 상수 결과 — 위 IL 분석에서 확인했듯, 점프 테이블 최적화가 적용됩니다.
Update()같은 핫패스에서도 안전합니다. - 임시 변수 제거 —
float speed; switch(...) { ... } use(speed);의 4줄이 한 식으로 압축됩니다.
Unity 호환성: switch 표현식은 C# 8 기능이므로 Unity 2020.3 이상(<LangVersion>8.0+)에서 사용 가능합니다. IL2CPP 빌드에서도 일반 IL 로 컴파일된 뒤 C++ 로 트랜슬레이트되므로 모바일에서도 안전합니다(추정 —switchopcode 와isinst모두 IL2CPP 가 지원).
패턴 결합 — switch 문 한 덩어리를 표현식으로 압축
이전 글에서 살펴본 5상태 캐릭터 상태머신을 패턴 매칭과 결합하면 더 압축할 수 있습니다.
// ❌ Before — switch 문 + if 분기 혼합
public DamageInfo CalcDamage(Attacker a, Defender d)
{
DamageInfo info;
switch (a.Type)
{
case AttackType.Melee:
if (d.HasShield) info = new DamageInfo(a.Base * 0.5f, "blocked");
else info = new DamageInfo(a.Base, "melee");
break;
case AttackType.Ranged:
if (d.Distance > 10) info = new DamageInfo(a.Base * 0.7f, "ranged-far");
else info = new DamageInfo(a.Base, "ranged");
break;
case AttackType.Magic:
info = new DamageInfo(a.Base * (1 - d.MagicResist), "magic");
break;
default:
info = new DamageInfo(0, "unknown");
break;
}
return info;
}
// ✅ After — switch 표현식 + 속성 패턴 + when 절
public DamageInfo CalcDamage(Attacker a, Defender d) => (a.Type, d) switch
{
(AttackType.Melee, { HasShield: true }) => new(a.Base * 0.5f, "blocked"),
(AttackType.Melee, _) => new(a.Base, "melee"),
(AttackType.Ranged, { Distance: > 10 }) => new(a.Base * 0.7f, "ranged-far"),
(AttackType.Ranged, _) => new(a.Base, "ranged"),
(AttackType.Magic, var def) => new(a.Base * (1 - def.MagicResist), "magic"),
_ => new(0, "unknown"),
};
해설:
- 튜플 패턴
(a.Type, d)— 두 값을 하나의 튜플로 묶어 한 번에 매치. 중첩된 switch+if 가 평탄해집니다. - 속성 패턴
{ HasShield: true }— Defender 의 속성을 직접 검사. 별도 if 분기가 사라집니다. - 순서 의존성 —
Melee+Shield가Melee+_보다 먼저 와야 합니다(반대면 도달 불가 컴파일 에러). - IL 비용 — 점프 테이블이 아닌 비교 사슬로 풀립니다. 분기가 수십 개로 늘어나면 if-else 사슬과 비슷한 비용. 단, 가독성 이득이 크므로 일반적으로 권장.
5. [함정과 주의사항]
5.1. 누락된 케이스 — 컴파일 경고 무시 시 SwitchExpressionException
❌ 잘못된 패턴
public string DescribeHttpStatus(int code) => code switch
{
200 => "OK",
404 => "Not Found",
500 => "Server Error",
// ⚠️ CS8509 — 모든 가능한 값을 다루지 않음
};
// 런타임:
DescribeHttpStatus(403);
// → System.Runtime.CompilerServices.SwitchExpressionException
// Non-exhaustive switch expression failed to match its input value.
// Unmatched value was 403.
문제:
- 컴파일러는 CS8509 경고를 발생시키지만 빌드는 통과합니다. 경고를 무시하고 배포하면 사용자가 처음 보는 코드(403, 401, 503...)에서 모두 예외가 던져집니다.
- 예외 메시지에
Unmatched value was 403이 포함되어 디버깅은 비교적 쉽습니다(NRE 보다 정보가 많음). - 그러나 이 예외는 게임 로직의 일부가 아닌 "프로그래머 실수의 신호" 입니다. 사용자에게 도달하면 안 됩니다.
✅ 올바른 패턴
public string DescribeHttpStatus(int code) => code switch
{
200 => "OK",
404 => "Not Found",
500 => "Server Error",
_ => $"Unknown status: {code}", // ← 명시적 fallback
};
원칙: switch 표현식은 항상 모든 입력에 대해 결과가 정의되어야 합니다. 그렇게 만들지 않으면 컴파일러가 경고로 알려주지만, 경고를 그냥 두면 사고가 사용자에게 도달합니다. CS8509 는 경고로 두지 말고 <TreatWarningsAsErrors> 또는 적어도 CS8509 만이라도 에러로 승격시키는 것을 추천합니다.
enum 의 경우: enum 의 모든 멤버를 다뤘다면 컴파일러가 경고하지 않습니다 — 단, 정의된 enum 멤버 한정. enum 은 internal 표현이 정수이므로(State)999같은 비정의 값이 입력되면 여전히SwitchExpressionException이 발생합니다. enum 도_ => throw …분기를 두는 것이 안전합니다.
5.2. 패턴 순서 — 도달 불가 패턴(CS8120/CS8510)
❌ 잘못된 패턴
public abstract record Shape;
public record Circle(double Radius) : Shape;
double Area(Shape s) => s switch
{
Shape sh => 0, // ← 너무 일반적인 패턴 먼저
Circle c => Math.PI * c.Radius * c.Radius, // ← CS8120: 도달할 수 없음
_ => -1, // ← CS8510: 도달할 수 없음
};
컴파일러는 다음을 알려줍니다:
- CS8120:
Circle c패턴은 위의Shape sh패턴에 이미 포섭됨 → 도달 불가. - CS8510:
_패턴 역시 도달 불가.
이는 컴파일 에러 입니다(경고가 아님). 빌드 자체가 실패합니다.
✅ 올바른 패턴 — 구체 → 일반 순서
double Area(Shape s) => s switch
{
Circle c => Math.PI * c.Radius * c.Radius, // 가장 구체적
Rect r => r.W * r.H, // 다음 구체
null => 0, // null 처리
_ => -1, // 가장 일반 — fallback
};
원칙: switch 표현식의 분기들은 구체적인 패턴부터 일반적인 패턴 순으로 배치합니다. 이는 단순한 스타일이 아니라 컴파일러가 강제합니다 — 도달 불가 패턴은 에러로 빌드를 막아줍니다. switch 문에서는 같은 위반이 가능했지만(컴파일러가 잡아주지 않음), 표현식에서는 이 위험이 사라졌습니다.
언제 헷갈리나: when 절을 결합한 패턴에서 자주 발생합니다. 예:case int n: ...다음에case int n when n > 0: ...이 오면 두 번째는 도달 불가. when 절을 가진 더 구체적인 패턴이 항상 위에 와야 합니다.
5.3. null 처리 — 두 형태가 의미론이 다르다
이 차이는 switch 문과 표현식의 가장 의미 있는 차이 중 하나입니다.
switch 문 — null 입력 시 NRE
string? name = GetName(); // null 가능
switch (name.Length) // ⚠️ name 이 null 이면 여기서 NRE
{
case 0: ...; break;
case > 0: ...; break;
}
switch(name.Length) 에서 name 이 null 이면 .Length 접근이 실패합니다 — case 평가 전에 NullReferenceException. switch 문은 이 시점에 어떤 분기로도 들어가지 않습니다.
switch 표현식 — null 을 패턴으로
string? name = GetName();
string description = name switch
{
null => "no name", // ← null 을 명시적 패턴으로 받음
"" => "empty",
{ Length: > 10 } => "long name",
_ => "regular name",
};
해설:
null패턴 — 입력이 null 인 경우를 명시적으로 매치합니다. NRE 가 발생하지 않습니다.{ Length: > 10 }속성 패턴 — null 이 아닐 때만 매치되는 의미가 자동으로 적용됩니다. 별도의 null 체크가 필요 없습니다.- 순서 —
null패턴은 보통 가장 위 또는 가장 명확한 위치에 두는 것이 관습입니다.
Unity 객체 주의: Unity 의UnityEngine.Object(MonoBehaviour 포함)는 C# 의null비교가==연산자 오버로딩으로 가짜 null(destroyed but not yet collected) 을 진짜 null 처럼 보이게 합니다. 그러나 switch 표현식의null패턴은 진짜 참조 null 만 매치합니다 — 가짜 null 은 일반 객체로 인식됩니다. 이게 함정입니다. Unity 객체를 switch 표현식의null패턴으로 받으려면obj == null로 별도 체크하거나,obj as MonoBehaviour is null같은 우회가 필요합니다(Unity 객체의 가짜 null 동작은 별도 글에서 다룰 주제).
5.4. 부수효과를 식 안에 끼워 넣기 — 안티 패턴
// ❌ 안티 패턴 — 분기마다 부수효과를 끼워 넣음
int code = state switch
{
State.Run => DoRunAndReturn(), // 부수효과 + 값
State.Attack => DoAttackAndReturn(), // 부수효과 + 값
_ => 0,
};
문제:
- switch 표현식은 식이므로 형태상 부수효과를 끼워 넣을 수는 있습니다. 하지만 "식은 값을 만든다" 는 의미가 흐려집니다.
- 코드 리뷰어는
code의 값을 보러 들어가서 갑자기DoRun…의 부수효과를 발견하게 됩니다. 의도가 분산됩니다. - 디버거에서 이 식 안의 메서드 호출을 잡기가 까다롭습니다.
✅ 올바른 분리
// 부수효과는 switch 문으로
switch (state)
{
case State.Run: DoRun(); break;
case State.Attack: DoAttack(); break;
}
// 값 매핑은 switch 표현식으로
int code = state switch
{
State.Run => 1,
State.Attack => 2,
_ => 0,
};
원칙: switch 표현식은 순수 값 매핑에 한정하고, 부수효과는 switch 문으로 분리합니다. 둘이 한 메서드 안에 같이 있어도 무방합니다 — 의도가 명확해집니다.
5.5. discard _ 는 모든 패턴 뒤에 와야 한다
// ❌ 컴파일 에러 — discard 뒤의 패턴들 도달 불가
int code = x switch
{
1 => "one",
_ => "other", // ← discard
2 => "two", // ← CS8510: 도달 불가
};
_ 패턴은 모든 입력에 매치되므로, 그 뒤에 어떤 패턴을 두어도 도달할 수 없습니다. switch 문의 default: 는 어디에 있어도 평가 순서상 마지막이지만, 표현식의 _ 는 소스 순서가 곧 평가 순서입니다.
원칙: _ 는 항상 마지막 분기에 둡니다. switch 문의 default 가 어디에든 위치할 수 있는 자유와 다른 점입니다.
5.6. C# 7 의 case T t when ... 와 헷갈림
// switch 문 (C# 7+) — case 라벨에 패턴을 쓸 수도 있음
switch (obj)
{
case int n when n > 0: Console.WriteLine($"positive: {n}"); break;
case int n when n < 0: Console.WriteLine($"negative: {n}"); break;
case int n: Console.WriteLine("zero"); break;
case null: Console.WriteLine("null"); break;
default: Console.WriteLine("other"); break;
}
// switch 표현식 — 같은 패턴, 다른 문법
string desc = obj switch
{
int n when n > 0 => $"positive: {n}",
int n when n < 0 => $"negative: {n}",
int n => "zero",
null => "null",
_ => "other",
};
같은 패턴이 두 형태에서 모두 동작합니다. 차이는 종결자(break; vs ,) 와 위치(부수효과 vs 값) 뿐입니다. 단, switch 문의 패턴 매칭은 점프 테이블 최적화를 잃습니다(추정 — 비교 사슬로 풀어짐). switch 표현식도 동일. 패턴 매칭이 들어오는 순간 두 형태의 IL 비용은 같아집니다.
6. [C# 버전별 변화]
switch 표현식은 C# 8.0 에서 도입된 후 패턴 매칭과 함께 진화했습니다.
C# 8.0 (2019) — switch 표현식 기본형
var label = n switch
{
1 => "one",
2 => "two",
_ => "other",
};
switch키워드가 subject 뒤에 오는 새로운 어순=>화살표로 패턴 → 결과 매핑- 모든 가능한 값을 다루지 않으면 CS8509 경고 +
SwitchExpressionException - 함께 도입된 패턴: 타입 패턴, 속성 패턴, 위치(튜플) 패턴, discard 패턴,
var패턴
C# 9.0 (2020) — 관계 패턴 · 논리 패턴
string Grade(int score) => score switch
{
>= 90 => "A",
>= 80 and < 90 => "B", // ← 논리 패턴 'and'
< 60 => "F",
not 0 and < 60 => "F", // ← 논리 패턴 'not'
_ => "C",
};
- 관계 패턴:
< x,> x,<= x,>= x— 값을 직접 비교 - 논리 패턴:
and,or,not— 패턴을 결합
이 추가로 switch 표현식이 if-else if 사슬의 거의 완벽한 대체재가 되었습니다.
C# 10.0 (2021) — 확장 속성 패턴
record User(string Name, Address Home);
record Address(string City);
string Region(User u) => u switch
{
{ Home.City: "Seoul" } => "수도권", // ← 중첩 속성 직접 매치
{ Home.City: "Busan" or "Daegu" } => "영남", // ← 논리 패턴 결합
_ => "기타",
};
- C# 9 까지는
{ Home: { City: "Seoul" } }처럼 중첩이 필요했던 것을 한 줄로 압축
C# 11.0 (2022) — 리스트 패턴
string Describe(int[] arr) => arr switch
{
[] => "empty",
[var x] => $"single: {x}",
[1, 2, 3] => "1-2-3 sequence",
[1, .., 3] => "starts with 1, ends with 3",
_ => "other",
};
- 배열/리스트의 구조 자체를 패턴으로 매칭
..(slice 패턴) 으로 중간 요소 무시- 별도 글에서 다룹니다.
정리 표
| 버전 | 기능 |
|---|---|
| C# 8.0 | switch 표현식, 타입/속성/위치/discard 패턴 |
| C# 9.0 | 관계 패턴(< 0), 논리 패턴(and/or/not) |
| C# 10.0 | 확장 속성 패턴(Home.City: ...) |
| C# 11.0 | 리스트 패턴([1, 2, ..]) |
이 글은 C# 8 의 표현식 자체에 집중하며, C# 9 이후의 패턴들은 본문 예시에서 부분적으로만 사용했습니다. 패턴별 상세는 [패턴 매칭 시리즈] 별도 글을 참조하세요.
7. [정리]
이 글에서 다룬 핵심을 7가지 체크리스트로 정리합니다.
- switch 표현식은 분기를 식으로 한 등급 올린 형태: subject 가 먼저 오고(
x switch), 종결자 없이=>한 줄, 분기는 쉼표로 구분. 임시 변수와break가 사라진다. - dense enum/정수 케이스에서 IL 은 switch 문과 동일: 같은 점프 테이블, 같은 코드 크기. 표현식이라고 느려지지 않는다(IL 디컴파일로 검증). 차이는 케이스 종류(패턴 매칭 결합)일 때 생긴다.
- 완전성 검사 — 모든 입력에 결과가 정의되어야 한다: enum 의 모든 멤버를 다루거나
_분기를 두지 않으면 CS8509 경고. 무시하면 런타임에SwitchExpressionException던짐. 컴파일러가 자동으로 throw 블록을 IL 에 주입한다. - 패턴 순서가 곧 평가 순서: 위에서 아래로 첫 매치만 실행. 더 일반적인 패턴이 위에 있으면 뒤 패턴은 도달 불가 — 컴파일 에러(switch 문은 같은 위반을 잡지 못했음).
_는 반드시 마지막. - null 처리는 패턴으로 받는다: switch 문은 null 입력 시 분기 평가 전에 NRE. switch 표현식은
null패턴으로 명시적으로 매치 가능. 단, Unity 객체의 가짜 null 은 별도 처리. - 언제 표현식을 쓰는가: 분기의 목적이 "값 하나를 만드는 것" 일 때. 각 분기가 여러 부수효과를 실행하거나
goto case가 필요하면 switch 문이 더 적합. - 패턴 매칭과의 결합이 진짜 가치:
(a.Type, d) switch { (Melee, { HasShield: true }) => ..., ... }같은 튜플 + 속성 + 관계 패턴 결합. switch+if 중첩이 한 식으로 평탄해진다. IL 은 비교 사슬로 풀리지만 가독성 이득이 일반적으로 더 크다.
다음 글: 패턴 매칭 기초 — is 타입 패턴 — switch 표현식의 패턴들이 시작된 곳, 그리고 단독 식으로의 사용
'C# 기초' 카테고리의 다른 글
| [PART4.제어문: 분기와 반복(5/13)] 리스트 패턴 — 배열·리스트의 모양으로 분기 (C# 11) (0) | 2026.04.26 |
|---|---|
| [PART4.제어문: 분기와 반복(4/13)] 패턴 매칭 기초 — is 타입 패턴 (0) | 2026.04.26 |
| [PART4.제어문: 분기와 반복(2/13)] switch 문 — 고전적 형태 (0) | 2026.04.26 |
| [PART4.제어문: 분기와 반복(1/13)] if · else if · else (0) | 2026.04.26 |
| [PART3.연산자와 표현식(11/11)] 문자열 보간과 기본 포맷팅 — `$"..."` 한 줄이 만들어내는 차이 (0) | 2026.04.25 |