def data_from_response(response): """response에 문제가 없다면 response의 payload를 반환 - response 사전의 예제:: { "status": 200, # <int> "timestamp": "....", # 현재 시간의 ISO 포맷 문자열 "payload": {...} # 반환하려는 사전 데이터 } - 반환 사전 값의 예제:: {"data": {..}} - 발생 가능한 예외: - HTTP status가 200이 아닌 경우 ValueError 발생 """
파이썬은 동적 타이핑을 하기 때문에 예상되는 함수의 입출력을 문서화하면 사용시 함수의 종작을 이해하기 쉽다.
위 함수는 어노테이션을 통해 파라미터와 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']}
위에서 설명한 기능들은 __getitem__ 이라는 매직 메서드 덕분에 동작한다.
이것은 my_object[key]와 같은 형태를 사용할 때 호출되는 메서드로 key에 해당하는 대괄호 안의 값을 파라미터로 전달한다.
특히 시퀀스는 __getitem__과 __len__을 모두 구현하는 객체이므로 반복이 가능하다.
리스트, 튜플, 문자열은 표준 라이브러리에 있는 시퀀스 객체의 예이다.
위 코드는 offline_backup()를 실행할 때 dbhandler_decorator의 context를 하당한다.
이 접근법의 유일한 단점은 독립적이라는 것이다. 데코레이터는 함수에 대해 모르고 그 반대도 마찬가지이다. 이것은 좋은 특성이지만 컨텍스트 관리자 내부에서 사용하고자 하는 객체를 얻을 수 없다는 것을 의미한다.
로직을 한번만 정의하면 동일한 로직을 필요로 하는 함수에 데코레이터를 적용함으로써 원하는 만큼 재사용 할 수 있다는 장점이 있다.
프로퍼티, 속성과 객체 메서드의 다른 타입들
다른 언어들과 달리 파이썬 객체의 모든 프로퍼티와 함수는 public이다. 엄격한 강제 사항은 없지만 몇 가지 규칙이 있다. 밑줄로 시작하는 속성은 해당 객체에 대해 private를 의미하며, 외부에서 호출하지 않기를 기대한다.
파이썬에서의 밑줄
객체는 외부 호출 객체와 관련된 속성과 메서드만을 노출해야 한다. 즉 객체의 인터페이스로 공개하는 용도가 아니라면 모든 멤버에는 접두사로 하나의 밑줄을 사용하는 것이 좋다.
이것은 객체의 인터페이스를 명확하게 구분하기 위한 pythonic한 방식이다.
파이썬에서 이중 밑줄로 정의한 속성에 접근하려고 하면 AttributeError 가 발생한다. 이와 같은 방법을 사용하여 일부 속성을 숨길 수 있으므로 해당 속성이 private이며 다른 객체가 수정할 수 없다고 생각할 수 있다.
하지만 발생한 예외는 AttributeError 에러로 접근 권한에 대해 언급하지 않고 속성이 없음을 나타낸다. 이것은 실제로 뭔가 다른 일이 벌어졌으며 부작용에 의한 결과로 생긴 것이라는 것을 암시한다.
밑줄 두 개를 사용하면 실제로 파이썬은 다른 이름을 만든다. 이것을 name mangling 이라고 한다. 이것이 하는 일은 다음과 같은 이름의 속성을 만드는 것이다. _[클래스이름]__[멤버이름]
이중 밑줄은 pythonic한 코드가 아니다. 속성을 private로 정의하려는 경우 하나의 밑줄을 사용하고 pythonic한 관습을 지켜야 한다.
프로퍼티
객체에 값을 저장해야 할 경우 일반적인 속성(attribute)을 사용할 수 있다. 객체의 상태나 다른 속성의 값을 기반으로 계산을 수행하려는 경우 대부분 프로퍼티를 사용하는 것이 좋은 선택이다.
프로퍼티는 객체의 속성에 대한 접근을 제어하려는 경우 사용한다. Java와 같은 다른 프로그래밍 언어에서는 접근 메서드(getter, setter)를 만들지만 파이썬에서는 프로퍼티를 사용한다.
import reEMAIL_FORMAT = re.compile(r"[^@]+@[^@]+\\.[^@]+")def is_valid_email(potentially_valid_email: str): return re.match(EMAIL_FORMAT, potentially_valid_email) is not Noneclass 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_ 메서드를 작성할 필요는 없다. 대부분의 경우 일반 속성을 사용하는 것으로 충분하고, 속성 값을 가져오거나 수정할 때 특별한 로직이 필요한 경우에 프로퍼티를 사용한다.
프로퍼티는 명령-쿼리 분리 원칙을 따르기 위한 좋은 방법이다.
메서드는 한 가지만 수행해야 한다. 작업을 처리한 다음 상태를 확인하려면 메서드를 분리해야 한다.
이터러블 객체
파이썬의 반복은 이터러블 프로토콜이라는 자체 프로토콜을 사용해 동작한다.
객체를 반복할 수 있는지 확인하기 위해 파이썬은 고수준에서 다음 두가지를 차례로 검사한다.
이 객체는 한 쌍의 날짜를 통해 생성되며 해당 기간의 날짜를 반복하면서 하루 간격으로 날짜를 표시한다.
for 루프는 iter() 함수를 호출하고, 이 함수는 __iter__ 매직 메서드를 호출한다. __iter__ 메서드는 이터러블 객체를 반환한다.
각 루프마다 반환된 이터러블 객체의 next()함수를 호출한다. next()함수는 다시 __next__ 메서드에게 위임한다. 이 메서드는 요소를 어떻게 생산하고 하나씩 반환할 것인지 결정한다.
더이상 생산할 것이 없을 경우 파이썬에게 StopIteration 예외를 발생시켜야 한다.
위 예제는 한 번 실행하면 끝 인덱스에 도달한 상태이므로 이후에 호출하면 StopIteration예외가 발생한다. 즉 두 개 이상의 for루프에서 이 값을 사용하면 첫 번째 루프만 작동시키고 두 번째 루프는 작동하지 않게 된다.
이 문제를 수정하는 한 가지 방법은 매번 새로운 DataRangeIterable 인스턴스를 만드는 방법이 있다. 또는 __iter__에서 제너레이터를 사용할 수도 있다.
컨테이너는 __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.heightclass 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} 속성이 없음")
첫 번째 방법은 객체에 있는 속성을 요청하고 그 결과 값을 반환한다.
두 번째 방법은 객체에 없는 fallback_test라는 메서드를 호출하기 때문에 __getattr__이 호출되어 값을 반환한다.
세 번째 예제에서는 fallback_new라는 새로운 속성이 생성된다. 실제로 이 호출은 dyn.fallback_new = "new value"를 실행한 것과 동일하다. 메서드가 호출되지 않았기 때문에 __getattr__의 로직이 적용되지 않는다.
마지막 예제의 __getattr__메서드에서 값을 검색할 수 없는 경우 AttributeError 가 발생한다. 이것은 예외 메시지를 포함해 일관성을 유지할 뿐만 아니라 내장 getattr() 함수에서도 필요한 부분이다. 이 예외가 발생하면 getattr() 함수는 기본 값을 반환한다.
__getattr__과 같은 동적인 메서드를 구현할 때는 예외시 AttributeError를 발생시켜야 한다
호출형(callable) 객체
매직메서드 __call__을 사용하면 객체를 일반 함수처럼 호출할 수 있다. 여기에 전달된 모든 파라미터는 __call__메서드에 그대로 전달된다. 객체를 이렇게 사용하는 주된 이점은 객체에는 상태가 있기 때문에 함수 호출 사이에 정보를 저장할 수 있다는 점이다.
이 메서드는 객체를 파라미터가 있는 함수처럼 사용하거나 정보를 기억하는 함수처럼 사용할 경우 유용하다.
다음은 __call__메서드를 사용하여 입력된 파라미터와 동일한 값으로 몇 번이나 호출되었는지를 반환하는 객체를 만든 예이다.
본 섹션에서 논의되는 대부분은 완전히 피할 수 있는 것들이며, 안티 패턴을 정당화 하는 시나리오가 거의 없다.
변경 가능한 파라미터의 기본 값
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가 발생한다.
기본 값을 사용해 함수를 호출하면 기본 데이터로 사용될 딕셔너리를 한 번만 생성하고 user_metadata는 이것을 가리킨다. 이 값은 프로그램이 실행되는동안 계속 메모리에 남아있게 되며, 값을 수정할 경우 기본 인자 대신 수정된 값을 사용한다.
이 문제를 해결하기 위해서는 기본 초기값으로 None을 사용하고 함수 본문에서 기본 값을 할당하면 된다.
내장(built-in) 타입 확장
리스트 , 문자열, 딕셔너리와 같은 내장 타입을 확장하는 올바른 방법은 collections 모듈을 사용하는 것이다. 내장 타입을 직접 확장하는 클래스를 만들면 예상하지 못한 결과를 얻을 수 있다. CPython에서는 클래스의 메서드를 서로 호출하지 않기 때문에 메서드 중에 하나를 오버라이드하면 나머지에는 반영되지 않기 떄문이다.
사용자 정의 매핑 타입을 만들 때에는 dict보다 collections.UserDict를 사용하는 것이 좋다.
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 메서드 내부에서 매우 다른 유형의 에러인 ValueError와 ConnectionError가 동시에 발생하고 있다. 이렇게 매우 다른 유형의 오류를 살펴봄으로써 책임을 어떻게 분산해야 하는지에 대한 아이디어를 얻을 수 있다.
ConnectionError는 connect 메서드 내에서 처리되어야 한다. 이렇게 하면 행동을 명확하게 분리할 수 있게 된다.
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 * 2def 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을 호출하는 것은 상황을 더 악화시킨다. 사용자에게 노출된 인터페이스 또한 불분명하다.
사용자는 초기화를 통해 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)에 대한 문제가 있다. TransactionalPolicy는 dict의 모든 메서드를 포함한다. TransactionalPolicy 에는 필요 없는 메서드가 포함되어 있다. 이것들은 public 메서드이므로 이 인터페이스의 사용자는 이 메서드를 호출할 수도 있다.
key 기능을 얻기 위해 dict를 확장하는 것은 충분한 확장의 근거가 되지 않는다.
올바른 해결법은 컴포지션을 사용하는 것이다. TransactionalPolicy 자체가 딕셔너리가 되는 것이 아니라 딕셔너리를 활용하는 것이다. 딕셔너리를 private 속성에 저장하고 __getitem__() 으로 사전의 프록시를 만들고 나머지 필요한 public 메서드를 추가적으로 구현하는 것이다.
파이썬은 C3 linearization 또는 MRO라는 알고리즘을 사용하여 이 문제를 해결한다. 이 알고리즘은 메서드가 호출되는 방식을 정의한다.
믹스인
믹스인은 코드를 재사용 하기 위해 일반적인 행동을 캡슐화 해놓은 기본 클래스이다. 일반적으로 믹스인 클래스는 클래스에 정의된 메서드나 속성에 의존하기 때문에 이 클래스만 확장해서는 확실히 동작하지 않는다. 보통은 믹스인 클래스를 다중 상속하여 믹스인에 있는 메서드나 속성을 사용한다.
다음은 문자열을 받아 하이픈(-) 으로 구분된 값을 반환하는 파서 클래스이다.
class BaseTokenizer: def __init__(self, str_token): self.str_token = str_token def __iter__(self): yield from self.str_token.split("-")
함수의 인수의 갯수를 줄일 대안 중 하나는 구체화가 있다. 다시말해 전달하는 모든 인자를 포함하는 새로운 객체를 만드는 것이다.
다른 방법은 파이썬의 특정기능 (가변 인자, 키워드) 를 사용해 동적 구조를 만드는 방법이다.
만약 파라미터 값에 대응하여 너무 많은 것들을 함수애서 처리하고 있다면 여러 작은 함수로 분리를 하라는 사인이다.
함수 인자와 결합력
함수의 인수가 많을수록 호출자 함수와 밀접하게 결합될 가능성이 커진다. 많은 파라미터를 사용할수록 호출자는 정상 동작을 위한 모든 정보를 수집하기 어려워진다.
함수가 보다 일반적인 인터페이스를 제공하고 더 높은 수준의 추상화로 작업할 수 있다면 코드 재사용성이 높아진다.
함수가 제대로 동작하기 위해 너무 많은 파라미터가 필요한 경우 코드에 문제가 있을 가능성이 높다.
많은 인자를 취하는 작은 함수
만약 공통 객체에 파라미터 대부분이 포함되어 있다면 가장 쉽게 수정할 수 있다.
track_request(request.headers, request.ip_addr, request_id) 이 예제에서 모든 파라미터가 request와 관련이 있으며 request를 파라미터로 전달하는 것이 좋은 방법이다. 올바르게 함수를 호출하려면 track_request(request)여야 한다.
변경 가능한 객체를 전당할 때에는 부작용에 주의해야 한다. 함수는 전달받은 객체를 변경해서는 안되며 변경이 필요할 경우 전달된 값을 복사한 다음 새로운 수정본을 반환하는 것이 나은 대안이다.
변경 불가능한 객체를 사용하여 부작용을 최소화 한다.
파라미터들을 그룹핑하여 디자인에서 누락되었던 추상화 작업을 할 수 있다.
위와 같은 방법들을 적용할 수 없다면 마지막 수단으로 함수의 구조를 변경하여 다양한 인자를 혀용할 수 있다. 인자가 많은 경우에 *args 또는 **kwargs를 사용하면 더 이해하기 어려운 상황을 만들 수 있다. 이런 경우 인터페이스에 대한 문서화를 하고 정확하게 사용했는지 확실히 확인해야 한다.
*args 와 **kwargs 로 정의된 함수가 융통성 있고 적응력이 좋지만, 함수는 구조를 잃어버리고 가독성을 거의 상실한다. 위치 인자 또는 가변 인자를 사용하면 매우 좋은 docstring을 제작하지 않는 한 나중에 해당 함수에 사용된 파라미터만 보고 정확한 동작을 알 수 없다.
소프트웨어 디자인 우수 사례 결론
소프트웨어의 독립성(orthogonality)
모듈, 클래스, 함수를 변경하면 수정한 컴포넌트가 다른 컴포넌트들에게 영향을 미치지 않아야 한다. 이것이 불가능하다고 해도 좋은 디자인은 가능한 한 영향을 최소화하려고 시도해야 한다.
파이썬에서 함수는 일반 객체일 뿐이므로 파라미터로 전달할 수 있다. 독립성을 얻기위해 이 기능을 활용할 수 있다.
위쪽의 두 함수는 독립성을 가진다. 만약 하나를 변경해도 다른 하나는 변경되지 않는다. 마지막 함수는 문자열 변환을 기본 표현 함수로 사용하고 사용자 정의 함수를 전달하면 해당 함수를 사용해 문자열을 포매팅한다. 그러나 show_price의 변경사항은 calculate_price에 영향을 미치지 않는다.
코드의 두 부분이 독립적이라는 것은 다른 부분에 영향을 주지 않고 변경할 수 있다는 것을 뜻한다. 이는 변경된 부분의 단위 테스트가 나머지 단위 테스트와도 독립적이라는 것을 뜻한다. 이러한 가정 하에 두 개의 테스트가 통과하면 전체 회귀 테스트를 하지 않고도 애플리케이션에 문제가 없다고 어느정도 확신할 수 있다.
독립성은 기능 면에서 생각해 볼 수 있다. 애플리케이션의 두 가지 기능이 완전히 독립적이라면 다른 코드를 손상시킬 것에 대한 염려가 없으므로 간단한 테스트 후에 배포할 수 있다.
코드 구조
코드를 구조화하는 방법은 팀의 작업 효율성과 유지보수성에 영향을 미친다.
여러 정의(클래스, 함수, 상수)가 들어있는 큰 파일을 만드는 것은 좋지 않으므로 피하는 것이 좋다. 좋은 코드는 유사한 컴포넌트끼리 정리하여 구조화해야 한다.
코드의 여러 부분이 해당 파일의 정의에 종속되어 있어도 전체적인 호환성을 유지하면서 패키지로 나눌 수 있다. 해결 방법은 __init__.py 파일을 가진 새 디렉터리를 만드는 것이다. 이렇게 하면 파이썬 패키지가 만들어 진다.
단일 책임 원칙(Single Responsibility Principle - SRP)는 소프트웨어 컴포넌트가 단 하나의 책임을 져야한다는 원칙이다.
클래스가 유일한 책임이 있다는 것은 하나의 구체적인 일을 담당한다는 것을 의미하며, 변화해야 할 이유 또한 한 가지임을 의미한다.
도메인의 문제가 변경되면 클래스를 업데이트 해야 한다. 다른 이유로 클래스를 수정해야 한다면 추상화가 잘못되어 클래스에 너무 많은 책임이 있다는 것을 뜻한다.
이 디자인 원칙은 보다 응집력 있는 추상화를 하는 데 도움이 된다. 어떤 경우에도 여러 책임을 가진 객체를 만들어서는 안된다. 서로 다른 행동을 그룹화 한 객체는 유지보수가 어려워진다.
클래스는 작을수록 좋다. 소프트웨어 디자인에서 SRP는 응집력과 밀접한 관련이 있다. 여기서 추구하려는 것은 클래스에 있는 프로퍼티와 속성이 항상 메서드를 통해서 사용되도록 하는 것이다. 이렇게 하면 이들은 관련된 개념이기 때문에 동일한 추상화로 묶는 것이 가능하다.
너무 많은 책임을 가진 클래스
다음은 SRP를 준수하지 않은 디자인이다.
class SystemMonitor: def load_activity(self): """소스에서 처리할 이벤트 가져오기""" def identify_events(self): """가져온 데이터를 파싱하여 도메인 객체 이벤트로 변환""" def stream_events(self): """파싱한 이벤트를 외부 에이전트로 전송"""
이 클래스의 문제점은 독립적인 동작을 하는 메서드를 하나의 인터페이스에 정의했다는 것이다. 각각의 동작은 나머지 부분과 독립적으로 수행할 수 있다.
이 디자인 결함은 유지보수를 어렵게 하여 클래스가 경직되고 융통성 없으며 어류가 발생하기 쉽게 만든다.
여기에서 각 메서드는 클래스의 책임을 대표한다. 각각의 책임마다 수정 사유가 발생한다. 즉 메서드마다 다양한 변경의 필요성이 생기게된다.
위 코드에서 load_activity의 데이터의 구조를 변경하는 등의 이유로 수정해야 할 경우 클래스 전체를 변경해야 하며, 이는 적절하지 않다.
데이터의 표현 변경으로 전체 클래스를 변경해서는 안된다.
책임 분산
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_dataclass 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)
이 디자인은 몇가지 문제점을 가진다. 첫 번째 문제는 이벤트 유형을 결정하는 논리가 일체형으로 중앙 집중화 된다는 점이다.
지원하려는 이벤트가 늘어날수록 메서드가 커지므로 결국 매우 큰 메서드가 될 수도 있다.
또한 elif문을 사용함으로써 새로운 유형의 이벤트를 시스템에 추가할 때마다 메서드를 수정해야 한다.
확장성을 가진 이벤트 시스템으로 리팩토링
이전 예제의 문제점은 SystemMonitor 클래스가 분류하려는 구체 클래스와 직접 상호작용 한다는 점이다.
개방/폐쇄 원칙을 따르는 디자인을 달성하려면 추상화를 해야 한다.
대안은 SystemMonitor 클래스를 추상적인 이벤트와 협력하도록 변경하고, 이벤트에 대응하는 개별 로직을 각 이벤트 클래스에 위임하는 것이다. 그 후 각각의 이벤트에 다형성을 가진 새로운 메서드를 추가해야 한다.
이 메서드는 전달되는 데이터가 해당 킁래스의 타입과 일치하는지 판단하는 역할을 한다.
class Event: def __init__(self, raw_data): self.raw_data = raw_data @staticmethod def meets_condition(event_data: dict): return Falseclass 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 메서드를 구현하기만 하면 된다.
이벤트 시스템 확장
새로운 타입의 이벤트를 추가하게 되는 경우 새로운 TransactionEvent 클래스를 추가하는 것만으로 기존 코드가 예상한 대로 잘 동작한다.
class Event: def __init__(self, raw_data): self.raw_data = raw_data @staticmethod def meets_condition(event_data: dict): return Falseclass 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 Noneclass 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의 주된 생각은 어떤 클래스에서든 클라이언트는 특별한 주의를 기울이지 않고 하위 타입을 사용할 수 있어야 한다는 것이다.
위 그림에서 클라이언트는 별다른 주의를 기울이지 않고도 타입을 확장하는 여러 하위클래스(subtype1subtype2subtypeN)의 인스턴스로 작업을 할 수 있어야 한다.
주어진 타입과 클래스 사이에는 계약이 필요하다. LSP의 규칙에 따르면 하위 클래스는 상위 클래스에서 정의한 계약을 따르도록 디자인해야 한다.
만약 파생 클래스가 부모 클래스에서 정의한 파라미터와 다른 타입이나 반환 값을 사용할 경우 LSP가 위반되었다고 할 수 있다.
애매한 LSP 위반 사례
클라이언트와 제공자 사이의 계약은 몇 가지 규칙을 가지고 있다. 클라이언트는 제공자가 유효성을 검사할 수 있도록 사전조건을 제공하고 제공자는 클라이언트가 사후 조건으로 검사할 값을 반환한다.
부모 클래스는 클라이언트와의 계약을 정의한다. 하위 클래스는 그 계약을 따라야 한다.
하위 클래스는 부모 클래스에 정의된 것보다 사전 조건을 엄격하게 만들면 안된다.
하위 클래스는 부모 클래스에 정의된 것보다 약한 사후조건을 만들면 안된다.
LSP 최종 정리
LSP는 객체지향 소프트웨어 설계의 핵심이 되는 다형성을 강조하기 때문에 좋은 디자인의 기초가 된다.
새로운 클래스가 원래의 계약과 호환되지 않는 확장을 하려고 하면 클라이언트와의 계약이 깨져서 결과적으로는 확장이 불가할것이다. 또는 확장이 가능하려면 수정에 대해 폐쇄되어야 한다는 원칙을 깨야 한다.
이는 바람직하지 못한 형태이다.
LSP에서 제안하는 방식으로 신중하게 클래스를 디자인하면 계층을 올바르게 확장하는 데 도움이 된다. 즉 LSP가 OCP에 기여한다고 할 수 있다.
인터페이스 분리 원칙
인터페이스 분리 원칙(interface segregation principle, ISP)은 “작은 인터페이스”에 대한 가이드라인을 제공한다.
객체 지향적 용어로 인터페이스는 객체가 노출하는 메서드의 집합이다. 즉 객체가 수신하거나 해석할 수 있는 모든 메시지가 인터페이스를 구성하며, 이것들은 다른 클라이언트에서 호출할 수 있는 요청들이다.
인터페이스는 클래스에 노출된 동작의 정의와 구현을 분리한다.
파이썬에서 인터페이스는 클래스 메서드의 형태를 보고 암시적으로 정의한다. 이것은 파이썬이 덕 타이핑(duck typing) 원리를 따르기 때문이다.
덕타이핑은 모든 객체가 자신이 가지고 있는 메서드와 자신이 할 수 있는 일에 의해서 표현된다는 점에서 출발한다.
즉 클래스의 유형, 이름, docstring, 클래스 속성 또는 인스턴스 속성에 관계없이 객체의 본질을 정의하는 것은 궁극적으로 메서드의 형태이다.
추상적으로 ISP는 다음을 뜻한다.
다중 메서드를 가진 인터페이스가 있다면 매우 정확하고 구체적인 구분에 따라 더 적은 수의 메서드(가급적 하나)를 가진 여러 개의 메서드로 분할하는 것이 좋다는 것이다.
재사용성을 높이기 위해 가능한 작은 단위로 인터페이스를 분리한다면 인터페이스 중 하나를 구현하려는 각 클래스가 매우 명확한 동작과 책임을 지니기 때문에 응집력이 높아진다.
너무 많은 일을 하는 인터페이스
class EventParser: def from_xml(self): def from_json(self):
위 클래스는 구체적인 클래스 대신 인터페이스에 종속성이 있는 경우이다.
이것을 인터페이스로 만들려면 파이썬에서는 추상 기본 클래스를 만들고 from_xml()과 from_json()이라는 메서드를 정의한다.
이 클래스를 상속한 이벤트는 이 메서드들을 구현해야 한다.
만약 클래스가 XML, JSON 둘 중 하나만 필요로 할 경우에도 위 인터페이스에서는 필요하지 않은 나머지 메서드를 제공한다.
이것은 결합력을 높여 유연성을 떨어트리며, 클라이언트가 필요하지 않은 메서드를 구현하도록 한다.
인터페이스는 작을수록 좋다.
앞의 인터페이스는 각각 하나의 메서드를 가진 두 개의 다른 인터페이스로 분리하는 것이 좋다.
class XMLEventParser: def from_xml(self): class JSONEventParser: def from_json(self):
이 디자인을 사용하면 XMLEventParser에서 파생된 클래스는 from_xml() 메서드만을 구현하면 되고, 마찬가지로 JSONEventParser에서 파생된 클래스는 from_json()만을 구현하면 된다.
이것으로 두 클래스가 독립성을 유지하게 되었고 새로운 작은 객체를 사용해 모든 기능을 유연하게 조합할 수 있게 되었다.
SRP와 유사하지만 주요 차이점은 ISP는 인터페이스에 대해 이야기하고 있다는 점이다.
인터페이스는 얼마나 작아야 할까?
클래스는 다른 클래스들이 확장할 수 있도록 인터페이스를 정의해야 한다.
이것은 응집력의 관점에서 가능한 단 한 가지 일을 수행하는 작은 인터페이스여야 한다.
의존성 역전
의존성 역전(DIP)은 코드가 깨지거나 손상되는 취약점으로부터 보호해주는 디자인 원칙을 제시한다.
의존성을 역전시킨다는 것은 코드가 세부사항이나 구체적인 구현에 적응하도록 하지 않고 API등에 적응하도록 하는 것이다.
추상화를 통해 세부 사항에 의존하지 않도록 해야 하지만, 반대로 세부 사항(구체적 구현)은 추상화에 의존해야 한다.
추상화는 인터페이스 형태로 제공된다.
일반적으로 구체적인 구현이 추상 컴포넌트보다 훨씬 더 자주 바뀐다.
이런 이유로 시스템이 변경, 수정, 확장 될 것으로 예상되는 지점에 유연성을 확보하기 위해 추상화(인터페이스 사용)를 하는 것이다.
이러한 문제를 해결하려면 EventStreamer을 구체 클래스가 아닌 인터페이스와 대화하도록 하는 것이 좋다. 이렇게 하면 인터페이스의 구현은 세부 구현사항을 가진 저수준 클래스가 담당하게 된다.
첫 번째 EventStream구현은 Syslog유형의 객체와만 동작했기 때문에 유연성이 떨어진다.
두 번째에서는 .send()메서드를 인터페이스의 메서드로 사용한다. Syslog는 send()메서드가 정의된 DataTargetClient 추상 기본 클래스를 확장한다.
이것으로 EventStreamer는 send()가 구현된 객체라면 어떤 것과도 통신할 수 있게 되었다.
이렇게 의존성을 동적으로 제공한다고 의존성 주입(dependency injection) 이라고도 한다.
파라미터를 갖는 데코레이터를 구현하는 일반적인 방법중 첫 번째는 간접 참조(indirection)를 통해 새로운 레벨의 중첩 함수를 만들어 데코레이터의 모든 것을 한 단게 더 깊게 만드는 것이다. 두 번째 방법은 데코레이터를 위한 클래스를 만드는 것이다. 일반적으로 두 번째 방법이 가독성이 더 좋다.
중첩 함수의 데코레이터
데코레이터에 파라미터를 전달하기 위해서는 최소 세 단계의 중첩 함수가 필요하다.
여기에서 첫 번째 함수는 파라미터를 받아 내부 함수에 전달한다. 두 번째 함수는 데코레이터가 될 함수이다. 세 번째는 데코레이팅의 결과를 반환하는 함수이다.
파라미터와 기본 값을 가지는 새로운 데코레이터 구현은 다음과 같다
RETRIES_LIMIT = 3def 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
데코레이터의 부작용을 활용하는 대표적인 예로 모듈의 공용 레지스트리에 객체를 등록하는 경우가 있다.
이벤트 시스템에서 일부 이벤트만 사용하려는 경우 이벤트 계층 구조의 중간에 가상의 클래스를 만들고 일부 파생 클래스에 대해서만 이벤트를 처리하도록 할 수 있다. 각 클래스마다 처리 여부에 플래그 표시를 하는 대신 데코레이터를 사용해 명시적으로 표시 할 수 있다.
EVENTS_REGISTRY = {}def register_event(event_cls): """모듈에서 접근 가능하도록 이벤트 클래스를 레지스트리에 등록""" EVENTS_REGISTRY[event_cls.__name__] = event_cls return event_clsclass Event: """기본 이벤트 객체"""class UserEvent: TYPE = "user"@register_eventclass UserLoginEvent(UserEvent): """사용자가 시스템에 접근했을 때 발생하는 이벤트"""@register_eventclass UserLogoutEvent(UserEvent): """사용자가 시스템에서 나갈 때 발생하는 이벤트"""
위 코드에서 처음에 EVENTS_REGISTRY는 비어있는 것처럼 보이지만 이 모듈을 임포트하면 register_event 가 지정된 클래스들로 채워지게 된다. EVENTS_REGISTRY는 모듈을 임포트한 직후에 최종 값을 가지므로 코드만 봐서는 값을 쉽게 예측하기 어렵다.
이러한 동작 방식이 문제가 되는 경우도 있지만 이 패턴이 필요한 경우도 존재한다.
어느 곳에서나 동작하는 데코레이터 만들기
함수에 사용될 데코레이터를 클래스의 메서드에 사용하거나 또는 메서드에 대한 데코레이터를 다른 유사한 메서드에 적용하려는 경우 오류가 발생할 수 있다.
데코레이터를 만들 때에는 일반적으로 재사용을 고려하여 함수뿐만 아니라 메서드에서도 동작하기를 바란다.
*args와 **kwargs 시그니처를 사용하여 데코레이터를 정의하면 모든 경우에 사용할 수 있다. 하지만 다음 이유들로 원래 함수의 시그니처와 비슷하게 데코레이터를 정의하는 것이 좋을 때가 있다.
원래의 함수와 모양이 비슷하기 때문에 읽기가 쉽다.
파라미터를 받아 작업할 시 *args와 **kwargs를 사용하는 것이 불편하다.
from functools import wrapsclass 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_driverdef 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 wrapsfrom types import MethodTypeclass 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 wrappeddef 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_executiondef operation(): ....
데코레이터에 하나 이상의 책임을 두면 안 된다. SRP는 데코레이터에도 적용된다.
좋은 데코레이터 분석
좋은 데코레이터가 갖추어야 할 특성
캡슐화와 관심사의 분리 : 실제로 하는 일과 데코레이팅 하는 일의 책임을 명확히 구분해야 한다.
데코레이터의 클라이언트는 내부에서 어떻게 구현했는지 전혀 알 수 없는 블랙박스처럼 동작해야 한다.
독립성 : 데코레이터가 하는 일은 독립적이어야 하며 데코레이팅 되는 객체와 최대한 분리되어야 한다.
재사용성 : 데코레이터는 하나의 여러 유형에 적용 가능한 형태가 바람직하다.
하나의 함수에만 적용된다면 데코레이터가 아닌 함수로 대체할 수 있다.
클라이언트 클래스는 디스크립터 구현의 기능을 활용할 도메일 모델로서 솔루션을 위해 생성한 일반적인 추상화 객체이다.
디스크립터 클래스는 디스크립터 로직의 구현체이다.
디스크립터는 디스크립터 프로토콜을 구현한 클래스의 인스턴스이다. 이 클래스는 다음 매직 메서드 중 최소 한 개 이상을 포함해야 한다.
__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 instanceclass ClientClass: descriptor = DescriptorClass()
>>> client = ClientClass()>>> client.descriptorINFO: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] = valueclass 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 NoneTrue>>> 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 nameclass 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 42class ClientClass: descriptor = NonDataDescriptor()
.descriptor속성에 다른 값을 설정하면 인스턴스의 딕셔너리가 변경된다.
이후 .descriptor속성을 조회하면 객체의 __dict__ 딕셔너리에서 descriptor키를 찾을 수 있으므로 클래스까지 검색하지 않고 __dict__ 딕셔너리에서 값을 반환한다.
때문에 디스크립터 프로토콜이 사용되지 않고 다음에 이 속성을 조회할 때는 덮어써진 43을 반환한다.
그 뒤 del을 호출해 속성을 제거하게 되면 객체의 __dict__ 딕셔너리에서 descriptor키를 지운 것과 같으므로 디스크립터 프로토콜이 다시 활성화 된다.
디스크립터가 __delete__ 메서드를 구현하지 않았기 때문에 디스크립터의 속성을 설정하면 속성이 깨지게 된다.
디스크립터가 객체를 바로 저장하게 되는 경우에 인스턴스의 값을 수정하면 같은 클래스의 다른 모든 인스턴스에서도 값이 수정된다.
>>> client1 = ClientClass()>>> client1.descriptor'첫 번째 값'>>> client2 = ClientClass()>>> client2.descriptor'첫 번째 값'>>> client2.descriptor = "client 2를 위한 값">>> client2.descriptor'client 2를 위한 값'>>> client1.descriptor'client 2를 위한 값'
약한 참조 사용
__dict__를 사용하지 않으려는 경우 다른 대안은 디스크립터 객체가 내부 매핑을 통해 각 인스턴스의 값을 보관하고 반환하는 것이다.
내부 매핑을 할 때는 딕셔너리를 사용해서는 안 된다. 클라이언트 클래스는 디스크립터에 대한 참조를 가지며 디스크립터는 디스크립터를 사용하는 객체에 대한 참조를가지므로 순환 종속성이 생겨 가비지 컬렉션이 되지 않는다.
이러한 문제를 해결하기 위해서 딕셔너리는 약한 키가 되어야 한다.
from weakref import WeakKeyDictionaryclass 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에 대해서는 디스크립터를 사용하는 것이 좋다. 이는 일회성 솔루션이 아닌 라이브러리나 프레임워크의 디자인에대해 기능을 확장하기 좋기 때문이다.
매우 특별한 이유가 있거나 코드를 훨씬 좋게하는 무언가가 없다면 디스크립터에 비즈니스 로직을 넣어서는 안 된다.
객체의 __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_nameclass 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__() 메서드에서 self는 Method의 인스턴스를 나타낸다.
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 islicepurchases = islice(filter(lambda p: p > 1000.0, purchases), 10)
이런 식으로 필터링을 해도 메모리의 손해는 없다. 모든 것이 제너레이터이므로 요소 하나씩 평가된다.
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), celldef 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): passdef 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
그리고 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 이후로 코루틴이라는 새로운 타입이 추가되었다.
새로운 구문으로 await와 async def또한 추가되었다. await는 yield from을 대신하기 위한 용도로 awaitable 객체에 대해서만 동작한다. async def는 @coroutine 데코레이터를 대신하여 코루틴을 정의하는 새로운 방법이다.
단위테스트는 다른 코드의 일부분이 유효한지를 검사하는 코드이다.
단위테스트는 소프트웨어의 핵심이 되는 필수적인 기능으로서 일반 비즈니스 로직과 동일한 수준으로 다루어져아 한다.
단위테스트는 비즈니스 로직이 특정 조건을 보장하는지를 확인하기 위해 여러 시나리오를 검증하는 코드이다. 단위 테스트는 다음과 같은 특징을 가진다.
격리 : 단위 테스트는 다른 외부 에이전트와 완전히 독립적이어야 하며 비즈니스 로직에만 집중해야 한다.
성능 : 단위 테스트는 신속하게 실행되어야 한다. 반복적으로 여러 번 실행될 수 있도록 설계해야 한다.
자체 검증 : 단위 테스트의 실행만으로 결과를 결정할 수 있어야 한다. 단위 테스트를 처리하기 위한 추가 단계가 없어야 한다.
자동화된 테스트의 다른 형태
단위 테스트는 함수, 메서드와 같은 작은 단위를 확인하기 위한 것이다. 클래스를 테스트하려면 단위 테스트의 집합인 테스트 스위트를 사용한다.
단위테스트의 종류
통합 테스트: 한 번에 여러 컴포넌트를 테스트, 부작용, 격리를 고려하지 않은 채로 작업을 수행
인수 테스트: 유스케이스를 활용하여 사용자의 관점에서 시스템의 유효성을 검사
일반적으로 단위 테스트는 항상 수행되기를 원하지만 통합 테스트, 인수 테스트는 그보다 덜 자주 수행되기를 바란다.
단위 테스트와 애자일 소프트웨어 개발
변화에 효과적으로 대응할 수 있는 소프트웨어를 개발하고자 한다면 유연하며 확장 가능해야 한다.
코드 자체만으로는 변경에 유연하다는 보장을 할 수가 없다. 또한 잘 짜여진 컴포넌트가 변경 작업 후에도 기존 기능이 보존되며 버그가 없을것을 확신할 수는 없다.
이 경우 단위테스트가 프로그램이 명세에 따라 정확하게 동작한다는 보장을 해 줄 수 있다.
단위 테스트와 소프트웨어 디자인
테스트의 용이성은 클린 코드의 핵심 가치이다. 단위 테스트는 기본 코드를 보완하기 위한 것이 아닌 실제 코드의 작성 방식에 직접적 영향을 미치는 것이다.
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)
특정 상태를 가진 객체를 만들고 여러 테스트에서 해당 객체를 재사용할 수 있다. 픽스처를 정의하려면 함수를 정의하고 @pytest.fixture 데코레이터를 적용한다. 픽스처를 사용하기 원하는 테스트에는 파라미터로 픽스처의 이름을 전달하면 pytest가 그것을 활용한다.
모의(mock) 객체
Mock Object
테스트를 하는 과정 중에는 우리가 작성한 코드 뿐만 아니라 외부 서비스들과의 연결도 하게 된다. 이러한 외부 서비스에는 필연적으로 부작용이 존재한다.
모의 객체는 원하지 않는 부작용으로부터 테스트 코드를 보호하는 가장 좋은 방법 중 하나이다.
단위 테스트는 빠르게 실행되어야 하기 때문에 이러한 대기 시간을 감당할 수 없다. 따라서 단위 테스트에는 이러한 외부 서비스를 호출하지 않는다.
단위 테스트에서는 이것들이 호출되는지만 확인하면 된다.
모의 객체는 유용하지만 남용하여 안티패턴을 만들지 않도록 유의해야 한다.
패치와 모의에 대한 주의사항
단위 테스트는 보다 나은 코드를 작성하는데 도움이 된다. 특정 코드를 테스트하려면 테스트가 가능하도록 짜야 하는데, 이는 코드가 응집력이 뛰어나고 세분화 되어 있으며 작다는 것을 의미한다. 이는 코드가 응집력이 뛰어나고, 세분화 되어 있으며, 작다는 것을 의미한다. 이는 소프트웨어 컴포넌트에 있어서도 모두 좋은 특징들이다.
또한 테스트를 통해 문제가 없다고 생각하던 부분에서 나쁜 부분을 찾아낼 수 있다.
unittest 모듈은 객체를 패치하기 위한 도구를 제공한다. 패치란 임포트 중에 경로를 지정했던 원본 코드를 다른 것으로 대체하는 것을 말한다. 이렇게 하면 런타임 중에 코드가 바뀌어 테스트가 어려워지는 단점이 있다. 런타임 시 인터프리터에서 객체를 수정하는 오버헤드도 있으므로 성능상 이슈도 있다.
몽키패치, 모의 객체를 사용하는 것 자체가 문제가 되지는 않지만 몽키 패치를 남용한다면 원본 코드를 개선할 여지가 있다는 신호이다.
Mock 객체 사용하기
단위 테스트에서 말하는 테스트 더블의 카테고리에 속하는 타입에는 여러 객체가 있다. 테스트 더블은 여러가지 이유로 테스트 스위트에서 실제 코드를 대신해 실제인 것처럼 동작하는 코드를 말한다. 실제 상용 코드의 불필요, 권한 부재, 부작용 등의 이유로 사용된다.
테스트 더블에는 더미(dummy), 스텁(stub), 스파이(spy), 모의 객체(mock)와 같은 다양한 타입의 객체가 있다. 모의 객체는 가장 일반적 유형의 객체이며 융통성이 있고 다양한 기능을 가지고 있기 때문에 모든 경우에 적합하다.
모의 객체(mock)는 스펙을 따르는 객체 타입으로 응답 값을 수정할 수 있다. 즉 모의 객체 호출 시 응답해야 하는 값이나 행동을 특정할 수 있다. 모의 객체는 내부에 호출 방법을 기록하고 나중에 이 정보를 사용하여 애플리케이션의 동작을 검증한다.
Mock 객체의 종류
파이썬 표준 라이브러리는 unittest.mock 모듈에서 Mock과 MagicMock 객체를 제공한다. Mock은 모든 값을 반환하도록 설정할 수 있는 테스트 더블이며 모든 호출을 추적한다. MagicMock 은 Mock의 기능을 모두 가지고 매직 메서드 또한 지원한다. Mock 객체에서 매직 메서드를 사용하려고 하면 에러가 발생한다.
지금 테스트 하려는 것은 정보가 적절하게 구성되어 API에 잘 전달되었는지 여부이다. 따라서 실제로 API를 호출할 필요는 없고 API가 잘 호출 되는지만 확인하면 된다.
또 다른 문제는 API에 전달되는 값 중 시간 값이 있는데, 이 값은 실시간으로 변하는 값이므로 정확히 예측을 할 수가 없다는 점이다.
datetime 모듈은 C로 작성되었으므로 직접 패치할 수는 없다. 따라서 여기에서는 직접 패치할 수 있는 build_date 정적 메서드를 래핑한다.
@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.pyimport requestsdef 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 TestCasefrom unittest.mock import patchimport user_managerclass 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>")
리팩토링은 소프트웨어 유지 관리에서 중요한 활동이지만 단위 테스트가 없다면 정확성을 보장받기 어렵다.
일반적으로 코드를 리팩토링할 때는 구조를 개선하여 보다 나은 코드로 만들려는 경우가 있고, 떄로는 좀 더 일반적인 코드로 수정하여 가독성을 높이려는 경우가 있다. 중요한 점은 이러한 수정작업 이전과 이후가 완전히 동일한 기능을 유지해야 한다는 것이다.
이전과 같은 기능을 지원할 때에만 다른 코드를 사용할 수 있다는 것은 수정된 코드에 대해 회귀 테스트를 실행해야 함을 의미한다. 회귀 테스트를 실행하는 유일한 효율적 방법은 테스트를 자동화 하는 것이다. 자동 테스트의 가장 효율적인 버전이 바로 단위 테스트이다.
단위 테스트에 대한 추가 논의
속성 기반 테스트
속성 기반 테스트는 테스트를 실패하게 만드는 데이터를 찾는 것이다. 이를 위해 hypothesis 라이브러리를 사용한다. 이 라이브러리는 코드를 실패하게 만드는 데이터를 찾는데 도움을 준다.
이 라이브러리를 통해 성공하지 못하는 반대 사례를 찾을 수 있다. 이 라이브러리에 코드의 유효한 가설을 정의하면 hypothesis 라이브러리가 에러를 유발하는 사례를 찾아줄 것이다.
변형 테스트
단위 테스트를 작성하는 이유는 버그로부터 코드를 보호하고 서비스 중에 정말 발생해서는 안 되는 실패에 대해 미리 검증하기 위한 것이다. 검사는 통과하는 것이 좋지만 테스트를 잘못하여 통과한 것이라면 더 위험할 수 있다. 즉 단위테스트중 버그를 추가했다면 적어도 하나 이상의 테스트에서 이를 포착하여 테스트에 실패해야 한다. 만약 이런 일이 발생하지 않았다면 테스트에 누락된 부분이 있다거나 올바른 체크를 하지 않았다는 뜻이다.
이것이 변형 테스트를 하는 이유이다. 변형 테스트 도구를 사용하면 원래 코드를 변경된 새로운 코드로 수정한다. 좋은 테스트 스위트는 이러한 돌연변이를 죽여야 한다. 일부 돌연변이가 실험에서 생존하면 대개 나쁜 징후이다.
테스트 주도 개발 간략 소개
TDD의 요점은 기능의 결함으로 실패하게 될 테스트를 상용화 전에 미리 작성해야 한다는 것이다.
단위 테스트를 먼저 작성한 다음에 코드를 작성하면 기본적인 기능 테스트를 누락할 가능성이 매우 낮아진다.
이러한 작업은 크게 3단게로 구성된다.
구현 내용을 기술하는 단위 테스트를 작성한다. 여기서 테스트를 실행하면 기능이 아직 구현되지 않았기 때문에 실패할 것이다.
해당 조건을 충족시키는 최소한의 필수 코드를 구현하고 테스트를 다시 실행한다. 이번에는 테스트를 통과해야 한다.
각 패턴은 생성(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를 공유하고 있음을 쉽게 확인할 수 있다.
파사드(Facade)는 객체간 상호작용을 단순화하려는 많은 상황에서 유용하다. 이 패턴은 여러 객체가 다대다 관계를 이루며 상호작용하는 경우에 사용된다.
파사드는 허브, 단일 참조점의 역할을 한다 외부 오브젝트는 파사드와만 대화하고 파사드에서 적절히 요청을 전달한다. 외부 오브젝트의 입장에서는 파사드 내부의 모든 내용이 완전히 불투명해야 한다.
이 패턴은 객체의 결합력을 낮춰주며 인터페이스의 개수를 줄이고 보다 더 나은 캡슐화를 지원한다.
이 패턴을 사용하여 API를 제공하면 사용자가 노출된 기능을 쉽게 사용할 수 있다. 또한 기능만 노출하고 나머지는 인터페이스 뒤에 숨김으로써 세부 코드의 리팩토링이 자유로워진다.
행동패턴
행동 패턴은 객체의 협력, 통신, 런타임중 인터페이스에 대한 문제를 해결하는 것을 목표로 한다.
책임 연쇄 패턴
책임 연쇄 패턴은 요청을 처리할 수 있는 기회를 하나 이상의 객체에게 부여함으로써 요청 객체와 처리 객체의 결합도를 없앤다.
아래 예제에서 이벤트들은 로그 라인을 처리할 수 없는 경우 후계자 객체에게 전달하고 이러한 과정을 반복한다.
이 패턴은 코드의 재사용성을 높여주고 객체를 보다 유연하게 하여 다형성을 유지하면서도 코드를 쉽게 수정할 수 있다.
주요 개념은 어떤 행위를 정의할 때 특정한 형태의 클래스 계층구조를 만드는 것이다. 공통 로직을 부모 클래스의 public 메서드로 구현하고 그 안에서 서브클래스에서 구현하는 private 메서드를 호출하는 것이다.
이 패턴을 사용하면 다형성을 쉽게 보존할 수 있으므로 디자인이 유연해진다. 구현을 변경해야 할 경우 하위 클래스의 private메서드를 오버라이드하기만 하면 된다.
커맨드 패턴
커맨드 패턴은 수행해야 할 작업을 요청한 순간부터 실제 실행 시까지 분리할 수 있는 기능을 제공한다.
데이터베이스와 상호작용하는 라이브러이에서 이러한 패턴을 찾아볼 수 있다. psycopg2에서는 execute() 메서드를 호출하면 객체의 내부 표현이 변경되지만 실제로 실행되지는 않는다. fetchall() 메서드를 호출할 때 비로소 데이터가 조회되고 커서에서 사용 가능한 상태가 된다.
이 패턴을 따르게 하는 가장 간단한 방법은 실행될 명령의 파라미터들을 저장하는 객체를 만드는 것이다. 그리고 파라미터들과 상호작용 할 수 있는 메서드를 제공하는 객체를 만들어야 한다. 마지막으로 실제로 작업을 수행할 객체를 만들어야 한다.
상태 패턴
상태 패턴은 구체화를 도와준다. 상태에 따른 행동을 수행할 경우 상태를 열거형이나 상수가 아닌 객체로 표현한다.
애플리케이션 내부에는 여러 컴포넌트가 있다. 컴포넌트는 모듈, 패키지의 하위 컴포넌트로 나뉘며 이는 클래스와 함수로, 클래스는 다시 메서드로 나눌 수 있다. 이 컴포넌트는 가능한 작게 유지되어야 하며 특히 함수는 한 가지 작업만을 수행해야 하며 작게 유지되어야 한다.
애플리케이션의 컴포넌트는 높은 응집력과 낮은 결합력을 가져야 한다. 새로운 요구사항이 생기면 단 하나의 장소에서만 수정해야 하고 나머지 코드는 영향을 받지 않아야 한다.
추상화
코드는 그 자체로 문서화가 되는 정도의 표현력을 가져야 하며, 문제의 본질에 대한 해결책을 제시하는 올바른 추상화를 해야 한다. 아키텍처도 마찬가지로 시스템이 어떻게 되는지 설명할 수 있어야 한다. 여기서는 프레임워크, 사용라이브러리, 디스크 저장 방법등의 세부사항이 중요하지는 않다.
소프트웨어 컴토넌트
패키지
파이썬 패키지는 소프트웨어를 배포하고 일반적인 방식으로 코드를 재사용하기 위한 편리한 방법이다.
플랫폼에 독립적이며 로컬 설치에 의존하지 않는지 테스트, 검증해야 한다.
단위 테스트를 패키지에 같이 배포하지 않는다.
의존성 분리 가장 많이 요구되는 명령에 대해서는 진입점을 만드는 것이 좋다.
컨테이너
도커 컨테이너는 애플리케이션을 독립적인 컴포넌트로 관리한다.
컨테이너를 만드는 이유는 작고 명확한 서비스를 나타내는 작은 컴포넌트를 만들기 위함이다.
컨테이너는 애플리케이션의 다양한 서비스를 만드는 수단이다. 이들은 아키텍처에 대한 관심사의 분리를 도와준다. 이러한 컨테이너는 유지보수가 가능한 형태로 디자인되어야 한다. 책임을 명확하게 구분하여 해당 서비스의 변경으로 인해 애플리케이션의 다른 부분의 영향을 미치지 않도록 해야 한다.