JSON Toolkit

JSON을 YAML로 바꾸는 커맨드라인: yq 명령 하나에 숨은 네 가지 함정

핵심 요약: 튜토리얼에서 가장 자주 등장하는 yq -P input.json > output.yaml은 실측에서도 여전히 JSON을 출력합니다. 이 글은 yq v4.44.6, PyYAML 6.0.3, js-yaml을 2.39 MB 샘플에 돌려 출력 형식이 확장자로 결정된다는 사실을 네 세트의 데이터로 보여주고, 일괄 변환·따옴표·들여쓰기·큰 정수 정밀도의 올바른 작성법을 제시합니다.

중급약 9분 소요2026-11-03 발행2026-11-03 업데이트
터미널에서 JSON을 YAML로 변환하는 명령과 출력 결과를 비교한 표지

결론부터: 튜토리얼에서 가장 흔히 등장하는 yq -P input.json > output.yaml은 yq v4에서도 여전히 JSON을 내놓습니다. YAML을 원한다면 -o=yaml(또는 -oy)를 명시적으로 써야 합니다. -P는 --prettyPrint의 축약형이며, 담당하는 것은 들여쓰기와 블록 스타일이지 출력 형식이 아닙니다.

JSON을 YAML로 바꾸는 일은 처음부터 "파일 확장자만 갈아끼우는" 문제가 아닙니다. 진짜 비용은 네 군데에 숨어 있습니다. 출력 형식이 입력 확장자로 결정된다는 점, 따옴표 규칙이 문자열을 조용히 다시 쓴다는 점, 시퀀스 들여쓰기가 하위 도구에 영향을 준다는 점, 그리고 큰 정수는 한 번 yq를 거치면 정밀도를 영구히 잃는다는 점입니다.

1. 가장 널리 퍼진 명령을 먼저 한 번 돌려보기

깨끗한 머신에 yq v4.44.6을 설치하고 최소 입력을 만듭니다:

printf '{"a":1,"b":{"c":2}}' > s.json
yq -P '.' s.json

돌아오는 것은 YAML이 아니라 들여쓰기가 된 JSON입니다:

{
  "a": 1,
  "b": {
    "c": 2
  }
}

튜토리얼을 그대로 따라 이것을 output.yaml로 리다이렉트하면, 확장자는 .yaml인데 내용은 JSON인 파일을 얻습니다. 모든 YAML 1.2 파서가 이를 아무 문제 없이 읽습니다(YAML 1.2에서 JSON은 부분 집합이며, 이 포함 관계의 전개는JSON ↔ YAML과 XML 변환의 경계에 정리되어 있습니다). 그래서 당장은 아무 일도 일어나지 않습니다—누군가 주석이나 앵커를 추가하는 날, 또는 CI의 yamllint가 truthy value should be one of를 쏟아내기 시작하는 날까지는.

실제로 YAML을 만들어내는 작성법은 출력 형식을 명시하는 것입니다:

yq -o=yaml '.' s.json
# a: 1
# b:
#   c: 2

2. 왜 이런가: 확장자가 출력 형식을 결정한다

yq v4의 동작 원칙은 "입력 형식이 출력 형식을 결정한다"입니다. 입력 파일의 확장자에서 형식을 추론하고, -o가 없으면 그 추론 결과를 그대로 씁니다. .json 입력이면 JSON 출력, .yaml 입력이면 YAML 출력, 확장자 없이 stdin으로 읽으면 기본값인 YAML입니다.

같은 입력에 대한 네 가지 작성법의 실측 결과:

명령 입력 출처 실제 출력 형식 판정
yq -P '.' s.json s.json 파일 JSON(들여쓰기 있음) .yaml로 떨어뜨리면 함정
yq -o=yaml '.' s.json s.json 파일 YAML(블록 스타일) 정확
yq -P -o=yaml '.' s.json s.json 파일 YAML(블록 스타일) 정확, 여기서 -P는 불필요
cat s.json | yq -P '.' stdin, 확장자 없음 YAML(블록 스타일) 정확, 기본값이 yaml이므로

세 번째 행은 한 가지를 말해줍니다. -o=yaml이 이미 있다면 -P가 항상 필요한 것은 아니다는 점입니다. 하지만 무해한 것도 아닙니다—6절의 소요 시간 데이터를 보세요. 네 번째 행은 "베껴 쓰면 그냥 되더라"는 경험이 나오는 이유를 설명합니다. 많은 사람이 파이프로 데이터를 넘기기 때문에, 우연히 확장자 분기 자체를 피해 가는 것입니다.

3. 일괄 변환: 세 가지 셸 작성법

단일 파일 변환은 출발점일 뿐입니다. 커맨드라인에서 이 작업을 해야 하는 실제 이유는 거의 전부 일괄입니다. 한 디렉터리의 모든 설정, 캡처해 둔 모든 API 스냅샷을 한 번에 YAML로 바꿔야 합니다.

bash / zsh 루프(가장 직관적, 디렉터리 하나에 적합):

for f in configs/*.json; do
  yq -o=yaml '.' "$f" > "${f%.json}.yaml"
done

find 재귀(하위 디렉터리까지 대상일 때):

find . -name '*.json' -exec sh -c 'yq -o=yaml "." "$1" > "${1%.json}.yaml"' _ {} \;

PowerShell(Windows CI에서):

Get-ChildItem *.json | ForEach-Object { yq -o=yaml '.' $_.FullName > ($_.BaseName + '.yaml') }

세 가지写法에는 공통된 필수 전제가 있습니다. 먼저 원본 JSON이 유효한지 확인한다는 것입니다. 변환기는 파서가 아닙니다. 구문 오류를 만나면 많은 변환기는 검증기처럼 줄 번호를 정리해 주는 대신 모호한 위치만 알려줍니다. 루프 안에 사전 검증 하나를 넣는 비용은 거의 0입니다:

for f in configs/*.json; do
  jq empty "$f" || { echo "invalid: $f"; continue; }
  yq -o=yaml '.' "$f" > "${f%.json}.yaml"
done

jq empty는 출력 없이 파싱만 하고, 잘못된 JSON에 대해 0이 아닌 종료 코드를 돌려줍니다. set -euo pipefail과 함께 쓰면 파이프라인 전체가 첫 번째 깨진 파일에서 멈추고, 변환이 덜 된 결과물을 잔뜩 만들지 않습니다.

4. 출력 옵션 네 개는 저마다 다른 일을 한다

yq의 옵션 목록은 길지만, JSON → YAML 경로에서 외워야 할 것은 네 개뿐입니다:

옵션 하는 일 반드시 써야 할 때
-o=yaml / -oy 출력 형식을 YAML로 지정 입력이 .json 파일일 때 필수
-P / --prettyPrint 블록 스타일 강제, 들여쓰기 정규화 stdin으로 읽을 때, 시퀀스 들여쓰기가 필요할 때
-I=n 들여쓰기 칸 수, 기본값 2 하위 도구가 4칸을 요구할 때(일부 Ansible 규약)
--split-exp 배열 요소를 여러 문서로 분리 Kubernetes 다중 리소스 매니페스트를 만들 때

여러 문서 이야기는 따로 짚을 만합니다. Kubernetes 매니페스트는 관례적으로 여러 리소스를 ---로 구분합니다. JSON 배열에서 생성한다면:

yq -P -o=yaml '.[] | splitDoc' --split-exp 'true' kubernetes-list.json

--split-exp 없이는 YAML 시퀀스 하나를 얻고, 있으면 각 배열 요소가 ---로 구분된 독립 문서가 됩니다. 둘 다 kubectl apply가 받아들이지만, diff의 가독성은 한 자릿수 차이가 납니다.

5. 따옴표: 반드시 따옴표가 필요한 여섯 종류의 문자열

YAML의 타입 추론은 암묵적입니다. 숫자나 불리언처럼 생긴 문자열 값은 따옴표가 없으면 다른 타입으로 파싱됩니다. 이는 이론상의 위험이 아닙니다—다음 여섯 종류의 값은 yq v4.44.6에서 실제로 자동으로 따옴표가 붙었고, 이는 파서가 정말로 잘못 읽는다는 증거입니다:

printf '{"version":"1.0","enabled":"true","port":"8080","empty":"","colon":"host: value","hash":"v # c","unicode":"端口 8080","ts":"2026-11-03"}' | yq -P -o=yaml '.'
version: "1.0"
enabled: "true"
port: "8080"
empty: ""
colon: 'host: value'
hash: 'v # c'
unicode: 端口 8080
ts: "2026-11-03"

하나씩 결과를 봅시다:

원래 값(JSON 문자열) 따옴표 없이 읽히는 것 실제 영향
"1.0" 부동소수점 1.0 버전이 소수가 되고, 1.10이 1.1로 뭉개짐
"true" 불리언 true 분기 로직이 뒤집히거나 타입이 안 맞음
"8080" 정수 8080 포트 타입이 바뀌어 문자열 비교 실패
"" null 빈 문자열이 사라지고 하위는 None을 받음
"host: value" 파싱 오류 또는 중첩 매핑 콜론 뒤에는 공백이 있어야 함
"2026-11-03" 날짜 객체 문자열이 아니라 datetime.date로 역직렬화됨

마지막 항목이 가장 음흉합니다. JSON에서 날짜는 그냥 문자열인데, 변환 후에는 YAML의 날짜 타입이 되고, 다시 JSON으로 돌릴 때 많은 구현이 원래 문자열이 아니라 ISO 형식의 날짜 객체를 출력합니다. 왕복 일관성이 여기서 끊깁니다. PyYAML과 yq 모두 이 값들에 따옴표를 보태는 선택을 했으며, 이는 위험이 실재한다는 것을 인정한 셈입니다.

덧붙이면 unicode: 端口 8080 행에는 따옴표가 붙지 않았습니다—중국어 텍스트는 YAML에서 안전합니다. 걱정해야 할 것은 그것이 아니라, 다음 절에서 다루는 키 순서와 정밀도입니다.

6. Python과 Node의 동등한 작성법과 실측 소요 시간

모든 머신에 yq를 설치할 수 있는 것은 아닙니다(오프라인 환경, 제한된 CI 이미지, 패키지 매니저 없는 Windows). Python과 Node의 동등한 작성법에는 저마다 기본값 함정이 있습니다. 세 경로를 상황별로 어떻게 고를지는JSON 커맨드라인 도구와 에디터 선정에 비교표가 있고, 이 글은 JSON → YAML 한 방향만 다룹니다.

Python + PyYAML(인자 세 개, 하나도 빼놓을 수 없음):

import json, yaml

with open("config.json", encoding="utf-8") as f:
    data = json.load(f)

with open("config.yaml", "w", encoding="utf-8") as f:
    yaml.safe_dump(
        data, f,
        default_flow_style=False,  # 블록 스타일, {a: 1, b: 2}가 아님
        allow_unicode=True,        # 중국어를 \u7AEF\u53E3로 이스케이프하지 않음
        sort_keys=False,           # JSON 원래의 키 순서 유지
    )

이 세 개의 기본값은 False / False / True입니다. 즉 기본 상태의 PyYAML은 키를 알파벳순으로 재정렬하고 중국어를 \uXXXX로 바꿉니다. 같은 입력에 기본값으로 돌린 실측 결과:

apiVersion: v1
data:
  LOG_LEVEL: info
  colon: 'host: value'
  unicode: "\u7AEF\u53E3 8080"
kind: ConfigMap
metadata:
  labels:
    app: web
  name: app-config

kind가 apiVersion 뒤, metadata 앞으로 밀려 있는 점을 보세요. Kubernetes 매니페스트에서 apiVersion이 kind보다 앞에 와야 한다는 것은 커뮤니티 관례입니다. 키 순서가 흐트러져도 파싱은 깨지지 않지만, 리뷰하는 사람을 몹시 괴롭힙니다.

Node + js-yaml:

const fs = require("fs");
const yaml = require("js-yaml");

const data = JSON.parse(fs.readFileSync("config.json", "utf8"));
fs.writeFileSync(
  "config.yaml",
  yaml.dump(data, { lineWidth: -1, noRefs: true, sortKeys: false })
);

lineWidth: -1은 긴 줄 접기를 끕니다(그렇지 않으면 긴 URL이 중간에서 잘립니다). noRefs: true는 앵커와 별칭 생성을 금지합니다(소비자가 kubectl일 때는 반드시 켜야 합니다. 앵커를 해석하지 못하는 도구도 있습니다).

실측 소요 시간(샘플: 2.39 MB JSON, 12000개 레코드, 각 레코드에 중첩 객체 하나와 배열 두 개 포함. 머신은 이 작업에 사용한 샌드박스 환경):

구현 변환 시간 역방향 파싱 시간 왕복 일관성
yq -o=yaml v4.44.6 0.45 s 0.45 s 일치
yq -P -o=yaml v4.44.6 4.16 s — 일치
Node js-yaml 4.x 0.23 s 0.11 s 일치
Python PyYAML 6.0.3 3.26 s 8.01 s 일치

직관에 어긋나는 점이 둘 있습니다. 첫째, -P를 붙이면 yq가 약 9배 느려집니다(0.45 s → 4.16 s). 그런데 이 샘플에서는 출력 바이트 수가 완전히 동일합니다(양쪽 모두 2,326,895 바이트)—-P는 한 번의 완전한 재레이아웃을 수행하므로 비용이 작지 않고, 수백 개 파일을 일괄 변환할 때는 줄일 수 있으면 줄이는 편이 낫습니다. 둘째, PyYAML의 역방향 파싱(8.01 s)은 정방향 변환(3.26 s)보다 두 배 이상 느립니다. YAML을 반복해서 다시 읽는 파이프라인이라면 Python은 좋은 선택이 아닙니다.

하나 더. 2.39 MB JSON은 블록 스타일 YAML로 바꾸면 2.33 MB가 되어 오히려 약 3% 작아졌습니다. "YAML은 반드시 JSON보다 크다"는 통설은 성립하지 않습니다—깊은 중첩에서는 JSON의 따옴표, 쉼표, 중괄호가 차지하는 부피가 YAML의 들여쓰기 공백을 넘어설 수 있습니다.

7. 변환하면 돌아오지 않는 세 가지

앞의 세 절은 "올바르게 변환하는 법"이었고, 이 절은 "변환하면 잃는 것"입니다. 의도적으로 만든 입력으로 실측합니다:

printf '{"order_id":12345678901234567890,"zip":"0150","ts":"2026-11-03","f":1.0e6}' > t.json
yq -o=yaml '.' t.json

yq의 출력:

order_id: 1.2345678901234567e+19
zip: "0150"
ts: "2026-11-03"
f: 1000000

다시 JSON으로 돌리면 {"order_id":12345678901234567000,"zip":"0150","ts":"2026-11-03","f":1000000}가 됩니다.

20자리 주문 번호 12345678901234567890이 12345678901234567000이 되어 끝 세 자리가 0으로 밀리고, 아무런 경고도 없습니다. 원인은 yq가 Go의 float64 파싱 경로를 타는 것입니다. 2^53을 넘는 정수는 정확히 표현할 수 없으므로 과학적 표기법 출력으로 전환되고, 돌려받을 때쯤에는 이미 되돌릴 수 없습니다.

같은 입력을 PyYAML에 넘기면:

order_id: 12345678901234567890
zip: '0150'
ts: '2026-11-03'
f: 1000000.0

Python의 정수는 임의 정밀도이므로 20자리 숫자가 온전히 남고, 다시 읽어도 12345678901234567890입니다. 변환할 데이터에 주문 번호, Snowflake ID, 신분증 번호 같은 긴 숫자가 있다면 yq를 쓰지 말고 Python을 쓰거나, 먼저 그 필드를 문자열로 바꾸세요. 이런 정밀도 문제의 전체 메커니즘은JSON 큰 정수가 정밀도를 잃는 이유에 자세히 설명되어 있습니다.

두 번째로 잃는 것은 float와 int의 구분입니다. 1.0e6은 yq 출력에서 1000000(정수)이 되고, PyYAML은 1000000.0을 유지합니다. 세 번째는 키 순서입니다—PyYAML은 기본적으로 재정렬하고, yq와 js-yaml은 기본적으로 유지합니다. 여러 언어가 섞인 협업에서는 이것이 숨은 불일치의 원천입니다.

8. CI에서는 convert만 하지 말기: 검증 → 변환 → lint

변환만을 유일한 단계로 두는 것은 가장 흔한 파이프라인 결함입니다. 변환이 종료 코드 0으로 성공해도, 그것은 "입력 JSON을 파싱할 수 있었다"는 뜻일 뿐이며 출력이 옳다는 뜻도, 출력이 소비자의 요구를 만족한다는 뜻도 아닙니다. 올바른 3단 구성은 다음 모양입니다:

#!/usr/bin/env bash
set -euo pipefail

# 1. 원본 검증: 구문 계층
jq empty deployment.json

# 2. 변환: 출력 형식을 명시적으로 지정
yq -o=yaml '.' deployment.json > deployment.yaml

# 3. 산출물 검증: YAML 파서로 다시 읽히고, 규약에도 맞는지
yq -o=json -I=0 '.' deployment.yaml > /dev/null
yamllint deployment.yaml

3단계의 yq -o=json ... > /dev/null은 겉보기에는 중복이지만 이 블록에서 가장 쓸모 있는 한 줄입니다. 산출물에 다시 파싱을 강제하기 때문입니다. 들여쓰기 실수, 앵커 해석 실패, 바뀐 타입이 바로 여기서 드러납니다. 이를 건너뛰면 문제는 kubectl apply나 애플리케이션 시작 시점까지 미뤄집니다.

산출물이 사람이 관리하는 설정(Helm values, docker-compose, GitHub Actions 워크플로)이라면 세 가지를 더 확인하세요. 키 순서가 흐트러지지 않았는지, 긴 숫자가 자릿수를 잃지 않았는지, 주석을 사람이 보강해야 하는지—JSON에는 주석이 없으므로 어떤 주해도 변환 후에 넣을 수밖에 없습니다. 변환기가 대신 만들어 주지는 않습니다.

FAQ

yq의 -P와 -o=yaml 중 무엇을 써야 하나요?

-o=yaml은 "출력이 YAML인가"를 결정하고, -P는 "그 YAML이 어떻게 생겼는가"를 결정합니다. 입력이 .json 파일이면 -o=yaml은 필수이고, -P는 블록 스타일 강제나 시퀀스 들여쓰기가 필요할 때만 더합니다. 확장자 없이 stdin으로 읽는 경우 yq는 기본적으로 YAML을 출력하므로 -P 단독으로 충분합니다.

변환된 YAML을 그대로 Kubernetes에 쓸 수 있나요?

문법적으로는 가능하지만 전제가 셋 있습니다. apiVersion처럼 순서 관례가 있는 키가 재정렬되지 않았을 것(PyYAML은 기본적으로 재정렬하므로 sort_keys=False를 설정), 앵커와 별칭이 생성되지 않았을 것(js-yaml은 noRefs: true), 긴 숫자가 자릿수를 잃지 않았을 것. 다중 리소스 매니페스트라면 --split-exp로 --- 구분 문서로 나눠야 합니다.

변환 결과가 들여쓰기 없이 한 줄 {a: 1, b: 2}로 나오는 이유는?

그것은 플로 스타일입니다. 유효한 YAML이긴 하지만 변환한 보람이 없습니다. -P로 블록 스타일을 강제하세요. PyYAML을 쓴다면 default_flow_style=False를 확인하세요(PyYAML 5.1부터 기본값이 False로 바뀌었지만, 오래된 버전에서는 명시적으로 써야 합니다).

20자리 주문 번호가 YAML 변환 뒤 끝자리 0이 되었는데, 살릴 수 있나요?

살릴 수 없습니다. 예방만 가능합니다. yq는 float64로 파싱하므로 2^53을 넘는 정수는 구조상 반드시 정밀도를 잃습니다. Python으로 바꾸거나(정수는 임의 정밀도이며 실측에서 온전히 보존됨), JSON 단계에서 긴 숫자를 문자열로 저장하세요. 후자가 더 견고합니다. JavaScript 쪽 JSON.parse의 동일한 문제까지 함께 해결하기 때문입니다.

온라인 변환 도구와 커맨드라인 중 무엇을 고를까요?

기밀이 담긴 설정이나 token이 든 API 스냅샷은 로컬 커맨드라인이나 순수 프런트엔드 도구를 쓰고 업로드하지 마세요. 일회성이고 크기가 작으며 눈으로 확인하고 싶다면 온라인 도구가 더 빠릅니다. 이 사이트의 /convert/yaml은 브라우저 안에서만 동작하며 데이터는 기기를 떠나지 않습니다. 일괄 처리, CI 편입, 재현성이 필요한 작업은 모두 커맨드라인을 쓰고, 검증 단계도 같은 스크립트에 함께 적어 두세요.