C# 15 유니언 타입(union)과 닫힌 계층(closed) 해부: System.Text.Json 분류기·Web 옵션 모호성 함정과 ASP.NET Core 바인딩 경계 점검하기
.NET 11과 함께 정식 출시된 C# 15의 유니언 타입(union)과 닫힌 계층 구조(closed)의 설계 경계 및 System.Text.Json·ASP.NET Core 직렬화 규칙을 정리한다. JsonSerializerOptions.Web의 문자열-숫자 모호성 예외, 구조적 분류기(JsonUnionTypeStructuralClassifier) 한계, 비전이성 규칙과 로컬 검증 예제를 살펴본다.
C# 15 유니언 타입(union) vs 닫힌 계층(closed) 및 System.Text.Json·ASP.NET Core 바인딩 아키텍처 다이어그램 (개념 일러스트 SVG)
2026년 11월 10~13일 열린 .NET Conf 2026과 함께 .NET 11 및 C# 15가 정식 출시(GA)되면서, net11.0 타깃 프로젝트에서 별도의 <LangVersion>preview</LangVersion> 설정 없이 **유니언 타입(union)**과 **닫힌 계층 구조(closed hierarchies)**를 기본 문법으로 사용할 수 있게 되었다 (What's new in C# 15). 두 기능 모두 컴파일러가 switch 식의 완전성(Exhaustiveness, CS8509)을 검사해 _ => ... 폴백 분기 없이 모든 케이스 처리를 강제하지만, 런타임 타입 관계와 System.Text.Json(STJ)·ASP.NET Core 직렬화 동작은 완전히 다르다 (Serialize union types with System.Text.Json, Use C# unions and closed hierarchies in ASP.NET Core).
실무 판단 기준을 먼저 요약하면, 원시 타입(int, string)이나 외부 라이브러리의 무관한 타입들을 감싸야 하거나 $type 판별자(Discriminator) 없는 기존 JSON 계약을 유지해야 할 때는 union을 선택하고, 직접 소유한 도메인 모델 계층에서 안정적인 $type 다형성 직렬화가 필요할 때는 closed 클래스에 [JsonPolymorphic(InferClosedTypePolymorphism = true)]를 조합하는 편이 낫다. 특히 ASP.NET Core의 웹 기본 옵션(JsonSerializerOptions.Web)에서는 JSON 문자열에서 숫자를 읽는 기능이 켜져 있어 union(int, string)을 요청 본문([FromBody])으로 역직렬화하면 "25%" 같은 문자열도 모호성 오류(JsonException)로 실패하므로 반드시 커스텀 분류기(JsonTypeClassifier)를 지정해야 하며, 쿼리 스트링·라우트·헤더·폼 바인딩에서는 유니언이 지원되지 않는다. 이 글은 Microsoft의 .NET 11 출시 발표, C# 15 공식 문서, System.Text.Json 유니언 직렬화 가이드, ASP.NET Core 유니언·닫힌 계층 가이드를 교차 검증해 정리한 글이며, 프로덕션 웹 서버의 처리량 벤치마크를 직접 측정한 결과가 아니라 공식 컴파일러·직렬화 명세와 로컬 Python 분류 알고리즘 시뮬레이션(5개 검증 항목)을 기준으로 설명한다.
1. C# 15 union 타입과 closed 계층 구조의 문법과 컴파일러 완전성 검사
C# 15 이전에는 서로 다른 반환 결과를 하나의 시그니처로 묶기 위해 object를 반환하거나 억지로 공통 기반 클래스를 도입해야 했다. C# 15는 이 문제를 두 가지 서로 다른 축으로 해결한다 (What's new in C# 15):
- 유니언 타입(
union): 상속 관계가 없는 임의의 케이스 타입 목록(원시 타입, 레코드, 클래스, 인터페이스, Nullable 타입 포함) 중 정확히 하나의 값을 담는 명명된 타입이다. 각 케이스 타입에서 유니언으로의 암시적 변환(Implicit Conversion)을 제공하며, 컴파일러가switch식의 모든 케이스 누락 여부를 검사(CS8509)한다.
- 닫힌 계층 구조(
closed): 기반 클래스에closed제한자를 붙여 선언된 어셈블리 외부에서 직접 파생(Direct Derivation)하는 것을 차단한다. 컴파일러가 컴파일 시점에 직접 파생된 하위 타입 전체 집합을 확정할 수 있으므로,switch식에서 모든 하위 타입을 처리하면 기본(_) 분기가 필요 없다.
2. 혼동하기 쉬운 5가지 핵심 경계: 비전이성, 무래퍼 JSON, Web 옵션 모호성, 구조적 분류기, 바인딩 제한
C# 15의 union과 closed를 백엔드 API와 직렬화 파이프라인에 적용할 때 반드시 구분해야 할 다섯 가지 기술적 경계가 있다:
closed제한자는 하위 클래스로 자동 전파(Transitive)되지 않으며 단독으로 JSON$type을 추가하지 않는다:closed클래스는 암시적으로abstract이며sealed,static, 명시적abstract와 함께 쓸 수 없다. 무엇보다 파생 제한은 전이되지 않으므로(Derivation isn't transitive),closed기반 클래스의 자식 클래스가sealed나closed로 선언되지 않았다면 다른 어셈블리에서 그 자식 클래스를 다시 상속할 수 있다 (What's new in C# 15 — Closed hierarchies). 또한closed키워드 자체는 C# 타입 관계만 제어할 뿐 JSON에$type판별자를 자동 추가하지 않으므로, 기반 타입 기준 다형성 역직렬화가 필요하면[JsonPolymorphic(InferClosedTypePolymorphism = true)]를 명시해야 한다 (Serialize union types with System.Text.Json).
union직렬화는 래퍼(Wrapper)나$type없이 활성 케이스 값만 그대로 출력한다:System.Text.Json은 유니언을 직렬화할 때 유니언 껍데기를 벗기고 현재 활성 케이스의 JSON 계약만 그대로 기록한다. 예를 들어UnionIntString(42)는42로,UnionPet(new Cat("Whiskers", "Tabby"))는{"name":"Whiskers","coat":"Tabby"}로 직렬화된다 (Use C# unions and closed hierarchies in ASP.NET Core).
- 기본
JsonSerializerOptions와JsonSerializerOptions.Web에서union(int, string)의 역직렬화 결과가 다르다: 기본 옵션에서는 JSON 숫자가int로, JSON 문자열이string으로 명확히 매핑된다. 그러나 ASP.NET Core Minimal API·MVC가 기본 사용하는JsonSerializerOptions.Web에서는 숫자를 JSON 문자열에서도 읽을 수 있도록 허용하므로int와string두 케이스가 모두 JSONString토큰 소유권을 주장한다. 그 결과"25%"처럼 숫자가 아닌 문자열이 들어와도 파싱 전에 모호성 예외(JsonException)가 발생한다 (Serialize union types with System.Text.Json). 반면 SignalR의JsonHubProtocol은 문자열 토큰을 숫자 케이스와 모호한 것으로 취급하지 않아 별도 분류기 없이 양방향 전송이 가능하다.
- 내장 구조적 분류기(
JsonUnionTypeStructuralClassifier)는 루트 프로퍼티 이름만 검사하며 값이나 중첩 객체는 보지 않는다: 두 케이스가 모두 JSON 객체(StartObject)인union Pet(Dog, Cat)은 기본 토큰 분류기로 구분할 수 없어[JsonUnion(TypeClassifier = typeof(JsonUnionTypeStructuralClassifier))]를 붙여야 한다. 이 분류기는 루트 레벨 프로퍼티 이름, 필수(required) 프로퍼티 누락 여부,JsonUnmappedMemberHandling.Disallow설정만 사용해 후보를 좁히며, 프로퍼티 값·중첩 객체·배열 원소는 검사하지 않는다. 후보가 0개이거나 2개 이상 남으면JsonException을 던지고,Guid와string처럼 동일한 비객체 JSON 토큰을 공유하는 조합은 애초에 분류기 생성 시점에NotSupportedException이 발생한다 (Distinguish cases with the same JSON token type).
- ASP.NET Core의 유니언 바인딩은
System.Text.Json경로([FromBody])에서만 동작하며 쿼리·라우트·헤더·폼에서는 지원되지 않는다:?id=42같은 원시 문자열 토큰은 JSON 파싱을 거치지 않으므로int,string,Guid중 어느 케이스로 바인딩할지 결정할 수 없다. 따라서 쿼리 스트링, 라우트 파라미터, HTTP 헤더, 폼 필드(Blazor의[SupplyParameterFromQuery],[SupplyParameterFromForm]포함) 및 SignalR의MessagePack·Newtonsoft.Json프로토콜에서는 유니언이 지원되지 않는다 (ASP.NET Core Limitations).
| 비교 기준 | C# 15 유니언 타입 ( union) | C# 15 닫힌 계층 ( closed) | 열린 다형성 계층 (기존 abstract) |
| 선언 문법 | public union Payload(int, string, Message); | public closed record Event; | public abstract record Shape; |
| 케이스 간 상속 관계 | 불필요 (원시 타입·외부 레코드·인터페이스 혼합 가능) | 동일 어셈블리 내 공통 기반 클래스 직접 상속 필수 | 어셈블리 경계 없이 기반 클래스 상속 가능 |
switch 완전성 검사 | 컴파일러가 모든 케이스 검사 ( CS8509, _ 불필요) | 컴파일러가 직접 파생 타입 전체 검사 (하위 전이는 sealed/closed 필요) | 불가능 (외부 파생 가능성이 열려 있어 _ 폴백 필수) |
| STJ JSON 직렬화 형태 | 래퍼· $type 없이 활성 케이스 값만 그대로 출력 | 기본은 일반 직렬화, InferClosedTypePolymorphism = true 시 $type 포함 | [JsonPolymorphic] • [JsonDerivedType] 수동 등록 시 $type 포함 |
| OpenAPI 3.2 스키마 | anyOf 스키마 (개별 타입의 기존 컴포넌트 $ref 재사용) | $type 판별자 기반 다형성 스키마 생성 | 명시 등록된 파생 타입에 한해 다형성 스키마 생성 |
| 권장 사용 시나리오 | Kubernetes IntOrString처럼 $type 없는 외부 계약 유지, 이질적 타입 조합 | 직접 제어하는 도메인 이벤트·상태 모델 및 신규 API 계약 설계 | 플러그인·외부 어셈블리 확장을 허용해야 하는 개방형 모델 |
3. System.Text.Json 및 ASP.NET Core 경로별 유니언 동작·제약 매트릭스
유니언 구성 방식과 ASP.NET Core 실행 경로에 따라 추가 분류기(TypeClassifier) 필요 여부와 예외 발생 조건이 어떻게 달라지는지 아래 표로 정리했다 (Serialize union types with System.Text.Json, Use C# unions and closed hierarchies in ASP.NET Core).
| 유니언 구성 및 바인딩 경로 | 기본 동작 (분류기 미지정 시) | 발생 예외 및 원인 | 올바른 해결책 및 설정 방법 |
union(bool, string)(서로 다른 JSON 토큰 타입) | JSON true/false → bool, "..." → string 자동 분류 | 예외 없음 (단일 토큰 매칭) | 추가 분류기 없이 Minimal API·MVC·SignalR 모두 즉시 동작 |
union(int, string) +기본 STJ / SignalR JsonHubProtocol | JSON 숫자 → int, JSON 문자열 → string 정상 왕복 | 예외 없음 | SignalR JsonHubProtocol은 문자열 토큰을 숫자 케이스와 모호하게 보지 않음 |
union(int, string) +JsonSerializerOptions.Web (Minimal API / MVC 입력) | JSON 문자열 입력(예: "25%") 시 역직렬화 실패 | JsonException (웹 옵션이 문자열→숫자 읽기를 허용해 int와 string 모두 String 토큰 주장) | 응답 출력 전용으로 쓰거나, 입력 본문 바인딩 시 커스텀 JsonTypeClassifierFactory 등록 |
union Pet(Dog, Cat)(두 개 이상의 JSON 객체 케이스) | 토큰 타입만으로는 두 StartObject 구분 불가 | JsonException / 소스 생성기 진단 경고 | [JsonUnion(TypeClassifier = typeof(JsonUnionTypeStructuralClassifier))] 지정 또는 closed • $type 사용 |
union(Guid, string) 또는 중첩 유니언 | 비객체 동일 토큰( String) 중복 / 중첩 유니언 미지원 | 분류기 생성 시 NotSupportedException | 래퍼 레코드로 분리하거나 커스텀 변환기/판별자 기반 closed 계층으로 재설계 |
| 쿼리 스트링·라우트·헤더·폼 ( ?id=42, [FromQuery], [SupplyParameterFromForm]) | System.Text.Json을 거치지 않는 문자열 바인딩 | 바인딩 미지원 ( int vs string vs Guid 판별 불가) | 유니언은 [FromBody] JSON 본문, JS Interop, PersistentComponentState에서만 사용 |
4. 실무 C# 15 코드 예제와 로컬 Python 분류 시뮬레이션 검증
4.1 C# 15 구조적 분류기(JsonUnionTypeStructuralClassifier), closed 다형성 추론, 컬렉션 식 인자 예제
아래 C# 15(.NET 11 net11.0) 코드는 서로 다른 프로퍼티를 가진 객체 유니언의 구조적 분류, 자체 도메인 모델을 위한 closed 계층의 $type 자동 추론(InferClosedTypePolymorphism = true), 그리고 C# 15에 함께 추가된 컬렉션 식 생성자 인자(with(...)) 및 확장 인덱서(Extension Indexer) 활용 패턴을 보여준다 (What's new in C# 15, Serialize union types with System.Text.Json).
4.2 로컬 Python 분류기·모호성·비전이성 검증 스크립트
아래 Python 스크립트는 공식 문서에 정의된 System.Text.Json의 토큰 기반 분류와 JsonSerializerOptions.Web에서의 union(int, string) 문자열 모호성 예외(JsonException), JsonUnionTypeStructuralClassifier의 루트 프로퍼티 후보 소거 규칙, null 입력 시 첫 번째 Nullable 케이스 선택 규칙, 그리고 closed 계층의 비전이성(중간 클래스 미봉인 시 외부 확장 가능)까지 5가지 핵심 규칙을 검증한다.
5. 실무 도입 및 API 설계 점검 체크리스트
신규 자체 API 계약인지, 판별자 없는 외부 JSON 계약인지 먼저 구분: 직접 제어하는 도메인 모델 계층이라면 프로퍼티 이름 변경에 취약한 구조적 분류기(JsonUnionTypeStructuralClassifier) 대신 closed 계층 + [JsonPolymorphic(InferClosedTypePolymorphism = true)]를 우선 검토한다.
ASP.NET Core 요청 본문([FromBody])의 union(int, string) 모호성 점검: Minimal API와 MVC는 JsonSerializerDefaults.Web을 사용하므로 숫자+문자열 유니언을 입력 본문으로 받을 때 JsonException이 발생한다. 입력 바인딩이 필요하면 커스텀 JsonTypeClassifierFactory를 등록하거나 숫자 문자열 허용 옵션을 조정한다.
closed 계층의 중간 파생 클래스에 sealed 또는 closed 누락 여부 확인: closed 제한자는 하위 계층으로 자동 전이되지 않는다. 모든 리프(Leaf) 레코드/클래스는 sealed로, 중간 계층 클래스는 closed로 선언해야 외부 어셈블리의 우회 상속을 막고 하위 계층 switch에서도 완전성 검사를 유지할 수 있다.
라우트·쿼리·헤더·폼 파라미터에서 유니언 타입 제거: [FromQuery], [FromRoute], [FromHeader], [FromForm] 및 Blazor [SupplyParameterFromQuery]는 System.Text.Json을 거치지 않으므로 유니언 바인딩이 동작하지 않는다.
컴파일러 경고 CS8509(비완전 switch)를 CI에서 오류(WarningsAsErrors)로 격상: 유니언이나 closed 계층에 새 케이스가 추가되었을 때 누락된 switch 식을 빌드 단계에서 즉시 잡아낼 수 있도록 <WarningsAsErrors>CS8509</WarningsAsErrors> 설정을 권장한다.
6. 참고 자료 및 공식 출처 (2026-11-14 확인)
English version:
C# 15 Union Types vs. Closed Hierarchies in .NET 11: System.Text.Json Classifiers, Web Option Ambiguity Traps, and ASP.NET Core Binding Boundaries