어노테이션의 기본 아이디어는 코드 사용자에게 함수 인자로 어떤 값이 와야 하는지 힌트를 주자는 것이다. 어노테이션은 후에 공부할 타입힌팅(type hinting)을 활성화한다.
어노테이션을 사용해 변수의 예상 타입을 지정할 수 있다. 실제로는 타입 뿐 아니라 변수를 이해하는데 도움이 되는 어떤 형태의 메타데이터라도 지정이 가능하다.
@dataclass:
class Point:
lat: float
long: float
def locate(latitude: float, longitude: float) -> Point:
여기서 latitude와 longitude는 float 타입의 변수이다. 이것을 통해 함수 사용자는 예상되는 타입을 알 수 있다. 하지만 파이썬이 타입을 검사하거나 강제하지는 않는다.
또한 함수 반환 값에 대한 예상 타입을 지정할 수도 있다. 위 예제에서 Point는 사용자 정의 클래스이므로 반환되는 값이 Point의 인스턴스라는 것을 의미한다.
어노테이션을 통해서 타입을 지정ㄹ하거나, 변수의 의도를 설명하는 문자열, 콜백이나 유효성 검사 함수로 사용할 수 있는 callable 등이 있다.
어노테이션을 활용하면 좀 더 표현력을 가진 코드를 작성할 수 있다. 몇 초 후에 어떤 작업을 실행하는 다음 함수를 생각해보면
def launch_task(delay_in_seconds):
...
여기에서 delay_in_seconds 파라미터는 긴 이름을 가지고 있어서 많은 정보를 담고 있는 것 같이 보이지만 사실은 충분한 정보를 제공하지 못하고 있다. 다음과 같이 수정하여 더 많은 설명력을 부여할 수 있다.
Seconds = float
def launch_task(delay: Seconds):
...
Seconds 어노테이션을 사용하여 시간을 어떻게 해석할지에 대해 작은 추상화를 했다고 볼 수도 있다. 또한 나중에 입력 값의 형태를 변경하기로 했다면(정수만 허용), 이제 한 곳에서만 관련 내용을 변경하면 된다.
어노테이션을 사용하면 __anotations__이라는 특수한 속성이 생긴다. 이 속성은 어노테이션의 이름과 값을 매핑한 딕셔너리 타입의 값이다. 앞 예제에서는 다음과 같이 출력된다.

이 정보를 사용하여 문서 생성, 유효성 검증 또는 타입 체크를 할 수 있다.
어노테이션을 작성하면, 함수의 속성으로
__anotations__이 생기고 이를 통해 함수에 대한 정보를 파악할 수 있다.
타입 힌트가 단순히 데이터 타입을 확인하기 위한 것만은 아니다. 이전 예제에서 봤던 것처럼 유의미한 이름을 사용하거나 적절한 데이터 타입 추상화를 하도록 도와줄 수 있다.
클라이언트들 배열에 대해서 어떤 작업을 수행하는 다음 함수를 생각할 때, 가장 간단하게는 기본 list를 사용해서 어노테이션을 추가할 수 있다.
def process_clients(clients: list)
...
좀 더 많은 정보를 알고 있다면 다음과 같이 정수와 문자열의 튜플임을 알려준다.
def process_clients(clients: list[tuple[int,str]])
...
그러나 여전히 충분한 정보를 제공하지 못하고 있으므로, 해당 데이터 구조를 따로 정의하여 클라이언트가 어떤 데이터 구조를 가지고 있는지 명시적으로 알려주는 것이 좋다.
from typing import Tuple
Client = Tuple[int,str]
def process_clients(clients: list[Client])
...
이제야 의미가 좀 더 명확하고 무엇보다 진화 가능한 데이터 타입을 가지게 되었다. 현재로 튜플 형태의 데이터 구조일 수 있지만, 나중에는 다른 객체나 클래스로 변경될 수 있다. 이러한 경우에도 위 Client의 타입만 변경하면 되고, 같은 어노테이션을 사용할 수 있다.
어노테이션을 사용함으로 생기는 또 다른 이점도 있다. PEP-526과 PEP-557 표준을 도입하면 클래스를 보다 간결하게 작성하고 작은 컨테이너 객체를 쉽게 정의할 수 있다. 클래스에서 데이터 타입에 대한 어노테이션과 함께 속성을 선언하고 @dataclass 데코레이터를 사용하기만 하면 에전처럼 별도의 __init__ 메서드에서 변수를 선언하고 할당하는 작업을 하지 않아도 바로 인스턴스 속성으로 인식한다.
다음 코드를 통해서 before & after를 확인해볼 수 있다.
before
class Point:
def __init__(self,lat,long):
self.lat = lat
self.long = long
after
from dataclasses import dataclass
@dataclass
class Point:
lat: float
long: float
객체 지향 설계에 대한 모범 사례 중에는 함수의 호출 규약을 인터페이스에 명시하고 해당 인터페이스에 의존하도록 하는 의존성 주입과 같은 것이 있다. 이러한 의존성을 선언하는 가장 좋은 방법은 어노테이션일 것이다.
docstring과 어노테이션은 서로 보완적인 개념이다.
데이터 타입을 어노테이션으로 명시하면 더 이상 docstring에서 파라미터의 데이터 타입을 명시할 필요가 없다.
그러나 docstring을 통해서 보다 나은 문서화를 위한 여지를 남겯두어야 한다. 특히 동적 데이터 타입과 중첩 데이터 타입의 경우 예상 데이터의 에제를 제공하여 어떤 형태의 데이터를 다루는지 제공하는 것이 좋다.
다음 예제를 통해서 docstring과 어노테이션을 같이 사용할 때의 시너지를 확인해보자
def data_from_response(response: dict) -> dict:
if response["status"] != 200:
raise ValueError
return {"data": response["payload"]}
이 함수는 사전 형태의 파라미터를 받아서 사전 형태의 값을 반환한다. 그러나 상세한 내용은 알 수가 없다. 예를 들어 response 객체의 올바른 인스턴스는 어떤 형태일까? 결과의 인스턴스는 어떤 형태일까? 이를 답하려면 파라미터와 함수 반환 값의 예상 형태를 docstring으로 문서화하는 것이 좋다.
def data_from_response(response: dict) -> dict:
""" response의 HTTP status가 200이라면 response의 payload를 반환
- response 딕셔너리 예제::
{
"status": 200, # <int>
"timestamp": "....", # 현재 시간의 ISO 포맷 문자열
"payload": {...} #반환하려는 사전 데이터
}
- 반환 딕셔너리 값의 예제::
{"data": {...} }
- 발생 가능한 예외:
- HTTP status가 200이 아닌 경우 valueError 발생
"""
if response["status"] != 200:
raise ValueError
return {"data":response["payload"]}
이제 함수에 입력 값과 반환 값의 예상 형태를 더 잘 이해할 수 있다. 이 문서는 입출력 값을 더 잘 이해하기 위해서 뿐 아니라, 단위 테스트에서도 유용한 정보로 사용된다. 테스트용 입력 값을 생성할 수도 있고, 테스트의 성공 실패를 판단할 수도 있다.
반복적인 확인 작업을 줄이기 위해 코드 검사를 자동으로 실행하는 기본 도구를 설정
동적으로 데이터 타입이 변하는 파이썬에서도 데이터 타입이 의도한 것과 일치하는지 쉽게 확인
데이터 타입의 일관성을 확인하기 위한 대표적인 2가지 도구
mypy는 pip install mypy로 쉽게 설치할 수 있으며, 프로젝트의 의존성 목록에 포함하는 것을 추천
가상 환경에 mypy를 설치하고 mypy {file_name}과 같은 명령어를 실행하면 의심이 되는 오류들을 보고한다.
일반적인 오류를 방지하는 데 도움이 되는 것들이므로 보고서에서 발견된 문제를 해결하는 것이 좋다. 그러나 mypy 역시 완벽한 것은 아니므로 잘못된 탐지라고 생각되면 다음과 같이 해당 라인에 대한 검사를 무시하도록 설정할 수 있다.
type_to_ignore = "something" # type: ignore
mypy와 같은 도구를 유용하게 쓰려면 먼저 어노테이션을 정확하게 작성해야 한다. 타입의 범위가 너무 일반적이라면 정상적인 경우라고 판단할 수 있기 때문이다.
pytype도 비슷한 방식으로 작동하지만, 오류를 확인하는 시점에서 차이가 있다.
예를 들어 pytype에서는 일시적으로 지정된 데이터 타입과 다른 타입을 사용하여도 최종 결과가 선언된 유형을 준수하는 한 문제로 간주되지 않는다.
from typing import List
def get_list() -> List[str]:
lst = ["PyCon"]
lst.append(2022) # mypy에서는 오류지만 pytype에서는 허용
return [str(x) for x in lst]
여기서 lst.append(2022)는 문자형이 아닌 숫자형을 추가하려고 했기 때문에 정적인 코드 분석 관점에서 보면 오류이지만, 최종적으로 str함수를 사용해 문자열로 변환하기 때문에 런타임 시에는 문제가 되지 않는다.
데이터 타입을 검사하는 것 외에도 보다 일반적인 유형의 품질 검사를 하는 것도 가능하다.
이들은 PEP-8 준수 여부를 검증하는 것뿐만 아니라 PEP-8 이상의 더 복잡한 것에 대한 추가 검사를 제공하는 도구도 있다.
PEP-8을 완벽히 준수하였다고 하더라도 여전히 최적의 코드는 아니라는 점을 기억해야한다.
예를 들어서 PEP-8은 일반적인 코드의 스타일이나 구조에 관한 검사할 뿐 모든 메서드, 클래스, 모듈에 docstring 여부 또는 너무 많은 파라미터를 사용하는 함수 등에 대해서도 아무런 메시지를 주지 않는다.
이러한 추가 검증이 가능한 도구 중의 하나가 바로
pylint는 가장 엄격한 수준의 검증을 하는 도구
pylint는 기본 값으로 모든 함수에 docstring이 있는지 역시 검사한다.
flake8과 같은 도구는 PEP-8 준수 여부를 검사할 뿐만 아니라 자동으로 PEP-8표준을 준수하는 코드로 변환하는 기능도 있다. 이러한 도구들은 굉장히 유연함을 가지고 있지만, 다음 도구는 결정적인 방식을 강요하는 도구이다.
black은 라인 길이 제외와 같은 옵션을 허용하지 않으면서 고유하고 결정적인 방식으로 코드 형식을 지정하는 특징이 있다.
예를 들어, 따옴표는 항상 큰따옴표만을 사용해야하고, 파라미터의 순서는 항상 동일한 구조를 따라야한다.
클린코드 책 1장 코드 포매팅과 도구를 공부하면서, 클린코드가 단순히 깔끔한 코드, 가독성이 좋은 코드의 개념에서 기술 부채를 줄이고, 가독성과 유지보수성 그리고 타인의 이해도를 높이는 효과적인 코드 작성 방법이라는 것을 배울 수 있었다.
또한 코딩 스타일이나 가이드라인을 준수하는 것 역시 중요하다는 것을 배웠다.