.NET에서의 직렬화
이 스킬을 사용해야 하는 경우
다음과 같은 경우 이 스킬을 사용하세요:
- API, 메시징, 영속성을 위한 직렬화 형식 선택
- Newtonsoft.Json에서 System.Text.Json으로 마이그레이션
- AOT 호환 직렬화 구현
- 분산 시스템을 위한 와이어 형식 설계
- 직렬화 성능 최적화
스키마 기반 vs 리플렉션 기반
| 항목 | 스키마 기반 | 리플렉션 기반 |
|---|---|---|
| 예시 | Protobuf, MessagePack, System.Text.Json (소스 생성) | Newtonsoft.Json, BinaryFormatter |
| 페이로드 내 타입 정보 | 없음 (외부 스키마) | 있음 (타입 이름 포함) |
| 버전 관리 | 명시적 필드 번호/이름 | 암시적 (타입 구조) |
| 성능 | 빠름 (리플렉션 없음) | 느림 (런타임 리플렉션) |
| AOT 호환 | 예 | 아니요 |
| 와이어 호환성 | 우수 | 낮음 |
권장: 프로세스 경계를 넘는 모든 작업에는 스키마 기반 직렬화를 사용하세요.
형식 권장 사항
| 사용 사례 | 권장 형식 | 이유 |
|---|---|---|
| REST API | System.Text.Json (소스 생성) | 표준, AOT 호환 |
| gRPC | Protocol Buffers | 네이티브 형식, 우수한 버전 관리 |
| 액터 메시징 | MessagePack 또는 Protobuf | 간결, 빠름, 버전 안전 |
| 이벤트 소싱 | Protobuf 또는 MessagePack | 오래된 이벤트를 영원히 처리해야 함 |
| 캐싱 | MessagePack | 간결, 빠름 |
| 구성 | JSON (System.Text.Json) | 사람이 읽기 쉬움 |
| 로깅 | JSON (System.Text.Json) | 구조화, 파싱 가능 |
피해야 할 형식
| 형식 | 문제점 |
|---|---|
| BinaryFormatter | 보안 취약점, 사용 중단됨, 절대 사용 금지 |
| Newtonsoft.Json 기본값 | 페이로드에 타입 이름 포함되어 이름 변경 시 문제 발생 |
| DataContractSerializer | 복잡, 버전 관리 불량 |
| XML | 장황, 느림, 복잡 |
소스 생성기를 사용한 System.Text.Json
JSON 직렬화의 경우 AOT 호환성과 성능을 위해 소스 생성기와 함께 System.Text.Json을 사용하세요.
설정
// 모든 타입으로 JsonSerializerContext 정의
[JsonSerializable(typeof(Order))]
[JsonSerializable(typeof(OrderItem))]
[JsonSerializable(typeof(Customer))]
[JsonSerializable(typeof(List<Order>))]
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
public partial class AppJsonContext : JsonSerializerContext { }
사용법
// 컨텍스트로 직렬화
var json = JsonSerializer.Serialize(order, AppJsonContext.Default.Order);
// 컨텍스트로 역직렬화
var order = JsonSerializer.Deserialize(json, AppJsonContext.Default.Order);
// ASP.NET Core에서 구성
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default);
});
이점
- 런타임에 리플렉션 없음 - 모든 타입 정보가 컴파일 타임에 생성됨
- AOT 호환 - Native AOT 게시와 함께 작동
- 더 빠름 - 런타임 타입 분석 없음
- 트림 안전 - 링커가 정확히 필요한 것을 알고 있음
Protocol Buffers (Protobuf)
적합한 대상: 액터 시스템, gRPC, 이벤트 소싱, 장기 유지되는 와이어 형식.
설정
dotnet add package Google.Protobuf
dotnet add package Grpc.Tools
스키마 정의
// orders.proto
syntax = "proto3";
message Order {
string id = 1;
string customer_id = 2;
repeated OrderItem items = 3;
int64 created_at_ticks = 4;
// 새 필드 추가는 항상 안전합니다
string notes = 5; // v2에서 추가됨 - 이전 리더는 무시함
}
message OrderItem {
string product_id = 1;
int32 quantity = 2;
int64 price_cents = 3;
}
버전 관리 규칙
// 안전: 새 번호로 새 필드 추가
message Order {
string id = 1;
string customer_id = 2;
string shipping_address = 5; // 새 항목 - 안전
}
// 안전: 필드 제거 (이전 리더는 알 수 없는 필드 무시, 새 리더는 기본값 사용)
// 필드 사용을 중지하고 번호를 예약하세요
message Order {
string id = 1;
// customer_id가 제거되었지만 필드 2는 예약됨
reserved 2;
}
// 안전하지 않음: 필드 타입 변경
message Order {
int32 id = 1; // 이전: string - 손상됨!
}
// 안전하지 않음: 필드 번호 재사용
message Order {
reserved 2;
string new_field = 2; // 2번 재사용 - 손상됨!
}
MessagePack
적합한 대상: 고성능 시나리오, 간결한 페이로드, 액터 메시징.
설정
dotnet add package MessagePack
dotnet add package MessagePack.Annotations
계약과 함께 사용
[MessagePackObject]
public sealed class Order
{
[Key(0)]
public required string Id { get; init; }
[Key(1)]
public required string CustomerId { get; init; }
[Key(2)]
public required IReadOnlyList<OrderItem> Items { get; init; }
[Key(3)]
public required DateTimeOffset CreatedAt { get; init; }
// 새 필드 - 이전 리더는 알 수 없는 키를 건너뜀
[Key(4)]
public string? Notes { get; init; }
}
// 직렬화
var bytes = MessagePackSerializer.Serialize(order);
// 역직렬화
var order = MessagePackSerializer.Deserialize<Order>(bytes);
AOT 호환 설정
// AOT를 위해 소스 생성기 사용
[MessagePackObject]
public partial class Order { } // partial로 소스 생성 활성화
// 리졸버 구성
var options = MessagePackSerializerOptions.Standard
.WithResolver(CompositeResolver.Create(
GeneratedResolver.Instance, // 생성된 리졸버
StandardResolver.Instance));
Newtonsoft.Json에서 마이그레이션
일반적인 문제
| Newtonsoft | System.Text.Json | 해결 방법 |
|---|---|---|
JSON의 $type | 기본적으로 지원되지 않음 | 판별자 또는 사용자 지정 변환기 사용 |
JsonProperty | JsonPropertyName | 다른 특성 |
DefaultValueHandling | DefaultIgnoreCondition | 다른 API |
NullValueHandling | DefaultIgnoreCondition | 다른 API |
| 전용 setter | [JsonInclude] 필요 | 명시적 옵트인 |
| 다형성 | [JsonDerivedType] (.NET 7+) | 명시적 판별자 |
마이그레이션 패턴
// Newtonsoft (리플렉션 기반)
public class Order
{
[JsonProperty("order_id")]
public string Id { get; set; }
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public string? Notes { get; set; }
}
// System.Text.Json (소스 생성 호환)
public sealed record Order(
[property: JsonPropertyName("order_id")]
string Id,
string? Notes // JsonSerializerOptions를 통한 null 처리
);
[JsonSerializable(typeof(Order))]
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
public partial class OrderJsonContext : JsonSerializerContext { }
판별자를 사용한 다형성
// .NET 7+ 다형성
[JsonDerivedType(typeof(CreditCardPayment), "credit_card")]
[JsonDerivedType(typeof(BankTransferPayment), "bank_transfer")]
public abstract record Payment(decimal Amount);
public sealed record CreditCardPayment(decimal Amount, string Last4) : Payment(Amount);
public sealed record BankTransferPayment(decimal Amount, string AccountNumber) : Payment(Amount);
// 다음과 같이 직렬화됨:
// { "$type": "credit_card", "amount": 100, "last4": "1234" }
와이어 호환성 패턴
관용적 리더
이전 코드는 알 수 없는 필드를 안전하게 무시해야 합니다:
// Protobuf/MessagePack: 자동 - 알 수 없는 필드는 건너뜀
// System.Text.Json: 허용하도록 구성
var options = new JsonSerializerOptions
{
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip
};
쓰기 전 읽기 도입
새 형식에 대해 직렬화 도구보다 역직렬화 도구를 먼저 배포하세요:
// 1단계: 역직렬화 추가 (모든 곳에 배포)
public Order Deserialize(byte[] data, string manifest) => manifest switch
{
"Order.V1" => DeserializeV1(data),
"Order.V2" => DeserializeV2(data), // 새 항목 - V2 읽기 가능
_ => throw new NotSupportedException()
};
// 2단계: 직렬화 활성화 (다음 릴리스, V1이 모든 곳에 배포된 후)
public (byte[] data, string manifest) Serialize(Order order) =>
_useV2Format
? (SerializeV2(order), "Order.V2")
: (SerializeV1(order), "Order.V1");
타입 이름을 포함하지 마세요
// 나쁨: 페이로드에 타입 이름 포함 - 클래스 이름 변경 시 와이어 형식이 깨짐
{
"$type": "MyApp.Order, MyApp.Core",
"id": "123"
}
// 좋음: 명시적 판별자 - 리팩토링에 안전
{
"type": "order",
"id": "123"
}
성능 비교
대략적인 처리량 (높을수록 좋음):
| 형식 | 직렬화 | 역직렬화 | 크기 |
|---|---|---|---|
| MessagePack | ★★★★★ | ★★★★★ | ★★★★★ |
| Protobuf | ★★★★★ | ★★★★★ | ★★★★★ |
| System.Text.Json (소스 생성) | ★★★★☆ | ★★★★☆ | ★★★☆☆ |
| System.Text.Json (리플렉션) | ★★★☆☆ | ★★★☆☆ | ★★★☆☆ |
| Newtonsoft.Json | ★★☆☆☆ | ★★☆☆☆ | ★★★☆☆ |
핫 경로의 경우 MessagePack 또는 Protobuf를 선호하세요.
Akka.NET 직렬화
Akka.NET 액터 시스템의 경우 스키마 기반 직렬화를 사용하세요:
akka {
actor {
serializers {
messagepack = "Akka.Serialization.MessagePackSerializer, Akka.Serialization.MessagePack"
}
serialization-bindings {
"MyApp.Messages.IMessage, MyApp" = messagepack
}
}
}
Akka.NET 직렬화 문서를 참조하세요.
모범 사례
해야 할 것
// System.Text.Json에 소스 생성기 사용
[JsonSerializable(typeof(Order))]
public partial class AppJsonContext : JsonSerializerContext { }
// 명시적 필드 번호/키 사용
[MessagePackObject]
public class Order
{
[Key(0)] public string Id { get; init; }
}
// 불변 메시지 타입에 record 사용
public sealed record OrderCreated(OrderId Id, CustomerId CustomerId);
하지 말아야 할 것
// BinaryFormatter 사용 금지 (절대)
var formatter = new BinaryFormatter(); // 보안 위험!
// 와이어 형식에 타입 이름 포함 금지
settings.TypeNameHandling = TypeNameHandling.All; // 이름 변경 시 손상!
// 핫 경로에 리플렉션 직렬화 사용 금지
JsonConvert.SerializeObject(order); // 느림, AOT 호환 안 됨
리소스
- System.Text.Json 소스 생성: https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/source-generation
- Protocol Buffers: https://protobuf.dev/
- MessagePack-CSharp: https://github.com/MessagePack-CSharp/MessagePack-CSharp
- Akka.NET 직렬화: https://getakka.net/articles/networking/serialization.html
- 와이어 호환성: https://getakka.net/community/contributing/wire-compatibility.html