유니언 타입

유니언 타입 (C# 참조)

값이 여러 케이스 타입(case types) 중 하나가 될 수 있는 타입을 유니언 타입(union type) 이라고 해요. 유니언은 각 케이스 타입에서의 암시적 변환, 철저한(exhaustive) 패턴 매칭, 그리고 더 정확해진 nullability 추적을 제공합니다. 유니언 타입은 union 키워드로 선언합니다.

본문

union 키워드를 사용해 유니언 타입을 선언할 수 있어요.

public union Pet(Cat, Dog, Bird);

이렇게 선언하면 Pet 유니언에 Cat, Dog, Bird라는 세 개의 케이스 타입이 생깁니다. 이제 Pet 변수에 세 케이스 타입 중 아무 값이나 넣을 수 있어요. 그리고 컴파일러가 switch 식이 모든 케이스 타입을 빠짐없이 처리하는지 확인해 주죠.

유니언은 값이 정해진 타입 집합 중 정확히 하나여야 하고, 그 모든 가능성을 반드시 처리하도록 컴파일러가 강제하기를 원할 때 선언해요. 흔한 사용 시나리오는 다음과 같습니다.

  • 결과 또는 오류 반환: 메서드가 성공 값이나 오류 값을 반환하고, 호출 측이 둘 다 처리해야 하는 경우예요. union Result(Success, Error) 같은 유니언이 결과 집합을 명시적으로 만들어 줍니다.
  • 메시지 또는 명령 디스패칭: 시스템이 처리하는 메시지 타입 집합이 닫혀 있는 경우요. 유니언을 쓰면 아직 처리하지 않은 새 메시지 타입이 생길 때마다 이를 다루지 않는 모든 switch에서 컴파일 타임 경고가 발생합니다.
  • 마커 인터페이스나 추상 기본 클래스 대체: 패턴 매칭을 위해 타입들을 묶는 용도로만 인터페이스나 추상 클래스를 쓰고 있었다면, 유니언은 상속이나 공유 멤버 없이도 철저성(exhaustiveness) 검사를 제공해 줍니다.

유니언은 다른 타입 선언과 몇 가지 중요한 차이점이 있어요.

  • classstruct와 달리 유니언은 새로운 데이터 멤버를 정의하지 않아요. 대신 기존 타입들을 닫힌 대안 집합으로 조합할 뿐이죠.
  • interface와 달리 유니언은 닫혀 있어요. 선언에 케이스 타입의 전체 목록을 정의하고, 컴파일러가 그 목록으로 철저성 검사를 해요.
  • record와 달리 유니언은 같음(equality), 복제(cloning), 해체(deconstruction) 동작을 추가하지 않아요. 유니언은 "어떤 필드를 갖고 있나?"보다 "어떤 케이스인가?"에 집중합니다.

유니언 선언

유니언 선언은 이름과 케이스 타입 목록을 지정합니다.

public union Pet(Cat, Dog, Bird);

케이스 타입object로 변환할 수 있는 어떤 타입이든 될 수 있어요. 클래스, 구조체, 인터페이스, 타입 매개변수, nullable 타입, 다른 유니언까지 포함됩니다. 아래 예시들은 서로 다른 케이스 타입 가능성을 보여 줍니다.

public record class Cat(string Name);
public record class Dog(string Name);
public record class Bird(string Name);
public record class None;
public record class Some<T>(T Value);
public union Option<T>(None, Some<T>);
public union IntOrString(int, string);

케이스 타입이 값 타입(예: int)이면 유니언의 Value 속성에 저장될 때 boxing됩니다. 유니언은 내용물을 단일 object? 참조로 저장하거든요.

유니언 선언은 struct처럼 본문을 가질 수 있고, 추가 멤버를 포함할 수 있어요. 단 몇 가지 제약이 있습니다. 유니언 선언에는 인스턴스 필드, 자동 속성(auto-properties), 필드형 이벤트를 넣을 수 없어요. 또 단일 매개변수를 가진 public 생성자도 선언할 수 없는데, 컴파일러가 그 생성자를 유니언 생성 멤버로 만들어 버리기 때문이에요. 아래 Length 유니언은 모든 케이스 타입을 처리하는 패턴 매칭을 사용하는 TotalMeters 속성과, 두 길이를 합치는 Add 메서드를 추가합니다.

public record class Meters(double Value);
public record class Feet(double Value);

public union Length(Meters, Feet)
{
    public double TotalMeters => this switch
    {
        Meters m => m.Value,
        Feet f => f.Value * 0.3048,
        _ => throw new InvalidOperationException("The Length has no value."),
    };

    public Length Add(Length other) => new Meters(TotalMeters + other.TotalMeters);
}

유니언 변환

각 케이스 타입에서 유니언 타입으로의 암시적 유니언 변환이 존재합니다.

Pet pet = new Dog("Rex");
Console.WriteLine(pet.Value); // output: Dog { Name = Rex }

Pet pet2 = new Cat("Whiskers");
Console.WriteLine(pet2.Value); // output: Cat { Name = Whiskers }

유니언 변환은 해당하는 생성된 생성자를 호출해 동작해요. 같은 타입에 사용자 정의 암시적 변환 연산자가 존재하면 사용자 정의 연산자가 유니언 변환보다 우선합니다. 소스 값에 적용 가능한 케이스 타입이 둘 이상이면 유니언 변환이 모호해져서 컴파일러가 오류를 보고합니다. 변환 우선순위에 대한 자세한 내용은 기능 사양을 참고하세요.

T가 유니언 타입일 때 nullable 유니언 구조체(T?)로의 유니언 변환도 동작합니다.

Pet? maybePet = new Dog("Buddy");
Pet? noPet = null;

Console.WriteLine(Describe(maybePet)); // output: Dog: Buddy
Console.WriteLine(Describe(noPet));    // output: no pet

static string Describe(Pet? pet) => pet switch
{
    Dog d => d.Name,
    Cat c => c.Name,
    Bird b => b.Name,
    null => "no pet",
};

유니언 패턴 매칭

유니언 타입을 패턴 매칭할 때, 패턴은 일반적으로 유니언 값 자체가 아니라 유니언의 Value 속성에 적용돼요. 이 "언랩핑(unwrapping)" 동작 덕분에 유니언은 패턴 매칭에 투명하게 드러납니다.

Pet pet = new Dog("Rex");

var name = pet switch
{
    Dog d => d.Name,
    Cat c => c.Name,
    Bird b => b.Name,
};
Console.WriteLine(name); // output: Rex

이 규칙에는 세 가지 패턴이 예외예요. 폐기(discard) 패턴 _, var 패턴, not 패턴은 Value 속성이 아니라 유니언 값 자체에 적용됩니다. GetPet()Pet?(Nullable<Pet>)를 반환할 때 var를 사용해 유니언 값을 캡처하세요.

if (GetPet() is var pet) { /* pet is the Pet? value returned from GetPet */ }

논리 패턴에서는 각 분기가 개별적으로 언랩핑 규칙을 따릅니다. and 패턴의 왼쪽 분기는 오른쪽 분기가 보는 들어오는 값을 변경할 수 있어요. not 패턴이 들어오는 유니언 값(Value가 아닌)에 적용되므로, 앞에 오는 not null은 뒤따르는 분기를 위해 값을 언랩하지 않습니다.

GetPet() switch
{
    // 'var pet' captures the Pet?; 'not null' applies to the Pet? value (not pet.Value)
    var pet and not null => ...,
    // 'not null' doesn't unwrap to Pet, so 'var value' still captures the Pet?
    not null and var value => ...,
}

[!NOTE] 패턴이 Value에 적용되므로 pet is Pet 같은 패턴은 일반적으로 매칭되지 않아요. Pet은 유니언 자체가 아니라 유니언의 내용물에 대해 검사되기 때문입니다.

null 매칭

구조체 유니언의 경우 null 패턴은 Value가 null인지 확인합니다.

Pet pet = default;
Console.WriteLine(pet.Value is null); // output: True

var description = pet switch
{
    Dog d => d.Name,
    Cat c => c.Name,
    Bird b => b.Name,
    null => "no pet",
};
Console.WriteLine(description); // output: no pet

클래스 기반 유니언의 경우 null은 유니언 참조 자체가 null이거나 Value 속성이 null일 때 성공합니다.

Result<string>? result = null;
if (result is null) { /* true — the reference is null */ }

Result<string> empty = new Result<string>((string?)null);
if (empty is null) { /* true — Value is null */ }

nullable 유니언 구조체 타입(Pet?)의 경우 null은 nullable 래퍼에 값이 없거나, 기본 유니언의 Value가 null일 때 성공합니다.

유니언 철저성 (Exhaustiveness)

switch 식이 유니언의 모든 케이스 타입을 처리하면 철저(exhaustive)하다고 해요. 컴파일러는 케이스 타입을 처리하지 않았을 때만 경고합니다. 식이 확실히(definitely) 할당된 경우에는 어떤 타입이든 매칭하기 위해 폐기 패턴(_)이나 var 패턴을 포함할 필요가 없어요.

Pet pet = new Dog("Rex");

var name = pet switch
{
    Dog d => d.Name,
    Cat c => c.Name,
    Bird b => b.Name,
};
Console.WriteLine(name); // output: Rex

유니언의 Value 속성 null 상태가 "maybe null"이면 경고를 피하려면 null도 반드시 처리해야 해요.

Pet pet = default;
Console.WriteLine(pet.Value is null); // output: True

var description = pet switch
{
    Dog d => d.Name,
    Cat c => c.Name,
    Bird b => b.Name,
    null => "no pet",
};
Console.WriteLine(description); // output: no pet

이런 상황은 앞의 샘플에서처럼 union 식이 기본값이거나 확실히 할당되지 않았을 때 발생할 수 있어요.

Nullability

컴파일러는 다음 규칙을 통해 유니언의 Value 속성 null 상태를 추적합니다.

  • 유니언의 Value 속성 기본 null 상태는, 케이스 타입 중 하나의 기본 null 상태가 "maybe null"이면 "maybe null"이에요. 그 외에는 "not null"입니다.
  • 케이스 타입(생성자 또는 유니언 변환을 통해)으로 유니언 값을 만들면 Value는 들어오는 값의 null 상태를 받아요.
  • non-boxing 접근 패턴의 HasValueTryGetValue(...) 멤버가 유니언 내용물을 조회하면 true 분기에서 Value의 null 상태가 "not null"이 됩니다.

커스텀 유니언 타입

컴파일러는 union 선언을 struct 선언으로 변환해요. 이 구조체는 [System.Runtime.CompilerServices.Union] 속성으로 표시되고 IUnion 인터페이스를 구현합니다. 각 케이스 타입에 대한 public 생성자와 암시적 변환, 그리고 Value 속성을 포함하죠. 그 생성된 형태는 결정적(opinionated)이에요. 항상 구조체이고, 항상 값 타입 케이스를 boxing하며, 항상 내용물을 object?로 저장합니다.

기존 타입에 맞추거나, 클래스 기반 유니언을 만들거나, 커스텀 저장 전략을 쓰거나, interop 지원이 필요하다면 다른 동작이 필요할 수 있어요. 그럴 때 유니언 타입을 직접 만들 수 있습니다.

[Union] 속성이 있는 어떤 클래스나 구조체든 기본 유니언 패턴을 따르면 유니언 타입이 됩니다. 기본 유니언 패턴은 다음을 요구해요.

  • 타입에 [Union] 속성
  • 단일 by-value 또는 in 매개변수를 가진 public 생성자가 하나 이상. 각 생성자의 매개변수 타입이 케이스 타입을 정의합니다.
  • get 접근자를 가진 object?(또는 object) 타입의 public Value 속성

앞의 모든 유니언 멤버는 public이어야 해요. 컴파일러는 이 멤버들을 사용해 유니언 변환, 패턴 매칭, 철저성 검사를 구현합니다. non-boxing 접근 패턴을 구현하거나 클래스 기반 유니언 타입을 만들 수도 있고요. 커스텀 유니언 타입에 멤버를 더 추가할 수도 있어요.

컴파일러는 커스텀 유니언 타입이 다음 동작 규칙을 만족한다고 가정합니다.

  • 건전성(Soundness): Value는 항상 null 또는 케이스 타입 중 하나의 값을 반환하며, 다른 타입의 값은 절대 반환하지 않아요. 구조체 유니언의 경우 defaultnullValue를 만듭니다.
  • 안정성(Stability): 케이스 타입에서 유니언 값을 만들면 Value는 그 케이스 타입과 일치합니다(입력이 null이었다면 null).
  • 생성 동등성(Creation equivalence): 값이 두 개의 서로 다른 케이스 타입으로 암시적으로 변환 가능하면, 두 생성 멤버 모두 동일한 관찰 가능한 동작을 만들어 냅니다.
  • 접근 패턴 일관성: HasValueTryGetValue 멤버가 있으면 Value를 직접 확인하는 것과 동등하게 동작합니다.

다음 예시는 커스텀 유니언 타입을 보여 줍니다.

[System.Runtime.CompilerServices.Union]
public struct Shape : System.Runtime.CompilerServices.IUnion
{
    private readonly object? _value;

    public Shape(Circle value) { _value = value; }
    public Shape(Rectangle value) { _value = value; }

    public object? Value => _value;
}

public record class Circle(double Radius);
public record class Rectangle(double Width, double Height);
Shape shape = new Shape(new Circle(5.0));

var area = shape switch
{
    Circle c => Math.PI * c.Radius * c.Radius,
    Rectangle r => r.Width * r.Height,
};
Console.WriteLine($"{area:F2}"); // output: 78.54

Non-boxing 접근 패턴

커스텀 유니언 타입은 선택적으로 non-boxing 접근 패턴을 구현해, 패턴 매칭 중 boxing 없이 값 타입 케이스에 대한 강력한 타입(strongly typed) 접근을 활성화할 수 있어요. 이 패턴은 다음을 요구합니다.

  • Valuenull이 아닐 때 true를 반환하는 bool 타입의 HasValue 속성
  • 각 케이스 타입에 대한 TryGetValue 메서드. bool을 반환하고 out 매개변수를 통해 값을 전달해요. Value가 해당 케이스 타입의 non-null 값일 때만 true를 반환합니다. out 매개변수의 타입은 케이스 타입에 대해 identity-convertible이거나, 케이스 타입이 nullable 값 타입일 때 기본 값 타입입니다.
[System.Runtime.CompilerServices.Union]
public struct IntOrBool : System.Runtime.CompilerServices.IUnion
{
    private readonly int _intValue;
    private readonly bool _boolValue;
    private readonly byte _tag; // 0 = none, 1 = int, 2 = bool

    public IntOrBool(int? value)
    {
        if (value.HasValue)
        {
            _intValue = value.Value;
            _tag = 1;
        }
    }

    public IntOrBool(bool? value)
    {
        if (value.HasValue)
        {
            _boolValue = value.Value;
            _tag = 2;
        }
    }

    public object? Value => _tag switch
    {
        1 => _intValue,
        2 => _boolValue,
        _ => null
    };

    public bool HasValue => _tag != 0;

    public bool TryGetValue(out int value)
    {
        value = _intValue;
        return _tag == 1;
    }

    public bool TryGetValue(out bool value)
    {
        value = _boolValue;
        return _tag == 2;
    }
}
IntOrBool val = new IntOrBool((int?)42);

var description = val switch
{
    int i => $"int: {i}",
    bool b => $"bool: {b}",
};
Console.WriteLine(description); // output: int: 42

패턴 매칭에서 컴파일러는 타입 패턴 검사(union is T 같은)에는 TryGetValue를, null 패턴 검사(union is null 같은)에는 HasValue를 호출해서 값 타입 케이스의 boxing을 피해요. 각 멤버는 자신의 패턴 종류에 적용되며, 서로의 폴백이 아닙니다. 멤버가 없으면 컴파일러는 대신 그 패턴에 Value 속성을 사용합니다. 자세한 내용은 컴파일러가 패턴 매칭 코드를 생성하는 방법을 참고하세요.

유니언 멤버 공급자 (Union member providers)

유니언 타입은 자신의 유니언 멤버를 중첩된 IUnionMembers 인터페이스에 위임할 수 있어요. 이 인터페이스가 있으면 union 타입은 유니언 멤버 공급자 역할을 하고, 컴파일러는 IUnionMembers에 선언된 멤버에 대해서만 호출을 생성합니다. 유니언 타입 자체에 선언되어 있지만 IUnionMembers 인터페이스에는 없는 멤버는 컴파일러가 사용하지 않아요.

유니언 멤버 공급자를 사용할 때 인터페이스는 다음 필수 멤버를 선언해야 합니다.

  • 정적 Create 메서드: 각 케이스 타입마다 하나씩, 단일 매개변수를 가지며 반환 타입이 유니언 타입으로 identity-convertible입니다. 이 메서드들이 케이스 타입을 확립합니다.
  • Value 속성: 포함된 값을 반환하는 get 접근자를 가진 public object 또는 object? 속성

컴파일러가 더 효율적인 패턴 매칭 코드를 생성하도록 다음 멤버를 인터페이스에 선택적으로 선언할 수 있어요.

  • TryGetValue 메서드: 각 케이스 타입마다 하나씩, 케이스 타입의 out 매개변수로 bool을 반환합니다.
  • HasValue 속성: Valuenull이 아닐 때 true를 반환하는 get 접근자를 가진 public bool 속성

컴파일러가 패턴 매칭 코드를 생성하는 방법: TryGetValueHasValue는 서로 다른 종류의 패턴에 적용되며, 어느 쪽도 다른 쪽의 폴백이 아닙니다.

  • 타입 패턴(union is T 같은)의 경우, 컴파일러는 선언된 TryGetValue(out T value)를 호출해서 boxing 없이 값을 추출하고 그 값에 패턴을 적용해요. 선언되지 않았다면 컴파일러는 Value 속성에 패턴을 적용하는데, 이는 T에 대한 런타임 타입 검사를 수행하고 T가 값 타입일 때 값을 boxing할 수 있습니다.
  • null 패턴(union is null 같은)의 경우, 컴파일러는 선언된 HasValue를 호출해서 유니언이 값을 포함하는지 검사해요. 선언되지 않았다면 컴파일러는 null 패턴을 Value 속성에 직접 적용합니다.

각 멤버는 독립적으로 선택적이에요. TryGetValue가 없으면 타입 패턴이 Value를 사용하고, HasValue가 없으면 null 패턴이 Value를 사용하죠. 두 중 하나를 선언하면 컴파일러가 해당 패턴 종류에 대해 더 효율적이고 강력한 타입의 코드를 생성할 수 있습니다.

[System.Runtime.CompilerServices.Union]
public struct Outcome<T> : Outcome<T>.IUnionMembers
{
    private readonly object? _value;

    private Outcome(object? value) => _value = value;

    public interface IUnionMembers
    {
        static Outcome<T> Create(T? value) => new(value);
        static Outcome<T> Create(Exception? value) => new(value);
        object? Value { get; }

        // Optional but recommended: TryGetValue enables efficient pattern matching
        bool TryGetValue(out T value);
        bool TryGetValue(out Exception value);
    }

    object? IUnionMembers.Value => _value;

    public bool TryGetValue(out T value)
    {
        if (_value is T t)
        {
            value = t;
            return true;
        }
        value = default!;
        return false;
    }

    public bool TryGetValue(out Exception value)
    {
        if (_value is Exception e)
        {
            value = e;
            return true;
        }
        value = default!;
        return false;
    }
}

유니언 멤버 공급자는 유니언 타입에 private 생성자가 필요하거나, record class 유니언 타입처럼 생성 로직에 팩토리 패턴이 필요할 때 유용해요.

클래스 기반 유니언 타입

클래스도 유니언 타입이 될 수 있어요. 이 유형의 유니언은 참조 의미(reference semantics)나 상속이 필요할 때 유용합니다.

[System.Runtime.CompilerServices.Union]
public class Result<T> : System.Runtime.CompilerServices.IUnion
{
    private readonly object? _value;

    public Result(T? value) { _value = value; }
    public Result(Exception? value) { _value = value; }

    public object? Value => _value;
}
Result<string> ok = new Result<string>("success");
Result<string> err = new Result<string>(new InvalidOperationException("failed"));

Console.WriteLine(Describe(ok));  // output: OK: success
Console.WriteLine(Describe(err)); // output: Error: failed

static string Describe(Result<string> result) => result switch
{
    string s => $"OK: {s}",
    Exception e => $"Error: {e.Message}",
    null => "null",
};

클래스 기반 유니언의 경우 null 패턴은 null 참조와 null Value 둘 다 매칭합니다.

유니언 구현

유니언 타입은 System.Runtime.CompilerServices 네임스페이스의 UnionAttributeIUnion 타입에 의존해요. 런타임에는 .NET 11 Preview 5부터 이 타입들이 포함됩니다.

namespace System.Runtime.CompilerServices;

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false)]
public sealed class UnionAttribute : Attribute;

public interface IUnion
{
    object? Value { get; }
}

컴파일러가 생성한 유니언 선언은 IUnion을 구현해요. 런타임에서 IUnion을 사용해 어떤 유니언 값인지 확인할 수 있습니다.

if (value is IUnion { Value: null }) { /* the union's value is null */ }

union 타입을 선언하면 컴파일러는 IUnion을 구현하는 구조체를 생성합니다. 예를 들어 Pet 선언(public union Pet(Cat, Dog, Bird);)은 다음과 동일해집니다.

[Union] public struct Pet : IUnion
{
    public Pet(Cat value) => Value = value;
    public Pet(Dog value) => Value = value;
    public Pet(Bird value) => Value = value;
    public object? Value { get; }
}

더 알아보기

출처: Microsoft Learn