.NET에서 다형성 JSON을 역직렬화하면서 타입 안전성 유지
Source: Dev.to
(번역을 진행하려면 번역할 텍스트를 제공해 주세요.)
시나리오
새 프로젝트를 축하합니다! 이제 상징적인 프랑스 장소들의 인터랙티브 지도 백엔드를 구축하는 임무를 맡게 되었습니다. 외부 소스에서 데이터를 가져와 API를 통해 제공해야 합니다.
불행히도 외부 API가 잘 구조화되어 있지 않습니다: 위치 데이터가 포인트들의 혼합 컬렉션으로 제공되며, 랜드마크와 상점이 같은 배열에 섞여 있습니다.
[
{
"Name": "Bouillon Chartier",
"Category": "Restaurant",
"Latitude": 48.8759,
"Longitude": 2.3587,
"Description": "Simple french dishes in an authentic 1950s atmosphere.",
"OpeningHours": "11:30 - 00:00",
"HasParking": false
},
{
"Name": "Mont Saint-Michel",
"Period": "Gothic",
"Latitude": 48.6361,
"Longitude": -1.5115,
"Description": "Medieval abbey and UNESCO World Heritage Site."
},
{
"Name": "Grand Café Foy",
"Category": "Café",
"Latitude": 48.6930,
"Longitude": 6.1827,
"Description": "A french café at the heart of the Place Stanislas.",
"OpeningHours": "07:30 - 02:00",
"HasParking": false
},
{
"Name": "Cathedral of Our Lady of Strasbourg",
"Period": "Gothic",
"Latitude": 48.5819,
"Longitude": 7.7513,
"Description": "Catholic cathedral among the finest examples of Rayonnant Gothic architecture."
}
]
각 항목은 기본적인 지리 좌표를 가지고 있지만, 유형별 속성은 다릅니다. 게다가 Type: "Landmark"와 같은 명확한 구분자가 없습니다.
“God Object” 접근법
빠르고 대충하는 해결책은 모든 가능한 속성을 포함하는 단일 클래스를 만들고, 누락될 수 있는 속성은 nullable로 표시하는 것입니다. 이는 흔히 God Object 라고 불립니다.
public record Location(
string Name,
double Latitude,
double Longitude,
string Description,
// Shop properties
ShopCategory? Category,
string? OpeningHours,
bool? HasParking,
// Landmark properties
HistoricalPeriod? Period,
decimal? EntryFee);
역직렬화가 매우 간단해집니다:
var json = "...";
var locations = JsonSerializer.Deserialize<List<Location>>(json);
단점
- 우리는 어떤 종류의 위치를 다루고 있는지 알 수 없으므로 →
null체크가 많이 필요합니다. - 모델이 도메인을 반영하지 않습니다 (상점 vs. 랜드마크).
- 새로운 위치 유형을 추가하면 금방 관리가 어려워집니다.
도메인‑주도 모델
Instead of shaping the model to satisfy the deserializer, let’s model the data as it is.
공유 기본 타입
public abstract record MapLocation(
string Name,
double Latitude,
double Longitude,
string Description);
구체적인 파생 타입
public record Shop(
string Name,
double Latitude,
double Longitude,
string Description,
ShopCategory Category,
string OpeningHours,
bool HasParking) : MapLocation(Name, Latitude, Longitude, Description);
public record Landmark(
string Name,
double Latitude,
double Longitude,
string Description,
HistoricalPeriod HistoricalPeriod,
decimal? EntryFee) : MapLocation(Name, Latitude, Longitude, Description)
{
public bool IsFreeEntry => EntryFee is null;
}
이제 타입 시스템이 위치가 Shop인지 Landmark인지 알려주며, 도메인 규칙을 인코딩할 수 있습니다(예: 랜드마크는 무료 입장이 가능할 수 있음).
직접 역직렬화를 시도하면:
var locations = JsonSerializer.Deserialize<List<MapLocation>>(json);
…예외 발생:
System.NotSupportedException: Deserialization of interface or abstract types is not supported.
직렬 변환기는 어떤 구체 타입을 인스턴스화해야 할지 추론할 수 없습니다.
커스텀 컨버터로 직렬화기 안내하기
System.Text.Json은 JsonConverter를 제공하여 타입이 어떻게 읽히고 쓰여지는지를 제어할 수 있습니다. 우리는 다형성 계층 구조에 대해 읽기만 구현하면 됩니다.
컨버터 스켈레톤
public sealed class MapLocationJsonConverter : JsonConverter<MapLocation>
{
public override MapLocation? Read(
ref Utf8JsonReader reader,
Type typeToConvert,
JsonSerializerOptions options)
{
// 1️⃣ Load the JSON object into a JsonDocument for inspection
using var doc = JsonDocument.ParseValue(ref reader);
var root = doc.RootElement;
// 2️⃣ Decide which concrete type to deserialize into
// – If the JSON contains a "Category" property → Shop
// – If it contains a "Period" property → Landmark
// (Add more rules as needed)
if (root.TryGetProperty("Category", out _))
{
// Deserialize as Shop
return JsonSerializer.Deserialize<Shop>(root.GetRawText(), options);
}
if (root.TryGetProperty("Period", out _))
{
// Deserialize as Landmark
return JsonSerializer.Deserialize<Landmark>(root.GetRawText(), options);
}
// Fallback – could throw or return null
throw new JsonException("Unable to determine MapLocation subtype.");
}
// We are not interested in writing in this example, but we must implement it.
public override void Write(
Utf8JsonWriter writer,
MapLocation value,
JsonSerializerOptions options) =>
JsonSerializer.Serialize(writer, (object)value, options);
}
컨버터 등록
var options = new JsonSerializerOptions
{
Converters = { new MapLocationJsonConverter() },
PropertyNameCaseInsensitive = true
};
var locations = JsonSerializer.Deserialize<List<MapLocation>>(json, options);
이제 locations에는 Shop과 Landmark 객체가 혼합되어 들어 있으며, 각각 올바른 강타입 속성을 가집니다.
Source: …
요약
| 접근 방식 | 장점 | 단점 |
|---|---|---|
God Object (Location) | 간단하고 한 줄로 역직렬화 가능 | null이 많이 발생하고 타입 안전성이 없으며 확장이 어려움 |
| Domain model + custom converter | 강한 타입 지정, 실제 도메인을 반영, 확장 가능 | 약간 더 많은 코드가 필요하고 컨버터 로직을 유지 관리해야 함 |
구분자가 없는 다형성 JSON을 처리할 때, 커스텀 JsonConverter 를 사용해 들어오는 페이로드를 검사하고 적절한 구체 타입을 선택하는 것이 .NET에서 관용적인 해결책입니다.
행복한 코딩 되세요! 🚀
=> throw new NotImplementedException();
public override void Write(
Utf8JsonWriter writer,
MapLocation value,
JsonSerializerOptions options) =>
throw new NotImplementedException();
}
데이터에 $type 과 같은 명시적인 구분자가 있었다면
[JsonDerivedType(typeof(...), typeDiscriminator: "...")]
와 같이 사용하고 이 전체 과정을 건너뛸 수 있었을 것입니다. 여기서 까다로운 점은 구분자가 암시적이라는 점—즉, 속성의 존재 여부에 따라 결정된다는 것입니다.
Read 메서드
JSON에서 현재 항목이 Shop인지 Landmark인지 알려주는 값을 찾아야 합니다. 정답은 하나가 없으며, 이 예시에서는 HistoricalPeriod 속성이 포함된 항목을 Landmark로, 그 외는 모두 Shop으로 간주합니다.
public override MapLocation? Read(
ref Utf8JsonReader reader,
Type typeToConvert,
JsonSerializerOptions options)
{
// JSON 조각을 한 번 파싱해서 검사할 수 있게 합니다.
using var jsonDoc = JsonDocument.ParseValue(ref reader);
var root = jsonDoc.RootElement;
// 객체에 "Period" 속성이 있는가?
var hasHistoricalPeriod = root.TryGetProperty("Period", out _);
// 적절한 구체 타입으로 역직렬화합니다.
return hasHistoricalPeriod
? root.Deserialize<Landmark>(options)
: root.Deserialize<Shop>(options);
}
참고: 여기서는 간단히
JsonDocument를 사용했습니다. 데이터 양이 많다면 전체 조각을 DOM에 로드하는 메모리 오버헤드를 피하기 위해 스트리밍 방식을 고려해야 합니다.
컨버터 등록 (다시)
var options = new JsonSerializerOptions
{
Converters = { new MapLocationJsonConverter() },
};
var locations = JsonSerializer.Deserialize<List<MapLocation>>(json, options);
코드를 다시 실행하면 List 에 이제 적절한 구체 타입 인스턴스만 들어 있음을 확인할 수 있습니다—만세!
우리가 달성한 것
- 이제 .NET이 강한 타입을 자동으로 처리하도록 항목을 사용할 수 있습니다.
- 명시적인 구분자가 없어도 다형성 역직렬화가 정상적으로 동작합니다.
Recap
이 기사에서는 명시적인 구분자가 없을 때 다형성 JSON을 역직렬화하는 문제를 다루었습니다.
- God‑object 접근법 – 타입 안전성을 희생하고 디버깅을 어렵게 만들며, null 검사가 코드에 난무하게 되는 빠르지만 지저분한 해결책.
- 상속 + 커스텀
JsonConverter– 약간 더 많은 사전 작업이 필요하지만 .NET의 타입 안전성과 유지 보수성을 완전히 활용할 수 있는 보다 견고한 해결책.
Photo by David Clode on Unsplash