[파이썬 100강] 73강. UserList로 리스트 규칙을 안전하게 커스터마이징하기

[파이썬 100강] 73강. UserList로 리스트 규칙을 안전하게 커스터마이징하기

실무에서 리스트는 거의 모든 곳에 등장합니다. 그런데 "그냥 append 하면 된다" 수준으로 쓰다 보면 데이터 타입이 섞이거나, 중복이 누적되거나, 정렬 기준이 뒤섞여서 나중에 품질 문제가 터집니다. 이번 강의에서는 collections.UserList를 사용해 리스트 자체에 규칙을 내장하는 방법을 익힙니다. 서론은 여기까지 하고, 바로 코드로 감각을 잡아봅시다.


핵심 개념

  • UserList는 내장 list를 직접 상속하는 대신, 내부 데이터(self.data)를 감싸는 래퍼형 리스트 클래스입니다.
  • append, extend, insert, 슬라이싱 할당 같은 변경 지점을 오버라이드해 입력 검증/정규화 정책을 한곳에 모을 수 있습니다.
  • "리스트를 쓰는 모든 호출부"에 검증 코드를 반복하는 대신, 자료구조 레벨에서 일관성을 강제할 수 있습니다.

UserList의 실전 가치는 "정책을 호출부에서 떼어내는 것"입니다. 예를 들어 태그 목록은 문자열만 허용하고, 공백은 제거하고, 소문자로 통일해야 할 수 있습니다. 이 규칙을 API 핸들러, 배치 스크립트, 관리자 도구에 각각 적어 두면 시간이 갈수록 누락이 생깁니다. 반면 UserList로 규칙을 만든 뒤에는 어디서 값을 넣든 동일한 동작을 얻을 수 있습니다. 또 팀원 입장에서도 타입/정규화 규칙을 클래스 한 파일에서 확인할 수 있어 코드리뷰가 빨라집니다. 성능 미세 최적화보다 "오염 방지"가 중요한 업무 코드에서는 특히 효과가 큽니다.

기본 사용

예제 1) 문자열 태그만 받고 자동 정규화하기

>>> from collections import UserList
>>>
>>> class TagList(UserList):
...     def _normalize(self, value):
...         if not isinstance(value, str):
...             raise TypeError("tag must be str")
...         tag = value.strip().lower()
...         if not tag:
...             raise ValueError("empty tag is not allowed")
...         return tag
...
...     def append(self, item):
...         super().append(self._normalize(item))
...
...     def extend(self, other):
...         super().extend(self._normalize(x) for x in other)
...
>>> tags = TagList()
>>> tags.append(" Python ")
>>> tags.extend(["AI", " DevOps "])
>>> tags
['python', 'ai', 'devops']

해설:

  • _normalize()를 별도 메서드로 분리하면 검증 규칙을 재사용하기 쉽습니다.
  • appendextend를 모두 오버라이드해야 "어떤 입력 경로든" 정규화가 보장됩니다.

예제 2) 숫자 점수 리스트에 범위 검증 붙이기

>>> from collections import UserList
>>>
>>> class ScoreList(UserList):
...     def _check(self, score):
...         if not isinstance(score, (int, float)):
...             raise TypeError("score must be number")
...         if not (0 <= score <= 100):
...             raise ValueError("score must be between 0 and 100")
...         return float(score)
...
...     def append(self, item):
...         super().append(self._check(item))
...
...     def insert(self, i, item):
...         super().insert(i, self._check(item))
...
...     def extend(self, other):
...         super().extend(self._check(x) for x in other)
...
>>> scores = ScoreList([95.0])
>>> scores.append(88)
>>> scores.insert(0, 100)
>>> scores
[100.0, 95.0, 88.0]
>>> round(sum(scores) / len(scores), 2)
94.33

해설:

  • 숫자 목록처럼 도메인 규칙이 분명한 데이터는 리스트 클래스에서 미리 차단하는 편이 안전합니다.
  • insert까지 오버라이드해야 "중간 삽입" 경로에서도 검증 누락이 없습니다.

예제 3) 중복 없는 최근 검색어 리스트

>>> from collections import UserList
>>>
>>> class RecentQueryList(UserList):
...     def __init__(self, limit=5, initlist=None):
...         self.limit = limit
...         super().__init__(initlist or [])
...
...     def add(self, query):
...         q = query.strip()
...         if not q:
...             return
...         if q in self.data:
...             self.data.remove(q)
...         self.data.append(q)
...         if len(self.data) > self.limit:
...             del self.data[0]
...
>>> recent = RecentQueryList(limit=5)
>>> for q in ["python", "list", "cache", "api", "test", "python", "typing"]:
...     recent.add(q)
...
>>> recent
['list', 'cache', 'api', 'test', 'python', 'typing']
>>> recent[-5:]
['cache', 'api', 'test', 'python', 'typing']

해설:

  • "최근성 유지 + 중복 제거"는 검색, 추천, 히스토리 기능에서 자주 쓰는 패턴입니다.
  • 이 예제는 동작 이해를 위해 단순화했습니다. 엄밀히 5개 제한을 강제하려면 리스트 길이 조정 로직을 조금 더 명확히 구성하면 됩니다.

예제 4) 슬라이싱 할당까지 검증하려면 __setitem__도 고려하기

>>> from collections import UserList
>>>
>>> class IntList(UserList):
...     def _v(self, x):
...         if not isinstance(x, int):
...             raise TypeError("only int")
...         return x
...
...     def append(self, item):
...         super().append(self._v(item))
...
...     def __setitem__(self, i, item):
...         if isinstance(i, slice):
...             super().__setitem__(i, [self._v(x) for x in item])
...         else:
...             super().__setitem__(i, self._v(item))
...
>>> nums = IntList([1, 2, 3])
>>> nums[1] = 10
>>> nums[0:2] = [7, 8]
>>> nums
[7, 8, 3]

해설:

  • 리스트는 단일 대입(a[1]=...)뿐 아니라 슬라이싱 대입(a[1:3]=...)도 자주 일어납니다.
  • "검증을 진짜 완성"하려면 변경 경로를 빠짐없이 점검해야 합니다.

자주 하는 실수

실수 1) append만 막아두고 extend는 열어두기

>>> from collections import UserList
>>>
>>> class NumberList(UserList):
...     def append(self, item):
...         if not isinstance(item, (int, float)):
...             raise TypeError("number only")
...         super().append(item)
...
>>> n = NumberList()
>>> n.append(1)
>>> n.extend([2, "oops"])   # 검증 누락
>>> n
[1, 2, 'oops']

원인:

  • 리스트 변경 API가 append 하나라고 착각하면 검증 구멍이 생깁니다.

해결:

>>> class SafeNumberList(UserList):
...     def _v(self, x):
...         if not isinstance(x, (int, float)):
...             raise TypeError("number only")
...         return x
...     def append(self, item):
...         super().append(self._v(item))
...     def extend(self, other):
...         super().extend(self._v(x) for x in other)
...
>>> s = SafeNumberList()
>>> s.extend([1, 2, 3])
>>> s
[1, 2, 3]

실수 2) 초기값(__init__)은 검증 없이 통과시키기

>>> from collections import UserList
>>>
>>> class EmailList(UserList):
...     def append(self, item):
...         if "@" not in item:
...             raise ValueError("invalid email")
...         super().append(item)
...
>>> e = EmailList(["[email protected]", "broken-email"])  # 초기값 오염
>>> e
['[email protected]', 'broken-email']

원인:

  • 생성자에서 전달한 initlist는 별도 검증 없이 들어갈 수 있다는 점을 놓치기 쉽습니다.

해결:

>>> class SafeEmailList(UserList):
...     def __init__(self, initlist=None):
...         super().__init__()
...         if initlist:
...             self.extend(initlist)
...     def _v(self, x):
...         if not isinstance(x, str) or "@" not in x:
...             raise ValueError("invalid email")
...         return x
...     def append(self, item):
...         super().append(self._v(item))
...     def extend(self, other):
...         super().extend(self._v(x) for x in other)
...
>>> SafeEmailList(["[email protected]", "[email protected]"])
['[email protected]', '[email protected]']

실수 3) self.data를 외부에서 직접 조작

  • 증상: 클래스 규칙이 있는데도 이상한 값이 리스트에 섞임
  • 원인: obj.data.append(...)처럼 내부 저장소를 직접 만져 검증 경로를 우회함
  • 해결: 외부 사용자는 append/extend/insert만 사용하게 규약화하고, 코드리뷰에서 data 직접 접근을 금지

실수 4) "정렬된 리스트" 규칙을 말로만 관리

  • 증상: 어떤 코드에서는 정렬 상태, 어떤 코드에서는 비정렬 상태라 이진탐색/범위조회가 틀림
  • 원인: 삽입 시 정렬 재보장을 강제하지 않음
  • 해결: add() 같은 단일 진입 API를 만들고 내부에서 삽입 위치를 통제

실무 패턴

  • 입력 검증 규칙

    • _validate()_normalize()를 분리해 "형식 검증"과 "표준화"를 명확히 나눕니다.
    • 검증 예외 타입도 의도적으로 구분하세요. 타입 문제는 TypeError, 값 범위 문제는 ValueError가 유지보수에 유리합니다.
  • 로그/예외 처리 규칙

    • 정책 위반 입력은 조용히 버리지 말고, 최소한 애플리케이션 로그에 이유를 남겨야 원인 추적이 가능합니다.
    • 사용자 입력 경로에서는 예외를 잡아 메시지를 친절히 바꾸되, 내부 로그에는 원본 예외 타입과 값을 남겨두세요.
  • 재사용 함수/구조화 팁

    • 프로젝트에서 반복되는 "검증 리스트" 패턴은 BaseValidatedList(UserList)로 공통화하면 코드 중복이 크게 줄어듭니다.
    • 도메인 이름을 붙인 클래스를 쓰세요. TagList, ScoreList, AllowedIPs처럼 이름 자체가 규칙 문서 역할을 합니다.
  • 성능/메모리 체크 포인트

    • UserList는 래퍼 구조라 극단적 핫패스에서는 오버헤드가 있을 수 있습니다. 하지만 대부분의 업무 로직에서는 안정성 이점이 훨씬 큽니다.
    • 정말 고빈도 경로라면 벤치마크 후 내장 list + 별도 검증 전략으로 전환 여부를 판단하세요.
>>> from collections import UserList
>>> import ipaddress
>>>
>>> class AllowedIPs(UserList):
...     def _v(self, x):
...         return str(ipaddress.ip_address(x))
...     def append(self, item):
...         super().append(self._v(item))
...     def extend(self, other):
...         super().extend(self._v(x) for x in other)
...
>>> allow = AllowedIPs()
>>> allow.extend(["192.168.0.1", "10.0.0.7"])
>>> allow.append("127.0.0.1")
>>> allow
['192.168.0.1', '10.0.0.7', '127.0.0.1']

이 패턴은 보안/네트워크 설정 화면에서 특히 유용합니다. 잘못된 IP 문자열이 들어오면 등록 단계에서 즉시 실패하므로, 운영 중 파싱 에러를 뒤늦게 맞는 일을 줄일 수 있습니다.

오늘의 결론

한 줄 요약: 리스트에 규칙이 생기는 순간, UserList로 규칙을 자료구조 내부에 고정해 누락과 오염을 막아라.

기억할 것:

  • append만이 아니라 extend/insert/__setitem__ 등 모든 변경 경로를 점검해야 합니다.
  • 초기값(initlist)도 검증 대상입니다. 생성자 경로를 놓치면 반쪽짜리 안전장치가 됩니다.
  • 호출부 검증 중복을 줄이고 싶다면, 도메인 전용 UserList 클래스를 만드는 것이 가장 깔끔합니다.

연습문제

  1. UserList를 상속해 UniqueTagList를 만들고, 문자열 태그를 소문자로 정규화한 뒤 중복 없이 유지하세요.
  2. 0~1 사이 실수만 허용하는 ProbabilityList를 구현하고, append, extend, 슬라이싱 할당 모두 검증되게 작성하세요.
  3. 최근 본 문서 ID를 최대 10개만 저장하는 RecentDocList를 구현하세요. 이미 있는 ID가 다시 들어오면 맨 뒤로 이동하고 길이는 10을 넘지 않게 하세요.

이전 강의 정답

  1. 키를 snake_case로 정규화하는 UserDict
>>> import re
>>> from collections import UserDict
>>>
>>> class SnakeMap(UserDict):
...     def _k(self, key):
...         s = re.sub(r"\s+", "_", key.strip())
...         s = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", s)
...         return s.lower()
...     def __setitem__(self, key, value):
...         super().__setitem__(self._k(key), value)
...     def __getitem__(self, key):
...         return super().__getitem__(self._k(key))
...
>>> m = SnakeMap()
>>> m["User Name"] = "Kim"
>>> m["userName"] = "Lee"
>>> dict(m)
{'user_name': 'Lee'}
  1. 주문 상태만 허용하는 OrderStateMap
>>> from collections import UserDict
>>>
>>> class OrderStateMap(UserDict):
...     ALLOWED = {"pending", "paid", "cancelled"}
...     def __setitem__(self, key, value):
...         if value not in self.ALLOWED:
...             raise ValueError("invalid order state")
...         super().__setitem__(key, value)
...
>>> s = OrderStateMap()
>>> s["A100"] = "pending"
>>> s["A100"] = "paid"
>>> s
{'A100': 'paid'}
  1. 읽기 전용 키 재할당 방지 ReadOnlyKeysMap
>>> from collections import UserDict
>>>
>>> class ReadOnlyKeysMap(UserDict):
...     def __init__(self, readonly_keys, *args, **kwargs):
...         super().__init__(*args, **kwargs)
...         self.readonly_keys = set(readonly_keys)
...     def __setitem__(self, key, value):
...         if key in self.readonly_keys and key in self.data:
...             raise PermissionError(f"{key} is read-only")
...         super().__setitem__(key, value)
...
>>> r = ReadOnlyKeysMap(readonly_keys={"service_name"})
>>> r["service_name"] = "devlab-api"
>>> r["feature_x"] = True
>>> r
{'service_name': 'devlab-api', 'feature_x': True}

실습 환경/재현 정보

  • 실행 환경: conda env python100 (Python 3.11.14)
  • 가정한 OS: macOS/Linux 공통
  • 사용 모듈: collections.UserList, ipaddress (표준 라이브러리)
  • 실행 방법: 터미널에서 python 또는 ipython 실행 후 예제 순서대로 입력
  • 재현 체크리스트:
    • append, extend, insert, __setitem__(슬라이싱 포함)에서 검증 누락이 없는지 확인
    • 생성자 초기값 경로에서도 동일 검증이 적용되는지 확인
    • 예외 타입이 의도대로 분리되는지(TypeError vs ValueError) 확인