반응형

[PART4.제어문: 분기와 반복(8/13)] 위치 패턴 — Deconstruct 기반 구조 분해 매칭 (C# 8)

요약 위치 패턴(positional pattern)은 객체를 Deconstruct 메서드로 분해한 다음, 꺼낸 값들을 위치 순서로 매칭하는 C# 8 기능입니다. 핵심은 point is (0, var y) 라고 쓰면 컴파일러가 자동으로 point.Deconstruct(out int x, out int y) 호출 코드를 끼워 넣고, 그 결과를 비교 사슬로 검사한다는 점입니다. 직전 글의 튜플 패턴이 이미 분해된 값을 매칭하는 문법이라면, 위치 패턴은 분해 자체를 매칭의 일부로 만드는 문법입니다. 동작은 단순하지만 함정이 네 가지 있습니다. ① Deconstruct 가 없는 타입에는 쓸 수 없습니다. ② recordrecord struct 는 자동으로 Deconstruct 를 만들어 주지만 매개변수 순서가 바뀌면 매칭 결과도 뒤집힙니다. ③ Deconstruct 안에 부수 효과(I/O, 카운터 증가)가 있으면 패턴이 매칭될 때마다 호출됩니다. ④ 클래스에 대해서는 컴파일러가 null 검사를 자동으로 끼워 넣어서, null 인 경우 패턴은 조용히 false 가 됩니다.


1. 들어가며 — 왜 객체를 "꺼내서" 매칭하고 싶은가

직전 글에서 다룬 튜플 패턴은 두 변수의 조합을 한 번에 매칭하는 문법이었습니다. 그런데 실제 코드에서 다루는 데이터는 거의 항상 객체 입니다. Unity 게임에서도 (int x, int y) 같은 날 튜플보다는 Vector2, Vector3, 직접 만든 EnemyState 같은 타입을 다룹니다.

C#
public record Point(int X, int Y);

static string ClassifyOld(Point p)
{
    if (p.X == 0 && p.Y == 0)        return "origin";
    else if (p.X == 0 || p.Y == 0)   return "on axis";
    else if (p.X > 0 && p.Y > 0)     return "Q1";
    else                              return "other";
}

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

  1. p.X, p.Y 가 4번씩 반복됩니다. 필드가 늘어나면 더 길어집니다.
  2. 분기 조건이 "X 가 0 이고 Y 도 0 이면..." 이라는 절차적 표현인데, 실제 의도는 "(0, 0) 이면" 이라는 모양(shape) 매칭입니다.

C# 8 의 위치 패턴은 객체를 모양으로 매칭하는 문법입니다.

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

같은 로직이 4 줄로 줄었습니다. 더 중요한 것은 p 가 객체임에도 불구하고 코드가 튜플을 다루는 것처럼 읽힌다는 점입니다. 이 변환을 가능하게 하는 것이 Deconstruct 메서드입니다.

이 글에서 다룰 것 / 다루지 않을 것
  • 다룰 것: 위치 패턴 문법, Deconstruct 메서드 작성법, record 자동 생성, IL 변환, struct vs class 차이, 함정.
  • 다루지 않을 것: 튜플 패턴 자체와 패턴 매칭의 기초 — 직전 글들에서 다뤘습니다. 이 글은 "객체를 분해해서 매칭한다" 는 한 가지 주제에 집중합니다.

2. 핵심 개념 — 튜플 패턴과 무엇이 다른가

위치 패턴과 튜플 패턴은 겉보기에 똑같이 (0, var y) 를 씁니다. 하지만 컴파일러가 다루는 대상이 완전히 다릅니다.

튜플 패턴 vs 위치 패턴 — 매칭 직전의 데이터 형태

차이를 코드로 정리하면

C#
// (1) 튜플 패턴 — 좌측이 ValueTuple<int,int> 일 때
(int, int) tuple = (0, 5);
if (tuple is (0, var y1)) Console.WriteLine($"y1={y1}");

// (2) 위치 패턴 — 좌측이 일반 객체이고 그 객체에 Deconstruct 가 있을 때
Point p = new Point(0, 5);
if (p is (0, var y2)) Console.WriteLine($"y2={y2}");

(1) 의 tuple이미 두 값으로 나뉘어 있는 ValueTuple 구조체입니다. 매칭은 두 값을 그대로 비교하면 끝납니다. (2) 의 p객체 하나입니다. 컴파일러가 매칭 직전에 p.Deconstruct(out int x_temp, out int y_temp) 를 호출해서 두 값을 꺼낸 다음, 그 결과를 비교합니다. 즉 위치 패턴은 "분해 + 비교" 가 한 줄에 묶인 문법입니다.

Deconstruct — 구조 분해 메서드 (deconstructor) 인스턴스 메서드 이름이 정확히 Deconstruct 이고 모든 매개변수가 out 으로 선언된 메서드. 컴파일러는 이 메서드를 보면 위치 패턴과 분해 대입(var (a, b) = obj) 양쪽에서 자동으로 호출합니다.
예시: public void Deconstruct(out int x, out int y) { x = X; y = Y; } 이 메서드가 있으면 obj is (0, var y) 가 컴파일됩니다.

3. 한 줄짜리 비유 — 자판기 영수증 vs 자판기

영수증을 받았다면 거기에 "콜라 / 1500 원" 이라고 이미 두 줄로 나뉘어 적혀 있습니다. 보고 그대로 비교하면 됩니다 — 이게 튜플 패턴입니다.

자판기 본체가 있다면 안에 들어 있는 정보를 알기 위해 먼저 분해 버튼을 눌러야 합니다. 누르면 "이 안에는 콜라가 들어 있고 가격은 1500 원입니다" 라고 두 값을 출력합니다. 그다음 출력된 값을 비교합니다 — 이게 위치 패턴입니다.

C# 에서 그 분해 버튼이 바로 Deconstruct 메서드입니다. 컴파일러는 위치 패턴을 만나면 자동으로 이 버튼을 눌러 줍니다.


4. 기본 사용법 — Deconstruct 가 있어야 위치 패턴이 가능하다

가장 작은 예제부터 봅니다. Point 가 위치 패턴을 받을 수 있게 만들려면 두 가지 방법 중 하나를 씁니다.

4-1. 직접 Deconstruct 메서드 작성

C#
public class Point
{
    public int X { get; }
    public int Y { get; }

    public Point(int x, int y) { X = x; Y = y; }

    // 위치 패턴은 이 메서드를 호출한다.
    public void Deconstruct(out int x, out int y)
    {
        x = X;
        y = Y;
    }
}

Point p = new Point(0, 5);
if (p is (0, var y)) Console.WriteLine($"x=0, y={y}");  // 출력: x=0, y=5

Deconstruct 의 규칙은 단순합니다.

  • 이름은 정확히 Deconstruct
  • 반환 타입은 void
  • 모든 매개변수는 out
  • 매개변수 순서가 곧 위치 패턴의 슬롯 순서

4-2. record / record struct — 자동 생성

record 또는 record struct 를 위치 매개변수로 선언하면 컴파일러가 같은 모양의 Deconstruct 를 자동으로 만들어 줍니다.

C#
public record Point(int X, int Y);              // class 형 record
public record struct PointRS(int X, int Y);     // value 형 record (C# 10+)

위 한 줄짜리 선언만으로 다음과 동일한 Deconstruct 가 생긴다는 사실을 IL 로 확인할 수 있습니다.

IL
.method public hidebysig instance void Deconstruct (
    [out] int32& X, [out] int32& Y
) cil managed
{
    IL_0000: ldarg.1                   // out 매개변수 X 의 주소를 평가 스택에 적재
    IL_0001: ldarg.0
    IL_0002: call instance int32 Point::get_X()  // 자동 생성된 X 프로퍼티 게터 호출
    IL_0007: stind.i4                  // 해당 주소에 X 값 저장
    IL_0008: ldarg.2                   // out 매개변수 Y 의 주소
    IL_0009: ldarg.0
    IL_000a: call instance int32 Point::get_Y()
    IL_000f: stind.i4
    IL_0010: ret
}

복잡해 보이지만 본질은 한 줄짜리 C# 과 같습니다 — X = this.X; Y = this.Y;. 자동 생성되는 Deconstruct 는 부수 효과가 없는 단순 게터 호출의 나열 입니다. 그래서 record 위치 패턴은 매번 호출해도 비용이 작습니다.

4-3. point is (0, var y) vs point is Point(0, var y)

같은 위치 패턴이지만 앞에 타입 이름이 붙느냐로 의미가 살짝 달라집니다.

C#
object obj = new Point(0, 5);

// (A) obj 의 컴파일 타임 타입은 object 라서 어떤 Deconstruct 를 부를지 모른다 → 컴파일 에러
// if (obj is (0, var y)) { ... }

// (B) Point 인지 확인 + Deconstruct 호출 + 비교가 한 번에 일어남
if (obj is Point(0, var y)) Console.WriteLine($"y={y}");  // 출력: y=5

규칙은 다음과 같이 정리할 수 있습니다.

형태 컴파일러가 보는 것
p is (0, var y) p컴파일 타임 타입에서 Deconstruct 를 찾는다. 그 타입에 없으면 에러.
p is Point(0, var y) 먼저 p is Point 인지 검사하고, 그 다음에 Point.Deconstruct 를 호출. 다른 타입이면 매칭 실패.

object 변수에 들어 있는 객체나 다형 변수처럼 런타임 타입을 함께 검사해야 하는 경우 에는 항상 타입 이름을 붙여야 합니다. 컴파일 타임에 타입이 이미 명확하다면 생략해도 됩니다.


5. 내부 동작 — IL 로 본 위치 패턴의 진짜 모습

위치 패턴이 실제로 어떻게 풀리는지 IL 로 확인합니다. 다음 C# 코드를 컴파일했습니다.

C#
public record Point(int X, int Y);  // 자동 Deconstruct

static string Classify(Point p) => p switch
{
    (0, 0)     => "origin",
    (0, var y) => $"x=0, y={y}",
    (var x, 0) => $"y=0, x={x}",
    _          => "other"
};

Classify 함수의 핵심 IL 은 다음과 같습니다.

IL
.method private hidebysig static string Classify(class Point p)
{
    .locals init (
        [0] int32,        // V_0 — Deconstruct 의 두 번째 out (Y)
        [1] int32,        // V_1 — Deconstruct 의 첫 번째 out (X)
        [2] string        // V_2 — switch 의 결과
    )

    IL_0000: ldarg.0
    IL_0001: brfalse.s   IL_0070      // ① null 이면 default arm 으로 점프

    IL_0003: ldarg.0
    IL_0004: ldloca.s    1            // ② V_1 (X 받을 자리) 의 주소
    IL_0006: ldloca.s    0            // ③ V_0 (Y 받을 자리) 의 주소
    IL_0008: callvirt    instance void Point::Deconstruct(int32&, int32&)
    //     ↑ class 이므로 callvirt — null 검사 + 가상 디스패치

    IL_000d: ldloc.1                   // ④ V_1 (X) 검사
    IL_000e: brtrue.s    IL_0015      // X != 0 이면 (var x, 0) arm 으로
    IL_0010: ldloc.0                   // ⑤ V_0 (Y) 검사
    IL_0011: brfalse.s   IL_001a      // Y == 0 이면 "origin"
    IL_0013: br.s        IL_0022      // 그 외 → "x=0, y=..." arm

    IL_0015: ldloc.0                   // ⑥ X != 0 인 경로에서 Y 검사
    IL_0016: brfalse.s   IL_0049      // Y == 0 이면 (var x, 0) arm
    IL_0018: br.s        IL_0070      // 그 외 → default ("other")

    IL_001a: ldstr       "origin"      // ⑦ "origin" 분기
    IL_001f: stloc.2
    IL_0020: br.s        IL_0076

    // ... (나머지 분기는 생략) ...

    IL_0070: ldstr       "other"       // default arm
    IL_0075: stloc.2
    IL_0076: ldloc.2
    IL_0077: ret
}

이 IL 에서 위치 패턴의 본질이 그대로 보입니다.

  • ① null 검사: brfalse.s 한 줄로 null 처리됩니다. null 이면 Deconstruct 호출은 건너뛰고 곧장 default arm 으로 갑니다. 이 검사는 컴파일러가 자동으로 끼워 넣은 것 입니다.
  • ② ~ ③ out 변수 준비: ldloca.s 로 지역변수의 주소를 평가 스택에 올립니다. C# 의 out int x 와 같은 의미입니다.
  • ③ Deconstruct 호출: callvirt 한 번만 호출됩니다. switch 의 모든 arm 이 이 한 번의 호출 결과를 공유 합니다 — arm 마다 매번 호출하지 않습니다.
  • ④ ~ ⑥ 비교 사슬: 꺼낸 X, Y 를 brtrue.s / brfalse.s 로 비교하는 단순한 분기 트리입니다. 튜플 패턴과 본질이 같습니다.

위치 패턴의 컴파일러 변환 모델

위 IL 을 사람이 읽기 쉬운 C# 으로 풀어 쓰면 다음과 같습니다.

C#
// switch 표현식이 컴파일러 머릿속에서 풀리는 모습
string Classify(Point p)
{
    if (p == null) return "other";

    int x_temp, y_temp;
    p.Deconstruct(out x_temp, out y_temp);

    if (x_temp == 0)
    {
        if (y_temp == 0) return "origin";
        return $"x=0, y={y_temp}";
    }
    else
    {
        if (y_temp == 0) return $"y=0, x={x_temp}";
    }
    return "other";
}

위치 패턴은 마법이 아니라 "Deconstruct 한 번 호출 + if 사슬" 의 syntax sugar 입니다. 그래서 비용도 그것과 같습니다.


6. struct vs class — callcallvirt 의 차이

같은 위치 패턴이라도 대상이 struct 인지 class 인지에 따라 IL 이 달라집니다. 다음 두 함수를 컴파일해 비교했습니다.

C#
public class  PointCls { public int X; public int Y;
    public PointCls(int x, int y){X=x;Y=y;}
    public void Deconstruct(out int x, out int y){x=X;y=Y;} }

public struct PointSt  { public int X; public int Y;
    public PointSt(int x, int y){X=x;Y=y;}
    public void Deconstruct(out int x, out int y){x=X;y=Y;} }

static string ClassifyClass (PointCls p) => p switch { (0, 0) => "origin", (0, var y) => $"x=0, y={y}", _ => "other" };
static string ClassifyStruct(PointSt  p) => p switch { (0, 0) => "origin", (0, var y) => $"x=0, y={y}", _ => "other" };

class 버전 IL:

IL
IL_0000: ldarg.0
IL_0001: brfalse.s   IL_0042       // null 이면 default arm
IL_0003: ldarg.0
IL_0004: ldloca.s    2
IL_0006: ldloca.s    0
IL_0008: callvirt    instance void PointCls::Deconstruct(int32&, int32&)
//     ↑ callvirt — 가상 디스패치 + 호출 직전 null 재검사

struct 버전 IL:

IL
IL_0000: ldarga.s    p             // p 의 주소 (struct 는 ref 로 호출)
IL_0002: ldloca.s    2
IL_0004: ldloca.s    0
IL_0006: call        instance void PointSt::Deconstruct(int32&, int32&)
//     ↑ call — 정적 디스패치, null 검사 없음, brfalse 도 없음

두 가지 차이가 있습니다.

call vs callvirt — IL 의 두 가지 메서드 호출 명령 call 은 컴파일 타임에 결정된 메서드를 그대로 호출합니다(정적 디스패치, null 검사 없음). callvirt 는 객체의 실제 타입에서 메서드를 찾아 호출하며(가상 디스패치), 호출 직전에 객체가 null 이면 NullReferenceException 을 던집니다.
예시: obj.ToString() → C# 컴파일러는 일반 클래스에는 callvirt, 값 타입에는 call 을 씁니다. 위치 패턴의 Deconstruct 도 같은 규칙을 따릅니다.
항목 class struct
Deconstruct 호출 명령 callvirt call
null 검사 brfalse.s 로 자동 삽입 없음 (값 타입은 null 불가)
메서드 디스패치 가상 (override 시 자식 메서드 호출) 정적 (컴파일 타임 결정)
비용 약간 높음 (vtable 조회) 낮음 (직접 호출)

핫패스에서 위치 패턴을 자주 쓴다면, 작은 데이터는 record struct 로 만드는 편이 IL 한 단계라도 줄어듭니다.


7. 사용자 정의 Deconstruct — 인스턴스 메서드 vs 확장 메서드

Deconstruct확장 메서드 로도 작성할 수 있고, 위치 패턴에서 동작하는지 자주 헷갈리는 부분입니다. 직접 컴파일해 확인했습니다.

C#
public class Foo
{
    public int A; public int B;
    public Foo(int a, int b) { A = a; B = b; }
    // 인스턴스 Deconstruct 가 없음
}

public static class FooExt
{
    public static void Deconstruct(this Foo f, out int a, out int b)
    {
        a = f.A; b = f.B;
    }
}

class Program
{
    static void Main()
    {
        var f = new Foo(1, 2);
        var (x, y) = f;                              // 분해 대입 — 동작
        if (f is (1, var b)) Console.Write(b);       // 위치 패턴 — 동작?
    }
}

dotnet build 결과 경고도 에러도 없이 빌드되며 실행 결과는 정상으로 매칭 됩니다. IL 을 디컴파일하면 다음과 같이 풀립니다.

C#
// 컴파일러가 생성한 코드 (디컴파일)
Foo foo = new Foo(1, 2);
foo.Deconstruct(out var a, out var b);   // 분해 대입
int value = a; int value2 = b;
Console.WriteLine($"{value},{value2}");

if (foo != null)                          // 위치 패턴의 자동 null 검사
{
    foo.Deconstruct(out b, out var b2);   // 확장 메서드를 호출!
    if (b == 1) Console.Write(b2);
}

현재 .NET (C# 9 이상)에서는 확장 메서드 Deconstruct 도 위치 패턴에서 정상 동작 합니다. 다만 두 가지 주의가 있습니다.

  1. 인스턴스 메서드가 우선 합니다. 같은 시그니처의 인스턴스 메서드와 확장 메서드가 모두 있으면 인스턴스 쪽이 호출됩니다 — C# 의 일반 멤버 접근 규칙과 동일합니다.
  2. 일부 자료(특히 C# 8 초기 글)는 "확장 메서드 Deconstruct 는 위치 패턴에서 동작하지 않는다" 고 적혀 있는데, 이는 초기 컴파일러 동작에 대한 설명입니다. 최신 컴파일러(Roslyn)에서는 정상 동작합니다.
확인 방법: 의심되면 dotnet buildilspycmd 로 디컴파일해 보면 됩니다. 이 글의 검증도 그렇게 했습니다.

이 사실 덕분에 Unity 의 Vector3, Transform 같은 수정할 수 없는 외부 타입에도 위치 패턴을 적용 할 수 있습니다.


8. 실전 적용 — Unity 에서 위치 패턴

8-1. Vector3 분해로 축 상태 분류

Unity 의 Vector3 자체에는 Deconstruct 가 없지만 확장 메서드로 추가하면 됩니다.

C#
// 어딘가의 정적 클래스에 한 번만 정의
public static class UnityDeconstructExtensions
{
    public static void Deconstruct(this Vector3 v, out float x, out float y, out float z)
    {
        x = v.x; y = v.y; z = v.z;
    }
}

// 사용처
public class PlayerMover : MonoBehaviour
{
    void Update()
    {
        Vector3 v = rb.velocity;

        // ❌ Before — if 사슬
        string state;
        if (v.x == 0 && v.y == 0 && v.z == 0)        state = "Idle";
        else if (v.y > 0 && v.x == 0 && v.z == 0)    state = "PureJump";
        else if (v.y < 0)                             state = "Falling";
        else                                          state = "Moving";

        // ✅ After — 위치 패턴
        string state2 = v switch
        {
            (0f, 0f, 0f)        => "Idle",
            (0f, > 0f, 0f)      => "PureJump",
            (_, < 0f, _)        => "Falling",
            _                   => "Moving"
        };
    }
}

Vector3.x, .y, .z 가 6 번 반복되던 코드가 4 줄짜리 표로 바뀝니다. 컴파일러는 이 코드를 위 5 절에서 본 IL 과 동일한 패턴으로 풀어내므로 switch 안에서 Deconstruct 는 한 번만 호출됩니다.

8-2. ❌ 부수 효과를 가진 Deconstruct

다음은 신입이 자주 저지르는 실수입니다.

C#
// ❌ Bad — Deconstruct 안에서 카운터 증가
public class Counter
{
    public int X;  public int Y;  public int CallCount;

    public void Deconstruct(out int x, out int y)
    {
        CallCount++;       // 부수 효과!
        x = X;  y = Y;
    }
}

var c = new Counter { X = 0, Y = 5 };
string r = c switch
{
    (0, 0)     => "origin",
    (0, var y) => $"y={y}",
    _          => "other"
};
// c.CallCount == 1

CallCount 가 1 인 것은 정상입니다 — 5 절 IL 에서 본 것처럼 switch 표현식 안에서 Deconstruct 는 한 번만 호출되기 때문입니다. 그러나 같은 객체로 매칭이 자주 일어나는 코드 (이벤트 핸들러, Update 안의 분기 등)에서는 호출 횟수가 누적됩니다.

C#
// ✅ Good — Deconstruct 는 단순 게터 나열로 둔다
public class Counter
{
    public int X;  public int Y;
    // CallCount 같은 부수 효과는 별도 메서드로 분리
    public void Deconstruct(out int x, out int y) { x = X; y = Y; }
}
원칙: Deconstruct 는 항상 순수 함수(게터 나열)로 작성합니다. 부수 효과는 절대 넣지 않습니다.

8-3. record 자동 Deconstruct 와 매개변수 순서

C#
public record EnemyState(int Hp, int Mp);   // (Hp, Mp) 순서

string status = enemy switch
{
    (0, _)        => "Dead",
    (_, 0)        => "OutOfMana",
    (< 30, _)     => "LowHp",
    _             => "Healthy"
};

이 코드는 잘 동작합니다. 하지만 누군가가 record 정의를 record EnemyState(int Mp, int Hp) 로 바꾸면, 위 switch 의 의미가 모두 뒤집힙니다 — 컴파일은 그대로 통과합니다. 이 함정은 7 절에서 다시 다룹니다.


9. 함정과 주의사항

9-1. ❌ Deconstruct 가 없는 타입에는 못 쓴다

C#
public class Point2 { public int X; public int Y; }    // Deconstruct 없음

Point2 p = new Point2 { X = 0, Y = 5 };
// ❌ 컴파일 에러 CS1061 / CS8129
// if (p is (0, var y)) Console.WriteLine(y);

에러 메시지는 다음과 같습니다.

error CS8129: No suitable 'Deconstruct' instance or extension method was found
              for type 'Point2', with 2 out parameters and a void return type.

해결법은 두 가지입니다.

C#
// ✅ (A) Deconstruct 인스턴스 메서드 추가
public class Point2
{
    public int X; public int Y;
    public void Deconstruct(out int x, out int y) { x = X; y = Y; }
}

// ✅ (B) 확장 메서드로 추가 — 외부 타입에 유용
public static class Point2Ext
{
    public static void Deconstruct(this Point2 p, out int x, out int y)
    { x = p.X; y = p.Y; }
}

9-2. ❌ record 매개변수 순서 변경

C#
// 원래 정의
public record Point(int X, int Y);

string s = p switch
{
    (0, var y) => $"x=0, y={y}",   // 1번째가 0, 2번째 그대로
    (var x, 0) => $"y=0, x={x}",
    _          => "other"
};

// ❌ 이후 누군가가 record 를 이렇게 바꿈
public record Point(int Y, int X);   // 매개변수 순서를 뒤집음

// switch 는 그대로지만 의미가 완전히 바뀜:
// (0, var y) 는 이제 "Y == 0 이고 X 를 y 라는 이름에 담는다" 가 됨!

위치 패턴은 이름이 아니라 순서로 매칭 합니다. record 의 위치 매개변수 순서는 공개 API 의 일부로 다뤄야 합니다. 이름으로 매칭하고 싶다면 속성 패턴(앞 글에서 다룸) 을 써야 합니다.

C#
// ✅ 순서가 바뀌어도 안전
string s = p switch
{
    { X: 0, Y: var y } => $"x=0, y={y}",
    { X: var x, Y: 0 } => $"y=0, x={x}",
    _                  => "other"
};

9-3. ❌ Deconstruct 안의 부수 효과

8-2 절에서 본 것과 같은 문제입니다. 한 번 더 정리하면:

C#
// ❌ Bad — 로깅을 Deconstruct 에 넣음
public void Deconstruct(out int x, out int y)
{
    Debug.Log($"Deconstruct called on {this}");   // 매 매칭마다 로그가 찍힘
    x = X; y = Y;
}

위치 패턴이 들어 있는 함수가 자주 호출되면 로그가 폭주합니다. Deconstruct 는 절대 로깅·I/O·상태 변경을 하지 않는다 는 규약을 팀 전체에서 유지해야 합니다.

9-4. null 자동 검사 — 의도와 다를 수 있음

5 절 IL 에서 본 것처럼 class 위치 패턴은 자동으로 null 검사를 끼워 넣어서, null 이면 패턴이 조용히 false 가 됩니다.

C#
Point p = null;
string r = p switch
{
    (0, 0)     => "origin",
    (0, var y) => $"y={y}",
    _          => "other"
};
// r == "other"  — NullReferenceException 이 아니라 default arm 으로 떨어짐

이 동작은 안전한 기본값이지만, 때로 null 이 진짜 버그인데 default arm 으로 가려져서 안 보이는 상황 이 생깁니다. null 을 명시적으로 다루고 싶다면:

C#
// ✅ null 패턴을 명시적으로 추가
string r = p switch
{
    null       => throw new ArgumentNullException(nameof(p)),
    (0, 0)     => "origin",
    (0, var y) => $"y={y}",
    _          => "other"
};

10. C# 버전별 변화

버전 변화
C# 7.0 Deconstruct 메서드와 분해 대입(var (a, b) = obj) 도입. 단, 패턴 매칭에서는 사용 불가.
C# 8.0 위치 패턴 도입. obj is (...)switch 표현식에서 Deconstruct 를 호출하는 매칭 가능.
C# 9.0 record 도입 — 위치 매개변수가 있으면 Deconstruct 자동 생성. 위치 패턴이 사실상 표준 도구가 됨.
C# 10.0 record struct 도입 — 값 타입 record 도 자동 Deconstruct. struct 라서 call 사용으로 비용 더 낮음.

C# 7 분해 대입 vs C# 8 위치 패턴 — 무엇이 새로운가

C#
// C# 7 — 분해 대입 (Deconstruct 호출은 같지만 매칭은 없음)
var (x, y) = point;
if (x == 0 && y == 0) Console.WriteLine("origin");

// C# 8 — 위치 패턴 (Deconstruct 호출 + 비교가 한 줄에)
if (point is (0, 0)) Console.WriteLine("origin");

두 코드는 동일한 Deconstruct 를 호출합니다. 차이는 호출 결과를 어떻게 사용하느냐 입니다. 분해 대입은 결과를 변수에 담을 뿐, 매칭은 따로 해야 합니다. 위치 패턴은 결과를 곧장 비교 합니다.

C# 7 의 Deconstruct 가 없었다면 C# 8 의 위치 패턴도 없었을 것입니다. 같은 메커니즘 위에 매칭이 한 층 더 얹힌 셈입니다.


11. 정리

위치 패턴을 한 문장으로 요약하면 "객체에 Deconstruct 가 있으면, 그 결과를 튜플처럼 매칭할 수 있다" 입니다. 외워야 할 것은 다음 다섯 가지뿐입니다.

  • [ ] 위치 패턴 = Deconstruct 호출 + 비교 사슬. 컴파일러가 obj.Deconstruct(out _, out _) 를 자동으로 끼워 넣고 결과를 비교합니다. switch 안에서는 한 번만 호출됩니다.
  • [ ] 튜플 패턴은 이미 분해된 값, 위치 패턴은 객체. (int, int) 변수에는 튜플 패턴, 객체 변수에는 위치 패턴.
  • [ ] record / record struct 는 자동 Deconstruct. 단, 매개변수 순서 가 매칭에 그대로 반영되므로 순서 변경 = 의미 변경입니다.
  • [ ] class 는 callvirt + 자동 null 검사, struct 는 call. struct 가 IL 한 단계 짧고 null 검사도 없습니다. 핫패스에서는 record struct 가 유리합니다.
  • [ ] Deconstruct 는 순수 함수로. 로깅·I/O·상태 변경 금지. 확장 메서드로 작성해도 위치 패턴에서 정상 동작하므로 외부 타입(Vector3 등)에도 자유롭게 적용할 수 있습니다.

위치 패턴은 패턴 매칭 시리즈의 마지막 큰 조각입니다. 다음 글부터는 분기에서 반복으로 시선을 옮겨 for, foreach, while 같은 반복문의 내부 동작을 봅니다.

반응형

+ Recent posts