[파이썬 100강] 72강. UserDict로 딕셔너리 규칙을 안전하게 커스터마이징하기
실무에서는 "딕셔너리처럼 쓰되, 아무 키나 받으면 안 되는" 상황이 자주 나옵니다. 예를 들어 설정값 저장소는 키를 소문자로 통일하고 싶고, API 응답 캐시는 만료 시간을 자동으로 확인하고 싶고, 사용자 입력 매핑은 값 타입을 강제하고 싶습니다. 이런 요구를 일반 dict 위에 여기저기 if 문으로 붙이기 시작하면 코드가 금방 지저분해집니다. 이번 강의에서는 collections.UserDict로 매핑 규칙을 한곳에 모아 안전하고 읽기 쉬운 딕셔너리 확장을 만드는 방법을 배웁니다.
핵심 개념
UserDict는 내부에 실제 데이터를self.data로 보관하는 래퍼 기반 매핑 클래스입니다.- 내장
dict를 직접 상속하는 것보다,__setitem__,update,get같은 동작을 일관되게 커스터마이징하기 쉽습니다. - 입력 정규화(키 소문자화), 값 검증(타입/범위 검사), 접근 제어(읽기 전용 일부 키) 같은 정책을 한 클래스에 모을 수 있습니다.
UserDict의 핵심 장점은 "딕셔너리처럼 사용되는 인터페이스"를 유지하면서도, 내부 규칙을 명시적으로 붙일 수 있다는 점입니다. 팀 프로젝트에서는 규칙이 분산될수록 버그가 늘어납니다. 어떤 함수는 키를 소문자로 바꾸고, 어떤 함수는 안 바꾸고, 어떤 API는 문자열 숫자를 int로 바꾸고, 어떤 API는 그냥 저장해 버리면 데이터 일관성이 무너집니다. 반면 UserDict를 쓰면 저장 시점의 정책을 한 곳에서 강제할 수 있고, 호출부 코드는 그냥 store[key] = value 형태를 유지해 가독성도 좋아집니다. 특히 "규칙이 시간이 지나며 복잡해지는" 설정/캐시/권한 맵에서 유지보수 효과가 크게 납니다.
기본 사용
예제 1) 키를 소문자로 통일하는 설정 매핑
>>> from collections import UserDict
>>>
>>> class CaseInsensitiveConfig(UserDict):
... def __setitem__(self, key, value):
... if not isinstance(key, str):
... raise TypeError("key must be str")
... super().__setitem__(key.lower(), value)
...
... def __getitem__(self, key):
... return super().__getitem__(key.lower())
...
>>> cfg = CaseInsensitiveConfig()
>>> cfg["HOST"] = "localhost"
>>> cfg["Port"] = 8080
>>> cfg["host"], cfg["PORT"]
('localhost', 8080)
>>> dict(cfg)
{'host': 'localhost', 'port': 8080}
해설:
- 저장과 조회 모두에서 같은 정규화 규칙(소문자화)을 적용해야 일관성이 유지됩니다.
- 규칙이 클래스 내부에 있어 호출부는 평범한 딕셔너리처럼 사용할 수 있습니다.
예제 2) 값 타입을 강제하는 사용자 프로필 맵
>>> from collections import UserDict
>>>
>>> class TypedProfile(UserDict):
... SCHEMA = {
... "name": str,
... "age": int,
... "active": bool,
... }
...
... def __setitem__(self, key, value):
... if key not in self.SCHEMA:
... raise KeyError(f"unknown field: {key}")
... expected = self.SCHEMA[key]
... if not isinstance(value, expected):
... raise TypeError(f"{key} must be {expected.__name__}")
... super().__setitem__(key, value)
...
>>> p = TypedProfile()
>>> p["name"] = "Kim"
>>> p["age"] = 31
>>> p["active"] = True
>>> p
{'name': 'Kim', 'age': 31, 'active': True}
해설:
- 필드 허용 목록 + 타입 검증을
__setitem__에 모으면, 데이터 오염을 초기에 막을 수 있습니다. - DB 저장 전 검증, API payload 전처리 단계에서 특히 유용합니다.
예제 3) 만료 시간을 관리하는 간단 캐시 맵
>>> import time
>>> from collections import UserDict
>>>
>>> class TTLCache(UserDict):
... def __init__(self, ttl_sec):
... super().__init__()
... self.ttl_sec = ttl_sec
...
... def __setitem__(self, key, value):
... expires_at = time.time() + self.ttl_sec
... super().__setitem__(key, (value, expires_at))
...
... def __getitem__(self, key):
... value, expires_at = super().__getitem__(key)
... if time.time() > expires_at:
... del self.data[key]
... raise KeyError(f"expired key: {key}")
... return value
...
>>> c = TTLCache(ttl_sec=1)
>>> c["token"] = "abc123"
>>> c["token"]
'abc123'
>>> time.sleep(1.1)
>>> c["token"]
Traceback (most recent call last):
...
KeyError: 'expired key: token'
해설:
- 저장 시점에 메타데이터(만료 시간)를 함께 보관하고, 조회 시점에 정책을 적용하는 패턴입니다.
- "데이터 구조 + 정책"을 같이 넣는 전형적인 UserDict 활용 방식입니다.
예제 4) 키 정규화 + 감사 로그를 함께 남기기
>>> from collections import UserDict
>>>
>>> class AuditMap(UserDict):
... def __init__(self):
... super().__init__()
... self.audit = []
...
... def __setitem__(self, key, value):
... norm = key.strip().lower()
... super().__setitem__(norm, value)
... self.audit.append(("set", norm, value))
...
... def __delitem__(self, key):
... norm = key.strip().lower()
... super().__delitem__(norm)
... self.audit.append(("del", norm, None))
...
>>> m = AuditMap()
>>> m[" Theme "] = "dark"
>>> m["LANG"] = "ko"
>>> del m[" theme "]
>>> dict(m)
{'lang': 'ko'}
>>> m.audit
[('set', 'theme', 'dark'), ('set', 'lang', 'ko'), ('del', 'theme', None)]
해설:
- "어떤 키가 언제 어떻게 바뀌었는지" 기록을 남기면 운영 중 장애 분석이 쉬워집니다.
- 감사 로그를 호출부가 아니라 자료구조 내부에서 일관되게 수집할 수 있습니다.
자주 하는 실수
실수 1) self.data를 우회해서 직접 만져 정책이 깨짐
>>> from collections import UserDict
>>>
>>> class LowerMap(UserDict):
... def __setitem__(self, key, value):
... super().__setitem__(key.lower(), value)
...
>>> lm = LowerMap()
>>> lm["HOST"] = "localhost"
>>> lm.data["PORT"] = 8080 # 정책 우회(나쁜 예)
>>> dict(lm)
{'host': 'localhost', 'PORT': 8080}
원인:
UserDict내부 저장소인self.data는 구현 세부입니다. 여길 직접 수정하면__setitem__검증/정규화 로직이 실행되지 않습니다.
해결:
>>> lm = LowerMap()
>>> lm["HOST"] = "localhost"
>>> lm["PORT"] = 8080
>>> dict(lm)
{'host': 'localhost', 'port': 8080}
- 외부 코드에서는 반드시 매핑 인터페이스(
obj[key] = value)를 사용하세요. - 팀 규칙으로 "self.data 직접 접근 금지"를 정해 두는 것이 안전합니다.
실수 2) update()는 검증될 거라 가정했는데 누락됨
>>> from collections import UserDict
>>>
>>> class NumberOnly(UserDict):
... def __setitem__(self, key, value):
... if not isinstance(value, (int, float)):
... raise TypeError("value must be number")
... super().__setitem__(key, value)
...
>>> n = NumberOnly()
>>> n.update({"a": 1, "b": "oops"})
Traceback (most recent call last):
...
TypeError: value must be number
원인:
UserDict.update()는 내부적으로__setitem__을 호출하므로 이 예제는 안전합니다. 문제는 개발자가 이를 모르고 "검증이 안 될 수도 있다"며 우회 코드를 추가하거나, 반대로 dict 상속에서 같은 보장을 기대하는 경우입니다.
해결:
UserDict에서는update()도 정책이 적용된다는 점을 팀에 명확히 공유하세요.- 코드리뷰 체크포인트: "검증 로직이
__setitem__단일 경로에 모여 있는가?"
실수 3) 조회 메서드(get, in)에서 정규화 누락
- 증상:
m["HOST"]는 되는데m.get("HOST")는None이 나옴 - 원인:
__getitem__만 오버라이드하고get,__contains__동작 차이를 고려하지 않음 - 해결: 키 정규화를 별도 메서드(
_norm_key)로 만들고, 필요한 지점에서 공통 적용
실수 4) 예외를 너무 넓게 잡아 데이터 오류를 숨김
- 증상: 잘못된 타입이 들어와도 "그냥 기본값"으로 넘어가서 장애가 늦게 터짐
- 원인:
except Exception으로 검증 에러까지 삼켜 버림 - 해결:
TypeError,KeyError를 구분해 처리하고, 정책 위반은 로그에 반드시 남기기
실무 패턴
-
입력 검증 규칙
_norm_key(key)와_validate_value(key, value)를 분리해서 테스트 가능하게 만듭니다.- 정책이 늘어나면
if문 덩어리 대신 작은 검증 함수 목록으로 조합합니다.
-
로그/예외 처리 규칙
- 정책 위반은 침묵하지 말고 구조 로그(
event=validation_failed, key=..., reason=...)로 남깁니다. - 사용자 입력 오류(
TypeError)와 시스템 상태 오류(RuntimeError)를 분리해야 대응 우선순위를 세울 수 있습니다.
- 정책 위반은 침묵하지 말고 구조 로그(
-
재사용 함수/구조화 팁
- 프로젝트마다 반복되는 패턴(소문자 키, 타입 강제, 읽기 전용 키)은 베이스 UserDict 클래스로 공통화합니다.
- 서비스 레이어에서는 UserDict를 직접 노출하기보다, 도메인 이름(
UserSettings,FeatureFlags)을 붙여 의미를 드러내세요.
-
성능/메모리 체크 포인트
UserDict는 래퍼이므로 아주 미세한 오버헤드가 있습니다. 핫패스(초고빈도 연산)에서는 측정 후 선택하세요.- 그러나 대부분 업무 코드에서는 미세 성능보다 "규칙 누락 방지" 이점이 훨씬 큽니다.
>>> from collections import UserDict
>>>
>>> class FeatureFlags(UserDict):
... READ_ONLY = {"service_name"}
...
... def _norm_key(self, key):
... if not isinstance(key, str):
... raise TypeError("key must be str")
... return key.strip().lower()
...
... def __setitem__(self, key, value):
... k = self._norm_key(key)
... if k in self.READ_ONLY and k in self.data:
... raise PermissionError(f"{k} is read-only")
... if not isinstance(value, bool) and k != "service_name":
... raise TypeError(f"{k} must be bool")
... super().__setitem__(k, value)
...
>>> flags = FeatureFlags()
>>> flags["service_name"] = "devlab-api"
>>> flags["new_checkout"] = True
>>> flags["new_checkout"] = False
>>> flags
{'service_name': 'devlab-api', 'new_checkout': False}
이 패턴은 특히 기능 플래그 운영에서 유용합니다. 서비스명처럼 초기화 이후 바뀌면 안 되는 키는 읽기 전용으로 잠그고, 실제 토글 값은 bool만 허용하면 운영 실수를 많이 줄일 수 있습니다. 핵심은 "검증이 분산되지 않게" 하는 것입니다. API 레이어, 배치 스크립트, 관리자 페이지가 모두 같은 자료구조 규칙을 공유하면, 입력 경로가 늘어나도 일관성이 깨지지 않습니다.
오늘의 결론
한 줄 요약: 딕셔너리 규칙이 생겼다면, 흩어진 if 문 대신 UserDict로 정책을 자료구조 안에 넣어라.
기억할 것:
UserDict는 매핑 확장을 안전하게 설계하기 위한 표준 도구입니다.__setitem__한 지점에 검증/정규화를 모으면 누락이 줄어듭니다.self.data직접 접근을 막고, 매핑 인터페이스 경유를 팀 규칙으로 정하세요.
연습문제
- 키를 모두 snake_case로 정규화하는
UserDict를 만들고, 공백/대문자 입력도 같은 키로 저장되게 구현하세요. - 주문 상태(
pending,paid,cancelled)만 허용하는OrderStateMap을 만들고, 잘못된 상태 입력 시ValueError를 발생시키세요. - 읽기 전용 키 목록을 받아, 초기 설정 이후 해당 키 재할당을 막는
ReadOnlyKeysMap을 구현하세요.
이전 강의 정답
- 크기 3인 LRU 캐시 구현 + 접근 시 최신화
>>> from collections import OrderedDict
>>>
>>> class LRU:
... def __init__(self, cap=3):
... self.cap = cap
... self.d = OrderedDict()
... def get(self, k):
... if k not in self.d:
... return None
... self.d.move_to_end(k)
... return self.d[k]
... def put(self, k, v):
... if k in self.d:
... self.d[k] = v
... self.d.move_to_end(k)
... else:
... self.d[k] = v
... if len(self.d) > self.cap:
... self.d.popitem(last=False)
...
>>> c = LRU(3)
>>> c.put("a", 1); c.put("b", 2); c.put("c", 3)
>>> c.get("a")
1
>>> c.put("d", 4)
>>> list(c.d.keys())
['c', 'a', 'd']
- 오래된 작업부터 꺼내는
dequeue_oldest()
>>> from collections import OrderedDict
>>> q = OrderedDict()
>>> q["job1"] = "pending"
>>> q["job2"] = "pending"
>>> q["job3"] = "pending"
>>> def dequeue_oldest(queue):
... if not queue:
... return None
... return queue.popitem(last=False)
...
>>> dequeue_oldest(q)
('job1', 'pending')
>>> list(q.keys())
['job2', 'job3']
- 사용자별 최근 검색어 5개 유지 + 재입력 최신화
>>> from collections import OrderedDict, defaultdict
>>> recent = defaultdict(OrderedDict)
>>> LIMIT = 5
>>>
>>> def add_query(user, query):
... bucket = recent[user]
... if query in bucket:
... bucket.move_to_end(query)
... else:
... bucket[query] = True
... if len(bucket) > LIMIT:
... bucket.popitem(last=False)
...
>>> for q in ["python", "dict", "cache", "api", "test", "python"]:
... add_query("u1", q)
...
>>> list(recent["u1"].keys())
['dict', 'cache', 'api', 'test', 'python']
실습 환경/재현 정보
- 실행 환경:
condaenvpython100(Python 3.11.14) - 가정한 OS: macOS/Linux 공통
- 사용 모듈:
collections.UserDict,time(표준 라이브러리) - 실행 방법: 터미널에서
python또는ipython실행 후 예제를 순서대로 입력 - 재현 체크:
- 키 정규화 규칙이
[],get,in에서 일관되게 동작하는지 확인 - 타입/상태 검증이
setitem과update경로 모두에서 동일하게 적용되는지 확인 - 만료 캐시 예제에서 TTL 경과 후
KeyError가 발생하고 항목이 정리되는지 확인
- 키 정규화 규칙이