System.Text.Json 이 가진 한계
System.Text.Json 가 Microsoft 에서 공식적으로 배포하고,
.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); // ③
}
private
생성자로 선언하여 해당 클래스 외부에서는 인스턴스를 생성할 수 없도록 합니다.Create(...)
static 메서드를 통해서만 신규BookId
인스턴스를 생성할 수 있도록 합니다. (aka. Factory-Pattern)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);
자, 역직렬화 된 deserialized
의 Value
는 어떻게 출력될 것이라고 생각하시나요?
- 내가 입력한 끝자리가 1인
00000000-0000-0000-0000-000000000001
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
그리고 그 코드들이 특별한 기능을 수행하는 코드들이 아닌 단순히 역직렬화를 위한 보일러플레이트에 불과하단 것이죠.
아직 연구 중인 방안
사실 이를 해결하기 위한 방법은 여러가지 있습니다만, 각각 다 장단점이 존재합니다.
-
JsonSerializerOptions
사용하기- 방법:
IncludeFields
를true
로 설정하여 필드에 대한 역직렬화 허용 - 단점: 직렬화에 노출되면 안되는 필드가 직렬화에 포함될 수 있습니다.
- 방법:
-
커스텀
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");
}
}