JSON ↔ YAML과 XML 변환의 경계
핵심 요약: JSON이 YAML의 부분 집합이라는 주장은 한 방향만 보장하며, XML에는 공식 JSON 매핑이 전혀 없어 모든 것이 관례에 달려 있습니다.

핵심부터 말하면, JSON을 중간 형식으로 쓰세요. 소스를 데이터 모델로 파싱한 뒤 대상으로 직렬화합니다. 변환 전에 주석, XML 속성과 네임스페이스, 숫자와 불리언 타입이 살아남아야 하는지 결정하세요.
변환 파이프라인이 쓸 만한지는 출력이 얼마나 예쁜지가 아니라, 무엇을 얼마나 버렸는지 인지하고 있는지에 달려 있습니다.
1. JSON과 YAML: 단방향 포함
YAML 1.2 명세는 분명히 말합니다. JSON은 YAML의 부분 집합입니다. 즉 유효한 JSON은 모두 유효한 YAML입니다:
{"name": "json", "tags": ["a", "b"], "count": 3}
이 조각은 JSON이기도 하고 YAML 플로 스타일이기도 합니다. 반대는 성립하지 않습니다.
| 기능 | JSON | YAML 1.2 |
|---|---|---|
| 주석 | ✗ | ✓ |
| 앵커와 별칭 | ✗ | ✓ |
여러 문서(---) |
✗ | ✓ |
| 문자열이 아닌 키 | ✗ | ✓ (1: a, true: b) |
| 복합 키 | ✗ | ✓ (? [a, b]) |
| 들여쓰기 민감 | ✗ | ✓ (탭 들여쓰기는 거부됨) |
| 타입 추론 | 명시적 | 암묵적(0o17, 0x1F, .inf) |
| 단일 줄 가독성 | 좋음 | 나쁨(긴 내용은 폴딩 문법에 의존) |
1.1 타입 추론이 가장 큰 함정
YAML은 문자열처럼 보이는 일부 값을 다른 타입으로 파싱합니다:
zip: 01234 # string or octal integer? the YAML 1.2 core schema treats it as a string
version: 1.10 # 1.1 (a float); the semantics are lost
country: NO # a string in YAML 1.2; boolean false in YAML 1.1
date: 2026-10-30 # some implementations parse it as a date object
NO 사례는 많은 사람을 태웠습니다. YAML 1.1 파서(일부 오래된 라이브러리)에서는 country: NO가 false가 됩니다.
대응책: "숫자나 불리언처럼 보이는" 모든 식별자에 따옴표를 붙이세요. 예: version: "1.10".
1.2 앵커, 별칭, 여러 문서
defaults: &defaults
retries: 3
timeout: 30
serviceA:
<<: *defaults
host: a.example.com
---
serviceB:
<<: *defaults
host: b.example.com
앵커를 확장해 JSON으로 변환하면 대개 중복된 설정 객체 두 개가 되거나, 하나의 배열이 됩니다(여러 문서는 [{...}, {...}]가 되어야 함).
어느 쪽이든 역변환에서 앵커는 복원되지 않습니다. 이 손실은 불가피합니다.
1.3 주석을 보존하는 변환
대부분 라이브러리는 변환 중에 주석을 그냥 버립니다. 주석을 유지해야 할 때는 yaml 패키지의 Document API를 쓰세요:
import { parseDocument } from 'yaml';
const document = parseDocument(`
# Service configuration
port: 8080
`);
document.set('port', 3000);
console.log(document.toString());
// # Service configuration
// port: 3000
검사는 간단합니다. 이 파일을 사람이 계속 편집할 것이라면 "파싱 → 객체 → 직렬화" 경로를 타지 마세요.
1.4 왕복 일관성을 검증하는 방법
/**
* JSON → YAML → JSON의 왕복 일관성을 검증합니다.
* @param sourceValue 원본 JSON 값
* @param stringify YAML 직렬화 함수
* @param parse YAML 파싱 함수
* @returns 두 결과가 정확히 같은지 여부
*/
function isRoundTripStable(
sourceValue: unknown,
stringify: (input: unknown) => string,
parse: (input: string) => unknown,
): boolean {
const yamlText = stringify(sourceValue);
const restored = parse(yamlText);
return JSON.stringify(restored) === JSON.stringify(sourceValue);
}
실제 데이터에 돌려 보는 것이 문서를 열 번 읽는 것보다 값어치가 큽니다. 샘플 집합이 다음을 덮게 하세요. null, 빈 문자열, 빈 배열, "00", "1e5", 음의 0, 2^53을 넘는 정수, 줄 바꿈이 포함된 긴 문자열.
2. JSON과 XML: 표준 매핑이 없음
XML은 JSON보다 오래되었고 구조 능력(속성, 네임스페이스, 혼합 콘텐츠, DTD)이 훨씬 강합니다. 둘 사이에는 공식 매핑 명세가 없습니다. 모든 도구가 같은 일을 합니다. 관례를 고르고, 많이 잃지 않도록 노력합니다.
2.1 흔한 관례
| 관례 | 표현 | 예 |
|---|---|---|
| 요소 → 객체 키 | 같은 이름의 요소가 필드가 됨 | <a>1</a> → {"a": "1"} |
속성 → @ 접두사 |
@name |
<a id="1"/> → {"a": {"@id": "1"}} |
텍스트 → #text |
속성과 텍스트가 모두 있을 때 | <a id="1">hi</a> → {"a": {"@id": "1", "#text": "hi"}} |
| 반복 요소 → 배열 | 두 번 이상 나타나면 배열 | <i>1</i><i>2</i> → {"i": ["1","2"]} |
| 네임스페이스 → 접두사 | ns:tag |
<ns:tag/> → {"ns:tag": ...} |
<order id="A1001">
<item sku="X1" qty="2">笔记本</item>
<item sku="X2" qty="1">钢笔</item>
</order>
{
"order": {
"@id": "A1001",
"item": [
{ "@sku": "X1", "@qty": "2", "#text": "笔记本" },
{ "@sku": "X2", "@qty": "1", "#text": "钢笔" }
]
}
}
2.2 불가피한 다섯 가지 손실
- 요소 순서: JSON 객체는 순서가 없고, XML은 요소의 정렬된 시퀀스입니다.
- 속성 대 자식 요소: XML에서는 서로 다른 의미를 지니지만 JSON에서는 모두 평범한 필드가 됩니다.
- 혼합 콘텐츠:
<p>你好 <b>世界</b> 再见</p>같은 구조는 평범한 객체로 표현할 수 없습니다. - 네임스페이스 선언:
xmlns는 스코프 개념이며, 필드로 평탄화되면 스코프가 사라집니다. - CDATA와 엔티티:
<![CDATA[...]]>와&는 JSON에서 텍스트일 뿐입니다.
2.3 "하나의 요소인가 배열인가"의 딜레마
<list><item>1</item></list>
item이 하나면 xml2js는 기본적으로 객체를 반환하고, 두 개가 되어야 배열을 반환합니다.
그래서 프런트엔드 코드는 Array.isArray(x) ? x : [x]를 써야 합니다.
대응책: 파싱할 때 "항상 배열" 옵션을 켜거나, 스키마를 근거로 필드를 배열 타입으로 선언하세요.
import { parseStringPromise } from 'xml2js';
const parsed = await parseStringPromise(xmlText, {
explicitArray: true, // return an array even for a single element
trim: true,
explicitRoot: false,
});
2.4 보안 알림
신뢰할 수 없는 XML을 파싱하기 전에 외부 엔티티를 반드시 비활성화해야 합니다. 그렇지 않으면 XXE(로컬 파일 읽기, 내부 네트워크 탐색)를 유발할 수 있습니다:
// In the Node ecosystem, DTDs and external entities are usually disabled through parser options
const parserOptions = { doctype: false, externalEntities: false };
XML → JSON은 변환의 첫 계층일 뿐입니다. 변환이 XML이 가져오는 공격 표면을 없애지는 않습니다.
3. 세 형식이 각각 어울리는 곳
| 시나리오 | 권장 형식 | 이유 |
|---|---|---|
| 공개 HTTP API | JSON | 브라우저와 모든 언어의 네이티브 지원 |
| 사람이 관리하는 설정 | YAML | 주석, 들여쓰기, 가독성 |
| 강한 스키마와 네임스페이스가 필요한 문서(SOAP, Office Open XML, SVG) | XML | 구조 능력과 기존 생태계 |
| 운영 도구(K8s, CI)와 상호작용 | YAML | 생태계 기본값 |
| 영속화, 캐시, 로그 | JSON / JSONL | 보편적이고 의존성 없음 |
4. 구현 노트
import { stringify as toYaml } from 'yaml';
/**
* 중국어 텍스트를 이스케이프하지 않은 상태로 JSON 값을 YAML 텍스트로 변환합니다.
* @param value 변환할 JSON 값
* @returns YAML 텍스트
*/
function jsonToYaml(value: unknown): string {
return toYaml(value, {
indent: 2,
lineWidth: 0, // disable automatic wrapping so long strings are not split
defaultStringType: 'QUOTE_DOUBLE',
});
}
실용적인 옵션 몇 가지:
lineWidth: 0은 자동 줄 바꿈을 끕니다. 그렇지 않으면 긴 URL이 여러 줄로 나뉘어 읽기 어려워집니다.- 모든 값에 큰따옴표를 붙이면
NO,1.10같은 값이 잘못 읽히지 않습니다. - 숫자 정밀도가 민감한 데이터는 직렬화 전에 문자열로 변환하세요.
XML 방향에서는 하나의 매핑 함수를 고정하고 "속성 접두사", "텍스트 키 이름", "배열 전략"을 함수 서명에 넣어 흩어지지 않게 하세요:
type XmlMappingOptions = {
attributePrefix: string;
textKey: string;
alwaysArray: boolean;
};
const defaultMapping: XmlMappingOptions = {
attributePrefix: '@',
textKey: '#text',
alwaysArray: false,
};
5. 체크리스트
- [ ] YAML에서 "숫자/불리언처럼 보이는" 모든 식별자에 따옴표가 있습니다
- [ ] JSON → YAML → JSON 검증 샘플 집합이 정의되고 테스트가 덮습니다
- [ ] 주석을 보존해야 할 때 객체 왕복 대신 Document API를 씁니다
- [ ] XML 매핑 관례가 문서화되고 팀이 한 세트의 파라미터를 공유합니다
- [ ] 신뢰할 수 없는 XML을 파싱할 때 DTD와 외부 엔티티를 비활성화합니다
- [ ] 변환 전후 바이트 수와 필드 수를 비교하고 이상이 있으면 경고를 냅니다
FAQ
JSON, YAML, XML 사이의 변환 방향을 어떻게 고르나?
JSON을 중간 형식으로 선호하세요. YAML과 XML을 먼저 데이터 모델로 파싱한 뒤 텍스트를 다시 쓰는 대신 그 모델에서 직렬화합니다. 주석이 살아남아야 하면 YAML Document API를 쓰고, 속성이나 네임스페이스가 필요하기 전에 하나의 XML 매핑 관례를 고정하세요.
XML-to-JSON 변환이 속성과 네임스페이스를 잃는 이유는?
JSON에는 속성이나 네임스페이스 개념이 없으므로, XML 속성, 접두사, 네임스페이스 URI를 평범한 키로 접어 넣어야 하며, 흔히 @ 접두사나 #text 노드를 씁니다. 매핑 규칙이 갈라지면 같은 XML이 다른 JSON을 만들어 왕복할 수 없습니다.
JSON, YAML, XML을 장기 양방향 동기화해야 하나?
아니요. 주석, 앵커, XML 속성, 네임스페이스는 형식 간에 손실되며, 양방향 동기화는 차이를 계속 누적합니다. 하나의 원본 형식을 고르고 나머지는 일회성 내보내기나 임시 뷰로 취급하세요.