stax-xml v1 설계 노트 1: JavaScript에서 StAX를 다시 만든 이유
stax-xml은 JavaScript와 TypeScript에서 XML을 순차적으로 읽고 쓰는 라이브러리입니다. 중심에는 StAX 스타일의 pull parser가 있고, 같은 namespace와 entity 규칙을 사용하는 streaming writer와 schema converter가 함께 있습니다. XML 전체를 DOM이나 JavaScript object로 바꾸는 대신, 애플리케이션이 필요한 만큼 앞으로 진행하면서 현재 element, text, attribute를 읽습니다.
여기서 pull은 parser의 제어권을 뜻합니다. parser가 callback을 호출하는 SAX와 달리 애플리케이션이 next()를 호출해 다음 XML event를 요구합니다. writer는 pull parser와 같은 방식으로 “당겨 오는” API는 아닙니다. 애플리케이션이 writeStartElement(), writeCharacters(), writeEndElement()를 순서대로 호출해 XML을 출력하는 StAX 스타일의 streaming cursor API입니다.
입력 byte/string → StreamReader.next() → 현재 XML event
애플리케이션 값 → Writer.write*() → 출력 byte/string
따라서 stax-xml의 근본 개념은 XML 전체를 먼저 소유하지 않고, 애플리케이션이 입력과 출력의 진행 시점을 소유하는 것입니다.
Java의 StAX에서 가져온 것
Java의 StAX API는 XML을 위한 cursor API와 event iterator API를 함께 정의합니다. 낮은 수준의 XMLStreamReader와 XMLStreamWriter는 현재 위치의 값을 accessor로 읽고 쓰며, XMLEventReader와 XMLEventWriter는 다루기 쉬운 event object를 제공합니다. Oracle 문서도 cursor API를 효율적인 읽기·쓰기에, event iterator API를 사용 편의성과 확장성에 맞춘 계층으로 설명합니다.
저는 Java를 주로 개발하면서 JDK StAX와 Woodstox의 XMLStreamReader에 익숙했습니다. 대용량 XML을 처리할 때 while (reader.hasNext()), reader.next(), reader.getLocalName()으로 이어지는 흐름은 특별한 parser 사용법이라기보다 평범한 반복문에 가까웠습니다. 필요한 element를 찾으면 break하고, 하위 구조는 별도 메서드에서 읽고, 오류와 자원 정리는 기존 Java 제어문으로 처리할 수 있습니다.
stax-xml은 Java interface를 그대로 옮기지 않습니다. 가져온 것은 다음 event를 읽을 시점을 애플리케이션이 결정하는 제어 모델, 현재 token을 accessor로 읽는 cursor, 안정적인 event object를 그 위에 올리는 계층 구조입니다. 입력은 Java InputStream 대신 JavaScript의 Uint8Array, AsyncIterable, Web ReadableStream을 받습니다.
JavaScript에서 찾지 못한 StAX 스타일 API
JavaScript에서 XML을 처리하는 선택지 자체가 없었던 것은 아닙니다. 브라우저에는 DOM API가 있고, Node 생태계에는 XML 전체를 JavaScript object로 바꾸는 fast-xml-parser, xml2js와 callback event를 제공하는 sax-js가 있습니다.
제가 찾지 못한 것은 pull로 소비할 수 있는 parser 자체가 아니라 다음 조건을 함께 갖춘 StAX 스타일 API였습니다.
- async byte stream을 문서 전체 materialization 없이 읽는다.
- parser callback이 아니라 소비자가
next()로 진행한다. - 현재 token만 읽는 낮은 allocation 경로와 안정적인 event object 경로를 함께 제공한다.
- 같은 token core 위에서 schema 기반 typed object도 만든다.
- Node뿐 아니라 Bun, Deno, browser, edge runtime에서도 같은 API를 사용한다.
즉 JavaScript에는 async-saxophone처럼 async iterator로 token을 소비하는 parser도 있었지만, Java StAX의 cursor와 event 계층을 JavaScript stream에 결합한 API는 찾지 못했습니다. object mapper는 편하지만 결과 전체를 만들고, SAX parser는 streaming에 적합하지만 제어 흐름을 callback으로 넘깁니다. stax-xml은 그 사이를 채웁니다.
대용량 XML에서의 sweet spot
대용량 XML에서는 메모리만 줄인다고 API가 실용적이지는 않습니다. DOM이나 object mapper는 사용하기 편하지만 문서 또는 관심 subtree를 object graph로 만듭니다. SAX는 전체 tree를 만들지 않지만 중첩 구조, 조기 종료, 비동기 작업을 callback 사이의 상태로 표현해야 합니다. pull reader는 두 방식의 중간에 있습니다.
| 방식 | 메모리 모델 | 제어 흐름 | 적합한 작업 |
|---|---|---|---|
| DOM/object 변환 | 문서 또는 subtree를 materialize | 완성된 결과를 탐색 | 작은 XML, 구조가 고정된 응답 |
| SAX callback | event를 순차 전달 | parser가 handler 호출 | 모든 event에 같은 변환을 적용하는 작업 |
| StAX pull | 현재 token 또는 선택한 event만 유지 | 애플리케이션이 next() 호출 |
큰 XML의 선택적 처리, 조기 종료, 비동기 소비 |
pull API가 SAX보다 본질적으로 빠르다는 뜻은 아닙니다. 둘 다 전체 DOM을 만들지 않고 구현할 수 있고, 실제 처리량은 tokenizer, string 생성, event materialization과 입력 방식에 좌우됩니다. sweet spot은 성능과 편의성을 함께 봤을 때 생깁니다. 관심 없는 값은 만들지 않으면서 break, return, throw, await, try/finally를 평소 코드와 같은 위치에 둘 수 있습니다.
이 특성은 XML 크기가 커질수록 중요합니다. 수 GB export에서 특정 record만 DB에 넣거나, 수백만 개의 element 중 첫 일치 항목에서 읽기를 멈추거나, 각 record를 queue에 넣은 뒤 다음 입력을 요청하는 작업은 parser 처리량만큼 애플리케이션의 제어 흐름이 중요합니다.
parser가 아니라 애플리케이션이 다음 event를 요청한다
XML parser는 문서를 순서대로 읽으며 시작 태그, text, 종료 태그 같은 event를 발견합니다. 차이는 그 event를 누가 요구하느냐에 있습니다.
- SAX에서는 parser가 애플리케이션의 handler를 호출합니다.
- StAX에서는 애플리케이션이 reader의
next()를 호출합니다.
Oracle의 StAX 설명도 StAX를 XML 읽기와 쓰기를 위한 event-driven pull parsing API로 정의합니다.
수백만 개의 <item> 중 조건에 맞는 첫 항목만 찾는 코드는 평범한 반복문으로 작성할 수 있습니다.
import { StreamReader, XmlEventType } from 'stax-xml';
const reader = new StreamReader(response.body!);
while (await reader.next() !== null) {
if (
reader.eventType() === XmlEventType.START_ELEMENT &&
reader.localName() === 'item' &&
reader.attributeValue('id') === wantedId
) {
console.log(reader.attributeValue('name'));
break;
}
}
break, return, throw, try/finally의 의미가 평소 JavaScript와 같습니다. 파싱 흐름이 함수의 지역 제어 흐름 안에 있기 때문입니다. 읽기를 멈추는 조건과 자원 정리 위치도 눈에 보입니다.
반대로 callback 기반 parser에서는 “찾았다”는 상태를 저장하고 이후 callback을 무시하거나, parser/입력 stream을 별도로 중단하고, 완료 callback과 오류 callback 사이의 정리 순서도 맞춰야 합니다. SAX가 표현할 수 없는 작업은 아니지만 애플리케이션의 제어 흐름이 parser 쪽으로 뒤집힙니다.
비동기는 입력 경계에만 둔다
StreamReader.next()는 내부 버퍼에 다음 token이 있으면 바로 진행하고, 입력을 보충해야 할 때만 기다립니다.
network/file I/O await가 필요한 영역
↓
byte batch ingress
↓
token scan/event 동기 계산 영역
애플리케이션은 await reader.next()라는 단일한 사용법을 가지면서도, parser core는 도착한 byte를 동기적으로 소진할 수 있습니다. 입력을 소비하는 속도 역시 reader loop가 자연스럽게 결정하므로 backpressure를 별도 callback protocol로 재구성할 필요가 없습니다.
event object가 필요한 경우와 필요하지 않은 경우
v1은 pull 모델 안에서도 두 reader를 제공합니다.
EventReader와 EventReaderSync는 이벤트 객체를 만듭니다. 이벤트를 다른 함수로 전달하거나 보관할 때 사용합니다.
import { EventReaderSync, XmlEventType } from 'stax-xml';
for (const event of new EventReaderSync(xml)) {
if (event.type === XmlEventType.START_ELEMENT) {
console.log(event.name, event.attributes);
}
}
StreamReader와 StreamReaderSync는 현재 token에 cursor를 두고 accessor로 값을 읽습니다. 필요한 이름이나 속성만 materialize할 수 있어 allocation에 민감한 경로에 적합합니다. Java의 XMLStreamReader가 next(), event type, text accessor를 분리한 것과 비슷한 형태지만, JavaScript stream과 iterator 관습에 맞춰 설계했습니다.
결과 object의 모양이 이미 정해져 있다면 더 낮은 수준의 reader를 직접 다룰 필요도 없습니다. v1의 권장 시작점은 stax-xml/converter입니다. pull reader는 converter의 기반이자, object graph 전체를 만들지 않아야 하는 작업을 위한 escape hatch입니다.
parser와 짝을 이루는 streaming writer
입력만 streaming하고 출력은 하나의 거대한 string으로 조립하면 대용량 처리의 이점이 반쪽에 그칩니다. Writer는 Web WritableStream에 XML byte를 순차적으로 내보냅니다.
import { Writer } from 'stax-xml';
const writer = new Writer(output);
await writer.writeStartDocument('1.0', 'utf-8');
await writer.writeStartElement('items');
for await (const item of items) {
await writer.writeStartElement('item', {
attributes: { id: item.id },
});
await writer.writeCharacters(item.name);
await writer.writeEndElement();
}
await writer.writeEndElement();
await writer.close();
reader loop와 writer 호출을 연결하면 입력 XML을 전부 object로 만들지 않고 필요한 값만 변환해 다른 XML stream으로 보낼 수 있습니다. async writer의 각 호출은 output stream의 backpressure를 따릅니다. 작은 결과를 string으로 얻을 때는 WriterSync, 큰 파일에 동기적으로 기록할 때는 WriterSyncSink를 사용합니다.
v1에서 비용 모델을 API로 나눈다
v0의 StaxXmlParserSync와 StaxXmlParser는 iterator와 async iterator로 pull parsing을 제공했지만 항상 안정적인 event object를 만들었습니다. v1은 pull 모델을 유지하면서 token 탐색과 event object 생성을 분리합니다.
- pure JavaScript
TokenCursor가 parser core를 담당합니다. StreamReaderSync와StreamReader는 현재 token을 accessor로 노출합니다.EventReaderSync와EventReader는 현재 token을 안정적인 event object로 materialize합니다.stax-xml/converter는 같은 reader 위에서 알려진 XML을 typed object로 변환합니다.
이 구분은 “가장 빠른 API 하나”를 고르는 설계가 아닙니다. 필요한 값의 수명에 맞춰 비용을 선택하는 설계입니다. 현재 loop에서 이름과 attribute 몇 개만 확인한다면 stream reader가 맞고, event를 queue에 넣거나 다른 함수로 넘긴다면 event reader가 맞습니다. 결과 구조를 이미 알고 있다면 converter가 더 간단합니다.
pull reader에서도 남는 상태
pull 방식에서도 XML 처리를 위한 상태는 남습니다.
- 중첩 구조를 처리하려면 depth나 stack이 필요할 수 있습니다.
- namespace와 entity 규칙은 여전히 지켜야 합니다.
- text가 언제나 한 번의 이벤트로 온다고 가정해서는 안 됩니다.
- 이벤트를 모두 객체로 보관하면 streaming의 메모리 이점은 사라집니다.
달라지는 것은 이 상태를 어디에서 관리하느냐입니다. parser callback 여러 곳에 흩어 놓는 대신, 하나의 loop와 지역 변수 안에서 관리할 수 있습니다.
StAX 스타일은 대용량 XML을 순차 처리하면서 제어 흐름, 중단, 오류, backpressure를 애플리케이션 코드 안에 둡니다. 다음 글에서는 SAX의 제어 흐름을 구체적으로 살펴봅니다.
댓글 영역에 가까워지면 자동으로 불러옵니다.
Preparing comments...