반응형

[PART4.제어문: 분기와 반복(7/13)] 튜플 패턴 — 여러 값을 동시에 매칭 (C# 8)

요약 튜플 패턴은 두 개 이상의 값을 한 번에 묶어 switch 표현식으로 분기하는 C# 8 기능입니다. 핵심은 "여러 변수의 상태 조합을 표(table) 처럼 펼쳐 보여 준다"는 것입니다. (x, y) switch { ... } 라고 쓰지만 실제 IL 에서는 ValueTuple<int,int> struct 인스턴스가 만들어지지 않고, xy 에 대한 bgt.s/brfalse.s 분기 사슬로 평탄화됩니다. 즉, 가독성은 선언적이고 비용은 if-else 와 동일합니다. 다만 세 가지 함정이 있습니다. ① 패턴은 위에서 아래로 매칭되므로 일반적인 패턴이 위에 있으면 뒤가 도달 불가가 됩니다. ② switch 표현식은 모든 입력 조합을 커버해야 CS8509 경고가 사라지므로 마지막에 _ 또는 명시적 예외가 필요합니다. ③ (x, y) 리터럴은 ValueTuple<int,int> 와 같은 타입이라는 점을 잊으면 의도치 않은 박싱이나 타입 추론 오류가 생깁니다.


1. 들어가며 — 왜 두 변수를 한 번에 보고 싶은가

게임 캐릭터의 좌표가 1 사분면 안에 있는지, 축 위에 있는지, 원점에 있는지를 분류한다고 가정해 봅니다. C# 7 까지의 코드는 보통 이렇게 생겼습니다.

C#
static string ClassifyOld(int x, int y)
{
    if (x == 0 && y == 0)        return "origin";
    else if (x == 0 || y == 0)   return "on axis";
    else if (x > 0 && y > 0)     return "Q1";
    else                          return "other";
}

이 코드는 동작하지만 두 가지 문제가 있습니다.

  1. 변수 이름이 4번씩 반복됩니다. x, yItemPickupRequestEventArgs 처럼 긴 이름이라면 가독성이 더 떨어집니다.
  2. 분기 의도가 절차적입니다. "만약 x 가 0 이고 y 가 0 이면..." 이 아니라 "(0, 0) 이면..." 이라는 데이터 형태(shape) 기반 사고로 옮길 수 있다면 코드가 짧아집니다.

C# 8 의 튜플 패턴은 이 전환을 위한 문법입니다.

C#
static string Classify(int x, int y) => (x, y) switch
{
    (0, 0)            => "origin",
    (_, 0) or (0, _)  => "on axis",
    (> 0, > 0)        => "Q1",
    _                 => "other"
};

같은 로직이 4 줄로 줄었습니다. 더 중요한 것은 각 줄을 "조합 → 결과" 라는 표 한 행으로 읽을 수 있다는 점입니다.

이 글에서 다룰 것 / 다루지 않을 것
  • 다룰 것: 튜플 패턴 자체((x, y) switch), 관계·논리 패턴과의 결합, switch 표현식의 완전성, IL 동작, Unity 활용.
  • 다루지 않을 것: 위치 패턴(Point p switch { (0, 0) => ... })은 사용자 정의 타입의 Deconstruct 를 활용한 별개 주제로 후속 글에서 다룹니다. 이 글에서는 4 절에서 차이점만 짚습니다.

2. 핵심 개념 — 튜플 패턴의 5 가지 빌딩 블록

튜플 패턴은 단독 문법이 아니라 여러 패턴을 괄호로 묶는 컨테이너입니다. 각 위치에는 다른 패턴이 들어갈 수 있습니다.

튜플 패턴 = (패턴, 패턴) 컨테이너

이 글의 예제로 다시 매핑하면 다음과 같습니다.

분기 슬롯1(x) 슬롯2(y) 슬롯 결합
(0, 0) 상수 0 상수 0 단일 튜플 패턴
(_, 0) or (0, _) discard / 상수 0 상수 0 / discard 두 튜플을 or
(> 0, > 0) 관계 > 0 관계 > 0 단일 튜플 패턴
_ 모든 값 매칭

핵심 원칙: 튜플 패턴 자체는 "여러 패턴을 괄호로 묶는 문법" 일 뿐입니다. 진짜 표현력은 각 슬롯에 들어가는 상수·관계·논리·discard 패턴의 조합에서 나옵니다.


3. 한 줄짜리 비유 — 좌표 평면을 표로 보기

좌표 평면 분류 — 튜플 패턴이 본 모양

좌표 평면의 4 가지 영역(원점·축·1 사분면·기타) 을 위에서 아래로 한 행씩 매칭한다고 보면 됩니다. 가장 좁은 영역(원점) 이 위에, 가장 넓은 영역(_) 이 아래에 있는 것은 우연이 아닙니다 — 6 절에서 다시 다룹니다.


4. 위치 패턴(Positional Pattern) 과의 차이 — 한 번만 짚기

이 둘은 자주 혼동되지만 사용 대상이 다릅니다.

C#
// (1) 튜플 패턴 — int, int 변수를 괄호로 즉석에서 묶음
static string ClassifyXY(int x, int y) => (x, y) switch
{
    (0, 0) => "origin",
    _      => "other"
};

// (2) 위치 패턴 — Point 라는 사용자 타입을 Deconstruct 로 분해
public readonly record struct Point(int X, int Y);
// record struct 는 Deconstruct(out int X, out int Y) 를 자동 생성

static string ClassifyPoint(Point p) => p switch
{
    (0, 0) => "origin",       // ← p.Deconstruct(out var x, out var y) 후 (0,0) 비교
    _      => "other"
};
항목 튜플 패턴 위치 패턴
대상 (x, y) 같은 변수 묶음 또는 ValueTuple Deconstruct 메서드를 가진 사용자 타입
컴파일러 동작 변수 비교로 평탄화, ValueTuple 객체 미생성 Deconstruct 호출 후 결과를 비교
도입 시점 C# 8 C# 8 (record / Deconstruct 와 함께)
이 글의 범위: 본문 예제((x, y) switch)는 모두 튜플 패턴입니다. 위치 패턴은 별도 주제이므로 차이점만 기억해 두면 충분합니다.

5. 동작 원리 — 컴파일러는 무엇을 만들까

5.1 핵심 질문: (x, y) switch 가 ValueTuple 을 만들까?

직관적으로는 두 가지 가설이 가능합니다.

  • 가설 A: (x, y)ValueTuple<int,int> 인스턴스를 스택에 만들고, 그 Item1, Item2 필드를 검사한다.
  • 가설 B: 컴파일러가 (x, y) 를 가짜 컨테이너로 보고, 실제로는 xy 자체를 직접 비교한다.

실측 결과는 가설 B 입니다. 다음 절에서 IL 을 직접 확인합니다.

컴파일러가 (x, y) switch 를 보는 방식 소스 코드 (x, y) switch { (0, 0) => ..., (_, 0) or (0,_)=> ..., (>0, >0) => ..., _ => ... } → 컴파일 후 (개념상 IL 흐름)// ValueTuple newobj 없음if (x == 0) goto CHECK_Y_FOR_ORIGINif (y == 0) goto ON_AXISgoto CHECK_Q1CHECK_Q1: if (x > 0 && y > 0) → "Q1" else → "other"→ x, y 변수에 대한 분기 사슬

5.2 IL 직접 검증

Classify 메서드를 Release 모드로 빌드하고 ilspycmd 로 디컴파일한 결과입니다.

IL
.method public hidebysig static string Classify(int32 x, int32 y) cil managed
{
    .maxstack 2
    .locals init ( [0] string )

    IL_0000: ldarg.0       // x
    IL_0001: brtrue.s IL_0008   // x != 0 → IL_0008 로
    IL_0003: ldarg.1       // x == 0 인 경로: y 검사
    IL_0004: brfalse.s IL_0015  // y == 0 → "origin"
    IL_0006: br.s IL_001d       // y != 0 → "on axis"
    IL_0008: ldarg.1       // x != 0 인 경로: y 검사
    IL_0009: brfalse.s IL_001d  // y == 0 → "on axis"
    IL_000b: ldarg.0       // (>0, >0) 검사
    IL_000c: ldc.i4.0
    IL_000d: ble.s IL_002d       // x <= 0 → "other"
    IL_000f: ldarg.1
    IL_0010: ldc.i4.0
    IL_0011: bgt.s IL_0025       // y > 0 → "Q1"
    IL_0013: br.s IL_002d        // 그 외 → "other"

    IL_0015: ldstr "origin"  / IL_001a: stloc.0 / IL_001b: br.s IL_0033
    IL_001d: ldstr "on axis" / IL_0022: stloc.0 / IL_0023: br.s IL_0033
    IL_0025: ldstr "Q1"      / IL_002a: stloc.0 / IL_002b: br.s IL_0033
    IL_002d: ldstr "other"   / IL_0032: stloc.0
    IL_0033: ldloc.0         / IL_0034: ret
}

확인할 핵심 사실 4 가지:

  1. newobj [System.Runtime]System.ValueTuple\2..ctor` 가 없습니다. ValueTuple struct 인스턴스가 생성되지 않습니다.
  2. 모든 비교가 ldarg.0(x), ldarg.1(y) 즉 메서드 인자에 직접 가해집니다. 임시 변수로 복사하지도 않습니다.
  3. (0, 0) 매칭은 brtrue.s(0 이 아니면 분기) 두 번으로 풀립니다. (> 0, > 0)ble.s (작거나 같으면 분기) 와 bgt.s (크면 분기) 의 사슬입니다.
  4. switch 표현식은 결과를 stloc.0 으로 지역 변수에 모은 뒤 단일 ret 으로 끝냅니다(전형적인 단일 출구 형태).

5.3 if-else 와의 비교

같은 의미의 if-else 체인을 동일 옵션으로 컴파일한 결과입니다.

IL
.method public hidebysig static string ClassifyIfElse(int32 x, int32 y) cil managed
{
    .maxstack 8
    IL_0000: ldarg.0
    IL_0001: brtrue.s IL_000c     // x != 0 → 다음 검사
    IL_0003: ldarg.1
    IL_0004: brtrue.s IL_000c     // y != 0 → 다음 검사
    IL_0006: ldstr "origin"
    IL_000b: ret                   // ← 여러 출구

    IL_000c: ldarg.0
    IL_000d: brfalse.s IL_0012
    IL_000f: ldarg.1
    IL_0010: brtrue.s IL_0018
    IL_0012: ldstr "on axis"
    IL_0017: ret                   // ← 여러 출구

    IL_0018: ldarg.0
    IL_0019: ldc.i4.0
    IL_001a: ble.s IL_0026
    IL_001c: ldarg.1
    IL_001d: ldc.i4.0
    IL_001e: ble.s IL_0026
    IL_0020: ldstr "Q1"
    IL_0025: ret                   // ← 여러 출구

    IL_0026: ldstr "other"
    IL_002b: ret
}
비교 항목 튜플 패턴 (Classify) if-else (ClassifyIfElse)
코드 크기 53 byte 44 byte
출구(ret) 개수 1 (값을 stloc 에 모음) 4 (각 분기마다 ret)
ValueTuple 생성 없음 없음 (애초에 안 씀)
비교 명령 brtrue.s / brfalse.s / ble.s / bgt.s 동일
박싱 / 힙 할당 없음 없음

코드 크기 차이(53 vs 44)는 switch 표현식이 단일 출구 + stloc 패턴 으로 결과를 모으기 때문에 발생하는 것으로, 본질적인 비용 차이가 아닙니다. JIT 가 인라인하면 양쪽이 사실상 동일한 기계어로 수렴합니다. 튜플 패턴은 가독성을 얻으면서 런타임 비용은 0 인 전형적인 제로 코스트 추상화입니다.

결론: (x, y) switch 는 문법적 설탕입니다. 컴파일러는 ValueTuple 인스턴스를 만들지 않고 두 변수에 대한 분기 사슬로 평탄화하므로, "튜플을 쓰면 느려진다" 는 걱정은 이 경우 사실이 아닙니다.

6. 깊이 들어가기 — 패턴 결합과 완전성

6.1 관계 패턴 결합 ((> 0, > 0))

C# 9 에서 도입된 관계 패턴(>, <, >=, <=) 은 튜플 슬롯 안에 그대로 들어갑니다.

C#
(int health, int mana) hp = (50, 30);
string state = hp switch
{
    (<= 0, _)         => "Dead",
    (< 30, _)         => "Critical",
    (_, < 10)         => "OutOfMana",
    (>= 30, >= 10)    => "Ready",
    _                 => "Unknown"
};

IL 측면에서는 > 0ldc.i4.0 + bgt.s 한 쌍, >= 0ldc.i4.0 + bge.s 한 쌍으로 평탄화됩니다. 즉 슬롯 하나당 상수 로드 + 분기 명령 한 쌍 이 추가됩니다.

6.2 논리 패턴 결합 (or, and, not)

(_, 0) or (0, _) 처럼 패턴 자체를 결합할 수 있습니다.

C#
static bool OnAxis(int x, int y) => (x, y) is (_, 0) or (0, _);

IL 에서 or조건이 하나라도 참이면 통과 하는 분기 사슬로 풀립니다. 본문 5.2 IL 의 IL_0001: brtrue.s IL_0008 ~ IL_0009: brfalse.s IL_001d 가 정확히 그 동작입니다(왼쪽 패턴이 매칭 안 되면 오른쪽 패턴을 검사).

and 는 양쪽 모두 매칭되어야 하므로 짧은 조건 단락(short-circuit) 분기 사슬입니다. not 은 단순히 분기 방향을 반전시킵니다(예: not 0brtrue.s 한 번).

괄호 권장: (_, 0) or (0, _) and (1, _) 같은 코드는 우선순위가 헷갈립니다. C# 에서 andor 보다 우선이지만, 의도를 명확히 하려면 ((_, 0) or (0, _)) and (1, _) 처럼 괄호를 두십시오.

6.3 완전성 검사 (CS8509)

switch 표현식은 모든 입력에 대해 결과를 만들어야 하므로 컴파일러는 누락된 케이스를 잡아 줍니다.

C#
// CS8509 경고: "switch expression does not handle all possible values..."
static string Bad(int x, int y) => (x, y) switch
{
    (0, 0)     => "origin",
    (> 0, > 0) => "Q1"
    // (-1, 5) 같은 입력에 대한 처리가 없음
};

런타임에는 매칭이 없으면 SwitchExpressionException 이 던져지므로, 마지막에 둘 중 하나를 두는 것이 표준입니다.

C#
// 옵션 A: 안전하게 기본값
_ => "other"

// 옵션 B: 의도적 예외
_ => throw new ArgumentOutOfRangeException(nameof(x), $"unexpected ({x}, {y})")
참고: 이름 있는 enum 타입의 경우 모든 값이 enum 멤버로 명시돼 있으면 컴파일러가 별도의 경고(CS8524) 를 낼 수 있습니다 — 이 경우에도 _ 를 추가하는 것이 안전합니다.

6.4 패턴 순서 의존성과 도달 불가 (CS8510)

매칭은 위에서 아래 순서이며, 첫 번째로 매칭된 가지 가 실행됩니다. 따라서 일반적인 패턴이 위에 있으면 아래가 도달 불가가 됩니다.

C#
// CS8510: The pattern is unreachable.
static string Wrong(int x, int y) => (x, y) switch
{
    (_, _)            => "anything",   // ← 모든 입력을 잡음
    (0, 0)            => "origin",     // ← 절대 실행 안 됨
    _                 => "other"
};
패턴 순서 — 좁은 것부터, 넓은 것은 마지막

6.5 (x, y)ValueTuple<int,int> 는 같은 타입

흔한 오해 한 가지를 짚고 갑니다.

C#
(int, int) a = (1, 2);
ValueTuple<int, int> b = (1, 2);
a = b;          // OK — 정확히 같은 타입
b = a;          // OK
object o = a;   // ← 박싱 발생! ValueTuple 은 struct 이므로

(int, int) 는 컴파일러가 ValueTuple<int,int> 로 풀어 주는 별칭일 뿐입니다. 5 절에서 본 IL 에서 ValueTuple 인스턴스가 안 만들어진 이유는 튜플 패턴이 매칭용 문법 이기 때문이지, ValueTuple 자체가 안 쓰여서가 아닙니다. 변수에 (1, 2) 를 대입하는 일반 코드에서는 ValueTuple struct 가 정상적으로 만들어집니다.


7. 함정과 모범 사례

7.1 함정 — 패턴 순서 의존성 (도달 불가)

switch 가 위에서 아래로 평가되므로, 일반 패턴(_ 또는 (_, _)) 을 위에 두면 그 아래가 모두 죽습니다. 컴파일러 경고 CS8510 을 절대 무시하지 마십시오.

7.2 함정 — 완전성 누락 (CS8509 + 런타임 예외)

switch 표현식에 _ 가 없고 컴파일러가 모든 케이스를 증명하지 못하면 CS8509 가 뜹니다. 그 상태로 빌드하면 런타임에 SwitchExpressionException 이 발생할 수 있습니다. 마지막에 _ 를 두거나 명시적 throw 를 두는 습관을 들이십시오.

7.3 함정 — (x, y) 의 박싱 오해

본문 IL 에서 보았듯 튜플 패턴 자체는 ValueTuple 인스턴스를 만들지 않습니다. 그러나 일반 코드에서 (x, y) 를 변수에 담거나 object 로 형 변환하면 ValueTuple struct 가 생성되고, object 로 갈 때는 박싱(힙 할당) 이 일어납니다. 매 프레임 호출되는 핫패스에서 object 파라미터를 받는 메서드에 튜플을 넘기지 마십시오.

7.4 함정 — 너무 많은 변수를 한 튜플로

3 개 이상의 변수를 한 번에 매칭하기 시작하면 가지가 기하급수적으로 늘어납니다. 변수가 4 개 이상이면 보통 상태 객체(state record) 와 위치 패턴이 더 적절합니다. 이 글의 범위는 2 변수 튜플 매칭이며, 그 이상은 후속 글의 영역입니다.

7.5 모범 사례 — 좁은 패턴 → 넓은 패턴

(0, 0)(_, 0) or (0, _)(> 0, > 0)_ 처럼 점점 일반적인 패턴이 아래로 가도록 정렬하십시오. 이 순서는 좌표 평면의 "한 점 → 두 직선 → 한 영역 → 그 외" 구조와 정확히 일치합니다.

7.6 모범 사례 — discard 의 의도를 주석으로

(_, 0) 에서 _ 가 "x 는 무엇이든 상관없음" 이라는 의도라는 사실을 주석이나 변수명으로 남기면, 나중에 코드를 읽는 사람이 "혹시 빠뜨린 케이스 아닌가?" 하고 의심하지 않습니다.

C#
// y 축 위(x 무관) 또는 x 축 위(y 무관)
(_, 0) or (0, _) => "on axis"

8. Unity 활용 사례

8.1 입력 조합으로 행동 분기

C#
void Update()
{
    bool moveKey   = Input.GetKey(KeyCode.W);
    bool runKey    = Input.GetKey(KeyCode.LeftShift);
    bool grounded  = controller.isGrounded;

    var action = (moveKey, runKey, grounded) switch
    {
        (true,  true,  true)  => PlayerAction.Run,
        (true,  false, true)  => PlayerAction.Walk,
        (true,  _,     false) => PlayerAction.AirMove,
        (false, _,     true)  => PlayerAction.Idle,
        _                     => PlayerAction.Falling
    };

    controller.Apply(action);
}

효과: if (moveKey && runKey && grounded) ... else if ... 5 단 중첩이 5 줄짜리 표로 변합니다. 분기 사슬은 IL 단계에서 brtrue.s/brfalse.s 로 풀리므로 Update 핫패스에서도 부담이 없습니다.

8.2 ECS / Job 다중 컴포넌트 상태

DOTS 의 IJobEntity 또는 일반 MonoBehaviour 에서 두 컴포넌트의 상태 조합으로 분기할 때 유용합니다.

C#
[BurstCompile]
public partial struct EnemyAIJob : IJobEntity
{
    public void Execute(ref EnemyState state, in Health hp, in Mana mp)
    {
        state.Action = (hp.Value, mp.Value) switch
        {
            (<= 0, _)         => AiAction.Die,
            (< 30, > 50)      => AiAction.Heal,
            (> 70, < 10)      => AiAction.MeleeRush,
            (> 50, >= 50)     => AiAction.CastSpell,
            _                 => AiAction.Idle
        };
    }
}
Burst 호환성: 튜플 패턴은 IL 수준에서 분기 사슬로 평탄화되므로 Burst 컴파일러가 그대로 받아들입니다. ValueTuple 인스턴스가 생성되지 않으므로 박싱·할당이 없어 Burst 의 "관리 객체 금지" 규칙도 위반하지 않습니다.

8.3 간단한 FSM 전이 테이블

C#
enum State { Idle, Patrol, Chase, Attack }
enum Trigger { SeePlayer, LosePlayer, InRange, OutOfRange, Killed }

State Next(State current, Trigger t) => (current, t) switch
{
    (State.Idle,   Trigger.SeePlayer)    => State.Chase,
    (State.Patrol, Trigger.SeePlayer)    => State.Chase,
    (State.Chase,  Trigger.InRange)      => State.Attack,
    (State.Chase,  Trigger.LosePlayer)   => State.Patrol,
    (State.Attack, Trigger.OutOfRange)   => State.Chase,
    (_,            Trigger.Killed)       => State.Idle,
    _                                     => current     // 전이 없음
};

테이블 자체가 상태 전이도(state diagram) 의 텍스트 표현 이 됩니다. 디자이너에게 보여 주기에도 좋고, 새 상태를 추가할 때 빠진 전이를 발견하기도 쉽습니다.

8.4 Unity 에서 주의할 점

  • object 박스 경로 회피: Animator.SetTrigger(string) 처럼 string/int 만 받는 API 에 튜플을 직접 넘기진 않으므로 박싱은 거의 발생하지 않지만, Debug.Log($"{(x,y)}") 같은 보간식은 ValueTuple 인스턴스를 만들고 ToString 호출 과정에서 임시 string 을 생성합니다. 핫패스에서는 피하십시오.
  • switch 가 너무 길어지면 ScriptableObject 로: 50 가지 전이가 있는 FSM 을 한 함수에 욱여넣지 말고, 데이터 자산(ScriptableObject 의 List<Transition>) 으로 빼는 편이 빌드 시간과 가독성 모두에 유리합니다. 튜플 패턴은 5~15 가지 전이까지가 sweet spot 입니다.

9. 마무리

항목 한 줄 요약
무엇 C# 8 에서 도입된 여러 값 동시 매칭 문법
어떻게 컴파일러가 (x, y) 를 ValueTuple 인스턴스 없이 두 변수에 대한 분기 사슬 로 평탄화
if-else 5 단 중첩을 표(table) 같은 선언적 코드로 바꿔 가독성을 높임 — 비용은 동일
함정 1 패턴 순서 — 좁은 것부터 넓은 것 순으로, _ 는 마지막에
함정 2 완전성 — switch 표현식은 모든 입력을 커버해야 하며 누락 시 CS8509 + 런타임 예외
함정 3 (x, y)ValueTuple<int,int> 는 같은 struct 타입이며, object 로 갈 때만 박싱
위치 패턴 Deconstruct 가진 사용자 타입에 적용되는 별개 주제 — 후속 글
Unity 적용 입력 조합 / ECS 컴포넌트 상태 / FSM 전이 테이블 — Burst 호환

다음 글에서는 위치 패턴(Positional Pattern) 을 다룹니다. 사용자 정의 Point, record 타입에서 Deconstruct 메서드가 어떻게 작동하는지, 튜플 패턴과 IL 가 어떻게 달라지는지 비교합니다.


참고 문헌

  • Microsoft Learn — Patterns and pattern matching: https://learn.microsoft.com/dotnet/csharp/language-reference/operators/patterns
  • Microsoft Learn — switch expression: https://learn.microsoft.com/dotnet/csharp/language-reference/operators/switch-expression
  • C# 8.0 Language Specification — Recursive patterns
  • C# 9.0 Language Specification — Relational and logical patterns
  • ECMA-335 CIL specification — Conditional branch instructions (brtrue, brfalse, bgt, ble)
반응형

+ Recent posts