[파이썬 100강] 80강. functools.singledispatch로 타입별 처리 로직 깔끔하게 분기하기

[파이썬 100강] 80강. functools.singledispatch로 타입별 처리 로직 깔끔하게 분기하기

실무에서 데이터를 처리하다 보면 같은 함수 이름으로 "문자열이면 이렇게", "딕셔너리면 저렇게" 처리해야 할 때가 자주 있습니다. 보통은 if isinstance(...) 분기를 길게 늘어뜨리는데, 케이스가 늘어날수록 함수가 비대해지고 수정도 위험해집니다. 이번 강의에서는 functools.singledispatch로 타입별 로직을 분리해, 읽기 쉽고 확장 가능한 구조를 만드는 방법을 바로 예제로 정리합니다.


핵심 개념

  • @singledispatch첫 번째 인자의 타입을 기준으로 함수를 디스패치(분기)합니다.
  • 기본 함수(디폴트 구현)를 하나 두고, .register(타입)으로 타입별 구현을 추가합니다.
  • if/elif isinstance 체인을 대체해, "기능 이름은 하나, 구현은 타입별로 분리"하는 구조를 만들 수 있습니다.

singledispatch의 핵심은 "분기 규칙을 함수 몸체가 아니라 등록 레이어로 분리"한다는 점입니다. 기존 방식은 함수 하나 안에 분기와 비즈니스 로직이 섞여서 읽기 어렵고, 타입 하나를 추가할 때마다 본문을 계속 수정해야 합니다. 반면 singledispatch는 새 타입 지원이 필요하면 .register() 구현만 추가하면 됩니다. 즉, 기존 구현을 덜 건드리면서 기능을 확장할 수 있습니다.

또한 팀 협업에서 장점이 큽니다. 예를 들어 로그 포맷 함수 to_log_line()이 있을 때, API 팀은 dict 처리기를, 데이터 팀은 list 처리기를, 플랫폼 팀은 사용자 정의 클래스 처리기를 각각 별도 코드로 등록할 수 있습니다. 충돌이 줄고 책임 경계도 명확해집니다.

기본 사용

예제 1) 가장 기본 패턴: 기본 구현 + 타입 등록

>>> from functools import singledispatch
>>>
>>> @singledispatch
... def normalize(value):
...     return f"[default] {value}"
...
>>> @normalize.register
... def _(value: str):
...     return value.strip().lower()
...
>>> @normalize.register
... def _(value: int):
...     return value * 10
...
>>> normalize("  PyThOn  ")
'python'
>>> normalize(7)
70
>>> normalize(3.14)
'[default] 3.14'

해설:

  • @singledispatch가 붙은 함수가 디폴트 구현입니다.
  • @normalize.register에서 타입 힌트(value: str, value: int)를 읽어 자동 등록됩니다.
  • 등록되지 않은 타입(float)은 디폴트 구현으로 떨어집니다.

예제 2) 컬렉션 타입별 변환 규칙 분리

>>> from functools import singledispatch
>>>
>>> @singledispatch
... def to_payload(data):
...     raise TypeError(f"unsupported type: {type(data).__name__}")
...
>>> @to_payload.register
... def _(data: dict):
...     return {"kind": "object", "value": data}
...
>>> @to_payload.register
... def _(data: list):
...     return {"kind": "array", "value": data}
...
>>> @to_payload.register
... def _(data: str):
...     return {"kind": "text", "value": data}
...
>>> to_payload({"name": "gunwoo"})
{'kind': 'object', 'value': {'name': 'gunwoo'}}
>>> to_payload([1, 2, 3])
{'kind': 'array', 'value': [1, 2, 3]}
>>> to_payload("hello")
{'kind': 'text', 'value': 'hello'}

해설:

  • API 직렬화 같은 작업에서 특히 유용합니다.
  • "타입 판별"과 "변환 로직"이 분리되어 테스트도 쉬워집니다.
  • 기본 구현에서 TypeError를 명시적으로 던지면, 미지원 타입을 초기에 빠르게 발견할 수 있습니다.

예제 3) 사용자 정의 클래스 등록

>>> from dataclasses import dataclass
>>> from functools import singledispatch
>>>
>>> @dataclass
... class User:
...     id: int
...     name: str
...
>>> @dataclass
... class Order:
...     no: str
...     amount: int
...
>>> @singledispatch
... def summary(obj):
...     return f"unknown:{type(obj).__name__}"
...
>>> @summary.register
... def _(obj: User):
...     return f"User#{obj.id}:{obj.name}"
...
>>> @summary.register
... def _(obj: Order):
...     return f"Order({obj.no})={obj.amount}"
...
>>> summary(User(1, "Toby"))
'User#1:Toby'
>>> summary(Order("A-100", 39000))
'Order(A-100)=39000'

해설:

  • 도메인 객체마다 표현 규칙이 다를 때 깔끔하게 나뉩니다.
  • 클래스가 늘어나도 기존 본문을 크게 건드리지 않아도 됩니다.

예제 4) 등록 여부와 디버깅 포인트 확인

>>> from functools import singledispatch
>>>
>>> @singledispatch
... def render(x):
...     return "default"
...
>>> @render.register
... def _(x: int):
...     return "int"
...
>>> render.dispatch(int)
<function _ at ...>
>>> render.dispatch(str)
<function render at ...>
>>> sorted(t.__name__ for t in render.registry.keys())
['int', 'object']

해설:

  • dispatch(타입)으로 실제 어떤 구현이 선택되는지 확인할 수 있습니다.
  • registry를 보면 어떤 타입이 등록되었는지 점검할 수 있어 운영 디버깅에 유용합니다.

자주 하는 실수

실수 1) 첫 번째 인자가 아닌 값 기준으로 분기될 거라고 착각

>>> from functools import singledispatch
>>>
>>> @singledispatch
... def calc(a, b):
...     return "default"
...
>>> @calc.register
... def _(a: int, b):
...     return "int-first"
...
>>> calc("10", 3)
'default'
>>> calc(10, "3")
'int-first'

원인:

  • singledispatch오직 첫 번째 인자 타입만 봅니다. 두 번째 인자는 디스패치 기준이 아닙니다.

해결:

>>> # 두 번째 인자까지 포함한 복합 분기가 필요하면
>>> # 일반 함수 내부 분기 또는 별도 설계(예: 키 함수/전략 객체)를 사용한다.
>>> def calc_fixed(a, b):
...     if isinstance(a, int) and isinstance(b, str):
...         return "int+str"
...     if isinstance(a, str) and isinstance(b, int):
...         return "str+int"
...     return "default"
...
>>> calc_fixed(10, "3")
'int+str'

실수 2) 공통 부모 타입을 먼저 등록해 하위 타입 처리기가 안 불릴 거라 오해

  • 증상: boolint의 하위 타입이라 결과가 예상과 다르게 나옴
  • 원인: 파이썬 타입 계층을 놓침 (isinstance(True, int)True)
  • 해결: bool처럼 예외 케이스는 별도로 등록해 우선 의도를 명확히 표현
>>> from functools import singledispatch
>>>
>>> @singledispatch
... def tag(x):
...     return "default"
...
>>> @tag.register
... def _(x: int):
...     return "int"
...
>>> tag(True)
'int'
>>> @tag.register
... def _(x: bool):
...     return "bool"
...
>>> tag(True)
'bool'

실수 3) 인스턴스 메서드에 그대로 적용하려다 시그니처가 꼬임

  • 증상: 클래스 메서드에서 기대와 다르게 self 기준으로 분기됨
  • 원인: singledispatch는 첫 번째 인자를 기준으로 동작하는데, 메서드의 첫 번째 인자는 보통 self
  • 해결: 메서드에는 functools.singledispatchmethod를 사용하거나, 디스패치 함수를 클래스 밖으로 분리
>>> from functools import singledispatchmethod
>>>
>>> class Formatter:
...     @singledispatchmethod
...     def fmt(self, value):
...         return f"default:{value}"
...     @fmt.register
...     def _(self, value: int):
...         return f"int:{value}"
...
>>> f = Formatter()
>>> f.fmt(3)
'int:3'
>>> f.fmt("x")
'default:x'

실무 패턴

  • 입력 검증 규칙

    • 디폴트 구현에서 TypeError를 던질지, 안전한 폴백 문자열을 반환할지 먼저 정책을 정합니다.
    • 외부 입력(API body, queue message)은 디스패치 전에 스키마 검증을 한 번 거쳐 "타입은 맞는데 값이 잘못된" 상황을 분리합니다.
  • 로그/예외 처리 규칙

    • 디폴트 구현에서 unsupported type 로그를 남길 때는 type.__name__와 샘플 값을 함께 기록해 역추적성을 확보합니다.
    • 등록 함수 내부에서 예외를 삼키지 말고, 컨텍스트(요청 ID, 사용자 ID)를 붙여 재전파합니다.
  • 재사용 함수/구조화 팁

    • serialize, normalize, to_row, to_log_line처럼 "타입별 변환"이 반복되는 지점은 singledispatch 후보입니다.
    • 모듈 단위로 "기본 함수 + register 모음"을 둬서 기능 확장 포인트를 명확히 드러내면 팀 작업이 쉬워집니다.
  • 성능/메모리 체크 포인트

    • 디스패치 오버헤드는 작지만, 초고빈도 루프(수백만 회)에서는 직접 분기보다 느릴 수 있습니다.
    • 병목이 의심되면 마이크로벤치(timeit)로 비교하고, 핫패스에는 단순 분기/벡터화 등 더 직접적인 전략을 고려합니다.

오늘의 결론

한 줄 요약: singledispatch는 "조건문 덩어리"를 "타입별 확장 포인트"로 바꿔, 유지보수성과 협업성을 동시에 높여준다.

기억할 것:

  • 디스패치 기준은 첫 번째 인자 타입 하나입니다.
  • 디폴트 구현의 실패 정책(에러/폴백)을 명확히 정해야 운영 중 혼란이 줄어듭니다.
  • 메서드에는 singledispatchmethod를 쓰는 게 안전합니다.

연습문제

  1. @singledispatchto_csv_cell(value) 함수를 만드세요. int는 그대로 문자열, float는 소수 둘째 자리, None은 빈 문자열로 변환해 보세요.
  2. dict, list, str를 받아 공통 로그 포맷으로 바꾸는 to_log(data)를 작성하고, 미지원 타입은 TypeError를 내도록 구현해 보세요.
  3. 클래스 PriceFormatter를 만들고 @singledispatchmethodint, float, str에 대해 서로 다른 포맷을 출력해 보세요.

이전 강의 정답

  1. temporary_env(key, value) 컨텍스트 매니저 구현
>>> import os
>>> from contextlib import contextmanager
>>>
>>> @contextmanager
... def temporary_env(key: str, value: str):
...     existed = key in os.environ
...     old = os.environ.get(key)
...     os.environ[key] = value
...     try:
...         yield
...     finally:
...         if existed:
...             os.environ[key] = old  # type: ignore[arg-type]
...         else:
...             os.environ.pop(key, None)
...
>>> os.environ.get("APP_MODE") is None
True
>>> with temporary_env("APP_MODE", "test"):
...     print(os.environ.get("APP_MODE"))
...
test
>>> os.environ.get("APP_MODE") is None
True
  1. ExitStack으로 여러 파일 첫 줄 읽기
>>> from contextlib import ExitStack
>>>
>>> def read_first_lines(paths: list[str]) -> list[str]:
...     with ExitStack() as stack:
...         files = [stack.enter_context(open(p, "r", encoding="utf-8")) for p in paths]
...         return [f.readline().rstrip("\n") for f in files]
...
>>> # 예시 파일이 있다고 가정
>>> # read_first_lines(["a.txt", "b.txt"])
>>> # ['hello', 'python']
  1. error_boundary 확장: 재시도 횟수 로그 + 최종 실패 재전파
>>> from contextlib import contextmanager
>>>
>>> @contextmanager
... def error_boundary(task_name: str, attempt: int):
...     try:
...         yield
...     except Exception as e:
...         print(f"[ERROR] task={task_name} attempt={attempt} err={type(e).__name__}")
...         raise
...
>>> for i in range(1, 4):
...     try:
...         with error_boundary("sync-job", i):
...             if i < 3:
...                 raise RuntimeError("temporary")
...             print("success")
...         break
...     except RuntimeError:
...         if i == 3:
...             raise
...
[ERROR] task=sync-job attempt=1 err=RuntimeError
[ERROR] task=sync-job attempt=2 err=RuntimeError
success

실습 환경/재현 정보

  • 실행 환경: conda env python100 (Python 3.11.14)
  • 가정한 OS: macOS/Linux 공통
  • 사용 모듈: functools (singledispatch, singledispatchmethod), dataclasses
  • 재현 체크:
    • 등록되지 않은 타입이 디폴트 구현으로 정확히 떨어지는지
    • bool/int처럼 타입 계층이 있는 케이스에서 의도한 구현이 호출되는지
    • 메서드 구현에서 singledispatchmethod를 써서 self 이슈가 없는지