반응형

[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 프로젝트에서 이 패턴이 자주 등장합니다.

C#
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) 을 도입했습니다.

C#
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입니다).

구조

switch 문 (statement)

기본 문법

C#
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 을 디컴파일했습니다.

C#
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개로 동일):

IL
// (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
}

관찰된 사실:

  1. 두 메서드의 IL 은 바이트 단위로 동일합니다. 코드 크기 52바이트, 같은 점프 테이블, 같은 라벨 구조.
  2. switch 표현식이 점프 테이블 최적화를 잃는 게 아닌가 하는 흔한 우려는 dense enum 에서는 사실이 아닙니다. 컴파일러는 같은 분포에 같은 분기 전략을 적용합니다.
  3. 다른 점은 오직 소스 표현뿐입니다 — 한 쪽은 임시 변수 resultbreak 를 명시적으로 썼고, 다른 쪽은 컴파일러가 자동으로 같은 임시 변수([0] int32)를 만들었습니다.
JIT 관점: 두 메서드는 IL 이 같으므로 JIT 가 만들어내는 네이티브 코드도 같습니다. 즉 switch 표현식 vs switch 문의 성능 차이는 dense 정수/enum 에서는 0 입니다. 차이가 생긴다면 그건 case 분포(sparse, 패턴 매칭, when 절)나 컴파일러 버전 때문이지, 표현식/문 자체의 차이가 아닙니다.

누락된 케이스 — SwitchExpressionException 의 IL

다음 코드는 int 의 가능한 모든 값을 다루지 않으므로 컴파일러가 CS8509 경고를 발생시킵니다.

C#
public static string DescribeMissing(int x) => x switch
{
    1 => "one",
    2 => "two",
    // ⚠️ CS8509: switch 식에서 입력 형식의 가능한 값을 모두 처리하지는 않습니다(전체 아님).
    //          예를 들어 '0' 패턴은 포함되지 않습니다.
};
IL
.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
}

핵심:

  1. 컴파일러가 자동으로 throw 블록을 삽입합니다. 소스 코드에는 어디에도 throw 가 없지만, IL 의 IL_001a 부터가 자동 주입된 종결 처리입니다.
  2. <PrivateImplementationDetails>::ThrowSwitchExpressionException 은 컴파일러가 어셈블리에 추가하는 비공개 헬퍼 메서드입니다. 같은 어셈블리에 switch 표현식이 여러 개 있어도 이 헬퍼는 한 번만 생성되어 공유됩니다(추정 — 어셈블리 단위 코드 크기 절약 목적).
  3. 입력값은 box 명령으로 박싱되어 예외에 첨부됩니다. int 같은 값 타입을 object 매개변수로 넘기기 위함이며, 디버깅 시 SwitchExpressionException.UnmatchedValue 로 확인 가능합니다.
  4. switch 문에서는 이 자동 throw 가 없습니다 — case 가 다 안 맞으면 그냥 switch 블록을 통과하고 끝입니다. 이게 switch 표현식의 안전성을 만드는 가장 큰 차이입니다.

패턴 매칭 — 점프 테이블이 풀리는 순간

dense enum 처럼 case 가 모두 상수면 컴파일러는 점프 테이블을 만듭니다. 하지만 패턴 매칭(타입 패턴, 속성 패턴, 관계 패턴)이 들어오면 점프 테이블 최적화는 더 이상 가능하지 않습니다 — 각 패턴이 단순 정수 비교가 아니기 때문입니다.

C#
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,
};
IL
.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 문

C#
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 표현식 + 관계 패턴

C#
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 문이 더 적합합니다.

C#
// ✅ 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 핫패스 — 상태→속도, 상태→애니메이션

C#
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);
    }
}

핵심 패턴:

  1. _ => throw … — 새 enum 멤버가 추가됐을 때 즉시 실패하도록 던집니다(fail-fast). switch 문에서 default: throw … 와 동일한 의도.
  2. dense enum + 상수 결과 — 위 IL 분석에서 확인했듯, 점프 테이블 최적화가 적용됩니다. Update() 같은 핫패스에서도 안전합니다.
  3. 임시 변수 제거float speed; switch(...) { ... } use(speed); 의 4줄이 한 식으로 압축됩니다.
Unity 호환성: switch 표현식은 C# 8 기능이므로 Unity 2020.3 이상(<LangVersion> 8.0+)에서 사용 가능합니다. IL2CPP 빌드에서도 일반 IL 로 컴파일된 뒤 C++ 로 트랜슬레이트되므로 모바일에서도 안전합니다(추정 — switch opcode 와 isinst 모두 IL2CPP 가 지원).

패턴 결합 — switch 문 한 덩어리를 표현식으로 압축

이전 글에서 살펴본 5상태 캐릭터 상태머신을 패턴 매칭과 결합하면 더 압축할 수 있습니다.

C#
// ❌ 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;
}
C#
// ✅ 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+ShieldMelee+_ 보다 먼저 와야 합니다(반대면 도달 불가 컴파일 에러).
  • IL 비용 — 점프 테이블이 아닌 비교 사슬로 풀립니다. 분기가 수십 개로 늘어나면 if-else 사슬과 비슷한 비용. 단, 가독성 이득이 크므로 일반적으로 권장.

5. [함정과 주의사항]

5.1. 누락된 케이스 — 컴파일 경고 무시 시 SwitchExpressionException

❌ 잘못된 패턴

C#
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 보다 정보가 많음).
  • 그러나 이 예외는 게임 로직의 일부가 아닌 "프로그래머 실수의 신호" 입니다. 사용자에게 도달하면 안 됩니다.

✅ 올바른 패턴

C#
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)

❌ 잘못된 패턴

C#
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: _ 패턴 역시 도달 불가.

이는 컴파일 에러 입니다(경고가 아님). 빌드 자체가 실패합니다.

✅ 올바른 패턴 — 구체 → 일반 순서

C#
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

C#
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 을 패턴으로

C#
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. 부수효과를 식 안에 끼워 넣기 — 안티 패턴

C#
// ❌ 안티 패턴 — 분기마다 부수효과를 끼워 넣음
int code = state switch
{
    State.Run    => DoRunAndReturn(),       // 부수효과 + 값
    State.Attack => DoAttackAndReturn(),    // 부수효과 + 값
    _            => 0,
};

문제:

  • switch 표현식은 식이므로 형태상 부수효과를 끼워 넣을 수는 있습니다. 하지만 "식은 값을 만든다" 는 의미가 흐려집니다.
  • 코드 리뷰어는 code 의 값을 보러 들어가서 갑자기 DoRun… 의 부수효과를 발견하게 됩니다. 의도가 분산됩니다.
  • 디버거에서 이 식 안의 메서드 호출을 잡기가 까다롭습니다.

✅ 올바른 분리

C#
// 부수효과는 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 _ 는 모든 패턴 뒤에 와야 한다

C#
// ❌ 컴파일 에러 — discard 뒤의 패턴들 도달 불가
int code = x switch
{
    1 => "one",
    _ => "other",       // ← discard
    2 => "two",         // ← CS8510: 도달 불가
};

_ 패턴은 모든 입력에 매치되므로, 그 뒤에 어떤 패턴을 두어도 도달할 수 없습니다. switch 문의 default: 는 어디에 있어도 평가 순서상 마지막이지만, 표현식의 _소스 순서가 곧 평가 순서입니다.

원칙: _ 는 항상 마지막 분기에 둡니다. switch 문의 default 가 어디에든 위치할 수 있는 자유와 다른 점입니다.

5.6. C# 7 의 case T t when ... 와 헷갈림

C#
// 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 표현식 기본형

C#
var label = n switch
{
    1 => "one",
    2 => "two",
    _ => "other",
};
  • switch 키워드가 subject 뒤에 오는 새로운 어순
  • => 화살표로 패턴 → 결과 매핑
  • 모든 가능한 값을 다루지 않으면 CS8509 경고 + SwitchExpressionException
  • 함께 도입된 패턴: 타입 패턴, 속성 패턴, 위치(튜플) 패턴, discard 패턴, var 패턴

C# 9.0 (2020) — 관계 패턴 · 논리 패턴

C#
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) — 확장 속성 패턴

C#
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) — 리스트 패턴

C#
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 표현식의 패턴들이 시작된 곳, 그리고 단독 식으로의 사용
반응형

+ Recent posts