YAML 파싱 차이, 따옴표 없는 값이 파서마다 달라지는 문제

3줄 요약
- 따옴표 없이 적은 YAML 값은 파서를 바꾸면 다르게 읽힐까요. 같은 값 42개를 파서 5종에 넣어 대조했습니다.
- 다섯 곳이 모두 같은 값은 20개(47.6%)뿐이었습니다. PyYAML 6.0.3과 js-yaml 5.4.2만 놓고 보면 42개 중 20개가 다릅니다.
- 다만
version: 1.10은 다섯 곳 전부 1.1로 읽습니다. 원인이 구현이 아니라 표기인 값은 파서를 바꿔도 그대로입니다.
"리드님, 슬롯 번호를 설정 파일로 뺐는데 08만 문자열로 들어와요. 오타인가요?"
오타는 아니었습니다. name: 08, name: 13, name: 17 세 줄을 나란히 적으면 PyYAML은 첫 줄만 문자열 '08'로 주고 나머지 둘은 정수 13, 17로 줍니다. 그런데 같은 세 줄을 js-yaml로 읽으면 8, 13, 17 셋 다 숫자가 되고요. 따옴표를 안 붙였다는 것 말고는 세 줄에 다른 점이 없습니다.
저도 처음에는 파일이 잘못 저장된 줄 알았는데요, 편집기에서 다시 열어 봐도 세 줄은 똑같았습니다. 다른 것은 파일이 아니라 그 줄을 읽는 쪽이었고요, 그 사실을 확인하는 데만 한나절이 갔습니다.
빈 .vimrc 하나가 기능 15개를 되돌리는 것을 확인했을 때와 비슷한 자리인데요, 이번에는 파일의 존재가 아니라 값의 생김새가 판정 기준입니다. 그래서 설정 파일에 실제로 적힐 법한 값 42개를 골라 파서 다섯에 그대로 먹여 봤습니다.
1. 값 42개 중 다섯 곳이 모두 같게 읽은 것은 20개입니다
먼저 판정표부터 놓겠습니다. 아래 다섯 줄이 이 글에서 계속 나올 구성입니다.
| 파서 | 버전 | 게시 시각 (KST 환산) | 스스로 밝힌 준거 |
|---|---|---|---|
| PyYAML | 6.0.3 | 2025-09-26 06:31 | 소스의 리졸버가 YAML 1.1 타입 |
| ruamel.yaml (1.1 지정) | 0.19.1 | 2026-01-03 01:50 | version=(1,1) 명시 |
| ruamel.yaml (1.2 지정) | 0.19.1 | 동일 | version=(1,2) 명시 |
| js-yaml | 5.4.2 | 2026-09-13 09:15 | README "YAML 1.2 parser" (1.1 지원도 함께 명시) |
| yaml (eemeli) | 2.9.1 | 2026-09-12 05:30 | README "both YAML 1.1 and YAML 1.2" |
게시 시각은 PyPI JSON API의 upload_time_iso_8601과 registry.npmjs.org의 time 필드를 직접 읽어 KST로 환산한 값입니다(확인일 2026-09-19).
측정 방법
설정 파일에 실제로 적힐 법한 스칼라 42개를 뽑았습니다. 각각을 k: <값> 한 줄짜리 문서로 만들어 파서마다 타입과 값을 받아 적었습니다. 집계를 따라올 수 있도록 목록을 그대로 둡니다.
NO no No yes on off Off y n true false null ~
010 008 007 0777 08544 09 0o17 0x1F 00 0 +12345
1_000 1e3 1.0e3 1.0e+3 1.10 1.0 3.10 3.0 1.20.3
12:30 1:30 2026-09-19 2026-09-19T08:00:00Z 2026-9-19
.inf .nan v1.0 latest
# 타입과 값을 한 문자열로 묶어 파서 간 비교가 가능하게 만듭니다
import yaml, io
from ruamel.yaml import YAML
def sig(v):
import datetime
if isinstance(v, (datetime.date, datetime.datetime)): return "date|" + v.isoformat()
if isinstance(v, bool): return "bool|" + str(v).lower()
if v is None: return "null|"
if isinstance(v, (int, float)): return "num|" + repr(float(v))
return "str|" + str(v)
def ru(s, ver): # ruamel 은 버전을 코드에서 지정합니다
y = YAML(typ="safe"); y.version = ver
return sig(y.load(io.StringIO("k: " + s))["k"])
for c in ["NO", "08", "010", "12:30", "1e3", "1.10", "2026-09-19"]:
print(c, sig(yaml.safe_load("k: " + c)["k"]), ru(c, (1, 1)), ru(c, (1, 2)))
일치율
42개 중 다섯 곳이 전부 같은 결과를 준 것은 20개, 갈린 것은 22개였습니다. 파서 쌍으로 세면 격차가 더 분명합니다.
| 파서 쌍 | 42개 중 불일치 | 비율 |
|---|---|---|
| js-yaml 5.4.2 ↔ yaml 2.9.1 | 0 | 0.0% |
| ruamel 1.2 ↔ js-yaml | 3 | 7.1% |
| PyYAML ↔ ruamel 1.1 | 4 | 9.5% |
| PyYAML ↔ ruamel 1.2 | 17 | 40.5% |
| PyYAML ↔ js-yaml | 20 | 47.6% |
읽는 순서가 하나 있습니다. 자바스크립트 파서 두 개는 42개 전부 같았고, 파이썬 파서도 같은 준거를 지정하면 4개까지 좁혀집니다. 벌어지는 자리는 언어가 아니라 준거입니다. 가장 널리 쓰이는 PyYAML이 1.1을 고수하고 나머지 넷이 1.2 쪽에 서 있어서 그 경계가 40%대로 벌어집니다.
같은 파이썬 안에서도 마찬가지입니다. ruamel은 버전을 지정하지 않으면 1.2로 동작하므로 PyYAML과 17개(40.5%)가 갈립니다. 그러니 "언어를 안 건너면 괜찮다"가 아니라 "같은 파일을 같은 라이브러리로만 읽으면 괜찮다"가 맞습니다. 우리 파이프라인은 사람이 손으로 적고 파이썬과 노드가 나눠 읽는 구조라 그 조건을 못 채웁니다.
갈린 22개
| 입력 | PyYAML 6.0.3 | ruamel 1.2 | js-yaml 5.4.2 |
|---|---|---|---|
NO no No yes on off Off (7) |
불리언 | 문자열 | 문자열 |
y n (2) |
문자열 | 문자열 | 문자열 |
010 |
정수 8 | 정수 10 | 숫자 10 |
008 |
문자열 '008' |
정수 8 | 숫자 8 |
0777 |
정수 511 | 정수 777 | 숫자 777 |
08544 |
문자열 '08544' |
정수 8544 | 숫자 8544 |
09 |
문자열 '09' |
정수 9 | 숫자 9 |
0o17 |
문자열 '0o17' |
정수 15 | 숫자 15 |
1_000 |
정수 1000 | 정수 1000 | 문자열 "1_000" |
1e3 1.0e3 (2) |
문자열 | 실수 1000.0 | 숫자 1000 |
12:30 1:30 (2) |
정수 750 / 90 | 문자열 | 문자열 |
2026-09-19 2026-09-19T08:00:00Z (2) |
datetime.date |
datetime.date |
문자열 |
괄호 안 숫자를 더하면 22개가 됩니다. y와 n은 표의 세 열이 다 문자열인데도 갈린 쪽에 넣었습니다. ruamel을 1.1로 지정하면 이 둘이 True와 False가 되기 때문입니다. 이 지점은 3절에서 따로 보겠습니다.
2. 앞자리 0은 세 갈래로 흩어집니다
1절 표에서 가장 조용한 함정이 앞자리 0입니다. 같은 모양인데 값에 따라 결과가 달라지고, 파서에 따라 또 달라집니다.
슬롯 이름 08·13·17
이 블로그의 발행 파이프라인은 하루 세 번, 08시·13시·17시에 돕니다. 그 슬롯 번호를 설정 파일로 빼면 이렇게 됩니다.
| 적은 값 | PyYAML 6.0.3 | ruamel 1.2 | js-yaml 5.4.2 |
|---|---|---|---|
name: 08 |
문자열 '08' |
정수 8 |
숫자 8 |
name: 13 |
정수 13 |
정수 13 |
숫자 13 |
name: 17 |
정수 17 |
정수 17 |
숫자 17 |
형제 값 세 개가 한 파일 안에서 서로 다른 타입을 답니다. name == "13"으로 비교하는 코드는 조용히 거짓을 내고, name 값으로 폴더 이름 13_<slug>를 만드는 코드는 08 슬롯에서만 우연히 맞습니다. 08이 문자열로 살아남은 것은 설계가 아니라 8이 8진수 자릿수를 벗어났기 때문입니다.
이런 어긋남이 조용한 이유는 예외가 나지 않아서입니다. 파싱은 성공하고, 값도 들어 있고, 로그에도 아무 흔적이 없습니다. 증상은 비교가 거짓이 되는 자리에서야 드러나는데, 저는 그 자리를 찾는 데 한나절을 썼습니다.
왜 08은 살고 010은 8이 되는가
PyYAML 6.0.3의 정수 리졸버 정규식을 설치본 소스에서 그대로 옮기면 이렇습니다.
^(?:[-+]?0b[0-1_]+ ← 2진수
|[-+]?0[0-7_]+ ← 8진수. 0 다음에 0~7만 허용
|[-+]?(?:0|[1-9][0-9_]*) ← 10진수. 0 아닌 숫자로 시작해야 함
|[-+]?0x[0-9a-fA-F_]+ ← 16진수
|[-+]?[1-9][0-9_]*(?::[0-5]?[0-9])+)$ ← 60진수
010은 두 번째 줄에 걸려 8이 되고, 08은 8이 8진수 자릿수가 아니라 두 번째 줄에서 떨어집니다. 세 번째 줄도 0 하나이거나 1~9로 시작해야 하므로 08은 여기서도 떨어져 문자열로 남습니다. 우편번호 08544와 09가 문자열이 되는 이유도 같습니다.
YAML 1.2.2 명세의 Core Schema에는 8진수가 0o [0-7]+ 하나뿐이고 10진수는 [-+]? [0-9]+입니다. 앞자리 0을 특별 취급하지 않으므로 010은 그냥 10, 08544는 8544가 됩니다. 문자열이던 값이 숫자가 되면서 앞의 0이 사라지는 쪽입니다.
값별 정리
| 적은 값 | 1.1 계열 결과 | 1.2 계열 결과 | 실무에서 만나는 자리 |
|---|---|---|---|
010 |
8 | 10 | 빌드 번호, 버전 코드 |
008 |
'008' |
8 | 주문 번호, 시퀀스 |
0777 |
511 | 777 | 파일 권한 |
08544 |
'08544' |
8544 | 우편번호, 계좌 |
0o17 |
'0o17' |
15 | 1.2 문법으로 적은 권한 |
한 줄 안에서 8진수 의도와 문자열 의도가 겹치는 자리라, 어느 쪽 결과가 "맞다"고 말하기 어렵습니다. 그래서 처방은 5절처럼 표기 쪽에서 끝내는 편이 낫습니다.
3. 노르웨이 문제는 절반만 맞는 설명입니다
country: NO가 불리언 거짓이 된다는 이야기는 널리 알려져 있습니다. 실측에서도 PyYAML과 ruamel 1.1이 False를 줬습니다. 다만 "YAML은 NO를 불리언으로 읽는다"는 문장은 지금 기준으로는 절반만 맞습니다.
1.1과 1.2의 불리언 목록
| 준거 | 불리언으로 읽는 표기 |
|---|---|
| YAML 1.1 타입 명세 | y Y yes Yes YES n N no No NO true True TRUE false False FALSE on On ON off Off OFF |
| YAML 1.2.2 Core Schema | true True TRUE false False FALSE |
1.1 명세 원문은 정규식과 함께 "가독성을 위해 true/false, yes/no, on/off로 표기하며 한 글자 y/n으로 줄일 수 있다"고 적고 있습니다(Boolean Language-Independent Type for YAML 1.1, Working Draft 2005-01-18). 1.2.2 Core Schema는 이 목록을 여섯 개로 줄였습니다. 그래서 js-yaml 5.4.2와 yaml 2.9.1은 NO를 문자열로 줍니다.
같은 1.1인데 y와 n이 갈립니다
여기가 이번 측정에서 가장 뜻밖이었습니다. PyYAML은 1.1 리졸버를 쓰는데 y와 n을 불리언으로 읽지 않습니다. 설치본 소스의 불리언 정규식에 그 두 글자가 없습니다.
| 입력 | YAML 1.1 명세 | PyYAML 6.0.3 | ruamel 1.1 지정 |
|---|---|---|---|
y |
참 | 문자열 'y' |
True |
n |
거짓 | 문자열 'n' |
False |
no |
거짓 | False |
False |
같은 준거를 표방하는 구현 둘이 명세의 같은 줄에서 갈립니다. 그러므로 "우리는 1.1이다"라는 표기만으로는 결과를 예측할 수 없습니다. 예측하려면 그 구현의 정규식을 봐야 합니다.
방향도 직관과 반대입니다. 한 글자 y를 불리언으로 읽는 쪽이 더 공격적으로 보이는데, 명세를 그대로 따른 것은 그쪽(ruamel)이고 덜 읽는 쪽(PyYAML)이 명세에서 벗어나 있습니다. 결과만 놓고 보면 PyYAML 쪽이 덜 위험하지만, 그것은 명세를 지킨 덕이 아니라 지키지 않은 덕입니다.
on이 키 자리에 오면
불리언 오인은 값뿐 아니라 키에서도 납니다. 워크플로 파일의 최상위 on: 키를 파서별로 읽어 봤습니다.
# GitHub Actions 워크플로의 최상위 키를 읽는 흔한 코드
import yaml, io
from ruamel.yaml import YAML
wf = "on:\n push:\n branches: [main]\njobs: {}\n"
print(list(yaml.safe_load(wf).keys())) # [True, 'jobs']
y = YAML(typ="safe"); y.version = (1, 2)
print(list(y.load(io.StringIO(wf)).keys())) # ['on', 'jobs']
PyYAML로 읽으면 키가 문자열 'on'이 아니라 불리언 True입니다. cfg["on"]은 KeyError를 내고 cfg[True]가 값을 줍니다. 워크플로를 정적 검사하는 스크립트를 파이썬으로 짜다가 여기서 한 번 막히면 원인을 찾기까지 시간이 꽤 듭니다.
4. %YAML 1.2를 적어도 PyYAML은 1.1로 읽습니다
앞의 결론이 "파일에 준거를 명시하면 되겠다"로 이어질 수 있습니다. YAML에는 문서 맨 위에 버전을 적는 디렉티브가 있으니까요. 실측 결과는 반대였습니다.
디렉티브를 넣은 결과
import yaml, io
from ruamel.yaml import YAML
doc = "%YAML 1.2\n---\na: NO\nb: 010\n"
print(yaml.safe_load(doc)) # {'a': False, 'b': 8}
y = YAML(typ="safe")
print(y.load(io.StringIO(doc))) # {'a': 'NO', 'b': 10}
PyYAML은 %YAML 1.2를 오류 없이 받아들이고, 그다음 값을 1.1 규칙으로 읽습니다. 1.1, 1.2, 1.3 무엇을 적어도 결과가 같았고 2.0에서만 ParserError가 났습니다.
소스에서 확인한 이유
설치본 parser.py의 process_directives()는 메이저 버전이 1인지만 확인하고 값을 self.yaml_version에 담습니다. 그 뒤 이 변수를 쓰는 곳을 찾으면 parser.py 안에서 이벤트에 실어 보내는 것이 전부이고, 타입을 결정하는 resolver.py에는 version이라는 문자열이 한 번도 나오지 않습니다.
# 설치본에서 직접 확인한 결과 (PyYAML 6.0.3)
$ grep -c version resolver.py # 0
$ grep -n "version 1.\* is required" parser.py
229: "found incompatible YAML document (version 1.* is required)",
디렉티브가 리졸버에 닿지 않으니 준거를 바꿀 수 없습니다. 파일에 적는 버전 표기는 PyYAML에서 타입 판정에 아무 영향이 없습니다. 주석과 같다고는 못 합니다. 메이저가 1이 아니면 파싱 자체를 막으니까, 효력은 있는데 하필 원하는 쪽에만 없는 셈입니다.
소수점 없는 지수가 문자열이 되는 자리
같은 소스에서 실수 정규식도 확인했습니다. PyYAML은 가수부에 점을 요구하고, 지수부에도 부호를 요구합니다.
| 입력 | PyYAML 6.0.3 | ruamel 1.1 | js-yaml 5.4.2 |
|---|---|---|---|
1e3 |
문자열 | 1000.0 | 1000 |
1.0e3 |
문자열 | 1000.0 | 1000 |
1.0e+3 |
1000.0 | 1000.0 | 1000 |
timeout: 1e3처럼 적으면 파이썬 쪽에서는 문자열이 들어오고, 그 값을 그대로 int()에 넣는 코드에서 ValueError가 납니다. 태도는 셋으로 갈립니다. 문자열로 두는 쪽(PyYAML), 수로 읽는 쪽(js-yaml·yaml·ruamel 1.2), 그리고 같은 1.1을 지정했는데도 수로 읽으면서 MantissaNoDotYAML1_1Warning을 띄우는 쪽(ruamel 1.1)입니다.
5. 원고 80편을 훑고 파이프라인에 넣은 것
여기부터는 이 블로그 파이프라인에 실제로 무엇을 넣었는지 적겠습니다. 예약 작업 세 개가 같은 폴더를 공유하는 구조라, 한 군데서 타입이 어긋나면 다음 슬롯이 그대로 받습니다.
원고 80편의 front matter
발행분 80편의 front matter를 PyYAML과 js-yaml로 각각 읽어 대조했습니다. 키는 title·date·categories·tags·description 다섯 개고, 80편 전부 파싱 자체는 성공했습니다.
갈린 것은 date 하나, 80편 중 79편이었습니다. 파이썬에서는 datetime.date, 자바스크립트에서는 문자열입니다. 남은 한 편은 date: "2026-09-01"처럼 따옴표가 붙어 있어 양쪽 다 문자열이었고요. 따옴표 하나가 79편과 1편을 가른 셈입니다.
무엇을 고를 것인가
처방을 네 가지 놓고 골랐습니다.
| 처방 | 도입 난이도 | 유지보수 비용 | 팀 학습곡선 | 남는 위험 |
|---|---|---|---|---|
| 값에 전부 따옴표 | 낮음. 표기 규칙 한 줄 | 낮음. 사람이 잊으면 샘 | 낮음 | 새 파일에서 재발 |
BaseLoader로 전부 문자열 |
낮음. 인자 하나 | 중간. 숫자를 손으로 변환 | 중간 | 변환 누락 |
| 리졸버를 떼어낸 전용 로더 | 중간. 30줄 | 중간. 버전 올릴 때 재확인 | 높음 | 팀이 원리를 알아야 함 |
| 커밋 훅 스캐너 | 중간. 40줄 | 낮음. 자동 | 낮음 | 오탐 처리 |
QA 4~6명에 자동화 초기 단계인 우리 팀 조건에서는 첫 줄과 넷째 줄을 같이 골랐습니다. 표기 규칙은 사람이 지키고, 지켜졌는지는 기계가 봅니다. 세 번째는 힘은 제일 센데요, 리졸버를 손댄 이유를 아는 사람이 팀에 한 명뿐이면 그 사람이 자리를 비운 날 아무도 못 고칩니다.
두 번째를 뺀 이유도 적어 둡니다. 전부 문자열로 받으면 파서 차이는 확실히 사라지는데, 그 대신 숫자로 쓸 값을 손으로 변환하는 코드가 파일마다 늘어납니다. 변환을 빠뜨린 자리는 이번에 본 것과 똑같이 조용히 어긋나고요. 위험을 없앤 게 아니라 옮긴 셈이라 접었습니다.
스캐너
따옴표가 없으면서 파서에 따라 타입이 갈릴 값만 잡습니다. 정상 값을 잡지 않는 쪽으로 좁게 짰습니다.
# 위험 스칼라 스캐너 — 따옴표 없는 값 중 타입이 갈릴 것만 표시합니다
import re, sys, pathlib
RISK = [
(re.compile(r'^(y|Y|n|N|yes|Yes|YES|no|No|NO|on|On|ON|off|Off|OFF)$'), '불리언 오인'),
(re.compile(r'^0[0-9_]+$'), '앞자리 0'),
(re.compile(r'^[0-9]+:[0-9]{1,2}(:[0-9]{1,2})?$'), '60진수 오인'),
(re.compile(r'^[0-9][0-9_]*_[0-9_]*$'), '언더스코어 숫자'),
(re.compile(r'^[-+]?[0-9]+[eE][-+]?[0-9]+$'), '소수점 없는 지수'),
(re.compile(r'^0o[0-7]+$'), '1.2 전용 8진수'),
(re.compile(r'^\d{4}-\d{2}-\d{2}([T ].*)?$'), '날짜 — 언어별로 date/str'),
]
KV = re.compile(r'^\s*(?:-\s*)?([\w.\-]+)\s*:\s*(.+?)\s*(?:#.*)?$')
GLOB = sys.argv[2] if len(sys.argv) > 2 else '*.y*ml' # 마크다운 front matter면 '*.md'
def lines_of(path):
text = path.read_text(encoding='utf-8')
if path.suffix == '.md': # front matter 구간만 봅니다
text = text.split('---', 2)[1] if text.startswith('---') else ''
return text.split('\n')
for path in sorted(pathlib.Path(sys.argv[1]).rglob(GLOB)):
for ln, line in enumerate(lines_of(path), 1):
m = KV.match(line)
if not m:
continue
key, val = m.group(1), m.group(2)
if val[:1] in '"\'[{|>&*!' or not val: # 따옴표·흐름·블록 표기는 제외
continue
for rx, why in RISK:
if rx.match(val):
print(f"{path}:{ln}: {key}: {val} ← {why}")
break
아카이브 원고 80편에 *.md로 돌리니 79건이 떴고, 전부 date 줄이었습니다. 앞에서 파서 두 개로 대조해 얻은 숫자와 같습니다. 스캐너가 실제 갈림을 그대로 집어냈다는 뜻이라 이 정도면 쓸 만하다고 봤습니다.
다만 이 일치는 표본이 작아서 나온 것이기도 합니다. 원고 front matter는 키가 다섯 개뿐이고 값의 생김새도 단조롭거든요.
키가 수십 개인 워크플로 파일이나 배포 설정에 돌리면 오탐이 섞여 나올 겁니다. 그때는 정규식을 넓히지 말고 잡힌 줄에 따옴표를 붙이는 쪽이 빠릅니다. 오탐이든 진탐이든 처방이 같아서요.
오늘 바로 해볼 것
□ 설정 파일에서 따옴표 없는 값 중 불리언처럼 보이는 말을 찾는다
□ 앞자리가 0인 숫자를 찾는다 — 우편번호·빌드번호·권한이 자주 걸린다
□ 날짜를 파이썬과 JS 양쪽에서 읽는 파일이 있으면 따옴표를 붙인다
□ `%YAML` 디렉티브에 기대고 있는 곳이 있으면 걷어낸다 (PyYAML은 무시한다)
□ 위 스캐너를 커밋 훅에 걸고, 잡힌 줄은 따옴표로 닫는다
숫자로 쓸 값이면 숫자로 적고, 나머지는 따옴표로 닫는 것이 결국 가장 싼 방법이었습니다. 42개 중 22개가 갈린다는 사실보다, 갈리는 22개가 전부 따옴표 하나로 사라진다는 쪽이 실무에서는 더 쓸모 있었고요.
팀에 공유할 때도 명세 이야기는 꺼내지 않았습니다. "따옴표 없는 값 중 불리언처럼 보이거나 0으로 시작하면 닫는다" 한 줄이면 실무에서는 충분하더라고요. 원리는 이 글처럼 한 번 적어 두고, 지키는 것은 훅에 맡기는 편이 오래갑니다.
댓글
댓글 쓰기