XML을 CSV로 변환
CSV로 펼치고, 구조가 맞지 않으면 알려 줍니다.
모든 처리는 이 탭 안에서 이루어집니다. 붙여넣은 내용은 업로드되거나 기록되거나 전송되지 않습니다. 네트워크 패널을 열어 확인하세요.
레코드 목록으로 된 XML 문서를 붙여넣으면, 엑셀에서 열거나 pandas로 읽을 수 있는 쉼표 구분 표가 됩니다. 행 요소는 자동으로 찾아내고, 중첩된 자식은 address.city처럼 점으로 이어진 열 이름이 되며, 속성은 @를 앞에 붙인 열이 됩니다. 스캐너와 평탄화, CSV 쓰기는 이 탭의 웹 워커에서 도니, 아무것도 업로드되지 않고 아무것도 계정 뒤에 가둬 두지 않습니다.
위쪽은 XML만 말하고 아래쪽은 행만 말할 때 이것을 쓰세요. 상품 피드, 은행 거래내역 내보내기, 오래된 ERP에서 나온 보고서, 코드를 쓰기 전에 눈으로 훑어보고 싶은 API 응답. 사이트맵을 URL 한 열로 만드는 가장 빠른 방법이기도 합니다.
다른 점은, 표현할 수 없었던 것을 변환 쪽에서 말해 준다는 것입니다. CSV는 직사각형이고 XML은 나무이므로, 어떤 문서는 딱 맞게 평탄화되고 어떤 문서는 데이터를 잃습니다. 반복된 요소를 한 칸으로 접었을 때는 패널이 그렇게 말하고 횟수도 알려 주며, 행 요소를 추측했을 때는 무엇을 골랐는지 이름을 댑니다. 대부분의 변환기는 파일만 내주고 알아차리는 일은 여러분에게 맡깁니다.
CSV는 직사각형이고 XML은 나무다
RFC 4180은 제약을 한 줄로 적습니다. 「각 줄은 파일 전체에 걸쳐 같은 수의 필드를 담아야 한다.」 XML에는 그런 규칙이 없고, 이 어긋남이 어려움의 전부입니다. 변환이 충실한 것은 XML이 이미 표 모양일 때, 즉 반복되는 레코드가 하나 있고 그 자식들이 값 하나를 가진 잎일 때뿐입니다.
레코드가 반복되는 자식을 품을 때, 예컨대 <order>가 <line> 요소 셋을 담을 때, 변환기에는 선택이 셋 있습니다. line마다 한 행으로 펼치고 부모 열을 복제하거나, 반복을 한 칸으로 접거나, line.1.sku와 line.2.sku 같은 열을 만들어 그 개수를 가장 넓은 레코드에 맞추거나. 셋 다 무언가를 잃고, 네 번째는 없습니다. 이 도구는 두 번째를 택하고, 그것을 보고합니다.
- 딱 맞게 평탄화됨: 루트 아래 반복 요소 하나, 그 자식은 잎, 속성은 레코드나 그 잎에 붙어 있음.
- 잃으면서 평탄화됨: 레코드가 반복되는 자식을 담고 있음. 값이 이어 붙고 횟수가 보고됩니다.
- 평탄화되지 않음: 혼합 내용, 성질이 다른 형제들, 또는 중첩 자체가 요점인 문서. JSON을 쓰세요.
행 요소와 열은 어떻게 정해지는가
「행 요소」 칸을 비워 두면 루트의 자식 요소 가운데 가장 잦은 것이 행이 되며, XML에서 CSV로 가는 입력의 거의 전부에서 이것이 맞습니다. 그 판단은 숨기지 않고 찍어 줍니다. 「행은 <catalog> 아래의 <book> 요소 2개에서 가져왔습니다」. 추측이 틀렸다면 이름을 직접 적으세요.
붙여넣기 전에 알아 둘 한계가 하나 있습니다. 행 요소는 루트의 직접 자식이어야 합니다. 그래서 <catalog><books><book/><book/></books></catalog>처럼 목록을 한 단계 더 깊이 감싼 문서는 <books>에 대해 한 행만 내줍니다. 그 포장을 벗기거나, 중첩이 살아남는 JSON으로 변환하세요.
열은 모든 행에 걸친 모든 경로의 합집합이며, 처음 본 순서대로 놓입니다. 그래서 일부 레코드에만 있는 필드도 열을 얻고, 나머지는 빈 칸이 됩니다. 속성은 나타난 깊이에 놓이므로, price의 currency 속성은 price와 부딪히지 않고 price.@currency가 됩니다. 이름공간 접두사는 그대로 남고, 엔티티와 CDATA는 해석되며, 잎의 텍스트는 앞뒤 공백을 떼어 냅니다.
<catalog xmlns:dc="http://purl.org/dc/elements/1.1/">
<book id="bk101" available="true">
<dc:title>XML Developer's Guide</dc:title>
<price currency="GBP">44.95</price>
</book>
</catalog>
@id,@available,dc:title,price.@currency,price
bk101,true,XML Developer's Guide,GBP,44.95반복되는 요소, 그리고 정확히 무엇이 사라지는가
레코드가 같은 잎 요소를 두 번 이상 담을 때, 값들은 수직선으로 이어 붙어 한 칸에 들어가고 개수가 세어집니다. 칸의 이스케이프는 제대로 이루어지므로, 그 값 안의 쉼표가 열을 망가뜨리지 않습니다. 잃는 것은 경계입니다. 이미 수직선을 담은 값은 모호해지고, 구분 문자는 바꿀 수 없습니다.
이어 붙이기로 해결되지 않는 경우가 하나 있습니다. 반복되는 요소가 자기 자식을 가질 때, 출현마다 같은 점 이름의 열에 써 넣으므로 마지막 것이 이기고 앞의 것들은 아무 표시 없이 덮어써집니다. 사이트맵 <url> 안의 이미지 확장도, 각각 여러 필드를 가진 품목 줄도 모두 이렇게 행동합니다. 그런 것은 XPath로 뽑아내거나 JSON으로 변환하세요.
사이트맵, 그리고 이것의 SEO 용도
사이트맵은 레코드 목록이라 설정 없이 변환됩니다. 루트는 <urlset>, 반복되는 자식은 <url>, 그리고 loc, lastmod, changefreq, priority가 열이 되어 URL마다 한 행이 됩니다. 수정 날짜로 정렬하거나, 5만 URL 상한에 맞춰 세거나, 크롤 결과 옆에 loc 열을 붙여 한쪽에만 있는 페이지를 찾는 데는 이것으로 충분합니다. 사이트맵 색인도 같은 식으로 변환됩니다.
스프레드시트로 가져갈 사실 두 가지. 구글은 priority와 changefreq를 무시한다고 밝혀 두었으므로, 그 열들은 여러분의 CMS가 무엇을 내놓는지 점검하는 것일 뿐입니다. 그리고 구글은 lastmod를 「일관되고 검증 가능하게 정확할」 때만 쓰므로, 모든 행이 같은 타임스탬프를 가진 열이나 미래 날짜가 든 열은 구글이 버릴 법한 열입니다. 추출이 아니라 규칙 점검이 목적이라면, 상한과 changefreq 어휘와 W3C Datetime 형식을 강제하는 사이트맵 검사기를 쓰세요.
코드로 하기
같은 평탄화를 XML을 다루는 언어들에서. 위험한 쪽은 XML을 읽는 방향이므로, 모든 예제가 안전한 파서 설정을 씁니다. 외부 엔티티 끄기, DTD 불러오기 끄기, 네트워크 없음. CSV 쓰기는 명시적으로 적었습니다. 손으로 만든 내보내기가 대개 깨지는 곳이 이스케이프이기 때문입니다.
// Browser or Deno. DOMParser never resolves external entities, so XXE is
// not reachable here. In Node use @xmldom/xmldom, which also does not.
function xmlToCsv(source, rowTag) {
const doc = new DOMParser().parseFromString(source, 'application/xml');
const error = doc.querySelector('parsererror');
if (error) throw new Error(error.textContent.trim());
const root = doc.documentElement;
const rows = [...root.children].filter((el) => el.tagName === rowTag);
if (rows.length === 0) throw new Error('No <' + rowTag + '> under <' + root.tagName + '>');
const columns = [];
const records = rows.map((row) => {
const record = {};
const put = (key, value) => {
if (!columns.includes(key)) columns.push(key);
// Repeated leaves are joined. That is lossy; count it in real code.
record[key] = record[key] === undefined ? value : record[key] + '|' + value;
};
const walk = (el, prefix) => {
for (const a of el.attributes) {
put(prefix ? prefix + '.@' + a.name : '@' + a.name, a.value);
}
const kids = [...el.children];
if (kids.length === 0) {
put(prefix || el.tagName, el.textContent.trim());
return;
}
for (const k of kids) walk(k, prefix ? prefix + '.' + k.tagName : k.tagName);
};
walk(row, '');
return record;
});
// RFC 4180: quote a field containing a comma, a quote or a line break,
// and double any quote inside it.
const cell = (v) => (/[",\r\n]/.test(v) ? '"' + v.replace(/"/g, '""') + '"' : v);
const line = (values) => values.map((v) => cell(v ?? '')).join(',');
return [line(columns), ...records.map((r) => line(columns.map((c) => r[c])))].join('\r\n');
}# pip install defusedxml
# The standard library parser is not safe against entity expansion.
# defusedxml is a drop-in replacement that closes that and external entities.
import csv
import sys
from defusedxml.ElementTree import parse
def flatten(el, prefix, record, columns):
for name, value in el.attrib.items():
key = f"{prefix}.@{name}" if prefix else f"@{name}"
if key not in columns:
columns.append(key)
record[key] = value
children = list(el)
if not children:
key = prefix or el.tag
if key not in columns:
columns.append(key)
record[key] = (el.text or "").strip()
return
for child in children:
# ElementTree reports namespaced names in Clark notation,
# '{http://purl.org/dc/elements/1.1/}title', not 'dc:title'.
tag = child.tag.split("}")[-1]
key = f"{prefix}.{tag}" if prefix else tag
if key in record: # repeated leaf: join, and note the loss
record[key] += "|" + (child.text or "").strip()
else:
flatten(child, key, record, columns)
def xml_to_csv(path, row_tag, out):
root = parse(path).getroot()
columns, records = [], []
for row in root.findall(row_tag):
record = {}
flatten(row, "", record, columns)
records.append(record)
writer = csv.DictWriter(out, fieldnames=columns, restval="", extrasaction="ignore")
writer.writeheader()
writer.writerows(records)
# newline="" or csv writes \r\r\n on Windows.
# utf-8-sig writes the BOM Excel needs to read the file as UTF-8.
with open("out.csv", "w", newline="", encoding="utf-8-sig") as out:
xml_to_csv(sys.argv[1], sys.argv[2], out)import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
import javax.xml.XMLConstants;
import javax.xml.parsers.*;
import org.w3c.dom.*;
public class XmlToCsv {
static DocumentBuilder secureBuilder() throws Exception {
DocumentBuilderFactory f = DocumentBuilderFactory.newInstance();
// None of this is the default. Without it a document can read files
// off the machine running the conversion.
f.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
f.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
f.setFeature("http://xml.org/sax/features/external-general-entities", false);
f.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
f.setXIncludeAware(false);
f.setExpandEntityReferences(false);
return f.newDocumentBuilder();
}
static void flatten(Element el, String prefix, Map<String, String> rec, List<String> cols) {
NamedNodeMap attrs = el.getAttributes();
for (int i = 0; i < attrs.getLength(); i++) {
Node a = attrs.item(i);
String key = prefix.isEmpty() ? "@" + a.getNodeName() : prefix + ".@" + a.getNodeName();
if (!cols.contains(key)) cols.add(key);
rec.put(key, a.getNodeValue());
}
List<Element> kids = new ArrayList<>();
NodeList children = el.getChildNodes();
for (int i = 0; i < children.getLength(); i++) {
if (children.item(i) instanceof Element e) kids.add(e);
}
if (kids.isEmpty()) {
String key = prefix.isEmpty() ? el.getNodeName() : prefix;
if (!cols.contains(key)) cols.add(key);
rec.put(key, el.getTextContent().trim());
return;
}
for (Element k : kids) {
String key = prefix.isEmpty() ? k.getNodeName() : prefix + "." + k.getNodeName();
if (rec.containsKey(key)) {
rec.put(key, rec.get(key) + "|" + k.getTextContent().trim());
} else {
flatten(k, key, rec, cols);
}
}
}
static String cell(String v) {
if (v == null) return "";
boolean needsQuotes = v.indexOf(',') >= 0 || v.indexOf('"') >= 0
|| v.indexOf('\r') >= 0 || v.indexOf('\n') >= 0;
return needsQuotes ? '"' + v.replace("\"", "\"\"") + '"' : v;
}
public static void main(String[] args) throws Exception {
Document doc = secureBuilder().parse(new File(args[0]));
List<String> cols = new ArrayList<>();
List<Map<String, String>> recs = new ArrayList<>();
NodeList rows = doc.getDocumentElement().getElementsByTagName(args[1]);
for (int i = 0; i < rows.getLength(); i++) {
Map<String, String> rec = new LinkedHashMap<>();
flatten((Element) rows.item(i), "", rec, cols);
recs.add(rec);
}
try (PrintWriter out = new PrintWriter(new OutputStreamWriter(
new FileOutputStream("out.csv"), StandardCharsets.UTF_8))) {
out.print(String.join(",", cols.stream().map(XmlToCsv::cell).toList()) + "\r\n");
for (Map<String, String> rec : recs) {
out.print(String.join(",",
cols.stream().map(c -> cell(rec.get(c))).toList()) + "\r\n");
}
}
}
}using System.Text;
using System.Xml;
using System.Xml.Linq;
// A null XmlResolver means an external DTD or entity is never fetched.
var settings = new XmlReaderSettings
{
DtdProcessing = DtdProcessing.Prohibit,
XmlResolver = null,
MaxCharactersFromEntities = 1024 * 1024,
};
using var reader = XmlReader.Create(args[0], settings);
var doc = XDocument.Load(reader);
var rowName = args[1];
var columns = new List<string>();
var records = new List<Dictionary<string, string>>();
void Flatten(XElement el, string prefix, Dictionary<string, string> rec)
{
foreach (var a in el.Attributes())
{
if (a.IsNamespaceDeclaration) continue; // xmlns is not data
var key = prefix.Length == 0 ? "@" + a.Name.LocalName : prefix + ".@" + a.Name.LocalName;
if (!columns.Contains(key)) columns.Add(key);
rec[key] = a.Value;
}
var kids = el.Elements().ToList();
if (kids.Count == 0)
{
var key = prefix.Length == 0 ? el.Name.LocalName : prefix;
if (!columns.Contains(key)) columns.Add(key);
rec[key] = el.Value.Trim();
return;
}
foreach (var k in kids)
{
var key = prefix.Length == 0 ? k.Name.LocalName : prefix + "." + k.Name.LocalName;
if (rec.ContainsKey(key)) rec[key] += "|" + k.Value.Trim();
else Flatten(k, key, rec);
}
}
foreach (var row in doc.Root!.Elements().Where(e => e.Name.LocalName == rowName))
{
var rec = new Dictionary<string, string>();
Flatten(row, "", rec);
records.Add(rec);
}
static string Cell(string? v)
{
if (v is null) return "";
return v.IndexOfAny(new[] { ',', '"', '\r', '\n' }) >= 0
? "\"" + v.Replace("\"", "\"\"") + "\""
: v;
}
// UTF8Encoding(true) writes a BOM, which is what makes Excel on Windows
// read the file as UTF-8 rather than the system code page.
using var writer = new StreamWriter("out.csv", false, new UTF8Encoding(true));
writer.WriteLine(string.Join(",", columns.Select(Cell)));
foreach (var rec in records)
{
writer.WriteLine(string.Join(",", columns.Select(c => Cell(rec.GetValueOrDefault(c)))));
}<?php
// LIBXML_NONET blocks network access during the parse. PHP 8 does not load
// external entities by default; passing the flag keeps this correct on PHP 7
// and documents the intent.
libxml_use_internal_errors(true);
$doc = new DOMDocument();
if (!$doc->load($argv[1], LIBXML_NONET)) {
foreach (libxml_get_errors() as $e) {
fprintf(STDERR, "line %d: %s", $e->line, $e->message);
}
exit(1);
}
$rowName = $argv[2];
$columns = [];
$records = [];
$flatten = function (DOMElement $el, string $prefix, array &$rec) use (&$flatten, &$columns) {
foreach ($el->attributes as $a) {
$key = $prefix === '' ? '@' . $a->name : $prefix . '.@' . $a->name;
if (!in_array($key, $columns, true)) $columns[] = $key;
$rec[$key] = $a->value;
}
$kids = [];
foreach ($el->childNodes as $n) {
if ($n instanceof DOMElement) $kids[] = $n;
}
if (!$kids) {
$key = $prefix === '' ? $el->nodeName : $prefix;
if (!in_array($key, $columns, true)) $columns[] = $key;
$rec[$key] = trim($el->textContent);
return;
}
foreach ($kids as $k) {
$key = $prefix === '' ? $k->nodeName : $prefix . '.' . $k->nodeName;
if (array_key_exists($key, $rec)) {
$rec[$key] .= '|' . trim($k->textContent);
} else {
$flatten($k, $key, $rec);
}
}
};
foreach ($doc->documentElement->childNodes as $node) {
if ($node instanceof DOMElement && $node->nodeName === $rowName) {
$rec = [];
$flatten($node, '', $rec);
$records[] = $rec;
}
}
$out = fopen('out.csv', 'w');
fwrite($out, "\xEF\xBB\xBF"); // BOM, for Excel
fputcsv($out, $columns); // fputcsv applies RFC 4180 quoting
foreach ($records as $rec) {
fputcsv($out, array_map(fn($c) => $rec[$c] ?? '', $columns));
}
fclose($out);# xmlstarlet is the quickest route for a one-off extraction.
# --net=false stops it fetching a DTD or an external entity. Not the default.
xmlstarlet sel --net=false -t \
-o 'id,title,price' -n \
-m '/catalog/book' \
-v '@id' -o ',' \
-v 'title' -o ',' \
-v 'price' -n \
catalog.xml > out.csv
# It does no CSV quoting at all. A title containing a comma, a quote or a
# newline breaks the column alignment silently, so either be certain the
# data is clean or re-quote the output:
xmlstarlet sel --net=false -t -m '/catalog/book' \
-v 'concat(@id,",",title)' -n catalog.xml | csvformat - # csvkit
# For files too large to hold in memory, iterate instead of loading:
# python -c 'from xml.etree.ElementTree import iterparse; ...'모든 예제가 파서의 무언가를 끕니다. 안전하지 않은 쪽이 기본값인 것은 자바와 파이썬 표준 라이브러리이며, 변환이란 바로 그 파일이 내가 아니라 공급사나 고객, 또는 받은 편지함에서 온 상황입니다.
자주 묻는 질문
변환할 때 제 XML이 업로드되나요?
아닙니다. 스캐너와 평탄화, CSV 쓰기는 이 탭의 웹 워커에서 도는 자바스크립트이고, 무엇을 보낼 백엔드도 없습니다. 네트워크 패널을 열고 무언가 변환해 보세요. 페이지 자원이 한 번 불려 오고, 그 뒤로는 아무것도 없습니다.
이 점은 검증보다 변환에서 더 중요합니다. 파일의 성격이 다르기 때문입니다. 튜토리얼 조각을 CSV로 바꾸는 사람은 없습니다. 사람들이 변환하는 것은 고객 내보내기, 주문 이력, 공급사 가격표입니다. 입력은 새로 고쳐도 잃지 않도록 이 브라우저의 localStorage에 보관하며, 「지우기」로 사라집니다.
수백 행이 나와야 하는데 한 행만 나온 이유는 무엇인가요?
거의 언제나 목록이 루트보다 한 단계 아래에 있기 때문입니다. 행 요소는 루트의 직접 자식들에서 고르므로, <catalog><books><book/><book/></books></catalog>에서는 <books>라는 자식 하나를 보고 한 행을 내주며, 모든 <book>을 같은 열에 평탄화해 마지막 것이 이기게 합니다.
포장을 벗겨 반복 요소가 루트의 직접 자식이 되게 하거나, JSON으로 변환하세요. 「행 요소」 칸에 <book>이라고 적어도 도움이 되지 않습니다. 루트 아래에서 <book>을 찾지 못했다고 보고할 뿐인데, 그것은 사실입니다.
그 칸의 수직선은 무슨 뜻인가요?
CSV는 두 번 이상 나타나는 필드를 표현할 수 없으므로, 같은 잎 요소의 여러 사본을 수직선으로 이어 붙이고 그것이 몇 번 일어났는지를 패널이 보고합니다. 칸은 제대로 이스케이프되므로 그 값 안의 쉼표가 열을 망가뜨리지 않습니다. 잃는 것은 경계입니다. 이미 수직선을 담은 값은 이제 모호합니다.
반복되는 요소가 자기 자식을 가질 때는 이어 붙이기가 적용되지 않습니다. 출현마다 같은 점 이름의 열에 쓰고, 마지막 것이 나머지를 덮어씁니다. 그런 것은 XPath로 뽑아내거나, 반복이 배열이 되는 JSON을 쓰세요.
결과가 엑셀에서 제대로 열릴까요?
대개 열리지만, 변환기 탓으로 돌려지는 주의점이 둘 있습니다. 윈도우의 엑셀은 바이트 순서 표시로 시작하지 않는 맨 UTF-8 파일을 시스템 코드 페이지로 읽으며, 그러면 악센트가 든 이름이 깨집니다. 두 번 눌러 열지 말고, 「데이터」에서 「텍스트/CSV에서」를 골라 UTF-8을 지정해 가져오세요.
엑셀은 알아본 값을 다시 꾸미기도 합니다. 상품 코드 0012는 0을 잃고, 1-2는 날짜가 되고, 긴 식별자는 지수 표기가 됩니다. 가져올 때 그 열들을 「텍스트」로 지정하세요. 여러분의 로캘이 쌍반점으로 나눈다면 위의 구분 문자 조절을 바꾸세요.
얼마나 큰 파일을 변환할 수 있나요?
2천만 자까지, 대략 20 MB까지입니다. 계정도, 대기열도, 크기 등급도 없습니다. 그것을 재는 서버가 아예 없기 때문입니다. 이 분야에서 가장 눈에 띄는 무료 XML 도구는 입력을 512 KB로 제한하고, 데이터가 자기 서버에 저장된다는 확인을 요구합니다.
한계는 정책이 아니라 메모리입니다. 모든 것이 이 탭 안에 있습니다. 그 아래에서는 비용이 선형이고, 1 MB는 약 110밀리초, 10 MB는 1초를 조금 넘습니다. 그 위라면 iterparse나 SAX, xmlstarlet으로 로컬에서 스트림 처리하세요.
CSV로 변환해야 하나요, JSON으로 변환해야 하나요?
목적지가 스프레드시트이거나, 열을 기대하는 대량 가져오기이거나, 정렬하고 걸러 볼 사람이라면 CSV입니다. 데이터에 지킬 값어치가 있는 구조가 있다면 JSON입니다.
판단은 금방 됩니다. 레코드 하나를 보고, 모든 필드가 정확히 한 번 나타나고 값 하나를 지니는지 물어보세요. 그렇다면 CSV는 충실합니다. 어느 하나가 반복되거나 하위 필드를 지닌다면, 나중에 칸 안의 구분 문자에서 그 구조를 다시 세우게 됩니다.