[파이썬 100강] 76강. enum으로 상태와 상수를 안전하게 관리하기

[파이썬 100강] 76강. enum으로 상태와 상수를 안전하게 관리하기

숫자 1, 문자열 "done", 대문자 상수 STATUS_OK = "ok"를 여기저기 흩어 쓰기 시작하면, 프로젝트가 커질수록 상태값 관리가 급격히 불안정해집니다. 이번 강의에서는 enum을 사용해 상태와 상수의 의미를 코드에 명확히 고정하는 방법을 바로 예제로 익힙니다.


핵심 개념

  • Enum은 “허용 가능한 값 집합”을 이름 있는 멤버로 표현해, 매직 문자열/숫자를 줄여줍니다.
  • IntEnum은 정수와의 호환이 필요한 레거시 연동에서 유용하고, StrEnum(3.11+)은 문자열 기반 API 응답과 잘 맞습니다.
  • auto()를 쓰면 값 부여를 자동화할 수 있지만, 외부 시스템과 계약이 있는 경우에는 값을 명시하는 편이 안전합니다.

현업에서 가장 자주 생기는 버그 중 하나가 “값은 맞는데 의미가 틀린 상태”입니다. 예를 들어 주문 상태를 "paid", "PAID", "payment_done"처럼 팀원마다 다르게 쓰면, 분기문은 통과했는데 집계가 틀어지는 상황이 반복됩니다. Enum을 쓰면 가능한 상태를 코드 레벨에서 닫아 둘 수 있어, 오타·중복·비일관성을 크게 줄일 수 있습니다. 또 IDE 자동완성과 타입 힌트가 강하게 작동해 리뷰와 유지보수 속도도 올라갑니다. 특히 결제/배송/권한처럼 “상태 전이”가 중요한 도메인에서는 Enum 도입만으로도 운영 장애율이 눈에 띄게 줄어듭니다.

기본 사용

예제 1) 가장 기본적인 Enum 선언과 비교

>>> from enum import Enum
>>>
>>> class OrderStatus(Enum):
...     PENDING = "pending"
...     PAID = "paid"
...     SHIPPED = "shipped"
...     CANCELED = "canceled"
...
>>> OrderStatus.PAID
<OrderStatus.PAID: 'paid'>
>>> OrderStatus.PAID.value
'paid'
>>> OrderStatus.PAID == "paid"
False
>>> OrderStatus.PAID.value == "paid"
True

해설:

  • Enum 멤버 자체와 원시 값(value)은 다릅니다. 분기에서는 멤버를 비교하고, 직렬화 직전에만 value를 꺼내는 습관이 좋습니다.
  • == "paid" 같은 비교는 의도와 다르게 동작할 수 있으니, 코드베이스 전체에서 비교 규칙을 통일하세요.

예제 2) 문자열 입력을 안전하게 Enum으로 변환

>>> from enum import StrEnum
>>>
>>> class Role(StrEnum):
...     ADMIN = "admin"
...     EDITOR = "editor"
...     VIEWER = "viewer"
...
>>> Role("admin")
<Role.ADMIN: 'admin'>
>>> Role("ADMIN")
Traceback (most recent call last):
...
ValueError: 'ADMIN' is not a valid Role

해설:

  • 외부 입력 문자열을 Role(raw)로 즉시 변환하면, 유효하지 않은 값이 초기에 차단됩니다.
  • 사용자 입력은 대소문자/공백 정규화를 먼저 한 뒤 변환하면 운영 에러를 줄일 수 있습니다.

예제 3) IntEnum으로 레거시 숫자 코드와 호환

>>> from enum import IntEnum
>>>
>>> class HttpCode(IntEnum):
...     OK = 200
...     BAD_REQUEST = 400
...     NOT_FOUND = 404
...
>>> HttpCode.OK == 200
True
>>> int(HttpCode.NOT_FOUND)
404
>>> [code.name for code in HttpCode if code >= 400]
['BAD_REQUEST', 'NOT_FOUND']

해설:

  • 정수 비교가 필요한 기존 코드와 연결할 때 IntEnum은 이행 비용을 낮춰 줍니다.
  • 다만 새 코드에서는 숫자 자체보다 HttpCode.NOT_FOUND처럼 의미 있는 이름 중심으로 작성해야 가독성이 유지됩니다.

예제 4) 상태 전이 규칙을 Enum 기반으로 명시

>>> from enum import Enum
>>>
>>> class TicketStatus(Enum):
...     OPEN = "open"
...     IN_PROGRESS = "in_progress"
...     RESOLVED = "resolved"
...     CLOSED = "closed"
...
>>> ALLOWED_TRANSITIONS = {
...     TicketStatus.OPEN: {TicketStatus.IN_PROGRESS, TicketStatus.CLOSED},
...     TicketStatus.IN_PROGRESS: {TicketStatus.RESOLVED, TicketStatus.CLOSED},
...     TicketStatus.RESOLVED: {TicketStatus.CLOSED},
...     TicketStatus.CLOSED: set(),
... }
>>>
>>> def can_move(current: TicketStatus, target: TicketStatus) -> bool:
...     return target in ALLOWED_TRANSITIONS[current]
...
>>> can_move(TicketStatus.OPEN, TicketStatus.RESOLVED)
False
>>> can_move(TicketStatus.IN_PROGRESS, TicketStatus.RESOLVED)
True

해설:

  • 상태 전이표를 Enum 키로 두면 문자열 오타로 인한 규칙 누락을 막을 수 있습니다.
  • QA 시나리오를 만들 때도 상태 이름이 일관되어 테스트 가독성이 좋아집니다.

자주 하는 실수

실수 1) Enum 멤버와 원시 값을 섞어 비교

>>> from enum import Enum
>>> class Payment(Enum):
...     READY = "ready"
...     DONE = "done"
...
>>> status = Payment.DONE
>>> if status == "done":
...     print("결제 완료")
...

원인:

  • Enum 객체와 문자열을 동일하게 생각해 비교합니다. 실행은 되지만 조건이 거짓이라 조용히 로직이 틀어집니다.

해결:

>>> if status is Payment.DONE:
...     print("결제 완료")
...
결제 완료

실수 2) auto()를 외부 계약 코드에 사용

>>> from enum import Enum, auto
>>> class ExternalStatus(Enum):
...     OK = auto()
...     FAIL = auto()
...
>>> ExternalStatus.OK.value
1

원인:

  • 외부 API가 "OK", "FAIL" 같은 고정 문자열을 요구하는데 내부에서 자동 숫자를 써 버리면 직렬화 시 계약이 깨집니다.

해결:

>>> class ExternalStatus(Enum):
...     OK = "OK"
...     FAIL = "FAIL"
...
>>> ExternalStatus.OK.value
'OK'

실수 3) DB/JSON에서 읽은 문자열을 바로 신뢰

  • 증상: 알 수 없는 상태값이 들어왔는데도 분기문 default로 흘러가 장애를 늦게 발견함
  • 원인: 역직렬화 시 유효성 검증 없이 문자열을 그대로 사용
  • 해결: status = OrderStatus(raw_status)로 즉시 검증하고, ValueError를 잡아 경고 로그 + 격리 처리

실수 4) Enum 이름(name)과 값(value)의 용도를 혼동

  • 증상: 운영 로그에는 PAID를 남겼는데 API 응답에는 paid가 필요해 불일치 발생
  • 원인: name(개발자용 식별자)과 value(외부 표현)를 구분하지 않음
  • 해결: 내부 비교/분기에는 멤버 자체 사용, 외부 I/O 직전만 value, 디버그/로그 정책 문서화

실무 패턴

  • 입력 검증 규칙

    • API/큐/DB에서 들어오는 상태 문자열은 경계 계층에서 즉시 Enum으로 변환합니다.
    • 변환 실패는 400/도메인 에러로 명확히 반환하고, 원본 페이로드를 마스킹 로그로 남깁니다.
  • 로그/예외 처리 규칙

    • 상태 변경 로그는 from=OrderStatus.PENDING to=OrderStatus.PAID처럼 멤버 단위로 기록해 추적성을 높입니다.
    • 허용되지 않은 전이는 ValueError가 아니라 도메인 전용 예외(예: InvalidTransitionError)로 분리하는 것이 좋습니다.
  • 재사용 함수/구조화 팁

    • parse_status(raw: str) -> OrderStatus 같은 단일 진입점을 만들어 변환 정책을 중앙화합니다.
    • Enum과 상태 전이표를 같은 모듈에 두고, 테스트도 전이표 기준으로 파라미터화하면 누락이 줄어듭니다.
  • 성능/메모리 체크 포인트

    • 일반적인 웹/백엔드 트래픽에서는 Enum 오버헤드가 미미하지만, 초고빈도 루프에서는 반복적인 문자열↔Enum 변환을 캐시하세요.
    • 대량 직렬화 시 .value 추출을 배치 처리하면 미세한 비용을 줄일 수 있습니다.

오늘의 결론

한 줄 요약: 상태/상수는 문자열로 흩뿌리지 말고 Enum으로 닫아 두면, 코드 품질과 운영 안정성이 같이 올라갑니다.

기억할 것:

  • 내부 분기에서는 Enum 멤버를 비교하고, 외부 I/O 직전에만 .value를 사용하세요.
  • 외부 계약(HTTP, DB 코드)이 있는 값은 auto()보다 명시값이 안전합니다.
  • 상태 전이 규칙을 Enum 기반 테이블로 고정하면 테스트·리뷰·운영 대응이 쉬워집니다.

연습문제

  1. OrderPhase Enum(CREATED, PAID, PACKED, SHIPPED)을 만들고, 문자열 입력을 Enum으로 변환하는 parse_order_phase() 함수를 작성하세요. 잘못된 입력에는 ValueError를 발생시키세요.
  2. IntEnum으로 우선순위(LOW=1, MEDIUM=2, HIGH=3)를 만들고, 우선순위 목록을 높은 순으로 정렬하는 코드를 작성하세요.
  3. 티켓 상태 Enum을 만들고, 현재 상태에서 이동 가능한 다음 상태 목록을 반환하는 next_states() 함수를 작성하세요.

이전 강의 정답

  1. EvenNumbers(n) 시퀀스 구현 (음수 인덱스/슬라이스 지원)
>>> from collections.abc import Sequence
>>> class EvenNumbers(Sequence):
...     def __init__(self, n):
...         self.n = n
...     def __len__(self):
...         return self.n
...     def __getitem__(self, index):
...         if isinstance(index, slice):
...             s, e, step = index.indices(len(self))
...             return [2 * 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 2 * index
...
>>> nums = EvenNumbers(5)
>>> nums[-1]
8
>>> nums[1:4]
[2, 4, 6]
  1. 키 앞뒤 공백을 제거하는 TrimmedConfig
>>> from collections.abc import MutableMapping
>>> class TrimmedConfig(MutableMapping):
...     def __init__(self):
...         self._data = {}
...     def __getitem__(self, key):
...         return self._data[key.strip()]
...     def __setitem__(self, key, value):
...         self._data[key.strip()] = value
...     def __delitem__(self, key):
...         del self._data[key.strip()]
...     def __iter__(self):
...         return iter(self._data)
...     def __len__(self):
...         return len(self._data)
...
>>> cfg = TrimmedConfig()
>>> cfg["  host  "] = "localhost"
>>> cfg["host"]
'localhost'
  1. Mapping만 받는 to_query_string() 구현
>>> from collections.abc import Mapping
>>> def to_query_string(params):
...     if not isinstance(params, Mapping):
...         raise TypeError("params must satisfy Mapping")
...     return "&".join(f"{k}={v}" for k, v in params.items())
...
>>> to_query_string({"page": 1, "size": 20})
'page=1&size=20'
>>> to_query_string([("page", 1)])
Traceback (most recent call last):
...
TypeError: params must satisfy Mapping

실습 환경/재현 정보

  • 실행 환경: conda env python100 (Python 3.11.14)
  • 가정한 OS: macOS/Linux 공통
  • 사용 모듈: enum (Enum, IntEnum, StrEnum, auto)
  • 실행 방법: 터미널에서 python 또는 ipython 실행 후 예제를 순서대로 입력
  • 재현 체크:
    • Enum 멤버 비교(is)와 .value 비교 차이를 직접 확인
    • 문자열 입력 변환 시 잘못된 값이 ValueError로 차단되는지 확인
    • 상태 전이 테이블에서 허용/비허용 전이가 기대대로 판단되는지 확인