[파이썬 100강] 91강. tomllib로 TOML 설정 파일을 안전하게 로딩하기

[파이썬 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

실무 패턴

설정 로딩 코드는 짧아 보여도 아키텍처의 시작점입니다. 그래서 "읽기 → 검증 → 정규화 → 주입" 파이프라인을 고정해 두는 것이 좋습니다.

  1. 읽기(Read)
  • tomllib.load()로 원본 TOML을 dict로 읽습니다.
  • 파일 경로, 로딩 성공 여부, 로딩 시각을 로그에 남깁니다.
  1. 검증(Validate)
  • 필수 섹션(server, database 등) 존재 여부 확인
  • 타입 검증(문자열/정수/불리언)
  • 범위 검증(포트, 타임아웃, 재시도 횟수)
  1. 정규화(Normalize)
  • 누락 허용 필드에 기본값 주입
  • 별칭 키를 통일(예: db_url, database_urldatabase.url)
  • 앱 내부에서 쓰기 쉬운 구조로 변환
  1. 주입(Inject)
  • 전역 변수 남발 대신 Config 객체로 모듈에 전달
  • 테스트에서는 별도 TOML로 같은 경로를 재현

이 패턴의 장점은 장애 분석 속도입니다. 문제가 생기면 "파일이 깨졌는지", "값이 잘못됐는지", "내부 변환이 틀렸는지"를 단계별로 바로 좁힐 수 있습니다. 반대로 이 단계를 섞어 쓰면 에러는 한 줄인데 원인은 다섯 개인 상황이 자주 발생합니다.

또 하나 중요한 점은 보안입니다. API 키/토큰을 TOML에 평문으로 넣는 팀이 많습니다. 학습/개발 단계에서는 편하지만 운영에서는 위험합니다. 비밀값은 환경변수나 시크릿 매니저로 분리하고, TOML에는 키 이름/토글/포트처럼 공개 가능한 설정 위주로 두세요. 그러면 설정 파일을 저장소에 안전하게 관리하기 쉬워집니다.

오늘의 결론

한 줄 요약: tomllib는 단순 파서가 아니라, 안정적인 설정 로딩 파이프라인의 출발점이다.

기억할 것:

  • load()는 바이너리 모드 파일(rb)로 여는 습관을 고정합니다.
  • 파싱 성공과 유효성 검증은 반드시 분리합니다.
  • 설정은 "한 번 읽고 끝"이 아니라 "검증·정규화 후 주입"이 정석입니다.

연습문제

  1. app.toml에서 [server] host/port를 읽어 {"host": ..., "port": ...} 형태로 반환하는 load_server_config(path)를 작성해 보세요. port가 1~65535 범위를 벗어나면 ValueError를 발생시키세요.
  2. [feature] 섹션의 enable_cache가 없으면 False, 있으면 반드시 bool만 허용하도록 검증 함수를 작성해 보세요.
  3. 로딩 실패(TOMLDecodeError)와 검증 실패(ValueError)를 구분 로그로 남기는 bootstrap_config(path) 함수를 작성해 보세요.

이전 강의 정답

90강에서는 파일 정리·리포트 자동화 미니 프로젝트를 완성했습니다. 아래는 연습문제 예시 정답입니다.

  1. 확장자별 파일 개수 집계
>>> 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}
  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']
  1. 드라이런/실행 모드 분기 출력
>>> 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']

실습 환경/재현 정보

  • 실행 환경: conda env python100 (Python 3.11.14)
  • 가정한 OS: macOS/Linux 공통
  • 핵심 라이브러리: 표준 라이브러리 tomllib (Python 3.11+)
  • 재현 절차:
    1. 예제 TOML 파일 생성 (sample.toml)
    2. with open(..., "rb")tomllib.load() 실행
    3. 검증 함수(build_runtime_config)로 타입/범위 확인
    4. 실수 예제(TypeError/KeyError/ValueError)를 의도적으로 재현
  • 체크 포인트:
    • 텍스트 모드/바이너리 모드 차이를 직접 확인했는가?
    • "파싱"과 "검증" 단계가 코드에서 분리되어 있는가?
    • 누락 키/잘못된 타입에서 에러 메시지가 충분히 설명적인가?