Python에서 YAML을 다룬다면 거의 확실히 PyYAML을 사용하고 있을 겁니다. 2006년부터 이어온 표준 라이브러리이고, yaml.load()라는 함수를 제공하는데 이 함수에는 많은 팀들을 곤란하게 만든 심각한 보안 취약점이 있습니다. 해결책은 단 한 단어 — safe_load — 이지만, 왜 그런지, 어떤 것을 포기해야 하는지, 그리고 언제 더 새로운 ruamel.yaml 라이브러리를 선택해야 하는지를 제대로 이해할 필요가 있습니다.
이 가이드는 Python에서의 실용적인 YAML 파싱을 다룹니다: 안전한 로딩, 다중 문서 스트림, Python 객체를 다시 YAML로 덤프하기, 기본값을 포함한 설정 파일 패턴, 그리고 에러 처리. 모든 예제는 실제 시나리오를 사용합니다 — 플레이스홀더 데이터는 없습니다.
설치
pip install pyyaml
# For ruamel.yaml (covered later)
pip install ruamel.yamlyaml.safe_load() — 항상 사용해야 하는 함수
PyYAML에 대해 가장 중요하게 알아야 할 점은 yaml.load()가 YAML 파일에 삽입된 임의의 Python 코드를 실행할 수 있다는 것입니다. 이론적인 위험이 아닙니다 — 잘 문서화된 공격 벡터입니다. 항상 yaml.safe_load()를 사용하세요:
import yaml
# DANGEROUS — never use this with untrusted input
data = yaml.load(open('config.yaml'), Loader=yaml.FullLoader)
# SAFE — use this for any YAML from external sources
data = yaml.safe_load(open('config.yaml'))
# The attack: a YAML file could contain this, which executes Python
# !!python/object/apply:os.system ["rm -rf /important-dir"]yaml.safe_load()는 표준 YAML 타입만 지원합니다:
문자열, 숫자, 불리언, null, 리스트, 딕셔너리. YAML에 !!python/object와 같은 Python 특화 태그가 포함되어 있으면 ConstructorError를 발생시킵니다. 이것이 바로 원하는 동작입니다.
yaml.full_load()는 예전의 bare yaml.load()보다는 안전하지만 여전히 safe_load()보다는 덜 제한적입니다. safe_load()로 시작하고, 정말로 필요한 경우에만 업그레이드하세요.YAML 설정 파일 로딩
웹 애플리케이션을 위한 현실적인 설정 로딩 패턴입니다. YAML 설정 파일을 로드하고 Python의 dict 병합을 사용해 지정되지 않은 항목에 기본값을 채웁니다:
# config.yaml
database:
host: postgres.internal
port: 5432
name: myapp_prod
pool_size: 10
redis:
host: redis.internal
port: 6379
logging:
level: INFO
format: jsonimport yaml
from pathlib import Path
from typing import Any
DEFAULT_CONFIG = {
'database': {
'host': 'localhost',
'port': 5432,
'name': 'myapp',
'pool_size': 5,
'ssl': False,
},
'redis': {
'host': 'localhost',
'port': 6379,
'db': 0,
},
'logging': {
'level': 'DEBUG',
'format': 'text',
}
}
def deep_merge(base: dict, override: dict) -> dict:
"""Recursively merge override into base, returning a new dict."""
result = base.copy()
for key, value in override.items():
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
result[key] = deep_merge(result[key], value)
else:
result[key] = value
return result
def load_config(config_path: str | Path) -> dict[str, Any]:
path = Path(config_path)
if not path.exists():
raise FileNotFoundError(f"Config file not found: {path}")
with path.open('r', encoding='utf-8') as f:
raw = yaml.safe_load(f)
if raw is None:
return DEFAULT_CONFIG.copy()
return deep_merge(DEFAULT_CONFIG, raw)
config = load_config('config.yaml')
print(config['database']['host']) # postgres.internal
print(config['database']['ssl']) # False (from defaults)
print(config['redis']['db']) # 0 (from defaults)Python 객체를 YAML로 덤프하기
yaml.dump()는 Python의 딕셔너리, 리스트, 문자열, 숫자, 불리언, None을 YAML로 직렬화합니다.
기본적으로 플로우 스타일(인라인 중괄호)을 사용하는데 — 읽기 좋은 블록 스타일을 원한다면 default_flow_style=False로 설정하세요:
import yaml
from dataclasses import dataclass, asdict
@dataclass
class ServiceConfig:
name: str
replicas: int
image: str
port: int
tags: list[str]
service = ServiceConfig(
name='payment-api',
replicas=3,
image='payment-api:2.4.1',
port=8080,
tags=['payments', 'backend', 'critical']
)
# Convert dataclass to dict first, then dump to YAML
output = yaml.dump(
asdict(service),
default_flow_style=False,
sort_keys=False, # preserve insertion order
allow_unicode=True
)
print(output)
# image: payment-api:2.4.1
# name: payment-api
# port: 8080
# replicas: 3
# tags:
# - payments
# - backend
# - critical
# Write to file
with open('service-config.yaml', 'w', encoding='utf-8') as f:
yaml.dump(asdict(service), f, default_flow_style=False, sort_keys=False)load_all을 사용한 다중 문서 스트림
YAML은 ---로 구분된 단일 파일 내 여러 문서를 지원합니다. 이는 단일 파일에 Deployment, Service, ConfigMap이 포함될 수 있는 Kubernetes 매니페스트에서 흔히 사용됩니다.
모든 문서를 순회하려면 yaml.safe_load_all()을 사용하세요:
import yaml
# manifests.yaml contains multiple Kubernetes resources separated by ---
with open('manifests.yaml', 'r') as f:
# safe_load_all returns a generator
documents = list(yaml.safe_load_all(f))
for doc in documents:
if doc is None:
continue
kind = doc.get('kind', 'Unknown')
name = doc.get('metadata', {}).get('name', 'unnamed')
print(f"{kind}: {name}")
# Deployment: payment-api
# Service: payment-api-svc
# ConfigMap: payment-api-configyaml.dump_all()을 사용해 스트림에 여러 문서를 작성할 수도 있습니다:
import yaml
documents = [
{'kind': 'Deployment', 'metadata': {'name': 'api'}, 'spec': {'replicas': 2}},
{'kind': 'Service', 'metadata': {'name': 'api-svc'}, 'spec': {'port': 80}},
]
output = yaml.dump_all(documents, default_flow_style=False)
print(output)
# kind: Deployment
# metadata:
# name: api
# spec:
# replicas: 2
# ---
# kind: Service
# metadata:
# name: api-svc
# spec:
# port: 80ruamel.yaml — 주석을 보존해야 할 때
PyYAML에는 중요한 한계가 있습니다: 로딩 시 주석을 제거합니다. YAML 파일을 로드하고, 수정한 뒤 다시 저장하면 모든 주석이 사라집니다. 사람이 관리하는 설정 파일에서 주석이 사라지는 건 결정적인 단점입니다.
ruamel.yaml은 주석, 키 순서, 포맷을 보존하는 라운드트립 파서를 구현합니다 — 기본적으로 YAML 1.2 사양을 대상으로 합니다. 사람이 나중에 읽을 YAML을 프로그래밍 방식으로 편집할 때마다 올바른 선택입니다:
from ruamel.yaml import YAML
yaml = YAML()
yaml.preserve_quotes = True
# This config.yaml has important comments we need to keep:
# database:
# host: localhost # change this for production
# port: 5432 # default PostgreSQL port
# pool_size: 5 # increase under heavy load
with open('config.yaml', 'r') as f:
config = yaml.load(f)
# Modify a value
config['database']['host'] = 'postgres.prod.internal'
config['database']['pool_size'] = 20
# Write back — comments and formatting are preserved!
with open('config.yaml', 'w') as f:
yaml.dump(config, f)
# Result:
# database:
# host: postgres.prod.internal # change this for production
# port: 5432 # default PostgreSQL port
# pool_size: 20 # increase under heavy load- PyYAML 사용: YAML을 소비 목적으로 읽을 때 — 앱에 설정을 파싱하거나, 테스트 픽스처를 로드하거나, Kubernetes 매니페스트를 프로그래밍 방식으로 처리할 때.
- ruamel.yaml 사용: 사람이 유지 관리하는 YAML을 편집할 때 — 설정 파일을 제자리에서 업데이트하거나, CI 설정을 수정하는 툴링, 주석을 잃으면 안 되는 모든 경우.
- ruamel.yaml은 기본적으로 YAML 1.2를 준수하므로 Norway 문제(
NO→false)의 영향을 받지 않습니다. PyYAML은 기본적으로 YAML 1.1을 사용합니다.
에러 처리
YAML 파싱 에러는 yaml.YAMLError를 발생시키는데, 이는 모든 PyYAML 예외의 기본 클래스입니다.
신뢰할 수 없거나 사용자가 제공한 소스에서 YAML을 로드할 때는 항상 이 예외를 잡아야 합니다:
import yaml
from pathlib import Path
def load_user_config(path: str) -> dict:
try:
with open(path, 'r', encoding='utf-8') as f:
data = yaml.safe_load(f)
except FileNotFoundError:
raise FileNotFoundError(f"Config file not found: {path}")
except yaml.scanner.ScannerError as e:
# Includes line/column info in the error message
raise ValueError(f"YAML syntax error in {path}:\n{e}")
except yaml.YAMLError as e:
raise ValueError(f"Invalid YAML in {path}: {e}")
if data is None:
return {}
if not isinstance(data, dict):
raise TypeError(f"Expected a YAML mapping at top level, got {type(data).__name__}")
return datayaml.safe_load()는 유효한 YAML 구문만 보장할 뿐 — 데이터의 형태를 검증하지는 않습니다. 최상위에 database:가 있는 설정 파일도 유효하고, 최상위에 리스트가 있는 설정 파일도 유효한 YAML입니다. 로딩 후 타입 및 구조 검사를 추가하거나, Pydantic과 같은 스키마 검증 라이브러리를 사용해 로드된 딕셔너리를 타입이 지정된 모델로 파싱하세요.마무리
PyYAML은 Python에서 대부분의 YAML 작업을 처리합니다: 항상 yaml.safe_load()를 사용하고(yaml.load() 대신), 다중 문서 스트림에는 yaml.safe_load_all()을 사용하며, 읽기 좋은 출력을 위해 yaml.dump()에 default_flow_style=False를 설정하세요.
주석을 보존하거나 YAML 1.2 의미론이 필요하다면 ruamel.yaml로 전환하세요 — 읽기에는 드롭인 업그레이드이고 쓰기에는 약간의 API 변경이 있습니다. 코드가 실행되기 전 구문 오류는 YAML 검사기가 정확히 어느 줄과 열이 잘못됐는지 알려줄 것입니다.