반응형

[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입니다.

C#
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# PlayerName vs JSON playerName vs player_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; 한 줄로 사용 시작.

C#
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로 바꿉니다.

자주 쓰는 JsonSerializerOptions
C#
var options = new JsonSerializerOptions
{
    WriteIndented = true,                                 // 들여쓰기
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,    // PlayerHp → playerHp
    DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
    PropertyNameCaseInsensitive = true,                   // 역직렬화 시
};

string json = JsonSerializer.Serialize(player, options);

2.3 애트리뷰트로 프로퍼티 단위 제어

옵션은 전역 설정, 애트리뷰트는 프로퍼티별 제어입니다.

C#
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 기본 방식 — 런타임 리플렉션

C#
public record Player(string Name, int Hp, [property: JsonIgnore] string Secret);

public static string SerializePlayer(Player p)
{
    return JsonSerializer.Serialize(p);
}
IL
.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 옵션을 명시한 직렬화

C#
public static string SerializePretty(Player p)
{
    var options = new JsonSerializerOptions
    {
        WriteIndented = true,
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
    };
    return JsonSerializer.Serialize(p, options);
}
IL
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는 내부적으로 캐시를 만들어 동일 인스턴스 재사용 시 큰 성능 이득이 있습니다. 같은 옵션을 반복 사용한다면 정적 필드로 캐시해야 합니다.

C#
// ❌ 매번 옵션 객체 + 내부 캐시 무효화
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)할 수 있습니다. 게임에서 PlayerHp 프로퍼티를 직접 읽지 않고 JSON 직렬화로만 접근하면, 빌드 시 Hp의 메타데이터가 제거되어 런타임에 MissingMethodException 이 날 수 있습니다. 해결책은 [Preserve] 애트리뷰트나 link.xml로 보존을 명시하는 것, 또는 후술하는 Source Generator를 쓰는 것입니다.

3.4 Source Generator (.NET 6+) — 리플렉션 제거

.NET 6부터 컴파일 타임에 직렬화 코드를 미리 생성하는 Source Generator가 표준에 포함되었습니다.

C#
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

C#
// ✅ 100KB 이하의 작은 데이터는 string으로 충분
string json = JsonSerializer.Serialize(saveData);
File.WriteAllText(path, json);

string loaded = File.ReadAllText(path);
SaveData? data = JsonSerializer.Deserialize<SaveData>(loaded);

4.2 큰 데이터 — 스트림 + 비동기

C#
// ❌ 100MB 데이터를 string에 통째로 → OOM 위험
string json = JsonSerializer.Serialize(bigDataset);
File.WriteAllText(path, json);
C#
// ✅ 스트림 직렬화 — 메모리 사용 최소화
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 옵션 캐싱

C#
// ❌ Before — 매 호출마다 새 옵션 객체 + 내부 캐시 미스
public static string Save(Player p)
{
    return JsonSerializer.Serialize(p, new JsonSerializerOptions
    {
        WriteIndented = true,
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
    });
}
C#
// ✅ 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(수정 가능)를 사용합니다.

C#
// 읽기 전용 — 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();

JsonDocumentSpan<byte> 위에서 동작해 추가 할당이 거의 없습니다. 큰 JSON에서 일부분만 빠르게 읽을 때 이상적입니다. 단, using으로 반드시 해제해야 합니다.

4.5 다형성 (.NET 7+)

C#
[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 ❌ 옵션을 매번 새로 만들기

C#
// ❌ 매 호출마다 옵션 객체 + 내부 캐시 무효화
foreach (var p in players)
{
    string json = JsonSerializer.Serialize(p, new JsonSerializerOptions
    {
        WriteIndented = true,
    });
}
C#
// ✅ 옵션은 한 번만 만들어 재사용
private static readonly JsonSerializerOptions s_options = new() { WriteIndented = true };
foreach (var p in players)
{
    string json = JsonSerializer.Serialize(p, s_options);
}

5.3 ❌ private 필드를 직렬화 대상이라고 기대

C#
// ❌ _password가 직렬화될 거라 가정 — System.Text.Json은 기본 무시
class User
{
    public string Name { get; set; } = "";
    private string _password = "";
}
C#
// ✅ public 프로퍼티로 노출하거나, [JsonInclude]로 강제
class User
{
    public string Name { get; set; } = "";

    [JsonInclude]
    private string _password = "";
}

반대로 민감 정보는 절대 직렬화되면 안 되므로 Password 같은 프로퍼티에는 [JsonIgnore]를 명시합니다.

5.4 ❌ 큰 파일을 string으로 통째 처리

C#
// ❌ 100MB JSON → string 전체 메모리에 적재 → OOM
string json = File.ReadAllText(bigJsonPath);
List<Item>? items = JsonSerializer.Deserialize<List<Item>>(json);
C#
// ✅ 스트림 비동기 — 메모리 사용량 일정
await using FileStream fs = File.OpenRead(bigJsonPath);
List<Item>? items = await JsonSerializer.DeserializeAsync<List<Item>>(fs);

5.5 ❌ Unity IL2CPP에서 리플렉션 stripping

C#
// ❌ Unity IL2CPP 빌드에서 Player의 메타데이터가 제거되어 직렬화 실패
public class Player { public int Hp { get; set; } }
string json = JsonSerializer.Serialize(player);
// → MissingMemberException 또는 빈 JSON
C#
// ✅ 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 포맷 가정

C#
// ❌ 클라이언트 문화권에 따라 다른 포맷이 나올 수 있다고 오해
// (실제로는 System.Text.Json은 ISO 8601 + InvariantCulture로 고정)
string json = JsonSerializer.Serialize(new { Now = DateTime.Now });
// {"Now":"2026-05-06T12:34:56+09:00"}

System.Text.JsonDateTime을 항상 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이 어떻게 달라지는가

리플렉션 기반:

C#
string json = JsonSerializer.Serialize(player);
// 런타임에 Player의 모든 프로퍼티 메타데이터를 리플렉션으로 수집

Source Generator 기반:

C#
[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(쓰기). JsonDocumentusing으로 반드시 해제.
  • Newtonsoft.Json과 다르다. 대소문자 엄격, 순환 참조 예외, [JsonProperty] 미인식. 마이그레이션 시 전수 점검 필요.
  • Unity IL2CPP·AOT는 Source Generator ([JsonSerializable] + JsonSerializerContext) 가 정답. 리플렉션 stripping을 회피한다.
  • DateTime은 ISO 8601 + InvariantCulture로 자동 직렬화된다. 사용자 표시 포맷이 필요하면 별도 변환.
  • 다형성은 .NET 7+ [JsonDerivedType]. 그 전에는 커스텀 JsonConverter로 직접 구현해야 했다.
반응형

+ Recent posts