Docstring

코드의 특정 컴포넌트에 대한 문서화 가능한 많은 docstring을 추가하는 것이 좋다.

def data_from_response(response):
    """response에 문제가 없다면 response의 payload를 반환
    
    - response 사전의 예제::
    {
        "status": 200, # <int>
        "timestamp": "....", # 현재 시간의 ISO 포맷 문자열
        "payload": {...} # 반환하려는 사전 데이터
    }
    
    - 반환 사전 값의 예제::
    {"data": {..}}
    
    - 발생 가능한 예외:
    - HTTP status가 200이 아닌 경우 ValueError 발생
    """

파이썬은 동적 타이핑을 하기 때문에 예상되는 함수의 입출력을 문서화하면 사용시 함수의 종작을 이해하기 쉽다.

Link to original

Annotation

코드 사용자에게 함수 인자로 어떤 값이 와야 하는지 알려주는 역할. 어노테이션을 사용하여 변수의 예상 타입을 표시할 수 있다.

class Point:
    def __init__(self, lat, long):
        self.lat = lat
        self.long = long
 
def locate(latitude: float, longitude: float) -> Point:
    """맵에서 좌표에 해당하는 객체를 검색"""

어노테이션을 사용함으로써 locate 함수의 파라미터와 반환값을 알 수 있다. 파이썬은 타입을 검사하거나 어노테이션을 강제하지는 않는다.

파이썬 3.6부터는 함수 파라미터와 리턴 타입뿐만 아니라 변수에 직접 주석을 달 수 있다.

class Point:
    lat: float
    long: float

어노테이션은 Docstring을 대체하는가?

docstring의 대부분을 어노테이션으로 대체할 수 있지만. docstring은 여전히 필요하다. docstring과 어노테이션은 서로 보완적인 개념이기 때문이다.

어노테이션으로 대체 가능한 정보 또한 더 나은 문서화를 위해 docstring에 남겨두어야 한다. 특히 동적 데이터 타입과 중첩 데이터 타입의 경우 예상 데이터의 예제를 제공하는 것이 좋다.

def data_from_response(response: dict) -> dict:
    if response['status'] != 200:
        raise ValueError
    return {'data': response['payload']}

위 함수는 어노테이션을 통해 파라미터와 return 값의 type을 표시한다. 하지만 response 객체의 올바른 인스턴스 형태, 결과 인스턴스의 형태 등의 상세한 내용은 알 수 없다. 위 두 가지 문제를 해결하려면 파라미터와 함수 반환 값의 예상 형태를 docstring으로 문서화하는 것이 좋다.

def data_from_response(response: dict) -> dict:
    """response에 문제가 없다면 response의 payload를 반환
    
    - response 사전의 예제::
    {
        "status": 200, # <int>
        "timestamp": "....", # 현재 시간의 ISO 포맷 문자열
        "payload": {...} # 반환하려는 사전 데이터
    }
    
    - 반환 사전 값의 예제::
    {"data": {..}}
    
    - 발생 가능한 예외:
    - HTTP status가 200이 아닌 경우 ValueError 발생
    """
    if response['status'] != 200:
        raise ValueError
    return {"data": response['payload']}
Link to original

Pythonic 코드

인덱스와 슬라이스

파이썬의 list는 음수 인덱스를 지원한다. 음수 인덱스를 사용해 배열의 끝에서부터 접근할 수 있다.

>>> my_numbers = (4, 5, 3, 9)
>>> my_numbers[-1]
9
 
>>> my_numbers[-3]
5

list slice를 사용하여 구간의 여러 element들을 가져올 수 있다.

>>> my_numbers = (1, 1, 2, 3, 5, 8, 13, 21)
 
# [0 ~ 3]
>>> my_numbers[:3]
(1, 1, 2)
 
# [3 ~]
>>> my_numbers[3:]
(3, 5, 8, 13, 21)
 
# 튜플의 복사본을 만든다.
>>> my_numbers[::]
(1, 1, 2, 3, 5, 8, 13, 21)
 
# [1 ~ 7, 간격 2]
>>> my_numbers[1:7:2]
(1, 3, 8)

slice는 파이썬 내장 객체로 직접 빌드하여 전달할 수도 있다.

>>> interval = slice(1, 7, 2)
 
>>> my_numbers[interval]
(1, 3, 8)
 
>>> interval = slice(None, 3)
>>> my_numbers[interval] == my_numbers[:3]
True

자체 시퀀스 생성

위에서 설명한 기능들은 __getitem__ 이라는 매직 메서드 덕분에 동작한다. 이것은 my_object[key]와 같은 형태를 사용할 때 호출되는 메서드로 key에 해당하는 대괄호 안의 값을 파라미터로 전달한다. 특히 시퀀스는 __getitem____len__을 모두 구현하는 객체이므로 반복이 가능하다. 리스트, 튜플, 문자열은 표준 라이브러리에 있는 시퀀스 객체의 예이다.

class Items:
    def __init__(self, *values):
        self._values = list(values)
    
    def __len__(self):
        return len(self._values)
    
    def __getitem__(self, item):
        return self._values.__genitem__(item)

만약 래퍼도 아니고 내장 객체를 사용하지도 않는 경우는 자신만의 시퀀스를 구현할 수 있다. 시퀀스 구현시 주의사항

  • 범위로 인덱싱 하는 결과는 해당 클래스와 같은 타입의 인스턴스여야 한다.
  • slice에 의해 제공된 범위는 마지막 요소를 제외한다.

컨텍스트 관리자

컨텍스트 관리자 패턴은 주요 동작의 전후에 작업을 실행하려고 할 때 유용하다. 일반적으로 리소스 관리와 관련하여 컨텍스트 관리자를 자주 볼 수 있다.

작업이 끝날 시 일반적으로 할당된 모든 리소스를 해제해야 하는데, 예외나 오류를 감안하는 쉬운 방법은 finally 블록에 정리 코드를 넣는 것이다.

fd = open(filename)
try:
    process_file(fd)
finally:
    fd.close()

위 예제와 같은 기능을 pythonic한 방법으로 구현할 수도 있다.

with open(filename) as fd:
    process_file(fd)

여기에서 open 함수는 컨텍스트 관리자 프로토콜을 구현한다. 즉 예외가 발생한 경우에도 블록이 완료되면 파일이 자동으로 닫힌다.

컨텍스트 관리자는 __enter____exit__ 두 개의 매직 메서드로 구성된다. 첫 번째 줄에서 with문은 __enter__ 메서드를 호출하고 반환값을 as 뒤의 변수에 지정한다. with문이 끝나면 컨텍스트가 종료되며 __exit__메서드를 호출한다.

컨텍스트 관리자 블록 내에 예외 또는 오류가 있는 경우에도 __exit__ 메서드가 여전히 호출되므로 정리 조건을 안전하게 실행하는데 편리하다.

컨텍스트 관리자 구현

함수에 contextlib.contextmanager 데코레이터를 적용하면 해당 함수의 코드를 컨텍스트 관리자로 변환한다. 이 함수는 제러레이터여야하고, 코드의 문장을 __enter____exit__ 매직 메서드로 분리한다.

@contextlib.contextmanager
def db_handler():
    stop_database()
    yield 
    start_database()
 
with db_handler():
    db_backup( )

@contextlib.contextmanager 데코레이터를 적용하면 yield 문 앞의 코드는 __enter__ 로직으로 취급되며, 생성된 값은 컨텍스트 관리자의 return값으로 사용된다. yield문 다음에 오는 코드는 __exit__ 로직으로 취급한다.

contextlib.ContextDecorator 클래스를 상속함으로써 컨텍스트 관리자를 데코레이터로 만들 수 있다.

class dbhandler_decorator(contextlib.ContextDecorator):
    def __enter__(self):
        stop_database()
    
    def __exit__(self, exc_type, exc_val, exc_tb):
        start_database()
 
@dbhandler_decorator()
def offline_backup():
    run("pg_dump database")

위 코드는 offline_backup()를 실행할 때 dbhandler_decorator의 context를 하당한다.

이 접근법의 유일한 단점은 독립적이라는 것이다. 데코레이터는 함수에 대해 모르고 그 반대도 마찬가지이다. 이것은 좋은 특성이지만 컨텍스트 관리자 내부에서 사용하고자 하는 객체를 얻을 수 없다는 것을 의미한다.

로직을 한번만 정의하면 동일한 로직을 필요로 하는 함수에 데코레이터를 적용함으로써 원하는 만큼 재사용 할 수 있다는 장점이 있다.

프로퍼티, 속성과 객체 메서드의 다른 타입들

다른 언어들과 달리 파이썬 객체의 모든 프로퍼티와 함수는 public이다. 엄격한 강제 사항은 없지만 몇 가지 규칙이 있다. 밑줄로 시작하는 속성은 해당 객체에 대해 private를 의미하며, 외부에서 호출하지 않기를 기대한다.

파이썬에서의 밑줄

객체는 외부 호출 객체와 관련된 속성과 메서드만을 노출해야 한다. 즉 객체의 인터페이스로 공개하는 용도가 아니라면 모든 멤버에는 접두사로 하나의 밑줄을 사용하는 것이 좋다.

이것은 객체의 인터페이스를 명확하게 구분하기 위한 pythonic한 방식이다.

파이썬에서 이중 밑줄로 정의한 속성에 접근하려고 하면 AttributeError 가 발생한다. 이와 같은 방법을 사용하여 일부 속성을 숨길 수 있으므로 해당 속성이 private이며 다른 객체가 수정할 수 없다고 생각할 수 있다. 하지만 발생한 예외는 AttributeError 에러로 접근 권한에 대해 언급하지 않고 속성이 없음을 나타낸다. 이것은 실제로 뭔가 다른 일이 벌어졌으며 부작용에 의한 결과로 생긴 것이라는 것을 암시한다.

밑줄 두 개를 사용하면 실제로 파이썬은 다른 이름을 만든다. 이것을 name mangling 이라고 한다. 이것이 하는 일은 다음과 같은 이름의 속성을 만드는 것이다. _[클래스이름]__[멤버이름]

이중 밑줄은 pythonic한 코드가 아니다. 속성을 private로 정의하려는 경우 하나의 밑줄을 사용하고 pythonic한 관습을 지켜야 한다.

프로퍼티

객체에 값을 저장해야 할 경우 일반적인 속성(attribute)을 사용할 수 있다. 객체의 상태나 다른 속성의 값을 기반으로 계산을 수행하려는 경우 대부분 프로퍼티를 사용하는 것이 좋은 선택이다.

프로퍼티는 객체의 속성에 대한 접근을 제어하려는 경우 사용한다. Java와 같은 다른 프로그래밍 언어에서는 접근 메서드(getter, setter)를 만들지만 파이썬에서는 프로퍼티를 사용한다.

import re
 
EMAIL_FORMAT = re.compile(r"[^@]+@[^@]+\\.[^@]+")
 
def is_valid_email(potentially_valid_email: str):
    return re.match(EMAIL_FORMAT, potentially_valid_email) is not None
 
class User:
    def __init__(self, username):
        self.username = username
        self._email = None
 
    @property
    def email(self):
        return self._email
 
    @email.setter
    def email(self, new_email):
        if not is_valid_email(new_email):
            raise ValueError(f"유효한 이메일이 아니므로 {new_email} 값을 사용할 수 없음")
        self._email = new_email

이 예제에서 첫 번째 @property메서드는 private속성인 email값을 반환한다. 두 번째 메서드는 앞에서 정의한 프로퍼티에 @email.setter을 추가한다. 이 메서드는 <user>.email = <new_email>이 실행될 때 호출되는 코드로 <new_email>이 파라미터가 된다. 여기에서는 설정하고자 하는 값이 실제 이메일 주소가 아닌 경우 명확하게 유효성 검사를 한다. 이메일 포맷에 문제가 없으면 새 값으로 속성을 업데이트 한다.

객체의 모든 속성에 대해 get_, set_ 메서드를 작성할 필요는 없다. 대부분의 경우 일반 속성을 사용하는 것으로 충분하고, 속성 값을 가져오거나 수정할 때 특별한 로직이 필요한 경우에 프로퍼티를 사용한다.

프로퍼티는 명령-쿼리 분리 원칙을 따르기 위한 좋은 방법이다. 메서드는 한 가지만 수행해야 한다. 작업을 처리한 다음 상태를 확인하려면 메서드를 분리해야 한다.

이터러블 객체

파이썬의 반복은 이터러블 프로토콜이라는 자체 프로토콜을 사용해 동작한다. 객체를 반복할 수 있는지 확인하기 위해 파이썬은 고수준에서 다음 두가지를 차례로 검사한다.

  1. 객체가 __next____iter__ 이터레이터 메서드 중 하나를 포함하는지 여
  2. 객체가 시퀀스이고 __len____getitem__을 모두 가졌는지 여부

이터러블 객체 만들기

class DateRangeIterable:
    """자체 이터레이터 메서드를 가지고 있는 이터러블"""
 
    def __init__(self, start_date, end_date):
        self.start_date = start_date
        self.end_date = end_date
        self._present_day = start_date
 
    def __iter__(self):
        return self
 
    def __next__(self):
        if self._present_day >= self.end_date:
            raise StopIteration
        today = self._present_day
        self._present_day += timedelta(days=1)
        return today

이 객체는 한 쌍의 날짜를 통해 생성되며 해당 기간의 날짜를 반복하면서 하루 간격으로 날짜를 표시한다.

for 루프는 iter() 함수를 호출하고, 이 함수는 __iter__ 매직 메서드를 호출한다. __iter__ 메서드는 이터러블 객체를 반환한다. 각 루프마다 반환된 이터러블 객체의 next()함수를 호출한다. next()함수는 다시 __next__ 메서드에게 위임한다. 이 메서드는 요소를 어떻게 생산하고 하나씩 반환할 것인지 결정한다. 더이상 생산할 것이 없을 경우 파이썬에게 StopIteration 예외를 발생시켜야 한다.

위 예제는 한 번 실행하면 끝 인덱스에 도달한 상태이므로 이후에 호출하면 StopIteration예외가 발생한다. 즉 두 개 이상의 for루프에서 이 값을 사용하면 첫 번째 루프만 작동시키고 두 번째 루프는 작동하지 않게 된다.

이 문제를 수정하는 한 가지 방법은 매번 새로운 DataRangeIterable 인스턴스를 만드는 방법이 있다. 또는 __iter__에서 제너레이터를 사용할 수도 있다.

class DateRangeContainerIterable:
    def __init__(self, start_date, end_date):
        self.start_date = start_date
        self.end_date = end_date
 
    def __iter__(self):
        current_day = self.start_date
        while current_day < self.end_date:
            yield current_day
            current_day += timedelta(days=1)

이러한 형태의 객체를 컨테이너 이터러블(container iterable)이라고 한다. 일반적으로 제너레이터를 사용할 때는 컨테이너 이터러블을 사용하는 것이 좋다.

시퀀스

시퀀스는 __len____getitem__을 구현하고 첫 번째 인덱스 0부터 시작하여 포함된 요소를 한 번에 하나씩 차례로 가져올 수 있어야 한다.

class DateRangeSequence:
    def __init__(self, start_date, end_date):
        self.start_date = start_date
        self.end_date = end_date
        self._range = self._create_range()
 
    def _create_range(self):
        days = []
        current_day = self.start_date
        while current_day < self.end_date:
            days.append(current_day)
            current_day += timedelta(days=1)
        return days
 
    def __getitem__(self, day_no):
        return self._range[day_no]
 
    def __len__(self):
        return len(self._range)

컨테이너 객체

컨테이너는 __contains__ 메서드를 구현한 객체로 __contains__ 메서드는 일반적으로 Boolean 값을 반환한다. 이 메서드는 파이썬에서 in 키워드가 발견될 때 호출된다.

def mark_coordinate(grid, coord):
    if 0 <= coord.x < grid.width and 0 <= coord.y < grid.height:
        grid[coord] = MARKED

위 코드의 첫번째 if 문은 난해해 보인다. 코드의 의도가 무엇인지 이해하기 어렵고 직관적이지 않으며 매번 if문을 중복해서 호출한다.

class Boundaries:
    def __init__(self, width, height):
        self.width = width
        self.height = height
 
    def __contains__(self, coord):
        x, y = coord
        return 0 <= x < self.width and 0 <= y < self.height
 
 
class Grid:
    def __init__(self, width, height):
        self.width = width
        self.height = height
        self.limits = Boundaries(width, height)
 
    def __contains__(self, coord):
        return coord in self.limits

객체의 동적인 속성

__getattr__은 인스턴스에 해당 attribute가 없을 경우 호출된다. 이 매직 메서드를 사용해 객체에서 속성을 얻는 방법을 제어할 수 있다. <myobject>.<myattribute>를 호출하면 파이썬은 객체의 딕셔너리에서 <myattribute>를 찾아서 __getattribute__를 호출한다. 객체에 찾고있는 속성이 없는 경우 속성의 이름을 파라미터로 전달하여 __getattr__이라는 추가 메서드가 호출된다. 이 값을 사용하여 반환 값을 제어할 수 있다.

class DynamicAttributes:
    def __init__(self, attribute):
        self.attribute = attribute
 
    def __getattr__(self, attr):
        if attr.startswith("fallback_"):
            name = attr.replace("fallback_", "")
            return f"[fallback resolved] {name}"
        raise AttributeError(f"{self.__class__.__name__} 에는 {attr} 속성이 없음")

다음은 이 클래스 객체에 대한 호출이다.

>>> dyn = DynamicAttributes("value")
 
>>> dyn.attribute
'value'
 
>>> dyn.fallback_test
'[fallback resolved] test'
 
>>> dyn.__dict__["fallback_new"] = "new value"
>>> dyn.fallback_new
'new value'
 
>>> getattr(dyn, "something", "default")
'default'

첫 번째 방법은 객체에 있는 속성을 요청하고 그 결과 값을 반환한다. 두 번째 방법은 객체에 없는 fallback_test라는 메서드를 호출하기 때문에 __getattr__이 호출되어 값을 반환한다. 세 번째 예제에서는 fallback_new라는 새로운 속성이 생성된다. 실제로 이 호출은 dyn.fallback_new = "new value"를 실행한 것과 동일하다. 메서드가 호출되지 않았기 때문에 __getattr__의 로직이 적용되지 않는다.

마지막 예제의 __getattr__메서드에서 값을 검색할 수 없는 경우 AttributeError 가 발생한다. 이것은 예외 메시지를 포함해 일관성을 유지할 뿐만 아니라 내장 getattr() 함수에서도 필요한 부분이다. 이 예외가 발생하면 getattr() 함수는 기본 값을 반환한다.

__getattr__과 같은 동적인 메서드를 구현할 때는 예외시 AttributeError를 발생시켜야 한다

호출형(callable) 객체

매직메서드 __call__을 사용하면 객체를 일반 함수처럼 호출할 수 있다. 여기에 전달된 모든 파라미터는 __call__메서드에 그대로 전달된다. 객체를 이렇게 사용하는 주된 이점은 객체에는 상태가 있기 때문에 함수 호출 사이에 정보를 저장할 수 있다는 점이다.

이 메서드는 객체를 파라미터가 있는 함수처럼 사용하거나 정보를 기억하는 함수처럼 사용할 경우 유용하다.

다음은 __call__메서드를 사용하여 입력된 파라미터와 동일한 값으로 몇 번이나 호출되었는지를 반환하는 객체를 만든 예이다.

class CallCount:
    def __init__(self):
        self._counts = defaultdict(int)
 
    def __call__(self, argument):
        self._counts[argument] += 1
        return self._counts[argument]

매직 메서드 요약

코드매직 메서드파이썬 컨셉
obj[key]
obj[i:j]
obj[i:j:k]
__getitem__(key)첨자형 객체(subscriptable) 객체
with obj: …enter/__exit__컨텍스트 관리자
for in obj: …__iter__/__next__, __len__/__getitem__이터러블 객체 시퀀스
obj.__getattr__동적 속성 조회
obj(*args, **kwargs)__call__(*args, **kwargs)호출형(callable) 객체

파이썬에서 유의할 점

본 섹션에서 논의되는 대부분은 완전히 피할 수 있는 것들이며, 안티 패턴을 정당화 하는 시나리오가 거의 없다.

변경 가능한 파라미터의 기본 값

def wrong_user_display(user_metadata: dict = {"name": "John", "age": 30}):
    name = user_metadata.pop("name")
    age = user_metadata.pop("age")
 
    return f"{name} ({age})"

이 함수는 변경가능한 인자를 사용한 것 외에도 함수 본문에서 가변 객체를 수정하여 부작용이 발생한다. 이 함수는 파라미터 없이 호출시 처음 한 번만 정상적으로 동작한다. 그 다음 호출할 때 명시적으로 user_metadata를 전달하지 않으면 KeyError가 발생한다.

>>> wrong_user_display()
'John (30)'
 
>>> wrong_user_display({"name": "jane", "age": 25})
'Jane (25)'
 
>>> wrong_user_display()
KeyError 'name'

기본 값을 사용해 함수를 호출하면 기본 데이터로 사용될 딕셔너리를 한 번만 생성하고 user_metadata는 이것을 가리킨다. 이 값은 프로그램이 실행되는동안 계속 메모리에 남아있게 되며, 값을 수정할 경우 기본 인자 대신 수정된 값을 사용한다. 이 문제를 해결하기 위해서는 기본 초기값으로 None을 사용하고 함수 본문에서 기본 값을 할당하면 된다.

내장(built-in) 타입 확장

리스트 , 문자열, 딕셔너리와 같은 내장 타입을 확장하는 올바른 방법은 collections 모듈을 사용하는 것이다. 내장 타입을 직접 확장하는 클래스를 만들면 예상하지 못한 결과를 얻을 수 있다. CPython에서는 클래스의 메서드를 서로 호출하지 않기 때문에 메서드 중에 하나를 오버라이드하면 나머지에는 반영되지 않기 떄문이다. 사용자 정의 매핑 타입을 만들 때에는 dict보다 collections.UserDict를 사용하는 것이 좋다.

Link to original

좋은 코드의 특징

계약에 의한 디자인

컴포넌트는 기능을 숨겨 캡슐화하고 API만을 노출해야 한다.

API 디자인시 예상되는 입력, 출력, 부작용을 문서화 해야한다. 그러나 문서화가 런타임시의 소프트웨어 동작까지 강제할 수는 없다. 코드가 정상적으로 동작하기 위해 기대하는 것과 호출자가 반환받기를 기대하는 것은 디자인의 하나가 되어야 한다.

여기서 계약 이라는 개념이 생긴다. 계약에 의한 디자인은 관리자가 기대하는 바를 암묵적으로 코드에 삽입하는 대신 양측이 동의하는 계약을 먼저 한 다음, 계약을 어겼을 경우는 명시적 예외를 발생시키는 것이다.

여기서 계약은 소프트웨어 컴포넌트 간의 통신 중에 반드시 지켜져야 할 몇 가지 규칙을 강제하는 것이다. 계약은 주로 사전 조건, 사후 조건을 명시하지만 때로는 불변식과 부작용을 기술한다.

  • 사전 조건 : 코드가 실행되기 전에 체크해야 하는 것. 파라미터에 제공된 데이터의 유효성을 검사한다.
  • 사후조건 : 함수 반환 값의 유효성 검사가 수행된다. 호출자가 이 컴포넌트에서 기대한 것을 제대로 받았는지 확인하기 위해 수행한다.
  • 불변식 : 때로는 함수의 docstring에 불변식에 대해 문서화하는 것이 좋다. 불변식은 함수가 실행되는 동안에 일정하게 유지되는 것으로 함수의 로직에 문제가 없는지 확인하기 위한 것이다.
  • 부작용 : 선택적으로 코드의 부작용을 docstring에 언급하기도 한다.

계약에 의해 디자인을 하게 되면 오류가 발생할 때 쉽게 찾아낼 수 있다. 또한 에러를 발생시키는 데서 그치는 것이 아니라 책임의 한계를 명확히 하는데 도움이 된다.

사전 조건은 클라이언트와 연관되어 있다. 클라이언트는 코드를 실행하기 위해 사전에 약속한 조건을 준수해야만 한다. 사후 조건은 컴포넌트와 연관되어 있다. 컴포넌트는 클라이언트가 확인하고 강제할 수 있는 값을 보장을 해야 한다.

사전조건

함수나 메서드가 제대로 동작하기 위해 보장해야 하는 모든 것들을 말한다. 특히 파이썬은 동적으로 타입이 결정되므로 전달된 데이터가 적절한 타입인지, 정확한지 확인하는 경우도 있다.

유효성 검사는 클라이언트가 함수를 호출하기 전에 모든 유효성 검사를 하는 관용적인 접근법과 함수가 자체적으로 로직을 실행하기 전에 검사하는 까다로운 접근법이 존재한다.

어떤 방식을 택하든 중복 제거 원칙을 항상 지켜야 한다.

사후 조건

사후 조건은 메서드 또는 함수가 반환된 후의 상태를 강제하는 계약의 일부이다.

함수 또는 메서드가 사전 조건에 맞는다면 사후조건은 특정 속성이 보존되도록 보장해야 한다.

메서드가 적절히 실행되었다면 계약이 이루어졌으므로 사후조건 검증에 통과하고 클라이언트는 반환 객체를 아무 문제없이 사용할 수 있어야 한다.

계약에 의한 디자인 - 결론

계약을 정의함으로써 런타임시 오류가 발생했을 때 코드의 어떤 부분이 손상되었는지 계약이 파손되었는지가 명확해진다.

이 원칙을 따르게 되면 코드가 더욱 견고해진다. 각 컴포넌트들은 자체적으로 제약 조건과 불변식을 관리하며 이러한 불변식이 유지되는한 프로그램이 정상 동작하는 것으로 볼 수 있다.

방어적 프로그래밍

방어적 프로그래밍은 객체, 함수 또는 메서드와 같은 코드의 모든 부분을 유효하지 않은 것으로부터 스스로 보호할 수 있게 하는 것이다.

방어적 프로그래밍의 주요 주제는 예상할 수 있는 시나리오의 오류를 처리하는 방법과 발생하지 않아야 하는 오류를 처리하는 방법에 대한 것이다.

에러 핸들링

에러 핸들링의 주요 목적은 예상되는 에러에 대해서 실행을 계속할 수 있을지 아니면 극복할 수 없는 오류여서 프로그램을 중단할지를 결정하는 것이다.

프로그램에서 에러를 처리하는 방법은 다음과 같다.

  • 값 대체
  • 에러 로깅
  • 예외 처리

값 대체

오류가 있어 소프트웨어가 잘못된 값을 생성하거나 전체가 종료될 위험이 있는 경우 결과 값을 안전한 다른 값으로 대체할 수 있다.

값 대체가 항상 가능하지는 않다. 대체 값이 실제로 안전한 옵션인 경우에 한해 신중하게 선택해야 한다. 이 결정을 내리는 것은 견고성과 정확성간의 트레이드오프이다.

약간 다른 방향으로 보다 안전한 방법을 택하려면 제공되지 않은 데이터에 기본 값을 사용하는 것이다. 설정되지 않은 변수의 기본 값, 설정 파일의 누락된 항목 또는 함수의 파라미터와 같은 것들은 기본 값으로 동작이 가능하다.

일반적으로 누락된 파라미터를 기본 값으로 바꾸어도 큰 문제가 없지만 오류가 있는 데이터를 유사한 값으로 대체하는 것은 더 위험하며 일부 오류를 숨길 수 도 있다.

예외 처리

잘못되거나 누락된 입력 데이터가 있는 경우 복구 처리가 가능한 경우가 있다. 그러나 어떤 경우에는 에러가 발생하기 쉽다는 가정으로 계속 실행하는 것보다 차라리 실행을 멈추고 호출자에게 실패했음을 알리는 것이 좋은 선택이다. 이 경우는 사전 조건 검증에 실패한 경우이다.

함수 호출 실패는 함수 자체의 문제가 아니라 외부 컴포넌트 중 하나의 문제로 인한 것일 수 있다. 함수는 심각한 오류에 대해 명확하고 분명하게 알려주어 쉽게 해결할 수 있도록 해야 한다.

예외적인 상황을 명확하게 알려주고 원래의 비즈니스 로직에 따라 흐름을 유지하는 것이 중요하다.

호출자가 알아야만 하는 실질적인 문제가 있을 경우에는 예외를 발생시켜야 한다.

예외는 대개 호출자에게 잘못을 알려주는 것이다. 예외는 캡슐화를 약화시키기 때문에 신중하게 사용해야 한다. 함수에 예외가 많을수록 호출자가 호출하는 함수에 대해 더 많은 것을 알아야 한다.

다음은 파이썬의 예외와 관련된 몇 가지 권장 사항이다.‘

올바른 수준의 추상화 단계에서의 예외 처리

예외는 오직 한 가지 일을 하는 함수의 한 부분이어야 하며, 함수가 처리하는 예외는 캡슐화된 로직과 일치해야 한다.

class DataTransport:
    """다른 레벨에서 예외를 처리하는 객체의 예"""
 
    retry_threshold: int = 5
    retry_n_times: int = 3
 
    def __init__(self, connector):
        self._connector = connector
        self.connection = None
 
    def deliver_event(self, event):
        try:
            self.connect()
            data = event.decode()
            self.send(data)
        except ConnectionError as e:
            logger.info("연결 실패: %s", e)
            raise
        except ValueError as e:
            logger.error("%r 잘못된 데이터 포함: %s", event, e)
            raise
 
    def connect(self):
        for _ in range(self.retry_n_times):
            try:
                self.connection = self._connector.connect()
            except ConnectionError as e:
                logger.info("%s: 새로운 연결 시도 %is", e,self.retry_threshold)
                time.sleep(self.retry_threshold)
            else:
                return self.connection
        raise ConnectionError(f"{self.retry_n_times} 번째 재시도 연결 실패")
 
    def send(self, data):
        return self.connection.send(data)

위 예제에서 deliver_event 메서드 내부에서 매우 다른 유형의 에러인 ValueErrorConnectionError가 동시에 발생하고 있다. 이렇게 매우 다른 유형의 오류를 살펴봄으로써 책임을 어떻게 분산해야 하는지에 대한 아이디어를 얻을 수 있다.

ConnectionErrorconnect 메서드 내에서 처리되어야 한다. 이렇게 하면 행동을 명확하게 분리할 수 있게 된다.

def connect_with_retry(connector, retry_n_times, retry_threshold=5):
    """connector의 연결을 맺는다. <retry_n_times>회 재시도
 
    연결에 성공하면 connection 객체 반환
        재시도까지 모두 실패하면 ConnectionError 발생
        
        :param connector: '.connect()' 메서드를 가진 객체
        :param retry_n_times int: "connector.connect()"를 호출 시도하는 횟수
        :param retry_threshold int: 재시도 사이의 간격
    """
 
    for _ in range(retry_n_times):
        try:
            return connector.connect()
        except ConnectionError as e:
            logger.info("%s: 새로운 연결 시도 %is", e, retry_threshold)
            time.sleep(retry_threshold)
    exc = ConnectionError(f"{retry_n_times} 번째 재시도 연결 실패")
    logger.exception(exc)
    raise exc

이 함수는 연결을 맺고, 발생 가능한 예외를 처리하고 로깅을 담당한다.

이렇게 구현을 수정하면 deliver_event에서는 예외를 catch할 필요가 없다.

class DataTransport:
    """추상화 수준에 따른 예외 분리를 한 객체의 예제"""
 
    retry_threshold: int = 5
    retry_n_times: int = 3
 
    def __init__(self, connector):
        self._connector = connector
        self.connection = None
 
    def deliver_event(self, event):
        self.connection = connect_with_retry(self._connector, self.retry_n_times, self.retry_threshold)
        self.send(event)
 
    def send(self, event):
        try:
            return self.connection.send(event.decode())
        except ValueError as e:
            logger.error("%r contains incorrect data: %s", event, e)
            raise

두 가지 예외를 분리한 새로운 메서드는 더 작고 읽기 쉽다.

Trackback 노출 금지

특정 문제를 나타내는 예외가 있는 경우 문제를 효율적으로 해결할 수 있도록 trackback 정보, 메시지 및 기타 수집 가능한 정보를 로그로 남기는 것이 중요하다. 그러나 이러한 세부사항은 절대 사용자에게 보여서는 안된다.

파이썬에서 trackback 정보는 매우 유용하고 많은 디버깅 정보를 포함한다. 이 정보가 악의적인 사용자에게 노출될 경우 중요 정보의 유출이 발생할 위험이 있다.

비어있는 except 블록 지양

try:
        process_data()
except:
        pass

위와 같이 코드를 작성할 경우 실패해야만 할 때조차 실패하지 않는다. 이것은 “에러는 결코 조용히 전달되어서는 안된다.” 라는 파이썬 원칙에 위배된다. 진짜 예외가 발생해도 이 코드는 실패하지 않으며 이러한 코드는 문제를 숨기고 유지보수를 더 어렵게 만든다.

여기에는 두 가지 대안이 있다.

  • 보다 구체적인 예외를 사용한다 (Exception 같은 광범위한 예외를 사용해서는 안된다)
  • except 블록에서 실제 오류 처리를 한다.

가장 좋은 방법은 두 항목을 동시에 적용하는 것이다.

보다 구체적인 예외를 사용하면 사용자는 무엇을 기대하는지 알게 되기 때문에 프로그램을 더 쉽게 유지 보수할 수 있다.

원본 예외 포함

오류 처리 과정에서 다른 오류를 발생시키고 메세지를 변경할 수도 있다. 이 경우 원본 예외를 포함하는 것이 좋다.

raise <e> from <original_exception> 구문을 사용하면 원본의 trackback이 새로운 exception에 포함되고 원본 예외는 __cause__속성으로 설정된다.

class InternalDataError(Exception):
    """업무 도메인 데이터의 예외"""
 
def process(data_dictionary, record_id):
    try:
        return data_dictionary[record_id]
    except KeyError as e:
        raise InternalDataError("Record not present") from e

예외의 타입을 변경할 때는 항상 raise <e> from <o> 구문을 사용한다.

파이썬에서 Assertion 사용하기

assertion은 절대로 일어나지 않아야 하는 상황에 사용되므로 assert 문에 사용된 표현식은 불가능한 조건을 의미한다. 이 상태가 된다는 것은 소프트웨어에 결함이 있음을 의미한다.

assertion은 잘못된 시나리오에 도달할 경우 프로그램이 더 큰 피해를 입지 않도록 하는 것이다. 이러한 이유로 assertion을 비즈니스 로직과 섞거나 소프트웨어 흐름 제어 메커니즘으로 사용해서는 안된다.

try:
    assert condition.holds(), "조건에 맞지 않음"
except: AssertionError:
    alternative_procedure()

위 예제는 좋지 않은 코드이다.

Assertion이 실패하면 반드시 프로그램을 종료시켜야 한다. 또한 Assertion 문장에 설명이 포함된 오류 메시지를 작성하여 나중에 디버깅하고 수정할 수 있도록 한다.

result = condition.holds()
assert result > 0, "에러 {}".format(result)

코드를 줄이고 유용한 정보를 포함하도록 수정한 코드

관심사의 분리

책임이 다르면 컴포넌트, 계층, 모듈로 분리되어야 한다. 프로그램의 각 부분은 기능의 일부분에 대해서만 책임을 지며 나머지 부분에 대해서는 알 필요가 없다.

소프트웨어에서 관심사를 분리하는 목표는 파급 효과를 최소화하여 유지보수성을 향상시키는 것이다. 파급 효과는 어느 지점에서의 변화가 전체로 전파되는 것을 의미한다.

응집력과 결합력

응집력이란 객체가 작고 잘 정의된 목적을 가져야 하며 가능하면 작아야 한다는 것을 의미한다. 객체의 응집력이 높을수록 더 유용하고 재사용성이 높아지므로 더 좋은 디자인이다.

결합력이란 두 개 이상의 객체가 서로 어떻게 의존하는지 나타낸다. 이 종속성은 제한을 의미한다. 객체 또는 메서드의 두 부분이 서로 너무 의존적이라면 다음과 같은 바람직하지 않은 결과를 가져온다.

  • 낮은 재사용성 : 어떤 함수가 특정 객체에 지나치게 의존하는 경우 이 함수는 해당 객체에 결합되게 된다.
  • 파급 효과 : 너무 가깝게 붙어있게 되면 두 부분중 하나를 변경하면 다른 부분에도 영향을 미친다.
  • 낮은 수준의 추상화: 두 함수가 너무 가깝게 관련되어 있으면 서로 다른 추상화 레벨에서 문제를 해결하기 어렵다.

개발 지침 약어

DRY/OAOO

DRY(Do not Repeat Yourself), OAOO(Once and Only Once)는 밀접한 관련이 있다.

코드에 있는 지식은 단 한번, 단 한 곳에 정의되어야 한다. 코드가 여러번 정의되었을 때의 문제는 다음과 같다.

  • 오류가 발생하기 쉽다 : 코드 전체에 여러 번 반복되어 있는 로직을 수정할 때 인스턴스의 하나라도 빠뜨리면 버그가 발생한다.
  • 비용이 비싸다 : 여러 곳에서 정의했을 경우 변경, 개발, 테스트 할 때 더 많은 시간이 소요된다.
  • 신뢰성이 떨어진다 : 문맥상 여러 코드를 변경해야 하는 경우 사람이 모든 인스턴스의 위치를 기억해야 한다. 단일 데이터 소스가 아니므로 데이터의 완결성이 떨어진다.
def process_students_list(students):
    # 중간 생략...
    
        students_ranking = sorted(students, key=lambda s: s.passed * 11 - s.failed * 5 - s.years * 2)
    # 학생별 순위 출력
    for student in students_ranking:
        print("이름: {0}, 점수: {1}".format(student.name, (student.passed * 11 - student.failed * 5 - student.years * 2)))

위 함수 내에서 sorted 함수의 key로 사용되는 lambda가 특별한 도메인 지식을 나타내지만 아무런 정의가 없다. 코드에서 의미를 부여하지 않았기 때문에 순위를 출력하는 동안 중복이 발생한다.

도메인 문제에 대한 지식이 사용된 경우 의미를 부여해야 한다.

def score_for_student(student):
    return student.passed * 11 - student.failed * 5 - student.years * 2
 
def process_students_list(students):
    # 중간 생략...
 
    students_ranking = sorted(students, key=score_for_student)
    # 학생별 순위 출력
    for student in students_ranking:
        print("Name: {0}, Score: {1}".format(student.name, score_for_student(student)))

YAGNI

YAGNI(You Ain’t Gonna Need it)는 과잉 엔지니어링을 하지 않기 위해 솔루션 작성 시 계속 염두에 두어야 하는 원칙이다. 오직 현재의 요구사항을 잘 해결하기 위한 소프트웨어를 작성하고 가능한 나중에 수정하기 쉽도록 작성하는 것이 좋다.

KIS

KIS(Keep It Simple) 원칙은 YAGNI 원칙과 매우 흡사하다. 소프트웨어 컴포넌트를 설계할 때 선택한 솔루션이 문제에 적합한 최소한의 솔루션인지 생각해야 한다.

class ComplicatedNamespace:
    """프로퍼티를 가진 객체를 초기화하는 복잡한 예제"""
 
    ACCEPTED_VALUES = ("id_", "user", "location")
 
    @classmethod
    def init_with_data(cls, **data):
        instance = cls()
        for key, value in data.items():
            if key in cls.ACCEPTED_VALUES:
                setattr(instance, key, value)
        return instance

위 클래스는 제공된 키워드 파라미터 세트에서 네임 스페이스를 작성하지만 다소 복잡한 코드 인터페이스를 가지고 있다.

객체를 초기화 하기 위해 추가 클래스를 만드는 것은 꼭 필요하지 않으며, 반복을 통해 setattr을 호출하는 것은 상황을 더 악화시킨다. 사용자에게 노출된 인터페이스 또한 불분명하다.

>>> cn = ComplicatedNamespace.init_with_data(id_=42, user="root", location="127.0.0.1", extra="excluded")
>>> cn.id_, cn.user, cn.location
(42, 'root', '127.0.0.1')
 
>>> hasattr(cn, "extra")
False

사용자는 초기화를 통해 init_with_data라는 일반적이지 않은 메서드의 이름을 알아야 한다. 파이썬에서 객체를 초기화 하는 경우 __init__ 메서드를 사용하는 것이 더 효과적이다.

class Namespace:
    """Create an object from keyword arguments."""
 
    ACCEPTED_VALUES = ("id_", "user", "location")
 
    def __init__(self, **data):
        accepted_data = {k: v for k, v in data.items() if k in self.ACCEPTED_VALUES}
        self.__dict__.update(accepted_data)

EAFP/LBYL

EAFP(Easier to Ask Forgiveness than Permission)는 허락보다는 용서를 구하는 것이 쉽다는 뜻이다. 반면에 LBYL(Look Before You Leap)은 도약하기 전에 살피라는 뜻이다.

EAAP는 일단 코드를 실행하고 실제 동작하지 않을 경우에 대응한다는 뜻이다. 일반적으로는 코드를 실행하고 발생한 예외를 catch하고 except 블록에서 바로잡는 코드를 실행하게 된다.

LBYL는 그 반대이다 도약하기 전에 먼저 무엇을 사용하려고 하는지 확인하는 것이다. 예를들어 파일을 사용하기 전에 먼저 파일을 사용할 수 있는지 확인하는 것이다.

if os.path.exists(filename):
    with open(filename) as f:
        ...

파이썬은 EAFP 방식으로 만들어졌기 때문에 위 방식은 pythonic한 방식이 아니다.

try:
    with open(filename) as f:
        ...
except FileNotFoundError as e:
    logger.error(e)

위와 같은 방식으로 다시 작성할 수 있다.

LBYL 원칙보다 EAFP 원칙이 더 바람직하다.

컴포지션과 상속

개발자는 종종 필요한 클래스들의 계층 구조를 만들고 각 클래스가 구현해야 하는 메서드를 결정하는 것으로부터 개발을 시작한다.

상속은 강력한 개념이지만 위험도 존재한다. 가장 주된 위험은 부모 클래스를 확장하여 새로운 클래스를 만들 때마다 부모와 강력하게 결합된 생긴다는 점이다. 소프트웨어를 설계할 때 결합력(coupling)을 최소한으로 줄이는 것이 중요하다.

상속이 좋은 선택인 경우

새로운 하위 클래스를 만들 때 클래스가 올바르게 정의되었는지 확인하기 위해 상속된 모든 메서드를 실제로 사용할 것인지 생각해보는 것이 좋다. 많은 메서드를 필요로 하지 않고 재정의하거나 대체해야 한다면 다름과 같은 이유로 설계상의 실수라고 할 수 있다.

  • 상위 클래스는 잘 정의된 인터페이스 대신 막연한 정의와 너무 많은 책임을 가졌다.
  • 하위 클래스는 확장하려고 하는 상위 클래스의 적절한 세분화가 아니다.

어떤 객체에 인터페이스 방식을 강제하고자 할 때 구현 하지 않은 기본 추상 클래스를 만들고, 실제 이 클래스를 상속하는 하위 클래스에서 적절한 구현을 하도록 하는 것이다.

다른 상속의 사용 예는 예외이다. 파이썬의 표준 예외는 Exception에서 파생된다.

상속 안티패턴

상속을 올바르게 사용하면 객체를 전문화하고 기본 객체에서 출발하여 세부적인 추상화를 할 수 있다.

부모 클래스는 새 파생 클래스의 공통 정의의 일부가 된다. 개발자가 코드 재사용만을 목적으로 상속을 사용하려고 할 때 매우 자주 발생한다.

class TransactionalPolicy(collections.UserDict):
    """잘못된 상속의 예"""
 
    def change_in_policy(self, customer_id, **new_policy_data):
        self[customer_id].update(**new_policy_data)

딕셔너리 기능을 수행하는 인터페이스를 갖게 되었지만 클래스에 불필요한 수 많은 메서드가 포함되었다.

위 디자인에서는 계층 구조가 잘못되었다. 기본 클래스에서 새 클래스를 만드는 것은 말 그대로 그것이 개념적으로 확장되고 세부적인 것이라는 것을 의미한다. TransactionalPolicy는 이름만 보고 dict형태라는 것을 알 수 없다.

또한 결합력(coupling)에 대한 문제가 있다. TransactionalPolicydict의 모든 메서드를 포함한다. TransactionalPolicy 에는 필요 없는 메서드가 포함되어 있다. 이것들은 public 메서드이므로 이 인터페이스의 사용자는 이 메서드를 호출할 수도 있다.

key 기능을 얻기 위해 dict를 확장하는 것은 충분한 확장의 근거가 되지 않는다.

올바른 해결법은 컴포지션을 사용하는 것이다. TransactionalPolicy 자체가 딕셔너리가 되는 것이 아니라 딕셔너리를 활용하는 것이다. 딕셔너리를 private 속성에 저장하고 __getitem__() 으로 사전의 프록시를 만들고 나머지 필요한 public 메서드를 추가적으로 구현하는 것이다.

class TransactionalPolicy:
    """컴포지션을 사용한 리팩토링 예제"""
 
    def __init__(self, policy_data, **extra_data):
        self._data = {**policy_data, **extra_data}
 
    def change_in_policy(self, customer_id, **new_policy_data):
        self._data[customer_id].update(**new_policy_data)
 
    def __getitem__(self, customer_id):
        return self._data[customer_id]
 
    def __len__(self):
        return len(self._data)

파이썬의 다중상속

부적절하게 사용된 상속은 디자인 문제를 유발하며 특히 다중 상속을 잘못 사용하면 더 큰 문제를 초래할 수도 있다.

다중 상속은 올바르게 사용될 때에만 온전히 유효한 해결책이 될 수 있으므로 보통 어댑터 패턴과 믹스인을 사용한다.

메서드 결정 순서(MRO)

두 개 이상의 클래스를 확장하고 해당 클래스들이 모두 하나의 같은 기본 클래스를 확장한 경우 맨 아래 클래스가 어느 메서드가 사용될지 모호해진다.

클래스의 속성 값을 통해 명확하게 확인해볼 수 있다.

class BaseModule:
    module_name = "top"
 
    def __init__(self, module_name):
        self.name = module_name
 
    def __str__(self):
        return f"{self.module_name}:{self.name}"
 
class BaseModule1(BaseModule):
    module_name = "module-1"
 
class BaseModule2(BaseModule):
    module_name = "module-2"
 
class BaseModule3(BaseModule):
    module_name = "module-3"
 
class ConcreteModuleA12(BaseModule1, BaseModule2):
    """1 & 2 계정 확장"""
 
class ConcreteModuleB23(BaseModule2, BaseModule3):
    """2 & 3 계정 확장"""
>>> str(ConcreteModuleA12('name'))
'module-1:name'

파이썬은 C3 linearization 또는 MRO라는 알고리즘을 사용하여 이 문제를 해결한다. 이 알고리즘은 메서드가 호출되는 방식을 정의한다.

믹스인

믹스인은 코드를 재사용 하기 위해 일반적인 행동을 캡슐화 해놓은 기본 클래스이다. 일반적으로 믹스인 클래스는 클래스에 정의된 메서드나 속성에 의존하기 때문에 이 클래스만 확장해서는 확실히 동작하지 않는다. 보통은 믹스인 클래스를 다중 상속하여 믹스인에 있는 메서드나 속성을 사용한다.

다음은 문자열을 받아 하이픈(-) 으로 구분된 값을 반환하는 파서 클래스이다.

class BaseTokenizer:
    def __init__(self, str_token):
        self.str_token = str_token
 
    def __iter__(self):
        yield from self.str_token.split("-")
>>> tk = BaseTokenizer("28a2320b-fd3f-4627-9792-a2b38e3c46b0")
>>> list(tk)
['28a2320b', 'fd3f', '4627', '9792', 'a2b38e3c46b0']

여기에서 BaseTokenizer 클래스를 변경하지 않고 값을 대문자로 변경하고자 할 때 믹스인을 사용할 수 있다.

class UpperIterableMixin:
    def __iter__(self):
        return map(str.upper, super().__iter__())
 
# < 방향으로 오버라이딩된다. (BaseTokenizer > UpperIterableMixin)
class Tokenizer(UpperIterableMixin, BaseTokenizer):
        pass

Tokenizer는 믹스인에서 __iter__을 호출하고 다시 super() 호출을 통해 클래스 BaseTokenizer 에 위임한다.

함수와 메서드의 인자

파이썬의 함수 인자 동작방식

인자는 함수에 어떻게 복사되는가

파이썬에서는 모든 인자가 값에 의해 전달(passed by a value)된다. 함수에 값을 전달하면 함수의 로컬 변수에 할당하고 나중에 사용한다.

인자를 변경하는 함수는 인자의 타입에 따라 다른 결과를 낼 수 있다. 변형 가능한(mutable) 객체를 전달하고 함수에서 값을 변경하면 함수 반환 시 실제 값이 변경되는 부작용이 생길 수 있다.

>>> def function(arg):
    arg += "in function"
    print(arg)
>>> imutable = "hello"
>>> function(immutable)
hello in function
 
>>> mutable = list("hello")
>>> immutable
hello
 
>>> function(mutable)
['h', 'e', 'l', 'l', 'o', ' ', 'i', 'n', ' ', 'f', 'u', 'n', 'c', 't', 'i', 'o', 'n']
 
>>> mutable
['h', 'e', 'l', 'l', 'o', ' ', 'i', 'n', ' ', 'f', 'u', 'n', 'c', 't', 'i', 'o', 'n']

string객체는 불변형(immutable) 타입이므로 arg += <expression> 문장은 arg + <expression> 형태의 새로운 객체를 만들어 arg에 다시 할당한다.

변형 가능한(mutable) 객체인 리스트를 전달하면 해당 문장은 원래 리스트 객체에 대한 참조를 보유하고 있는 변수를 통해 값을 수정하므로 함수 외부에서도 실제 값을 수정할 수 있다.

이런 유형의 파라미터를 사용하면 예상치 못한 부작용을 유뱔할 수 있으므로 주의해야 한다.

함수 인자를 변경하지 않아야 한다. 최대한 함수에서 발생할 수 있는 부작용을 회피하라.

가변인자

가변 인자를 사용하려면 해당 인자를 패킹할 변수의 이름 앞에 별표(*)를 사용한다.

세 개의 인자를 갖는 함수에 패킹 기법을 사용하여 하나의 명령어로 값을 전달할 수 있다.

>>> def f(first, second, third):
    print(first)
    print(second)
    print(third)
>>> l = [1, 2, 3]
>>> f(*l)
1
2
3

파이썬은 부분적인 언패킹도 지원한다. 언패킹 하는 순서는 제한이 없으며, 언패킹할 부분이 없으면 결과는 비어있게 된다.

>>> def show(e, rest):
        print("요소: {0} - 나머지: {1}".format(e, rest))
>>> first, *rest = [1, 2, 3, 4, 5]
>>> show(first, rest)
요소: 1 - 나머지: [2, 3, 4, 5]
 
>>> *rest, last = range(6)
>>> show(last, rest)
요소: 5 - 나머지: [0, 1, 2, 3, 4]
 
>>> first, *middle, last = range(6)
>>> first
0
 
>>> middle
[1, 2, 3, 4]
 
>>> last
5
 
>>> first, last, *empty = (1, 2)
>>> first
1
 
>>> last
2
 
>>> empty
[]

변수 언패킹의 좋은 예는 반복이다. 일련의 요소를 반복해야 하고 각 요소가 차례로 있다면 각 요소를 반복할 때 언패킹 하는 것이 좋다.

USERS = [(i, f"first_name_{i}", f"last_name_{i}") for i in range(1_000)]
 
class User:
    def __init__(self, user_id, first_name, last_name):
        self.user_id = user_id
        self.first_name = first_name
        self.last_name = last_name
 
    def __repr__(self):
        return f"{self.__class__.__name__}({self.user_id!r}, {self.first_name!r}, {self.last_name!r})"
 
def bad_users_from_rows(dbrows) -> list:
    """DB 레코드에서 사용자를 생성하는 파이썬스럽지 않은 잘못된 사용 예"""
    return [User(row[0], row[1], row[2]) for row in dbrows]
 
def users_from_rows(dbrows) -> list:
    """DB 레코드에서 사용자 생성"""
    return [User(user_id, first_name, last_name) for (user_id, first_name, last_name) in dbrows]

첫 번째 구현에서는 row[0], row[1], row[2] 가 무엇을 뜻하는지 전혀 알 수 없다. 반면에 언패킹을 사용한 쪽의 user_id, first_name, last_name는 자명한 이름이다.

비슷한 표기로 이중 별표(**)를 키워드 인자에 사용할 수 있다. 딕셔너리에 이중 별표를 사용하여 함수에 전달하면 파라미터의 이름으로 키를 사용하고, 파라미터의 값으로 사전의 값을 사용한다.

function(**{"key": "value"}) 이중 별표를 사용한 이 코드는 function(key="value") 이 코드와 동일하다.

반대로 이중 별표로 시작하는 파라미터를 함수에 사용하면 반대로 동작한다. 키워드 제공 인자들이 딕셔너리로 패킹된다.

>>> def function(**kwargs):
    print(kwargs)
 
>>> function(key="value")
{'key': 'value'}

함수 인자의 개수

함수의 인수가 많을수록 호출자 함수와 밀접하게 결합될 가능성이 커진다.

함수의 인수의 갯수를 줄일 대안 중 하나는 구체화가 있다. 다시말해 전달하는 모든 인자를 포함하는 새로운 객체를 만드는 것이다.

다른 방법은 파이썬의 특정기능 (가변 인자, 키워드) 를 사용해 동적 구조를 만드는 방법이다.

만약 파라미터 값에 대응하여 너무 많은 것들을 함수애서 처리하고 있다면 여러 작은 함수로 분리를 하라는 사인이다.

함수 인자와 결합력

함수의 인수가 많을수록 호출자 함수와 밀접하게 결합될 가능성이 커진다. 많은 파라미터를 사용할수록 호출자는 정상 동작을 위한 모든 정보를 수집하기 어려워진다.

함수가 보다 일반적인 인터페이스를 제공하고 더 높은 수준의 추상화로 작업할 수 있다면 코드 재사용성이 높아진다.

함수가 제대로 동작하기 위해 너무 많은 파라미터가 필요한 경우 코드에 문제가 있을 가능성이 높다.

많은 인자를 취하는 작은 함수

만약 공통 객체에 파라미터 대부분이 포함되어 있다면 가장 쉽게 수정할 수 있다.

track_request(request.headers, request.ip_addr, request_id) 이 예제에서 모든 파라미터가 request와 관련이 있으며 request를 파라미터로 전달하는 것이 좋은 방법이다. 올바르게 함수를 호출하려면 track_request(request)여야 한다.

변경 가능한 객체를 전당할 때에는 부작용에 주의해야 한다. 함수는 전달받은 객체를 변경해서는 안되며 변경이 필요할 경우 전달된 값을 복사한 다음 새로운 수정본을 반환하는 것이 나은 대안이다.

변경 불가능한 객체를 사용하여 부작용을 최소화 한다.

파라미터들을 그룹핑하여 디자인에서 누락되었던 추상화 작업을 할 수 있다.

위와 같은 방법들을 적용할 수 없다면 마지막 수단으로 함수의 구조를 변경하여 다양한 인자를 혀용할 수 있다. 인자가 많은 경우에 *args 또는 **kwargs를 사용하면 더 이해하기 어려운 상황을 만들 수 있다. 이런 경우 인터페이스에 대한 문서화를 하고 정확하게 사용했는지 확실히 확인해야 한다.

*args**kwargs 로 정의된 함수가 융통성 있고 적응력이 좋지만, 함수는 구조를 잃어버리고 가독성을 거의 상실한다. 위치 인자 또는 가변 인자를 사용하면 매우 좋은 docstring을 제작하지 않는 한 나중에 해당 함수에 사용된 파라미터만 보고 정확한 동작을 알 수 없다.

소프트웨어 디자인 우수 사례 결론

소프트웨어의 독립성(orthogonality)

모듈, 클래스, 함수를 변경하면 수정한 컴포넌트가 다른 컴포넌트들에게 영향을 미치지 않아야 한다. 이것이 불가능하다고 해도 좋은 디자인은 가능한 한 영향을 최소화하려고 시도해야 한다.

파이썬에서 함수는 일반 객체일 뿐이므로 파라미터로 전달할 수 있다. 독립성을 얻기위해 이 기능을 활용할 수 있다.

def calculate_price(base_price: float, tax: float, discount: float) -> float:
    return (base_price * (1 + tax)) * (1 - discount)
 
def show_price(price: float) -> str:
    return "$ {0:,.2f}".format(price)
 
def str_final_price(base_price: float, tax: float, discount: float, fmt_function=str) -> str:
    return fmt_function(calculate_price(base_price, tax, discount))

위쪽의 두 함수는 독립성을 가진다. 만약 하나를 변경해도 다른 하나는 변경되지 않는다. 마지막 함수는 문자열 변환을 기본 표현 함수로 사용하고 사용자 정의 함수를 전달하면 해당 함수를 사용해 문자열을 포매팅한다. 그러나 show_price의 변경사항은 calculate_price에 영향을 미치지 않는다.

>>> str_final_price(10, 0.2, 0.5)
'6.0'
 
>>> str_final_price(1000, 0.2, 0)
'1200.0'
 
>>> str_final_price(1000, 0.2, 0.1, fmt_function=show_price)
'$ 1,080.00'

코드의 두 부분이 독립적이라는 것은 다른 부분에 영향을 주지 않고 변경할 수 있다는 것을 뜻한다. 이는 변경된 부분의 단위 테스트가 나머지 단위 테스트와도 독립적이라는 것을 뜻한다. 이러한 가정 하에 두 개의 테스트가 통과하면 전체 회귀 테스트를 하지 않고도 애플리케이션에 문제가 없다고 어느정도 확신할 수 있다.

독립성은 기능 면에서 생각해 볼 수 있다. 애플리케이션의 두 가지 기능이 완전히 독립적이라면 다른 코드를 손상시킬 것에 대한 염려가 없으므로 간단한 테스트 후에 배포할 수 있다.

코드 구조

코드를 구조화하는 방법은 팀의 작업 효율성과 유지보수성에 영향을 미친다.

여러 정의(클래스, 함수, 상수)가 들어있는 큰 파일을 만드는 것은 좋지 않으므로 피하는 것이 좋다. 좋은 코드는 유사한 컴포넌트끼리 정리하여 구조화해야 한다.

코드의 여러 부분이 해당 파일의 정의에 종속되어 있어도 전체적인 호환성을 유지하면서 패키지로 나눌 수 있다. 해결 방법은 __init__.py 파일을 가진 새 디렉터리를 만드는 것이다. 이렇게 하면 파이썬 패키지가 만들어 진다.

이 방법은 다음과 같은 이유 때문에 효율적이라고 할 수 있다.

  • 모듈을 임포트할 때 구문을 분석하고 메모리에 로드할 객체가 줄어든다.
  • 의존성이 줄었기 때문에 더 적은 모듈만 가져오면 된다.
Link to original

SOLID 원칙

  • S : 단일 책임 원칙
  • O : 개방, 폐쇄의 원칙
  • L : 리스코프 치환 원칙
  • I : 인터페이스 분리 원칙
  • D : 의존성 역전 원칙

단일 책임 원칙

단일 책임 원칙(Single Responsibility Principle - SRP)는 소프트웨어 컴포넌트가 단 하나의 책임을 져야한다는 원칙이다.
클래스가 유일한 책임이 있다는 것은 하나의 구체적인 일을 담당한다는 것을 의미하며, 변화해야 할 이유 또한 한 가지임을 의미한다.

도메인의 문제가 변경되면 클래스를 업데이트 해야 한다. 다른 이유로 클래스를 수정해야 한다면 추상화가 잘못되어 클래스에 너무 많은 책임이 있다는 것을 뜻한다.

이 디자인 원칙은 보다 응집력 있는 추상화를 하는 데 도움이 된다. 어떤 경우에도 여러 책임을 가진 객체를 만들어서는 안된다. 서로 다른 행동을 그룹화 한 객체는 유지보수가 어려워진다.

클래스는 작을수록 좋다. 소프트웨어 디자인에서 SRP는 응집력과 밀접한 관련이 있다. 여기서 추구하려는 것은 클래스에 있는 프로퍼티와 속성이 항상 메서드를 통해서 사용되도록 하는 것이다. 이렇게 하면 이들은 관련된 개념이기 때문에 동일한 추상화로 묶는 것이 가능하다.

너무 많은 책임을 가진 클래스

다음은 SRP를 준수하지 않은 디자인이다.

Untitled 84.png

class SystemMonitor:
    def load_activity(self):
        """소스에서 처리할 이벤트 가져오기"""
 
    def identify_events(self):
        """가져온 데이터를 파싱하여 도메인 객체 이벤트로 변환"""
 
    def stream_events(self):
        """파싱한 이벤트를 외부 에이전트로 전송"""

이 클래스의 문제점은 독립적인 동작을 하는 메서드를 하나의 인터페이스에 정의했다는 것이다. 각각의 동작은 나머지 부분과 독립적으로 수행할 수 있다.

이 디자인 결함은 유지보수를 어렵게 하여 클래스가 경직되고 융통성 없으며 어류가 발생하기 쉽게 만든다.
여기에서 각 메서드는 클래스의 책임을 대표한다. 각각의 책임마다 수정 사유가 발생한다. 즉 메서드마다 다양한 변경의 필요성이 생기게된다.

위 코드에서 load_activity의 데이터의 구조를 변경하는 등의 이유로 수정해야 할 경우 클래스 전체를 변경해야 하며, 이는 적절하지 않다.

데이터의 표현 변경으로 전체 클래스를 변경해서는 안된다.

책임 분산

Untitled 1 35.png

class ActivityReader: 
    def load(self): 
 
 
class SystemMonitor: 
    def identify_events(self): 
 
 
class Ouput: 
    def stream(self): 
 
 
class AlertSystem: 
    def run(self): 
        ActivityReader().load() 
        SystemMonitor().identify_events() 
        Output().stream()

솔루션을 보다 쉽게 관리하기 위해 모든 메서드를 다른 클래스로 분리하여 각 클래스마다 단일 책임을 갖게 한 예이다.

메서드들을 각자의 책임을 가진 여러 객체로 만들고, 이들 객체들과 협력하여 동일한 기능을 수행하는 객체를 만들 수 있다. 이 때 각각의 객체들은 특정한 기능을 캡슐화하여 나머지 객체들에 영향을 미치지 않으며 명확하고 구체적인 의미를 갖는다.

이 변경으로 변경사항이 로컬에만 적용되고 영향이 미미하므로 각 클래스의 유지보수가 쉬워진다.

ActivityReader을 다른 용도로 사용하고자 할 때 이 디자인을 적용하면 단순히 ActivityReader타입의 객체를 사용하면 된다.
이전 디자인에서는 전혀 필요하지 않은 identify_events()stream_events()같은 메서드도 같이 상속 받야아 했지만, 이제는 필요한 메서드만 상속 받을 수 있다.

처리해야 할 로직이 같은 경우에만 한 클래스 내에 여러 메서드를 추가할 수 있다.

개방 폐쇄 원칙

개방/폐쇄 원칙(Open/Close Principle)은 모듈이 개방되어 있으면서도 폐쇄되어야 한다는 원칙이다.

클래스를 디자인할 때는 유지보수가 쉽도록 로직을 캡슐화하여 확장에는 개방되고 수정에는 폐쇄되도록 해야 한다.

새로운 기능을 추가하다가 기존 코드를 수정했다면 그것은 기존 로직이 잘못 디자인되었다는 것을 뜻한다. 요구사항이 변경되면 새로운 기능을 구현하기 위한 모듈만 확장을 하고 기존 코드는 수정하면 안 된다.

개방/폐쇄 원칙을 따르지 않을 경우 유지보수의 어려움

사용하는 예제는 수집한 데이터를 기반으로 발생하는 이벤트의 타입을 분류한다.

class Event:
    def __init__(self, raw_data):
        self.raw_data = raw_data
 
 
class UnknownEvent(Event):
    """데이터만으로 식별할 수 없는 이벤트"""
 
 
class LoginEvent(Event):
    """로그인 사용자에 의한 이벤트"""
 
 
class LogoutEvent(Event):
    """로그아웃 사용자에 의한 이벤트"""
 
 
class SystemMonitor:
    """시스템에서 발생한 이벤트 분류"""
 
    def __init__(self, event_data):
        self.event_data = event_data
 
    def identify_event(self):
        if (
                self.event_data["before"]["session"] == 0
                and self.event_data["after"]["session"] == 1
            ):
                return LoginEvent(self.event_data)
        elif (
            self.event_data["before"]["session"] == 1
            and self.event_data["after"]["session"] == 0
        ):
            return LogoutEvent(self.event_data)
        
        return UnknownEvent(self.event_data)

위 코드는 다음과 같이 동작한다.

>>> l1 = SystemMonitor({"before": {"session": 0}, "after": {"session": 1}})
>>> l1.identify_event().__class__.__name__
'LoginEvent'
 
>>> l2 = SystemMonitor({"before": {"session": 1}, "after": {"session": 0}})
>>> l2.identify_event().__class__.__name__
'LogoutEvent'
 
>>> l3 = SystemMonitor({"before": {"session": 1}, "after": {"session": 1}})
>>> l3.identify_event().__class__.__name__
'UnknownEvent'

이 디자인은 몇가지 문제점을 가진다. 첫 번째 문제는 이벤트 유형을 결정하는 논리가 일체형으로 중앙 집중화 된다는 점이다.
지원하려는 이벤트가 늘어날수록 메서드가 커지므로 결국 매우 큰 메서드가 될 수도 있다.

또한 elif문을 사용함으로써 새로운 유형의 이벤트를 시스템에 추가할 때마다 메서드를 수정해야 한다.

확장성을 가진 이벤트 시스템으로 리팩토링

이전 예제의 문제점은 SystemMonitor 클래스가 분류하려는 구체 클래스와 직접 상호작용 한다는 점이다.

개방/폐쇄 원칙을 따르는 디자인을 달성하려면 추상화를 해야 한다.

대안은 SystemMonitor 클래스를 추상적인 이벤트와 협력하도록 변경하고, 이벤트에 대응하는 개별 로직을 각 이벤트 클래스에 위임하는 것이다. 그 후 각각의 이벤트에 다형성을 가진 새로운 메서드를 추가해야 한다.
이 메서드는 전달되는 데이터가 해당 킁래스의 타입과 일치하는지 판단하는 역할을 한다.

Untitled 2 30.png

class Event:
    def __init__(self, raw_data):
        self.raw_data = raw_data
 
    @staticmethod
    def meets_condition(event_data: dict):
        return False
 
class UnknownEvent(Event):
    """데이터만으로 식별할 수 없는 이벤트"""
 
class LoginEvent(Event):
    @staticmethod
    def meets_condition(event_data: dict):
        return (
            event_data["before"]["session"] == 0
            and event_data["after"]["session"] == 1
        )
 
class LogoutEvent(Event):
    @staticmethod
    def meets_condition(event_data: dict):
        return (
            event_data["before"]["session"] == 1
            and event_data["after"]["session"] == 0
        )
 
class SystemMonitor:
    """시스템에서 발생한 이벤트 분류"""
 
    def __init__(self, event_data):
        self.event_data = event_data
 
    def identify_event(self):
        for event_cls in Event.__subclasses__():
            try:
                if event_cls.meets_condition(self.event_data):
                    return event_cls(self.event_data)
            except KeyError:
                continue
        return UnknownEvent(self.event_data)

이제 상호작용이 추상화를 통해 이루어진다. 이 인터페이스를 따르는 제네릭들은 모두 meets_condition 메서드를 구현하여 다형성을 보장한다.

위 코드에서는 __subclasses__()를 통해 이벤트 유형을 찾고 있으며 새로운 유형의 이벤트를 지원하려면 Event 클래스를 상속 받아 meets_condition 메서드를 구현하기만 하면 된다.

이벤트 시스템 확장

Untitled 3 28.png

새로운 타입의 이벤트를 추가하게 되는 경우 새로운 TransactionEvent 클래스를 추가하는 것만으로 기존 코드가 예상한 대로 잘 동작한다.

class Event:
    def __init__(self, raw_data):
        self.raw_data = raw_data
 
    @staticmethod
    def meets_condition(event_data: dict):
        return False
 
class UnknownEvent(Event):
    """데이터만으로 식별할 수 없는 이벤트"""
 
class LoginEvent(Event):
    @staticmethod
    def meets_condition(event_data: dict):
        return (
            event_data["before"]["session"] == 0
            and event_data["after"]["session"] == 1
        )
 
class LogoutEvent(Event):
    @staticmethod
    def meets_condition(event_data: dict):
        return (
            event_data["before"]["session"] == 1
            and event_data["after"]["session"] == 0
        )
 
class TransactionEvent(Event):
    """시스템에서 발생한 트랜잭션 이벤트"""
 
    @staticmethod
    def meets_condition(event_data: dict):
        return event_data["after"].get("transaction") is not None
 
class SystemMonitor:
    """시스템에서 발생한 이벤트 분류"""
 
    def __init__(self, event_data):
        self.event_data = event_data
 
    def identify_event(self):
        for event_cls in Event.__subclasses__():
            try:
                if event_cls.meets_condition(self.event_data):
                    return event_cls(self.event_data)
            except KeyError:
                continue
 
        return UnknownEvent(self.event_data)

새로운 이벤트를 추가했지만 SystemMonitor.identify_event() 메서드는 전혀 수정하지 않았다. 따라서 이 메서드가 새로운 유형의 이벤트에 대하여 폐쇄되어 있다고 할 수 있다.

반대로 Event 클래스는 필요할 때마다 새로운 유형의 이벤트를 추가할 수 있게 해준다. 따라서 이벤트는 새로운 타입의 확장에 대해 개방되어 있다고 할 수 있다.

도메인에 새로운 문제가 나타나도 기존 코드를 수정하지 않고 새 코드를 추가하기만 하면 되는것이 이 원칙의 진정한 본질이다.

OCP 최종 정리

이 원칙은 다형성의 효과적인 사용과 밀접하게 관련되어 있다. 다형성을 따르는 형태의 계약을 만들고 모델을 쉽게 확장할 수 있는 일반적인 구조로 디자인하는 것이다.

이 원칙은 소프트웨어 엔지니어링의 중요한 문제인 유지보수성에 대한 문제를 해결한다. OCP를 따르지 않으면 파급 효과가 생겨나거나 작은 변경이 코드 전체에 영향을 미치거나 다른 부분을 손상시키게 된다.

코드를 변경하지 않고 기능을 확장하기 위해서는 보호하려는 추상화에 대해서 적절한 폐쇄를 해야 한다.

리스코프 치환 원칙(LSP)

리스코프 치환 원칙(Liskov substitution principle, LSP)은 설계시 안정성을 유지하기 위해 객체 타입이 유지해야 하는 일련의 특성을 말한다.

LSP의 주된 생각은 어떤 클래스에서든 클라이언트는 특별한 주의를 기울이지 않고 하위 타입을 사용할 수 있어야 한다는 것이다.

Untitled 4 25.png

위 그림에서 클라이언트는 별다른 주의를 기울이지 않고도 타입을 확장하는 여러 하위클래스(subtype1 subtype2 subtypeN)의 인스턴스로 작업을 할 수 있어야 한다.

주어진 타입과 클래스 사이에는 계약이 필요하다. LSP의 규칙에 따르면 하위 클래스는 상위 클래스에서 정의한 계약을 따르도록 디자인해야 한다.

만약 파생 클래스가 부모 클래스에서 정의한 파라미터와 다른 타입이나 반환 값을 사용할 경우 LSP가 위반되었다고 할 수 있다.

애매한 LSP 위반 사례

클라이언트와 제공자 사이의 계약은 몇 가지 규칙을 가지고 있다. 클라이언트는 제공자가 유효성을 검사할 수 있도록 사전조건을 제공하고 제공자는 클라이언트가 사후 조건으로 검사할 값을 반환한다.

부모 클래스는 클라이언트와의 계약을 정의한다. 하위 클래스는 그 계약을 따라야 한다.

  • 하위 클래스는 부모 클래스에 정의된 것보다 사전 조건을 엄격하게 만들면 안된다.
  • 하위 클래스는 부모 클래스에 정의된 것보다 약한 사후조건을 만들면 안된다.

LSP 최종 정리

LSP는 객체지향 소프트웨어 설계의 핵심이 되는 다형성을 강조하기 때문에 좋은 디자인의 기초가 된다.

새로운 클래스가 원래의 계약과 호환되지 않는 확장을 하려고 하면 클라이언트와의 계약이 깨져서 결과적으로는 확장이 불가할것이다. 또는 확장이 가능하려면 수정에 대해 폐쇄되어야 한다는 원칙을 깨야 한다. 이는 바람직하지 못한 형태이다.

LSP에서 제안하는 방식으로 신중하게 클래스를 디자인하면 계층을 올바르게 확장하는 데 도움이 된다. 즉 LSP가 OCP에 기여한다고 할 수 있다.

인터페이스 분리 원칙

인터페이스 분리 원칙(interface segregation principle, ISP)은 “작은 인터페이스”에 대한 가이드라인을 제공한다.

객체 지향적 용어로 인터페이스는 객체가 노출하는 메서드의 집합이다. 즉 객체가 수신하거나 해석할 수 있는 모든 메시지가 인터페이스를 구성하며, 이것들은 다른 클라이언트에서 호출할 수 있는 요청들이다. 인터페이스는 클래스에 노출된 동작의 정의와 구현을 분리한다.

파이썬에서 인터페이스는 클래스 메서드의 형태를 보고 암시적으로 정의한다. 이것은 파이썬이 덕 타이핑(duck typing) 원리를 따르기 때문이다.

덕타이핑은 모든 객체가 자신이 가지고 있는 메서드와 자신이 할 수 있는 일에 의해서 표현된다는 점에서 출발한다.
즉 클래스의 유형, 이름, docstring, 클래스 속성 또는 인스턴스 속성에 관계없이 객체의 본질을 정의하는 것은 궁극적으로 메서드의 형태이다.

추상적으로 ISP는 다음을 뜻한다.
다중 메서드를 가진 인터페이스가 있다면 매우 정확하고 구체적인 구분에 따라 더 적은 수의 메서드(가급적 하나)를 가진 여러 개의 메서드로 분할하는 것이 좋다는 것이다.

재사용성을 높이기 위해 가능한 작은 단위로 인터페이스를 분리한다면 인터페이스 중 하나를 구현하려는 각 클래스가 매우 명확한 동작과 책임을 지니기 때문에 응집력이 높아진다.

너무 많은 일을 하는 인터페이스

Untitled 5 22.png

class EventParser: 
    def from_xml(self): 
    
    def from_json(self):

위 클래스는 구체적인 클래스 대신 인터페이스에 종속성이 있는 경우이다.

이것을 인터페이스로 만들려면 파이썬에서는 추상 기본 클래스를 만들고 from_xml()from_json()이라는 메서드를 정의한다.
이 클래스를 상속한 이벤트는 이 메서드들을 구현해야 한다.

만약 클래스가 XML, JSON 둘 중 하나만 필요로 할 경우에도 위 인터페이스에서는 필요하지 않은 나머지 메서드를 제공한다.
이것은 결합력을 높여 유연성을 떨어트리며, 클라이언트가 필요하지 않은 메서드를 구현하도록 한다.

인터페이스는 작을수록 좋다.

앞의 인터페이스는 각각 하나의 메서드를 가진 두 개의 다른 인터페이스로 분리하는 것이 좋다.

Untitled 6 19.png

class XMLEventParser: 
    def from_xml(self): 
 
 
class JSONEventParser: 
    def from_json(self):

이 디자인을 사용하면 XMLEventParser에서 파생된 클래스는 from_xml() 메서드만을 구현하면 되고, 마찬가지로 JSONEventParser에서 파생된 클래스는 from_json()만을 구현하면 된다.

이것으로 두 클래스가 독립성을 유지하게 되었고 새로운 작은 객체를 사용해 모든 기능을 유연하게 조합할 수 있게 되었다. SRP와 유사하지만 주요 차이점은 ISP는 인터페이스에 대해 이야기하고 있다는 점이다.

인터페이스는 얼마나 작아야 할까?

클래스는 다른 클래스들이 확장할 수 있도록 인터페이스를 정의해야 한다.
이것은 응집력의 관점에서 가능한 단 한 가지 일을 수행하는 작은 인터페이스여야 한다.

의존성 역전

의존성 역전(DIP)은 코드가 깨지거나 손상되는 취약점으로부터 보호해주는 디자인 원칙을 제시한다.

의존성을 역전시킨다는 것은 코드가 세부사항이나 구체적인 구현에 적응하도록 하지 않고 API등에 적응하도록 하는 것이다.

추상화를 통해 세부 사항에 의존하지 않도록 해야 하지만, 반대로 세부 사항(구체적 구현)은 추상화에 의존해야 한다.

추상화는 인터페이스 형태로 제공된다.

일반적으로 구체적인 구현이 추상 컴포넌트보다 훨씬 더 자주 바뀐다.
이런 이유로 시스템이 변경, 수정, 확장 될 것으로 예상되는 지점에 유연성을 확보하기 위해 추상화(인터페이스 사용)를 하는 것이다.

엄격한 의존의 예

식별된 이벤트를 전달하는 코드의 예이다.

Untitled 7 17.png

class EventStreamer:
    def __init__(self, parsed_data: str, client: Syslog):
        self.parsed_data = parsed_data
        self.client = client
        
    def stream(self):
        self.client.send(self.parsed_data)    
        
class Syslog:
    def send(data: str):
        pass

위 디자인은 저수준의 내용에 따라 고수준의 클래스가 변경되어야 하므로 좋은 디자인이라고 보기 어렵다.

의존성을 거꾸로

Untitled 8 15.png

from abc import ABCMeta, abstractmethod
 
class EventStreamer:
    def __init__(self, parsed_data: str, client: DataTargetClient):
        self.parsed_data = parsed_data
        self.client = client
        
    def stream(self):
        self.client.send(self.parsed_data)    
 
 
class DataTargetClient(metaclass=ABCMeta):
    @abstractmethod
    def send(self, data: str):
        pass
 
class Syslog(DataTargetClient):
    def send(data: str):
        pass

이러한 문제를 해결하려면 EventStreamer을 구체 클래스가 아닌 인터페이스와 대화하도록 하는 것이 좋다. 이렇게 하면 인터페이스의 구현은 세부 구현사항을 가진 저수준 클래스가 담당하게 된다.

첫 번째 EventStream구현은 Syslog유형의 객체와만 동작했기 때문에 유연성이 떨어진다. 두 번째에서는 .send()메서드를 인터페이스의 메서드로 사용한다. Syslogsend()메서드가 정의된 DataTargetClient 추상 기본 클래스를 확장한다.
이것으로 EventStreamer는 send()가 구현된 객체라면 어떤 것과도 통신할 수 있게 되었다.

이렇게 의존성을 동적으로 제공한다고 의존성 주입(dependency injection) 이라고도 한다.

Link to original

데코레이터

파이썬의 데코레이터

데코레이터는 데코레이터 이후에 나오는 것을 데코레이터의 첫 파라미터로 하고 데코레이터의 결과 값을 반환한다.

함수 데코레이터

함수에 데코레이터를 사용하면 어떤 종류의 로직이라도 적용할 수 있다.

class ControlledException(Exception):
    """도메인에서 발생하는 일반적인 예외"""
 
def retry(operation):
    @wraps(operation)
    def wrapped(*args, **kwargs):
        last_raised = None
        RETRIES_LIMIT = 3
        for _ in range(RETRIES_LIMIT):
            try:
                return operation(*args, **kwargs)
            except ControlledException as e:
                logger.info("retrying %s", operation.__qualname__)
                last_raised = e
        raise last_raised
 
    return wrapped

위의 retry 데코레이터는 파라미터가 필요 없으므로 어떤 함수애도 쉽게 적용할 수 있다.

@retry
def run_operation(task):
    """실행중 예외가 발생할 것으로 예상되는 특정 작업을 실행"""
    return task.run()

클래스 데코레이터

데코레이터를 남용할 경우 코드가 복잡해지고 가독성을 떨어뜨릴 수 있다.

클래스 데코레이터의 장점

  • 코드 재사용과 DRY 원칙의 모든 이점을 공유한다. 클래스 데코레이터를 사용하면 여러 클래스가 특정 인터페이스나 기준을 따르도록 강제할 수 있다. 여러 클래스에 적용할 검사를 데코레이터에서 한 번만 하면 된다.
  • 처음에 작고 간단한 클래스를 생성하고 추후 데코레이터로 기능을 보강할 수 있다.
  • 특정 클래스에 대해서는 유지보수시 데코레이터를 사용해 기존 로직을 쉽게 변경할 수 있다. 메타클래스와 같은 방법을 사용해 보다 복잡하게 만드는 것은 주로 권장되지 않는다.
class LoginEventSerializer:
    def __init__(self, event):
        self.event = event
 
    def serialize(self) -> dict:
        return {
            "username": self.event.username,
            "password": "**redacted**",
            "ip": self.event.ip,
            "timestamp": self.event.timestamp.strftime("%Y-%m-%d %H:%M"),
        }
 
class LoginEvent:
    SERIALIZER = LoginEventSerializer
 
    def __init__(self, username, password, ip, timestamp):
        self.username = username
        self.password = password
        self.ip = ip
        self.timestamp = timestamp
 
    def serialize(self) -> dict:
        return self.SERIALIZER(self).serialize()

위 예제는 LoginEvent의 데이터에 대해 직렬화를 수행한다. 로그인 이벤트에 직접 매핑할 클래스를 선언하여 사용했다.

이 방법은 시스템을 확장할수록 다음과 같은 문제가 발생한다.

  • 클래스가 너무 많아진다 : 이벤트 클래스와 직렬화 클래스가 1 : 1로 매핑되어 있으므로 직렬화 클래스가 많아지게 된다.
  • 충분히 유연하지 않다 : 만약 password를 가진 다른 클래스에서도 이 필드를 숨기고자 한다면 함수로 분리한 후 여러 클래스에서 호출해야 한다. 이는 코드를 충분히 재사용했다고 볼 수 없다.
  • 표준화 : serialize()메서드는 모든 이벤트 클래스에 있어야만 한다. 믹스인을 사용해 다른 클래스로 분리할 수 있지만 상속을 제대로 사용했다고 볼 수 없다.

다른 방법은 이벤트 인스턴스와 변형 함수를 필터로 받아서 동적으로 객체를 만드는 것이다.

def hide_field(field) -> str:
    return "**redacted**"
 
def format_time(field_timestamp: datetime) -> str:
    return field_timestamp.strftime("%Y-%m-%d %H:%M")
 
def show_original(event_field):
    return event_field
 
 
class EventSerializer:
    def __init__(self, serialization_fields: dict) -> None:
        self.serialization_fields = serialization_fields
 
    def serialize(self, event) -> dict:
        return {
            field: transformation(getattr(event, field)) for field, transformation in self.serialization_fields.items()
        }
 
 
class Serialization:
    def __init__(self, **transformations):
        self.serializer = EventSerializer(transformations)
 
    def __call__(self, event_class):
        def serialize_method(event_instance):
            return self.serializer.serialize(event_instance)
 
        event_class.serialize = serialize_method
        return event_class
 
 
@Serialization(
    username=str.lower,
    password=hide_field,
    ip=show_original,
    timestamp=format_time,
)
class LoginEvent:
    def __init__(self, username, password, ip, timestamp):
        self.username = username
        self.password = password
        self.ip = ip
        self.timestamp = timestamp

데코레이터를 사용하면 다른 클래스의 코드를 확인하지 않고도 각 필드가 어떻게 처리되는지 쉽게 알 수 있다.

파이썬 3.7 이상의 버전에서의 데코레이터를 사용하면 init함수의 템플릿화된 단순 코드를 작성하지 않고도 앞의 예제를 보다 간단하게 작성할 수 있다.

from dataclasses import dataclass
from datetime import datetime
 
@Serialization(
    username=str.lower,
    password=hide_field,
    ip=show_original,
    timestamp=format_time,
)
@dataclass
class LoginEvent:
    username: str
    password: str
    ip: str
    timestamp: datetime

데코레이터에 인자 전달

파라미터를 갖는 데코레이터를 구현하는 일반적인 방법중 첫 번째는 간접 참조(indirection)를 통해 새로운 레벨의 중첩 함수를 만들어 데코레이터의 모든 것을 한 단게 더 깊게 만드는 것이다. 두 번째 방법은 데코레이터를 위한 클래스를 만드는 것이다. 일반적으로 두 번째 방법이 가독성이 더 좋다.

중첩 함수의 데코레이터

데코레이터에 파라미터를 전달하기 위해서는 최소 세 단계의 중첩 함수가 필요하다.

여기에서 첫 번째 함수는 파라미터를 받아 내부 함수에 전달한다. 두 번째 함수는 데코레이터가 될 함수이다. 세 번째는 데코레이팅의 결과를 반환하는 함수이다.

파라미터와 기본 값을 가지는 새로운 데코레이터 구현은 다음과 같다

RETRIES_LIMIT = 3
 
def with_retry(retries_limit=RETRIES_LIMIT, allowed_exceptions=None):
    allowed_exceptions = allowed_exceptions or (ControlledException,)
 
    def retry(operation):
        @wraps(operation)
        def wrapped(*args, **kwargs):
            last_raised = None
            for _ in range(retries_limit):
                try:
                    return operation(*args, **kwargs)
                except allowed_exceptions as e:
                    logger.warning(
                        "retrying %s due to %s", operation.__qualname__, e
                    )
                    last_raised = e
            raise last_raised
 
        return wrapped
 
    return retry

다음은 이 데코레이터를 함수에 적용한 예이다.

@with_retry()
def run_operation(task):
    return task.run()
 
@with_retry(retries_limit=5)
def run_with_custom_retries_limit(task):
    return task.run()
 
@with_retry(allowed_exceptions=(AttributeError,))
def run_with_custom_exceptions(task):
    return task.run()
 
@with_retry(retries_limit=4, allowed_exceptions=(ZeroDivisionError, AttributeError))
def run_with_custom_parameters(task):
    return task.run()

데코레이터 객체

중첩 함수 방식의 파라미터 전달은 세 단계의 중첩된 함수가 필요하다.

이것을 보다 깔끔하게 구현하기 위해 클래스를 사용하여 데코레이터를 정의할 수 있다. 이 경우 __init__ 메서드에 파라미터를 전달한 후 __call__ 매직 메서드에서 데코레이터의 로직을 구현한다.

class WithRetry:
    def __init__(self, retries_limit=RETRIES_LIMIT, allowed_exceptions=None):
        self.retries_limit = retries_limit
        self.allowed_exceptions = allowed_exceptions or (ControlledException,)
 
    def __call__(self, operation):
        @wraps(operation)
        def wrapped(*args, **kwargs):
            last_raised = None
 
            for _ in range(self.retries_limit):
                try:
                    return operation(*args, **kwargs)
                except self.allowed_exceptions as e:
                    logger.info(
                        "retrying %s due to %s", operation.__qualname__, e
                    )
                    last_raised = e
            raise last_raised
 
        return wrapped

사용 방법은 이전과 유사하다.

@WithRetry(retries_limit=5)
def run_with_custom_retries_limit(task):
    return task.run()

데코레이터 활용 우수 사례

  • 파라미터 변환 : 더 좋은 API를 노출하기 위해 함수의 시그니처를 변경하는 경우 이 때 파라미터가 어떻게 처리되고 변환되는지를 캡슐화하여 숨길 수 있다.
  • 코드 추적 : 파라미터와 함께 함수의 실행을 로깅하려는 경우
  • 파라미터 유효성 검사
  • 재시도 로직 구현
  • 일부 반복 작업을 데코레이터로 이동하여 클래스 단순화

파라미터 변환

데코레이터를 사용하여 파라미터의 유효성을 검사할 수 있다. DbC의 원칙에 따라 사전조건 또는 사후조건을 강제할 수도 있다. 따라서 일반적으로 파라미터를 다룰 때 데코레이터를 자주 사용한다.

특히 유사한 객체를 반복적으로 생성하거나 추상화를 위해 유사한 변형을 반복하는 경우 데코레이터를 사용하면 작업을 쉽게 처리할 수 있다.

코드 추적

  • 실제 함수의 실행 경로 추적 (실행 함수 로깅)
  • 함수 지표 모니터링 (CPU 사용량, 메모리 사용량 등)
  • 함수의 실행 시간 측정
  • 함수의 실행 시각, 파라미터 종류 로깅)

데코레이터의 활용 - 흔한 실수 피하기

래핑된 원본 객체의 데이터 보존

데코레이터를 함수에 적용할 때 원본 함수의 일부 프로퍼티 또는 속성을 유지하지 않아 원하지 않는 부작용을 유발하는 경우가 있다.

def trace_decorator(function):
    def wrapped(*args, **kwargs):
        logger.info("running %s", function.__qualname__)
        return function(*args, **kwargs)
 
    return wrapped

위 데코레이터를 사용한 함수의 경우 __qualname__ 속성을 사용해 함수의 이름을 출력 할 경우 원본 함수의 이름이 아닌 새로운 함수의 이름을 출력하게 된다.

@trace_decorator
def process_account(account_id):
    """Id별 계정 처리"""
    logger.info("processing account %s", account_id)
    ...
>>> print(process_account.__qualname__)
'trace_decorator.<locals>.wrapped'

이 데코레이터를 이름이 다른 여러 함수에 적용하더라도 wrapped라는 이름만 출력하게 된다. 이렇게 되면 개별 함수를 확인하고 싶은 경우에 실제 함수를 알 수 없으므로 오히려 디버깅이 더 어려워지는 문제가 생긴다.

또 다른 문제는 이러한 함수에 테스트와 함께 docstring을 작성한 경우 데코레이터에 의해 덮어써진다는 점이다.

이 것을 수정하기 위해서는 래핑된 함수 wrapped 함수에 @wraps 데코레이터를 적용하여 function 파라미터 함수를 래핑한 것이라고 명시해 주어야 한다.

def trace_decorator(function):
    @wraps(function)
    def wrapped(*args, **kwargs):
        logger.info("running %s", function.__qualname__)
        return function(*args, **kwargs)
 
    return wrapped

이렇게 코드를 변경하면 함수의 이름이 보존되며, wraps 데코레이터의 __wrapped__를 사용해 수정되지 않은 원본 함수에도 접근할 수 있다. 일반적인 데코레이터의 경우 아래의 구조에 따라 functiongools.wraps를 추가하면 된다.

def decorator(original_function):
    @wraps(original_function)
    def decorated_function(*args, **kwargs):
        # 데코레이터에 의한 수정 작업 ...
        return original_function(*args, **kwargs)
 
    return decorated_function

데코레이터 부작용 처리

데코레이터 부작용의 잘못된 처리

def traced_function_wrong(function):
    logger.debug("%s 함수 실행", function)
    start_time = time.time()
 
    @wraps(function)
    def wrapped(*args, **kwargs):
        result = function(*args, **kwargs)
        logger.info(
            "함수 %s의 실행시간: %.2fs", function, time.time() - start_time
        )
        return result
 
    return wrapped

위의 예제는 함수의 실행과 실행 시간을 로깅하는 데코레이터이다. 여기에서 start_time는 모듈을 처음 임포트할 때의 시간이 된다. 때문에 함수가 실제로 호출될 때가 아니라 잘못된 시점에 기록되게 된다.

start_time = time.time() 코드를 래핑된 함수 내부로 이동시켜 실행을 지연시키면 문제는 해결된다.

def traced_function(function):
    @wraps(function)
    def wrapped(*args, **kwargs):
        logger.info("%s 함수 실행", function)
        start_time = time.time()
        result = function(*args, **kwargs)
        logger.info(
            "함수 %s의 실행시간: %.2fs", function, time.time() - start_time
        )
        return result
 
    return wrapped

데코레이터 부작용의 활용

데코레이터의 부작용을 활용하는 대표적인 예로 모듈의 공용 레지스트리에 객체를 등록하는 경우가 있다.

이벤트 시스템에서 일부 이벤트만 사용하려는 경우 이벤트 계층 구조의 중간에 가상의 클래스를 만들고 일부 파생 클래스에 대해서만 이벤트를 처리하도록 할 수 있다. 각 클래스마다 처리 여부에 플래그 표시를 하는 대신 데코레이터를 사용해 명시적으로 표시 할 수 있다.

EVENTS_REGISTRY = {}
 
def register_event(event_cls):
    """모듈에서 접근 가능하도록 이벤트 클래스를 레지스트리에 등록"""
    EVENTS_REGISTRY[event_cls.__name__] = event_cls
    return event_cls
 
class Event:
    """기본 이벤트 객체"""
 
class UserEvent:
    TYPE = "user"
 
@register_event
class UserLoginEvent(UserEvent):
    """사용자가 시스템에 접근했을 때 발생하는 이벤트"""
 
@register_event
class UserLogoutEvent(UserEvent):
    """사용자가 시스템에서 나갈 때 발생하는 이벤트"""

위 코드에서 처음에 EVENTS_REGISTRY는 비어있는 것처럼 보이지만 이 모듈을 임포트하면 register_event 가 지정된 클래스들로 채워지게 된다. EVENTS_REGISTRY는 모듈을 임포트한 직후에 최종 값을 가지므로 코드만 봐서는 값을 쉽게 예측하기 어렵다.

이러한 동작 방식이 문제가 되는 경우도 있지만 이 패턴이 필요한 경우도 존재한다.

어느 곳에서나 동작하는 데코레이터 만들기

함수에 사용될 데코레이터를 클래스의 메서드에 사용하거나 또는 메서드에 대한 데코레이터를 다른 유사한 메서드에 적용하려는 경우 오류가 발생할 수 있다.

데코레이터를 만들 때에는 일반적으로 재사용을 고려하여 함수뿐만 아니라 메서드에서도 동작하기를 바란다.

*args**kwargs 시그니처를 사용하여 데코레이터를 정의하면 모든 경우에 사용할 수 있다. 하지만 다음 이유들로 원래 함수의 시그니처와 비슷하게 데코레이터를 정의하는 것이 좋을 때가 있다.

  • 원래의 함수와 모양이 비슷하기 때문에 읽기가 쉽다.
  • 파라미터를 받아 작업할 시 *args**kwargs를 사용하는 것이 불편하다.
from functools import wraps
 
class DBDriver:
    def __init__(self, dbstring):
        self.dbstring = dbstring
 
    def execute(self, query):
        return f"query {query} at {self.dbstring}"
 
 
def inject_db_driver(function):
    """데이터베이스 dns 문자열을 받아서 DBDriver 인스턴스를 생성하는 데코레이터"""
 
    @wraps(function)
    def wrapped(dbstring):
        return function(DBDriver(dbstring))
 
    return wrapped
 
 
@inject_db_driver
def run_query(driver):
    return driver.execute("test_function")

함수에 문자열을 전달하면 DBDriver인스턴스를 반환하므로 예상한 것처럼 동작한다. 하지만 이 데코레이터를 클래스 메서드에 적용할 경우 동작하지 않는다.

class DataHandler:
    @inject_db_driver
    def run_query(self, driver):
        return driver.execute(self.__class__.__name__)

클래스의 메서드는 self라는 특수한 변수를 항상 첫 번째 파라미터로 받는다. 따라서 하나의 파라미터만 받도록 설계된 이 데코레이터는 연결 문자열 자리에 self 를 전달하고, 두 번째 파라미터에는 아무것도 전달하지 않아 에러가 발생한다.

이 문제의 해결책은 데코레이터를 클래스 객체로 구성하고 __get__ 메서드를 구현한 디스크립터 객체를 만드는 것이다.

from functools import wraps
from types import MethodType
 
class inject_db_driver:
    """문자열을 DBDriver 인스턴스로 변환하여 래핑된 함수에 전달"""
 
    def __init__(self, function):
        self.function = function
        wraps(self.function)(self)
 
    def __call__(self, dbstring):
        return self.function(DBDriver(dbstring))
 
    def __get__(self, instance, owner):
        if instance is None:
            return self
        return self.__class__(MethodType(self.function, instance))

데코레이터와 DRY 원칙

데코레이터를 사용하면 특정 로직을 분리된 컴포넌트로 추상화할 수 있다. 이것의 가장 큰 장점은 여러 객체에 데코레이터를 적용하여 코드를 재사용할 수 있다는 것이다. 이것은 특정 기능을 한번만 정의하기 때문에 DRY(Don’t Repeat Yourself) 원칙을 잘 따른다.

코드 재사용을 위해서 데코레이터를 사용할 때에는 실질적으로 코드 사용량을 줄일 수 있다는 확신이 있어야 한다.

모든 데코레이터, 특히 신중하게 설계되지 않은 데코레이터는 코드의 복잡성을 증가시킨다. 따라서 그다지 재사용할 필요가 없을 경우 별개의 함수나 작은 클래스로도 충분한 경우가 있다.

다음과 같은 사항을 고려했을 경우에만 데코레이터 사용을 하는 것이 좋다.

  • 처음부터 데코레이터를 만들지 않는다. 패턴이 생기고 데코레이터에 대한 추상화가 명확해지면 그 때 리팩토링을 한다.
  • 데코레이터가 적어도 3회 이상 필요한 경우에만 구현한다. 재사용 가능한 컴포넌트를 만드는 것은 일반 컴포넌트를 만드는 것보다 세 배나 더 어렵다.
  • 데코레이터 코드를 최소한으로 유지한다.

데코레이터와 관심사의 분리

코드 재사용의 핵심은 응집력이 있는 컴포넌트를 만드는 것이다. 최소한의 책임을 가져서 오직 한 가지 일만 해야 하며, 그 일을 잘 해야 한다. 컴포넌트가 작을수록 재사용성이 높아진다. 또한 결합과 종속성을 유발하고 소프트웨어의 유연성을 떨어뜨리는 추가 동작이 필요 없이 여러 상황에서 쓰일 수 있다.

def traced_function(function):
    @functools.wraps(function)
    def wrapped(*args, **kwargs):
        logger.info("%s 함수 실행", function.__qualname__)
        start_time = time.time()
        result = function(*args, **kwargs)
        logger.info(
            "함수 %s 처리 소요시간 %.2fs",
            function.__qualname__,
            time.time() - start_time
        )
        return result
    return wrapped

위의 데코레이터는 하나 이상의 작업을 수행하고 있다. 오직 한 가지의 작업만 원하는 경우에도 두 가지 책임을 실행하고 있다.

이것은 좀 더 구체적이고 제한적인 책임을 지닌 더 작은 데코레이터로 분류되어야 한다.

def log_execution(function):
    @wraps(function)
    def wrapped(*args, **kwargs):
        logger.info("%s 함수 실행", function.__qualname__)
        return function(*kwargs, **kwargs)
    return wrapped
    
def measure_time(function):
    @wraps(function)
    def wrapped(*args, **kwargs):
        start_time = time.time()
        result = function(*args, **kwargs)
        logger.info("함수 %s 처리 소요시간 %.2fs", function.__qualname__,
        time.time() - start_time)
        return result
    return wrapped

동일한 기능을 다음과 같이 조합하여 달성할 수 있다.

@measure_time
@log_execution
def operation():
 ....

데코레이터에 하나 이상의 책임을 두면 안 된다. SRP는 데코레이터에도 적용된다.

좋은 데코레이터 분석

좋은 데코레이터가 갖추어야 할 특성

  • 캡슐화와 관심사의 분리 : 실제로 하는 일과 데코레이팅 하는 일의 책임을 명확히 구분해야 한다. 데코레이터의 클라이언트는 내부에서 어떻게 구현했는지 전혀 알 수 없는 블랙박스처럼 동작해야 한다.
  • 독립성 : 데코레이터가 하는 일은 독립적이어야 하며 데코레이팅 되는 객체와 최대한 분리되어야 한다.
  • 재사용성 : 데코레이터는 하나의 여러 유형에 적용 가능한 형태가 바람직하다. 하나의 함수에만 적용된다면 데코레이터가 아닌 함수로 대체할 수 있다.
Link to original

디스크립터

디스크립터 개요

디스크립터는 클래스 변수의 조작에 제약을 주고 싶을 때 사용한다.

디스크립터 메커니즘

디스크립터를 구현하려면 최소 두 개의 클래스가 필요하다.

클라이언트 클래스는 디스크립터 구현의 기능을 활용할 도메일 모델로서 솔루션을 위해 생성한 일반적인 추상화 객체이다. 디스크립터 클래스는 디스크립터 로직의 구현체이다.

디스크립터는 디스크립터 프로토콜을 구현한 클래스의 인스턴스이다. 이 클래스는 다음 매직 메서드 중 최소 한 개 이상을 포함해야 한다.

  • __get__
  • __set__
  • __delete__
  • __set_name__

이 프로토콜이 동작하려면 디스크립터 객체가 클래스 속성으로 정의되어야 한다. 이 객체를 인스턴스 속성으로 생성하면 동작하지 않으므로 init 메서드가 아닌 클래스 본문에 있어야 한다.

디스크립터의 경우 클래스에서 디스크립터를 호출하면 객체 자체를 반환하는 것이 아닌 __get__ 매직 메서드의 결과를 반환한다.

class DescriptorClass:
    def __get__(self, instance, owner):
        if instance is None:
            return self
        logger.info(
            "Call: %s.__get__(%r, %r)",
            self.__class__.__name__,
            instance,
            owner,
        )
        return instance
 
class ClientClass:
    descriptor = DescriptorClass()
>>> client = ClientClass()
>>> client.descriptor
INFO:Call: DescriptorClass.__get__(<ClientClass object at 0x...>, <class 'ClientClass'>)
<ClientClass object at 0x...>

ClientClass 인스턴스의 descriptor 속성에 접근해보면 DescriptorClass인스턴스를 반환하지 않고 대신 __get__() 메서드의 반환 값을 사용한다.

디스크립터 프로토콜의 메서드 탐색

__get__(self, instance, owner)

두 번째 파라미터 instance는 디스크립터를 호출한 객체를 의미한다. 위 예제에서는 client객체를 의미한다.

세 번째 owner 파라미터는 해당 객체의 클래스를 의미한다. 예제에서는 ClientClass 클래스이다.

client인스턴스가 아닌 ClientClass에서 descriptor을 호출하는 경우 instance의 값은 None이 되기 때문에 owner 파라미터를 갖는다.

__set__(self, instance, value)

이 메서드는 디스크립터에 값을 할당하려고 할 때 호출된다. 만약 __set__()메서드를 구현하지 않은 경우 값 할당시 디스크립터를 덮어쓴다.

디스크립터 속성에 값을 할당할 때는 __set__ 메서드를 구현했는지 반드시 확인해야 한다.

class Validation:
    def __init__(self, validation_function: Callable[[Any], bool], error_msg: str) -> None:
        self.validation_function = validation_function
        self.error_msg = error_msg
 
    def __call__(self, value):
        if not self.validation_function(value):
            raise ValueError(f"{value!r} {self.error_msg}")
 
class Field:
    def __init__(self, *validations):
        self._name = None
        self.validations = validations
 
    def __set_name__(self, owner, name):
        self._name = name
 
    def __get__(self, instance, owner):
        if instance is None:
            return self
        return instance.__dict__[self._name]
 
    def validate(self, value):
        for validation in self.validations:
            validation(value)
 
    def __set__(self, instance, value):
        self.validate(value)
        instance.__dict__[self._name] = value
 
class ClientClass:
    descriptor = Field(
        Validation(lambda x: isinstance(x, (int, float)), "는 숫자가 아님"),
        Validation(lambda x: x >= 0, "is not >= 0"),
    )

프로퍼티 자리에 놓일수 있는 것은 디스크립터로 추상화할 수 있으며 여러번 재사용할 수 있다. 위의 예에서는 @property.setter가 하던 일을 __set__()메서드가 대신한다.

__delete__(self, instance)

class ProtectedAttribute:
    def __init__(self, requires_role=None) -> None:
        self.permission_required = requires_role
        self._name = None
 
    def __set_name__(self, owner, name):
        self._name = name
 
    def __set__(self, user, value):
        if value is None:
            raise ValueError(f"{self._name}을 None으로 설정할 수 없음")
        user.__dict__[self._name] = value
 
    def __delete__(self, user):
        if self.permission_required in user.permissions:
            user.__dict__[self._name] = None
        else:
            raise ValueError(f"{user!s} 사용자는 {self.permission_required} 권한이 없음")
 
class User:
    """admin 권한을 가진 사용자만 이메일 주소를 삭제할 수 있음"""
 
    email = ProtectedAttribute(requires_role="admin")
 
    def __init__(self, username: str, email: str, permission_list: list = None) -> None:
        self.username = username
        self.email = email
        self.permissions = permission_list or []
 
    def __str__(self):
        return self.username
>>> admin = User("root", "root@d.com", ["admin"])
>>> user = User("user", "user1@d.com", ["email", "helpdesk"])
>>> admin.email
'root@d.com'
 
>>> del admin.email
>>> admin.email is None
True
 
>>> user.email
'user1@d.com'
 
>>> user.email = None
...
ValueError: email을  None으로 설정할 수 없음
 
>>> del user.email
...
ValueError: User 사용자는 admin 권한이 없음

admin권한이 있는 사용자만 email을 삭제할 수 있다. admin권한이 없는 사용자가 해당 속성에 del을 요청하면 ValueError이 발생한다.

set_name(self, owner, name)

한 클래스에 같은 디스크립터를 이용해 두 개 이상의 스태틱 필드를 만드는 경우에 각 디스크립터가 관리하는 프로퍼티를 구분하기 위해 사용한다.

파이썬 3.6 이전에는 디스크립터가 이름을 자동으로 설정하지 못했기 때문에 객체 초기화시 명시적으로 이름을 전달했다. 이 방법은 잘 동작하지만 새로운 속성에 대한 디스크립터를 추가할 때마다 이름을 복사해야 하는 불편함이 있었다.

class DescriptorWithName:
    def __init__(self, name):
        self.name = name
 
    def __get__(self, instance, value):
        if instance is None:
            return self
        logger.debug("%r에서  %r 속성 가져오기", instance, self.name)
        return instance.__dict__[self.name]
 
    def __set__(self, instance, value):
        instance.__dict__[self.name] = value

위 코드는 __set_name()__ 이 없을 때의 디스크립터 코드이다.

class DescriptorWithName(DescriptorWithName):
    def __init__(self, name: str = None) -> None:
        self.name = name
 
    def __set_name__(self, owner, name):
        self.name = self.name or name
 
class ClientClass:
    descriptor = DescriptorWithName("descriptor")

__set_name__ 메서드를 사용하면 DescriptorWithName에 변수 이름을 복사하여 파라미터에 전달하지 않아도 된다. 하위 호환을 위해 __init__ 메서드에 기본 값을 지정하고 __set_name__을 함께 사용하는 것이 좋다.

디스크립터의 유형

디스크립터의 작동방식에 따라 디스크립터를 구분할 수 있다.

  • 데이터 디스크립터: __set__이나 __delete__ 메서드 구현
  • 비데이터 디스크립터: __get__만을 구현

객체의 속성을 결정할 때 데이터 디스크립터는 객체의 딕셔너리보다 우선적으로 적용되지만 비데이터 디스크립터는 그렇지 않다. 만약 객체의 사전에 비데이터 디스크립터와 동일한 이름의 키가 있으면 디스크립터는 절대 호출되지 않는다.

비데이터 디스크립터

class NonDataDescriptor:
    def __get__(self, instance, owner):
        if instance is None:
            return self
        return 42
 
class ClientClass:
    descriptor = NonDataDescriptor()
>>> client = ClientClass()
>>> client.descriptor
42
 
>>> client.descriptor = 43
>>> client.descriptor
43
 
>>> vars(client)
{'descriptor': 43}
 
>>> del client.descriptor
>>> client.descriptor
42

.descriptor속성에 다른 값을 설정하면 인스턴스의 딕셔너리가 변경된다. 이후 .descriptor속성을 조회하면 객체의 __dict__ 딕셔너리에서 descriptor키를 찾을 수 있으므로 클래스까지 검색하지 않고 __dict__ 딕셔너리에서 값을 반환한다. 때문에 디스크립터 프로토콜이 사용되지 않고 다음에 이 속성을 조회할 때는 덮어써진 43을 반환한다.

그 뒤 del을 호출해 속성을 제거하게 되면 객체의 __dict__ 딕셔너리에서 descriptor키를 지운 것과 같으므로 디스크립터 프로토콜이 다시 활성화 된다.

디스크립터가 __delete__ 메서드를 구현하지 않았기 때문에 디스크립터의 속성을 설정하면 속성이 깨지게 된다.

데이터 디스크립터

class DataDescriptor:
    def __get__(self, instance, owner):
        if instance is None:
            return self
        return 42
 
    def __set__(self, instance, value):
        logger.debug("%s.descriptor를 %s 값으로 설정", instance, value)
        instance.__dict__["descriptor"] = value
 
class ClientClass:
    descriptor = DataDescriptor()
>>> client = ClientClass()
>>> client.descriptor
42
 
>>> client.descriptor = 99
>>> client.descriptor
42
 
>>> vars(client)
{'descriptor': 99}
 
>>> client.__dict__["descriptor"]
99
 
>>> del client.descriptor
Traceback (most recent call last):
    File "<stdin>", line 1, in <module>
AttributeError: __delete__

descriptor의 값을 변경해도 반환 값은 변경되지 않는다. 객체의 __dict__에는 값 설정이 되었지만 데이터 디스크립터는 객체의 __dict__에서 조회하는 대신 클래스의 descriptor을 먼저 조회한다.

속성 삭제 또한 동작하지 않는다.

del을 호출하면 descriptor에서 __delete__()메서드를 호출하게 되는데 위의 예제에서는 __delete__메서드를 구현하지 않았기 때문이다.

instance.__dict__["descriptor"] = value

위 코드에서는 인스턴스의 __dict__에 직접 접근해 값을 할당한다. 만약 setattr(instance, "descriptor", value) 을 통해 값을 설정하려고 시도하면 __set__ 메서드가 다시 호출되어 무한루프가 발생하게 된다.

디스크립터의 set 메서드에서 setattr()이나 할당 표현식을 직접 사용하면 안 된다.

디스크립터 실전

디스크립터를 사용한 애플리케이션

디스크립터를 사용하지 않은 예

예제에서 사용하는 클래스는 current_city를 속성으로 가진다. 프로그램 실행중 current_city의 변경 사항을 추적한다.

class Traveller:
    def __init__(self, name, current_city):
        self.name = name
        self._current_city = current_city
        self._cities_visited = [current_city]
 
    @property
    def current_city(self):
        return self._current_city
 
    @current_city.setter
    def current_city(self, new_city):
        if new_city != self._current_city:
            self._cities_visited.append(new_city)
        self._current_city = new_city
 
    @property
    def cities_visited(self):
        return self._cities_visited
>>> alice = Traveller("Alice", "Barcelona")
>>> alice.current_city = "Paris"
>>> alice.current_city = "Brussels"
>>> alice.current_city = "Amsterdam"
>>> alice.cities_visited
['Barcelona', 'Paris', 'Brussels', 'Amsterdam']
 
>>> alice.current_city
'Amsterdam'

코드는 요구사항을 만족한다. 이 것이 필요한 전부라면 프로퍼티를 사용하는것 만으로도 충분하며 추가로 구현해야 할 것은 없다.

애플리케이션이 여러 곳에서 같은 로직을 사용하고자 할 경우 코드를 반복하거나 데코레이터, 디스크립터 등을사용해야 한다.

이상적인 구현방법

실질적인 코드 반복의 증거가 없거나 복잡성의 대가가 명확하지 않다면 디스크립터를 사용할 필요가 없다.

디스크립터가 다른 프로젝트에서도 동일한 결과를 내도록 설계되었기 때문에 메서드나 속성의 이름이 현재 도메인과 관련이 없다.

class HistoryTracedAttribute:
    def __init__(self, trace_attribute_name: str) -> None:
        self.trace_attribute_name = trace_attribute_name
        self._name = None
 
    def __set_name__(self, owner, name):
        self._name = name
 
    def __get__(self, instance, owner):
        if instance is None:
            return self
        return instance.__dict__[self._name]
 
    def __set__(self, instance, value):
        self._track_change_in_value_for_instance(instance, value)
        instance.__dict__[self._name] = value
 
    def _track_change_in_value_for_instance(self, instance, value):
        self._set_default(instance)
        if self._needs_to_track_change(instance, value):
            instance.__dict__[self.trace_attribute_name].append(value)
 
    def _needs_to_track_change(self, instance, value) -> bool:
        try:
            current_value = instance.__dict__[self._name]
        except KeyError:
            return True
        return value != current_value
 
    def _set_default(self, instance):
        instance.__dict__.setdefault(self.trace_attribute_name, [])
 
 
class Traveller:
    current_city = HistoryTracedAttribute("cities_visited")
 
    def __init__(self, name, current_city):
        self.name = name
        self.current_city = current_city

예제에서 디스크립터의 코드는 복잡해졌지만 클라이언트 클래스의 코드는 간결해졌다.

디스크립터 안에서는 어떠한 비즈니스 로직도 포함되어 있지 않다. 따라서 다른 어떤 클래스에 적용하여도 동일하게 동작할 것이다.

다른 형태의 디스크립터

전역 상태 공유 이슈

디스크립터는 클래스 속성으로 설정해야 한다.

클래스 속성의 문제점은 해당 클래스의 모든 인스턴스에서 공유된다는 것이다. 디스크립터도 예외가 아니기 때문에 디스크립터 객체에 데이터를 보관하면 모든 객체가 동일한 값에 접근할 수 있다.

class SharedDataDescriptor:
    def __init__(self, initial_value):
        self.value = initial_value
 
    def __get__(self, instance, owner):
        if instance is None:
            return self
        return self.value
 
    def __set__(self, instance, value):
        self.value = value
 
class ClientClass:
    descriptor = SharedDataDescriptor("첫번째 값")

디스크립터가 객체를 바로 저장하게 되는 경우에 인스턴스의 값을 수정하면 같은 클래스의 다른 모든 인스턴스에서도 값이 수정된다.

>>> client1 = ClientClass()
>>> client1.descriptor
'첫 번째 값'
 
>>> client2 = ClientClass()
>>> client2.descriptor
'첫 번째 값'
 
>>> client2.descriptor = "client 2를 위한 값"
>>> client2.descriptor
'client 2를 위한 값'
 
>>> client1.descriptor
'client 2를 위한 값'

약한 참조 사용

__dict__를 사용하지 않으려는 경우 다른 대안은 디스크립터 객체가 내부 매핑을 통해 각 인스턴스의 값을 보관하고 반환하는 것이다.

내부 매핑을 할 때는 딕셔너리를 사용해서는 안 된다. 클라이언트 클래스는 디스크립터에 대한 참조를 가지며 디스크립터는 디스크립터를 사용하는 객체에 대한 참조를가지므로 순환 종속성이 생겨 가비지 컬렉션이 되지 않는다.

이러한 문제를 해결하기 위해서 딕셔너리는 약한 키가 되어야 한다.

from weakref import WeakKeyDictionary
 
class DescriptorClass:
    def __init__(self, initial_value):
        self.value = initial_value
        self.mapping = WeakKeyDictionary()
 
    def __get__(self, instance, owner):
        if instance is None:
            return self
        return self.mapping.get(instance, self.value)
 
    def __set__(self, instance, value):
        self.mapping[instance] = value

이렇게 하면 문제가 해결되지만 몇 가지 고려사항이 존재한다.

  • 인스턴스 객체는 속성을 보유하지 않는다. 객체의 딕셔너리에 있는 내용을 찾으려고 할 경우 완전한 데이터를 반환하지 않는다. (예: vars(client) 호출)
  • 객체는 __hash__ 메서드를 구현하여 해시가 가능해야 한다. 만약 해시가 가능하지 않다면 WeakKeyDictionary 에 매핑할 수 없다.

디스크립터에 대한 추가 고려사항

코드 재사용

디스크립터는 코드 중복을 피하기 위한 일반적인 도구이자 강력한 추상화 도구이다. 디스크립터가 필요한 곳을 찾는 좋은 방법은 프로퍼티가 필요한 구조가 반복되는 경우를 찾는 것이다.

프로퍼티는 디스크립터의 특수한경우이며 디스크립터는 프로퍼티보다 훨씬 복잡한 작업에 사용될 수 있다.

디스크립터는 데코레이터가 클래스 메서드에서도 동작할 수 있도록 도와 더 나은 데코레이터를 만들 수 있게 한다. 데코레이터는 항상 __get__() 메서드를 구현하고 디스크립터를 사용하는 것이 안전하다.

데코레이터, 디스크립터를 만들 가치가 있는지 결정할 때는 같은 코드가 3회 이상 필요한지 확인해야 한다. 하지만 클라이언트가 사용하게 되는 내부API에 대해서는 디스크립터를 사용하는 것이 좋다. 이는 일회성 솔루션이 아닌 라이브러리나 프레임워크의 디자인에대해 기능을 확장하기 좋기 때문이다.

매우 특별한 이유가 있거나 코드를 훨씬 좋게하는 무언가가 없다면 디스크립터에 비즈니스 로직을 넣어서는 안 된다.

클래스 데코레이터 피하기

from functools import partial
from typing import Any, Callable
 
class BaseFieldTransformation:
    def __init__(self, transformation: Callable[[Any, str], str]) -> None:
        self._name = None
        self.transformation = transformation
 
    def __get__(self, instance, owner):
        if instance is None:
            return self
        raw_value = instance.__dict__[self._name]
        return self.transformation(raw_value)
 
    def __set_name__(self, owner, name):
        self._name = name
 
    def __set__(self, instance, value):
        instance.__dict__[self._name] = value
 
ShowOriginal = partial(BaseFieldTransformation, transformation=lambda x: x)
HideField = partial(BaseFieldTransformation, transformation=lambda x: "**redacted**")
FormatTime = partial(BaseFieldTransformation,transformation=lambda ft: ft.strftime("%Y-%m-%d %H:%M"),)

위의 예제는 각 속성에 대해 주어진 함수를 적용시킨 후 수정된 버전을 반환하는 디스크립터이다.

class LoginEvent:
    username = ShowOriginal()
    password = HideField()
    ip = ShowOriginal()
    timestamp = FormatTime()
 
    def __init__(self, username, password, ip, timestamp):
        self.username = username
        self.password = password
        self.ip = ip
        self.timestamp = timestamp
 
    def serialize(self):
        return {
            "username": self.username,
            "password": self.password,
            "ip": self.ip,
            "timestamp": self.timestamp,
        }

객체의 __dict__ 딕셔너리에 접근하여 원본 값을 가져올 수도 있다. 그러나 기본적으로는 값을 요청하면 변환된 값을 반환한다.

디스크립터는 객체이므로 모델을 만들어서 객체 지향 프로그래밍의 모든 규칙을 적용할 수 있다. 디자인 패턴은 디스크립터에도 적용된다. 계층 구조를 정의하고 사용자 정의 동작을 설정하는 등의 작업을 할 수 있다.

__init__()serialize() 메서드를 구현한 기본 클래스를 만들고 그 클래스를 상속받아 LoginEvent 클래스를 정의할 수 있다.

class BaseEvent:
    def __init__(self, **kwargs):
        self.__dict__.update(kwargs)
 
    def serialize(self):
        return {
            attr: getattr(self, attr) for attr in self._fields_to_serialize()
        }
 
    def _fields_to_serialize(self):
        for attr_name, value in vars(self.__class__).items():
            if isinstance(value, BaseFieldTransformation):
                yield attr_name
 
class NewLoginEvent(BaseEvent):
    username = ShowOriginal()
    password = HideField()
    ip = ShowOriginal()
    timestamp = FormatTime()

이렇게 코드를 작성하면 필요한 속성만 정의하면 되고 각 속성의 클래스를 보면 어떤 로직이 적용되었는지 바로 이해할 수 있다. 기본 클래스는 공통 메서드만 추상화할 것이고, 각 이벤트 클래스는 더 작고 간단하게 된다.

각 이벤트 클래스가 단순해질 뿐만 아니라, 디스크립터 자체도 작아서 클래스 데코레이터보다 훨씬 간단하다.

디스크립터 분석

파이썬 내부에서의 디스크립터 활용

함수와 메서드

함수는 __get__ 메서드를 구현했기 때문에 클래스 안에서 메서드처럼 동작할 수 있다.

메서드는 추가 파라미터를 가진 함수일 뿐이다. 관습적으로 메서드의 첫 번째 파라미터는 self라는 이름을 사용하여 해당 메서드를 소유하고 있는 클래스의 인스턴스를 나타낸다. 따라서 메서드에서 self를 사용하는 것은 객체를 받아서 수정을 하는 함수를 사용하는 것과 동일하다.

다음과 같은 형태로 메서드를 호출하면

instance = MyClass()
instance.method(...)

파이썬은 내부적으로 디스크립터를 사용하여 다음과 같이 변환하여 처리한다.

instance = MyClass()
MyClass.method(instance)

함수는 디스크립터 프로토콜을 구현했으므로 메서드를 호출하기 전 __get__() 메서드가 먼저 호출된 후 필요한 작업을 수행한다.

class Method:
    def __init__(self, name):
        self.name = name
 
    def __call__(self, instance, arg1, arg2):
        print(f"{self.name}: {instance} 호출됨. 인자는 {arg1}{arg2} 입니다.")
 
class MyClass:
    method = Method("Internal call") 

이 함수는 전달 받은 3개의 인자를 그대로 출력한다. __call__() 메서드에서 selfMethod의 인스턴스를 나타낸다.

instance = MyClass()
Method('External call')(instance, "first", "second")
instance.method("first", "second")

위의 두 가지 호출은 동일한 역할을 할 것으로 보인다. 하지만 첫 번째 호출은 동작하지만 두 번째 호출은 에러를 발생시킨다.

오류가 발생하는 이유는 파라미터가 한 칸씩 밀려 arg2자리에 아무 것도 전달되지 않았기 때문이다.

메서드를 디스크립터로 변경함으로써 이 문제를 해결할 수 있다.

디스크립터로 변경하면 instance.method 호출 시 Method.__get__ 메서드를 먼저 호출할 것이다. 여기에서 첫 번째 파라미터로 Method의 인스턴스를 전달함으로써 객체에 바인딩 해 준다.

from types import MethodType
 
class Method:
    def __init__(self, name):
        self.name = name
 
    def __call__(self, instance, arg1, arg2):
        print(f"{self.name}: {instance} 호출됨. 인자는 {arg1}{arg2} 입니다.")
 
    def __get__(self, instance, owner):
        if instance is None:
            return self
        return MethodType(self, instance)

이렇게 수정하면 두 호출이 모두 예상대로 동작한다.

types 모듈의 MethodType을 사용하여 함수를 메서드로 변환하였다.

파이썬의 함수 객체도 이것과 비슷하게 동작한다. 따라서 클래스 내부에 함수를 정의할 경우 메서드처럼 동작하는 것이다.

메서드를 위한 빌트인 데코레이터

메서드를 인스턴스가 아닌 클래스에서 직접 호출할 때는 디스크립터 자체를 반환한다. @classmethod를 사용하면 데코레이팅 함수의 첫 번째 파라미터로 메서드를 소유한 클래스를 넘겨준다.

@staticmethod를 사용하면 정의한 파라미터 이외의 파라미터를 넘기지 않는다. 즉 함수의 첫 번째 파라미터에 self를 바인딩하는 작업을 취소한다.

슬롯

클래스에 __slot__ 속성을 정의하면 클래스가 기대하는 특정 속성만 정의하고 다른 것은 제한할 수 있다.

__slot__에 정의되지 않은 속성을 동적으로 추가하려고 할 경우 AttributeError가 발생한다. 이 속성을 정의하면 클래스는 정적으로 되고 __dict__ 속성을 갖지 않는다. 따라서 객체에 동적으로 속성을 추가할 수 없다.

이것은 파이썬의 동적인 특성을 없애기 때문에 조심해서 사용해야 한다.

Link to original

제너레이터

제너레이터 만들기

제너레이터는 한 번에 하나씩 구성요소를 반환해주는 이터러블을 생성해주는 객체이다. 제너레이터는 필요할 때마다 요소를 하나씩만 가져오는 방식으로 메모리를 절약한다.

제너레이터 개요

class PurchasesStats:
    def __init__(self, purchases):
        self.purchases = iter(purchases)
        self.min_price: float = None
        self.max_price: float = None
        self._total_purchases_price: float = 0.0
        self._total_purchases = 0
        self._initialize()
 
    def _initialize(self):
        try:
            first_value = next(self.purchases)
        except StopIteration:
            raise ValueError("no values provided")
 
        self.min_price = self.max_price = first_value
        self._update_avg(first_value)
 
    def process(self):
        for purchase_value in self.purchases:
            self._update_min(purchase_value)
            self._update_max(purchase_value)
            self._update_avg(purchase_value)
        return self
 
    def _update_min(self, new_value: float):
        if new_value < self.min_price:
            self.min_price = new_value
 
    def _update_max(self, new_value: float):
        if new_value > self.max_price:
            self.max_price = new_value
 
    @property
    def avg_price(self):
        return self._total_purchases_price / self._total_purchases
 
    def _update_avg(self, new_value: float):
        self._total_purchases_price += new_value
        self._total_purchases += 1
 
    def __str__(self):
        return (
            f"{self.__class__.__name__}({self.min_price}, "
            f"{self.max_price}, {self.avg_price})"
        )

이 객체는 모든 구매 정보 purchases를 받아서 필요한 계산을 한다.

다음 코드는 모든 정보를 로드해 반환해주는 함수이다.

def _load_purchases(filename):
    purchases = []
    with open(filename) as f:
        for line in f:
            *_, price_raw = line.partition(",")
            purchases.append(float(price_raw))
 
    return purchases

이 코드는 정상적인 결과를 반환한다. 하지만 성능에 문제가 있다. 파일에 많은 데이터가 있다면 로드시에 시간이 오래 걸리고 메모리에 담지 못할 수도 있다.

PurchasesStats 객체에서는 한 번에 하나의 데이터만 사용하므로 한번에 모든 데이터를 읽어와 메모리에 보관할 이유가 없다.

이에대한 해결책은 제너레이터를 만드는 것이다. 제너레이터는 파일의 전체 내용을 리스트에보관하는 대신 필요한 값만 그때그때 가져온다.

def load_purchases(filename):
    with open(filename) as f:
        for line in f:
            *_, price_raw = line.partition(",")
            yield float(price_raw)

이렇게 수정하면 메모리 사용량을 줄일 수 있다. 결과 리스트를 만들 필요도 없으며 return문도 사라졌다.

load_purchases함수를 제너레이터라고 부른다. 파이썬에서는 어떤 함수라도 yield 키워드를 사용하면 제너레이터가 된다. yield가 포함된 함수를 호출하면 제너레이터의 인스턴스를 만든다.

모든 제너레이터 객체는 이터러블이다. 이터러블은 for 루프와 함께 사용할 수 있다. 이터러블을 사용하면 for루프의 다형성을 보장하는 강력한 추상화가 가능하다.

제너레이터 표현식

제너레이터를 사용하면 많은 메모리를 절약할 수 있다. 제너레이터는 이터레이터이므로 리스트, 튜플, 세트처럼 많은 메모리를 필요로 하는 이터러블이나 컨테이너의 대안이 될 수 있다.

컴프리헨션(comprehension)에 의해 정의될 수 있는 리스트, 세트, 딕셔너리처럼 제너레이터도 제너레이터 표현식으로 정의할 수 있다.

리스트 컴프리헨션에서 대괄호를 괄호로 교체하면 표현식의 결과로부터 제너레이터가 생성된다. 제너레이터 표현식은 sum()이나 max()같이 이터러블 연산이 가능한 함수에 직접 전달할 수도 있다.

>>> (x**2 for x in range(10))
<generator object <genexpr> at 0x...>

이상적인 반복

관용적인 반복 코드

파이썬에서 이터레이터 객체를 만들기 위해서는 __iter__()매직 메서드를 구현하여 객체가 반복 가능하게 만들어야 한다. 또한 __next__()매직 메서드를 구현해야 한다.

class SequenceOfNumbers:
    def __init__(self, start=0):
        self.current = start
 
    def __next__(self):
        current = self.current
        self.current += 1
        return current
 
    def __iter__(self):
        return self

이렇게 하면 요소를 반복할 수 있을 뿐 아니라 내장 함수 next()를 사용할 수 있게 된다.

next() 함수

next() 내장함수는 이터러블을 다음 요소로 이동시키고 기존의 값을 반환한다.

이터레이터가 더 이상의 값을 가지고 있지 않다면 StopIteration 예외가 발생한다.

이 문제의 해결 방법에는 StopIteration 예외를 캐치하는 것 이외에도 next() 함수의 두 번째 파라미터에 기본 값을 제공할 수도 있다. 이 값을 제공하면 StopIteration예외를 발생시키는 대신 기본 값을 반환한다.

제너레이터 사용하기

제너레이터를 사용하면 클래스를 만드는 대신 필요한 값을 yield하는 함수를 만들면 된다.

def sequence(start=0):
    while True:
        yield start
        start += 1

제너레이터 함수가 호출되면 yield문장을 만나기 전까지 실행된다. 그리고 값을 생성하고 그 자리에서 멈춘다.

itertools

처음 예제에서 특정 기준을 넘은 값에 대해서만 연산을 하고자 할 경우 반복문 안에 조건문을 추가하면 된다.

def process(self)
    for purchase in self.purchases:
        if purchase > 1000.0:
            ...

이것은 pythonic하지 않을 뿐만 아니라 너무 엄격하다. 이 객체의 고유 책임은 요소에 대해 지표 값을 계산하고 출력하는 것뿐이다.

이 객체는 클라이언트의 요구로부터 독립되어야 한다.

from itertools import islice
 
purchases = islice(filter(lambda p: p > 1000.0, purchases), 10)

이런 식으로 필터링을 해도 메모리의 손해는 없다. 모든 것이 제너레이터이므로 요소 하나씩 평가된다.

이터레이터를 사용한 코드 간소화

여러번 반복하기

itertools모듈을 사용해 처음의 예제를 간소화할 수 있다.

itertools.tee는 원래의 이터러블을 여러 개의 새로운 이터러블로 복제한다.

def process_purchases(purchases):
    min_, max_, avg = itertools.tee(purchases, 3)
    return min(min_), max(max_), median(avg)

중첩루프

1차원 이상을 반복하여 값을 찾아야 할 수 있다. 중첩 루프를 사용할 시 값을 찾으면 순환을 멈추고 break키워드를 호출해야 하는데 이 경우 두 단계 이상을 버어나야 하므로 정상 동작하지 않는다.

이 경우 플래그나 예외를 사용할 수 있지만 이것들은 코드를 나쁘게 만든다.

def search_nested_bad(array, desired_value):
    coords = None
    for i, row in enumerate(array):
        for j, cell in enumerate(row):
            if cell == desired_value:
                coords = (i, j)
                break
 
        if coords is not None:
            break
 
    if coords is None:
        raise ValueError(f"{desired_value} not found")
 
    logger.info("value %r found at [%i, %i]", desired_value, *coords)
    return coords

가장 좋은 방법은 중첩을 풀어 1차원 루프로 만드는 것이다.

 def _iterate_array2d(array2d):
    for i, row in enumerate(array2d):
        for j, cell in enumerate(row):
            yield (i, j), cell
 
def search_nested(array, desired_value):
    try:
        coord = next(coord for (coord, cell) in _iterate_array2d(array) if cell == desired_value)
    except StopIteration:
        raise ValueError("{desired_value} not found")
 
    logger.debug("[%i, %i]에서 값 %r 찾음",*coord, desired_value)
    return coord

파이썬의 이터레이터 패턴

이터레이션 인터페이스

이터러블은 반복할 수 있는 것으로 실제 반복을 할 때에는 이터레이터를 사용한다. 즉 __iter__매직 메서드를 사용하여 이터레이터를 반환하고 __next__ 매직 메서드를 통해 반복 로직을 구현한다.

파이썬 개념매직 메서드비고
이터러블__iter__이터레이터와 함께 반복 로직을 만든다.
for in 구문에서 사용할 수 있다.
이터레이터__next__한 번에 하나의 값을 생성하는 로직을 정의한다.
더 이상 생성할 값이 없을 경우 StopIteration 예외를 발생시킨다. next()를 사용할 수 있다.

이터러블이 가능한 시퀀스 객체

__iter__() 매직 메서드를 구현한 객체는 for 루프에서 사용할 수 있다. 이것은 큰 특징이지만 꼭 이런 형태여만 반복이 가능한 것은 아니다. 파이썬이 for 루프를 만나면 객체가 __iter__을 구현했는지 확인하여 있으면 그것을 사용한다. 없을 경우에는 다른 대비 옵션을 사용한다.

객체가 시퀀스인 경우도 반복이 가능하다. 이 경우 인터프리터는 IndexError 예외가 발생할 때까지 순서대로 값을 제공한다.

코루틴

제너레이터 인터페이스 메서드

close()

이 메서드를 호출하면 제너레이터에서 GeneratorExit 예외가 발생한다. 이 예외를 따로 처리하지 않으면 제너레이터가 더 이상 값을 생성하지 않으며 반복이 중지된다.

이 예외는 종료 상태를 지정하는데 사용할 수 있다. 코루틴이 자원관리를 하는 경우 이 예외를 통해 코루틴이 보유한 모든 자원을 해제할 수 있다. 컨텍스트 관리자, finally와 비슷하지만 이 예외를 사용하면 보다 명확하게 처리할 수 있다.

def stream_db_records(db_handler):
    try:
        while True:
            yield db_handler.read_n_records(10)
    except GeneratorExit:
        db_handler.close()

위 예제는 코루틴을 사용하여 데이터베이터베이스에서 특정 크기의 페이지를 스트리밍한다.

제너레이터에서 작업을 종료할 때는 close() 메서드를 사용한다.

throw(ex_type[, ex_value[, ex_traceback]])

이 메서드는 현재 제너레이터가 중단된 위치에서 예외를 던진다. 제너레이터가 예외를 처리했으면 해당 except절에 있는 코드가호출되고, 그렇지 않으면 예외가 호출자에게 전파된다.

class CustomException(Exception):
    pass
 
def stream_data(db_handler):
    while True:
        try:
            yield db_handler.read_n_records(10)
        except CustomException as e:
            logger.info("처리 가능한 에러 %r, 계속 진행", e)
        except Exception as e:
            logger.info("처리 불가능한 에러 %r, 중단", e)
            db_handler.close()
            break
>>> streamer = stream_data(DBHandler("testdb"))
>>> next(streamer)
[(0, 'row 0'), (1, 'row 1'), (2, 'row 2'), (3, 'row 3'), (4, 'row 4'), (5, 'row 5'), (6, 'row 6'), (7, 'row 7'), (8, 'row 8'), (9, 'row 9')]
 
>>> next(streamer)
[(0, 'row 0'), (1, 'row 1'), (2, 'row 2'), (3, 'row 3'), (4, 'row 4'), (5, 'row 5'), (6, 'row 6'), (7, 'row 7'), (8, 'row 8'), (9, 'row 9')]
 
>>> streamer.throw(CustomException)
처리 가능한 에러 CustomException(), 계속 진행
 
>>> streamer.throw(RuntimeError)
처리 불가능한 에러 RuntimeError(), 중단
Traceback (most recent call last):

CustomException 예외가 발생한 경우 제너레이터는 INFO 레벨의 메세지를 기록하고 다음 yield 구문으로 이동하여 데이터베이스에서 데이터를 가져온다.

마지막 블록 (except Exception)가 없을 경우 제너레이터가 중지된 행에서 예외가 호출자에게 전파되고 제너레이터는 중지된다.

send(value)

현재 제너레이터의 주요 기능은 고정된 수의 레코드를 읽는 것이다. 읽어올 레코드의 수를 수정하고자 할 경우 next() 함수는 해당 옵션을 제공하지 않는다. 이 경우 send() 메서드를 사용하면 된다.

def stream_db_records(db_handler):
    retrieved_data = None
    previous_page_size = 10
    try:
        while True:
            page_size = yield retrieved_data
            if page_size is None:
                page_size = previous_page_size
 
            previous_page_size = page_size
 
            retrieved_data = db_handler.read_n_records(page_size)
    except GeneratorExit:
        db_handler.close()

코루틴에서는 일반적으로 다음과 같은 형태로 yield 키워드를 사용한다.

receive = yield produced

이 경우 yield 키워드는 두 가지 일을 한다. 하나는 produced 값을 호출자에게 보내는 것이고 다른 하나는 호출자로부터 send() 메서드를 통해 전달된 produced값을 받는 것이다.

코루틴에 값을 전송하는 것은 yield 구문이 멈춘 상태에서만 가능하다. 즉 코루틴에게 값을 보내기 전에 next() 메서드를 최소 한 번은 호출해야 한다.

코루틴에서 send() 메서드를 호출하려면 항상 next()를 먼저 호출해야 한다.

위 코드에서 send()를 사용해 값을 제공하면 yield 문의 반환 값으로 page_size변수에 할당된다. 이제 기본값인 previous_page_size이 아닌 사용자가 지정한 값이 page_size에 설정된다.

def stream_db_records(db_handler):
    retrieved_data = None
    page_size = 10
    try:
        while True:
            page_size = (yield retrieved_data) or page_size
            retrieved_data = db_handler.read_n_records(page_size)
    except GeneratorExit:
        db_handler.close()

코드가 보다 간결해졌지만 여전히 send() 전에 next() 를 먼저 호출해야 한다. 그렇지 않으면 TypeError 가 발생한다. 이를 데코레이터를 사용해 해결할 수 있다.

send() 전에 next()를 호출하지 않고 코루틴을 생성하자마자 바로 사용할 수 있게 하기 위해 데코레이터를 사용한다.

def prepare_coroutine(coroutine):
    def wrapped(*args, **kwargs):
        advanced_coroutine = coroutine(*args, **kwargs)
        next(advanced_coroutine)
        return advanced_coroutine
 
    return wrapped
 
@prepare_coroutine
def auto_stream_db_records(db_handler):
    retrieved_data = None
    page_size = 10
    try:
        while True:
            page_size = (yield retrieved_data) or page_size
            retrieved_data = db_handler.read_n_records(page_size)
    except GeneratorExit:
        db_handler.close()

작은 코루틴에 위임하기 - yield from 구문

코루틴이 값을 반환할 수 있도록 지원하는 구문이 yield from이다.

가장 간단한 yield from 사용 예

def chain(*iterables):
    for it in iterables:
        for value in it:
            yield value

여러 이터러블을 받아 개별 값들을 가져오는 제너레이터이다. 여기서 yield from 구문을 사용해 코드를 단순화시킬 수 있다.

def chain(*iterables):
    for it in iterables:
        yield from it

서브 제너레이터에서 반환된 값 구하기

def sequence(name, start, end):
    logger.info("%s 제너레이터 %i에서 시작", name, start)
    yield from range(start, end)
    logger.info("%s 제너레이터 %i에서 종료", name, end)
    return end
 
def main():
    step1 = yield from sequence("first", 0, 5)
    step2 = yield from sequence("second", step1, 10)
    return step1 + step2
>>> g = main()
>>> next(g)
INFO:generators_yieldfrom_2:first 제너레이터 0에서 시작
0
 
>>> next(g)
1
 
>>> next(g)
2
 
>>> next(g)
3
 
>>> next(g)
4
 
>>> next(g)
INFO:generators_yieldfrom_2:first 제너레이터 5에서 종료
INFO:generators_yieldfrom_2:second 제너레이터 5에서 시작
5
 
>>> next(g)
6
 
>>> next(g)
7
 
>>> next(g)
8
 
>>> next(g)
9
 
>>> next(g)
INFO:generators_yieldfrom_2:second 제너레이터 10에서 종료
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
StopIteration: 15

main 제너레이터는 내부 제너레이터에서 생산된 값을 가져온다.

그리고 sequence()제너레이터 종료 시의 반환 값을 다음 sequence()제너레이터에 전달한다.

yield from을 사용하면 코루틴의 종료 시 최종 반환값을 구할 수 있다.

서브 제너레이터와 데이터 송수신하기

앞의 예제에서 최상위 main 제너레이터는 그대로 유지하고 값을 수신하고 예외를 처리할 내부 제너레이터 sequence함수를 수정한다.

def sequence(name, start, end):
    value = start
    logger.info("%s 제너레이터 %i에서 시작", name, value)
    while value < end:
        try:
            received = yield value
            logger.info("%s 제너레이터 %r 값 수신", name, received)
            value += 1
        except CustomException as e:
            logger.info("%s 제너레이터 %s 에러 처리", name, e)
            received = yield "OK"
    return end
>>> g = main()
>>> next(g)
INFO: first 제너레이터 0에서 시작
0
 
>>> next(g)
INFO: first 제너레이터 None 값 수신
1
 
>>> g.send("첫 번째 제너레이터를 위한 인자 값")
INFO: first 제너레이터 '첫 번째 제너레이터를 위한 인자 값' 값 수신
2
 
>>> g.throw(CustomException("처리 가능한 예외 던지기"))
INFO: first 제너레이터 처리 가능한 예외 던지기 에러 처리
'OK'
 
>>> next(g)
INFO: first 제너레이터 None 값 수신
2
 
>>> next(g)
INFO: first 제너레이터 None 값 수신
3
 
>>> next(g)
INFO: first 제너레이터 None 값 수신
4
 
>>> next(g)
INFO: first 제너레이터 None 값 수신
INFO:second 제너레이터 5에서 시작
5
 
>>> g.throw(CustomException("두 번째 제너레이터를 향한 예외 던지기"))
INFO: second 제너레이터 두 번째 제너레이터를 향한 예외 던지기 예외 처리
'OK'

이 예제에서 오직 main 제너레이터에만 값을 보냈지만 실제 값은 sequence 서브 제너레이터가 받는다. 명시적으로 sequence에 데이터를 보낸 적은 없지만 yield from을 통해 sequence에 데이터를 전달한다.

비동기 프로그래밍

비동기 프로그래밍의 장점은 논블로킹 방식으로 병렬I/O 작업을 할 수 있다는 것이다.

제너레이터와 코루틴은 문법적, 기술적으로는 동일하지만 의미적으로는 다르다. 효율적 반복을 원할 때는 제너레이터, 논블로킹I/O 작업을 원할 때는 코루틴을 사용한다.

제너레이터와 코루틴의 혼합으로 발생하는 런타임 오류를 줄이기 위해 python3.6 이후로 코루틴이라는 새로운 타입이 추가되었다.

새로운 구문으로 awaitasync def또한 추가되었다. awaityield from을 대신하기 위한 용도로 awaitable 객체에 대해서만 동작한다. async def@coroutine 데코레이터를 대신하여 코루틴을 정의하는 새로운 방법이다.

Link to original

단위테스트

디자인 원칙과 단위 테스트

단위테스트는 다른 코드의 일부분이 유효한지를 검사하는 코드이다. 단위테스트는 소프트웨어의 핵심이 되는 필수적인 기능으로서 일반 비즈니스 로직과 동일한 수준으로 다루어져아 한다.

단위테스트는 비즈니스 로직이 특정 조건을 보장하는지를 확인하기 위해 여러 시나리오를 검증하는 코드이다. 단위 테스트는 다음과 같은 특징을 가진다.

  • 격리 : 단위 테스트는 다른 외부 에이전트와 완전히 독립적이어야 하며 비즈니스 로직에만 집중해야 한다.
  • 성능 : 단위 테스트는 신속하게 실행되어야 한다. 반복적으로 여러 번 실행될 수 있도록 설계해야 한다.
  • 자체 검증 : 단위 테스트의 실행만으로 결과를 결정할 수 있어야 한다. 단위 테스트를 처리하기 위한 추가 단계가 없어야 한다.

자동화된 테스트의 다른 형태

단위 테스트는 함수, 메서드와 같은 작은 단위를 확인하기 위한 것이다. 클래스를 테스트하려면 단위 테스트의 집합인 테스트 스위트를 사용한다.

단위테스트의 종류

  • 통합 테스트: 한 번에 여러 컴포넌트를 테스트, 부작용, 격리를 고려하지 않은 채로 작업을 수행
  • 인수 테스트: 유스케이스를 활용하여 사용자의 관점에서 시스템의 유효성을 검사

일반적으로 단위 테스트는 항상 수행되기를 원하지만 통합 테스트, 인수 테스트는 그보다 덜 자주 수행되기를 바란다.

단위 테스트와 애자일 소프트웨어 개발

변화에 효과적으로 대응할 수 있는 소프트웨어를 개발하고자 한다면 유연하며 확장 가능해야 한다.

코드 자체만으로는 변경에 유연하다는 보장을 할 수가 없다. 또한 잘 짜여진 컴포넌트가 변경 작업 후에도 기존 기능이 보존되며 버그가 없을것을 확신할 수는 없다. 이 경우 단위테스트가 프로그램이 명세에 따라 정확하게 동작한다는 보장을 해 줄 수 있다.

단위 테스트와 소프트웨어 디자인

테스트의 용이성은 클린 코드의 핵심 가치이다. 단위 테스트는 기본 코드를 보완하기 위한 것이 아닌 실제 코드의 작성 방식에 직접적 영향을 미치는 것이다.

class MetricsClient:
    """타사 지표 전송 클라이언트"""
 
    def send(self, metric_name, metric_value):
        if not isinstance(metric_name, str):
            raise TypeError("metric_name으로 문자열 타입을 사용해야 함")
 
        if not isinstance(metric_value, str):
            raise TypeError("metric_value으로 문자열 타입을 사용해야 함")
 
        logger.info("%s 전송 값 = %s", metric_name, metric_value)
 
 
class Process:
    def __init__(self):
        self.client = MetricsClient()  # 타사 지표 전송 클라이언트
 
    def process_iterations(self, n_iterations):
        for i in range(n_iterations):
            result = self.run_process()
            self.client.send("iteration.{}".format(i), result)

타사 지표 전송 클라이언트는 파라미터가 문자열 타입이어야 한다는 요구사항이 있다. 따라서 run_process() 메서드에서 반환한 result가 문자열이 아닌 경우 전송에 실패한다.

타사 라이브러리는 직접 제어할 수 없으므로 반드시 실행 전에 정확한 타입을 제공해야만 한다. 단위 테스트를 통해 이 문제의 발생을 차단하려고 한다.

단위 테스트가 있으면 리팩토링을 여러 번 하더라도 문제가 이후에 재현되지 않는다는 것을 증명할 수 있다.

필요한 부분만 테스트 하기 위해 main 메서드에서 client를 직접 다루지 않고 래퍼 메서드에 위임한다.

class WrappedClient:
    """3rd party 라이브러리를 통제 하에 둘 수 있도록 하는 wrapper 객체"""
    
    def __init__(self):
 
    def send(self, metric_name, metric_value):
        return self.client.send(str(metric_name), str(metric_value))
 
 
class Process:
    def __init__(self):
        self.client = WrappedClient()
    
        def process_iterations(self, n_iterations):
            for i in range(n_iterations):
                result = self.run_process()
                self.client.send("iteration.{}".format(i), result)

메서드가 분리되었으므로 단위 테스트를 작성할 수 있다.

from unittest import TestCase, main
from unittest.mock import Mock
 
 
class TestWrappedClient(TestCase):
    def test_send_converts_types(self):
        wrapped_client = WrappedClient()
        wrapped_client.client = Mock()
        wrapped_client.send("value", 1)
 
        wrapped_client.client.send.assert_called_with("value", "1")

테스트의 경계 정하기

테스트의 범위는 외부 라이브러리나 모듈이 아닌 우리가 작성한 코드의 범위로 한정해야 한다. 신중하게 디자인을 하여 시스템의 기준을 명확히 했다면 단위 테스트를 작성할 때 인터페이스를 모의하는 것이 훨씬 쉬워진다.

테스트를 위한 프레임워크와 도구

단위 테스트 프레임워크와 라이브러리

테스트 시나리오를 다루는 것은 unittest만으로 충분할 수 있다. 그러나 외부시스템과의 연결 등 의존성이 많고 복잡한 경우 pytest가 적합하다.

아래는 이 둘을 비교하기 위한 코드이다.

class MergeRequestStatus(Enum):
    APPROVED = "approved"
    REJECTED = "rejected"
    PENDING = "pending"
 
class MergeRequest:
    def __init__(self):
        self._context = {"upvotes": set(), "downvotes": set()}
 
    @property
    def status(self):
        if self._context["downvotes"]:
            return MergeRequestStatus.REJECTED
        elif len(self._context["upvotes"]) >= 2:
            return MergeRequestStatus.APPROVED
        return MergeRequestStatus.PENDING
 
    def upvote(self, by_user):
        self._context["downvotes"].discard(by_user)
        self._context["upvotes"].add(by_user)
 
    def downvote(self, by_user):
        self._context["upvotes"].discard(by_user)
        self._context["downvotes"].add(by_user)

unittest

unittest 모듈을 사용해 단위 테스트를 만들려면 unittest.TestCase를 상속하여 테스트 클래스를 만들고 메서드에 테스트할 조건을 정의하면 된다.

이러한 메서드는 test_로 시작해야 하며 본문에서는 unittest.TestCase 에서 상속받은 메서드를 사용해 체크하려는 조건이 참인지 확인하면 된다.

class TestMergeRequestStatus(unittest.TestCase):
    def test_simple_rejected(self):
        merge_request = MergeRequest()
        merge_request.downvote("maintainer")
        self.assertEqual(merge_request.status, MergeRequestStatus.REJECTED)
    
    def test_just_created_is_pending(self):
        self.assertEqual(MergeRequest().status, MergeRequestStatus.PENDING)
    
    def test_pending_awaiting_review(self):
        merge_request = MergeRequest()
        merge_request.upvote("core-dev")
        self.assertEqual(merge_request.status, MergeRequestStatus.PENDING)
    
    def test_approved(self):
        merge_request = MergeRequest()
        merge_request.upvote("dev1")
        merge_request.upvote("dev2")
        self.assertEqual(merge_request.status, MergeRequestStatus.APPROVED)

단위 테스트 API는 비교를 위한 다양한 메서드를제공하는데 가장 일반적인 메서드는 실제 실행 값과 예상 값을 비교하는 assertEquals이다.

class MergeRequest:
    def __init__(self):
        self._context = {"upvotes": set(), "downvotes": set()}
        self._status = MergeRequestStatus.OPEN
 
    def close(self):
        self._status = MergeRequestStatus.CLOSED
 
    @property
    def status(self):
        if self._status == MergeRequestStatus.CLOSED:
            return self._status
 
        if self._context["downvotes"]:
            return MergeRequestStatus.REJECTED
        elif len(self._context["upvotes"]) >= 2:
            return MergeRequestStatus.APPROVED
        return MergeRequestStatus.PENDING
 
    def _cannot_vote_if_closed(self):
        if self._status == MergeRequestStatus.CLOSED:
            raise MergeRequestException("종료된 머지 리퀘스트에 투표할 수 없음")
 
    def upvote(self, by_user):
        self._cannot_vote_if_closed()
 
        self._context["downvotes"].discard(by_user)
        self._context["upvotes"].add(by_user)
 
    def downvote(self, by_user):
        self._cannot_vote_if_closed()
 
        self._context["upvotes"].discard(by_user)
        self._context["downvotes"].add(by_user)
def test_cannot_upvote_on_closed_merge_request(self):
    self.merge_request.close()
    self.assertRaises(
        MergeRequestException, # Exception
        self.merge_request.upvote, # callable
        "dev1" # params
    )
 
def test_cannot_downvote_on_closed_merge_request(self):
    self.merge_request.close()
    self.assertRaisesRegex(
        MergeRequestException, # Exception
        "종료된 머지 리퀘스트에 투표할 수 없음", # regex
        self.merge_request.downvote, # callable
        "dev1", # params
    )

첫 번쨰 코드는 제공한 예외가 실제로 발생하는지 확인한다. 후자는 동일한 방식으로 처리하지만 발생된 예외의 메시지가 제공된 정규식과 일치하는지 또한 확인한다.

테스트 파라미터화

class AcceptanceThreshold:
    def __init__(self, merge_request_context: dict) -> None:
        self._context = merge_request_context
 
    def status(self):
        if self._context["downvotes"]:
            return MergeRequestStatus.REJECTED
        elif len(self._context["upvotes"]) >= 2:
            return MergeRequestStatus.APPROVED
        return MergeRequestStatus.PENDING
 
 
class MergeRequest:
    def __init__(self):
        self._context = {"upvotes": set(), "downvotes": set()}
        self._status = MergeRequestStatus.OPEN
 
    def close(self):
        self._status = MergeRequestStatus.CLOSED
 
    @property
    def status(self):
        if self._status == MergeRequestStatus.CLOSED:
            return self._status
 
        return AcceptanceThreshold(self._context).status()
 
    def _cannot_vote_if_closed(self):
        if self._status == MergeRequestStatus.CLOSED:
            raise MergeRequestException("종료된 머지 리퀘스트에 투표할 수 없음")
 
    def upvote(self, by_user):
        self._cannot_vote_if_closed()
 
        self._context["downvotes"].discard(by_user)
        self._context["upvotes"].add(by_user)
 
    def downvote(self, by_user):
        self._cannot_vote_if_closed()
 
        self._context["upvotes"].discard(by_user)
        self._context["downvotes"].add(by_user)

데이터에 따라 머지 리퀘스트가 정상적으로 동작하는지를 확인하기 위해 임계값을 변경하며 테스트한다.

이러한 작업을 수행하는 가장 좋은 방법은 해당 컴포넌트를 다른 클래스로 분리하고 컴포지션을 사용해 다시 가져오는 것이다. 분리된 클래스에 대해서는 자체 테스트 스위트를 가진 새로운 추상화 객체를 만들고 이것에 대해 테스트를 한다.

class TestAcceptanceThreshold(unittest.TestCase):
    def setUp(self):
        self.fixture_data = (
            (
                {"downvotes": set(), "upvotes": set()},
                MergeRequestStatus.PENDING
            ),
            (
                {"downvotes": set(), "upvotes": {"dev1"}},
                MergeRequestStatus.PENDING,
            ),
            (
                {"downvotes": "dev1", "upvotes": set()},
                MergeRequestStatus.REJECTED
            ),
            (
                {"downvotes": set(), "upvotes": {"dev1", "dev2"}},
                MergeRequestStatus.APPROVED
            ),
        )
 
    def test_status_resolution(self):
        for context, expected in self.fixture_data:
            with self.subTest(context=context):
                status = AcceptanceThreshold(context).status()
                self.assertEqual(status, expected)

setUp()메서드에서는 테스트 전반에 걸쳐 사용될 데이터 픽스처를 정의한다. 테스트 코드를 이렇게 수정함으로써 코드의 파라미터를 쉽고 간결하게 전달할 수 있게 되었으며 각각 결과도 쉽게 확인할 수 있게 되었다.

subText 헬퍼는 호출되는 테스트 조건을 표시하는데 사용된다. 반복중 하나가 실패하면 unittestsubTest에 전달된 변수의 값을 보고한다.

테스트에 파라미터를 사용하는 경우 각 인스턴스에 최대한 많은 컨택스트 정보를 제공하여 오류 발생 시 디버깅을 쉽게 한다.

pytest

기초적인 pytest 사용예

def test_simple_rejected():
    merge_request = MergeRequest()
    merge_request.downvote("maintainer")
    assert merge_request.status == MergeRequestStatus.REJECTED
 
def test_just_created_is_pending():
    assert MergeRequest().status == MergeRequestStatus.PENDING
 
def test_pending_awaiting_review():
    merge_request = MergeRequest()
    merge_request.upvote("core-dev")
    assert merge_request.status == MergeRequestStatus.PENDING

위 코드는 pytest를 사용해 다시 작성한 이전 섹션의 테스트이다.

결과가 참인지를 비교하는 것은 assert 구문을 사용하면 되지만, 예외의 발생유무 등의 검사는 일부 함수를 사용해야 한다.

def test_invalid_types():
    merge_request = MergeRequest()
    pytest.raises(TypeError, merge_request.upvote, {"invalid-object"})
 
def test_cannot_vote_on_closed_merge_request():
    merge_request = MergeRequest()
    merge_request.close()
    pytest.raises(MergeRequestException, merge_request.upvote, "dev1")
    with pytest.raises(MergeRequestException, match="종료된 머지 리퀘스트에 투표할 수 없음"):
        merge_request.downvote("dev1")

여기에서 pytest.raisesunittest.TestCase.assertRaizes와 동일하다.

테스트 파라미터화

pytest를 사용함으로서 unittest 를 사용하는 것보다 더 나은 방식으로 파라미터화 된 테스트를 수행할 수 있다. pytest는 테스트 조합마다 새로운 테스트 케이스를 생성하기 때문이다.

이를 위해서는 pytest.mark.parametrize 데코레이터를 사용해야 한다.

@pytest.mark.parametrize(
    "context,expected_status",
    (
        ({"downvotes": set(), "upvotes": set()}, MergeRequestStatus.PENDING),
        ({"downvotes": set(), "upvotes": {"dev1"}}, MergeRequestStatus.PENDING),
        ({"downvotes": "dev1", "upvotes": set()}, MergeRequestStatus.REJECTED),
        ({"downvotes": set(), "upvotes": {"dev1", "dev2"}}, MergeRequestStatus.APPROVED),
    ),
)
def test_acceptance_threshold_status_resolution(context, expected_status):
    assert AcceptanceThreshold(context).status() == expected_status

테스트 함수의 본문에서 내부 for 루프와 중첩된 컨텍스트 관리자가 제거되고 한 줄로 변경되었다. 각 테스트 케이스의 데이터는 함수 본문에서 올바르게 분리되어 확장, 유지보수에 유리한 구조가 되었다.

픽스처(Fixture)

pytest의 가장 큰 장점 중 하나는 재사용 가능한 기능을 쉽게 만들 수 있다는 점이다. 이렇게 생성한 데이터나 객체를 재사용해 보다 효율적으로 테스트를 할 수 있다.

@pytest.fixture
def rejected_mr():
    merge_request = MergeRequest()
 
    merge_request.downvote("dev1")
    merge_request.upvote("dev2")
    merge_request.upvote("dev3")
    merge_request.downvote("dev4")
 
    return merge_request
 
def test_simple_rejected(rejected_mr):
    assert rejected_mr.status == MergeRequestStatus.REJECTED
 
def test_rejected_with_approvals(rejected_mr):
    rejected_mr.upvote("dev2")
    rejected_mr.upvote("dev3")
    assert rejected_mr.status == MergeRequestStatus.REJECTED
 
def test_rejected_to_pending(rejected_mr):
    rejected_mr.upvote("dev1")
    assert rejected_mr.status == MergeRequestStatus.PENDING
 
def test_rejected_to_approved(rejected_mr):
    rejected_mr.upvote("dev1")
    rejected_mr.upvote("dev4")
    assert rejected_mr.status == MergeRequestStatus.APPROVED

특정 상태를 가진 객체를 만들고 여러 테스트에서 해당 객체를 재사용할 수 있다. 픽스처를 정의하려면 함수를 정의하고 @pytest.fixture 데코레이터를 적용한다. 픽스처를 사용하기 원하는 테스트에는 파라미터로 픽스처의 이름을 전달하면 pytest가 그것을 활용한다.

모의(mock) 객체

Mock Object

테스트를 하는 과정 중에는 우리가 작성한 코드 뿐만 아니라 외부 서비스들과의 연결도 하게 된다. 이러한 외부 서비스에는 필연적으로 부작용이 존재한다.

모의 객체는 원하지 않는 부작용으로부터 테스트 코드를 보호하는 가장 좋은 방법 중 하나이다. 단위 테스트는 빠르게 실행되어야 하기 때문에 이러한 대기 시간을 감당할 수 없다. 따라서 단위 테스트에는 이러한 외부 서비스를 호출하지 않는다. 단위 테스트에서는 이것들이 호출되는지만 확인하면 된다.

모의 객체는 유용하지만 남용하여 안티패턴을 만들지 않도록 유의해야 한다.

패치와 모의에 대한 주의사항

단위 테스트는 보다 나은 코드를 작성하는데 도움이 된다. 특정 코드를 테스트하려면 테스트가 가능하도록 짜야 하는데, 이는 코드가 응집력이 뛰어나고 세분화 되어 있으며 작다는 것을 의미한다. 이는 코드가 응집력이 뛰어나고, 세분화 되어 있으며, 작다는 것을 의미한다. 이는 소프트웨어 컴포넌트에 있어서도 모두 좋은 특징들이다.

또한 테스트를 통해 문제가 없다고 생각하던 부분에서 나쁜 부분을 찾아낼 수 있다.

unittest 모듈은 객체를 패치하기 위한 도구를 제공한다. 패치란 임포트 중에 경로를 지정했던 원본 코드를 다른 것으로 대체하는 것을 말한다. 이렇게 하면 런타임 중에 코드가 바뀌어 테스트가 어려워지는 단점이 있다. 런타임 시 인터프리터에서 객체를 수정하는 오버헤드도 있으므로 성능상 이슈도 있다.

몽키패치, 모의 객체를 사용하는 것 자체가 문제가 되지는 않지만 몽키 패치를 남용한다면 원본 코드를 개선할 여지가 있다는 신호이다.

Mock 객체 사용하기

단위 테스트에서 말하는 테스트 더블의 카테고리에 속하는 타입에는 여러 객체가 있다. 테스트 더블은 여러가지 이유로 테스트 스위트에서 실제 코드를 대신해 실제인 것처럼 동작하는 코드를 말한다. 실제 상용 코드의 불필요, 권한 부재, 부작용 등의 이유로 사용된다.

테스트 더블에는 더미(dummy), 스텁(stub), 스파이(spy), 모의 객체(mock)와 같은 다양한 타입의 객체가 있다. 모의 객체는 가장 일반적 유형의 객체이며 융통성이 있고 다양한 기능을 가지고 있기 때문에 모든 경우에 적합하다.

모의 객체(mock)는 스펙을 따르는 객체 타입으로 응답 값을 수정할 수 있다. 즉 모의 객체 호출 시 응답해야 하는 값이나 행동을 특정할 수 있다. 모의 객체는 내부에 호출 방법을 기록하고 나중에 이 정보를 사용하여 애플리케이션의 동작을 검증한다.

Mock 객체의 종류

파이썬 표준 라이브러리는 unittest.mock 모듈에서 MockMagicMock 객체를 제공한다. Mock은 모든 값을 반환하도록 설정할 수 있는 테스트 더블이며 모든 호출을 추적한다. MagicMockMock의 기능을 모두 가지고 매직 메서드 또한 지원한다. Mock 객체에서 매직 메서드를 사용하려고 하면 에러가 발생한다.

테스트 더블의 사용 예

from datetime import datetime
 
import requests
from constants import STATUS_ENDPOINT
 
class BuildStatus:
    """CI 도구에서의 머지 리퀘스트 상태"""
 
    @staticmethod
    def build_date() -> str:
        return datetime.utcnow().isoformat()
 
    @classmethod
    def notify(cls, merge_request_id, status):
        build_status = {
            "id": merge_request_id,
            "status": status,
            "built_at": cls.build_date(),
        }
        response = requests.post(STATUS_ENDPOINT, json=build_status)
        response.raise_for_status() # 200이 아닐 경우 예외 발생
        return response

이 클래스의 부작용중 하나는 외부 모듈에 의존성이 너무 크다는 것이다.

지금 테스트 하려는 것은 정보가 적절하게 구성되어 API에 잘 전달되었는지 여부이다. 따라서 실제로 API를 호출할 필요는 없고 API가 잘 호출 되는지만 확인하면 된다. 또 다른 문제는 API에 전달되는 값 중 시간 값이 있는데, 이 값은 실시간으로 변하는 값이므로 정확히 예측을 할 수가 없다는 점이다.

datetime 모듈은 C로 작성되었으므로 직접 패치할 수는 없다. 따라서 여기에서는 직접 패치할 수 있는 build_date 정적 메서드를 래핑한다.

from unittest import mock
 
from constants import STATUS_ENDPOINT
from mock_2 import BuildStatus
 
@mock.patch("mock_2.requests")
def test_build_notification_sent(mock_requests):
    build_date = "2018-01-01T00:00:01"
    with mock.patch("mock_2.BuildStatus.build_date", return_value=build_date):
        BuildStatus.notify(123, "OK")
 
    expected_payload = {"id": 123, "status": "OK", "built_at": build_date}
    mock_requests.post.assert_called_with(STATUS_ENDPOINT, json=expected_payload)

@mock.patch를 데코레이터를 사용하여 테스트 안에서 mock_2.request를 호출하면 mock_requests라는 객체가 대신할 것이라고 알려준다. 그리고 mock.patch 함수를 컨텍스트 매니저로 사용하여 build_date() 메서드 호출 시 어설션에 사용할 build_date를 반환하도록 패치한다.

이는 mock_2.requests.post에 날짜가 포함된 데이터가 파라미터로 전달될 경우 HTTP 상태가 STATUS_ENDPOINT이 될 것이라는 지정을 한 셈이다. 따라서 mock_request.post 에 동일한 파라미터를 사용해 호출하면 assert_called_with는 성공하게 된다.

Mock 사용예제

# user_manager.py
import requests
 
 
def get_user(id):
    response = requests.get(f"https://jsonplaceholder.typicode.com/users/{id}")
    if response.status_code != 200:
        raise Exception("Failed to get a user.")
    return response.json()
 
def create_user(user):
    response = requests.post(f"https://jsonplaceholder.typicode.com/users", data=user)
    if response.status_code != 201:
        raise Exception("Failed to create a user.")
    return response.json()

get_user 테스트

from unittest import TestCase
from unittest.mock import patch
 
import user_manager
 
class TestUserManager(TestCase):
    @patch("requests.get")
    def test_get_user(self, mock_get):
        res = mock_get.return_value
        res.status_code = 200
        res.json.return_value = {
            "name": "Test User",
            "email": "user@test.com",
        }
 
        # 내부에서 requests.get()을 호출한다.
        user = user_manager.get_user(1)
        
        self.assertEqual(user["name"], "Test User")
        self.assertEqual(user["email"], "user@test.com")
 
        # mock_get()이 정확한 파라미터로 호출되었는지 확인한다.
        mock_get.assert_called_once_with("<https://jsonplaceholder.typicode.com/users/1>")

create_user 테스트

from unittest import TestCase
from unittest.mock import patch
 
import user_manager
 
class TestUserManager(TestCase):
    @patch("requests.post")
    def test_create_user(self, mock_post):
        res = mock_post.return_value
        res.status_code = 201
        res.json.return_value = {"id": 99}
 
        user = user_manager.create_user(
            {"name": "Test User", "email": "user@test.com",}
        )
   
        self.assertEqual(user["id"], 99)
        mock_post.assert_called_once_with(
            "<https://jsonplaceholder.typicode.com/users>",
            data={"name": "Test User", "email": "user@test.com",},
        )
Link to original

리팩토링

리팩토링은 소프트웨어 유지 관리에서 중요한 활동이지만 단위 테스트가 없다면 정확성을 보장받기 어렵다.

일반적으로 코드를 리팩토링할 때는 구조를 개선하여 보다 나은 코드로 만들려는 경우가 있고, 떄로는 좀 더 일반적인 코드로 수정하여 가독성을 높이려는 경우가 있다. 중요한 점은 이러한 수정작업 이전과 이후가 완전히 동일한 기능을 유지해야 한다는 것이다.

이전과 같은 기능을 지원할 때에만 다른 코드를 사용할 수 있다는 것은 수정된 코드에 대해 회귀 테스트를 실행해야 함을 의미한다. 회귀 테스트를 실행하는 유일한 효율적 방법은 테스트를 자동화 하는 것이다. 자동 테스트의 가장 효율적인 버전이 바로 단위 테스트이다.

단위 테스트에 대한 추가 논의

속성 기반 테스트

속성 기반 테스트는 테스트를 실패하게 만드는 데이터를 찾는 것이다. 이를 위해 hypothesis 라이브러리를 사용한다. 이 라이브러리는 코드를 실패하게 만드는 데이터를 찾는데 도움을 준다.

이 라이브러리를 통해 성공하지 못하는 반대 사례를 찾을 수 있다. 이 라이브러리에 코드의 유효한 가설을 정의하면 hypothesis 라이브러리가 에러를 유발하는 사례를 찾아줄 것이다.

변형 테스트

단위 테스트를 작성하는 이유는 버그로부터 코드를 보호하고 서비스 중에 정말 발생해서는 안 되는 실패에 대해 미리 검증하기 위한 것이다. 검사는 통과하는 것이 좋지만 테스트를 잘못하여 통과한 것이라면 더 위험할 수 있다. 즉 단위테스트중 버그를 추가했다면 적어도 하나 이상의 테스트에서 이를 포착하여 테스트에 실패해야 한다. 만약 이런 일이 발생하지 않았다면 테스트에 누락된 부분이 있다거나 올바른 체크를 하지 않았다는 뜻이다.

이것이 변형 테스트를 하는 이유이다. 변형 테스트 도구를 사용하면 원래 코드를 변경된 새로운 코드로 수정한다. 좋은 테스트 스위트는 이러한 돌연변이를 죽여야 한다. 일부 돌연변이가 실험에서 생존하면 대개 나쁜 징후이다.

테스트 주도 개발 간략 소개

TDD의 요점은 기능의 결함으로 실패하게 될 테스트를 상용화 전에 미리 작성해야 한다는 것이다.

단위 테스트를 먼저 작성한 다음에 코드를 작성하면 기본적인 기능 테스트를 누락할 가능성이 매우 낮아진다.

이러한 작업은 크게 3단게로 구성된다.

  1. 구현 내용을 기술하는 단위 테스트를 작성한다. 여기서 테스트를 실행하면 기능이 아직 구현되지 않았기 때문에 실패할 것이다.
  2. 해당 조건을 충족시키는 최소한의 필수 코드를 구현하고 테스트를 다시 실행한다. 이번에는 테스트를 통과해야 한다.
  3. 리팩토링을 통해 코드를 개선한다.

이러한 사이클을 red-green-refactor 이라고 한다.

Link to original

디자인 패턴

실전 속의 디자인 패턴

각 패턴은 생성(creational), 구조(structural), 행동(behavioral) 패턴 중의 하나로 분류된다. 일부 패턴은 파이썬 내부에서 자체적으로 구현되어 있으므로 보이지 않은 채로도 적절히 적용될 수 있다.

생성 패턴

생성 패턴은 객체를 인스턴스화 할 때의 복잡성을 최대한 추상화하기 위한 것이다. 객체 초기화를 위한 파라미터의 결정, 초기화에 필요한 관련 객체 준비 등의 모든 관련 작업을 단순화 한다. 이를 통해 더 간단한 인터페이스를 제공할 수 있고 사용자는 보다 안전하게 객체를 생성할 수 있다.

팩토리

파이썬의 핵심 기능 중 하나는 모든 것이 객체라는 것이며 따라서 모두 똑같이 취급될 수 있다. 이러한 이유로 파이썬에서는 팩토리 패턴이 별로 필요하지 않다. 간단히 객체를 생성할 수 있는 함수를 만들 수 있으며, 생성하려는 클래스를 파라미터로 전달할 수도 있다.

싱글턴과 공유상태

싱글턴 패턴은 파이썬에 의해 완전히 추상회되지 않은 패턴이다. 대부분의 경우 이 패턴은 실제로 필요하지 않거나 나쁜 선택이다. 싱글턴은 전역 변수의 한 형태이며 단위 테스트가 어렵다.

일반적으로 싱글턴은 가능하면 사용하지 않는 것이 좋다. 꼭 필요하다면 파이썬에서 가장 쉬운 방법은 모듈을 사용하는 것이다. 모듈에 객체를 생성하면 모듈을 임포트한 모든 곳에서 사용할 수 있다.

공유 상태

하나의 인스턴스만 갖는 싱글턴을 사용하는 것보다는 여러 인스턴스에서 사용할 수 있도록 데이터를 복제하는 것이 좋다.

모노 스테이트 패턴의 주요 개념은 싱글턴인지 아닌지에 상관없이 일반 객체처럼 많은 인스턴스를 만들 수 있어야 한다는 것이다.

따라서 이 패턴을 사용하는 것이 사용하기 편할 뿐 아니라 에러 발생 가능성이 적기 때문에 더 좋은 선택이다.

class GitFetcher:
    _current_tag = None
 
    def __init__(self, tag):
        self.current_tag = tag
 
    @property
    def current_tag(self):
        if self._current_tag is None:
            raise AttributeError("tag가 초기화되지 않음")
        return self._current_tag
 
    @current_tag.setter
    def current_tag(self, new_tag):
        self.__class__._current_tag = new_tag
 
    def pull(self):
        logger.info("%s에서 풀", self.current_tag)
        return self.current_tag

위 예제에서 다른 버전을 가진 GitFetcher 인스턴스를 만들어 보면 모두가 같은 최신 버전tag를 공유하고 있음을 쉽게 확인할 수 있다.

>>> f1 = GitFetcher(0.1)
>>> f2 = GitFetcher(0.2)
>>> f1.current_tag = 0.3
>>> f2.pull()
0.3
 
>>> f1.pull()
0.3

더 많은 속성이 필요하거나 공유 속성을 좀 더 캡슐화하고 싶다면 디스크립터를 사용할 수 있다.

class SharedAttribute:
    def __init__(self, initial_value=None):
        self.value = initial_value
        self._name = None
 
    def __get__(self, instance, owner):
        if instance is None:
            return self
        if self.value is None:
            raise AttributeError(f"{self._name} was never set")
        return self.value
 
    def __set__(self, instance, new_value):
        self.value = new_value
 
    def __set_name__(self, owner, name):
        self._name = name
 
class GitFetcher:
 
    current_tag = SharedAttribute()
    current_branch = SharedAttribute()
 
    def __init__(self, tag, branch=None):
        self.current_tag = tag
        self.current_branch = branch
 
    def pull(self):
        logger.info("%s에서 풀", self.current_tag)
        return self.current_tag

borg 패턴

이전의 솔루션은 대부분 잘 작동하지만 꼭 싱글턴을 사용해야하는 경우 최후의 더 나은 대안이 있다. 주요 개넘은 같은 클래스의 모든 인스턴스가 하나의 상태를 공유하는 것이다.

class BaseFetcher:
    def __init__(self, source):
        self.source = source
 
 
class TagFetcher(BaseFetcher):
    _attributes = {}
 
    def __init__(self, source):
        self.__dict__ = self.__class__._attributes # 인스턴스에 클래스변수 할당
        super().__init__(source)
 
    def pull(self):
        logger.info("%s 태그에서 풀", self.source)
        return f"Tag = {self.source}"
 
 
class BranchFetcher(BaseFetcher):
    _attributes = {}
 
    def __init__(self, source):
        self.__dict__ = self.__class__._attributes # 인스턴스에 클래스변수 할당
        super().__init__(source)
 
    def pull(self):
        logger.info("%s 브랜치에서 풀", self.source)
        return f"Branch = {self.source}"

예제에서 한 인스턴스에서 딕셔너리를 업데이트하면 모든 객체에 동일하게 업데이트 된다. 이런 타입의 새로운 객체에 대해서는 같은 딕셔너리를 사용할 것이므로 사전은 공통적으로 지속 업데이트 될 것이다.

구조 패턴

구조(Structural) 패턴은 인터페이스의 복잡성을 늘리지 않으면서 기능을 확장하여 객체, 인터페이스를 만들어야 하는 상황에 유용하다. 이러한 패턴의 가장 큰 장점은 향상된 기능을 깔끔하게 구현할 수 있다는 것이다.

어댑터 패턴

래퍼(wrapper)라고도 불리는 이 패턴은 호환되지 않은 두 개 이상의 객체에 대한 인터페이스를 동시에 사용할 수 있게 한다. 여기에는 두 가지 방법이 있다.

첫 번째 방법은 사용하려는 클래스를 상속받는 클래스를 만드는 것이다. 상속받은 클래스는 기존 메서드와 호환될 수 있도록 래핑하는 메서드를 가진다.

class UserSource(UsernameLookup):
    def fetch(self, user_id, username):
        user_namespace = self._adapt_arguments(user_id, username)
        return self.search(user_namespace)
 
    @staticmethod
    def _adapt_arguments(user_id, username):
        return f"{user_id}:{username}"

기존의 객체들이 fetch()를 공통으로 사용하고 있을 때 한 객체가 search()를 지원할 경우 이를 래핑하는 새로은 fetch()를 만든다.

상속을 통해 구현할 경우 얼마나 많은 외부 라이브러리를 가져올지 정확히 알기 어려움으로 강한 결합을 만들고 융통성을 떨어트린다. 따라서 더 나은 방법은 컴포지션 패턴을 사용하는 것이다.

class UserSource:
    def __init__(self, username_lookup: UsernameLookup) -> None:
        self.username_lookup = username_lookup
 
    def fetch(self, user_id, username):
        user_namespace = self._adapt_arguments(user_id, username)
        return self.username_lookup.search(user_namespace)
 
    @staticmethod
    def _adapt_arguments(user_id, username):
        return f"{user_id}:{username}"

객체에 기존에 상속하던 객체의 인스턴스를 제공하는 방식으로 구현한다.

컴포지트

컴포지트 패턴은 기본 객체와 기본객체들을 포함하는 컨테이너 객체를 같은 방법으로 사용할 수 있도록 해 준다.

class Product:
    def __init__(self, name, price):
        self._name = name
        self._price = price
 
    @property
    def price(self):
        return self._price
 
class ProductBundle:
    def __init__(
        self,
        name,
        perc_discount,
        *products: Iterable[Union[Product, "ProductBundle"]]
    ) -> None:
        self._name = name
        self._perc_discount = perc_discount
        self._products = products
 
    @property
    def price(self):
        total = sum(p.price for p in self._products)
        return total * (1 - self._perc_discount)

위 예제에서 ProductBundle 객체는 Product 또는 ProductBundle을 하위 객체로 가질 수 있다. 전체 가격을 계산하기 위해서는 하위 상품이 없을 때까지 계속 상품의 가격을 확인해야 한다.

데코레이터

이 패턴을 사용하면 상속없이도 객체의 기능을 동적으로 확장할 수 있다.

class DictQuery:
    def __init__(self, **kwargs):
        self._raw_query = kwargs
 
    def render(self) -> dict:
        return self._raw_query
 
 
class QueryEnhancer:
    def __init__(self, query: DictQuery):
        self.decorated = query
 
    def render(self):
        return self.decorated.render()
 
 
class RemoveEmpty(QueryEnhancer):
    def render(self):
        original = super().render()
        return {k: v for k, v in original.items() if v}
 
 
class CaseInsensitive(QueryEnhancer):
    def render(self):
        original = super().render()
        return {k: v.lower() for k, v in original.items()}
>>> original = DictQuery(key="value", empty="", none=None, upper="UPPERCASE", title="Title")
>>> new_query = CaseInsensitive(RemoveEmpty(original))
>>> original.render()
{'key': 'value', 'empty': '', 'none': None, 'upper': 'UPPERCASE', 'title': 'Title'}
 
>>> new_query.render()
{'key': 'value', 'upper': 'uppercase', 'title': 'title'}

위 예제에서 데코레이터 패턴을 사용하여 동일한 인터페이스를 가지며 장식된 객체를 만든다.

파이썬은 동적인 특성을 가지고 있다. 각각의 데코레이션 단계를 함수로 정의하여 기본 데코레이터 객체에 전달하는 방법으로 데코레이팅을 수행할 수 있다.

class DictQuery:
    def __init__(self, **kwargs):
        self._raw_query = kwargs
 
    def render(self) -> dict:
        return self._raw_query
 
 
class QueryEnhancer:
    def __init__(
        self,
        query: DictQuery,
        *decorators: Iterable[Callable[[Dict[str, str]], Dict[str, str]]]
    ) -> None:
        self._decorated = query
        self._decorators = decorators
 
    def render(self):
        current_result = self._decorated.render()
        for deco in self._decorators:
            current_result = deco(current_result)
        return current_result
 
 
def remove_empty(original: dict) -> dict:
    return {k: v for k, v in original.items() if v}
 
def case_insensitive(original: dict) -> dict:
    return {k: v.lower() for k, v in original.items()}
>>> query = DictQuery(foo="bar", empty="", none=None, upper="UPPERCASE", title="Title")
>>> QueryEnhancer(query, remove_empty, case_insensitive).render()
{'foo': 'bar', 'upper': 'uppercase', 'title': 'title'}

파사드

파사드(Facade)는 객체간 상호작용을 단순화하려는 많은 상황에서 유용하다. 이 패턴은 여러 객체가 다대다 관계를 이루며 상호작용하는 경우에 사용된다.

파사드는 허브, 단일 참조점의 역할을 한다 외부 오브젝트는 파사드와만 대화하고 파사드에서 적절히 요청을 전달한다. 외부 오브젝트의 입장에서는 파사드 내부의 모든 내용이 완전히 불투명해야 한다.

이 패턴은 객체의 결합력을 낮춰주며 인터페이스의 개수를 줄이고 보다 더 나은 캡슐화를 지원한다. 이 패턴을 사용하여 API를 제공하면 사용자가 노출된 기능을 쉽게 사용할 수 있다. 또한 기능만 노출하고 나머지는 인터페이스 뒤에 숨김으로써 세부 코드의 리팩토링이 자유로워진다.

행동패턴

행동 패턴은 객체의 협력, 통신, 런타임중 인터페이스에 대한 문제를 해결하는 것을 목표로 한다.

책임 연쇄 패턴

책임 연쇄 패턴은 요청을 처리할 수 있는 기회를 하나 이상의 객체에게 부여함으로써 요청 객체와 처리 객체의 결합도를 없앤다.

아래 예제에서 이벤트들은 로그 라인을 처리할 수 없는 경우 후계자 객체에게 전달하고 이러한 과정을 반복한다.

import re
 
class Event:
    pattern = None
 
    def __init__(self, next_event=None):
        self.successor = next_event
 
    def process(self, logline: str):
        if self.can_process(logline):
            return self._process(logline)
 
        if self.successor is not None:
            return self.successor.process(logline)
 
    def _process(self, logline: str) -> dict:
        parsed_data = self._parse_data(logline)
        return {
            "type": self.__class__.__name__,
            "id": parsed_data["id"],
            "value": parsed_data["value"],
        }
 
    @classmethod
    def can_process(cls, logline: str) -> bool:
        return cls.pattern.match(logline) is not None
 
    @classmethod
    def _parse_data(cls, logline: str) -> dict:
        return cls.pattern.match(logline).groupdict()
 
class LoginEvent(Event):
    pattern = re.compile(r"(?P<id>\\d+):\\s+login\\s+(?P<value>\\S+)")
 
class LogoutEvent(Event):
    pattern = re.compile(r"(?P<id>\\d+):\\s+logout\\s+(?P<value>\\S+)")
 
class SessionEvent(Event):
    pattern = re.compile(r"(?P<id>\\d+):\\s+log(in|out)\\s+(?P<value>\\S+)")
>>> chain = LogoutEvent(LoginEvent())
>>> chain.process("567: login user")
{'type': 'LoginEvent', 'id': '567', 'value': 'user'}

이 솔루션은 충분히 유연하며 모든 조건들이 상호배타적이다.

템플릿 메서드 패턴

이 패턴은 코드의 재사용성을 높여주고 객체를 보다 유연하게 하여 다형성을 유지하면서도 코드를 쉽게 수정할 수 있다.

주요 개념은 어떤 행위를 정의할 때 특정한 형태의 클래스 계층구조를 만드는 것이다. 공통 로직을 부모 클래스의 public 메서드로 구현하고 그 안에서 서브클래스에서 구현하는 private 메서드를 호출하는 것이다.

이 패턴을 사용하면 다형성을 쉽게 보존할 수 있으므로 디자인이 유연해진다. 구현을 변경해야 할 경우 하위 클래스의 private메서드를 오버라이드하기만 하면 된다.

커맨드 패턴

커맨드 패턴은 수행해야 할 작업을 요청한 순간부터 실제 실행 시까지 분리할 수 있는 기능을 제공한다.

데이터베이스와 상호작용하는 라이브러이에서 이러한 패턴을 찾아볼 수 있다. psycopg2에서는 execute() 메서드를 호출하면 객체의 내부 표현이 변경되지만 실제로 실행되지는 않는다. fetchall() 메서드를 호출할 때 비로소 데이터가 조회되고 커서에서 사용 가능한 상태가 된다.

이 패턴을 따르게 하는 가장 간단한 방법은 실행될 명령의 파라미터들을 저장하는 객체를 만드는 것이다. 그리고 파라미터들과 상호작용 할 수 있는 메서드를 제공하는 객체를 만들어야 한다. 마지막으로 실제로 작업을 수행할 객체를 만들어야 한다.

상태 패턴

상태 패턴은 구체화를 도와준다. 상태에 따른 행동을 수행할 경우 상태를 열거형이나 상수가 아닌 객체로 표현한다.

import abc
 
from log import logger
 
class InvalidTransitionError(Exception):
    """도달 불가능한 상태에서 전이할 때 발생하는 예외"""
 
 
class MergeRequestState(abc.ABC):
    def __init__(self, merge_request):
        self._merge_request = merge_request
 
    @abc.abstractmethod
    def open(self):
        pass
 
    @abc.abstractmethod
    def close(self):
        pass
 
    @abc.abstractmethod
    def merge(self):
        pass
 
    def __str__(self):
        return self.__class__.__name__
 
 
class Open(MergeRequestState):
    def open(self):
        self._merge_request.approvals = 0
 
    def close(self):
        self._merge_request.approvals = 0
        self._merge_request.state = Closed
 
    def merge(self):
        logger.info("머지 %s", self._merge_request)
        logger.info("브랜치 삭제 %s", self._merge_request.source_branch)
        self._merge_request.state = Merged
 
 
class Closed(MergeRequestState):
    def open(self):
        logger.info("종료된 머지 리퀘스트 %s 재오픈", self._merge_request)
        self._merge_request.state = Open
 
    def close(self):
        pass
 
    def merge(self):
        raise InvalidTransitionError("종료한 요청을 머지할 수 없음")
 
 
class Merged(MergeRequestState):
    def open(self):
        raise InvalidTransitionError("이미 머지 완료됨")
 
    def close(self):
        raise InvalidTransitionError("이미 머지 완료됨")
 
    def merge(self):
        """이미 머지 완료됨"""
 
 
class MergeRequest:
    def __init__(self, source_branch: str, target_branch: str) -> None:
        self.source_branch = source_branch
        self.target_branch = target_branch
        self._state = None
        self.approvals = 0
        self.state = Open
 
    @property
    def state(self):
        return self._state
 
    @state.setter
    def state(self, new_state_cls):
        self._state = new_state_cls(self)
 
    def open(self):
        return self.state.open()
 
    def close(self):
        return self.state.close()
 
    def merge(self):
        return self.state.merge()
 
    def __str__(self):
        return f"{self.target_branch}:{self.source_branch}"

이 예제에서 상태 전이는 state 객체에 위임된다.

MergeRequest가 모든 처리를 state 객체에 위임했기 때문에 항상 self.state.open()과 같은 형태로 호출된다.

__getattr__매직 메서드를 사용하면 반복적 코드를 제거할 수 있다.

class MergeRequest:
    def __init__(self, source_branch: str, target_branch: str) -> None:
        self.source_branch = source_branch
        self.target_branch = target_branch
        self._state: MergeRequestState
        self.approvals = 0
        self.state = Open
 
    @property
    def state(self):
        return self._state
 
    @state.setter
    def state(self, new_state_cls):
        self._state = new_state_cls(self)
 
    def __getattr__(self, method):
        return getattr(self.state, method)
 
    def __str__(self):
        return f"{self.target_branch}:{self.source_branch}"

Null 객체 패턴

Null 객체 패턴의 원칙은 함수나 메서드는 일관된 타입을 반환해야 한다는 것이다. 이것이 보장된다면 클라이언트는 다형성을 가진 메서드에서 반환되는 객체에 대해 null 체크를 하지 않고 바로 사용할 수 있다.

함수는 일관성이 있는 타입의 객체를 반환해야 한다. 어떠한 경우에도 None을 반환하면 안 된다. None은 일어난 일에 대해 아무것도 설명해주지 않는다. 때문에 반환된 객체에 대해 메서드를 호출하는 호출자는 AttributeError을 발생시키게 된다.

비어있는 상태의 객체를 나타내는 null 객체는 사용자가 원래 기대하던 것과 동일한 메서드를 가지고 있어야 하며 아무 일도 수행하지 않아야 한다.

Link to original

클린 아키텍처

클린 코드에서 클린 아키텍처로

관심사의 분리

애플리케이션 내부에는 여러 컴포넌트가 있다. 컴포넌트는 모듈, 패키지의 하위 컴포넌트로 나뉘며 이는 클래스와 함수로, 클래스는 다시 메서드로 나눌 수 있다. 이 컴포넌트는 가능한 작게 유지되어야 하며 특히 함수는 한 가지 작업만을 수행해야 하며 작게 유지되어야 한다.

애플리케이션의 컴포넌트는 높은 응집력과 낮은 결합력을 가져야 한다. 새로운 요구사항이 생기면 단 하나의 장소에서만 수정해야 하고 나머지 코드는 영향을 받지 않아야 한다.

추상화

코드는 그 자체로 문서화가 되는 정도의 표현력을 가져야 하며, 문제의 본질에 대한 해결책을 제시하는 올바른 추상화를 해야 한다. 아키텍처도 마찬가지로 시스템이 어떻게 되는지 설명할 수 있어야 한다. 여기서는 프레임워크, 사용라이브러리, 디스크 저장 방법등의 세부사항이 중요하지는 않다.

소프트웨어 컴토넌트

패키지

파이썬 패키지는 소프트웨어를 배포하고 일반적인 방식으로 코드를 재사용하기 위한 편리한 방법이다.

일반적인 라이브러리의 기본 구조는 다음과 같다

├──── Makefile
├──── README.rst
├──── setup.py
├──── src
│   ├──── apptool
│   ├──── __init__.py
│   └──── common.py
│   └────parse.py
└──── tests
    ├──── integration
    └──── unit

setup.py 파일에는 프로젝트의 모든 중요한 정의(요구사항, 의존성, 이름, 설명)가 지정되어 있다.

src 내부의 apptool 디렉토리는 작업중인 라이브러리의 이름이다. 일반적으로 파이썬 프로젝트에서 필요한 모든 것을 이곳에 배치한다.

setup.py 파일의 예는 다음과 같다.

from setuptools import find_packages, setup
 
with open("README.rst", "r") as longdesc:
    long_description = longdesc.read()
 
setup(
    name="apptool",
    description="패키지 설명",
    long_description=long_description,
    author="저자 이름",
    version="0.1.0",
    packages=find_packages(where="src/"),
    package_dir={"": "src"},
)

파이썬 프로젝트의 핵심은 다음과 같다.

플랫폼에 독립적이며 로컬 설치에 의존하지 않는지 테스트, 검증해야 한다. 단위 테스트를 패키지에 같이 배포하지 않는다. 의존성 분리 가장 많이 요구되는 명령에 대해서는 진입점을 만드는 것이 좋다.

컨테이너

도커 컨테이너는 애플리케이션을 독립적인 컴포넌트로 관리한다. 컨테이너를 만드는 이유는 작고 명확한 서비스를 나타내는 작은 컴포넌트를 만들기 위함이다.

컨테이너는 애플리케이션의 다양한 서비스를 만드는 수단이다. 이들은 아키텍처에 대한 관심사의 분리를 도와준다. 이러한 컨테이너는 유지보수가 가능한 형태로 디자인되어야 한다. 책임을 명확하게 구분하여 해당 서비스의 변경으로 인해 애플리케이션의 다른 부분의 영향을 미치지 않도록 해야 한다.

Link to original