C#에 유니온 타입 도입: .NET 11 최신 추가 기능 심층 분석

수년간, F#, Rust, TypeScript와 같은 함수형 언어에서 온 개발자들은 C#을 바라보며 여러 다른 타입 중 하나가 될 수 있는 값을 일급으로 표현할 방법이 없다는 점에 의문을 품어 왔습니다. 지금까지 일반적인 우회 방법은 불안정한 기반 클래스, object 캐스팅, 혹은 OneOf와 같은 서드파티 라이브러리를 사용하는 것이었습니다.

.NET 11 프리뷰(및 C# 15)와 함께, 언어는 마침내 union 키워드를 도입했습니다. 이 추가 기능을 통해 개발자는 "이것 혹은 저것" 시나리오를 타입 수준에서 직접 모델링할 수 있게 되어, 타입 안전성을 크게 향상시키고 "Result 패턴"과 관련된 보일러플레이트 코드를 줄여줍니다.

C# 15에서 유니온 타입 이해하기

핵심적으로, 유니온 타입은 하나의 변수가 미리 정의된 여러 타입 중 하나를 보유하도록 허용합니다. 함수형 언어에서는 수십 년간 사용되어 왔지만, C#의 구현은 태그가 있는 유니온(구분 유니온 또는 합 타입이라고도 함)에 중점을 둡니다. 이러한 유니온은 값이 허용된 타입 중 정확히 하나임을 보장하는 래퍼 역할을 합니다.

union 키워드 활용 예시

여러 운영 체제를 지원해야 하고 각각 다른 데이터 요구사항이 있는 상황을 생각해 보세요. 이전에는 기반 클래스나 열거형 태그를 사용했을 수 있습니다. C# 15에서는 이를 간결하게 정의할 수 있습니다:

public record Windows(string Version);
public record Linux(string Distro, string Version);
public record MacOS(string Name, int Version);

// Define the union type
public union SupportedOS(Windows, Linux, MacOS);

인스턴스를 생성하는 것은 직관적이며, 명시적 생성과 암시적 변환을 모두 지원합니다:

// Implicit conversion
SupportedOS os = new MacOS("Tahoe", 25);

완전한 패턴 매칭

유니온의 진정한 힘은 switch 식과 결합될 때 발휘됩니다. 컴파일러는 이제 유니온의 경계를 이해하므로 완전성을 강제할 수 있습니다. 가능한 타입 중 하나를 처리하지 않으면 컴파일러는 경고(CS8509)를 발생시키며, 특별히 원하지 않는 한 기본 버림(_) 케이스가 필요 없게 됩니다.

string GetDescription(SupportedOS os) => os switch
{
    Windows windows => $"Windows {windows.Version}",
    Linux linux => $"{linux.Distro} {linux.Version}",
    MacOS macOS => $"MacOS {macOS.Name} ({macOS.Version})",
}; // No discard case required!

내부 구현: 구현 방식과 박싱

성능 영향을 이해하려면 컴파일러가 union 키워드를 어떻게 구현하는지 살펴보는 것이 도움이 됩니다. 기본적으로 union은 새로운 IUnion 인터페이스를 구현하고 [Union] 특성으로 장식된 struct로 생성됩니다.

기본 구현

표준으로 생성된 코드는 대략 다음과 같습니다:

[Union]
public struct SupportedOS : IUnion
{
    public object? Value { get; }
    public SupportedOS(Windows value) => this.Value = (object) value;
    public SupportedOS(Linux value) => this.Value = (object) value;
    public SupportedOS(MacOS value) => this.Value = (object) value;
}

내부 값이 object로 저장되기 때문에 값 타입(intbool 등)은 힙에 박싱됩니다. 많은 애플리케이션에서는 이 오버헤드가 무시할 수준이지만, 고성능 "핫 경로"에서는 상당한 병목이 될 수 있습니다.

사용자 정의 유니온으로 박싱 회피

성능이 중요한 코드에서는 .NET 11 프레임워크가 TryGetValue 패턴을 구현함으로써 기본 박싱 동작을 우회할 수 있게 합니다. 유니온의 각 멤버에 대해 특정 TryGetValue 메서드를 제공하면, 컴파일러는 switch 식에서 박싱된 Value 속성에 접근하는 대신 이 메서드들을 사용합니다.

[Union]
public struct IntOrBool : IUnion
{
    private readonly bool _isBool;
    private readonly int _value;

    public bool TryGetValue(out int value)
    {
        value = _value;
        return !_isBool;
    }

    public bool TryGetValue(out bool value)
    {
        value = _isBool && _value is 1;
        return _isBool;
    }
    
    public object Value => _isBool ? _value is 1 : _value;
}

커뮤니티 관점 및 트레이드오프

유니온 타입 도입은 개발자 커뮤니티 사이에서 큰 논의를 촉발했습니다. 전반적으로 환영받고 있지만, 몇 가지 논쟁점이 부각되었습니다:

  • "F# 영향": 많은 관찰자들은 C#가 시간이 지남에 따라 F#의 기능을 효과적으로 흡수하고 있다고 지적했습니다. 한 댓글자는 이렇게 말했습니다:

    "F#는 수십 년 동안 이 기능을 가지고 있었고, C#는 기본적으로 C 스타일 문법을 가진 F#로 서서히 변하고 있다."

  • 태그가 있는 유니온 vs. 태그가 없는 유니온: 용어에 대한 중요한 기술적 구분이 제기되었습니다. C#는 태그가 있는 유니온(대수적 데이터 타입)을 구현하고 있으며, 이는 TypeScript에서 변수 타입이 A | B와 같이 래퍼 생성자 없이도 될 수 있는 태그가 없는 유니온과 다릅니다.
  • 성능 우려: 일부 개발자는 박싱이 기본 동작이라는 점에 실망감을 표하며, 언어가 기본적으로 값 타입에 대한 힙 할당을 피하기 위해 TryGetValue 패턴을 내부적으로 구현했어야 한다고 주장했습니다.

앞으로의 전망

유니온 타입은 C#에서 보다 나은 완전성을 추구하는 더 넓은 움직임의 시작에 불과합니다. 언어 로드맵에는 여러 관련 제안이 포함되어 있습니다:

  1. Closed Enums(폐쇄형 열거형): 모든 경우를 포괄하는 디스크패치 케이스 없이 switch 식을 사용할 수 있는 열거형.
  2. Closed Hierarchies(폐쇄형 계층): 클래스를 closed로 표시하여 외부 파생을 방지하고, 클래스 계층에 대한 완전 매칭을 가능하게 하는 기능.
  3. Union Member Providers(유니온 멤버 제공자): 유니온 자체와 별개의 타입에 유니온 멤버를 정의하는 방법.

이러한 기능들을 통합함으로써, C#는 함수형 언어의 안전성과 표현력을 점점 더 가까이 끌어당기면서도, 범용 객체 지향 언어로서의 다재다능함을 유지하고 있습니다.

SUMMARY: C# 15는 'union' 키워드를 도입하여, .NET 생태계에 오래 기다려온 태그가 있는 유니온 지원을 제공함으로써 보다 안전하고 표현력 있는 데이터 모델링을 가능하게 합니다.

TITLE: C#에 유니온 타입 도입: .NET 11 최신 추가 기능 심층 분석

Sources