[파이썬 100강] 87강. warnings로 위험 신호를 조용하지만 확실하게 전달하기

[파이썬 100강] 87강. warnings로 위험 신호를 조용하지만 확실하게 전달하기

에러를 바로 터뜨리면 사용자가 불편하고, 아무 말 없이 넘어가면 나중에 장애가 커집니다. warnings는 이 사이를 메워 주는 도구입니다. 이번 강의에서는 서론 길게 끌지 않고, "지금은 동작하지만 곧 문제가 될 수 있는 코드"를 어떻게 신호로 남길지 바로 예제로 익혀보겠습니다.


핵심 개념

  • warnings.warn()은 프로그램 실행을 중단하지 않으면서 "주의가 필요한 상태"를 알리는 표준 경고를 발생시킵니다.
  • 경고는 카테고리(UserWarning, DeprecationWarning, RuntimeWarning 등)로 분류할 수 있고, 필터 정책으로 표시/무시/에러 전환을 제어할 수 있습니다.
  • 테스트나 CI에서는 경고를 에러로 승격해 기술 부채를 조기에 잡고, 운영에서는 과도한 노이즈를 줄이도록 정책을 나눠 적용하는 것이 핵심입니다.

warnings를 처음 볼 때 많은 분이 "그냥 print()로 안내하면 되지 않나요?"라고 묻습니다. 하지만 print()는 구조화되지 않아 자동화가 어렵고, 끄고 켜는 기준도 없습니다. 반면 warnings는 표준 형식과 카테고리를 가지므로 팀 단위 정책을 만들 수 있습니다. 예를 들어 오래된 함수 사용은 DeprecationWarning으로, 수치 안정성 저하는 RuntimeWarning으로 분리하면 나중에 특정 경고만 추적하기 쉬워집니다. 또한 경고는 기본적으로 실행을 멈추지 않으므로, 서비스 사용자 경험을 해치지 않으면서 개발팀에게 개선 신호를 보낼 수 있습니다. 즉, warnings는 "장애 직전의 징후"를 코드 수준에서 관리하는 안전장치입니다.

기본 사용

예제 1) warnings.warn()으로 기본 경고 보내기

>>> import warnings
>>> def normalize_username(name):
...     if name != name.strip():
...         warnings.warn("앞뒤 공백이 포함된 사용자명입니다. strip() 적용을 권장합니다.", UserWarning)
...     return name.strip().lower()
...
>>> normalize_username("  GeonWoo  ")
'geonwoo'

해설:

  • 함수는 정상 결과를 반환하면서도, 입력 품질 이슈를 경고로 남깁니다.
  • 바로 실패시키지 않아서 사용자 흐름은 유지되고, 개발자는 개선 지점을 파악할 수 있습니다.

예제 2) 폐기 예정 API에 DeprecationWarning 붙이기

>>> import warnings
>>> def old_sum(values):
...     warnings.warn("old_sum은 곧 제거됩니다. sum_values를 사용하세요.", DeprecationWarning, stacklevel=2)
...     return sum(values)
...
>>> def sum_values(values):
...     return sum(values)
...
>>> old_sum([1, 2, 3])
6

해설:

  • stacklevel=2를 주면 경고 위치가 내부 함수가 아니라 호출자 코드에 맞춰져, 누가 오래된 API를 호출했는지 찾기 쉬워집니다.
  • 마이그레이션 기간에는 경고, 종료 시점에는 예외로 전환하는 식의 단계적 정책이 가능합니다.

예제 3) 특정 경고를 에러로 바꿔 테스트에서 강제하기

>>> import warnings
>>> warnings.simplefilter("error", DeprecationWarning)
>>> def old_loader():
...     warnings.warn("old_loader는 deprecated", DeprecationWarning)
...     return {"ok": True}
...
>>> try:
...     old_loader()
... except DeprecationWarning as e:
...     print(type(e).__name__)
...
DeprecationWarning

해설:

  • 로컬 개발/CI에서는 deprecated 사용을 즉시 실패시켜 기술 부채가 누적되지 않게 막을 수 있습니다.
  • 운영 런타임과 테스트 런타임의 경고 정책을 분리하는 것이 실무적으로 중요합니다.

예제 4) catch_warnings로 테스트 범위 안에서만 정책 변경

>>> import warnings
>>> def compute_ratio(a, b):
...     if b == 0:
...         warnings.warn("분모가 0이어서 결과를 0으로 대체합니다.", RuntimeWarning)
...         return 0
...     return a / b
...
>>> with warnings.catch_warnings(record=True) as bucket:
...     warnings.simplefilter("always")
...     value = compute_ratio(10, 0)
...     print(value)
...     print(len(bucket), bucket[0].category.__name__)
...
0
1 RuntimeWarning

해설:

  • 전역 정책을 오염시키지 않고, 특정 테스트 블록에서만 경고 수집/검증이 가능합니다.
  • "경고가 발생해야 하는 시나리오"를 회귀 테스트로 고정할 때 매우 유용합니다.

자주 하는 실수

실수 1) 같은 경고가 안 보여서 "경고가 안 뜬다"고 오해하기

>>> import warnings
>>> def f():
...     warnings.warn("반복 경고 테스트", UserWarning)
...
>>> f(); f(); f()

원인:

  • 기본 필터는 같은 위치에서 반복되는 경고를 한 번만 보여줄 수 있습니다.
  • 그래서 두 번째부터 조용해지면 "코드가 고쳐졌나?"라고 착각하기 쉽습니다.

해결:

>>> import warnings
>>> warnings.simplefilter("always", UserWarning)
>>> def f():
...     warnings.warn("반복 경고 테스트", UserWarning)
...
>>> f(); f()
  • 디버깅/검증 단계에서는 always로 바꿔 모든 발생을 확인하세요.
  • 운영에서는 노이즈를 줄이기 위해 기본 정책 또는 once를 유지하는 편이 현실적입니다.

실수 2) print("deprecated")로 대체해서 추적 가능성을 잃기

  • 증상: 콘솔에 문구는 보이지만 테스트에서 분류/검증이 안 되고, 팀 규칙을 자동화할 수 없습니다.
  • 원인: 경고 체계를 쓰지 않고 임의 문자열 출력으로 처리했습니다.
  • 해결: warnings.warn(..., DeprecationWarning)으로 바꾸고 필요 시 stacklevel을 지정합니다.
>>> import warnings
>>> def old_api():
...     warnings.warn("old_api는 제거 예정입니다.", DeprecationWarning, stacklevel=2)
...     return "ok"
...
>>> old_api()
'ok'

실수 3) 모든 경고를 무조건 무시해서 위험 신호까지 가려버리기

>>> import warnings
>>> warnings.simplefilter("ignore")
>>> warnings.warn("중요 경고도 묻힐 수 있음", RuntimeWarning)

원인:

  • "로그가 시끄럽다"는 이유로 전체 ignore를 적용하면, 실제로 잡아야 할 경고까지 사라집니다.

해결:

>>> import warnings
>>> warnings.resetwarnings()
>>> warnings.filterwarnings("ignore", category=UserWarning, message=".*미세 노이즈.*")
>>> warnings.filterwarnings("error", category=DeprecationWarning)
  • 카테고리/메시지 패턴 기반으로 정밀하게 조절하세요.
  • 특히 DeprecationWarning은 CI에서 에러로 승격하는 습관이 유지보수 비용을 크게 줄입니다.

실무 패턴

  • 입력 검증 규칙: 당장 실패시킬 필요가 없는 입력 품질 이슈(공백, 느슨한 형식, 구버전 옵션)는 경고로 먼저 노출하고, 수집 지표를 본 뒤 단계적으로 강제합니다.
  • 로그/예외 처리 규칙: 경고를 남길 때 request_id, feature flag, 호출 경로를 함께 기록하면 "어느 기능에서 경고가 많이 나는지"를 빠르게 분석할 수 있습니다.
  • 재사용 함수/구조화 팁: warn_deprecated(old, new, remove_in) 같은 유틸 함수를 만들어 메시지 형식과 카테고리를 통일하면 팀 코드 품질이 올라갑니다.
  • 성능/메모리 체크 포인트: 핫패스에서 동일 경고를 과도하게 발생시키면 로깅 오버헤드가 커집니다. 경고 발생 빈도 제한(샘플링)이나 위치 통합 설계를 고려하세요.

실무에서는 경고 정책을 "환경별"로 나누는 것이 특히 중요합니다. 로컬/CI는 공격적으로(경고를 에러로), 운영은 보수적으로(중요 경고 위주 노출) 가져가야 개발 생산성과 사용자 안정성을 동시에 챙길 수 있습니다. 또 하나의 팁은 릴리스 노트와 경고 메시지를 연결하는 것입니다. 예를 들어 remove_in="2026.06" 같은 메타를 넣어 두면 마이그레이션 우선순위를 잡기 쉬워집니다. 결국 경고는 단순 로그가 아니라, 팀의 기술 부채 상환 일정을 조율하는 신호 체계입니다.

오늘의 결론

한 줄 요약: warnings는 장애를 바로 내지 않으면서도 미래 장애를 예방하게 만드는, 점진적 품질 관리 도구입니다.

기억할 것:

  • print 대신 warnings.warn을 써야 필터/테스트 자동화가 가능합니다.
  • DeprecationWarning은 CI에서 에러로 승격해 누적을 막으세요.
  • 전체 ignore 대신 카테고리 기반 정밀 필터를 사용해야 안전합니다.

연습문제

  1. parse_date(text) 함수를 만들고, YYYY/MM/DD 형식이 들어오면 자동 변환하되 UserWarning으로 "구형 날짜 포맷" 경고를 남겨 보세요.
  2. legacy_login() 호출 시 DeprecationWarning을 발생시키고, 테스트 코드에서 해당 경고가 실제로 발생했는지 catch_warnings(record=True)로 검증해 보세요.
  3. 프로젝트 경고 정책 함수를 설계해 보세요. 조건: 운영 환경에서는 UserWarning 일부 무시, CI에서는 DeprecationWarning을 에러로 처리.

이전 강의 정답

  1. safe_execute(func, *args) 구현
>>> import traceback
>>> def safe_execute(func, *args):
...     try:
...         return func(*args)
...     except Exception:
...         return traceback.format_exc()
...
>>> def div(a, b):
...     return a / b
...
>>> safe_execute(div, 8, 2)
4.0
>>> "ZeroDivisionError" in safe_execute(div, 8, 0)
True
  1. 마지막 프레임의 함수명/라인번호 추출
>>> import traceback
>>> def frame_summary(exc):
...     frames = traceback.extract_tb(exc.__traceback__)
...     last = frames[-1]
...     return {"func": last.name, "line": last.lineno}
...
>>> try:
...     int("not-number")
... except Exception as exc:
...     info = frame_summary(exc)
...     print(sorted(info.keys()))
...
['func', 'line']
  1. 사용자 응답/내부 로그 분리 템플릿
>>> import traceback
>>> def build_error_payload(request_id, exc):
...     user_message = "요청 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요."
...     internal_log = {
...         "request_id": request_id,
...         "error_type": type(exc).__name__,
...         "traceback": traceback.format_exception(type(exc), exc, exc.__traceback__),
...     }
...     return user_message, internal_log
...
>>> try:
...     {}["missing"]
... except Exception as exc:
...     public, internal = build_error_payload("req-8701", exc)
...     print(public)
...     print(internal["error_type"])
...
요청 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.
KeyError

실습 환경/재현 정보

  • 실행 환경: conda env python100 (Python 3.11.14)
  • 가정한 OS: macOS/Linux 공통
  • 예제 검증 기준: Python REPL(pycon)에서 경고/예외 동작 확인
  • 참고: 경고 출력은 인터프리터 옵션(-W)과 실행 환경 설정에 따라 보이는 방식이 달라질 수 있음