ASP.NET Core는 .NET 11에서 union 형식과 닫힌 계층 구조를 System.Text.Json과 함께 실용적으로 사용할 수 있도록 지원합니다. 이를 통해 하나의 값이 둘 이상의 형식 또는 특정 형식군에 속할 수 있음을 표현할 수 있습니다. 이 지원은 Minimal APIs, MVC 컨트롤러, SignalR, Blazor 및 OpenAPI 문서로 확장되지만, 읽기와 쓰기 방식 및 매개 변수 바인딩의 제한에는 중요한 차이가 있습니다.
union은 언제 적합한가?
union은 JSON 래퍼나 discriminator 필드 없이 독립적인 대안을 허용하는 계약에 적합합니다. 예를 들어 값이 정수 또는 문자열일 수 있거나, 불리언 값 또는 더 세부적인 옵션을 포함하는 객체를 허용하는 설정이 이에 해당합니다. 직렬화할 때는 활성 상태가 자연스러운 형식으로 직접 기록되므로 union 형식을 위한 별도의 래퍼나 discriminator가 추가되지 않습니다.
JSON 구조로 명확하게 구분할 수 있는 상태는 자동으로 직렬화됩니다. 그러나 Cat과 Dog 객체를 포함하는 union처럼 상태들이 동일한 구조를 공유하면 System.Text.Json은 JSON 토큰만으로 형식을 판별할 수 없습니다. 이 경우 올바른 상태를 선택하도록 사용자 지정 JsonTypeClassifier를 제공할 수 있습니다. 동일한 내용은 숫자 또는 문자열을 허용하는 일부 요청 계약에도 적용됩니다. 웹 전용 JSON 설정에서는 문자열에서 숫자를 읽을 수 있으므로 텍스트가 어느 상태로도 해석될 수 있기 때문입니다.
닫힌 계층 구조는 union과 다르다
closed 한정자를 사용한다고 해서 다형성 직렬화가 자동으로 활성화되는 것은 아닙니다. PaymentAuthorized와 같은 구체적인 파생 형식을 처리할 때 System.Text.Json은 이를 다른 형식과 동일하게 취급하며, discriminator 없이 paymentId와 amount를 포함하는 JSON을 생성합니다.
반면 API에서 기본 형식인 PaymentEvent를 사용할 때는 생성해야 할 파생 형식을 지정해야 합니다. 기본 형식에 JsonPolymorphic(InferClosedTypePolymorphism = true)를 적용하거나 JsonSerializerOptions.InferClosedTypePolymorphism을 통해 이를 활성화할 수 있습니다. 그러면 System.Text.Json은 닫힌 계층 구조에서 직렬화 시 파생 형식을 추론하고 해당 이름을 식별자로 사용합니다. 예를 들어 $type의 값은 PaymentAuthorized가 됩니다.
두 모델 중 어느 것을 선택할지는 계약의 성격에 따라 달라집니다. union은 하나의 계층 구조에 속하지 않을 수도 있는 대안을 나타내는 반면, 닫힌 계층 구조는 명확한 discriminator가 필요한 서로 연관된 이벤트 또는 엔터티 집합에 적합합니다.
ASP.NET Core 계층 전반의 지원
Minimal APIs에서는 일반적인 실행 경로 또는 Request Delegate Generator를 통해 union을 요청 본문의 매개 변수나 반환 형식으로 사용할 수 있으며, 두 경로에서 동일한 동작이 적용됩니다. 또한 union은 다른 모델 내부, IAsyncEnumerable<T>의 요소 또는 AsParameters를 사용하는 컨테이너 안에 나타날 수 있고, ConfigureHttpJsonOptions 설정은 직렬화에 계속 영향을 줍니다.
MVC 컨트롤러는 union을 작업의 매개 변수와 반환 형식으로 지원하며, Task<TUnion> 및 ValueTask<TUnion>도 포함됩니다. MVC는 JsonSerializerDefaults.Web 설정을 사용하므로 HTTP 입력에서 숫자와 문자열 사이에 발생하는 모호성 문제도 MVC에 적용됩니다.
SignalR에서는 매개 변수, 반환 값 및 스트리밍 요소에 대해 JsonHubProtocol을 통한 지원이 작동합니다. 이 프로토콜을 사용할 때 int 또는 string 형식의 union은 classifier가 필요하지 않습니다. JsonHubProtocol은 이 경우 JSON 문자열 토큰을 모호한 것으로 취급하지 않기 때문입니다. 그러나 UnionPet(Cat, Dog)처럼 상태들이 StartObject 토큰을 공유하는 union은 여전히 classifier가 필요합니다. SignalR은 MessagePack 또는 Newtonsoft.Json 허브 프로토콜을 사용할 때 이 기능을 지원하지 않습니다.
Blazor와 OpenAPI
프로세스 내부에서 Blazor 컴포넌트 매개 변수는 직접 할당을 통해 전달되므로 직렬화가 필요하지 않습니다. 그러나 JavaScript interop, 저장된 컴포넌트 상태 및 prerendering 중의 매개 변수는 System.Text.Json을 거치므로 동일한 union 규칙을 따릅니다. 예를 들어 활성 상태에 따라 union을 Boolean 또는 옵션 객체 형식으로 JavaScript에 전달할 수 있으며, 별도의 래퍼나 discriminator는 추가되지 않습니다.
OpenAPI에서는 각 상태에 하나의 형식을 포함하는 anyOf 스키마를 사용하여 union을 표현합니다. 상태가 독립적인 형식으로 나타날 때는 Cat과 Dog라는 동일한 구성 요소 이름을 재사용합니다. 이는 discriminator를 포함하는 다형성 형식과 다릅니다. 다형성 형식의 스키마는 구별되는 이름으로 등록됩니다. 또한 ApiExplorer는 동일한 상태 코드와 콘텐츠 형식에 대해 여러 응답 형식을 anyOf 안에 표현할 수 있습니다.
고려해야 할 제한 사항
union 지원은 System.Text.Json에 의존하므로 JSON 분석을 거치지 않는 바인딩 소스에서는 작동하지 않습니다. 여기에는 쿼리 문자열 값, 경로 값, 헤더 및 폼 필드가 포함됩니다. ?id=42와 같은 값만으로는 대상이 int인지 string인지 Guid인지 결정할 수 없습니다. Blazor에서는 [SupplyParameterFromQuery]와 [SupplyParameterFromForm]을 사용한 폼 바인딩에도 이 제한이 적용됩니다. 이 소스에서 union을 바인딩하는 방법은 아직 검토 및 피드백 수집 단계에 있다고 해당 문서는 설명합니다.
편집상의 해석: 여기서 핵심적인 가치는 JSON에 새로운 형식을 추가하는 것이 아니라 .NET 11 생태계의 광범위한 영역에서 여러 상태를 갖는 계약을 일관되게 처리하는 데 있습니다. 그러나 설계의 성공 여부는 상태를 구분할 수 있는지와 데이터 소스에 달려 있습니다. 따라서 classifier 또는 discriminator를 초기에 지정해야 하며, 직렬화 지원이 모든 매개 변수 바인딩 방식을 지원한다는 뜻은 아님을 전제해서는 안 됩니다.