[PART4.제어문: 분기와 반복(7/13)] 튜플 패턴 — 여러 값을 동시에 매칭 (C# 8)
요약 튜플 패턴은 두 개 이상의 값을 한 번에 묶어 switch 표현식으로 분기하는 C# 8 기능입니다. 핵심은 "여러 변수의 상태 조합을 표(table) 처럼 펼쳐 보여 준다"는 것입니다. (x, y) switch { ... } 라고 쓰지만 실제 IL 에서는 ValueTuple<int,int> struct 인스턴스가 만들어지지 않고, x 와 y 에 대한 bgt.s/brfalse.s 분기 사슬로 평탄화됩니다. 즉, 가독성은 선언적이고 비용은 if-else 와 동일합니다. 다만 세 가지 함정이 있습니다. ① 패턴은 위에서 아래로 매칭되므로 일반적인 패턴이 위에 있으면 뒤가 도달 불가가 됩니다. ② switch 표현식은 모든 입력 조합을 커버해야 CS8509 경고가 사라지므로 마지막에 _ 또는 명시적 예외가 필요합니다. ③ (x, y) 리터럴은 ValueTuple<int,int> 와 같은 타입이라는 점을 잊으면 의도치 않은 박싱이나 타입 추론 오류가 생깁니다.
목차
1. 들어가며 — 왜 두 변수를 한 번에 보고 싶은가
게임 캐릭터의 좌표가 1 사분면 안에 있는지, 축 위에 있는지, 원점에 있는지를 분류한다고 가정해 봅니다. C# 7 까지의 코드는 보통 이렇게 생겼습니다.
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";
}
이 코드는 동작하지만 두 가지 문제가 있습니다.
- 변수 이름이 4번씩 반복됩니다.
x,y가ItemPickupRequestEventArgs처럼 긴 이름이라면 가독성이 더 떨어집니다. - 분기 의도가 절차적입니다. "만약 x 가 0 이고 y 가 0 이면..." 이 아니라 "(0, 0) 이면..." 이라는 데이터 형태(shape) 기반 사고로 옮길 수 있다면 코드가 짧아집니다.
C# 8 의 튜플 패턴은 이 전환을 위한 문법입니다.
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) 과의 차이 — 한 번만 짚기
이 둘은 자주 혼동되지만 사용 대상이 다릅니다.
// (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)를 가짜 컨테이너로 보고, 실제로는x와y자체를 직접 비교한다.
실측 결과는 가설 B 입니다. 다음 절에서 IL 을 직접 확인합니다.
5.2 IL 직접 검증
위 Classify 메서드를 Release 모드로 빌드하고 ilspycmd 로 디컴파일한 결과입니다.
.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 가지:
newobj [System.Runtime]System.ValueTuple\2..ctor` 가 없습니다. ValueTuple struct 인스턴스가 생성되지 않습니다.- 모든 비교가
ldarg.0(x),ldarg.1(y) 즉 메서드 인자에 직접 가해집니다. 임시 변수로 복사하지도 않습니다. (0, 0)매칭은brtrue.s(0 이 아니면 분기) 두 번으로 풀립니다.(> 0, > 0)은ble.s(작거나 같으면 분기) 와bgt.s(크면 분기) 의 사슬입니다.- switch 표현식은 결과를
stloc.0으로 지역 변수에 모은 뒤 단일ret으로 끝냅니다(전형적인 단일 출구 형태).
5.3 if-else 와의 비교
같은 의미의 if-else 체인을 동일 옵션으로 컴파일한 결과입니다.
.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 에서 도입된 관계 패턴(>, <, >=, <=) 은 튜플 슬롯 안에 그대로 들어갑니다.
(int health, int mana) hp = (50, 30);
string state = hp switch
{
(<= 0, _) => "Dead",
(< 30, _) => "Critical",
(_, < 10) => "OutOfMana",
(>= 30, >= 10) => "Ready",
_ => "Unknown"
};
IL 측면에서는 > 0 이 ldc.i4.0 + bgt.s 한 쌍, >= 0 이 ldc.i4.0 + bge.s 한 쌍으로 평탄화됩니다. 즉 슬롯 하나당 상수 로드 + 분기 명령 한 쌍 이 추가됩니다.
6.2 논리 패턴 결합 (or, and, not)
(_, 0) or (0, _) 처럼 패턴 자체를 결합할 수 있습니다.
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 0 → brtrue.s 한 번).
괄호 권장:(_, 0) or (0, _) and (1, _)같은 코드는 우선순위가 헷갈립니다. C# 에서and는or보다 우선이지만, 의도를 명확히 하려면((_, 0) or (0, _)) and (1, _)처럼 괄호를 두십시오.
6.3 완전성 검사 (CS8509)
switch 표현식은 모든 입력에 대해 결과를 만들어야 하므로 컴파일러는 누락된 케이스를 잡아 줍니다.
// 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 이 던져지므로, 마지막에 둘 중 하나를 두는 것이 표준입니다.
// 옵션 A: 안전하게 기본값
_ => "other"
// 옵션 B: 의도적 예외
_ => throw new ArgumentOutOfRangeException(nameof(x), $"unexpected ({x}, {y})")
참고: 이름 있는 enum 타입의 경우 모든 값이 enum 멤버로 명시돼 있으면 컴파일러가 별도의 경고(CS8524) 를 낼 수 있습니다 — 이 경우에도 _ 를 추가하는 것이 안전합니다.
6.4 패턴 순서 의존성과 도달 불가 (CS8510)
매칭은 위에서 아래 순서이며, 첫 번째로 매칭된 가지 가 실행됩니다. 따라서 일반적인 패턴이 위에 있으면 아래가 도달 불가가 됩니다.
// 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> 는 같은 타입
흔한 오해 한 가지를 짚고 갑니다.
(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 는 무엇이든 상관없음" 이라는 의도라는 사실을 주석이나 변수명으로 남기면, 나중에 코드를 읽는 사람이 "혹시 빠뜨린 케이스 아닌가?" 하고 의심하지 않습니다.
// y 축 위(x 무관) 또는 x 축 위(y 무관)
(_, 0) or (0, _) => "on axis"
8. Unity 활용 사례
8.1 입력 조합으로 행동 분기
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 에서 두 컴포넌트의 상태 조합으로 분기할 때 유용합니다.
[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 전이 테이블
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)
'C# 기초' 카테고리의 다른 글
| [PART4.제어문: 분기와 반복(9/13)] for 반복문 — 인덱스를 직접 다루는 반복 (0) | 2026.04.26 |
|---|---|
| [PART4.제어문: 분기와 반복(8/13)] 위치 패턴 — Deconstruct 기반 구조 분해 매칭 (C# 8) (0) | 2026.04.26 |
| [PART4.제어문: 분기와 반복(6/13)] 속성 패턴 — 객체의 프로퍼티 값으로 분기 (C# 8) · 확장 속성 패턴 (C# 10) (0) | 2026.04.26 |
| [PART4.제어문: 분기와 반복(5/13)] 리스트 패턴 — 배열·리스트의 모양으로 분기 (C# 11) (0) | 2026.04.26 |
| [PART4.제어문: 분기와 반복(4/13)] 패턴 매칭 기초 — is 타입 패턴 (0) | 2026.04.26 |