[파이썬 100강] 74강. UserString으로 문자열 규칙을 안전하게 커스터마이징하기

[파이썬 100강] 74강. UserString으로 문자열 규칙을 안전하게 커스터마이징하기

문자열은 어디에나 있습니다. 사용자 아이디, 이메일, SKU, 로그 키, 캐시 키, URL 조각까지 전부 문자열이죠. 그래서 문자열 규칙을 느슨하게 두면 "어디선가 대문자, 어디선가 공백, 어디선가 한글 정규화 실패" 같은 문제가 쌓입니다. 이번 강의는 collections.UserString으로 문자열 자체에 규칙을 입혀서, 호출부마다 방어 코드를 반복하지 않고도 일관성을 얻는 방법을 다룹니다. 바로 예제로 들어가겠습니다.


핵심 개념

  • UserString은 내장 str을 직접 상속하는 대신 self.data에 실제 문자열을 보관하는 래퍼 타입입니다.
  • 생성 시점(__init__)과 연산 시점(메서드 오버라이드)에 검증/정규화 규칙을 넣어 문자열 정책을 강제할 수 있습니다.
  • "문자열을 쓰는 모든 곳"이 아니라 "문자열 클래스 자체"에 규칙을 두면 누락 포인트가 크게 줄어듭니다.

str은 불변(immutable) 타입이라 확장하려고 하면 생각보다 까다로운 지점이 많습니다. 반면 UserString은 "문자열처럼 동작하지만 정책을 추가하기 쉬운" 구조를 제공합니다. 예를 들어 서비스 전역에서 사용자명을 소문자 + 공백 제거 + 길이 제한으로 맞추고 싶다면, 함수 여러 개에서 strip().lower()를 반복하는 대신 Username 클래스로 고정하는 편이 훨씬 안전합니다. 또 코드리뷰 때도 "여기 정규화했나?"를 매번 확인할 필요가 줄어듭니다. 규칙이 클래스에 들어가 있으니, 인스턴스를 쓰는 순간 자동으로 통일되기 때문입니다.

기본 사용

예제 1) 사용자명 정규화 클래스로 시작하기

>>> from collections import UserString
>>>
>>> class Username(UserString):
...     def __init__(self, value):
...         normalized = str(value).strip().lower()
...         if not normalized:
...             raise ValueError("username cannot be empty")
...         if " " in normalized:
...             raise ValueError("username must not contain spaces")
...         if len(normalized) < 3:
...             raise ValueError("username must be at least 3 chars")
...         super().__init__(normalized)
...
>>> u = Username("  GUNWOO  ")
>>> u
'gunwoo'
>>> isinstance(u, str)
False
>>> str(u)
'gunwoo'

해설:

  • UserString 인스턴스는 str과 유사하게 출력되지만 타입은 다릅니다. 필요하면 str(u)로 원시 문자열을 얻습니다.
  • 검증을 생성자에 넣으면 "잘못된 문자열이 시스템에 들어오기 전에" 즉시 차단됩니다.

예제 2) 도메인 연산 메서드 추가하기

>>> from collections import UserString
>>>
>>> class SKU(UserString):
...     def __init__(self, value):
...         v = str(value).strip().upper().replace("-", "")
...         if not v.isalnum():
...             raise ValueError("SKU must be alphanumeric")
...         if len(v) < 6:
...             raise ValueError("SKU too short")
...         super().__init__(v)
...
...     def prefixed(self):
...         return f"SKU-{self.data}"
...
>>> sku = SKU("ab-12cd")
>>> sku
'AB12CD'
>>> sku.prefixed()
'SKU-AB12CD'

해설:

  • "문자열 + 도메인 규칙 + 도메인 메서드"를 한 클래스로 묶으면 읽기 좋은 코드가 됩니다.
  • 하이픈 제거, 대문자 통일 같은 전처리를 클래스 내부로 숨기면 호출부는 훨씬 단순해집니다.

예제 3) 로그 키 안전화(슬러그) 패턴

>>> import re
>>> from collections import UserString
>>>
>>> class LogKey(UserString):
...     def __init__(self, value):
...         s = str(value).strip().lower()
...         s = re.sub(r"\s+", "_", s)
...         s = re.sub(r"[^a-z0-9_]", "", s)
...         s = re.sub(r"_+", "_", s).strip("_")
...         if not s:
...             raise ValueError("invalid log key")
...         super().__init__(s)
...
>>> k1 = LogKey(" Checkout Success ")
>>> k2 = LogKey("Payment#Fail!!!")
>>> str(k1), str(k2)
('checkout_success', 'paymentfail')

해설:

  • 로깅/메트릭 키는 형식이 흔들리면 집계가 깨집니다. 키 정규화를 한 지점에서 강제하면 운영 품질이 좋아집니다.
  • 정규식은 "입력 정리"에서만 쓰고, 사용처는 LogKey(...)만 호출하도록 단순화하세요.

예제 4) 비교 정책까지 포함하기

>>> from collections import UserString
>>>
>>> class CaseInsensitiveText(UserString):
...     def __eq__(self, other):
...         return self.data.casefold() == str(other).casefold()
...
>>> CaseInsensitiveText("Admin") == "admin"
True
>>> CaseInsensitiveText("straße") == "STRASSE"
True

해설:

  • 국제화 텍스트 비교에는 lower()보다 casefold()가 안전합니다.
  • 팀 규칙상 대소문자 무시 비교가 필요하다면 클래스 차원에서 통일하는 게 실수 방지에 유리합니다.

자주 하는 실수

실수 1) strip/lower를 호출부마다 반복해서 결국 누락하기

>>> raw = "  Alice "
>>> user_a = raw.strip().lower()
>>> user_b = raw.lower()      # strip 누락
>>> user_c = raw.strip()      # lower 누락
>>> user_a, user_b, user_c
('alice', '  alice ', 'Alice')

원인:

  • 문자열 정규화를 "습관"에 맡기면 팀원/파일/시점마다 다르게 처리됩니다.

해결:

>>> from collections import UserString
>>> class SafeUsername(UserString):
...     def __init__(self, value):
...         v = str(value).strip().lower()
...         if not v:
...             raise ValueError("empty")
...         super().__init__(v)
...
>>> str(SafeUsername("  Alice "))
'alice'

실수 2) UserString을 만들었는데 생성자 검증을 비워두기

>>> from collections import UserString
>>> class EmailText(UserString):
...     pass
...
>>> e = EmailText("not-an-email")
>>> e
'not-an-email'

원인:

  • 클래스만 만들면 자동으로 안전해질 거라고 착각합니다. 정책 코드를 넣지 않으면 그냥 포장지일 뿐입니다.

해결:

>>> class SafeEmailText(UserString):
...     def __init__(self, value):
...         v = str(value).strip().lower()
...         if "@" not in v or v.startswith("@") or v.endswith("@"):
...             raise ValueError("invalid email")
...         super().__init__(v)
...
>>> str(SafeEmailText("[email protected] "))
'[email protected]'

실수 3) 문자열 결합 후 타입 보존을 기대하기

>>> from collections import UserString
>>> class Username(UserString):
...     def __init__(self, v):
...         super().__init__(str(v).strip().lower())
...
>>> u = Username("Gunwoo")
>>> x = u + "_dev"
>>> type(x)
<class 'str'>

원인:

  • 연산 결과가 항상 같은 커스텀 타입일 거라고 가정합니다. 상황에 따라 str이 반환될 수 있습니다.

해결:

>>> u2 = Username(str(u) + "_dev")
>>> type(u2), str(u2)
(<class '__main__.Username'>, 'gunwoo_dev')

실무 패턴

  • 입력 검증 규칙

    • 생성자에서 최소 검증(공백 제거, 빈 값 차단, 길이/패턴 체크)을 강제합니다.
    • 검증 실패 메시지는 운영 로그에서 바로 원인을 알 수 있게 구체적으로 작성합니다.
  • 로그/예외 처리 규칙

    • 외부 입력은 try/except ValueError로 감싸 사용자 메시지는 친절하게, 내부 로그는 원본 입력과 예외를 함께 남깁니다.
    • "조용히 보정"할지 "즉시 실패"할지 도메인별 기준을 정해 일관되게 유지합니다.
  • 재사용 함수/구조화 팁

    • BaseNormalizedText(UserString) 같은 공통 베이스를 만들고, 하위 클래스(Username, SKU, LogKey)에서 패턴만 추가하면 중복이 줄어듭니다.
    • API/배치/관리자 도구에서 동일 클래스를 재사용해 데이터 품질을 단일 기준으로 맞춥니다.
  • 성능/메모리 체크 포인트

    • 초당 수만 건 처리 같은 핫패스에서는 래퍼 타입 오버헤드를 벤치마크하세요.
    • 일반적인 백오피스/업무 자동화에서는 미세 성능보다 "오염 방지" 이점이 훨씬 큽니다.
>>> import re
>>> from collections import UserString
>>>
>>> class BaseNormalizedText(UserString):
...     def _normalize(self, value):
...         return str(value).strip()
...     def __init__(self, value):
...         v = self._normalize(value)
...         if not v:
...             raise ValueError("text cannot be empty")
...         super().__init__(v)
...
>>> class SlugText(BaseNormalizedText):
...     def _normalize(self, value):
...         s = super()._normalize(value).lower()
...         s = re.sub(r"\s+", "-", s)
...         s = re.sub(r"[^a-z0-9-]", "", s)
...         return re.sub(r"-+", "-", s).strip("-")
...
>>> str(SlugText("  Python UserString Guide!!  "))
'python-userstring-guide'

위 패턴은 블로그 URL, 문서 키, 캐시 키 같은 "형식 표준화"가 중요한 영역에서 특히 강력합니다. 문자열 오염은 처음엔 사소해 보여도, 시간이 지나면 검색 누락/중복 데이터/로그 분산 같은 운영 비용으로 되돌아옵니다. 클래스로 규칙을 고정해 두면 팀 전체가 같은 기준으로 움직일 수 있습니다.

오늘의 결론

한 줄 요약: 문자열 규칙이 반복된다면 UserString으로 타입을 만들고, 생성자에서 검증·정규화를 강제하라.

기억할 것:

  • 문자열 정책은 호출부가 아니라 타입에 두는 것이 누락을 줄입니다.
  • UserString은 확장성은 좋지만, 필요 시 str() 변환 지점을 명확히 관리해야 합니다.
  • 비교/정규화/도메인 메서드를 함께 묶으면 코드가 짧아지고 리뷰 품질이 올라갑니다.

연습문제

  1. UserString을 상속한 PhoneNumber 클래스를 만들고, 숫자만 남긴 뒤 길이가 10~11자리일 때만 허용하세요.
  2. ProductCode 클래스를 만들어 입력값을 대문자 + 하이픈 제거로 정규화하고, 영문/숫자 조합 8자 이상만 허용하세요.
  3. CaseInsensitiveSet(일반 set 기반)을 구현한다고 가정할 때, 원소를 넣기 전에 UserString 기반 NormalizedWord로 변환하도록 구조를 설계해 보세요.

이전 강의 정답

  1. 중복 없는 소문자 태그 리스트 UniqueTagList
>>> from collections import UserList
>>>
>>> class UniqueTagList(UserList):
...     def _n(self, x):
...         t = str(x).strip().lower()
...         if not t:
...             raise ValueError("empty tag")
...         return t
...     def add(self, x):
...         t = self._n(x)
...         if t not in self.data:
...             self.data.append(t)
...
>>> tags = UniqueTagList()
>>> tags.add(" Python ")
>>> tags.add("python")
>>> tags.add("AI")
>>> tags
['python', 'ai']
  1. 0~1 범위 확률 리스트 ProbabilityList
>>> from collections import UserList
>>>
>>> class ProbabilityList(UserList):
...     def _v(self, x):
...         v = float(x)
...         if not (0.0 <= v <= 1.0):
...             raise ValueError("probability must be 0..1")
...         return v
...     def append(self, item):
...         super().append(self._v(item))
...     def extend(self, other):
...         super().extend(self._v(x) for x in other)
...     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))
...
>>> p = ProbabilityList([0.2, 0.8])
>>> p.append(0.5)
>>> p[0:2] = [0.1, 0.9]
>>> p
[0.1, 0.9, 0.5]
  1. 최근 문서 10개 유지 RecentDocList
>>> from collections import UserList
>>>
>>> class RecentDocList(UserList):
...     def __init__(self, limit=10):
...         self.limit = limit
...         super().__init__()
...     def touch(self, doc_id):
...         d = str(doc_id).strip()
...         if not d:
...             return
...         if d in self.data:
...             self.data.remove(d)
...         self.data.append(d)
...         if len(self.data) > self.limit:
...             del self.data[0]
...
>>> r = RecentDocList(limit=3)
>>> for x in ["A", "B", "C", "B", "D"]:
...     r.touch(x)
...
>>> r
['C', 'B', 'D']

실습 환경/재현 정보

  • 실행 환경: conda env python100 (Python 3.11.14)
  • 가정한 OS: macOS/Linux 공통
  • 사용 모듈: collections.UserString, re (표준 라이브러리)
  • 실행 방법: 터미널에서 python 또는 ipython 실행 후 예제를 순서대로 입력
  • 재현 체크리스트:
    • 생성자 검증이 비어 있지 않은지 확인
    • 정규화 결과가 일관적인지(공백/대소문자/특수문자) 확인
    • 연산 후 타입(str vs 커스텀 타입) 기대치가 문서화되어 있는지 확인