[파이썬 100강] 91강. tomllib로 TOML 설정 파일을 안전하게 로딩하기
설정 파일은 "코드가 바뀌지 않아도 동작을 바꾸게" 해주는 핵심 장치입니다. 그리고 파이썬 3.11부터는 TOML을 표준 라이브러리 tomllib로 바로 읽을 수 있습니다. 오늘은 이 기능을 단순 파싱 수준이 아니라, 실무에서 실패를 줄이는 방식으로 정리합니다.
핵심 개념
tomllib는 TOML 문자열/파일을 파이썬 딕셔너리로 바꿔 주는 읽기 전용 파서입니다. 여기서 읽기 전용이라는 말이 중요합니다. tomllib는 "읽기"에 집중하고, "쓰기"는 제공하지 않습니다. 그래서 설정 파일을 로딩하고 검증해 애플리케이션 설정 객체로 만드는 흐름에 특히 잘 맞습니다.
TOML은 JSON보다 설정용으로 읽기 쉬운 편이고, YAML보다 문법이 상대적으로 단순해서 운영에서 실수를 줄이기 좋습니다. 섹션([database]), 배열, 중첩 테이블, 날짜/시간 타입을 명확하게 표현할 수 있어 config.py 하드코딩을 줄이는 데 유리합니다.
실무에서의 핵심은 세 가지입니다. 첫째, 파싱 성공과 설정 유효성 검증은 다른 문제라는 점. 둘째, 기본값 전략(누락 허용/불허)을 명확히 둘 것. 셋째, 실패 시점에 "무엇이 잘못됐는지"를 로그와 에러 메시지로 남길 것. 이 세 가지를 지키면 설정 문제로 야간 장애가 나는 확률이 크게 줄어듭니다.
기본 사용
예제 1) 문자열 TOML 파싱의 최소 단위
>>> import tomllib
>>> raw = b"""
... app_name = "python100"
... debug = true
... timeout = 3
... """
>>> cfg = tomllib.loads(raw.decode("utf-8"))
>>> cfg["app_name"], cfg["debug"], cfg["timeout"]
('python100', True, 3)
>>> type(cfg).__name__
'dict'
해설:
tomllib.loads()는 문자열을 입력받습니다. 파일에서 읽은 바이트라면 디코딩 후 전달하면 됩니다.- 불리언, 숫자 타입이 문자열이 아니라 파이썬 타입으로 매핑되므로 이후 검증이 쉬워집니다.
예제 2) 파일에서 직접 로딩하기
>>> from pathlib import Path
>>> p = Path("sample.toml")
>>> _ = p.write_text('title = "Demo"\n[server]\nport = 8080\n', encoding="utf-8")
>>> with p.open("rb") as f: # 중요: 바이너리 모드
... cfg = tomllib.load(f)
...
>>> cfg["title"], cfg["server"]["port"]
('Demo', 8080)
해설:
tomllib.load()는 파일 객체를 받아 파싱합니다.- 문서/관례대로
rb(바이너리 모드)로 열어주는 습관이 안전합니다. - 중첩 테이블은 중첩 dict로 접근합니다.
예제 3) 운영용 기본값 + 타입 검증 래퍼
>>> def build_runtime_config(cfg: dict) -> dict:
... server = cfg.get("server", {})
... host = server.get("host", "127.0.0.1")
... port = server.get("port", 8000)
... debug = cfg.get("debug", False)
... if not isinstance(host, str):
... raise ValueError("server.host must be str")
... if not isinstance(port, int) or not (1 <= port <= 65535):
... raise ValueError("server.port must be int in 1..65535")
... if not isinstance(debug, bool):
... raise ValueError("debug must be bool")
... return {"host": host, "port": port, "debug": debug}
...
>>> build_runtime_config({"server": {"port": 9000}, "debug": True})
{'host': '127.0.0.1', 'port': 9000, 'debug': True}
해설:
- 실무에서는 파싱 직후 곧바로 "내 앱이 쓸 수 있는 형태"로 정규화하는 편이 좋습니다.
- 값 범위(포트 범위 등)까지 확인하면 잘못된 배포를 시작 단계에서 막을 수 있습니다.
예제 4) 섹션 기반 설정을 서비스별로 분리
>>> cfg = {
... "database": {"url": "sqlite:///app.db", "pool_size": 5},
... "feature": {"use_cache": True}
... }
>>> db_url = cfg["database"]["url"]
>>> use_cache = cfg.get("feature", {}).get("use_cache", False)
>>> db_url, use_cache
('sqlite:///app.db', True)
해설:
- 설정이 커질수록 평면 키보다 섹션 구조가 유지보수에 유리합니다.
- 각 모듈이 자기 섹션만 읽도록 분리하면 결합도가 낮아집니다.
자주 하는 실수
실수 1) tomllib.load()에 텍스트 모드 파일을 넘김
>>> import tomllib
>>> from io import StringIO
>>> fake = StringIO('name = "x"')
>>> tomllib.load(fake)
Traceback (most recent call last):
...
TypeError: File must be opened in binary mode, e.g. use `open('foo.toml', 'rb')`
원인:
load()는 바이너리 파일 객체를 기대하는데, 텍스트 모드(문자 스트림)를 넣었습니다.
해결:
>>> from io import BytesIO
>>> good = BytesIO(b'name = "x"')
>>> tomllib.load(good)["name"]
'x'
실수 2) 파싱만 성공하면 설정이 유효하다고 착각
>>> cfg = {"server": {"port": "eighty"}} # 파싱 자체는 가능
>>> int(cfg["server"]["port"])
Traceback (most recent call last):
...
ValueError: invalid literal for int() with base 10: 'eighty'
원인:
- TOML 문법이 맞다는 사실과 서비스 요구사항(정수 포트)은 별개인데 이를 분리하지 않았습니다.
해결:
>>> def read_port(cfg):
... port = cfg.get("server", {}).get("port", 8000)
... if not isinstance(port, int):
... raise ValueError("server.port must be int")
... return port
...
>>> read_port({"server": {"port": 8080}})
8080
실수 3) 누락 키에서 바로 인덱싱해 런타임 장애
>>> cfg = {"server": {"host": "0.0.0.0"}}
>>> cfg["server"]["port"]
Traceback (most recent call last):
...
KeyError: 'port'
원인:
- "반드시 있을 것"이라는 가정이 코드에 박혀 있습니다.
해결:
- 필수 키는 부팅 단계에서 명시 검증하고 즉시 실패시킵니다.
- 선택 키는 기본값을 선언적으로 넣어 동작을 안정화합니다.
>>> def require_keys(d, required):
... miss = [k for k in required if k not in d]
... if miss:
... raise KeyError(f"missing keys: {miss}")
... return True
...
>>> require_keys({"host": "0.0.0.0", "port": 8000}, ["host", "port"])
True
실무 패턴
설정 로딩 코드는 짧아 보여도 아키텍처의 시작점입니다. 그래서 "읽기 → 검증 → 정규화 → 주입" 파이프라인을 고정해 두는 것이 좋습니다.
- 읽기(Read)
tomllib.load()로 원본 TOML을 dict로 읽습니다.- 파일 경로, 로딩 성공 여부, 로딩 시각을 로그에 남깁니다.
- 검증(Validate)
- 필수 섹션(
server,database등) 존재 여부 확인 - 타입 검증(문자열/정수/불리언)
- 범위 검증(포트, 타임아웃, 재시도 횟수)
- 정규화(Normalize)
- 누락 허용 필드에 기본값 주입
- 별칭 키를 통일(예:
db_url,database_url→database.url) - 앱 내부에서 쓰기 쉬운 구조로 변환
- 주입(Inject)
- 전역 변수 남발 대신
Config객체로 모듈에 전달 - 테스트에서는 별도 TOML로 같은 경로를 재현
이 패턴의 장점은 장애 분석 속도입니다. 문제가 생기면 "파일이 깨졌는지", "값이 잘못됐는지", "내부 변환이 틀렸는지"를 단계별로 바로 좁힐 수 있습니다. 반대로 이 단계를 섞어 쓰면 에러는 한 줄인데 원인은 다섯 개인 상황이 자주 발생합니다.
또 하나 중요한 점은 보안입니다. API 키/토큰을 TOML에 평문으로 넣는 팀이 많습니다. 학습/개발 단계에서는 편하지만 운영에서는 위험합니다. 비밀값은 환경변수나 시크릿 매니저로 분리하고, TOML에는 키 이름/토글/포트처럼 공개 가능한 설정 위주로 두세요. 그러면 설정 파일을 저장소에 안전하게 관리하기 쉬워집니다.
오늘의 결론
한 줄 요약: tomllib는 단순 파서가 아니라, 안정적인 설정 로딩 파이프라인의 출발점이다.
기억할 것:
load()는 바이너리 모드 파일(rb)로 여는 습관을 고정합니다.- 파싱 성공과 유효성 검증은 반드시 분리합니다.
- 설정은 "한 번 읽고 끝"이 아니라 "검증·정규화 후 주입"이 정석입니다.
연습문제
app.toml에서[server] host/port를 읽어{"host": ..., "port": ...}형태로 반환하는load_server_config(path)를 작성해 보세요.port가 1~65535 범위를 벗어나면ValueError를 발생시키세요.[feature]섹션의enable_cache가 없으면False, 있으면 반드시bool만 허용하도록 검증 함수를 작성해 보세요.- 로딩 실패(
TOMLDecodeError)와 검증 실패(ValueError)를 구분 로그로 남기는bootstrap_config(path)함수를 작성해 보세요.
이전 강의 정답
90강에서는 파일 정리·리포트 자동화 미니 프로젝트를 완성했습니다. 아래는 연습문제 예시 정답입니다.
- 확장자별 파일 개수 집계
>>> from collections import Counter
>>> files = ["a.py", "b.py", "c.md", "d.txt", "e.md"]
>>> ext_count = Counter(f.rsplit('.', 1)[-1] for f in files if '.' in f)
>>> dict(ext_count)
{'py': 2, 'md': 2, 'txt': 1}
- 최근 수정 파일 상위 N개 추리기(모의 데이터)
>>> items = [
... {"name": "a.py", "mtime": 1700000100},
... {"name": "b.md", "mtime": 1700000300},
... {"name": "c.txt", "mtime": 1700000200},
... ]
>>> top2 = sorted(items, key=lambda x: x["mtime"], reverse=True)[:2]
>>> [x["name"] for x in top2]
['b.md', 'c.txt']
- 드라이런/실행 모드 분기 출력
>>> def run_cleanup(targets, dry_run=True):
... action = "[DRY]" if dry_run else "[EXEC]"
... return [f"{action} {t}" for t in targets]
...
>>> run_cleanup(["tmp/a.log", "tmp/b.log"], dry_run=True)
['[DRY] tmp/a.log', '[DRY] tmp/b.log']
실습 환경/재현 정보
- 실행 환경:
condaenvpython100(Python 3.11.14) - 가정한 OS: macOS/Linux 공통
- 핵심 라이브러리: 표준 라이브러리
tomllib(Python 3.11+) - 재현 절차:
- 예제 TOML 파일 생성 (
sample.toml) with open(..., "rb")로tomllib.load()실행- 검증 함수(
build_runtime_config)로 타입/범위 확인 - 실수 예제(TypeError/KeyError/ValueError)를 의도적으로 재현
- 예제 TOML 파일 생성 (
- 체크 포인트:
- 텍스트 모드/바이너리 모드 차이를 직접 확인했는가?
- "파싱"과 "검증" 단계가 코드에서 분리되어 있는가?
- 누락 키/잘못된 타입에서 에러 메시지가 충분히 설명적인가?