System.Text.Json 이 가진 한계

System.Text.Json 이 가진 한계

System.Text.JsonMicrosoft 에서 공식적으로 배포하고,

.NET 생태계의 표준으로서 자리잡아가며, 여타 라이브러리들에서도

점점 Newtonsoft.Json 종속성을 제거하고, System.Text.Json을 적용하는 추세인 것 같습니다.

저 역시도 Newtonsoft.Json 보다는 System.Text.Json을 선호하는 편이고 최대한 적용하려고 노력하고 있습니다.

다만 사용하다보면 몇가지 한계점을 가진 엣지케이스들이 발견됩니다.

그 중 하나인 역직렬화 관련 이슈를 알아봅시다.


역직렬화(Deserialization)

1. 정의하기

먼저, 아래와 같은 Immutable Class를 생성합니다.

public record BookId
{
    public Guid Value { get; private set; }
    private BookId(Guid value)  // ①
    {
        this.Value = value;
    }

    public static BookId Create() => new(Guid.NewGuid());  // ②
    public static BookId Parse(Guid value) => new(value);  // ③
}
  1. private 생성자로 선언하여 해당 클래스 외부에서는 인스턴스를 생성할 수 없도록 합니다.
  2. Create(...) static 메서드를 통해서만 신규 BookId인스턴스를 생성할 수 있도록 합니다. (aka. Factory-Pattern)
  3. Parse(...) static 메서드를 통해서만 기존 유저의 ID를 BookId로 변환할 수 있도록 합니다.

2. BookId 인스턴스 생성 및 직렬화

Guid value = Guid.Parse("000000000-0000-0000-0000-000000000001"); // 끝자리 '1' 인 것에 주목
BookId testId = BookId.Parse(value);

string json = JsonSerializer.Serialize(testId);
Console.WriteLine(json);  // {"Value":"00000000-0000-0000-0000-000000000001"}

3. 역직렬화 테스트

자, 위의 코드에 역직렬화 된 인스턴스(deserialized)의 값을 출력하여 정상적으로 실행되었는지 확인해 보죠.

// ...
BookId deserialized = System.Text.Json.JsonSerializer.Deserialize<BookId>(json);
// 역직렬화 된 값 출력
Console.WriteLine(deserialized.Value);

자, 역직렬화 된 deserializedValue는 어떻게 출력될 것이라고 생각하시나요?

  1. 내가 입력한 끝자리가 1인 00000000-0000-0000-0000-000000000001
  2. Guid의 default 값(Guid.Empty) 끝자리가 0인 00000000-0000-0000-0000-000000000000

정답은 사실 둘 다 아니고 System.NotSupportedException 예외의 발생입니다.

이는 parameterless public constructor 가 선언되지 않았기 때문입니다.
일반적으로는 여러분들이 별도의 생성자가 없는 클래스 선언하면
내부적으로 ctor 스니펫으로 생성되는 parameterless public constructor를 가지고 있습니다.

public class Something
{
    // parameterless constructor
    public Something() { } // 이 줄을 주석처리 해도 사용하는데 문제가 없습니다.
}

그래서 여러분들은 일반적으로 var something = new Something()을 통해서 인스턴스를 생성할 수 있습니다.

하지만 위 코드에서는 명시적으로 생성자(private UserId(...))를 선언하면서 가본생성자(parameterless constructor)를 사용하지 못하도록 제한했습니다.
그 때문에 System.Text.Json에서는 역직렬화기(Sytem.Text.Json.Serialization.JsonSerializer)가 예외를 발생시킨거죠.

4. 솔루션

자 그럼 이 문제를 해결하기 위해 parameterless constructor를 명시적으로 추가합니다.

  public record BookId
  {
      // ..
+     private BookId() { }
      // ...
  }

다시 실행하면 정상적으로 동작할까요?

정답은 또 오류가 발생합니다

사실 앞서 발생한 Exception의 구체적인 메시지는 다음과 같습니다.

Deserialization of types without a parameterless constructor, 
a singular parameterized constructor, 
or a parameterized constructor annotated with 'JsonConstructorAttribute' is not supported.

역직렬화에 사용할 생성자가 없거나, 단일 매개변수 생성자 또는
JsonConstructorAttribute로 주석 처리된 매개변수 생성자가 없다는 것입니다.

번거롭지만 아래와 같이 어트리뷰트도 추가해 줍시다.

  public record BookId
  {
      // ..
+     [JsonConstructor]
+     private BookId() { }
      // ...
  }

이제 동일한 테스트를 다시 진행해봅시다.

Console.WriteLine(value);

결과는는…

00000000-0000-0000-0000-000000000000 이었었습니다!

끝 값이 1 이 나오기를 기대했는데 정상적으로 역직렬화가 안되었죠.

이유는 Value의 Setter를 private으로 제한했기 때문입니다.

public Guid Value { get; private set; }

그렇다고 private 한정자를 제외하자니 바깥에서 Immutable 한 값을 변경할 수 있는 치명적인 문제가 발생하죠.

4. 최종 솔루션

위 문제를 해결하는 방법은 사실 아주 간단합니다.

문제가 되는 해당 프로퍼티에 JsonIncludeAttribute를 추가하는 것입니다.

  public record BookId
  {
      // ...
+     [JsonInclude]
      public Guid Value { get; private set; }
      // ...
  }

코드를 수정하고 실행해보면 정상적으로 역직렬화가 수행된 000000000-0000-0000-0000-000000000001이 출력됩니다.


불만사항

Clean Architecture 를 지향하는 개발에서 도메인 레이어에서는

기술적인 실현(DB저장, 직렬화/역직렬화)을 위한 코드를 최대한 제외하고

순수한 비즈니스 로직을 구현하는 것을 원칙으로 합니다. (어느정도의 유연성은 필요하겠지만요)

그러나 BookId 이름에서 알 수 있듯이, 매우 도메인 귀속적인 객체입니다.

하지만 보셨다시피 정상적인 직렬화/역직렬화를 수행하기 위해 여러 코드들을 추가해야 했습니다.

  • parameterless constructor
  • JsonConstructorAttribute
  • JsonIncludeAttribute

그리고 그 코드들이 특별한 기능을 수행하는 코드들이 아닌 단순히 역직렬화를 위한 보일러플레이트에 불과하단 것이죠.

아직 연구 중인 방안

사실 이를 해결하기 위한 방법은 여러가지 있습니다만, 각각 다 장단점이 존재합니다.

  1. JsonSerializerOptions 사용하기

    • 방법: IncludeFieldstrue로 설정하여 필드에 대한 역직렬화 허용
    • 단점: 직렬화에 노출되면 안되는 필드가 직렬화에 포함될 수 있습니다.
  2. 커스텀 JsonConverter를 구현하여

    • 방법: 특정 타입에 대한 역직렬화 컨버터를 구현하여 역직렬화 수행
    • 단점:
      • 구현해야 할 코드가 많다. (aka. ~귀찮다~)
      • 직렬화/역직렬화 시 특정 엔티티에 대한 적절한 컨버터를 설정해주어야 합니다. (aka. ~번거롭다~)

결론

대부분의 경우, Newtonsoft.Json 대신 System.Text.Json을 사용해도 개발 과정에서 큰 문제를 겪지는 않습니다.

특히 성능 측면에서는 System.Text.Json이 더 가볍고 빠르기 때문에, 전환을 고려할 충분한 이유가 됩니다.

하지만 이번 글에서 살펴본 것처럼, Immutable 객체나 생성자 접근 제어를 엄격히 관리하는 도메인 객체를 다룰 때는 몇 가지 불편함을 마주칠 수 있습니다.

기본 생성자 없이 설계된 객체는 역직렬화가 어렵습니다.

도메인 모델을 순수하게 유지하고 싶은 경우, 직렬화/역직렬화 관련 코드들을(JsonConstructor, JsonInclude) 추가하는 것이 아쉽게 느껴집니다.

이를 해결하려면 추가 설정이나 커스텀 컨버터 작성 등의 번거로운 작업이 필요합니다.

즉, System.Text.Json은 범용성과 성능 면에서는 매우 뛰어나지만,
**엄격한 설계 원칙(예: Clean Architecture, DDD)**을 고수하려는 개발자에게는 여전히 몇몇 아쉽고 간지러운 부분이 남아있는 것 같네요.


최종 샘플 코드 보기
using System;
using System.Text.Json;
using System.Text.Json.Serialization;

public class Program
{
	public record UserId
    {
        public Guid Value { get; private init; }

		[JsonConstructor]
		private UserId() {}
        private UserId(Guid value)  // ①
        {
            this.Value = value;
        }

        public static UserId Create() => new(Guid.NewGuid());  // ②
        public static UserId Parse(Guid value) => new(value);  // ③
    }
	
	public static void Main()
	{
        string value = "00000000-0000-0000-0000-000000000001"; // 끝자리 '1' 인 것에 주목
        UserId testId = UserId.Parse(Guid.Parse(value));
        
        string json = JsonSerializer.Serialize(testId);
        Console.WriteLine($"Serialized:\n    {json}\n");  // {"Value":"00000000-0000-0000-0000-000000000001"}
        
        UserId deserialized = System.Text.Json.JsonSerializer.Deserialize<UserId>(json);

        Console.WriteLine($"Expectation:\n    {value}\n");
        Console.WriteLine($"Actual:\n    {deserialized.Value}\n");
	}
}
4개의 좋아요

사실 간단한 해결책은 생성자에 특성을 부여하는 것입니다.

public record BookId
{
    public Guid Value { get; private set; }
    [JsonConstructor]
    private BookId(Guid value)  // ①
    {
        this.Value = value;
    }
    // ...
}

그런데, 위의 코드에서 private setMutable을 전제한 것이라, 이 객체를 Immutable을 전제한 record 로 정의하는 것이 맞는지는 의문입니다.

아래의 정의가 record 의 불변성을 유지하는 가장 간단하고 효율적인 정의 방법입니다. 물론 직렬화에도 아무런 문제가 없습니다.

public record BookId(Guid Value);

만약 class 로 정의된 객체의 직렬화 문제라면, 제기하신 불편은 일리가 있습니다.

저도 System.Text.Json이 NewtonSoft.Json 보다 매우 깐깐하다고 느꼈습니다.
사실 언급하신 문제 말고도 대소문자 구분, 케이싱(카멜 케이싱, 파스칼 케이싱 등)도 그냥 지나가지 않죠.

이러한 깐깐함은 "직렬화 옵션은 니가 정해라"라고 말하는 듯 합니다.
그래서, System.Text.Json 을 쓸때는 JsonSerializeOption 을 항상 사용하는 중인데, .net 8.0 이상에서는 JsonSerializeOptions.Web 이라고 웹 환경에서 일반적으로 통용되는 규칙으로 사전 설정된 옵션 객체를 제공합니다

JsonSerializerOptions.Web Property (System.Text.Json) | Microsoft Learn

1개의 좋아요

init은 어떤가요?

public record BookId
{
    public Guid Value { get; init; }
}

샘플코드

using System.Text.Json;

BookId testId = new()
{
    Value = Guid.Parse("00000000-0000-0000-0000-000000000001"),
};

var json = JsonSerializer.Serialize(testId);
Console.WriteLine(json);

var deserializedId = JsonSerializer.Deserialize<BookId>(json);
Console.WriteLine(deserializedId);

결과

{"Value":"00000000-0000-0000-0000-000000000001"}
BookId { Value = 00000000-0000-0000-0000-000000000001 }
2개의 좋아요

Populate 지원좀 있었으면… ㅎㅎㅎ

감사하게도, 닷넷 카카오톡방 커뮤니티의 AbsoluteZero 님이 제안해주신 방법으로 해결 방법을 찾았습니다.

생각보다 방법이 간단하게 나와서 허탈하면서도 행복합니다.

public record BookId
{
    [JsonConstructor] private BookId() { } // ①
    public Guid Value { get; init; } // ②
    public static UserId Create() => new(){Value = Guid.NewGuid()}; // ③
    public static UserId Parse(Guid value) => new() { Value = value }; // ④
}
  • 생성자 [JsonConstructor] private BookId() { }
    • private 한정자: BookId 에 대해 외부에서 new를 사용한 생성을 제한합니다.
    • [JsonConstructor]: System.Text.Json 이 정상적으로 역직렬화 할 수 있도록 합니다.
  • Immutable public Guid Value { get; init; }
    • Public-Getter 로 외부에서 값을 읽을 수 있도록 허용합니다.
    • init Setter 를 이용하여 한번 값이 주어지면 변경이 불가능 제한합니다.
  • & Factory Method
    • ③: new로 생성하지 못하도록 한 신규값의 생성을 Create() static 메서드를 이용해 외부에서 생성할 수 있도록 합니다.
    • ④: 기존에 존재하는(DB등에) 값을 BookId 로 변환합니다.
3개의 좋아요