junyeokk
Blog
RSS Feed·2025. 07. 22

fast-xml-parser

XML을 파싱해야 하는 상황은 꽤 자주 생긴다. RSS 피드를 읽거나, 외부 API 응답이 XML로 오거나, 설정 파일이 XML인 경우. Node.js에서 XML을 다루는 방법은 크게 두 갈래로 나뉜다. DOM 파서와 스트리밍 파서.

DOM 파서(xml2js 같은)는 XML 전체를 메모리에 트리 구조로 올린 뒤 탐색한다. 직관적이지만 큰 파일에서는 메모리를 많이 먹는다. 스트리밍 파서(sax 같은)는 XML을 한 줄씩 읽으면서 이벤트를 발생시킨다. 메모리 효율은 좋지만 코드가 복잡해진다.

fast-xml-parser는 이 두 가지의 장점을 절충한다. XML 문자열을 받아서 JavaScript 객체로 직접 변환한다. 중간에 DOM 트리를 만들지 않고, 정규표현식 기반도 아닌 자체 파싱 로직으로 동작한다. 그래서 빠르면서도 사용법이 단순하다.

npm install fast-xml-parser

기본 사용법

가장 기본적인 사용은 XMLParser 클래스로 XML 문자열을 파싱하는 것이다.

typescript
npm install fast-xml-parser

parse() 메서드 하나로 끝난다. 반환값은 일반 JavaScript 객체이므로 . 접근자로 바로 사용할 수 있다. 숫자 값은 자동으로 number 타입으로 변환된다.


속성(Attribute) 파싱

XML의 핵심 특성 중 하나가 태그에 속성을 붙일 수 있다는 점이다. 기본 설정에서는 속성이 무시된다. 속성을 읽으려면 ignoreAttributes 옵션을 false로 설정해야 한다.

typescript
import { XMLParser } from 'fast-xml-parser';

const parser = new XMLParser();

const xml = `
<bookstore>
  <book>
    <title>Clean Code</title>
    <author>Robert C. Martin</author>
    <price>33.99</price>
  </book>
  <book>
    <title>Refactoring</title>
    <author>Martin Fowler</author>
    <price>47.99</price>
  </book>
</bookstore>
`;

const result = parser.parse(xml);
console.log(result.bookstore.book);
// [
//   { title: 'Clean Code', author: 'Robert C. Martin', price: 33.99 },
//   { title: 'Refactoring', author: 'Martin Fowler', price: 47.99 }
// ]

속성은 기본적으로 @_ 접두사가 붙어서 자식 요소와 구분된다. 이 접두사는 attributeNamePrefix 옵션으로 변경할 수 있다.

typescript
const parser = new XMLParser({
  ignoreAttributes: false,
});

const xml = `
<feed xmlns:atom="http://www.w3.org/2005/Atom">
  <entry id="1" published="true">
    <title>First Post</title>
    <link href="https://example.com/post/1" rel="alternate" />
  </entry>
</feed>
`;

const result = parser.parse(xml);
console.log(result.feed.entry);
// {
//   '@_id': 1,
//   '@_published': true,
//   title: 'First Post',
//   link: { '@_href': 'https://example.com/post/1', '@_rel': 'alternate' }
// }

접두사를 빈 문자열('')로 설정하면 속성과 자식 요소의 키가 같을 때 충돌이 발생할 수 있으니 주의해야 한다.


주요 옵션 상세

fast-xml-parser의 진짜 강점은 옵션의 세밀함에 있다. 실제로 XML을 다루다 보면 기본 동작으로는 부족한 경우가 많은데, 옵션 하나하나가 실무적인 문제를 해결한다.

parseAttributeValue

기본적으로 속성값은 문자열로 파싱된다. 이 옵션을 true로 설정하면 숫자, boolean 등을 자동으로 적절한 타입으로 변환한다.

typescript
const parser = new XMLParser({
  ignoreAttributes: false,
  attributeNamePrefix: 'attr_',
});
// 결과: { 'attr_id': 1, 'attr_published': true, ... }

타입 변환 없이 항상 문자열로 받고 싶다면 false(기본값)로 두면 된다. RSS 피드처럼 속성값에 숫자가 섞여 있을 때 유용하다.

trimValues

XML에서 태그 사이의 공백은 의미가 애매하다. trimValues: true로 설정하면 텍스트 값의 앞뒤 공백을 제거한다.

typescript
const parser = new XMLParser({
  ignoreAttributes: false,
  parseAttributeValue: true,
});

const xml = `<item count="42" active="true" ratio="3.14" />`;
const result = parser.parse(xml);
// { item: { '@_count': 42, '@_active': true, '@_ratio': 3.14 } }

RSS 피드를 파싱할 때 거의 필수적으로 켜야 하는 옵션이다. 피드 제공자마다 공백 처리가 제각각이기 때문이다.

isArray

XML에서 가장 골치 아픈 문제 중 하나가 배열 처리다. 같은 이름의 자식 요소가 여러 개면 배열로 파싱되지만, 하나만 있으면 객체로 파싱된다. 이러면 코드에서 매번 Array.isArray()로 체크해야 한다.

typescript
const parser = new XMLParser({ trimValues: true });

const xml = `<title>  Hello World  </title>`;
const result = parser.parse(xml);
// { title: 'Hello World' }

isArray 옵션으로 특정 태그를 항상 배열로 처리하게 강제할 수 있다.

typescript
// 요소가 2개일 때
const xml1 = `<root><item>A</item><item>B</item></root>`;
// result.root.item → ['A', 'B'] (배열)

// 요소가 1개일 때
const xml2 = `<root><item>A</item></root>`;
// result.root.item → 'A' (문자열!)

jpath는 현재 태그의 전체 경로(예: root.channel.item)를 나타낸다. 같은 이름이지만 위치에 따라 다르게 처리해야 할 때 유용하다.

tagValueProcessor / attributeValueProcessor

파싱 과정에서 값을 변환해야 할 때 사용한다. 모든 값에 대해 호출되는 콜백 함수다.

typescript
const parser = new XMLParser({
  isArray: (name, jpath, isLeafNode, isAttribute) => {
    const alwaysArray = ['item', 'entry', 'link'];
    return alwaysArray.includes(name);
  },
});

// 이제 요소가 1개여도 항상 배열
// result.root.item → ['A']

stopNodes

특정 노드의 자식은 파싱하지 않고 원본 XML 문자열 그대로 보존하고 싶을 때 사용한다. HTML이 포함된 RSS <description> 태그를 처리할 때 특히 유용하다.

typescript
const parser = new XMLParser({
  tagValueProcessor: (tagName, tagValue, jpath, hasAttributes, isLeafNode) => {
    // CDATA나 특수문자가 포함된 값을 정리
    if (typeof tagValue === 'string') {
      return tagValue.replace(/&amp;/g, '&').replace(/&lt;/g, '<');
    }
    return tagValue;
  },
});

*는 와일드카드로, 어떤 경로에 있든 해당 이름의 태그를 매칭한다.


CDATA 처리

CDATA 섹션은 XML에서 특수문자를 이스케이프 없이 포함하기 위한 구문이다. RSS 피드에서 HTML 콘텐츠를 담을 때 흔히 사용된다.

xml
const parser = new XMLParser({
  stopNodes: ['*.description', '*.content:encoded'],
});

const xml = `
<item>
  <title>My Post</title>
  <description><![CDATA[<p>Hello <strong>World</strong></p>]]></description>
</item>
`;

const result = parser.parse(xml);
// result.item.description → '<p>Hello <strong>World</strong></p>'
// HTML이 파싱되지 않고 문자열로 보존됨

fast-xml-parser는 기본적으로 CDATA를 자동 처리해서 텍스트 내용만 추출한다. CDATA임을 구분하고 싶다면 processEntitiescdataPropName 옵션을 사용한다.

typescript
<description><![CDATA[<p>Price: $10 & free shipping</p>]]></description>

대부분의 경우 기본 동작으로 충분하다. CDATA인지 일반 텍스트인지 구분해야 할 특수한 경우에만 cdataPropName을 설정하면 된다.


XMLBuilder: 객체 → XML 변환

파싱의 반대 방향도 지원한다. JavaScript 객체를 XML 문자열로 변환하는 XMLBuilder 클래스가 있다.

typescript
const parser = new XMLParser({
  cdataPropName: '__cdata',
});

// result.item.description.__cdata → '<p>Price: $10 & free shipping</p>'

XMLParserXMLBuilder의 옵션을 동일하게 맞추면 XML → 객체 → XML 라운드트립이 가능하다. 원본과 완전히 동일하진 않을 수 있지만(공백, 속성 순서 등) 의미적으로 동일한 XML을 생성한다.


RSS/Atom 피드 파싱 실전 패턴

fast-xml-parser가 가장 많이 사용되는 영역 중 하나가 RSS/Atom 피드 파싱이다. 실제로 피드를 파싱할 때 자주 만나는 패턴과 주의점을 정리한다.

RSS 2.0 파싱

typescript
import { XMLBuilder } from 'fast-xml-parser';

const builder = new XMLBuilder({
  ignoreAttributes: false,
  format: true,           // 들여쓰기 적용
  indentBy: '  ',         // 2칸 들여쓰기
  suppressEmptyNode: true, // 빈 노드를 self-closing으로
});

const obj = {
  rss: {
    '@_version': '2.0',
    channel: {
      title: 'My Blog',
      link: 'https://example.com',
      item: [
        { title: 'Post 1', link: 'https://example.com/1' },
        { title: 'Post 2', link: 'https://example.com/2' },
      ],
    },
  },
};

const xml = builder.build(obj);
console.log(xml);
// <rss version="2.0">
//   <channel>
//     <title>My Blog</title>
//     <link>https://example.com</link>
//     <item>
//       <title>Post 1</title>
//       <link>https://example.com/1</link>
//     </item>
//     <item>
//       <title>Post 2</title>
//       <link>https://example.com/2</link>
//     </item>
//   </channel>
// </rss>

itemcategoryisArray로 강제하는 게 핵심이다. 피드에 글이 1개만 있으면 배열이 아닌 객체로 파싱되기 때문이다.

Atom 1.0 파싱

Atom 피드는 RSS와 구조가 다르다. 특히 link 태그가 속성 기반이라는 점이 다르다.

typescript
const parser = new XMLParser({
  ignoreAttributes: false,
  attributeNamePrefix: '@_',
  parseAttributeValue: true,
  trimValues: true,
  isArray: (name) => ['item', 'category'].includes(name),
});

const result = parser.parse(xmlString);
const channel = result.rss?.channel;

if (channel) {
  const items = channel.item || [];
  items.forEach((item) => {
    console.log(item.title);
    console.log(item.link);
    console.log(item.pubDate);
    console.log(item.description);
  });
}

Atom에서는 <link href="..." rel="alternate" />처럼 속성으로 URL을 전달하므로, ignoreAttributes: false가 반드시 필요하다.

피드 포맷 자동 감지

실제 서비스에서는 다양한 블로그의 피드를 받기 때문에 RSS인지 Atom인지 미리 알 수 없다. 파싱 결과의 루트 키로 판별할 수 있다.

typescript
const result = parser.parse(xmlString);
const feed = result.feed;

if (feed) {
  const entries = Array.isArray(feed.entry) ? feed.entry : [feed.entry].filter(Boolean);
  
  entries.forEach((entry) => {
    console.log(entry.title);
    
    // Atom의 link는 href 속성에 URL이 들어있다
    const links = Array.isArray(entry.link) ? entry.link : [entry.link];
    const alternateLink = links.find((l) => l['@_rel'] === 'alternate');
    console.log(alternateLink?.['@_href']);
    
    console.log(entry.published || entry.updated);
  });
}

이 패턴을 확장하면 RSS 1.0(RDF 기반)이나 다른 포맷도 처리할 수 있다. 파서를 추상 클래스로 만들고 포맷별 구현체를 두는 전략 패턴과 잘 어울린다.


네임스페이스 처리

XML 네임스페이스는 태그 이름 충돌을 방지하는 메커니즘이다. RSS/Atom 피드에서는 확장 모듈에서 네임스페이스를 자주 사용한다.

xml
function detectFeedType(xmlString: string): 'rss' | 'atom' | 'unknown' {
  const parser = new XMLParser();
  const result = parser.parse(xmlString);
  
  if (result.rss) return 'rss';
  if (result.feed) return 'atom';
  return 'unknown';
}

fast-xml-parser는 기본적으로 네임스페이스 접두사를 포함한 태그 이름을 그대로 사용한다.

typescript
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <item>
      <title>Hello</title>
      <dc:creator>Author Name</dc:creator>
    </item>
  </channel>
</rss>

removeNSPrefix: true 옵션을 사용하면 네임스페이스 접두사를 제거할 수 있다.

typescript
const result = parser.parse(xml);
console.log(result.rss.channel.item['dc:creator']); // 'Author Name'

다만 접두사를 제거하면 서로 다른 네임스페이스의 같은 이름 태그가 충돌할 수 있으므로, 어떤 네임스페이스가 사용되는지 파악한 후에 설정해야 한다.


성능 비교

fast-xml-parser라는 이름답게 성능이 핵심 장점이다. 주요 XML 파서들과의 비교:

파서특징성능
fast-xml-parser순수 JS, 직접 객체 변환빠름
xml2jsDOM 기반, 콜백/프로미스느림
libxmljsC++ 네이티브 바인딩매우 빠름 (설치 복잡)
sax스트리밍, 이벤트 기반메모리 효율적

fast-xml-parser가 빠른 이유는:

  1. 중간 표현 없음: XML → DOM → 객체가 아니라 XML → 객체로 직접 변환
  2. 정규표현식 미사용: 문자 단위 파싱으로 정규표현식 오버헤드 없음
  3. 순수 JavaScript: 네이티브 바인딩이 필요 없어서 설치가 간단하고 어디서든 동작

일반적인 RSS 피드(수 KB ~ 수십 KB) 수준에서는 어떤 파서를 쓰든 체감 차이가 거의 없다. 하지만 수천 개의 피드를 주기적으로 파싱하는 크롤러에서는 파서 성능이 전체 처리 시간에 유의미한 영향을 준다.


XMLValidator

파싱 전에 XML이 올바른 형식인지 검증할 수 있다. 외부에서 받은 XML은 항상 깨져 있을 가능성이 있으므로 검증 단계를 거치는 것이 안전하다.

typescript
const parser = new XMLParser({ removeNSPrefix: true });
const result = parser.parse(xml);
console.log(result.rss.channel.item.creator); // 'Author Name'

validate()는 Well-formed XML인지만 검사한다. XML Schema(XSD)나 DTD 검증은 지원하지 않는다. RSS 피드 크롤링에서 네트워크 에러로 HTML 에러 페이지를 받거나, 잘못된 인코딩으로 XML이 깨지는 경우를 사전에 걸러낼 수 있다.


자주 겪는 함정

단일 요소의 배열/객체 문제

위에서 isArray로 해결하는 방법을 설명했지만, 이걸 모르고 넘어가면 런타임 에러가 발생한다.

typescript
import { XMLValidator } from 'fast-xml-parser';

const result = XMLValidator.validate(xmlString);

if (result === true) {
  // 유효한 XML
  const parsed = parser.parse(xmlString);
} else {
  // result.err에 에러 정보가 담김
  console.error(`Invalid XML: ${result.err.msg} at line ${result.err.line}`);
}

isArray 옵션을 설정하는 게 가장 깔끔하지만, 설정을 빠뜨렸을 때를 대비한 방어 코드도 함께 쓰는 것이 좋다.

숫자 자동 변환

기본적으로 태그의 텍스트 값이 숫자로 파싱 가능하면 자동으로 number 타입으로 변환된다. 전화번호나 우편번호처럼 앞에 0이 붙는 값은 숫자로 변환되면 0이 사라진다.

typescript
// 위험한 코드
items.forEach((item) => { ... }); // items가 배열이 아닐 수 있음!

// 안전한 코드
const items = Array.isArray(channel.item) ? channel.item : [channel.item].filter(Boolean);

빈 태그 처리

xml
const xml = `<phone>01012345678</phone>`;
// 기본 파싱 결과: { phone: 1012345678 } — 앞의 0이 사라짐!

// 해결: parseTagValue를 false로
const parser = new XMLParser({ parseTagValue: false });
// 결과: { phone: '01012345678' }

빈 태그는 기본적으로 빈 문자열('')로 파싱된다. self-closing 태그도 마찬가지다. undefinednull을 기대하고 코드를 작성하면 놓칠 수 있다.


관련 문서