[파이썬 100강] 69강. ChainMap으로 설정 우선순위와 오버라이드 깔끔하게 관리하기

[파이썬 100강] 69강. ChainMap으로 설정 우선순위와 오버라이드 깔끔하게 관리하기

설정은 보통 한 곳에서 끝나지 않습니다. 기본값이 있고, 환경별 설정이 있고, 실행 시점 옵션이 또 덮어씌웁니다. 이때 딕셔너리를 계속 복사해서 합치면 코드가 길어지고, "지금 어떤 값이 최종값인지" 추적이 어려워집니다. 이번 강의에서는 collections.ChainMap으로 여러 딕셔너리를 우선순위 순서대로 연결해, 설정 조회와 오버라이드 로직을 더 명확하게 만드는 방법을 바로 실습합니다.


핵심 개념

  • ChainMap은 여러 매핑(보통 dict)을 하나처럼 조회하게 해 주는 뷰입니다.
  • 조회는 앞에서 뒤 순서(왼쪽 → 오른쪽)로 진행되어, 앞쪽 값이 우선합니다.
  • 쓰기/수정은 기본적으로 첫 번째 매핑에만 반영됩니다.

초보 단계에서는 설정 병합을 a | b | c 또는 tmp = a.copy(); tmp.update(b)처럼 자주 처리합니다. 이 방식은 단순할 때는 괜찮지만, 실제 운영 코드에서는 단점이 분명합니다. 첫째, 합쳐진 결과가 "복사본"이라 원본 변경을 반영하지 못합니다. 둘째, 병합 단계가 늘어날수록 어떤 레이어가 최종 우선인지 눈으로 파악하기 어렵습니다. ChainMap은 이 문제를 "복사 없는 계층형 조회"로 해결합니다. 즉, 기본값/환경변수/CLI 인자 같은 레이어를 그대로 둔 채, 우선순위만 명시해 한 객체로 다룰 수 있습니다. 특히 디버깅할 때 maps 속성을 보면 어떤 레이어에 어떤 값이 들어 있는지 바로 확인할 수 있어 운영 관점에서도 이점이 큽니다.

기본 사용

예제 1) 기본값 + 환경값 + 실행옵션 우선순위

>>> from collections import ChainMap
>>> defaults = {"host": "127.0.0.1", "port": 8000, "debug": False}
>>> env_cfg = {"port": 9000}
>>> cli_cfg = {"debug": True}
>>> cfg = ChainMap(cli_cfg, env_cfg, defaults)
>>> cfg["host"], cfg["port"], cfg["debug"]
('127.0.0.1', 9000, True)

해설:

  • host는 앞 레이어에 없으므로 defaults에서 찾습니다.
  • portenv_cfg가 기본값을 덮어씁니다.
  • debug는 가장 앞 레이어인 cli_cfg가 최종 우선입니다.

예제 2) 키 존재 위치 확인과 레이어 점검

>>> from collections import ChainMap
>>> base = {"timeout": 3, "retries": 2}
>>> prod = {"timeout": 10}
>>> runtime = {}
>>> settings = ChainMap(runtime, prod, base)
>>> settings["timeout"], settings["retries"]
(10, 2)
>>> settings.maps
[{}, {'timeout': 10}, {'timeout': 3, 'retries': 2}]
>>> "retries" in settings
True

해설:

  • maps를 보면 어떤 레이어가 값을 제공하는지 추적할 수 있습니다.
  • 운영 중 "왜 timeout이 10이냐" 같은 질문이 들어왔을 때 원인 파악이 쉽습니다.

예제 3) 쓰기 동작은 첫 번째 매핑에만 반영됨

>>> from collections import ChainMap
>>> defaults = {"region": "ap-northeast-2"}
>>> env_cfg = {}
>>> cfg = ChainMap(env_cfg, defaults)
>>> cfg["region"]
'ap-northeast-2'
>>> cfg["region"] = "us-east-1"
>>> env_cfg
{'region': 'us-east-1'}
>>> defaults
{'region': 'ap-northeast-2'}

해설:

  • 조회는 체인 전체를 보지만, 대입은 첫 번째 dict(env_cfg)에만 기록됩니다.
  • 이 규칙을 이해하면 "원본이 왜 안 바뀌지?" 같은 혼란을 줄일 수 있습니다.

예제 4) 새 스코프 임시 오버라이드

>>> from collections import ChainMap
>>> base = {"log_level": "INFO", "timeout": 5}
>>> cfg = ChainMap({}, base)
>>> request_cfg = cfg.new_child({"timeout": 1})
>>> cfg["timeout"], request_cfg["timeout"]
(5, 1)
>>> request_cfg["log_level"]
'INFO'

해설:

  • new_child는 앞단에 새 레이어를 추가합니다.
  • 요청 단위/테스트 단위로 임시 오버라이드를 만들 때 매우 유용합니다.

자주 하는 실수

실수 1) "병합 결과 dict"라고 착각하고 dict 메서드에 의존

>>> from collections import ChainMap
>>> cm = ChainMap({"a": 1}, {"b": 2})
>>> type(cm)
<class 'collections.ChainMap'>
>>> dict(cm)
{'b': 2, 'a': 1}

원인:

  • ChainMap은 dict와 비슷하게 보이지만, 복사 병합본이 아니라 "연결된 뷰"입니다.

해결:

>>> merged = dict(cm)   # 외부 API로 넘길 때는 명시적으로 변환
>>> isinstance(merged, dict)
True

실수 2) 뒤쪽 레이어 값이 수정될 거라고 기대

>>> from collections import ChainMap
>>> front = {}
>>> back = {"x": 10}
>>> cm = ChainMap(front, back)
>>> cm["x"] = 99
>>> front, back
({'x': 99}, {'x': 10})

원인:

  • ChainMap 대입은 항상 첫 번째 매핑에만 기록됩니다.

해결:

>>> back["x"] = 99   # 뒤쪽 원본을 바꾸고 싶다면 직접 접근
>>> cm["x"]
99

실수 3) 레이어 순서를 거꾸로 넣어 우선순위 역전

  • 증상: CLI에서 준 값이 기본값에 덮여 버림
  • 원인: ChainMap(defaults, env, cli)처럼 우선순위 낮은 레이어를 앞에 둠
  • 해결: 가장 강한 오버라이드부터 앞에 배치 (ChainMap(cli, env, defaults))

실수 4) 체인에 mutable 값(list/dict)을 공유해 사이드이펙트 발생

  • 증상: 한 요청에서 수정한 리스트가 다른 요청에서도 보임
  • 원인: 레이어에 들어 있는 내부 객체를 깊은 복사 없이 공유
  • 해결: 변경 가능 객체는 요청 단위 생성하거나 필요 시 copy.deepcopy로 분리

실무 패턴

  • 입력 검증 규칙

    • 설정 레이어를 만들기 전에 키 이름 표준화(예: 소문자, _ 통일)를 먼저 적용합니다.
    • 타입이 중요한 값(timeout, port, retry)은 체인 생성 직후 정규화 함수에서 강제 변환합니다.
  • 로그/예외 처리 규칙

    • 최종 설정값만 로그로 남기지 말고, 민감정보를 제외한 레이어별 요약도 함께 남깁니다.
    • 필수 키 누락 시 KeyError를 그대로 노출하지 말고, 어떤 레이어를 확인해야 하는지 힌트를 포함한 예외 메시지를 사용합니다.
  • 재사용 함수/구조화 팁

    • build_config(cli, env, defaults) 같은 팩토리 함수로 체인 생성 규칙을 한 곳에 고정합니다.
    • 외부 경계(API 호출, 직렬화) 직전에는 dict(config)로 변환해 계약 타입을 명확히 합니다.
  • 성능/메모리 체크 포인트

    • ChainMap은 복사 비용을 줄이는 장점이 있지만, 레이어가 너무 깊으면 조회 시 탐색 비용이 커질 수 있습니다.
    • 보통 3~5개 레이어를 권장하고, 그 이상이면 레이어 역할을 재설계하는 편이 좋습니다.
>>> from collections import ChainMap
>>>
>>> def normalize(raw: dict) -> dict:
...     out = {}
...     for k, v in raw.items():
...         key = k.strip().lower()
...         out[key] = v
...     return out
...
>>> def build_config(cli_raw, env_raw, defaults):
...     cli = normalize(cli_raw)
...     env = normalize(env_raw)
...     cfg = ChainMap(cli, env, defaults)
...     return {
...         "host": cfg["host"],
...         "port": int(cfg["port"]),
...         "timeout": float(cfg["timeout"]),
...         "debug": bool(cfg["debug"]),
...     }
...
>>> build_config(
...     {"PORT": "8081", "DEBUG": True},
...     {"timeout": "2.5"},
...     {"host": "127.0.0.1", "port": 8000, "timeout": 5, "debug": False},
... )
{'host': '127.0.0.1', 'port': 8081, 'timeout': 2.5, 'debug': True}

이 패턴의 핵심은 "레이어링"과 "타입 확정"을 분리하는 것입니다. ChainMap은 우선순위 문제를 해결하고, 후속 단계에서 타입/검증을 확정하면 운영 중 오류를 크게 줄일 수 있습니다. 특히 설정이 늘어나는 프로젝트일수록 이 분리가 유지보수 비용을 결정합니다.

오늘의 결론

한 줄 요약: ChainMap은 설정 레이어를 복사 없이 합쳐 우선순위를 명확히 만들고, 오버라이드 로직을 안전하게 단순화하는 도구입니다.

기억할 것:

  • 순서가 곧 정책입니다. 강한 오버라이드를 앞에 둬야 합니다.
  • 조회는 체인 전체, 쓰기는 첫 번째 매핑이라는 규칙을 반드시 기억하세요.
  • 외부로 내보낼 때는 dict() 변환으로 타입 계약을 명확히 하세요.

연습문제

  1. defaults, env, cli 세 dict를 받아 우선순위 cli > env > defaults로 적용하는 make_config 함수를 작성하세요.
  2. new_child를 이용해 테스트 환경에서 timeout만 임시로 1로 낮춘 설정을 만들어 출력해 보세요.
  3. 레이어 순서를 잘못 넣었을 때와 올바르게 넣었을 때 port 결과가 어떻게 달라지는지 비교 코드를 작성하세요.

이전 강의 정답

  1. 주문 데이터 (고객ID, 주문금액)를 고객별 리스트로 그룹화
>>> from collections import defaultdict
>>> orders = [("u1", 12000), ("u2", 9000), ("u1", 5000)]
>>> by_user = defaultdict(list)
>>> for user, amount in orders:
...     by_user[user].append(amount)
...
>>> dict(by_user)
{'u1': [12000, 5000], 'u2': [9000]}
  1. 로그 레벨을 대소문자 정규화해 카운트
>>> from collections import defaultdict
>>> levels = ["info", "ERROR", "Warn", "error", "INFO"]
>>> cnt = defaultdict(int)
>>> for lv in levels:
...     cnt[lv.strip().upper()] += 1
...
>>> dict(cnt)
{'INFO': 2, 'ERROR': 2, 'WARN': 1}
  1. (월, 카테고리, 매출) 월별-카테고리별 합계
>>> from collections import defaultdict
>>> rows = [
...     ("2026-01", "book", 30),
...     ("2026-01", "toy", 20),
...     ("2026-01", "book", 15),
...     ("2026-02", "toy", 50),
... ]
>>> table = defaultdict(lambda: defaultdict(int))
>>> for month, cat, amount in rows:
...     table[month][cat] += amount
...
>>> {m: dict(v) for m, v in table.items()}
{'2026-01': {'book': 45, 'toy': 20}, '2026-02': {'toy': 50}}

실습 환경/재현 정보

  • 실행 환경: conda env python100 (Python 3.11.14)
  • 가정한 OS: macOS/Linux 공통
  • 사용 모듈: collections.ChainMap (표준 라이브러리)
  • 실행 방법: 터미널에서 python 또는 ipython 실행 후 예제 순서대로 입력
  • 재현 체크:
    • 레이어 순서를 바꿨을 때 최종 값이 달라지는지 확인
    • cm[key] = value 대입이 첫 번째 매핑에만 쓰이는지 확인
    • 외부 전달 직전 dict(cm) 변환 결과가 기대와 일치하는지 확인