정적 타입 검사(Static Typing)로 더 나은 Python 코드 작성

2020. 2. 29. 16:25Python Study

PyCon Korea 2019에서 발표된 blurfx 님의 자료를 토대로 작성하였다.

파이썬을 싫어하는 사람은 파이썬을 왜 싫어하는 걸까?

파이썬은 대표적인 동적 타이핑(Dynamic Typing) 언어다. 동적 타입 언어란 무엇이고, 정적 타입 언어란 무엇일까? 둘은 실행방식에 차이가 있다. 정적 타입 언어의 실행방식은 다음과 같다.

  • Writing Code
  • Build
  • Test
  • Run/Deploy

다음으로, 동적 타입 언어의 실행방식은 다음과 같다.

  • Writing Code
  • Test
  • Run/Deploy

Bold로 표현된 부분이 오류를 발견하게 되는 구간이다. 말하자면, Test, Run/Deploy시에 오류를 발견하는 것보단 초반에 발견하는게 좋다는 말인데 구체적으로 왜 그런건지 살펴보자.

정적 타입 검사가 무엇이고, 왜 써야 하는가?

프로젝트의 규모와 개발자 수, 버그의 수가 비례하는 경향이 있다. 대규모 프로젝트에서는 버그가 많아지게 된다. 동적 타입 언어로 작성한 코드는 프로젝트가 커질수록 각 객체가 어떤 값을 가지고 있는지 알기가 힘들어진다.
그러므로, 정적 타입 체크하는 과정을 추가하여 다음과 같이 코드를 작성할 수 있다.

  • Writing Code
  • Static Type Checking
  • Test
  • Run/Deploy

위처럼 정적 타입 체킹할 경우의 장점으로는 무엇이 있을까? 쉽게 말해서 코드의 가독성이 올라가고, 타입으로 발생할 수 있는 버그를 예방해주게 된다. 일례로, JavaScript(js)언어에는 타입이 따로 존재하지 않는데, TypeScript(Ts)를 도입함으로써, GitHub에 project 배포시 15%나 버그를 예방했다는 통계도 있다.

파이썬은 동적 타입 언어인데 어떻게 타입을 주는가?

Function Annotation, Type Annotation, Variable Annotation 등의 기능이 있었지만 소개는 생략하기로 하고, Python 3.6+의 버전부터는 Type Hinting 이란 기능을 제공하고 있다.

Type Hinting

어렵게 생각할 필요없이, 먼저 Type Hinting의 예시를 살펴보자.

1.
 age: int = 1234
 def concat_list(x: list, y: list) -> list:
     return x + y

 2. 
 class Person(object):
     name: str
    age: int
    # ...

 def is_same_person(x: Person, y: Person) -> bool:
     return x == y

 3. Error
 x: list[str] = ['A', 'B', 'C']

Basic Example

타입 힌팅은

  1. 정수와 문자열만 허용하는 리스트
  2. 문자열, 실수, 부울 순서로 된 튜플
  3. 문자열 키와 바이트 값을 가진 딕셔너리
  4. 제네릭 함수와 클래스
    등을 지원하고 있다. 파이썬 기본패키지 중, 하나이므로 import typing을 통해 간단히 사용할 수 있으며, 아래에서는 정말 간단한 예제를 보이도록 하겠다.
from typing import List, Tuple, Dict
str_list: List[str] = ['A','B','C']
user_tuple: Tuple[str, int, bool] = ('Name', 24, True)
progress_dict: Dict[str, float] = {
    'Task 1' : 100,
    'Task 2' : 70.24,
    'Task 3' : 91.1234,
}

str_list를 살펴보면, 문자열 자료형을 갖는 데이터의 List를 의도했음을 알 수 있다. 그럼로 List키워드로 str자료형을 감싸는 형식으로 표현할 수 있다.
user_tuple에서는 각각 string, int, bool 형을 갖는 튜플을 선언하고자 했다. Tuple키워드로 묶어주면 된다.
progress_dict는 key/value 쌍을 갖는 자료형에 대한 사용법을 보여주고 있는데, key로 string 자료형이 들어가고 value로 float형이 들어갔음을 알수 있다.
한편, 다음과 같은 코드를 살펴보면,

 from typing import List
 my_list: List[str, int] = []

를 실행하면 TypeError: Too many parameters for typing.List; actual2, expected 1을 출력함을 볼 수 있다. typing 모듈에서 List형은 오로지 하나의 문자열만 argument로 받고 있기 때문에 적절한 조치가 필요하다. 다음과 같이 시도해보자.

 from typing import List, Union
 my_list: List[Union[str, int]] = []

정상적으로 작동됨을 확인할 수 있다. 한편 Union의 인자 값에 None을 선언해주면 아직 명시되지 않은 채로 자유롭게 자료형을 입력받을 수 있다. 또, Optional[Type]Union[T, None]과 같으니 필요할 때 사용하면 되겠다.

Advanced Example

NewType - 별명붙이기

NewType을 사용하면 타입에 별칭을 붙이는 것도 가능하다. 단순히 별칭을 만드는 것이기 때문에, 실제 타입은 원형타입으로 취급된다. 사용법은 다음과 같다.

 from typing import List, Union, NewType
 numeric = NewType('numeric', Union[int, float])
 NumericList = NewType('NumericList', List[numeric])

 # module_1.py
 my_numeric_list: NumericList=[]
 # module_2.py
 other_numeric_list: NumericList=[]
 #module_3.py
 another_numeric_list: NumericList=[]

TypeVar, Sequence

TypeVar를 사용하면 Generic Type을 구현할 수 있다. 잠깐, Generic Type을 모른다면?

하나의 함수가 여러 타입의 인자를 받고, 인자의 타입에따라 적절히 동작하는 함수를 제네릭 함수라고 한다. 파이썬은 동적타입언어이므로 명시적인 지원기능이 따로 없었다.

아래는 모든 요소가 같은 타입으로만 이루어진 Sequence를 전달받아 첫번째 요소를 반환해주는 예제이다.

  from typing import TypeVar, Sequence
  T = TypeVar('T')
  def get_first_item(l: Sequence[T]) -> T:
      return l[0]

  print(get_first_item([1,2,3,4,5]))
  >>>> 1
  print(get_first_item('ABC'))
  >>>> 'A'

어떤 타입의 자료가 들어와도 정상적으로 출력하는 모습을 볼 수 있다. 만약 여러 자료형 중, 하나를 받아야할 때는, TypeVar에 여러타입 데이터를 전달해주거나, Union을 사용하면 된다. 필수적인 인자가 아니라면 (항상 값을 전달받지 않아도 된다면) Optional을 써도 무방하다. 아래 예시를 살펴보자.

  from typing import TypeVar, Union, Optional
  Numeric = TypeVar('Numeric', int, float) # int, float 중 하나를 받도록 강제

내가 보기엔 저렇게 TypeVar에 강제로 자료형을 명시해주는게 제네릭의 사상이랑 좀 어긋나는거 같지만...아무튼 그렇다고 한다. 구체적으로 stack 과 연결시켜 어떻게 사용하는지 예제를 살펴보자.

  from typing import TypeVar, Generic
  T = TypeVar('T')
  class Stack(Generic[T]):
      def __init__(self) -> None:
        self.items: List[T] = []

    def push(self, item: T) -> None:
        self.items.append(item)

    def pop(self) -> T:
        return self.items.pop()

    def empty(self) -> bool:
        return not self.item

  stack = Stack[int]() # Generic[T]에 인자를 주는 것
  stack.push(100)
  stack.push(999)
  stack.pop()

Callable - 함수 인자에 다른 함수를 넘겨줄 때

Callable[[인자 타입 리스트], 반환타입] 형식으로 사용할 수 있다. 사용예시를 보며 이해도를 높여보자.

  from typing import Callable
  def add(x: int, y:int) -> int:
      return x + y

  def subtract(x: int, y:int) -> int:
      return x - y

  def call_func(x: int, y: int, func: Callable[[int, int], int]) -> int:
      return func(x, y)

  # Usage
  call_func(10, 20, add)
  call_func(10, 20, subtract)

여기까지 static typing의 많은 부분을 살펴보았다. pycon ppt를 기준으로 모르거나 예시등을 찾아가며 작성한 것이므로 다른분들이 보시기에 틀린 내용이 있을 수도 있으니 언제든 지적 부탁드립니다.

'Python Study' 카테고리의 다른 글

Aiohttp를 이용한 rest api 작성  (0) 2020.04.12
Pythonic TDD(Pytest, Monkey Patch)  (0) 2020.03.31
Python - Threading.Thread  (0) 2020.03.16