[파이썬 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에서 찾습니다.port는env_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()변환으로 타입 계약을 명확히 하세요.
연습문제
defaults,env,cli세 dict를 받아 우선순위cli > env > defaults로 적용하는make_config함수를 작성하세요.new_child를 이용해 테스트 환경에서timeout만 임시로 1로 낮춘 설정을 만들어 출력해 보세요.- 레이어 순서를 잘못 넣었을 때와 올바르게 넣었을 때
port결과가 어떻게 달라지는지 비교 코드를 작성하세요.
이전 강의 정답
- 주문 데이터
(고객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]}
- 로그 레벨을 대소문자 정규화해 카운트
>>> 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}
(월, 카테고리, 매출)월별-카테고리별 합계
>>> 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}}
실습 환경/재현 정보
- 실행 환경:
condaenvpython100(Python 3.11.14) - 가정한 OS: macOS/Linux 공통
- 사용 모듈:
collections.ChainMap(표준 라이브러리) - 실행 방법: 터미널에서
python또는ipython실행 후 예제 순서대로 입력 - 재현 체크:
- 레이어 순서를 바꿨을 때 최종 값이 달라지는지 확인
cm[key] = value대입이 첫 번째 매핑에만 쓰이는지 확인- 외부 전달 직전
dict(cm)변환 결과가 기대와 일치하는지 확인