수십 MB 이상의 Base64 데이터를 포함한 XML 노드를 처리해야 하는 상황이 발생했다. DOM 방식은 메모리 한계로 불가능하므로 XmlReader 기반의 스트리밍 접근이 필수적이다. 그러나 XmlTextReader로 대용량 스트 노드를 읽을 때도 여러 함정이 존재한다.
ReadChars()의 함정
MSDN 문서에 소개된 ReadChars 기본 패턴은 다음과 같다.
int chunkLen;
char[] segment = new char[4096];
while ((chunkLen = reader.ReadChars(segment, 0, segment.Length)) > 0)
{
// segment[0..chunkLen] 처리
}
이 코드는 정상 포맷의 XML에서는 동작하지만, 다음과 같이 공백이나 줄바꿈이 제거된 압축 형태의 XML에서는 심각한 문제를 일으킨다.
<Root><LeafNode>Alpha</LeafNode><SiblingNode>Beta</SiblingNode></Root>
위 구조에서 첫 번째 LeafNode의 값을 읽으려 할 때, ReadChars는 버퍼边界的 제약 없이 연속된 텍스트를 모두 읽어들인다. 결과적으로 AlphaBeta처럼 인접 노드의 값이 뒤섞여 반환될 수 있다.
노드 경계 감지 메커니즘
문제의 원인은 ReadChars가 내부적으로 다음 요소의 시작 태그를 만나도 텍스트 읽기를 중단하지 않는 데 있다. 다행히 XmlTextReader는 내부 상태 전환 시점에 LocalName 속성을 갱신하므로, 이를 감시하면 노드 경계를 식별할 수 있다.
string nodeIdentity = reader.LocalName;
int received;
char[] block = new char[8192];
while (reader.LocalName == nodeIdentity && (received = reader.ReadChars(block, 0, block.Length)) > 0)
{
// block[0..received] 안전하게 처리
}
루프 조건의 reader.LocalName == nodeIdentity 검사가 핵심이다. 다음 요소로 진입하는 순간 LocalName이 변경되며 루프가 자동 종료된다.
완전한 스트리밍 파이프라인
실무에서는 특정 경로의 노드를 선택적으로 처리하면서, 대용량 노드는 스트리밍으로 통과시켜야 한다. 다음 구현은 경로 기반 필터링과 대용량 노드 처리를 결합한 예시다.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Xml;
class XmlStreamingProcessor
{
private readonly IReadOnlySet<string> _transformTargets;
private readonly IReadOnlySet<string> _streamTargets;
private readonly Stack<string> _hierarchy = new();
public XmlStreamingProcessor(
IEnumerable<string> transformPaths,
IEnumerable<string> streamPaths)
{
_transformTargets = new HashSet<string>(transformPaths);
_streamTargets = new HashSet<string>(streamPaths);
}
public void Execute(string inputFile, string outputFile)
{
using var source = new FileStream(inputFile, FileMode.Open, FileAccess.Read);
using var sink = new FileStream(outputFile, FileMode.Create, FileAccess.Write);
using var producer = new XmlTextReader(source);
using var consumer = new XmlTextWriter(sink, Encoding.UTF8) { Formatting = Formatting.None };
bool hasMore = producer.Read();
while (hasMore)
{
hasMore = Dispatch(producer, consumer);
}
}
private bool Dispatch(XmlTextReader producer, XmlTextWriter consumer)
{
switch (producer.NodeType)
{
case XmlNodeType.Element:
return HandleElement(producer, consumer);
case XmlNodeType.Text:
consumer.WriteString(producer.Value);
return producer.Read();
case XmlNodeType.CDATA:
consumer.WriteCData(producer.Value);
return producer.Read();
case XmlNodeType.ProcessingInstruction:
consumer.WriteProcessingInstruction(producer.Name, producer.Value);
return producer.Read();
case XmlNodeType.Comment:
consumer.WriteComment(producer.Value);
return producer.Read();
case XmlNodeType.Whitespace:
consumer.WriteWhitespace(producer.Value);
return producer.Read();
case XmlNodeType.EndElement:
_hierarchy.Pop();
consumer.WriteFullEndElement();
return producer.Read();
case XmlNodeType.XmlDeclaration:
consumer.WriteStartDocument();
return producer.Read();
case XmlNodeType.DocumentType:
consumer.WriteDocType(producer.Name, null, null, producer.Value);
return producer.Read();
default:
return producer.Read();
}
}
private bool HandleElement(XmlTextReader producer, XmlTextWriter consumer)
{
string elementName = producer.LocalName;
string currentPath = BuildCurrentPath(elementName);
_hierarchy.Push(elementName);
consumer.WriteStartElement(producer.Prefix, elementName, producer.NamespaceURI);
if (producer.HasAttributes)
{
while (producer.MoveToNextAttribute())
{
consumer.WriteAttributeString(producer.Prefix, producer.LocalName,
producer.NamespaceURI, producer.Value);
}
producer.MoveToElement();
}
if (producer.IsEmptyElement)
{
_hierarchy.Pop();
consumer.WriteEndElement();
return producer.Read();
}
if (_streamTargets.Contains(currentPath))
{
return StreamLargeNode(producer, consumer);
}
if (_transformTargets.Contains(currentPath))
{
return TransformNode(producer, consumer);
}
return producer.Read();
}
private bool StreamLargeNode(XmlTextReader producer, XmlTextWriter consumer)
{
string anchor = producer.LocalName;
producer.MoveToContent();
char[] payload = new char[65536];
int fetched;
while (producer.LocalName == anchor && (fetched = producer.ReadChars(payload, 0, payload.Length)) > 0)
{
consumer.WriteRaw(payload, 0, fetched);
}
_hierarchy.Pop();
consumer.WriteEndElement();
return !producer.EOF;
}
private bool TransformNode(XmlTextReader producer, XmlTextWriter consumer)
{
// 변환 로직: 예시로 내용을 대체
consumer.WriteString("[TRANSFORMED]");
// 원본 노드 소비
int depth = 1;
while (producer.Read() && depth > 0)
{
if (producer.NodeType == XmlNodeType.Element && !producer.IsEmptyElement)
depth++;
else if (producer.NodeType == XmlNodeType.EndElement)
depth--;
}
_hierarchy.Pop();
consumer.WriteEndElement();
return !producer.EOF;
}
private string BuildCurrentPath(string activeNode)
{
var segments = _hierarchy.Reverse().Concat(new[] { activeNode });
return string.Join("/", segments);
}
}
핵심 고려사항
- 버퍼 크기:
ReadChars의 버퍼는 64KB~256KB 범위가 일반적으로 최적이다. 너무 작으면 시스템 호출 오버헤드가, 너무 크면 메모리 캐시 효율이 저하된다. - Raw vs Value: 대용량 데이터를 그대로 통과시킬 때는
WriteRaw가WriteString보다 효율적이다. 후자는 자동으로 특수문자를 이스케이프하여 Base64 데이터에 불필요한 오버헤드를 추가한다. - 경로 정규화: 실제 구현에서는 네임스페이스 처리가 필요하다.
LocalName만으로는 충돌 가능성이 있으므로, 필요시NamespaceURI를 포함한 정규화된 이름을 사용해야 한다. MoveToContent호출 시점: 대용량 노드 처리 전 반드시 호출하여 공백, 주석 등을 건너뛴다.