stax-xml v1 설계 노트 5: JavaScript string이 XML parser에 만드는 한계
v0 event reader의 성능을 개선하고 v1의 pure JavaScript token core를 만들면서 저는 string 비용을 가장 오래 측정했습니다. XML 파일은 대개 UTF-8 byte로 도착하지만 JavaScript 애플리케이션은 element 이름과 text를 string으로 받습니다. 처음에는 decode 횟수를 줄이는 데 집중했고, 이후 substring allocation과 원본 string의 수명도 측정 대상에 넣었습니다.
아래에서 설명하는 V8 내부 표현은 구현 세부 사항이라 버전에 따라 달라질 수 있습니다.
명세의 string과 runtime의 string
ECMAScript에서 String 값은 16-bit unsigned integer 값의 불변 sequence로 정의됩니다. 그래서 length는 Unicode code point 수가 아니라 UTF-16 code unit 수를 셉니다. 보조 평면 문자는 surrogate pair 두 칸을 차지합니다.
XML parser 관점에서 이 정의에는 두 가지 제약이 있습니다.
첫째, JavaScript에는 일반 애플리케이션이 사용할 수 있는 mutable string buffer나 char[] view가 없습니다. Uint8Array.subarray()는 같은 ArrayBuffer를 가리키는 zero-copy view를 만들 수 있지만 그 값의 type은 여전히 byte array입니다. JavaScript의 string primitive로 사용할 수 없고, string API에 넘기려면 TextDecoder.decode()처럼 byte를 string으로 바꾸는 경계를 지나야 합니다. TextDecoder는 byte array를 입력받아 새 JavaScript string 값을 반환하도록 정의되어 있습니다.
Uint16Array도 해결책은 아닙니다. 저장 단위가 UTF-16 code unit과 같아 보이더라도 이것은 mutable numeric view일 뿐 String 값이 아닙니다. String.fromCharCode() 등으로 변환해야 하며, 그 결과는 원래 typed array와 독립된 불변 String 값으로 관찰됩니다. ECMAScript에는 다음과 같은 borrowed string type을 표현할 방법이 없습니다.
// JavaScript에는 이런 public type과 lifetime 계약이 없다.
type BorrowedString = {
buffer: Uint8Array;
start: number;
end: number;
encoding: 'utf-8';
};
이 구조체는 string이 아니므로 public API로 반환해도 ===, Map<string, ...>, 정규식, JSON 직렬화, DOM API에 바로 사용할 수 없습니다. .toString()을 호출하면 decode와 materialization 비용도 다시 발생합니다.
둘째, 명세가 표현하는 이론적 범위와 실제 runtime이 허용하는 한 문자열의 크기는 다릅니다. V8의 String::kMaxLength는 object layout과 정수 표현 때문에 구현상 최대 길이를 둡니다. 정확한 수치는 architecture와 V8 버전에 종속되므로 애플리케이션 계약으로 삼을 수 없습니다. 수 GB XML 전체를 먼저 한 문자열로 만들면 메모리뿐 아니라 runtime의 단일 string 한계에도 닿을 수 있습니다.
runtime의 string view를 API에서 선택할 수 없는 이유
ECMAScript는 String을 16-bit code unit의 sequence로 정의하지만 engine이 모든 문자를 항상 2 byte로 저장하는 것은 아닙니다.
V8은 내용에 따라 one-byte와 two-byte 표현을 사용합니다. V8의 설명처럼 ASCII 중심 string은 one-byte representation에 들어갈 수 있고 더 넓은 문자가 필요한 string은 two-byte representation을 사용합니다. 연결한 string을 즉시 평탄한 배열로 복사하지 않고 ConsString 같은 구조로 유지했다가 접근 시 flatten할 수도 있습니다.
다른 주요 engine도 같은 명세를 서로 다른 내부 구조로 최적화합니다. SpiderMonkey에는 Latin-1/two-byte linear string과 rope, dependent string 계열이 있고, JavaScriptCore는 8-bit/16-bit StringImpl과 substring 공유 같은 표현을 사용합니다. 이름과 임계값은 서로 다르고 계속 바뀝니다. 모두 engine의 GC가 관리하는 내부 object이며, 애플리케이션이 Uint8Array의 임의 범위를 내부 string view로 승격하거나 lifetime을 지정하는 표준 API는 없습니다.
V8의 SlicedString은 substring을 parent pointer, offset, length로 표현할 수 있습니다. character payload 복사는 피할 수 있지만 slice object allocation은 남고, 작은 substring이 큰 parent의 수명을 연장할 수도 있습니다. 현재 substring 경로는 길이와 runtime flag에 따라 copy와 slice를 선택하므로 어느 쪽도 JavaScript API의 보장으로 사용할 수 없습니다. Rust quick-xml의 lifetime 기반 borrow와 이 차이는 앞선 native addon 글에서 자세히 다뤘습니다.
parser에서는 다음 동작을 보장할 수 없습니다.
- 어떤 substring이 원본 저장소를 공유할지 복사될지는 engine 판단입니다.
- rope가 언제 flatten되어 큰 allocation을 만들지 애플리케이션이 통제할 수 없습니다.
- ASCII 문서라도 entity decode 결과나 비 ASCII text 때문에 표현이 달라질 수 있습니다.
- string을 object property와 event에 보관하면 해당 값의 수명도 길어집니다.
따라서 benchmark에서 heapUsed만 보고 “substring은 zero-copy일 것”이라고 가정할 수 없습니다. payload copy를 피한 경우에도 slice object allocation과 parent retention은 남을 수 있습니다.
chunk decode에도 경계가 있다
UTF-8 문자는 1–4 byte인데 network chunk는 문자 경계를 보장하지 않습니다. 마지막 byte 몇 개가 다음 chunk로 넘어갈 수 있습니다. 각 chunk에 독립적으로 decode()를 호출하면 정상 문자를 replacement character로 바꾸거나 오류로 처리할 수 있습니다.
streaming decoder는 이전 chunk 끝의 미완성 sequence를 보관해야 합니다. stax-xml v1 byte 입력의 기본 encoding은 UTF-8이고, 다른 encoding은 host TextDecoder가 지원하는 label로 지정할 수 있습니다. 어느 경우든 fatal decode를 사용해 잘못된 입력을 조용히 대체하지 않고 오류로 만듭니다. 동시에 작은 입력 chunk가 지나치게 많은 parser 호출을 만들지 않도록 내부 batch로 묶습니다.
XML token도 chunk 경계를 가로지를 수 있습니다.
<very-long-element attribute="value">text</very-long-element>
<very-long- 다음에 chunk가 끝나도 parser는 token이 완성될 때까지 필요한 부분을 보존해야 합니다. 반대로 이미 소비한 prefix는 계속 버려야 메모리가 전체 문서 크기에 비례해 커지지 않습니다.
span을 먼저 기록하고 string은 나중에 만든다
v1 parser core는 현재 buffer에서 token의 시작과 끝 위치를 먼저 기록합니다. element 이름, text, attribute value를 무조건 즉시 string과 object로 만들지 않습니다. StreamReader accessor가 값을 요청할 때 해당 span을 slice하고 필요한 entity decode를 수행합니다. 소비한 buffer prefix는 안전한 시점에 compact합니다.
저는 이 비용을 필요한 accessor를 호출할 때까지 미루기 위해 current-token API를 만들었습니다.
while (await reader.next() !== null) {
if (reader.eventType() !== XmlEventType.START_ELEMENT) continue;
// name만 필요하면 모든 attribute object를 만들 필요가 없다.
if (reader.localName() === 'record') {
const id = reader.attributeValue('id');
consume(id);
}
}
이 구조도 zero allocation은 아닙니다. 입력이 byte stream이면 JavaScript buffer string으로 decode해야 하고, localName()이나 attributeValue()가 String primitive를 반환할 때 runtime은 복사된 string 또는 slice string 같은 표현을 만듭니다. slice string도 allocation과 parent lifetime 문제는 남습니다. 저는 필요한 값만 늦게 만들고, 작은 결과 때문에 큰 buffer가 오래 남지 않도록 소비한 parser buffer를 compact하는 데 범위를 한정했습니다.
EventReader는 각 event를 안정적인 JavaScript object로 materialize합니다. 보관하고 전달하기 쉬운 대신 이름, 속성, event object를 할당합니다. 저는 이 비용 차이를 StreamReader와 EventReader라는 별도 API로 드러냈습니다.
큰 단일 string 입력과 parent retention
이 문제는 stax-xml이 string 입력도 받는다는 사실과 직접 연결됩니다. parser가 수백 MB짜리 원본 string에서 element 이름과 text를 slice()하고, 애플리케이션이 그 결과를 오래 보관한다고 합시다. V8이 이를 SlicedString으로 구현하면 작은 event 값이 큰 parser buffer의 parent reference를 붙잡을 수 있습니다. parser가 자기 buffer 참조를 버려도 event가 가진 slice 때문에 원본은 GC되지 않을 수 있습니다.
parser 내부에서 buffer = buffer.slice(consumed)처럼 앞부분을 잘라 내는 것만으로도 이 문제가 반드시 해결되지는 않습니다. 그 새 buffer 자체가 이전 string의 slice일 수 있기 때문입니다. 반대로 강제로 독립 string을 만들면 parent retention은 끊을 수 있지만 character copy와 allocation을 지불해야 합니다. JavaScript에는 “이 substring은 반드시 copy하라” 또는 “반드시 view로 만들어라”라고 runtime에 요구하는 표준 API도 없습니다.
그래서 아주 큰 XML에는 하나의 거대한 JavaScript string보다 byte stream 입력이 더 안전합니다. 작은 batch를 TextDecoder로 순차 decode하면 slice가 parent를 붙잡더라도 보존 범위가 전체 문서가 아니라 활성 decode batch에 가깝게 제한됩니다. 이것도 무조건 상수 메모리라는 보장은 아닙니다. 하나의 거대한 text token, 비정상적으로 큰 입력 chunk, event의 장기 보관은 여전히 메모리를 키울 수 있습니다. 다만 “작은 결과 하나가 전체 문서 string을 살려 두는” 최악의 수명 결합을 피할 수 있습니다.
v1에서 정한 bounded-memory 범위
streaming parser도 무한한 element 이름이나 하나의 거대한 text node를 상수 메모리로 string 반환할 수는 없습니다. 소비자가 2GB text 하나를 완전한 JavaScript string으로 요구한다면 그 값 자체의 비용과 runtime 한계는 피할 수 없습니다.
- 문서 전체를 하나의 string으로 만들지 않는다.
- 이미 소비한 byte와 text를 계속 보관하지 않는다.
- 관심 없는 token의 JavaScript 값을 만들지 않는다.
- 이벤트를 즉시 소비하면 메모리가 문서 전체 크기가 아니라 활성 buffer와 현재 token에 가깝게 유지되도록 한다.
JavaScript에서도 XML streaming은 구현할 수 있지만 C나 Java의 mutable character buffer와 같은 비용 모델을 만들 수는 없습니다. 그래서 v1은 byte stream, span, current token, materialized event를 분리하고 각 단계의 allocation을 API에서 선택할 수 있게 했습니다.
댓글 영역에 가까워지면 자동으로 불러옵니다.
Preparing comments...