[PART11.입출력 기본(7/7)] JSON 직렬화 — System.Text.Json — .NET 기본 제공 JSON의 모든 것
JsonSerializer.Serialize/Deserialize 기본 / 옵션·애트리뷰트로 동작 제어 / 스트림 비동기 직렬화로 큰 파일 처리 / Source Generator로 IL2CPP·AOT 호환 / Newtonsoft.Json과의 결정적 차이
목차
1. 문제 제기 — 게임 세이브 한 줄을 JSON으로 만드는 데 왜 라이브러리가 필요한가
게임 세이브, 서버 통신, 설정 파일 — C# 개발자가 거의 매일 마주하는 작업이 객체 ↔ JSON 변환입니다. C# 입문 단계에서 가장 처음 만나게 되는 직렬화 라이브러리가 System.Text.Json입니다.
record Player(string Name, int Hp);
var p = new Player("Alice", 100);
string json = JsonSerializer.Serialize(p);
// → {"Name":"Alice","Hp":100}
Player? back = JsonSerializer.Deserialize<Player>(json);
// → Player { Name = "Alice", Hp = 100 }
겉으로는 두 줄이지만, 그 뒤에는 다음 결정들이 숨어 있습니다.
- 누구의 키 표기를 따를까? — C#
PlayerNamevs JSONplayerNamevsplayer_name - 민감한 필드를 직렬화 대상에서 빼려면? —
Password,SessionToken - 큰 파일은 어떻게 처리하지? — 100MB JSON을
string으로 한 번에 만들면 OOM - Newtonsoft.Json에서는 되던 게 왜 안 될까? — 순환 참조, 다형성, 대소문자
- Unity IL2CPP 빌드에서 왜 모바일에선 직렬화가 안 될까? — 리플렉션 stripping
이 글에서는 System.Text.Json의 기본 사용법부터 옵션·애트리뷰트·스트림·Source Generator까지, 실제 게임 세이브와 서버 페이로드를 만들 때 필요한 결정들을 IL 레벨까지 정리합니다.
2. 개념 정의 — JsonSerializer 두 줄 API
2.1 가장 기본 형태
System.Text.Json은 .NET Core 3.0부터 기본 내장된 라이브러리입니다. using System.Text.Json; 한 줄로 사용 시작.
using System.Text.Json;
record Player(string Name, int Hp);
// 직렬화
string json = JsonSerializer.Serialize(new Player("Alice", 100));
// {"Name":"Alice","Hp":100}
// 역직렬화
Player? p = JsonSerializer.Deserialize<Player>(json);
직렬화 대상 규칙:
| 대상 | 동작 |
|---|---|
public 프로퍼티 |
자동 직렬화 |
public 필드 |
기본은 무시 (IncludeFields = true로 켤 수 있음) |
private 멤버 |
무시 ([JsonInclude]로 강제 가능) |
record 본문 매개변수 |
자동 직렬화 |
init 프로퍼티 |
자동 직렬화 + 역직렬화 |
2.2 JsonSerializerOptions로 동작 제어
기본 동작이 마음에 안 들면 JsonSerializerOptions로 바꿉니다.

var options = new JsonSerializerOptions
{
WriteIndented = true, // 들여쓰기
PropertyNamingPolicy = JsonNamingPolicy.CamelCase, // PlayerHp → playerHp
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNameCaseInsensitive = true, // 역직렬화 시
};
string json = JsonSerializer.Serialize(player, options);
2.3 애트리뷰트로 프로퍼티 단위 제어
옵션은 전역 설정, 애트리뷰트는 프로퍼티별 제어입니다.
record Player(
[property: JsonPropertyName("user_name")] string Name, // JSON 키를 "user_name"로
int Hp,
[property: JsonIgnore] string Password // 직렬화 대상에서 제외
);
[JsonIgnore]— 직렬화 제외 애트리뷰트 (Json ignore attribute) 해당 프로퍼티/필드를 JSON 직렬화·역직렬화 대상에서 완전히 제외한다. 비밀번호·세션 토큰 같은 민감 데이터에 필수.
예시:[JsonIgnore] public string Password { get; set; }— JSON 출력에 Password 키가 나타나지 않는다
| 애트리뷰트 | 역할 |
|---|---|
[JsonPropertyName("name")] |
JSON 키 이름 강제 지정 |
[JsonIgnore] |
직렬화·역직렬화 모두 제외 |
[JsonInclude] |
private/internal도 강제 포함 |
[JsonConstructor] |
역직렬화 시 사용할 생성자 지정 |
[JsonNumberHandling(WriteAsString)] |
숫자를 문자열로 입출력 |
3. 내부 동작 — 리플렉션과 Source Generator
3.1 기본 방식 — 런타임 리플렉션
public record Player(string Name, int Hp, [property: JsonIgnore] string Secret);
public static string SerializePlayer(Player p)
{
return JsonSerializer.Serialize(p);
}
.method public hidebysig static string SerializePlayer (class Player p) cil managed
{
.maxstack 2
.locals init ([0] string)
IL_0001: ldarg.0
IL_0002: ldnull // options = null
IL_0003: call string JsonSerializer::Serialize<Player>(!!0, JsonSerializerOptions)
IL_0008: stloc.0
IL_000b: ldloc.0
IL_000c: ret
}
3.2 옵션을 명시한 직렬화
public static string SerializePretty(Player p)
{
var options = new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
return JsonSerializer.Serialize(p, options);
}
IL_0001: newobj instance void JsonSerializerOptions::.ctor() // ★ 매번 새 옵션 객체
IL_0006: dup
IL_0007: ldc.i4.1
IL_0008: callvirt instance void JsonSerializerOptions::set_WriteIndented(bool)
IL_000d: dup
IL_000e: call class JsonNamingPolicy JsonNamingPolicy::get_CamelCase() // 정적 프로퍼티
IL_0013: callvirt instance void JsonSerializerOptions::set_PropertyNamingPolicy(JsonNamingPolicy)
IL_0019: dup
IL_001a: ldc.i4.3 // JsonIgnoreCondition.WhenWritingNull == 3
IL_001b: callvirt instance void JsonSerializerOptions::set_DefaultIgnoreCondition(JsonIgnoreCondition)
IL_0021: stloc.0
IL_0022: ldarg.0
IL_0023: ldloc.0
IL_0024: call string JsonSerializer::Serialize<Player>(!!0, JsonSerializerOptions)
IL_002a: stloc.1
IL_002e: ret
3.3 IL 분석 포인트
1. JsonSerializer.Serialize<T> — 제네릭 호출
컴파일러가 T = Player로 추론해 제네릭 메서드를 직접 호출합니다. 박싱 없이 Player 참조를 그대로 전달합니다.
2. newobj JsonSerializerOptions::.ctor() — 메서드 호출마다 새 옵션 객체
SerializePretty 같은 패턴은 함수가 호출될 때마다 옵션을 새로 만듭니다. JsonSerializerOptions는 내부적으로 캐시를 만들어 동일 인스턴스 재사용 시 큰 성능 이득이 있습니다. 같은 옵션을 반복 사용한다면 정적 필드로 캐시해야 합니다.
// ❌ 매번 옵션 객체 + 내부 캐시 무효화
public static string Serialize(Player p)
=> JsonSerializer.Serialize(p, new JsonSerializerOptions { WriteIndented = true });
// ✅ 옵션을 한 번만 만들어 재사용
private static readonly JsonSerializerOptions s_options = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
public static string Serialize(Player p) => JsonSerializer.Serialize(p, s_options);
3. IL 자체는 평범하지만, 내부에서 리플렉션으로 타입을 분석한다
JsonSerializer.Serialize<Player> 호출이 실제로 하는 일은 IL에는 보이지 않습니다. 런타임에 typeof(Player)로 메타데이터를 가져와 모든 프로퍼티를 순회하고, getter를 invoke해 값을 읽습니다. 이 과정이 첫 호출에서는 느리고 메모리를 많이 쓰며, 결과를 내부 캐시(MetadataCache)에 저장해 다음 호출은 빠르게 처리합니다.
4. Unity IL2CPP에서 stripping 위험
IL2CPP 빌드는 사용되지 않는 메타데이터를 제거(stripping)할 수 있습니다. 게임에서 Player의 Hp 프로퍼티를 직접 읽지 않고 JSON 직렬화로만 접근하면, 빌드 시 Hp의 메타데이터가 제거되어 런타임에 MissingMethodException 이 날 수 있습니다. 해결책은 [Preserve] 애트리뷰트나 link.xml로 보존을 명시하는 것, 또는 후술하는 Source Generator를 쓰는 것입니다.
3.4 Source Generator (.NET 6+) — 리플렉션 제거
.NET 6부터 컴파일 타임에 직렬화 코드를 미리 생성하는 Source Generator가 표준에 포함되었습니다.
using System.Text.Json.Serialization;
[JsonSerializable(typeof(Player))]
internal partial class GameJsonContext : JsonSerializerContext { }
// 사용
string json = JsonSerializer.Serialize(player, GameJsonContext.Default.Player);
Player? back = JsonSerializer.Deserialize(json, GameJsonContext.Default.Player);
이 방식은:
- 컴파일 타임에
Player전용 직렬화 메서드를 생성합니다 - 런타임 리플렉션을 거의 사용하지 않아 시작 시간이 짧고 빠릅니다
- Native AOT / IL2CPP 환경에서 stripping 안전합니다
Source Generator 자체의 IL은 컴파일러가 자동 생성한 코드라 직접 분석할 일이 적지만, 호출 측 IL은 위와 비슷합니다.
4. 실전 적용 — 스트림 직렬화와 옵션 캐싱
4.1 작은 객체 — string 기반 API
// ✅ 100KB 이하의 작은 데이터는 string으로 충분
string json = JsonSerializer.Serialize(saveData);
File.WriteAllText(path, json);
string loaded = File.ReadAllText(path);
SaveData? data = JsonSerializer.Deserialize<SaveData>(loaded);
4.2 큰 데이터 — 스트림 + 비동기
// ❌ 100MB 데이터를 string에 통째로 → OOM 위험
string json = JsonSerializer.Serialize(bigDataset);
File.WriteAllText(path, json);
// ✅ 스트림 직렬화 — 메모리 사용 최소화
await using FileStream fs = File.Create(path);
await JsonSerializer.SerializeAsync(fs, bigDataset);
// ✅ 역직렬화도 스트림으로
await using FileStream rfs = File.OpenRead(path);
BigData? data = await JsonSerializer.DeserializeAsync<BigData>(rfs);
SerializeAsync/DeserializeAsync는 내부적으로 4KB 청크 단위로 읽고 쓰기 때문에 데이터 크기와 무관하게 메모리 사용량이 일정합니다. 대형 페이로드는 무조건 스트림 버전을 우선합니다.
4.3 옵션 캐싱
// ❌ Before — 매 호출마다 새 옵션 객체 + 내부 캐시 미스
public static string Save(Player p)
{
return JsonSerializer.Serialize(p, new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
});
}
// ✅ After — 옵션을 정적 필드로 한 번만 만든다
private static readonly JsonSerializerOptions s_options = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
public static string Save(Player p) => JsonSerializer.Serialize(p, s_options);
JsonSerializerOptions는 첫 직렬화 시 타입별 변환기 캐시를 내부에 만듭니다. 같은 인스턴스를 반복 사용하면 두 번째 호출부터는 캐시 히트로 빠르게 처리되지만, 매번 새 옵션을 만들면 그 캐시가 매번 새로 생성되어 큰 비용이 됩니다.
4.4 동적 JSON — JsonDocument / JsonNode
스키마가 정해지지 않은 JSON을 처리할 때는 JsonDocument(읽기 전용) 또는 JsonNode(수정 가능)를 사용합니다.
// 읽기 전용 — JsonDocument
using JsonDocument doc = JsonDocument.Parse(json);
JsonElement root = doc.RootElement;
int hp = root.GetProperty("hp").GetInt32();
string name = root.GetProperty("name").GetString();
// 수정 가능 — JsonNode (.NET 6+)
JsonNode? node = JsonNode.Parse(json);
node!["hp"] = 200; // 직접 수정
node!["lastSavedAt"] = DateTime.UtcNow;
string updated = node.ToJsonString();
JsonDocument는 Span<byte> 위에서 동작해 추가 할당이 거의 없습니다. 큰 JSON에서 일부분만 빠르게 읽을 때 이상적입니다. 단, using으로 반드시 해제해야 합니다.
4.5 다형성 (.NET 7+)
[JsonDerivedType(typeof(Weapon), typeDiscriminator: "weapon")]
[JsonDerivedType(typeof(Potion), typeDiscriminator: "potion")]
public abstract record Item(string Id);
public record Weapon(string Id, int Damage) : Item(Id);
public record Potion(string Id, int Heal) : Item(Id);
List<Item> inventory = new() { new Weapon("sword", 10), new Potion("hp", 50) };
string json = JsonSerializer.Serialize(inventory);
// [{"$type":"weapon","Id":"sword","Damage":10},{"$type":"potion","Id":"hp","Heal":50}]
[JsonDerivedType]은 .NET 7부터 표준 다형성 지원입니다. 이전 버전에서는 커스텀 JsonConverter를 직접 구현해야 했습니다.
5. 함정과 주의사항
5.1 ❌ Newtonsoft.Json 습관 그대로 사용
| 차이 | Newtonsoft.Json | System.Text.Json |
|---|---|---|
| 대소문자 매칭 | 기본 무시 (forgiving) | 기본 엄격 |
| 순환 참조 | ReferenceLoopHandling.Ignore |
기본 예외, ReferenceHandler.Preserve 명시 |
| 다형성 | 자동 (TypeNameHandling) |
[JsonDerivedType] 명시 (.NET 7+) |
| 인코딩 | UTF-16 (string) |
UTF-8 (Span<byte>) |
[JsonProperty] |
인식 | 인식 안 함 → [JsonPropertyName] 사용 |
같은 코드라도 Newtonsoft.Json에서는 동작하던 게 System.Text.Json에서는 안 되는 경우가 잦습니다. 마이그레이션 시 이 차이를 한 번 점검해야 합니다.
5.2 ❌ 옵션을 매번 새로 만들기
// ❌ 매 호출마다 옵션 객체 + 내부 캐시 무효화
foreach (var p in players)
{
string json = JsonSerializer.Serialize(p, new JsonSerializerOptions
{
WriteIndented = true,
});
}
// ✅ 옵션은 한 번만 만들어 재사용
private static readonly JsonSerializerOptions s_options = new() { WriteIndented = true };
foreach (var p in players)
{
string json = JsonSerializer.Serialize(p, s_options);
}
5.3 ❌ private 필드를 직렬화 대상이라고 기대
// ❌ _password가 직렬화될 거라 가정 — System.Text.Json은 기본 무시
class User
{
public string Name { get; set; } = "";
private string _password = "";
}
// ✅ public 프로퍼티로 노출하거나, [JsonInclude]로 강제
class User
{
public string Name { get; set; } = "";
[JsonInclude]
private string _password = "";
}
반대로 민감 정보는 절대 직렬화되면 안 되므로 Password 같은 프로퍼티에는 [JsonIgnore]를 명시합니다.
5.4 ❌ 큰 파일을 string으로 통째 처리
// ❌ 100MB JSON → string 전체 메모리에 적재 → OOM
string json = File.ReadAllText(bigJsonPath);
List<Item>? items = JsonSerializer.Deserialize<List<Item>>(json);
// ✅ 스트림 비동기 — 메모리 사용량 일정
await using FileStream fs = File.OpenRead(bigJsonPath);
List<Item>? items = await JsonSerializer.DeserializeAsync<List<Item>>(fs);
5.5 ❌ Unity IL2CPP에서 리플렉션 stripping
// ❌ Unity IL2CPP 빌드에서 Player의 메타데이터가 제거되어 직렬화 실패
public class Player { public int Hp { get; set; } }
string json = JsonSerializer.Serialize(player);
// → MissingMemberException 또는 빈 JSON
// ✅ Source Generator 사용 (Unity 2022.3 + Mono/IL2CPP 호환)
[JsonSerializable(typeof(Player))]
internal partial class GameJsonContext : JsonSerializerContext { }
string json = JsonSerializer.Serialize(player, GameJsonContext.Default.Player);
// ✅ 또는 [Preserve] / link.xml로 stripping 차단
5.6 ❌ DateTime 포맷 가정
// ❌ 클라이언트 문화권에 따라 다른 포맷이 나올 수 있다고 오해
// (실제로는 System.Text.Json은 ISO 8601 + InvariantCulture로 고정)
string json = JsonSerializer.Serialize(new { Now = DateTime.Now });
// {"Now":"2026-05-06T12:34:56+09:00"}
System.Text.Json은 DateTime을 항상 ISO 8601 형식 + InvariantCulture로 직렬화합니다. 한국어/독일어 PC에서 같은 결과가 나오므로 데이터 호환성은 자동으로 보장됩니다. 대신 사용자에게 보여 주는 포맷이 필요하면 별도 변환이 필요합니다.
6. C# / .NET 버전별 변화
| .NET 버전 | 변경점 |
|---|---|
| .NET Core 3.0 (2019) | System.Text.Json 첫 도입 — 리플렉션 기반 |
| .NET 5 (2020) | JsonNamingPolicy, JsonNumberHandling 등 옵션 확장 |
| .NET 6 (2021) | Source Generator — 컴파일 타임 직렬화, AOT 호환 |
| .NET 6 (2021) | JsonNode — 수정 가능한 동적 JSON |
| .NET 7 (2022) | [JsonDerivedType] — 다형성 표준 지원 |
| .NET 7 (2022) | IUtf8SpanFormattable, IUtf8SpanParsable — UTF-8 직접 처리 |
| .NET 8 (2023) | JsonNamingPolicy.SnakeCaseLower, KebabCaseLower 추가 |
| .NET 9 (2024) | JSON 스키마 내보내기, JsonStringEnumConverter 개선 |
.NET 6 — Source Generator로 IL이 어떻게 달라지는가
리플렉션 기반:
string json = JsonSerializer.Serialize(player);
// 런타임에 Player의 모든 프로퍼티 메타데이터를 리플렉션으로 수집
Source Generator 기반:
[JsonSerializable(typeof(Player))]
internal partial class GameJsonContext : JsonSerializerContext { }
// 컴파일 타임에 GameJsonContext.Player 직렬화 메서드가 자동 생성됨
string json = JsonSerializer.Serialize(player, GameJsonContext.Default.Player);
후자는 컴파일러가 Write(Player) 같은 강타입 메서드를 미리 만들어 둡니다. 런타임에 리플렉션 없이 곧장 호출되므로:
- 시작 시간(cold start) 크게 단축
- AOT/IL2CPP 호환
- 메모리 사용량 감소
- 빌드 산출물 크기는 약간 증가 (생성 코드 분량만큼)
7. 정리 — 이것만 기억하라
System.Text.Json은 .NET Core 3.0부터 기본 내장. UTF-8 기반·Span 기반·엄격(strict) 동작이 특징.JsonSerializer.Serialize<T>/Deserialize<T>두 메서드가 핵심 API. 옵션을 안 주면 런타임 리플렉션으로 처리한다.JsonSerializerOptions는 정적 필드로 캐시한다. 매번 새로 만들면 내부 캐시가 무효화되어 성능이 크게 떨어진다.[JsonPropertyName]/[JsonIgnore]/[JsonInclude]로 프로퍼티 단위 제어. 비밀번호·세션 토큰은 반드시[JsonIgnore].- 큰 데이터는
SerializeAsync/DeserializeAsync+Stream으로 처리.ReadAllText+Deserialize<string>는 OOM 위험. - 동적 파싱은
JsonDocument(읽기) /JsonNode(쓰기).JsonDocument는using으로 반드시 해제. - Newtonsoft.Json과 다르다. 대소문자 엄격, 순환 참조 예외,
[JsonProperty]미인식. 마이그레이션 시 전수 점검 필요. - Unity IL2CPP·AOT는 Source Generator (
[JsonSerializable]+JsonSerializerContext) 가 정답. 리플렉션 stripping을 회피한다. DateTime은 ISO 8601 + InvariantCulture로 자동 직렬화된다. 사용자 표시 포맷이 필요하면 별도 변환.- 다형성은 .NET 7+
[JsonDerivedType]. 그 전에는 커스텀JsonConverter로 직접 구현해야 했다.
