1. 소개
이번 수업에서는 "그냥 리스트만 저장해줘" 단계에서 벗어나 System.Text.Json으로 유연하고 정확하며 성능 좋은 직렬화를 다루는 방법으로 한 걸음 나아갈 거야. 현대 .NET 프로젝트에서 JSON은 사실상 데이터 교환 표준이야. 기본적인 Serialize/Deserialize 호출은 간단하지만, 실제로는 세밀한 설정이 필요해: 필드 무시/이름 변경, 날짜 포맷 관리, 순환 보호, 대용량 데이터 처리, 커스텀 컨버터 등.
우리는 JsonSerializer의 메서드뿐 아니라 JsonSerializerOptions을 통한 파라미터, 어트리뷰트, Stream 작업, 메모리 관리, 그리고 JsonConverter로 직접 직렬화 규칙을 주입하는 방법까지 살펴볼 거야.
System.Text.Json의 간단한 역사와 위치
오랫동안 .NET에서는 유연하고 성숙한 Newtonsoft.Json (Json.NET)이 주류였어. 하지만 항상 가장 빠르거나 의존성이 적은 건 아니었지. .NET Core 3.0부터는 플랫폼에 포함된 System.Text.Json이 나왔어: 성능이 좋고 의존성이 적으며, ASP.NET Core와 밀접하게 통합되고 .NET 릴리즈와 함께 계속 발전하고 있어.
2. 주요 클래스와 메서드
기본 요소는 정적 클래스 JsonSerializer야. 이 클래스는 두 가지 방향을 제공해:
- 직렬화: 객체 → JSON 문자열 (Serialize)
- 역직렬화: JSON 문자열 → 필요한 타입의 객체 (Deserialize)
간단한 객체 직렬화 예제
using System.Text.Json;
var person = new Person { Name = "이반", Age = 30 };
string jsonString = JsonSerializer.Serialize(person);
Console.WriteLine(jsonString); // {"Name":"이반","Age":30}
역직렬화 예제
var json = "{\"Name\":\"안나\",\"Age\":22}";
var anna = JsonSerializer.Deserialize<Person>(json);
Console.WriteLine(anna.Name); // 안나
참고: Person 타입은 이전 강의에서 이미 구현되어 있으므로 여기서도 그대로 사용함.
3. 직렬화 제어: JsonSerializerOptions
실무에서는 거의 항상 설정이 필요해: 속성 이름을 camelCase로 만들기, 날짜 포맷, 순환 처리, 기본값 처리 등. 이런 것들을 JsonSerializerOptions로 조정해.
설정 예제
var options = new JsonSerializerOptions
{
WriteIndented = true, // JSON을 보기 좋게 포맷함 (공백과 줄바꿈 추가)
PropertyNameCaseInsensitive = true, // 역직렬화 시 속성 이름 대소문자 무시
PropertyNamingPolicy = JsonNamingPolicy.CamelCase // 속성 이름을 camelCase로 (PascalCase가 아님)
};
string json = JsonSerializer.Serialize(person, options);
/*
{
"name": "이반",
"age": 30
}
*/
왜 중요하냐면 대부분 프론트엔드 프레임워크는 .NET의 PascalCase가 아니라 camelCase를 기대하기 때문이야.
4. 어트리뷰트: System.Text.Json.Serialization
모델에서 바로 직렬화를 제어하고 싶을 때 어트리뷰트를 쓰는 게 편해. 속성/필드에 붙여서 이름 변경, 포함/제외, 값 처리 등을 제어할 수 있어.
주요 어트리뷰트
| 어트리뷰트 | 기능 |
|---|---|
|
속성을 직렬화/역직렬화에서 제외함 |
|
JSON에서 다른 이름을 사용함 |
|
비공개 속성/필드를 직렬화에 포함시킴 |
|
숫자 값 처리 방식을 제어함 |
예제: 어트리뷰트로 속성 제어하기
using System.Text.Json.Serialization;
public class Person
{
[JsonPropertyName("full_name")]
public string Name { get; set; }
[JsonIgnore]
public int SecretCode { get; set; }
public int Age { get; set; }
}
var person = new Person { Name = "표트르", Age = 45, SecretCode = 123 };
string json = JsonSerializer.Serialize(person);
// {"full_name":"표트르","Age":45}
주의: SecretCode는 JSON에 들어가지 않고, Name은 "full_name"으로 직렬화됐어.
5. 컬렉션과 중첩 객체 직렬화
컬렉션은 간단해
var numbers = new List<int> { 1, 2, 3 };
string json = JsonSerializer.Serialize(numbers); // [1,2,3]
var people = new List<Person> {
new Person { Name = "안나", Age = 20 },
new Person { Name = "막심", Age = 40 }
};
string jsonList = JsonSerializer.Serialize(people);
// [{"Name":"안나","Age":20},{"Name":"막심","Age":40}]
중첩 구조
public class Group
{
public string Name { get; set; }
public List<Person> Members { get; set; }
}
var group = new Group
{
Name = "개발자들",
Members = new List<Person>
{
new Person { Name = "사샤", Age = 23 },
new Person { Name = "마샤", Age = 28 }
}
};
string jsonGroup = JsonSerializer.Serialize(group, options);
/*
{
"name": "개발자들",
"members": [
{ "name": "사샤", "age": 23 },
{ "name": "마샤", "age": 28 }
]
}
*/
6. 역직렬화: 알아둬야 할 것들
var json = "[{\"Name\":\"이반\",\"Age\":21}]";
var list = JsonSerializer.Deserialize<List<Person>>(json);
Console.WriteLine(list[0].Name); // 이반
자주 있는 시나리오: JSON에 어떤 필드가 없으면 해당 속성은 기본값을 갖게 돼. JSON에 모델에 없는 여분의 필드가 있어도 무시돼. 하지만 타입이 일치하지 않으면(예: 숫자 대신 문자열이 온 경우) 역직렬화 중에 예외가 발생해.
7. 날짜, 시간, 포맷과 숫자 처리
public class Meeting
{
public string Topic { get; set; }
public DateTime Time { get; set; }
}
var meeting = new Meeting { Topic = "회의", Time = DateTime.Now };
string json = JsonSerializer.Serialize(meeting);
// {"Topic":"회의","Time":"2024-06-06T20:30:00.0000000+03:00"}
기본적으로 DateTime은 ISO 8601로 직렬화돼. 다른 형태(예: 날짜만)는 별도 속성을 쓰거나 커스텀 컨버터를 만들어야 해(아래 참조).
FAQ: 숫자를 문자열로 직렬화해야 할 때(예: 전화번호나 큰 ID)에는 어트리뷰트 [JsonNumberHandling(JsonNumberHandling.WriteAsString)]를 사용해.
8. 스트림과 파일 작업
문자열뿐 아니라 Stream을 써서 작업할 수 있어 — 파일이나 네트워크 같은 대용량 데이터에서 중요해.
파일에 쓰기 예제
using var fs = File.Create("person.json");
JsonSerializer.Serialize(fs, person);
// fs.Flush()를 호출하거나 using을 사용해서 닫는 걸 잊지 마!
파일에서 읽기 예제
using var fs = File.OpenRead("person.json");
var restored = JsonSerializer.Deserialize<Person>(fs);
스트림에는 비동기 메서드 SerializeAsync/DeserializeAsync도 있어서 고부하 서비스에 유용해.
9. 커스텀 컨버터
기본 규칙으로 안 되는 경우(비표준 날짜/숫자 포맷, 복잡한 값, 커스텀 구조 등)에는 JsonConverter를 작성하면 돼.
예제: 날짜를 "dd.MM.yyyy" 형식으로만 직렬화
public class CustomDateConverter : JsonConverter<DateTime>
{
public override DateTime Read(
ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
return DateTime.ParseExact(reader.GetString(), "dd.MM.yyyy", null);
}
public override void Write(
Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options)
{
writer.WriteStringValue(value.ToString("dd.MM.yyyy"));
}
}
var options = new JsonSerializerOptions();
options.Converters.Add(new CustomDateConverter());
var dt = new DateTime(2024, 6, 1);
string json = JsonSerializer.Serialize(dt, options); // "01.06.2024"
커스텀 컨버터는 좌표, 벡터, 색상, 비표준 날짜나 통화, 다양한 ID 포맷 같은 것을 직렬화할 때 유용해.
10. 유용한 팁
순환 참조와 깊은 계층 처리
var options = new JsonSerializerOptions
{
ReferenceHandler = ReferenceHandler.Preserve, // 모든 객체를 $id/$ref로 보존
WriteIndented = true
};
중요: 이 경우 JSON에 내부 속성 $id와 $ref가 생겨. 외부 시스템과 교환할 때 이걸 모르면 문제가 될 수 있어.
System.Text.Json와 Newtonsoft.Json의 차이
System.Text.Json은 이미 꽤 강력하지만 아직 Newtonsoft.Json이 다루던 모든 시나리오(비공개 생성자, 복잡한 동적 객체 등)를 완전히 대체하지는 못해. 대부분의 표준 작업에는 내장 직렬화기를 추천해 — 더 빠르고 불필요한 의존성이 없어.
JSON과 상호작용할 때: DOM API
모델 전체를 만들지 않고 JSON을 탐색하고 싶을 때는 JsonDocument와 JsonElement를 써.
using var doc = JsonDocument.Parse(jsonString);
JsonElement root = doc.RootElement;
if (root.TryGetProperty("Name", out var nameProperty))
{
Console.WriteLine(nameProperty.GetString());
}
11. 유용한 옵션과 효과
| 속성 | 값/용도 |
|---|---|
|
true — 들여쓰기 있는 포맷으로 출력 |
|
true — 역직렬화 시 속성 이름의 대소문자 무시 |
|
|
|
null/기본값을 무시하는 규칙 |
|
, |
|
true — 배열 끝에 쉼표 허용 |
|
숫자를 문자열로 변환/되돌리기 등 처리 방식 |
|
커스텀 컨버터 목록 |
12. 자주 하는 실수와 실용 팁
실수 #1: 역직렬화할 때 잘못된 타입을 사용함. 리스트를 직렬화했다면, 역직렬화할 때도 정확히 리스트로 받아야 해: List<T>로, 단일 객체로 하지 말 것.
실수 #2: 속성 이름의 대소문자 문제. 기본 설정에서는 속성을 못 찾을 수 있어. PropertyNameCaseInsensitive를 사용하거나 PropertyNamingPolicy를 맞춰줘.
실수 #3: 날짜 처리 오류. 기본 포맷은 ISO 8601야. 다른 포맷이 필요하면 컨버터(JsonConverter<DateTime>)를 만들어 적용해.
실수 #4: private/static 필드의 직렬화를 기대함. 기본적으로는 public 속성만 직렬화돼. 특수한 경우엔 어트리뷰트를 사용해([JsonInclude] 등).
실수 #5: 기본값의 이해 부족. JSON에 빠진 필드는 → 속성의 기본값이 들어간다는 걸 기억해. 로직에서 이 점을 고려해야 해.
실수 #6: 스트림 관리 부실. 리소스는 using이나 await using으로 닫아서 누수나 잠금을 피해.
GO TO FULL VERSION