코드로 우주평화

Python _ TypedDict를 사용하는 이유(feat. mypy) 본문

나는 이렇게 학습한다/Language

Python _ TypedDict를 사용하는 이유(feat. mypy)

daco2020 2022. 6. 22. 17:24

Python도 Type을 확인한다구!

파이썬은 타입 힌트를 제공함으로써 해당 데이터가 어떤 타입을 갖고 있는지 알 수 있다. 다만 파이썬은 타입을 강제하지 않기 때문에 일반 런타임 환경에서는 타입의 정상여부를 알기 어렵다. 때문에 타입이 정상인지 확인하기 위해 mypy 나 pyright 같은 정적 검사 도구를 이용한다.

 

 

하지만 그럼에도 애매한 경우가 있는데 바로 dict와 같은 value들이 다양한 타입을 가질 경우이다. dict value들의 타입을 일일이 확인하고 명시하기란 매우 귀찮은 일이다. 때문에 Dict[str, Any] 처럼 value에 해당되는 타입을 Any로 넘기는 경우가 많다. 하지만 이는 바람직하지 않다.

 

 

Any가 어떤 문제를 일으키는지 먼저 살펴보고, 이에 대한 해결책으로서 TypedDict를 적용하는 방법까지 알아보자.

 

 

 

 

 

반환 타입으로 Dict[str, Any]를 명시한 경우

# 변수 준비
any_dict = {"a": "문자", "b": 1, "c": [1, 2, 3]}


# 함수 준비
def any_foo(dict: dict) -> Dict[str, Any]:
    return dict


# test1 - any_dict1 정상결과 여부 :: True
any_dict1 = any_foo(any_dict)
print("any_dict1 정상결과 여부 ::",
      isinstance(any_dict1["a"], str) and
      isinstance(any_dict1["b"], int) and
      isinstance(any_dict1["c"], list)
      )

# test2 - any_dict2 정상결과 여부 :: False
any_dict["a"] = 100 # str -> int 로 변경됨
any_dict2 = any_foo(any_dict)
print("any_dict2 정상결과 여부 ::",
      isinstance(any_dict2["a"], str) and
      isinstance(any_dict2["b"], int) and
      isinstance(any_dict2["c"], list)
      )

any_foo 함수는 반환 값으로 Dict[str, Any] 형태의 타입을 반환한다.

 

 

 

밑에 test1 은 정상적인 타입을 반환했을 경우, test2 는 도중에 value타입이 변경되어 비정상적인 타입을 반환했을 경우이다.

 

any_dict1 정상결과 여부 :: True
any_dict2 정상결과 여부 :: False

파이썬은 타입을 강제하지 않기 때문에 에러 없이 True와 False가 출력된다.

 

 

 

만약 비정상 타입이 반환되었을 경우(False인 경우) 서비스 로직에 문제가 생긴다고 가정해보자. 우리는 False가 나오면 에러를 발생시켜 타입에 문제가 있음을 알아채야 한다.

 

 

이 상태에서 mypy 타입 검사 툴을 실행시켜보자.

 

Success: no issues found in 1 source file

놀랍게도 mypy 또한 성공메시지를 반환한다. 왜냐하면 value의 반환 타입을 Any로 지정했기 때문에 mypy는 해당 타입이 맞다고 본 것이다.

 

 

타입 힌트를 명시하고 mypy까지 돌렸음에도 결국 리스크는 그대로다. 타입을 명시한 이유와 목적이 사라진 셈이다.

 

 

 

 

 

TypedDict로 value 타입을 강제하자!

먼저 TypedDict를 사용하기 위해서는 import로 불러와야 한다. 그리고 class를 만들어 value들의 타입을 명시한다.

 

from typing import TypedDict

# TypedDict 준비
class DictType(TypedDict):
    a: str
    b: int
    c: list

 

 

앞서 설명한 코드와 동일한 형태로 코드를 작성해보겠다.

 

 

달라진 점은 변수를 만들 때, 위에서 작성한 DictType 클래스를 이용해 만든다는 것과 typed_foo 함수의 in, out 타입을 DictType 클래스로 명시했다는 점이다. 

# 변수 준비
typed_dict = DictType(a="문자", b=1, c=[1, 2, 3])

print(type(typed_dict))  # <class 'dict'>
# 참고로 클래스로 만든 'typed_dict'는 인스턴스가 아닌 딕셔너리다.


# 함수 준비
def typed_foo(dict: DictType) -> DictType:
    return dict


# test1 - typed_dict1 정상결과 여부 :: True
typed_dict1 = typed_foo(typed_dict)
print("typed_dict1 정상결과 여부 ::",
      isinstance(typed_dict1["a"], str) and
      isinstance(typed_dict1["b"], int) and
      isinstance(typed_dict1["c"], list)
      )


# test2 - typed_dict2 정상결과 여부 :: False
typed_dict["a"] = 100 # str -> int 로 변경됨
typed_dict2 = typed_foo(typed_dict)
print("typed_dict2 정상결과 여부 ::",
      isinstance(typed_dict2["a"], str) and
      isinstance(typed_dict2["b"], int) and
      isinstance(typed_dict2["c"], list)
      )

 

 

해당 코드를 실행하면 첫 번째 코드와 동일하게 정상(True), 비정상(False) 타입을 출력한다. TypedDict라 할지라도 파이썬은 타입을 강제하지 않으므로 런타임 환경에서는 그대로 실행하기 때문이다.

 

typed_dict1 정상결과 여부 :: True
typed_dict2 정상결과 여부 :: False

 

 

 

우리가 원하는 것은 Python을 실행하기 전, 정적 검사를 통해 비정상 타입이 나오는 것을 방지하는 것이다. 이제 mypy를 실행해보자!

 

error: Argument 2 has incompatible type "int"; expected "str"
Found 1 error in 1 file (checked 1 source file)

 

 

이번에는 mypy가 제대로 잡아냈다!

 

 

코드 중간에 value 타입이 바뀐 것을 정확히 집어내었다. 에러 메시지는 str이 있어야 할 value가 int 타입으로 바뀌었음을 우리에게 알려준다.

 

 

 

 

 

마무리

타입을 강제하면 리스크를 줄이고 제품의 안정성을 높일 수 있다. 그 외에도 가독성이나 개발 생산성을 끌어올릴 수 있다. 물론 타입을 강제하는 것이 무조건 정답은 아니다. 예를 들어 서비스나 개발자의 의도에 따라 타입이 시시때때로 변해야 할 수도 있을 것이다. 제품과 서비스의 상황에 맞게 적절한 해답을 찾아나가자.