[파이썬 100강] 75강. collections.abc로 컨테이너 인터페이스를 정확하게 설계하기

[파이썬 100강] 75강. collections.abc로 컨테이너 인터페이스를 정확하게 설계하기

리스트처럼 보이지만 진짜 리스트는 아닌 객체, 딕셔너리처럼 쓰지만 내부 저장 방식이 다른 객체를 만들 때가 많습니다. 이때 “겉모양만 비슷한” 수준에서 끝내면 협업 중에 금방 깨집니다. 이번 강의에서는 collections.abc를 이용해 컨테이너가 지켜야 할 약속(인터페이스) 을 코드로 명시하는 방법을 바로 실습합니다.


핵심 개념

  • collections.abcIterable, Sequence, Mapping, MutableMapping 같은 추상 베이스 클래스(ABC) 를 제공합니다.
  • ABC를 상속하면 “이 객체가 어떤 프로토콜을 지원하는지”를 타입과 메서드 수준에서 분명하게 표현할 수 있습니다.
  • 최소 필수 메서드만 구현해도 나머지 편의 메서드를 자동으로 얻는 경우가 있어, 구현 누락을 줄이고 재사용성을 높일 수 있습니다.

초보 시기에는 “동작만 하면 됐다”는 기준으로 커스텀 컨테이너를 만들기 쉽습니다. 그런데 팀 코드가 커질수록 문제가 생깁니다. 예를 들어 어떤 함수는 in 연산자를 기대하고, 어떤 함수는 len()과 인덱싱을 기대하고, 또 다른 함수는 .keys()를 기대합니다. 객체가 이런 기대를 만족하지 못하면 런타임에서 뒤늦게 터지고, 에러 메시지도 맥락이 부족해 디버깅 시간이 길어집니다. collections.abc를 쓰면 “이 객체는 Mapping 계약을 따릅니다”, “이 객체는 Sequence처럼 작동합니다”를 설계 단계에서 선언하게 됩니다. 즉, 구현 이전에 계약부터 맞추는 습관을 만들 수 있습니다. 실무에서 이 차이는 큽니다. 코드 리뷰가 쉬워지고, 테스트 범위가 명확해지고, 팀원이 새 객체를 받아도 예상 가능한 방식으로 사용할 수 있습니다.

기본 사용

예제 1) Iterable/Sequence 판별로 입력 계약 명확화

>>> from collections.abc import Iterable, Sequence
>>> isinstance([1, 2, 3], Iterable)
True
>>> isinstance("abc", Sequence)
True
>>> isinstance({"a": 1}, Sequence)
False

해설:

  • Iterable은 반복 가능 여부, Sequence는 순서/인덱싱 기반 컨테이너 여부를 나타냅니다.
  • “반복만 필요하다”와 “인덱스 접근이 필요하다”를 구분해 입력 검증을 분리하는 습관이 중요합니다.

예제 2) Sequence 구현: 필수 메서드 두 개로 시작하기

>>> from collections.abc import Sequence
>>>
>>> class MyRange(Sequence):
...     def __init__(self, start, stop):
...         self.start = start
...         self.stop = stop
...
...     def __len__(self):
...         return max(0, self.stop - self.start)
...
...     def __getitem__(self, index):
...         if isinstance(index, slice):
...             s, e, step = index.indices(len(self))
...             return [self.start + i for i in range(s, e, step)]
...         if index < 0:
...             index += len(self)
...         if index < 0 or index >= len(self):
...             raise IndexError("index out of range")
...         return self.start + index
...
>>> nums = MyRange(10, 15)
>>> len(nums)
5
>>> nums[2]
12
>>> list(nums)
[10, 11, 12, 13, 14]
>>> 13 in nums
True

해설:

  • Sequence__len__, __getitem__만 제대로 구현하면 반복, 포함 검사 등 여러 동작이 자연스럽게 작동합니다.
  • 계약 기반 구현의 장점은 “필수 최소 구현”이 명확하다는 점입니다.

예제 3) MutableMapping 구현으로 커스텀 설정 저장소 만들기

>>> from collections.abc import MutableMapping
>>>
>>> class EnvConfig(MutableMapping):
...     def __init__(self):
...         self._store = {}
...
...     def __getitem__(self, key):
...         return self._store[key.lower()]
...
...     def __setitem__(self, key, value):
...         self._store[key.lower()] = value
...
...     def __delitem__(self, key):
...         del self._store[key.lower()]
...
...     def __iter__(self):
...         return iter(self._store)
...
...     def __len__(self):
...         return len(self._store)
...
>>> cfg = EnvConfig()
>>> cfg["DEBUG"] = True
>>> cfg["Db_Host"] = "localhost"
>>> cfg["debug"]
True
>>> list(cfg.keys())
['debug', 'db_host']

해설:

  • MutableMapping을 상속하면 딕셔너리 유사 API를 표준 방식으로 제공할 수 있습니다.
  • 내부 저장 규칙(여기서는 소문자 정규화)을 안전하게 캡슐화하면서 외부 사용성은 지킬 수 있습니다.

예제 4) 함수 시그니처에서 인터페이스를 요구하기

>>> from collections.abc import Mapping
>>>
>>> def build_query(params: Mapping) -> str:
...     if not isinstance(params, Mapping):
...         raise TypeError("params must satisfy Mapping")
...     return "&".join(f"{k}={v}" for k, v in params.items())
...
>>> build_query({"page": 1, "size": 20})
'page=1&size=20'
>>> build_query([("page", 1)])
Traceback (most recent call last):
...
TypeError: params must satisfy Mapping

해설:

  • “딕셔너리만 받는다”보다 “Mapping 계약을 만족하면 받는다”가 더 유연하고 확장에 강합니다.
  • 라이브러리/유틸 설계에서 concrete type(dict) 고정은 생각보다 빨리 확장 한계를 만듭니다.

자주 하는 실수

실수 1) Sequence를 상속해 놓고 __getitem__ 경계 처리를 생략

>>> from collections.abc import Sequence
>>> class BadSeq(Sequence):
...     def __init__(self):
...         self.data = [10, 20]
...     def __len__(self):
...         return len(self.data)
...     def __getitem__(self, idx):
...         return self.data[idx]  # 슬라이스/음수 정책 고려 부족
...
>>> s = BadSeq()
>>> s[100]
Traceback (most recent call last):
...
IndexError: list index out of range

원인:

  • 내부 리스트에 위임하면 되겠지 하고 끝내 버리면, 슬라이스/음수/에러 메시지 정책이 객체 계약과 어긋날 수 있습니다.

해결:

>>> # __getitem__에서 음수 인덱스 보정, 슬라이스 처리, 명확한 예외를 직접 정의
>>> # (위 MyRange 구현처럼 계약을 명시적으로 맞춘다)

실수 2) isinstance(x, Iterable)로 문자열까지 허용해 버림

>>> from collections.abc import Iterable
>>> def sum_values(values):
...     if not isinstance(values, Iterable):
...         raise TypeError("iterable required")
...     return sum(values)
...
>>> sum_values("123")
Traceback (most recent call last):
...
TypeError: unsupported operand type(s) for +: 'int' and 'str'

원인:

  • 문자열도 Iterable이라 숫자 합산 로직에서 의도치 않게 통과합니다.

해결:

>>> from collections.abc import Iterable
>>> def sum_values(values):
...     if not isinstance(values, Iterable) or isinstance(values, (str, bytes)):
...         raise TypeError("numeric iterable required")
...     return sum(values)
...
>>> sum_values([1, 2, 3])
6

실수 3) Mapping이 필요한데 dict만 허용

  • 증상: UserDict, 커스텀 설정 객체, 읽기 전용 매핑 객체를 전달하면 불필요하게 거절됨
  • 원인: 입력 타입 힌트와 런타임 검사에서 dict로 고정
  • 해결: 타입 힌트를 Mapping[str, Any]로 열고, 런타임 검사도 isinstance(x, Mapping)로 맞춤

실수 4) ABC 상속만 해놓고 필수 메서드 구현 누락

  • 증상: 객체 생성 시점 또는 첫 호출 시 TypeError: Can't instantiate abstract class ...
  • 원인: MutableMapping에서 __iter__, __len__ 등을 빼먹음
  • 해결: 사용하는 ABC의 최소 구현 목록을 체크리스트로 두고 테스트에서 인스턴스 생성부터 검증

실무 패턴

  • 입력 검증 규칙

    • 함수가 요구하는 능력(capability)을 기준으로 Iterable/Sequence/Mapping을 선택합니다.
    • 문자열/바이트 예외 처리처럼 도메인 특수 규칙은 ABC 검사 다음에 추가합니다.
  • 로그/예외 처리 규칙

    • 계약 위반 시 “expected: Mapping, got: list”처럼 기대 인터페이스를 에러 메시지에 포함합니다.
    • 공용 유틸은 TypeError/ValueError를 명확히 구분해 호출자가 빠르게 수정하게 만듭니다.
  • 재사용 함수/구조화 팁

    • 내부 구현체 타입을 숨기고, 공개 API에는 ABC 타입 힌트를 사용합니다.
    • 커스텀 컨테이너는 __repr__를 읽기 쉽게 정의해 디버깅 생산성을 높입니다.
  • 성능/메모리 체크 포인트

    • ABC isinstance 검사 비용은 보통 미미하지만, 핫 루프에서는 반복 검사보다 입구(함수 시작) 1회 검증이 낫습니다.
    • 단순 전달 객체라면 커스텀 클래스 대신 기존 표준 타입 조합이 더 단순하고 빠를 수 있습니다.

오늘의 결론

한 줄 요약: 컨테이너를 만들 때는 동작 흉내보다 인터페이스 계약을 먼저 고정해야, 협업과 확장에서 코드가 안 무너집니다.

기억할 것:

  • dict/list 고정 대신 Mapping/Sequence 같은 능력 중심 타입을 먼저 떠올리세요.
  • ABC 상속 시 필수 메서드 최소 구현 목록을 체크리스트로 관리하세요.
  • 문자열 같은 경계 입력은 ABC 검사 이후 도메인 규칙으로 한 번 더 걸러야 안전합니다.

연습문제

  1. Sequence를 상속한 EvenNumbers(n) 클래스를 만들어 0, 2, 4, ... 형태로 길이 n을 제공하세요. 음수 인덱스와 슬라이스도 처리하세요.
  2. MutableMapping을 상속한 TrimmedConfig를 만들고, 키의 앞뒤 공백을 자동 제거해 저장되게 구현하세요.
  3. Mapping만 받는 to_query_string() 함수를 작성하고, 리스트/문자열 입력 시 명확한 TypeError를 발생시키세요.

이전 강의 정답

  1. 대소문자와 공백이 섞인 문자열을 정규화하는 UserString 클래스
>>> from collections import UserString
>>> class SlugText(UserString):
...     def slug(self):
...         return "-".join(self.data.strip().lower().split())
...
>>> SlugText("  Hello Python World  ").slug()
'hello-python-world'
  1. 금지어가 포함되면 치환하는 UserString 구현
>>> from collections import UserString
>>> class SafeText(UserString):
...     def sanitize(self):
...         banned = {"spoiler": "[redacted]", "leak": "[redacted]"}
...         text = self.data
...         for k, v in banned.items():
...             text = text.replace(k, v)
...         return text
...
>>> SafeText("new spoiler leak").sanitize()
'new [redacted] [redacted]'
  1. 문자열 템플릿에서 {name} 누락 시 기본값 처리
>>> from collections import UserString
>>> class WelcomeText(UserString):
...     def render(self, **ctx):
...         name = ctx.get("name", "guest")
...         return self.data.replace("{name}", name)
...
>>> WelcomeText("Hello, {name}!").render()
'Hello, guest!'
>>> WelcomeText("Hello, {name}!").render(name="Gunwoo")
'Hello, Gunwoo!'

실습 환경/재현 정보

  • 실행 환경: conda env python100 (Python 3.11.14)
  • 가정한 OS: macOS/Linux 공통
  • 사용 모듈: collections.abc (Iterable, Sequence, Mapping, MutableMapping)
  • 실행 방법: 터미널에서 python 또는 ipython 실행 후 예제 순서대로 입력
  • 재현 체크:
    • Sequence 구현 객체에서 len, 인덱싱, in 연산이 기대대로 동작하는지 확인
    • MutableMapping 구현 객체에서 keys/items/get 등 매핑 API가 일관되게 동작하는지 확인
    • 입력 검증 함수에서 Mapping 계약 위반 시 에러 메시지가 명확한지 확인