이제 파이썬 타입 검사기 5개를 실행해야 하나요?
Source: Hacker News
Mypy, Pyrefly, Pyright, ty, Zuban, 그리고 앞으로 등장할 수도 있는 여러 타입 체커… 라이브러리 유지보수자는 어떻게 대응해야 할까요?
TL;DR: 테스트 스위트에 가능한 한 많은 타입 체커를 실행하는 것을 우선시하세요. 소스 코드에는 최소 하나라도 실행하세요.
The type checking that matters most (and why you’ve probably got it backwards)
이 블로그 글에서 한 섹션만 읽는다면 바로 이 부분을 읽으세요. 많은 패키지가 여기서 실수를 하기 때문입니다. 패키지가 소스 코드에만 타입 체커를 돌리고 테스트 코드는 타입을 지정하지 않는 경우를 흔히 볼 수 있습니다. 그 접근법은 역전된 것입니다.
당신이 파이썬 패키지를 유지보수한다고 가정해봅시다. 가상의 사용자 입장에서 저는 당신의 내부 개발 방식에 별다른 관심이 없습니다. ruff format을 쓰든 black을 쓰든, import 정렬 방식이든, pytest를 쓰든 unittest를 쓰든, 이런 요소들은 저에게 영향을 주지 않죠. 제가 신경 쓰는 것은 공개 API와 그 API와 상호작용할 때의 경험입니다.
내부 소스 코드에 타입 체커를 실행하면 주로 내부 로직을 검증하게 됩니다. 어떤 체커를 쓸지는 여러분의 선택이죠. 반면에 사용자가 어떤 체커를 쓰는지는 여러분이 통제할 수 없습니다.
가능한 한 많은 타입 체커를 테스트 스위트에 적용하면, 여러분의 패키지 공개 API가 가능한 많은 사용자에게 잘 동작한다는 것을 보장할 수 있습니다.
The Polars story
Polars는 2020년 출시 이후 데이터 과학 분야를 강타하고 있는 최신 데이터프레임 라이브러리입니다. 저는 이 라이브러리의 무거운 사용자이기에 개발자 경험을 더욱 개선하고 싶었습니다. Polars의 타입이 정확하면, 사용자 입장에서 자동 완성, 문서, 그리고 특정 종류의 버그로부터 보호를 더 잘 받을 수 있습니다. Pyrefly를 Polars의 CI 작업에 추가하려면 무엇이 필요할까요?
조사를 시작했지만 곧 몇 가지 장애물에 부딪혔습니다. Pyrefly는 일반적으로 mypy보다 엄격하기 때문에, 변수 인스턴스화 시 더 명시적인 타입 어노테이션을 추가하거나 코드 일부를 재작성해야 했습니다. 게다가 Pyrefly 자체 버그도 발견했는데, 다행히도 대부분은 기대하던 v1 릴리스와 함께 해결되었습니다. 특히 중간 우선순위 버그를 발견했기에 시도해볼 가치가 있었지만, 또 다른 세 종류의 타입 체커에 대해서도 같은 작업을 해야 할지 고민했습니다.
이를 설명하기 위해 DataType.__eq__ 함수를 살펴보겠습니다. 파이썬에서 __eq__ 메서드는 bool을 반환해야 하며, 그렇지 않을 경우 타입 체커에게 오류를 무시하도록 명시적으로 알려야 합니다. Polars의 이 함수는 입력에 따라 다른 타입을 반환할 수 있어 오버로드가 필요합니다. mypy, Pyrefly, ty 모두를 만족시키려면 다음과 같이 작성해야 합니다:
@overload # type: ignore[override]
def __eq__( # pyrefly: ignore[bad-override]
self, other: pl.DataTypeExpr
) -> pl.Expr: ...
@overload
def __eq__(self, other: PolarsDataType) -> bool: ...
def __eq__(self, other: pl.DataTypeExpr | PolarsDataType) -> pl.Expr | bool: # ty: ignore[invalid-method-override] # pyright: ignore[reportIncompatibleMethodOverride]
와, 7줄짜리 코드에만 4개의 type-ignore 주석이 들어갑니다! 이렇게 되면 코드베이스가 금방 이런 주석들로 오염되거나, 서로 다른 타입 체커의 특성을 우회하기 위한 트릭들로 가득 차게 됩니다. 어느 라이브러리 유지보수자도 이런 모습을 원하지 않을 겁니다. 더 나은 방법이 없을까요?
내부 코드를 여러 타입 체커에 통과시키려 애쓰기보다, 먼저 주요 타입 체커들이 여러분 라이브러리의 공개 API와 함께 잘 동작하는지 테스트해보는 것이 어떨까요? 이는 훨씬 실용적이며, 시간 투자를 정당화하기도 쉽습니다. 또한 구현 방식이 조금씩 달라도, 라이브러리가 의도대로 사용될 때 타입 오류가 없다는 것을 보장하면 되기 때문에 더 간단합니다. DataType.__eq__의 경우, 아래와 같은 테스트가 있습니다:
DTYPE_TEMPORAL_UNITS: Final[frozenset[TimeUnit]] = frozenset(["ns", "us", "ms"])
def test_dtype_time_units() -> None:
# check (in)equality behaviour of temporal types that take units
for time_unit in DTYPE_TEMPORAL_UNITS:
assert pl.Datetime == pl.Datetime(time_unit)
assert pl.Duration == pl.Duration(time_unit)
assert pl.Datetime(time_unit) == pl.Datetime
assert pl.Duration(time_unit) == pl.Duration
흥미로운 점은 mypy, Pyrefly, Pyright, ty, Zuban 모두 이 코드를 오류 없이 타입 체크한다는 것입니다! 구현 방식에 약간의 차이가 있더라도, 공개 API에 미치는 영향은 모두 동의합니다. 바로 사용자가 신경 쓰는 부분이죠!
Polars 전체 테스트 스위트에 Pyrefly를 적용하는 일은 비교적 순조로웠으며, PR에서 확인할 수 있습니다. Polars 자체 내부 개발을 돕기 위해 소스 코드에 Pyrefly를 적용하는 방안도 탐색 중인데, 이는 더 큰 작업이며 점진적으로 진행되고 있습니다.
What about my source code? Why are there so many type checkers anyway?
typing spec은 타입 체커가 따라야 할 표준 규칙을 정의합니다. 하지만 사용자가 타입 정보를 충분히 제공하지 않을 경우와 같이 애매한 상황도 존재합니다. 이런 경우 서로 다른 타입 체커는 서로 다른 설계 결정을 내립니다:
- 가능한 한 엄격하게 동작해, 필요하면 거짓 양성(false‑positive)을 내보내더라도 잠재적인 버그로부터 최대한 보호하려는 경우
- 보다 관대하게 동작해, 타입 정보를 점진적으로 코드베이스에 추가하도록 허용하는 경우
소스 코드를 타입 체크할 때는 “엄격 vs 관대” 스펙트럼에서 자신이 어느 위치에 서고 싶은지 고민해보세요. Pyrefly는 엄격할 뿐만 아니라 (설정 가능), 빠르고(속도·메모리 비교), 표준 준수(타입 일관성 비교)도 뛰어나 훌륭한 선택입니다. 프로젝트에 적용해보다 문제가 생기면 버그를 제보해 주세요. 여러분과 모든 사용자가 수정 혜택을 누릴 수 있습니다!
The bottom line
요즘 주목받는 파이썬 타입 체커는 5가지입니다: mypy, Pyrefly, Pyright, ty, Zuban. 라이브러리 유지보수자는 소스 코드에 5가지 모두를 적용하는 것이 너무 많은 유지보수 비용이며, type-ignore 주석이 남발될까 걱정할 수 있습니다. 우리는 테스트에 여러 타입 체커를 적용하는 것이 더 효율적이라는 점을 강조했습니다. 이렇게 하면 사용자가 라이브러리를 실제로 사용할 때 타입 체크가 얼마나 잘 되는지를 검증할 수 있기 때문입니다.