본문 바로가기

C#

C# XML 직렬화와 역직렬화 고급 기법

XML은 시스템 간 계약을 강하게 유지하고, 스키마 검증과 호환성을 갖추기 좋습니다. 여기서는 XmlSerializer를 중심으로 성능, 버전 호환, 다형성, 보안, 커스텀 직렬화 등의 고급 기법을 간략히 정리합니다.

1. 성능 최적화와 안전한 리더/라이터 설정

XmlSerializer는 최초 생성 시 동적 어셈블리를 빌드하므로, 인스턴스를 재사용(캐싱)하는 것이 중요합니다. 또한 XML 파서 보안 설정을 명시해 XXE 같은 취약점을 차단합니다.

using System;
using System.Collections.Concurrent;
using System.IO;
using System.Xml;
using System.Xml.Serialization;

public static class XmlSer
{
    private static readonly ConcurrentDictionary<string, XmlSerializer> Cache = new();

    public static XmlSerializer Get<T>(XmlRootAttribute? root = null)
    {
        var key = typeof(T).AssemblyQualifiedName + "|" + (root?.ElementName ?? "") + "|" + (root?.Namespace ?? "");
        return Cache.GetOrAdd(key, _ => root == null ? new XmlSerializer(typeof(T)) : new XmlSerializer(typeof(T), root));
    }

    public static string ToXml<T>(T obj, XmlSerializerNamespaces? ns = null)
    {
        var settings = new XmlWriterSettings
        {
            Indent = true,
            OmitXmlDeclaration = true,
            NewLineHandling = NewLineHandling.Replace
        };
        using var sw = new StringWriter();
        using var xw = XmlWriter.Create(sw, settings);
        Get<T>().Serialize(xw, obj, ns);
        return sw.ToString();
    }

    public static T FromXml<T>(string xml, XmlReaderSettings? readerSettings = null)
    {
        readerSettings ??= new XmlReaderSettings
        {
            DtdProcessing = DtdProcessing.Prohibit,
            XmlResolver = null
        };
        using var sr = new StringReader(xml);
        using var xr = XmlReader.Create(sr, readerSettings);
        return (T)Get<T>().Deserialize(xr)!;
    }
}

팁: 빌드 타임 프리컴파일이 필요하면 sgen을 사용합니다. 단, 타입+오버라이드 조합마다 하나의 XmlSerializer를 재사용해야 메모리 누수를 방지합니다.

2. 네임스페이스/루트 제어

스키마 호환과 버전 네임스페이스를 명확히 관리합니다. XmlSerializerNamespaces로 프리픽스를 부여하고 [XmlRoot], [XmlType]으로 네임스페이스를 고정합니다.

using System.Collections.Generic;
using System.Xml.Serialization;

[XmlRoot("Order", Namespace = "urn:sample:v1")]
[XmlType(Namespace = "urn:sample:v1")]
public class Order
{
    [XmlAttribute] public string Id { get; set; } = "";
    [XmlElement("Item")] public List<OrderItem> Items { get; set; } = new();
    [XmlAnyElement] public System.Xml.XmlElement[]? Extras { get; set; }
    public bool ShouldSerializeExtras() => Extras != null && Extras.Length > 0;
}

public static class Ns
{
    public static XmlSerializerNamespaces App()
    {
        var ns = new XmlSerializerNamespaces();
        ns.Add("app", "urn:sample:v1");
        return ns;
    }
}

// 사용
// var xml = XmlSer.ToXml(new Order { Id = "A-100" }, Ns.App());

런타임에서 루트를 바꾸고 싶다면 XmlRootAttribute로 오버라이드합니다.

var rootV2 = new XmlRootAttribute("purchaseOrder") { Namespace = "urn:sample:v2" };
var ser = XmlSer.Get<Order>(rootV2);
// ser.Serialize(...)

3. 다형성(상속)과 컬렉션

상속형 데이터를 직렬화하려면 [XmlInclude] 또는 [XmlElement(Type=...)]를 사용합니다.

[XmlInclude(typeof(BookItem))]
public class OrderItem
{
    [XmlAttribute] public string Sku { get; set; } = "";
    [XmlAttribute] public int Qty { get; set; }
}

public class BookItem : OrderItem
{
    [XmlAttribute] public string Isbn { get; set; } = "";
}

// 예시
var order = new Order
{
    Id = "A-101",
    Items = new List<OrderItem> { new BookItem { Sku = "B-1", Qty = 2, Isbn = "9781234567890" } }
};
var xml = XmlSer.ToXml(order, Ns.App());

4. 버전 호환: 확장 필드, 알 수 없는 노드, 선택적 필드

미래 확장과 뒤로 호환을 위해 여유 필드를 수용합니다.

  • [XmlAnyElement]/[XmlAnyAttribute]로 알 수 없는 요소를 수집
  • UnknownElement/UnknownAttribute 이벤트로 로깅
  • ShouldSerializeXxx, XxxSpecified 패턴으로 선택적 필드 제어
// 이벤트 사용(단발성 사용 시 별도 인스턴스 권장)
var local = new XmlSerializer(typeof(Order));
local.UnknownElement += (s, e) => System.Diagnostics.Debug.WriteLine($"Unknown: {e.Element.Name}");
using var sr = new StringReader(xml);
var loaded = (Order)local.Deserialize(sr)!;

// ShouldSerialize / Specified 패턴
public class Customer
{
    public int Age { get; set; }
    public bool ShouldSerializeAge() => Age > 0;

    public bool Active { get; set; }
    [XmlIgnore] public bool ActiveSpecified { get; set; } // true일 때만 Active 요소 출력
}

5. XmlAttributeOverrides로 런타임 매핑 변경

스키마가 바뀌었거나 외부 XML과 이름을 맞춰야 할 때 유용합니다.

var overrides = new XmlAttributeOverrides();

// Sku -> ProductCode로 요소 이름 변경
var skuAttrs = new XmlAttributes();
skuAttrs.XmlElements.Add(new XmlElementAttribute("ProductCode"));
overrides.Add(typeof(OrderItem), nameof(OrderItem.Sku), skuAttrs);

// Qty 무시
var qtyAttrs = new XmlAttributes { XmlIgnore = true };
overrides.Add(typeof(OrderItem), nameof(OrderItem.Qty), qtyAttrs);

var ser2 = new XmlSerializer(typeof(Order), overrides);
// ser2.Serialize(...)

주의: XmlAttributeOverrides를 자주 생성하면 내부 동적 어셈블리가 누적될 수 있으므로 조합별로 캐싱하십시오.

6. IXmlSerializable로 완전 커스텀

표준 속성/요소 매핑으로 표현하기 어려운 경우 직접 읽기/쓰기를 구현합니다. 스키마는 GetSchema에서 null을 반환합니다.

using System.Globalization;
using System.Xml;
using System.Xml.Schema;
using System.Xml.Serialization;

public struct Point : IXmlSerializable
{
    public int X { get; set; }
    public int Y { get; set; }

    public XmlSchema? GetSchema() => null;

    public void ReadXml(XmlReader reader)
    {
        X = int.Parse(reader.GetAttribute("x") ?? "0", CultureInfo.InvariantCulture);
        Y = int.Parse(reader.GetAttribute("y") ?? "0", CultureInfo.InvariantCulture);
        reader.Read(); // <Point ... /> 소비
    }

    public void WriteXml(XmlWriter writer)
    {
        writer.WriteAttributeString("x", X.ToString(CultureInfo.InvariantCulture));
        writer.WriteAttributeString("y", Y.ToString(CultureInfo.InvariantCulture));
    }
}

주의: ReadXml/WriteXml에서 요소 경계 제어를 명확히 하십시오. 컬처 독립 처리를 위해 XmlConvert 또는 InvariantCulture를 사용합니다.

7. 날짜/시간, 사전(Dictionary) 등 특수 타입

DateTime은 Kind/타임존 혼동을 피해야 합니다. 선택형 날짜는 문자열 프록시로 라운드트립합니다.

using System;
using System.Xml;
using System.Xml.Serialization;

public class EventStamp
{
    [XmlElement(DataType = "dateTime")]
    public DateTime CreatedUtc { get; set; } = DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Utc);

    [XmlIgnore]
    public DateTime? OptionalWhen { get; set; }

    [XmlElement("OptionalWhen")]
    public string? OptionalWhenString
    {
        get => OptionalWhen.HasValue ? XmlConvert.ToString(OptionalWhen.Value, XmlDateTimeSerializationMode.RoundtripKind) : null;
        set => OptionalWhen = string.IsNullOrEmpty(value)
            ? null
            : XmlConvert.ToDateTime(value, XmlDateTimeSerializationMode.RoundtripKind);
    }
}

Dictionary는 기본적으로 바로 직렬화되지 않습니다. 프록시 리스트로 우회합니다.

using System.Collections.Generic;
using System.Linq;
using System.Xml.Serialization;

public class MapItem
{
    [XmlAttribute] public string Key { get; set; } = "";
    [XmlElement] public string Value { get; set; } = "";
}

public class Settings
{
    [XmlIgnore] public Dictionary<string, string> Map { get; set; } = new();

    [XmlArray("Map"), XmlArrayItem("Item")]
    public List<MapItem> MapItems
    {
        get => Map.Select(kv => new MapItem { Key = kv.Key, Value = kv.Value }).ToList();
        set => Map = value?.ToDictionary(i => i.Key, i => i.Value) ?? new Dictionary<string, string>();
    }
}

8. 스키마(XSD) 검증과 안정적 역직렬화

읽는 단계에서 XSD로 검증하면 조기 실패와 품질 보증에 유리합니다.

using System.IO;
using System.Xml;
using System.Xml.Schema;
using System.Xml.Serialization;

public static class XmlValidation
{
    public static T DeserializeWithValidation<T>(string xml, XmlSchemaSet schemas)
    {
        var settings = new XmlReaderSettings
        {
            DtdProcessing = DtdProcessing.Prohibit,
            XmlResolver = null,
            ValidationType = ValidationType.Schema,
            Schemas = schemas
        };
        settings.ValidationEventHandler += (s, e) => throw new XmlSchemaValidationException(e.Message, e.Exception);

        var ser = new XmlSerializer(typeof(T));
        using var sr = new StringReader(xml);
        using var xr = XmlReader.Create(sr, settings);
        return (T)ser.Deserialize(xr)!;
    }
}

9. 체크리스트

  • Serializer 재사용(캐싱)으로 성능 최적화
  • DtdProcessing.Prohibit, XmlResolver = null로 보안 강화
  • 네임스페이스/루트/요소명 명시 및 XmlAttributeOverrides로 대응
  • [XmlInclude], [XmlAnyElement], ShouldSerialize/XxxSpecified로 확장성 확보
  • 필요 시 IXmlSerializable로 커스텀 직렬화 구현
  • DateTime은 XmlConvert와 UTC/라운드트립 준수
  • 사전/복잡 타입은 프록시로 변환
  • 스키마 검증과 라운드트립 테스트로 안정성 확보

위 패턴을 템플릿으로 삼아 프로젝트의 XML 계약을 견고하고 유지보수 가능하게 설계하시길 바랍니다.