.NET에서 JSON serialization(마샬링)용 사용자 지정 변환기를 작성하는 방법

이 문서에서는 System.Text.Json 네임스페이스에 제공된 JSON serialization 클래스에 대한 사용자 지정 변환기를 만드는 방법을 보여 줍니다. System.Text.Json에 대한 소개는 .NET에서 JSON을 직렬화 및 역직렬화하는 방법을 참조하세요.

변환기는 개체 또는 값을 JSON으로 변환하는 클래스입니다. System.Text.Json 네임스페이스에는 JavaScript 기본 형식에 매핑되는 기본 형식 대부분에 대한 기본 제공 변환기가 있습니다. 사용자 지정 변환기를 작성하여 기본 제공 변환기의 기본 동작을 재정의할 수 있습니다. 예시:

  • 값을 mm/dd/yyyy 형식으로 나타낼 수 있습니다 DateTime . 기본적으로 RFC 3339 프로필을 포함하여 ISO 8601-1:2019가 지원됩니다. 자세한 내용은 System.Text.Json의 DateTime 및 DateTimeOffset 지원을 참조하세요.
  • 예를 들어 PhoneNumber 형식을 사용하여 POCO를 JSON 문자열로 직렬화할 수 있습니다.

또한 새로운 기능으로 System.Text.Json을 사용자 지정하거나 확장하기 위해 사용자 지정 변환기를 작성할 수도 있습니다. 이 문서의 뒷부분에서 다음과 같은 시나리오에 대해 설명합니다.

Visual Basic은 사용자 지정 변환기를 작성하는 데 사용할 수 없지만 C# 라이브러리에 구현된 변환기를 호출할 수 있습니다. 자세한 내용은 Visual Basic 지원을 참조하세요.

사용자 지정 변환기 패턴

사용자 지정 변환기를 만드는 데는 기본 패턴과 팩터리 패턴의 두 가지 패턴이 있습니다. 팩터리 패턴은 Enum 형식 또는 개방형 제네릭을 처리하는 변환기를 위한 것입니다. 기본 패턴은 제네릭이 아닌 형식과 폐쇄형 제네릭 형식을 위한 것입니다. 예를 들어 다음 형식의 변환기에는 팩터리 패턴이 필요합니다.

기본 패턴으로 처리할 수 있는 형식의 몇 가지 예는 다음과 같습니다.

  • Dictionary<int, string>
  • WeekdaysEnum
  • List<DateTimeOffset>
  • DateTime
  • Int32

기본 패턴은 한 형식을 처리할 수 있는 클래스를 만듭니다. 팩터리 패턴은 런타임에 특정 형식이 필요한지 여부를 결정하는 클래스를 만들고 적절한 변환기를 동적으로 만듭니다.

샘플 기본 변환기

다음 샘플은 기존 데이터 형식에 대한 기본 serialization을 재정의하는 변환기입니다. 변환기는 DateTimeOffset 속성에 mm/dd/yyyy 형식을 사용합니다.

using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace SystemTextJsonSamples
{
    public class DateTimeOffsetJsonConverter : JsonConverter<DateTimeOffset>
    {
        public override DateTimeOffset Read(
            ref Utf8JsonReader reader,
            Type typeToConvert,
            JsonSerializerOptions options) =>
                DateTimeOffset.ParseExact(reader.GetString()!,
                    "MM/dd/yyyy", CultureInfo.InvariantCulture);

        public override void Write(
            Utf8JsonWriter writer,
            DateTimeOffset dateTimeValue,
            JsonSerializerOptions options) =>
                writer.WriteStringValue(dateTimeValue.ToString(
                    "MM/dd/yyyy", CultureInfo.InvariantCulture));
    }
}

샘플 팩터리 패턴 변환기

다음 코드에서는 Dictionary<Enum,TValue>와 함께 작동하는 사용자 지정 변환기를 보여 줍니다. 첫 번째 제네릭 형식 매개 변수가 Enum이고 두 번째는 개방형이기 때문에 이 코드는 팩터리 패턴을 따릅니다. CanConvert 메서드는 두 개의 제네릭 매개 변수(이 중 첫 번째는 Enum 형식)가 있는 Dictionary에 대해서만 true를 반환합니다. 내부 변환기는 런타임에 TValue에 제공되는 형식을 처리하기 위해 기존 변환기를 가져옵니다.

using System.Reflection;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace SystemTextJsonSamples
{
    public class DictionaryTKeyEnumTValueConverter : JsonConverterFactory
    {
        public override bool CanConvert(Type typeToConvert)
        {
            if (!typeToConvert.IsGenericType)
            {
                return false;
            }

            if (typeToConvert.GetGenericTypeDefinition() != typeof(Dictionary<,>))
            {
                return false;
            }

            return typeToConvert.GetGenericArguments()[0].IsEnum;
        }

        public override JsonConverter CreateConverter(
            Type type,
            JsonSerializerOptions options)
        {
            Type[] typeArguments = type.GetGenericArguments();
            Type keyType = typeArguments[0];
            Type valueType = typeArguments[1];

            JsonConverter converter = (JsonConverter)Activator.CreateInstance(
                typeof(DictionaryEnumConverterInner<,>).MakeGenericType(
                    [keyType, valueType]),
                BindingFlags.Instance | BindingFlags.Public,
                binder: null,
                args: [options],
                culture: null)!;

            return converter;
        }

        private class DictionaryEnumConverterInner<TKey, TValue> :
            JsonConverter<Dictionary<TKey, TValue>> where TKey : struct, Enum
        {
            private readonly JsonConverter<TValue> _valueConverter;
            private readonly Type _keyType;
            private readonly Type _valueType;

            public DictionaryEnumConverterInner(JsonSerializerOptions options)
            {
                // For performance, use the existing converter.
                _valueConverter = (JsonConverter<TValue>)options
                    .GetConverter(typeof(TValue));

                // Cache the key and value types.
                _keyType = typeof(TKey);
                _valueType = typeof(TValue);
            }

            public override Dictionary<TKey, TValue> Read(
                ref Utf8JsonReader reader,
                Type typeToConvert,
                JsonSerializerOptions options)
            {
                if (reader.TokenType != JsonTokenType.StartObject)
                {
                    throw new JsonException();
                }

                var dictionary = new Dictionary<TKey, TValue>();

                while (reader.Read())
                {
                    if (reader.TokenType == JsonTokenType.EndObject)
                    {
                        return dictionary;
                    }

                    // Get the key.
                    if (reader.TokenType != JsonTokenType.PropertyName)
                    {
                        throw new JsonException();
                    }

                    string? propertyName = reader.GetString();

                    // For performance, parse with ignoreCase:false first.
                    if (!Enum.TryParse(propertyName, ignoreCase: false, out TKey key) &&
                        !Enum.TryParse(propertyName, ignoreCase: true, out key))
                    {
                        throw new JsonException(
                            $"Unable to convert \"{propertyName}\" to Enum \"{_keyType}\".");
                    }

                    // Get the value.
                    reader.Read();
                    TValue value = _valueConverter.Read(ref reader, _valueType, options)!;

                    // Add to dictionary.
                    dictionary.Add(key, value);
                }

                throw new JsonException();
            }

            public override void Write(
                Utf8JsonWriter writer,
                Dictionary<TKey, TValue> dictionary,
                JsonSerializerOptions options)
            {
                writer.WriteStartObject();

                foreach ((TKey key, TValue value) in dictionary)
                {
                    string propertyName = key.ToString();
                    writer.WritePropertyName
                        (options.PropertyNamingPolicy?.ConvertName(propertyName) ?? propertyName);

                    _valueConverter.Write(writer, value, options);
                }

                writer.WriteEndObject();
            }
        }
    }
}

기본 패턴을 따르기 위한 단계

다음 단계에서는 기본 패턴을 따라 변환기를 만드는 방법을 설명합니다.

  • JsonConverter<T>에서 파생되는 클래스를 만듭니다. 여기서 T는 직렬화 및 역직렬화할 형식입니다.
  • 들어오는 JSON을 역직렬화하고 T 형식으로 변환하도록 Read 메서드를 재정의합니다. 메서드에 전달된 Utf8JsonReader를 사용하여 JSON을 읽습니다. 직렬 변환기가 현재 JSON 범위에 대한 모든 데이터를 전달하므로 부분 데이터 처리에 대해 신경을 쓸 필요가 없습니다. 따라서 Skip 또는 TrySkip을 호출하거나 true를 반환하는 Read의 유효성을 검사할 필요가 없습니다.
  • 들어오는 T 형식의 개체를 직렬화하도록 Write 메서드를 재정의합니다. 메서드에 전달된 Utf8JsonWriter를 사용하여 JSON을 작성합니다.
  • 필요한 경우에만 CanConvert 메서드를 재정의합니다. 기본 구현은 변환할 형식이 T 형식일 때 true를 반환합니다. 따라서 T 형식만 지원하는 변환기는 이 메서드를 재정의할 필요가 없습니다. 이 메서드를 재정의해야 하는 변환기의 예제는 이 문서의 뒷부분에 나오는 다형 deserialization 섹션을 참조하세요.

사용자 지정 변환기 작성을 위한 참조 구현으로 기본 제공 변환기 소스 코드를 참조할 수 있습니다.

팩터리 패턴을 따르기 위한 단계

다음 단계에서는 팩터리 패턴을 따라 변환기를 만드는 방법을 설명합니다.

  • JsonConverterFactory에서 파생되는 클래스를 만듭니다.
  • 변환할 형식이 변환기에서 처리할 수 있는 형식인 경우 true를 반환하도록 CanConvert 메서드를 재정의합니다. 예를 들어 List<T>에 대한 변환기인 경우 List<int>, List<string>List<DateTime>만 처리할 수 있습니다.
  • 런타임에 제공되는 변환할 형식을 처리할 변환기 클래스 인스턴스를 반환하도록 CreateConverter 메서드를 재정의합니다.
  • CreateConverter 메서드가 인스턴스화하는 변환기 클래스를 만듭니다.

개체와 문자열 간에 변환하는 코드가 모든 형식에 대해 동일하지 않기 때문에 개방형 제네릭에는 팩터리 패턴이 필요합니다. 개방형 제네릭 형식(예: List<T>)에 대한 변환기는 백그라운드에서 폐쇄형 제네릭 형식(예: List<DateTime>)에 대한 변환기를 만들어야 합니다. 변환기가 처리할 수 있는 각 폐쇄형 제네릭 형식을 처리하기 위해 코드를 작성해야 합니다.

Enum 형식은 개방형 제네릭 형식과 유사합니다. Enum에 대한 변환기는 내부적으로 특정 Enum(예: WeekdaysEnum)에 대한 변환기를 만들어야 합니다.

Read 메서드에서 Utf8JsonReader의 사용

변환기가 JSON 개체를 변환하는 경우 Read 메서드가 시작될 때 Utf8JsonReader가 begin 개체 토큰에 배치됩니다. 그런 다음 해당 개체의 모든 토큰을 끝까지 읽고 해당 end 개체 토큰에 판독기가 배치된 메서드를 종료해야 합니다. 개체의 끝 부분을 건너뛰어 읽거나 해당 end 토큰에 도달하기도 전에 읽기를 중지하는 경우 다음을 나타내는 JsonException 예외가 발생합니다.

변환기 'ConverterName'이 해당 토큰을 너무 많이 읽거나 충분히 읽지 않았습니다.

관련 예제는 이전 팩터리 패턴 샘플 변환기를 참조하세요. Read 메서드는 판독기가 start 개체 토큰에 배치되었는지 확인하는 것으로 시작합니다. 판독기가 다음 end 개체 토큰에 배치되는 것을 발견할 때까지 읽기는 계속 진행됩니다. 개체 내의 개체를 나타내는 중간 start 개체 토큰이 없으므로 다음 end 개체 토큰에서 읽기가 중지됩니다. 배열을 변환하는 경우 begin 토큰 및 end 토큰에 대한 동일한 규칙이 적용됩니다. 관련 예제는 이 문서 뒷부분에 있는 Stack<T> 샘플 변환기를 참조하세요.

오류 처리

직렬 변환기는 JsonExceptionNotSupportedException 예외 형식을 특별히 처리합니다.

JsonException

메시지 없이 JsonException을 throw하는 경우 직렬 변환기는 JSON에서 오류를 발생시킨 부분의 경로를 포함하는 메시지를 만듭니다. 예를 들어 throw new JsonException() 문은 다음 예제와 같은 오류 메시지를 생성합니다.

Unhandled exception. System.Text.Json.JsonException:
The JSON value could not be converted to System.Object.
Path: $.Date | LineNumber: 1 | BytePositionInLine: 37.

메시지를 제공하는 경우(예: throw new JsonException("Error occurred")) 직렬 변환기는 Path, LineNumberBytePositionInLine 속성을 계속 설정합니다.

NotSupportedException

NotSupportedException을 throw하는 경우 메시지에 항상 경로 정보를 받습니다. 메시지를 제공하는 경우 경로 정보가 추가됩니다. 예를 들어 throw new NotSupportedException("Error occurred.") 문은 다음 예제와 같은 오류 메시지를 생성합니다.

Error occurred. The unsupported member type is located on type
'System.Collections.Generic.Dictionary`2[Samples.SummaryWords,System.Int32]'.
Path: $.TemperatureRanges | LineNumber: 4 | BytePositionInLine: 24

예외 형식을 throw하는 경우

JSON 페이로드에 역직렬화되는 형식에 유효하지 않은 토큰이 포함된 경우 JsonException을 throw합니다.

특정 형식을 허용하지 않으려면 NotSupportedException을 throw합니다. 이 예외는 직렬 변환기가 지원되지 않는 형식에 대해 자동으로 throw하는 예외입니다. 예를 들어 System.Type은 보안상의 이유로 지원되지 않으므로, 역직렬화하려고 하면 NotSupportedException이 발생합니다.

필요에 따라 다른 예외를 throw할 수 있지만, JSON 경로 정보는 자동으로 포함되지 않습니다.

사용자 지정 변환기 등록

SerializeDeserialize 메서드가 사용할 수 있도록 사용자 지정 변환기를 등록합니다. 다음 방법 중 하나를 선택합니다.

  • JsonSerializerOptions.Converters 컬렉션에 변환기 클래스의 인스턴스를 추가합니다.
  • [JsonConverter] 특성을 사용자 지정 변환기가 필요한 속성에 적용합니다.
  • [JsonConverter] 특성을 사용자 지정 값 형식을 나타내는 클래스 또는 구조체에 적용합니다.

등록 샘플 - 변환기 컬렉션

다음 예제에서는 DateTimeOffsetJsonConverterDateTimeOffset 형식의 속성에 대한 기본값으로 지정합니다.

var serializeOptions = new JsonSerializerOptions
{
    WriteIndented = true,
    Converters =
    {
        new DateTimeOffsetJsonConverter()
    }
};

jsonString = JsonSerializer.Serialize(weatherForecast, serializeOptions);

다음 형식의 인스턴스를 직렬화한다고 가정합니다.

public class WeatherForecast
{
    public DateTimeOffset Date { get; set; }
    public int TemperatureCelsius { get; set; }
    public string? Summary { get; set; }
}

다음은 사용자 지정 변환기가 사용되었음을 보여 주는 JSON 출력의 예입니다.

{
  "Date": "08/01/2019",
  "TemperatureCelsius": 25,
  "Summary": "Hot"
}

다음 코드에서는 사용자 지정 DateTimeOffset 변환기를 사용하여 역직렬화하기 위해 동일한 방법을 사용합니다.

var deserializeOptions = new JsonSerializerOptions();
deserializeOptions.Converters.Add(new DateTimeOffsetJsonConverter());
weatherForecast = JsonSerializer.Deserialize<WeatherForecast>(jsonString, deserializeOptions)!;

등록 샘플 - 속성에 적용되는 [JsonConverter]

다음 코드에서는 Date 속성에 대한 사용자 지정 변환기를 선택합니다.

public class WeatherForecastWithConverterAttribute
{
    [JsonConverter(typeof(DateTimeOffsetJsonConverter))]
    public DateTimeOffset Date { get; set; }
    public int TemperatureCelsius { get; set; }
    public string? Summary { get; set; }
}

WeatherForecastWithConverterAttribute를 직렬화하는 코드는 JsonSerializeOptions.Converters를 사용할 필요가 없습니다.

var serializeOptions = new JsonSerializerOptions
{
    WriteIndented = true
};
jsonString = JsonSerializer.Serialize(weatherForecast, serializeOptions);

역직렬화하는 코드도 Converters를 사용할 필요가 없습니다.

weatherForecast = JsonSerializer.Deserialize<WeatherForecastWithConverterAttribute>(jsonString)!;

등록 샘플 - 형식에 적용되는 [JsonConverter]

다음은 구조체를 만들고 [JsonConverter] 특성을 적용하는 코드입니다.

using System.Text.Json.Serialization;

namespace SystemTextJsonSamples
{
    [JsonConverter(typeof(TemperatureConverter))]
    public struct Temperature
    {
        public Temperature(int degrees, bool celsius)
        {
            Degrees = degrees;
            IsCelsius = celsius;
        }

        public int Degrees { get; }
        public bool IsCelsius { get; }
        public bool IsFahrenheit => !IsCelsius;

        public override string ToString() =>
            $"{Degrees}{(IsCelsius ? "C" : "F")}";

        public static Temperature Parse(string input)
        {
            int degrees = int.Parse(input.Substring(0, input.Length - 1));
            bool celsius = input.Substring(input.Length - 1) == "C";

            return new Temperature(degrees, celsius);
        }
    }
}

앞의 구조체에 대한 사용자 지정 변환기는 다음과 같습니다.

using System.Text.Json;
using System.Text.Json.Serialization;

namespace SystemTextJsonSamples
{
    public class TemperatureConverter : JsonConverter<Temperature>
    {
        public override Temperature Read(
            ref Utf8JsonReader reader,
            Type typeToConvert,
            JsonSerializerOptions options) =>
                Temperature.Parse(reader.GetString()!);

        public override void Write(
            Utf8JsonWriter writer,
            Temperature temperature,
            JsonSerializerOptions options) =>
                writer.WriteStringValue(temperature.ToString());
    }
}

구조체의 [JsonConverter] 특성은 사용자 지정 변환기를 Temperature 형식의 속성에 대한 기본값으로 등록합니다. 이 변환기는 다음 형식의 TemperatureCelsius 속성을 직렬화 또는 역직렬화할 때 자동으로 사용됩니다.

public class WeatherForecastWithTemperatureStruct
{
    public DateTimeOffset Date { get; set; }
    public Temperature TemperatureCelsius { get; set; }
    public string? Summary { get; set; }
}

변환기 등록 우선 순위

serialization 또는 deserialization 동안 각 JSON 요소에 대해 가장 높은 우선 순위에서 가장 낮은 순서로 변환기가 선택됩니다.

  • 속성에 적용된 [JsonConverter].
  • Converters 컬렉션에 추가된 변환기.
  • 사용자 지정 값 형식 또는 POCO에 적용된 [JsonConverter].

Converters 컬렉션에 특정 형식에 대한 여러 사용자 지정 변환기가 등록된 경우 CanConverttrue를 반환하는 첫 번째 변환기가 사용됩니다.

기본 제공 변환기는 해당하는 사용자 지정 변환기가 등록되지 않은 경우에만 선택됩니다.

일반 시나리오용 변환기 샘플

다음 섹션에서는 기본 제공 기능이 처리하지 않는 몇 가지 일반적인 시나리오를 해결하는 변환기 샘플을 제공합니다.

샘플 DataTable 변환기는 지원되는 컬렉션 형식을 참조하세요.

유추된 형식을 개체 속성으로 역직렬화

object 형식의 속성으로 역직렬화할 때 JsonElement 개체가 만들어집니다. 그 이유는 역직렬 변환기가 만들 CLR 형식을 알지 못하고 추측하려고 하지 않기 때문입니다. 예를 들어 JSON 속성에 "true"가 있는 경우 역직렬 변환기는 값이 Boolean임을 유추하지 않으며 요소에 "01/01/2019"가 있는 경우 역직렬 변환기가 DateTime를 유추하지 않습니다.

형식 유추는 정확하지 않을 수 있습니다. 역직렬 변환기가 소수점이 없는 JSON 번호를 long으로 구문 분석하는 경우 값이 원래 ulong 또는 BigInteger로 직렬화되었다면 범위를 벗어남 문제가 발생할 수 있습니다. 소수점이 있는 숫자를 double로 구문 분석하면 해당 숫자가 원래 decimal로 직렬화된 경우에는 전체 자릿수가 손실될 수 있습니다.

형식 유추가 필요한 시나리오의 경우 다음 코드는 object 속성에 대한 사용자 지정 변환기를 보여 줍니다. 이 코드는 다음과 같이 변환합니다.

  • truefalseBoolean으로
  • 소수점이 없는 숫자를 long으로
  • 소수점이 있는 숫자를 double
  • 날짜를 DateTime으로
  • 문자열을 string으로
  • 그 밖의 모든 항목으로 JsonElement
using System.Text.Json;
using System.Text.Json.Serialization;

namespace CustomConverterInferredTypesToObject
{
    public class ObjectToInferredTypesConverter : JsonConverter<object>
    {
        public override object Read(
            ref Utf8JsonReader reader,
            Type typeToConvert,
            JsonSerializerOptions options) => reader.TokenType switch
            {
                JsonTokenType.True => true,
                JsonTokenType.False => false,
                JsonTokenType.Number when reader.TryGetInt64(out long l) => l,
                JsonTokenType.Number => reader.GetDouble(),
                JsonTokenType.String when reader.TryGetDateTime(out DateTime datetime) => datetime,
                JsonTokenType.String => reader.GetString()!,
                _ => JsonDocument.ParseValue(ref reader).RootElement.Clone()
            };

        public override void Write(
            Utf8JsonWriter writer,
            object objectToWrite,
            JsonSerializerOptions options) =>
            JsonSerializer.Serialize(writer, objectToWrite, objectToWrite.GetType(), options);
    }

    public class WeatherForecast
    {
        public object? Date { get; set; }
        public object? TemperatureCelsius { get; set; }
        public object? Summary { get; set; }
    }

    public class Program
    {
        public static void Main()
        {
            string jsonString = """
                {
                  "Date": "2019-08-01T00:00:00-07:00",
                  "TemperatureCelsius": 25,
                  "Summary": "Hot"
                }
                """;

            WeatherForecast weatherForecast = JsonSerializer.Deserialize<WeatherForecast>(jsonString)!;
            Console.WriteLine($"Type of Date property   no converter = {weatherForecast.Date!.GetType()}");

            var options = new JsonSerializerOptions();
            options.WriteIndented = true;
            options.Converters.Add(new ObjectToInferredTypesConverter());
            weatherForecast = JsonSerializer.Deserialize<WeatherForecast>(jsonString, options)!;
            Console.WriteLine($"Type of Date property with converter = {weatherForecast.Date!.GetType()}");

            Console.WriteLine(JsonSerializer.Serialize(weatherForecast, options));
        }
    }
}

// Produces output like the following example:
//
//Type of Date property   no converter = System.Text.Json.JsonElement
//Type of Date property with converter = System.DateTime
//{
//  "Date": "2019-08-01T00:00:00-07:00",
//  "TemperatureCelsius": 25,
//  "Summary": "Hot"
//}

이 예제에서는 변환기 코드와 object 속성이 있는 WeatherForecast 클래스를 보여줍니다. Main 메서드는 변환기부터 먼저 사용하지 않고 JSON 문자열을 WeatherForecast 인스턴스로 역직렬화한 후 변환기를 사용합니다. 콘솔 출력은 변환기가 없으면 Date 속성의 런타임 형식이 JsonElement이고 변환기가 있는 런타임 형식은 DateTime임을 보여줍니다.

System.Text.Json.Serialization 네임스페이스의 unit tests 폴더에는 object 속성에 대한 deserialization을 처리하는 사용자 지정 변환기의 더 많은 예제가 있습니다.

다형 deserialization 지원

.NET 7은 다형 직렬화와 역직렬화를 모두 지원합니다. 그러나 이전 .NET 버전에서는 다형 직렬화 지원이 제한되어 있고 역직렬화를 지원하지 않았습니다. .NET 6 또는 이전 버전을 사용하는 경우 역직렬화에는 사용자 지정 변환기가 필요합니다.

예를 들어 EmployeeCustomer 파생 클래스를 사용하는 Person 추상 기본 클래스가 있다고 가정합니다. 다형성 deserialization은 디자인 타임에 Person을 deserialization 대상으로 지정할 수 있고 런타임에 JSON의 CustomerEmployee 개체가 올바르게 역직렬화됨을 의미합니다. deserialization 동안 JSON에서 필요한 형식을 식별하는 단서를 찾아야 합니다. 사용 가능한 단서의 종류는 각 시나리오마다 다릅니다. 예를 들어 판별자 속성을 사용할 수 있거나 특정 속성이 존재하는지 여부에 의존해야 할 수도 있습니다. 현재 릴리스의 System.Text.Json에서는 다형 deserialization 시나리오를 처리하는 방법을 지정하는 특성을 제공하지 않으므로 사용자 지정 변환기가 필요합니다.

다음 코드에서는 기본 클래스, 두 개의 파생 클래스 및 해당 클래스에 대한 사용자 지정 변환기를 보여 줍니다. 이 변환기는 판별자 속성을 사용하여 다형 deserialization을 수행합니다. 형식 판별자는 클래스 정의에 없지만 serialization 동안 만들어지고 deserialization 동안 읽힙니다.

중요

예제 코드에서는 JSON의 표준 요구 사항이 아닌 순서대로 유지하려면 JSON 개체 이름/값 쌍이 필요합니다.

public class Person
{
    public string? Name { get; set; }
}

public class Customer : Person
{
    public decimal CreditLimit { get; set; }
}

public class Employee : Person
{
    public string? OfficeNumber { get; set; }
}
using System.Text.Json;
using System.Text.Json.Serialization;

namespace SystemTextJsonSamples
{
    public class PersonConverterWithTypeDiscriminator : JsonConverter<Person>
    {
        enum TypeDiscriminator
        {
            Customer = 1,
            Employee = 2
        }

        public override bool CanConvert(Type typeToConvert) =>
            typeof(Person).IsAssignableFrom(typeToConvert);

        public override Person Read(
            ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
        {
            if (reader.TokenType != JsonTokenType.StartObject)
            {
                throw new JsonException();
            }

            reader.Read();
            if (reader.TokenType != JsonTokenType.PropertyName)
            {
                throw new JsonException();
            }

            string? propertyName = reader.GetString();
            if (propertyName != "TypeDiscriminator")
            {
                throw new JsonException();
            }

            reader.Read();
            if (reader.TokenType != JsonTokenType.Number)
            {
                throw new JsonException();
            }

            TypeDiscriminator typeDiscriminator = (TypeDiscriminator)reader.GetInt32();
            Person person = typeDiscriminator switch
            {
                TypeDiscriminator.Customer => new Customer(),
                TypeDiscriminator.Employee => new Employee(),
                _ => throw new JsonException()
            };

            while (reader.Read())
            {
                if (reader.TokenType == JsonTokenType.EndObject)
                {
                    return person;
                }

                if (reader.TokenType == JsonTokenType.PropertyName)
                {
                    propertyName = reader.GetString();
                    reader.Read();
                    switch (propertyName)
                    {
                        case "CreditLimit":
                            decimal creditLimit = reader.GetDecimal();
                            ((Customer)person).CreditLimit = creditLimit;
                            break;
                        case "OfficeNumber":
                            string? officeNumber = reader.GetString();
                            ((Employee)person).OfficeNumber = officeNumber;
                            break;
                        case "Name":
                            string? name = reader.GetString();
                            person.Name = name;
                            break;
                    }
                }
            }

            throw new JsonException();
        }

        public override void Write(
            Utf8JsonWriter writer, Person person, JsonSerializerOptions options)
        {
            writer.WriteStartObject();

            if (person is Customer customer)
            {
                writer.WriteNumber("TypeDiscriminator", (int)TypeDiscriminator.Customer);
                writer.WriteNumber("CreditLimit", customer.CreditLimit);
            }
            else if (person is Employee employee)
            {
                writer.WriteNumber("TypeDiscriminator", (int)TypeDiscriminator.Employee);
                writer.WriteString("OfficeNumber", employee.OfficeNumber);
            }

            writer.WriteString("Name", person.Name);

            writer.WriteEndObject();
        }
    }
}

다음 코드는 변환기를 등록합니다.

var serializeOptions = new JsonSerializerOptions();
serializeOptions.Converters.Add(new PersonConverterWithTypeDiscriminator());

변환기는 동일한 직렬화 변환기를 사용하여 만든 JSON을 역직렬화할 수 있습니다. 예를 들면 다음과 같습니다.

[
  {
    "TypeDiscriminator": 1,
    "CreditLimit": 10000,
    "Name": "John"
  },
  {
    "TypeDiscriminator": 2,
    "OfficeNumber": "555-1234",
    "Name": "Nancy"
  }
]

이전 예제의 변환기 코드는 각 속성을 수동으로 읽고 씁니다. 대신 Deserialize 또는 Serialize를 호출하여 일부 작업을 수행할 수 있습니다. 예제는 이 StackOverflow 게시물을 참조하세요.

다형 역직렬화를 수행하는 다른 방법

Read 메서드에서 Deserialize를 호출할 수 있습니다.

  • Utf8JsonReader 인스턴스의 복제본을 만듭니다. Utf8JsonReader는 구조체이므로 대입문만 있으면 됩니다.
  • 복제본을 사용하여 판별자 토큰을 끝까지 읽습니다.
  • 필요한 형식을 알고 나면 원래 Reader 인스턴스를 사용하여 Deserialize를 호출합니다. 원래 Reader 인스턴스가 여전히 위치하여 begin 개체 토큰을 읽을 수 있으므로 Deserialize를 호출할 수 있습니다.

이 메서드의 단점은 변환기를 Deserialize에 등록하는 원래 옵션 인스턴스를 전달할 수 없다는 것입니다. 이렇게 하면 필수 속성에 설명된 대로 스택 오버플로가 발생합니다. 다음 예제에서는 이 대안을 사용하는 Read 메서드를 보여줍니다.

public override Person Read(
    ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
    Utf8JsonReader readerClone = reader;

    if (readerClone.TokenType != JsonTokenType.StartObject)
    {
        throw new JsonException();
    }

    readerClone.Read();
    if (readerClone.TokenType != JsonTokenType.PropertyName)
    {
        throw new JsonException();
    }

    string? propertyName = readerClone.GetString();
    if (propertyName != "TypeDiscriminator")
    {
        throw new JsonException();
    }

    readerClone.Read();
    if (readerClone.TokenType != JsonTokenType.Number)
    {
        throw new JsonException();
    }

    TypeDiscriminator typeDiscriminator = (TypeDiscriminator)readerClone.GetInt32();
    Person person = typeDiscriminator switch
    {
        TypeDiscriminator.Customer => JsonSerializer.Deserialize<Customer>(ref reader)!,
        TypeDiscriminator.Employee => JsonSerializer.Deserialize<Employee>(ref reader)!,
        _ => throw new JsonException()
    };
    return person;
}

Stack 형식에 대한 왕복 지원

JSON 문자열을 Stack 개체로 역직렬화한 다음 해당 개체를 직렬화하는 경우 스택의 내용이 역순으로 표시됩니다. 이 동작은 다음 형식 및 인터페이스, 그리고 이러한 형식에서 파생되는 사용자 정의 형식에 적용됩니다.

스택에서 원래 순서를 유지하는 serialization 및 deserialization을 지원하려면 사용자 지정 변환기가 필요합니다.

다음 코드는 Stack<T> 개체에 대한 라운드트립을 가능하게 하는 사용자 지정 변환기를 보여 줍니다.

using System.Diagnostics;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace SystemTextJsonSamples
{
    public class JsonConverterFactoryForStackOfT : JsonConverterFactory
    {
        public override bool CanConvert(Type typeToConvert)
            => typeToConvert.IsGenericType
            && typeToConvert.GetGenericTypeDefinition() == typeof(Stack<>);

        public override JsonConverter CreateConverter(
            Type typeToConvert, JsonSerializerOptions options)
        {
            Debug.Assert(typeToConvert.IsGenericType &&
                typeToConvert.GetGenericTypeDefinition() == typeof(Stack<>));

            Type elementType = typeToConvert.GetGenericArguments()[0];

            JsonConverter converter = (JsonConverter)Activator.CreateInstance(
                typeof(JsonConverterForStackOfT<>)
                    .MakeGenericType(new Type[] { elementType }),
                BindingFlags.Instance | BindingFlags.Public,
                binder: null,
                args: null,
                culture: null)!;

            return converter;
        }
    }

    public class JsonConverterForStackOfT<T> : JsonConverter<Stack<T>>
    {
        public override Stack<T> Read(
            ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
        {
            if (reader.TokenType != JsonTokenType.StartArray)
            {
                throw new JsonException();
            }
            reader.Read();

            var elements = new Stack<T>();

            while (reader.TokenType != JsonTokenType.EndArray)
            {
                elements.Push(JsonSerializer.Deserialize<T>(ref reader, options)!);

                reader.Read();
            }

            return elements;
        }

        public override void Write(
            Utf8JsonWriter writer, Stack<T> value, JsonSerializerOptions options)
        {
            writer.WriteStartArray();

            var reversed = new Stack<T>(value);

            foreach (T item in reversed)
            {
                JsonSerializer.Serialize(writer, item, options);
            }

            writer.WriteEndArray();
        }
    }
}

다음 코드는 변환기를 등록합니다.

var options = new JsonSerializerOptions
{
    Converters = { new JsonConverterFactoryForStackOfT() },
};

열거형 문자열 역직렬화를 위한 명명 정책

기본적으로 기본 제공 JsonStringEnumConverter는 열거형에 대한 문자열 값을 직렬화하고 역직렬화할 수 있습니다. 지정된 명명 정책이나 CamelCase 명명 정책 없이 작동합니다. 스네이크 케이스와 같은 다른 명명 정책은 지원하지 않습니다. 스네이크 대/소문자 명명 정책을 사용하는 동안 열거형 문자열 값에 대한 왕복을 지원할 수 있는 사용자 지정 변환기 코드에 관한 자세한 내용은 GitHub 문제 dotnet/runtime #31619를 참조하세요. 또는 열거형 문자열 값 간 왕복 시 명명 정책 적용을 기본적으로 지원하는 .NET 7 이상 버전으로 업그레이드합니다.

기본 시스템 변환기를 사용

일부 시나리오에서는 사용자 지정 변환기에서 필요에 따라 기본 시스템 변환기를 사용할 수 있습니다. 이렇게 하려면 다음 예제와 같이 JsonSerializerOptions.Default 속성에서 시스템 변환기를 가져옵니다.

public class MyCustomConverter : JsonConverter<int>
{
    private readonly static JsonConverter<int> s_defaultConverter = 
        (JsonConverter<int>)JsonSerializerOptions.Default.GetConverter(typeof(int));

    // Custom serialization logic
    public override void Write(
        Utf8JsonWriter writer, int value, JsonSerializerOptions options)
    {
        writer.WriteStringValue(value.ToString());
    }

    // Fall back to default deserialization logic
    public override int Read(
        ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        return s_defaultConverter.Read(ref reader, typeToConvert, options);
    }
}

Null 값 처리

기본적으로 직렬 변환기는 null 값을 다음과 같이 처리합니다.

  • 참조 형식 및 Nullable<T> 형식:

    • 직렬화 시 사용자 지정 변환기에 null을 전달하지 않습니다.
    • 역직렬화 시 사용자 지정 변환기에 JsonTokenType.Null을 전달하지 않습니다.
    • 역직렬화 시 null 인스턴스를 반환합니다.
    • 직렬화 시 기록기로 직접 null을 씁니다.
  • null을 허용하지 않는 값 형식:

    • 역직렬화 시 사용자 지정 변환기에 JsonTokenType.Null을 전달합니다. (사용자 지정 변환기를 사용할 수 없는 경우 형식의 내부 변환기에서 JsonException 예외가 throw됩니다.)

이 null 처리 동작은 주로 변환기에 대한 추가 호출을 건너뛰어 성능을 최적화하기 위한 것입니다. 또한 nullable 형식의 변환기가 모든 ReadWrite 메서드 재정의가 시작될 때 강제로 null을 확인하지 않도록 합니다.

사용자 지정 변환기가 참조 또는 값 형식의 null을 처리할 수 있게 하려면 다음 예제와 같이 true를 반환하도록 JsonConverter<T>.HandleNull을 재정의하세요.

using System.Text.Json;
using System.Text.Json.Serialization;

namespace CustomConverterHandleNull
{
    public class Point
    {
        public int X { get; set; }
        public int Y { get; set; }

        [JsonConverter(typeof(DescriptionConverter))]
        public string? Description { get; set; }
    }

    public class DescriptionConverter : JsonConverter<string>
    {
        public override bool HandleNull => true;

        public override string Read(
            ref Utf8JsonReader reader,
            Type typeToConvert,
            JsonSerializerOptions options) =>
            reader.GetString() ?? "No description provided.";

        public override void Write(
            Utf8JsonWriter writer,
            string value,
            JsonSerializerOptions options) =>
            writer.WriteStringValue(value);
    }

    public class Program
    {
        public static void Main()
        {
            string json = @"{""x"":1,""y"":2,""Description"":null}";

            Point point = JsonSerializer.Deserialize<Point>(json)!;
            Console.WriteLine($"Description: {point.Description}");
        }
    }
}

// Produces output like the following example:
//
//Description: No description provided.

참조 유지

기본적으로 참조 데이터는 Serialize 또는 Deserialize에 대한 각 호출에 대해서만 캐시됩니다. 한 Serialize/Deserialize 호출에서 다른 호출로의 참조를 유지하려면 Serialize/Deserialize의 호출 사이트에 ReferenceResolver 인스턴스를 루트합니다. 다음 코드에서는 이 시나리오의 예제를 보여줍니다.

  • Company 형식의 사용자 지정 변환기를 작성합니다.
  • Supervisor 속성을 수동으로 직렬화하지 않으려는데, 이 속성은 Employee입니다. 직렬 변환기에 위임하려고 하며 이미 저장한 참조도 유지하려고 합니다.

EmployeeCompany 클래스는 다음과 같습니다.

public class Employee
{
    public string? Name { get; set; }
    public Employee? Manager { get; set; }
    public List<Employee>? DirectReports { get; set; }
    public Company? Company { get; set; }
}

public class Company
{
    public string? Name { get; set; }
    public Employee? Supervisor { get; set; }
}

변환기는 다음과 같이 표시됩니다.

class CompanyConverter : JsonConverter<Company>
{
    public override Company Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        throw new NotImplementedException();
    }

    public override void Write(Utf8JsonWriter writer, Company value, JsonSerializerOptions options)
    {
        writer.WriteStartObject();

        writer.WriteString("Name", value.Name);

        writer.WritePropertyName("Supervisor");
        JsonSerializer.Serialize(writer, value.Supervisor, options);

        writer.WriteEndObject();
    }
}

ReferenceResolver에서 파생되는 클래스는 참조를 사전에 저장합니다.

class MyReferenceResolver : ReferenceResolver
{
    private uint _referenceCount;
    private readonly Dictionary<string, object> _referenceIdToObjectMap = new ();
    private readonly Dictionary<object, string> _objectToReferenceIdMap = new (ReferenceEqualityComparer.Instance);

    public override void AddReference(string referenceId, object value)
    {
        if (!_referenceIdToObjectMap.TryAdd(referenceId, value))
        {
            throw new JsonException();
        }
    }

    public override string GetReference(object value, out bool alreadyExists)
    {
        if (_objectToReferenceIdMap.TryGetValue(value, out string? referenceId))
        {
            alreadyExists = true;
        }
        else
        {
            _referenceCount++;
            referenceId = _referenceCount.ToString();
            _objectToReferenceIdMap.Add(value, referenceId);
            alreadyExists = false;
        }

        return referenceId;
    }

    public override object ResolveReference(string referenceId)
    {
        if (!_referenceIdToObjectMap.TryGetValue(referenceId, out object? value))
        {
            throw new JsonException();
        }

        return value;
    }
}

ReferenceHandler에서 파생되는 클래스는 MyReferenceResolver의 인스턴스를 보유하며 (이 예제에서 Reset으로 명명된 메서드에서) 필요한 경우에만 새 인스턴스를 만듭니다.

class MyReferenceHandler : ReferenceHandler
{
    public MyReferenceHandler() => Reset();

    private ReferenceResolver? _rootedResolver;
    public override ReferenceResolver CreateResolver() => _rootedResolver!;
    public void Reset() => _rootedResolver = new MyReferenceResolver();
}

샘플 코드는 직렬 변환기를 호출할 때 ReferenceHandler 속성이 MyReferenceHandler의 인스턴스로 설정된 JsonSerializerOptions 인스턴스를 사용합니다. 이 패턴을 따를 때는 직렬화를 마쳤을 때 ReferenceResolver 사전을 다시 설정하여 영원히 증가하지 않도록 해야 합니다.

var options = new JsonSerializerOptions();

options.Converters.Add(new CompanyConverter());
var myReferenceHandler = new MyReferenceHandler();
options.ReferenceHandler = myReferenceHandler;
options.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
options.WriteIndented = true;

string str = JsonSerializer.Serialize(tyler, options);

// Reset after serializing to avoid out of bounds memory growth in the resolver.
myReferenceHandler.Reset();

앞의 예제에서는 직렬화만 수행하지만 역직렬화를 위해 유사한 방법을 채택할 수 있습니다.

기타 사용자 지정 변환기 샘플

Newtonsoft.Json에서 System.Text.Json로 마이그레이션 문서에는 사용자 지정 변환기의 추가 샘플이 포함되어 있습니다.

System.Text.Json.Serialization 소스 코드의 unit tests 폴더에는 다음과 같은 다른 사용자 지정 변환기 샘플이 포함되어 있습니다.

기존 기본 제공 변환기의 동작을 수정하는 변환기를 만들어야 하는 경우 기존 변환기의 소스 코드를 가져와 사용자 지정을 위한 시작 지점으로 사용할 수 있습니다.

추가 자료