stax-xml v1 설계 노트 6: v1을 실제 코드에 적용하기
앞선 글에서는 StAX 스타일을 택한 이유부터 native addon을 제외한 과정까지 설명했습니다. 이 글에서는 그렇게 만든 stax-xml v1.0.0의 public API와 실제 사용 방법을 정리합니다.
v1은 pure JavaScript로 동작하는 ESM-only package입니다. parser와 writer에 native addon이나 runtime별 backend가 없고 Node, Bun, Deno, browser와 edge runtime에서 같은 API를 사용합니다.
pnpm add stax-xml
public entry point는 두 개입니다.
stax-xml은 sync/async reader와 writer를 제공합니다.stax-xml/converter는 XML schema와 JavaScript object 사이를 변환합니다.
결과의 모양을 알고 있다면 converter에서 시작하고, XML을 순서대로 직접 처리해야 할 때 reader를 선택하는 구조입니다.
먼저 고를 API
| 작업 | API | 입력 |
|---|---|---|
| 알려진 XML을 typed object로 변환 | stax-xml/converter |
string, byte, sync/async stream |
| event를 보관하거나 다른 함수에 전달 | EventReaderSync, EventReader |
sync 또는 async 입력 |
| 큰 XML에서 필요한 token만 읽기 | StreamReaderSync, StreamReader |
sync 또는 async 입력 |
| 작은 XML을 string으로 생성 | WriterSync |
동기 호출 |
| 큰 XML을 sync 방식으로 파일·buffer에 기록 | WriterSyncSink |
동기 sink |
XML을 WritableStream으로 전송 |
Writer |
async output stream |
Sync reader는 완성된 string, Uint8Array, Iterable<Uint8Array>를 받습니다. async reader는 ReadableStream<Uint8Array>와 AsyncIterable<Uint8Array>를 받습니다. Node의 Readable은 async iterable이고 Buffer는 Uint8Array이므로 createReadStream()을 바로 전달할 수 있습니다.
사용례 1: API 응답을 typed object로 변환한다
상품 API가 다음 XML을 반환한다고 가정하겠습니다.
<catalog>
<product>
<sku>A-100</sku>
<name>Keyboard</name>
<price>129.5</price>
</product>
<product>
<sku>B-200</sku>
<name>Mouse</name>
<price>49.9</price>
</product>
</catalog>
XML 구조와 최종 object가 정해져 있으므로 reader loop를 직접 작성할 필요가 없습니다.
import { x, type Infer } from 'stax-xml/converter';
const catalogSchema = x.object({
products: x.array(
x.object({
sku: x.string().xpath('./sku'),
name: x.string().xpath('./name'),
price: x.number().xpath('./price').min(0),
}),
'/catalog/product',
),
});
type Catalog = Infer<typeof catalogSchema>;
const response = await fetch('https://supplier.example/catalog.xml');
if (!response.body) throw new Error('Response body is empty');
const catalog: Catalog = await catalogSchema.parse(response.body);
schema에서 Catalog type을 추론하고 price가 음수인지도 parsing 과정에서 검사합니다. 같은 schema에 완성된 XML string을 넘길 때는 parseSync(xml)을 사용할 수 있습니다. 예외 대신 결과를 분기하려면 safeParse() 또는 safeParseSync()를 사용합니다.
converter는 필요한 결과를 결국 object graph로 만듭니다. 수 GB export에서 각 record를 읽자마자 DB에 넣어야 한다면 다음의 current-token reader가 맞습니다.
사용례 2: 대용량 export에서 필요한 record만 처리한다
아래 코드는 Node에서 큰 상품 export를 읽고 활성 상품의 id만 queue에 넣습니다. event object와 attribute array는 만들지 않습니다.
import { createReadStream } from 'node:fs';
import { StreamReader, XmlEventType } from 'stax-xml';
const reader = new StreamReader(createReadStream('products.xml'));
try {
while (await reader.next() !== null) {
if (reader.eventType() !== XmlEventType.START_ELEMENT) continue;
if (reader.localName() !== 'product') continue;
if (reader.attributeValue('active') !== 'true') continue;
const id = reader.attributeValue('id');
if (id !== undefined) await productQueue.add(id);
}
} finally {
await reader.close();
}
StreamReader의 accessor는 현재 token을 가리킵니다. 필요한 이름과 attribute는 다음 next()를 호출하기 전에 읽어야 합니다. 중간에 loop를 끝낼 때도 close()가 underlying iterator를 반환하거나 ReadableStream을 cancel합니다.
이 구조에서는 await productQueue.add(id)가 끝나야 다음 token을 요청합니다. 별도의 pause/resume callback을 만들지 않아도 소비 속도가 입력 속도를 제한합니다.
완성된 XML string을 같은 방식으로 읽으려면 StreamReaderSync를 사용합니다.
import { StreamReaderSync, XmlEventType } from 'stax-xml';
const reader = new StreamReaderSync(xml);
while (reader.next() !== null) {
if (reader.eventType() === XmlEventType.START_ELEMENT) {
console.log(reader.name());
}
}
사용례 3: event를 reader 밖으로 전달한다
current-token accessor의 수명 규칙이 맞지 않는 작업도 있습니다. XML event를 queue에 보내거나 여러 handler에 전달하려면 EventReader가 안정적인 JavaScript object를 만들어 주는 편이 단순합니다.
import { EventReader, isStartElement } from 'stax-xml';
const response = await fetch('https://partner.example/events.xml');
if (!response.body) throw new Error('Response body is empty');
for await (const event of new EventReader(response.body)) {
if (!isStartElement(event) || event.localName !== 'entry') continue;
await auditQueue.add({
name: event.name,
attributes: event.attributes,
});
}
반환된 event와 attribute는 reader가 다음 token으로 진행해도 바뀌지 않습니다. 그 대신 token마다 object를 materialize합니다. 보관할 필요가 없는 hot path라면 StreamReader, 보관하거나 전달해야 한다면 EventReader를 고르면 됩니다.
사용례 4: XML 응답을 stream으로 생성한다
v1에는 parser와 같은 namespace·entity 규칙을 사용하는 writer도 포함했습니다. 다음 예제는 Web WritableStream에 상품 목록을 바로 기록합니다.
import { Writer } from 'stax-xml';
async function writeProducts(
output: WritableStream<Uint8Array>,
products: AsyncIterable<{ id: string; name: string }>,
): Promise<void> {
const writer = new Writer(output);
await writer.writeStartDocument('1.0', 'utf-8');
await writer.writeStartElement('products');
for await (const product of products) {
await writer.writeStartElement('product', {
attributes: { id: product.id },
});
await writer.writeStartElement('name');
await writer.writeCharacters(product.name);
await writer.writeEndElement();
await writer.writeEndElement();
}
await writer.writeEndElement();
await writer.writeEndDocument();
await writer.close();
}
writeCharacters()는 XML에서 의미가 있는 문자를 escape하고 writer는 element stack을 검사합니다. 전체 문서를 하나의 string으로 조립하지 않으므로 HTTP response나 file output에 연결할 수 있습니다. 작은 문서를 string으로 만들 때는 WriterSync를 사용하면 됩니다.
사용례 5: WriterSyncSink로 큰 파일을 쓴다
WriterSync는 결과를 내부 string으로 모으는 writer입니다. 큰 파일에는 WriterSyncSink를 사용해야 합니다. sink는 XML text chunk를 받을 write() 함수만 구현하면 되고, writer는 내부 character buffer가 임계값에 도달할 때 sink로 내보냅니다. flush()와 close()는 필요한 sink에만 추가합니다.
Node의 파일 descriptor에 동기적으로 쓰는 예제는 다음과 같습니다.
import { closeSync, openSync, writeSync } from 'node:fs';
import { WriterSyncSink } from 'stax-xml';
const fd = openSync('products.xml', 'w');
const sink = {
write(chunk: string) {
writeSync(fd, chunk, undefined, 'utf8');
},
close() {
closeSync(fd);
},
};
const writer = new WriterSyncSink(sink, {
bufferSize: 64 * 1024,
flushThreshold: 0.8,
});
writer.writeStartDocument('1.0', 'utf-8');
writer.writeStartElement('products');
writer.writeStartElement('product', { attributes: { id: 'A-100' } });
writer.writeStartElement('name');
writer.writeCharacters('Keyboard & Mouse');
writer.writeEndElement();
writer.writeEndElement();
writer.writeEndElement();
writer.close();
이 방식에서 WriterSyncSink는 파일 전체를 하나의 JavaScript string으로 유지하지 않습니다. bounded character buffer를 비우면서 파일에 순차적으로 기록하므로, 출력 크기와 writer가 보유하는 메모리를 분리할 수 있습니다. writeSync()는 호출 스레드를 막으므로 요청 처리 event loop에서는 피해야 합니다. CLI, worker, 전용 출력 thread처럼 호출 스레드를 막아도 되는 작업에 적합합니다.
sink가 만든 성능 차이
v1 release benchmark는 Apple M4, Node 26.5.0, arm64 환경에서 같은 1 GiB XML 출력을 비교했습니다. 메모리 sink에서는 async Writer가 81.0 MiB/s, WriterSyncSink가 212.2 MiB/s였습니다. 임시 파일에서는 각각 79.3 MiB/s와 191.5 MiB/s였습니다. peak RSS는 메모리 sink에서 170.4 MiB와 179.0 MiB, 임시 파일에서 179.5 MiB와 179.7 MiB였습니다.
다른 구현과 같은 입력을 비교한 별도 측정도 추가했습니다. 같은 환경에서 12,798개 레코드(10.28 MiB)를 실제 파일에 쓴 3회 실행의 중앙값은 WriterSyncSink 187.1 MiB/s, Java Woodstox 6.7.0 125.6 MiB/s, Rust quick-xml 0.40.1 528.4 MiB/s였습니다. 이 조건에서는 stax-xml이 Woodstox보다 빨랐지만 quick-xml보다는 느렸습니다. 입력 크기와 sink 구성이 다른 1 GiB 측정값과 하나의 순위표로 합칠 수는 없습니다. 상세 조건과 raw data는 v1.0.0 benchmark 보고서에 기록했습니다.
async API를 사용해야만 streaming output을 만들 수 있는 것은 아닙니다. async Writer는 WritableStream의 backpressure와 promise를 표현할 때 적합하고, sync sink는 호출자가 직접 제공한 write()에 bounded chunk를 동기적으로 전달합니다. 둘 다 전체 결과를 먼저 materialize하지 않는 streaming 방식이며, 차이는 backpressure와 호출 비용을 누가 관리하느냐에 있습니다.
v1의 지원 범위
reader는 기본적으로 namespace-aware mode로 동작하고 XML predefined entity 5개와 numeric character reference를 decode합니다. addEntities로 등록한 internal vocabulary도 처리하지만 document DTD를 해석하거나 external resource를 읽지는 않습니다. DTD 전체 구현과 DOM mutation API도 제공하지 않습니다.
byte 입력의 기본 encoding은 UTF-8이며 host TextDecoder가 지원하는 다른 label도 지정할 수 있습니다. decode는 fatal mode이므로 잘못된 byte sequence와 malformed XML은 오류가 됩니다. 기본 documentMode는 여러 root를 허용하는 fragment이고, root element가 정확히 하나인 XML 문서를 검사하려면 document를 지정해야 합니다. parser와 writer는 XML 1.0만 지원합니다. 여러 next()를 동시에 호출하는 사용법도 지원하지 않습니다.
이 범위는 1–5편에서 다룬 선택의 결과입니다. v1은 XML 전체 기능을 한 API에 넣는 대신 다음 세 경로를 분리합니다.
- object가 필요하면 converter가 schema에 맞춰 materialize합니다.
- 안정적인 event가 필요하면 event reader가 object를 만듭니다.
- allocation을 줄여야 하면 stream reader가 현재 token만 노출합니다.
v1에서는 애플리케이션의 결과 형태와 event 수명에 맞춰 비용을 선택할 수 있습니다. API reference와 migration guide는 stax-xml 문서에서 확인할 수 있습니다.
댓글 영역에 가까워지면 자동으로 불러옵니다.
Preparing comments...