마리아노 아나야의 파이썬 클린코드를 읽고 정리한 포스트입니다.
주제
- 견고한 소프트웨어의 개념을 이해
- 작업 중 잘못된 데이터를 다루는 방법
- 새로운 요구 사항을 쉽게 받아들이고 확장할 수 있는 유지보수가 쉬운 소프트웨어 설계
- 재사용 가능한 소프트웨어 설계
- 개발팀의 생산성을 높이는 효율적인 코드 작성
계약에 의한 디자인
다른 레이어나 컴포넌트에서 호출하는 경우 이들 간의 교류를 어떻게 해야 하는지 고민해보자.
컴포넌트는 기능을 숨겨 캡슐화하고 함수를 사용할 클라이언트에게는 애플리케이션 프로그래밍 인터페이스(Application Programming Interface, API)
를 제공한다. API를 디자인할 때는 계약에 의한 디자인(Design by Contract, DbC)
을 적용하는 것이 좋다. 관계자 간에 계약을 맺듯이 API를 설계하고 구현하는 것이다. 계약은 주로 사전조건(precondition)
과 사후조건(postcondition)
으로 구성되지만 때로는 클래스 불변식(class invariant)
과 부작용(side effect)
도 포함된다.
- 사전조건(precondition): 함수가 실행되기 전에 참이어야 하는 조건. 함수가 실행되기 전에 사전조건이 거짓이면 함수는 예외를 발생시킨다. 일반적으로 파라미터의 유효성을 검사한다. 유효성 검사를 통해 부작용이 최소화된다. 예를 들어 데이터베이스, 파일, 이전에 호출된 다른 메서드의 검사 등이다.
- 사후조건(postcondition): 함수가 실행된 후에 참이어야 하는 조건. 함수가 실행된 후에 사후조건이 거짓이면 함수는 예외를 발생시킨다. 일반적으로 함수의 반환값을 검사한다. 호출자가 이 컴포넌트에서 기대한 것을 제대로 받았는지 확인한다.
- 클래스 불변식(invairant): 클래스의 인스턴스가 유효한 상태를 유지되는 것으로 함수의 로직에 문제가 없는지 확인하기 위한 것이다. 때로는 함수의 docstring에 불변식에 대해 문서화하는 것이 좋다.
- 부작용(side effect): 함수가 호출된 후에 함수 외부에 영향을 미치는 것. 선택적으로 docsring에 언급하기도 한다.
이상적으로는 이 모든 것들을 소프트웨어 컴포넌트 계약서의 일부로 문서화해야 하지만, 처음 2개인 사전조건과 사후조건만 문서화하는 것이 일반적이다.
이렇게 계약에 의한 디자인을 하는 이유는 오류를 쉽게 찾아낼 수 있기 떄문이다. 사전조건 또는 사후조건 검증에 실패할 경우 오류를 쉽게 찾아서 수정할 수 있다. 사전조건은 클라이언트와 연관되어있다. 클아이언트는 코드를 실행하기 위해서 사전에 약속한 조건을 준수해야만 한다. 반대로 사후조건은 컴포넌트와 연관되어 있다. 컴포넌트는 클라이언트가 확인하고 강제할 수 있는 값을 보장해야 한다.
이렇게 하면 책임소재를 명확히 할 수 있다. 클라이언트는 사전조건을 준수하지 않았다면 책임이 클라이언트에게 있다. 반면에 사후조건 검증에 실패하면 특정 모듈이나 제공 클래스 자체에 문제가 있다는 것을 의미한다.
특히 사전조건은 런타임 중에 확인할 수 있다는 점을 기억하자. 만약 사전조건에 맞지 않는다면 실행하지 않아야 한다. 왜냐면 조건에 맞지 않는데 실행하는 것이 이치에 맞지 않을뿐더러 상황을 더 악화시킬 수 있기 때문이다.
사전조건(precondition)
사전조건은 함수나 메서드가 제대로 동작하기 위해 보장되어야 하는 모든 것을 의미한다. 예를 들면 함수가 호출되기 전에 파라미터의 유효성을 검사하는 것이다. 파이썬은 동적으로 타입이 결정되므로 전달된 데이터가 적절한 타입인지 아닌지 확인하는 경우도 있다. 이것은 mypy가 하는 타입 체킹과 다르다. 그것보다는 필요로 하는 값이 정확한지 확인하는 것에 가깝다.
유효성 검사를 어디서 할건지는 개발자의 몫이다. 클라이언트가 함수를 호출하기 전에 모든 유효성 검사를 하도록 할 것인지, 함수가 자체적으로 로직을 실행하기 전에 검사하도록 할 것인지에 대한 문제이다. 전자는 관대한(tolerant) 접근법이다. 왜냐하면 함수 입장에서는 여전히 어떤 값이라도(심지어 깨진 데이터도) 수용하기 때문이다. 반면 후자는 까다로운(demanding) 접근법이다.
분석을 위해 까다로운 접근법을 사용해보자. 일반적으로 가장 안전하고 견고한 방법이며 업계에서도 가장 널리 쓰이는 방법이다.
어떤 방식을 택하든 중복 제거 원칙을 항상 마음속에 간직해야 한다. 중복 제거 원칙은 사전조건 검증을 양쪽에서 하지 말고 오직 어느 한쪽에서만 해야 한다는 것이다. 즉, 검증 로직을 클라이언트에 두거나 함수 자체에 두어야 한다. 어떤 경우에도 중복해서는 안 된다.
사후조건(postcondition)
사후조건은 메서드 또는 함수가 반환된 후의 상태를 강제하는 것이다. 함수 또는 메서드가 적절하게 호출되었다면 (즉 사전조건에 맞는다면) 사후조건은 특정 속성이 보존되어야 한다.
클라이언트는 사후조건을 사용하여 필요로 하는 모든 조건을 확인해 볼 수 있다. 메서드가 적절히 실행되었다면 계약이 이루어졌으므로 사후조건 검증에 모두 통과해야 하고 클라이언트는 반환 객체를 문제없이 사용할 수 있어야 한다.
파이썬스러운 계약
이 디자인 원칙을 구현하는 가장 좋은 방법은 메서드, 함수, 클래스에 제어 메커니즘을 추가하고 검사에 실패할 경우 RuntimeError나 ValueError를 발생시키는 것일 것이다. 올바른 예외 타입이 무엇인지는 애플리케이션의 특성에 따라 달라질 수 있다. 앞서 언급한 예외는 가장 일반적인 예외인데 사용자 정의 예외를 만들어서 사용할 수도 있다.
또한 코드를 가능한 한 격리된 상태로 유지하는 것이 좋다. 즉 사전조건에 대한 검사와 사후조건에 대한 검사 그리도 핵심 기능에 대한 구현을 구분하는 것이다. 더 작은 함수를 생성하여 해결할 수도 있지만 데코레이터를 사용하는 것도 좋은 방법이다.
계약에 의한 디자인(Design by Contract, DbC) - 결론
디자인 원칙의 주된 가치는 문제가 있는 부분을 효과적으로 식별하는데 있다. 계약을 정의함으로써 런타임 오류가 발생했을 때 코드의 어느 부분이 손상되었는지 그리고 무엇이 계약을 파손시켰는지 명확해진다.
이 원칙을 따르게 되면 코드가 더욱 견고해진다. 각 컴포넌트는 자체적으로 제약 조건과 불변식을 관리하며 이러한 불편식이 유지되는 한 프로그램이 정삭 동작하는 것으로 볼 수 있다.
또한 프로그램의 구조를 명확히 하는 데 도움이 된다. 즉흥적으로 유효성 검사를 해보거나 가능한 모든 실패 시나리오를 검증하는 대신 계약을 사용하면 명시적으로 함수나 메서드가 정상적으로 동작하기 위해 필요한 것이 무엇인지, 그리고 정상적으로 동작한 후에 무엇을 반환하는지 정의할 수 있다.
물론 이러한 원칙을 따르면 추가 작업이 필요하다. 왜냐하면 애플리케이션의 핵심 논리뿐만 아니라 계약을 작성해야 하기 때문이다. 하지만 이러한 작업은 애플리케이션의 생명주기 동안 계속 유지되는 장기적인 이점을 가져다 준다.
방어적(defective) 프로그래밍
방어적 프로그래밍은 계약에 의한 디자인(Design by Contract, DbC)과는 다소 다른 접근 방식을 따른다. 계약에서 예외를 발생시키고 실패하게 되는 모든 조건을 기술하는 대신 객체, 함수 또는 메서드와 같은 코드의 모든 부분을 유효하지 않은 것으로부터 스스로 보호할 수 있게 만드는 것이다. DbC와는 서로 보완적인 관계이다.
방어적 프로그래밍의 주요 주제는 예상할 수 있는 시나리오의 오류를 처리하는 방법과 (불가피한 조건에 의해서) 발생하지 않아야 하는 오류를 처리하는 방법에 대한 것이다. 전자는 에러 핸들링 프로시저에 대한 것이며, 후자는 어설션(assertion)에 대한 것이다.
에러 핸들링
오류가 발생하기 쉬운 상황에서 에러 핸들링 프로시저를 사용하는데 일반적으로 데이터 입력 확인 시 자주 사용된다.
에러 핸들링의 주요 목적은 예상되는 에러에 대해서 실행을 계속할 수 있을지 아니면 극복할 수 없는 오류여서 프로그램을 중단할지를 결정하는 것이다.
프로그래밍에서 에러를 처리하는 방법은 여러가지가 있지만, 모든 방법이 항상 적용 가능한 것은 아니다. 에러 처리 방법으로는 다음과 같은 것들이 있다.
- 값 대체(value substitution)
- 에러 로깅
- 예외 처리
값 대체(value substitution)
일부 시나리오에서는 오류가 있어 소프트웨어가 잘못된 값을 생성하거나 전체가 종료될 위험이 있을 경우 결과 값을 안전한 다른 값으로 대체할 수 있다. 이것을 값 대체라고 한다. 잘못된 결과를 정합성을 깨지 않는 다른 값으로 대체하는 것이다. 기본 값 또는 잘 알려진 상수, 초기값으로 바꾸는 것이다. 예를 들어, 결과 값을 누적시키는 경우 0을 반환하면 결과에 영향을 미치지 않게 된다.
그러나 값 대체는 항상 적용 가능한 것은 아니다. 대체 값이 실제로 안전한 옵션인 경웨 한해 적용할 수 있다. 이 것은 견고성과 정확성 간의 트레이드오프이다. 소프트웨어 프로그램은 예상치 못한 상황에서도 실패하지 않아야 견고하다가 할 수 있지만, 무조건 실패하지 않는 것이 항상 옳은 것은 아니다.
어떤 소프트웨어는 모든 것을 허용하는 게 어려울 수 있다. 민감하고 중요한 정보를 다루는 경우 부정확한 결과를 허용하는 것은 위험할 수 있다. 이 경우에는 예외를 발생시키는 것이 더 나은 방법일 수 있다.
안전한 방법의 하나로 정보가 제공되지 않을 경우 기본 값을 제공할 수도 있다. 설정되지 않은 환경 변수의 기본값, 설정 파일의 누락된 항목 또느 함수의 파라미터와 같은 것들은 기본 값으로 동작이 가능한 것들이다. 예를 들어 딕셔너리의 get 메서드 두 번째 파라미터에 기본 값을 설정할 수 있다.
>>> d = {'a': 1, 'b': 2}
>>> d.get('c', 3)
3
환경 변수에도 유사한 API가 있다.
>>> import os
>>> os.environ.get('MY_ENV_VAR', 'default')
'default'
혹은
>>> import os
>>> os.getenv('MY_ENV_VAR', 'default')
'default'
앞의 예제 모두 두 번쨰 파라미터를 제공하지 않으면 None을 반환한다. None이 함수에서 정의한 기본 값이기 떄문이다. 사용자 정의 함수에도 기본 값을 설정할 수 있다.
>>> def my_func(a, b=2):
... return a + b
...
>>> my_func(1)
3
예외 처리
잘못된 값을 입력하거나 누락할 경우에도 복구가 가능한 경우가 있다. 그러나 어떤 경웬느 잘못된 데이터를 사용하여 계속 실행하면 더 큰 문제가 발생할 수 있다. 이 경우에는 예외를 발생시켜서 프로그램을 중단시키는 것이 더 나은 방법일 수 있다. DbC에서 보았듯이 사전조건 검증에 실패한 것과 같은 경우이다.
그러나 입력이 잘못되었을 때만 함수에 문제가 생기는 것은 아니다. 함수는 단순히 데이터를 전달받는 것이 아니라 외부 컴포넌트에 연결되어 있으며 부작용 또한 가지고 있다.
함수 호출 실패는 함수 자체의 문제가 아니라 이러한 외부 컴포넌트 중 하나의 문제로 인한 것일 수도 있다. 이런 경우 적절하게 인터페이스를 설계하면 쉽게 디버깅할 수 있다. 함수는 심각한 오류에 대해 명확하고 분명하게 알려줘서 적절하게 해결할 수 있도록 해야 한다.
이것이 바로 예외 메커니즘이다. 예외적인 상황을 명확하게 알려주고 원래의 비즈니스 로직에 따라 흐름을 유지하는 것이 중요하다.
그러나 정상적인 시나리오나 비즈니스 로직을 예외 처리하려고 하면 프로그램의 흐름을 읽기 어려워진다. 이것은 예외를 남용하는 것이다. 예외는 예외적인 상황을 처리하는 것이다. 예외를 남용하면 코드를 읽기 어려워지고 디버깅하기 어려워진다.
마지막으로 중요한 개념이 하나 더 있다. 예외는 대게 호출자에게 잘못을 알려주는 것이다. 예외는 캡슐화를 약화시키기 때문에 신중하게 사용해야 한다. 함수에 예외가 많을수록 호출자는 함수에 대해 더 많은 것을 알아야 한다. 그리고 함수가 너무 많은 예외를 발생시킨다는 것은 문맥에서 자유롭지 않다는 것을 의미한다. 왜냐하면 호출할 때마다 발생 가능한 부작용을 염두에 두고 문맥을 유지해야 하기 때문이다.
이것은 함수가 응집력이 약하고 너무 많은 책임을 가지고 있다는 것을 의미한다. 만약 함수에서 너무 많은 예외를 발생시킨다면 함수를 분리하고 책임을 분산시켜야 한다.
다음은 파이썬의 예외와 관련된 몇 가지 권장 사항이다.
올바른 수준의 추상화 단계에서 예외 처리
예외는 오직 한 가지 일을 하는 함수의 한 부분이어야 한다. 함수가 처리하는 (또는 발생시키는) 예외는 함수가 캡슐화하고 있는 로직에 대한 것이어야 한다.
다음은 서로 다른 수준의 추상화를 혼합하는 예제이다. 애플리케이션에서 디코딩한 데이터를 외부 컴포넌트에 전달하는 객체를 상상해보자. deliver_event 메서드를 중점적으로 살펴보자.
class DataTransport:
"""다양한 수준의 예외를 처리하는 예"""
_RETRY_BACKOFF: int = 5
_RETRIES_LIMIT: int = 3
def __init__(self, connector: Connector) -> None:
self._connector = connector
self.connection = None
def deliver_event(self, event: Event):
try:
self.connect()
data = event.serialize()
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._RETRIES_LIMIT):
try:
self.connection = self._connector.connect()
except ConnectionError as e:
logger.info(
"%s: %s 초 후에 재시도합니다...", e, self._RETRY_BACKOFF
)
time.sleep(self._RETRY_BACKOFF)
self._RETRY_BACKOFF *= 2
else:
return self.connection
raise ConnectionError(
f"{self._RETRIES_LIMIT}번의 재시도 실패"
)
def send(self, data: bytes):
return self.connection.send(data)
deliver_event() 메서드가 예외를 처리하는 방법에 초점을 맞추어 분석해보자.
ValueError와 ConnectionError는 무슨 관계일까? 별로 없다. 이렇게 매우 다른 유형의 오류는 살펴봄으로써 책임을 어떻게 분산해야 하는지에 대한 아이디어를 얻을 수 있다. ConnectionError는 connect 메서드 내에서 처리되어야 한다. 이렇게 하면 행동을 명확하게 분리할 수 있다. 예를 들어 메서드가 재시도를 지원하는 경우 그 안에서 예외처리를 할 수 있다. 반대로 ValueError는 event의 decode 메서드에 속한 에러이다. 이렇게 구현을 수정하면 deliver_event 예외를 catch할 필요가 없다. 이전에 걱정했던 예외는 각각의 내부 메서드에서 처리하거나 의도적으로 예외가 발생하도록 내버려둘 수 있다.
따라서 deliver_event 메서드는 다른 메서드나 함수로 분리해야한다. 연결을 관리하는 것은 작은 함수로 충분해야 한다. 이 함수를 연결을 맺고, 발생 가능한 예외를 처리하고 로깅을 담당한다.
def connect_with_retry(
connector: Connector, retrie_n_times: int = 3, retry_backoff: int = 5
) -> Connection:
"""<connector>를 사용해 연결을 시도함.
연결에 실패할 경우 <retry_n_times>만큼 재시도하며,
재시도 사이의 간격은 <retry_backoff>초로 설정함.
연결에 성공하면 Connection 객체를 반환
재시도 횟수를 초과하며 연결에 실패하면 ConnectionError 예외 발생
:param connector: connect() 메서드를 가진 객체
:param retrie_n_times: 재시도 횟수
:param retry_backoff: 재시도 간격
:return: Connection 객체
:raise ConnectionError: 재시도 횟수를 초과하며 연결에 실패한 경우
"""
for _ in range(retrie_n_times):
try:
return connector.connect()
except ConnectionError as e:
logger.info(
"%s: %s 초 후에 재시도합니다...", e, retry_backoff
)
time.sleep(retry_backoff)
exc = ConnectionError(
f"{retrie_n_times}번의 재시도 실패"
)
logger.exception(exc)
raise exc
이제 원래 deliver_event 메서드에서 이 함수를 호출하면 된다. event의 ValueError 예외에 대해서도 새로운 객체로 분리할 수 있지만 일단 다른 메서드로 분리하는 것으로 대신한다. 이러한 두 가지를 적용한 새 메서드는 훨씬 더 작고 읽기 쉽다.
class DataTransport:
"""추상화 수준에 따라 예외 분리를 한 객체"""
_RETRY_BACKOFF: int = 5
_RETRIES_LIMIT: int = 3
def __init__(self, connector: Connector) -> None:
self._connector = connector
self.connection = None
def deliver_event(self, event: Event):
self.connection = connect_with_retry(
self._connector,
retrie_n_times=self._RETRIES_LIMIT,
retry_backoff=self._RETRY_BACKOFF,
)
self.send(event)
def send(self, event: Event):
try:
return self.connection.send(event.decode())
except ValueError as e:
logger.error("%r를 전송할 수 없습니다: %s", event, e)
raise
이제 예외 처리가 어떻게 관심사를 분리하는지 살펴보자. 처음 작성한 코드에서는 모든것이 섞여 있고 명확하게 분리되어 있지 않다. 그래서 연결 기능에 집중하여 connect_with_retry 함수를 만들고 그 함수 내에서 ConnectionError를 처리하도록 했다. 반면에 ValueError는 연결 기능의 일부가 아니므로 여전히 send 메서드에 남아있다.
엔드 유저에게 Traceback 노출 금지
이것은 보안을 위한 고려 사항이다. 예외를 처리할 때 오류의 발생 사실이 너무 중요하다면 그것을 전파하는 것도 가능하다. 그러나 검토된 시나리오이거나 견고함보다 정확성이 중요한 경우 등의 상황에선느 프로그램을 바로 중단할 수도 있다.
특정 문제를 나타내는 예외가 발생한 경우 문제를 효율적으로 해결할 수 있도록 traceback 정보, 메시지 및 기타 수집 가능한 정보를 최대한 로그로 남기는 것이 중요하다. 그러나 이러한 정보를 엔드 유저에게 노출시키면 안 된다. 왜냐하면 파이썬의 traceback은 매우 풍부하고 유용한 디버깅 정보를 포함하고 있다. 이러한 정보는 악의적인 사용자가 애플리케이션을 공격하는 데 사용할 수 있기 때문이다.
예외가 전파되도록 하는 경우 문제를 알리려면 “알 수 없는 문제가 발생했습니다.” 또는 “페이지를 찾을 수 없습니다.”와 같은 일반적인 메시지를 표시하는 것이 좋다.
비어있는 except 블록 지양
이것은 파이썬의 안티패턴 중에서도 가장 악마 같은 패턴(REAL 01)이다. 일부 오류에 대비하여 프로그램을 방어하는 것은 좋은 일이지만 너무 방어하는 것은 더 심각한 문제로 이어질 수 있다. 예를 들어, 다음과 같은 코드를 보자.
try:
do_something()
except:
pass
이 코드는 어떤 문제가 발생하더라도 무시하고 계속 실행하도록 되어있다. 또한 except 블록에는 어떤 예외도 지정되어 있지 않다. 보다 구체적인 예외(예: ValueError, TypeError, ZeroDivisionError 등)를 지정하면 사용자는 무성르 기대하는지 알게 되기 때문에 프로그램을 더욱 유지보수하기 쉽다. 또한 다른 종류의 예외가 발생하면 바로 버그로 판단할 수 있으므로 쉽게 대응할 수 있다.
예외를 자체적으로 처리하는 것의 가장 간단한 예로 예외 상황을 로깅하는 것이 있다. logger.exception 또는 logger.error를 사용하여 발생한 일의 전체 컨텍스트를 제공해야 한다. 다른 방법으로는 기본 값을 반환하는 것이다. 여기서 말하는 기본 값은 오류의 발견하지 전이 아니라 오직 오류를 발견한 뒤에만 사용하는 값이다. 또는 기존 오류와 다른 새로운 예외를 발생시킬 수도 있다.
pass를 사용해서 그것이 의미하는 바를 알 수 없게 하지 말고 명시적으로 해당 오류를 무시하려면 contextlib.suppress를 사용하자.
import contextlib
with contextlib.suppress(KeyError):
process_data()
다시 말하지만, 여기서도 Exception을 사용하면 모든 예외가 무시되므로 반드시 구체적인 예외를 지정해야 한다.
원본 예외 포함
오류 처리 과정에서 기존 오류와 다른 새로운 오류를 발생시키고 오류 메세지를 변경할 수도 있다. 이런 경우 어떤 오류가 있었는지에 대한 정보를 포함하는 것이 좋다. PEP-3134(Exception Chaining and Embedded Tracebacks)에서 소개된 raise
예를 들어 기본 예외를 사용자 정의 예외로 래핑하고 싶다면 루트 예외에 대한 정보를 다음과 같이 포함할 수 있다.
class InternalDataError(Exception):
"""업무 도메인 데이터의 예외"""
def process(data_dictionary, record_id):
try:
return data_dictionary[record_id]
except KeyError as e:
raise InternalDataError(
f"record {record_id}는 데이터에 존재하지 않습니다"
) from e
이 구문을 사용하면 traceback에 방금 발생한 오류에 대해서 보다 많은 정보를 전달할 수 있다. 이 정보들은 디버깅할 떄 크게 도움이 된다.
파이썬에서 어설션(assertion) 사용하기
어설션은 절대로 일어나지 않아야 하는 상황에 사용되므로 assert 문에 사용된 표현식은 불가능한 조건을 의미한다. 이 상태과 된다는 건 소프르웨어에 결함이 있다는 것을 의미한다.
예외를 직접 처리하는 방식과 비교해보면, 어떤 특정 상황이 발생했을 때는 더 이상 프로그램을 실행하는 것이 의미가 없을 수도 있다. 더 이상 극복할 수 없는 상황이라면 프로그램을 중단하는 것이 더 나은 방법일 수 있다. 이것이 바로 어설션이다.
문법상으로 어설션은 항상 참이어야만 하는 Boolean 조건이다. 만약 조건이 거짓이라면 AssertionError 예외가 발생한다면, 프로그램에서 극복할 수 없는 치명적인 결함이 있다는 것을 의미한다.
이러한 이유로 어설션을 비즈니스 로직과 섞거나 소프트웨어의 제어 프름 메터니즘으로 사용해서는 안된다. 다음 예제는 좋지 않은 생각이다.
try:
assert condition.holds(), "조건에 맞지 않음"
except AssertionError as e:
alternative_action()
Tip: AssertionError는 더 이상 처리가 불가능한 상황을 의미하므로 catch 후에 프로그램을 계속 실행하면 안된다. 특정 조건에 대한 검사가 필요하다면 보다 구체적인 오류를 발생시키도록 하자.
파이썬 프로그램을 -O 플래그와 함께 설정하면 assert 문을 무시할 수 있지만, 앞서 설명한 이유로 권장하지 않는다.
Tip: 상용 파이썬 프로그램에 -O 옵션을 사용하지 말자. 결함을 수정하지 위해 어설션을 활용하는 것이 좋기 때문이다.
앞의 예제 코드가 나쁜 또 다른 이유는 AssertionError를 처리하는 것 외에도 어설션 문장이 함수라는 것이다. 함수 호출은 부작용을 가질 수 있으며 항상 반복 가능하지는 않다(사실 condition.holds()를 다시 호출했을 때 같은 결과가 나올 것이라는 보장이 없다). 또한 디버거를 사용해 해당 라인에서 중지하여 오류 결과를 편리하게 볼 수 없으며, 다시 함수를 호출한다 하더라고 잘못된 값이었는지 알 수 없다.
보다 나은 방법은 코드를 줄이고 유용한 정보를 추가하는 것이다.
result = condition.holds()
assert result > 0, "조건에 맞지 않음: %s" % result
Tip: 어설션에서 함수를 직접 호출하지 말고, 로컬 변수에 저장한 다음에 비교하자.
어설션도 예외 처리로 볼 수 있을까? if 문과 raise exception 구문을 사용하면 되는데, 어설션을 사용하는 이유는 뭘까? 여기에는 미묘한 차이가 있다. 일반적으로 예외(exception)는 예상하지 못한 상황을 처리하지 위한 것이고, 어설션(assertion)은 정확성(correctness)을 보장하기 위해 스스로 체크하기 위한 것이다.
이러한 이유로 예외를 발생시키는 것이 assert 구문을 사용하는 것보다 훨씬 일반적이다. Assert 구문은 항상 변하지 않는 고정된 조건에 대해서 검증할 때 사용된다. 이 조건이 때진다면 무엇인가 잘못 구현되었거나 문제가 발생했음을 의미한다.
관심사의 분리
이것은 여러 수준에서 적용되는 디자인 원칙이다. 저수준의 디자인(코드)에 관한 것이 아니라 더 높은 수준의 추상화와도 관련이 있다.
책임이 다르면 컴포넌트, 계층 또는 모듈로 분리되어야 한다. 프로그램의 각 부분은 기능의 일부분(관심사)에 대해서만 책임을 지며 나머지 부분에 대해서는 알 필요가 없다.
소프트웨어 디자인에서 관심사 분리의 목표는 파급(ripple) 효과를 최소화하여 유지보수성을 향상시키는 것이다. 파급 효과는 어느 지점에서의 변화가 전체로 전파되는 것을 의미한다. 함수 정의를 약간만 변경해도 전체 프로그램이 망가질 수 있다. 이것은 코드가 너무 많은 것을 알고 있기 때문이다.
소프트웨어는 쉽게 변경될 수 있어야 한다. 애플리케이션의 나머지 부분에 대한 영향성을 최소화하면서 코드를 수정하거나 리팩토링을 하고 싶다면 적절한 캡슐화가 필요하다.
응집력(cohesion)과 결합력(coupling)
응집력은 객체가 작고 잘 정의된 목적을 가져야 하며 가능하면 작아야 한다는 것을 의미한다. 객체의 응집력이 높을수록 더 유용하고 재사용 가능하며 유지보수하기 쉬운 코드를 작성할 수 있다.
결합력이란 두 개 이상의 객체가 서로 어떻게 의존하는지를 나타낸다. 이 종속성은 제한을 의미한다. 객체 또는 메서드의 두 부분이 서로 의존적이라면 다음과 같은 바람직하지 않은 상황이 발생한다.
- 낮은 재사용성: 만약 어떤 함수가 특정 객체에 지나치게 의존하는 경우 또는 너무 많은 파라미터를 가진 경우 이 함수는 해당 객체에 결합되게 된다. 즉 다른 상황에서는 이 함수를 사용하기가 매우 어렵다는 뜻이다. 그렇게 하려면 매우 제한적인 인터페이스를 따르는 적절한 파라미터를 찾아야만 한다.
- 파급(ripple) 효과: 너무 가깝게 결합된 객체는 하나의 객체를 수정하면 다른 객체에도 영향을 미친다.
- 낮은 수준의 추상화: 두 함수가 너무 가깝게 관련되어 있으면 서로 다른 추상화 레벨에서 문제를 해결하기 어렵기 때문에 관심사가 분리되어 있다고 보기 어렵다.
Tip: 일반적으로 잘 정의된 소프트웨어는 높은 응집력과 낮은 결합력을 가진다. (high cohesion and low coupling)
개발 지침 약어
이 섹션에서는 좋은 디자인 아이디어를 주는 몇 가지 원칙을 검토한다. 요점은 좋은 소프트웨어 관행을 약어를 통해 쉽게 기억하자는 것이다.
DRY/OAOO
DRY(Don’t Repeat Yourself)는 코드의 중복을 피하라는 것이다. 코드를 작성할 때 중복을 피하면 유지보수성이 향상된다. 코드를 수정할 때 중복된 코드를 수정할 필요가 없기 때문이다.
OAOO(Once and Only Once)는 코드에 있는 지식은 단 한번, 단 한 곳에 정의되어야 한다는 것이다. 이것은 DRY와 밀접한 관련이 있다. 코드를 변경하려고 할 때 수정이 필요한 곳은 단 한 곳이어야 한다는 것을 의미한다. 그렇지 않다는 것은 잘못된 시스템의 징조이다.
코드 중복은 유지보수에 직접적인 영향을 미친다.
- 오류가 발생하기 쉽다: 어떤 로직이 코드 전체에 여러 번 반복되어 있는데 수정을 한다고 해보자. 이때 하나라도 빠뜨리면 오류가 발생할 수 있다.
- 비용이 비싸다: 코드를 수정할 때 중복된 코드를 수정해야 한다면 비용이 더 많이 든다.
- 신뢰성이 떨어진다: 코드를 수정할 때 중복된 코드를 수정해야 한다면 신뢰성이 떨어진다. 사람이 모든 위치를 기억하기는 어렵기 때문이다.
YAGNI
YAGNI(You Aren’t Gonna Need It)는 과잉 엔지니어링을 하지 않기 위해 계속 염두에 두어야 하는 원칙이다. 코드를 작성할 때 미래에 필요할 것 같은 기능을 추가하지 말라는 것이다. 미래에 필요할 것 같은 기능을 추가하는 것은 코드를 복잡하게 만들고 유지보수성을 떨어뜨린다.
나중에 필요할지 모르는 기능을 위해 과도하게 일반화를 위해 시간을 낭비하지 말자. 지금 생성한 클래스는 현재의 요구사항에 편향되었을 가능성이 높으므로 올바른 추상화가 되지 않을 수 있다.
가장 좋은 방법은 현재 필요한 부분만 작성하는 것이다. 나중에 새로운 요구 사항이 발생하면 베이스(base) 클래스를 만들고 일부 메서드를 추상황할 수 있으며, 아마도 나중에는 어떤 디자인 패턴이 출현하는 것도 발견하게 될 것이다. 이것이 바로 객체 지향에서 동작하는 상향식(bottom-up) 설계의 원리이다.
KIS
KIS(Keep It Simple)는 이전 원칙과 매우 흡사하다. 소프트웨어 컴포넌트를 설계할 때 과잉 엔지니어링을 피해야 한다. 디자인이 단순할수록 유지 관리가 쉽다는 것이다.
높은 수준에서 컴포넌트를 생각해보자. 정말 모든 기능이 필요할까? 이 모듈은 정말 지금 당장 완전히 확장 가능해야 할까? “지금 당장” 이라는 부분을 다시 살펴보자. 어쩌면 해당 컴포넌트를 확장 가능하게 만든다 하여도 지금은 적절한 시기가 아니거나, 적절한 추상화를 하기에 아직은 충분한 정보가 없는 경우일 수도 있다.
일반적으로 코드의 단순함이란 문제에 맞느 ㄴ갖아 작은 데이터 구조를 사용하는 것을 의미한다. 대부분은 표준 라이브러리에서 찾을 수 있다.
다음 클래스는 키워드 파라미터에서 제공되는 값들은 속석으로 초기화하는데 다소 복잡한 구조로 되어 있다.
class ComplicatedNamespace:
"""프로퍼티를 복잡한 방식으로 초기화하는 객체"""
ACCEPTED_VALUES = {"id_", "name", "created_at"}
@classmethod
def init_with_data(cls, **data):
"""키워드 파라미터에서 값을 추출해 객체를 초기화함"""
isntance = cls()
for key, value in data.items():
if key in cls.ACCEPTED_VALUES:
setattr(isntance, key, value)
return isntance
객체를 초기화 하기 위해 추가적인 클래스 메서드를 만드는 것은 꼭 필요해 보이지 않는다. 반복을 통해 setattr을 호출하는 것은 상황을 더 이상하게 만든다. 사용자에게 노출된 인터페이스 또한 분명하지 않다.
>>> cn = ComplicatedNamespace.init_with_data(id_=42, name="foo", created_at="2020-01-01")
>>> cn.id_, cn.name, cn.created_at
(42, 'foo', '2020-01-01')
>>> hasattr(cn, "ACCEPTED_VALUES")
False
사용자는 초기화를 위해 init_with_data라는 일반적이지 않은 메서드 이름을 알아야 하는데 이것 또한 불편한 부분이다. 파이썬에선 다른 객체를 초기화 할 때 init 메서드를 사용한다. 이것은 파이썬의 특별한 메서드이며, 이 메서드를 사용하면 객체를 초기화하는 것이 더욱 직관적이고 편리하다.
class Namespace:
"""키워드 인자(keyword argument)를 사용하여 객체를 생성"""
ACCEPTED_VALUES = {"id_", "name", "created_at"}
def __init__(self, **data):
for attr_name, attr_value in data.items():
if attr_name in self.ACCEPTED_VALUES:
setattr(self, attr_name, attr_value)
파이썬에서 코드를 추상화하는 일반적인 방법은 데코레이터를 사용하는 것이다. 그러나 작은 섹션의 중복을 피하려는 경우는 어떻게 하는 것이 좋을까? 예를 들어 세 줄짜리 코드를 생각해보자. 이 경우 데코레이터를 작성하는 데 더 많은 라인이 필요하고 나중에 적용하려고 할 때도 또 다른 문제를 일으킬 지도 모른다. 이 경우 상식적인 선에서 실용적인 방법을 사용하는 것이 좋다.
EAFP/LBYL
EAFP(Easier to Ask Forgiveness than Permission)는 허락보다는 용서를 구하는 것이 쉽다는 뜻이다. (무엇을 하지 위해 미리 허락을 구하는 것보다는 일단 실행한 뒤 발생한 오류에 대해서 용서를 구하는 것이 더 쉽다는 뜻이다.) 반면에 LBYL(Look Before You Leap)는 도약하기 전에 미리 살피라는 뜻이다. (어떤 일을 하기 전에 미리 조건을 검사하라는 뜻이다.)
EAFP는 일단 코드를 실행하고 실제 동작하지 않을 경우에 대응한다느 뜻이다. 일반적으로는 EAFP 방식으로 구현하면 일단 코드를 실행하고 발생한 예외를 catch하고 except 블록에서 바로 잡는 코드를 실행하게 된다.
LBYL는 그 반대이다. 이름에서 알 수 있듯이 도약하기 전에 먼저 무엇을 사용하려고 하는지 확인한다. 예를 들어 파일을 사용하기 전에 먼저 파일을 사용할 수 있는지 확인하는 것이다.
if os.path.exists(filename):
with open(filename) as f:
...
EAFP 버전은 다음과 같다.
try:
with open(filename) as f:
...
except FileNotFoundError as e:
...
예외가 없는 C 언어 같은 경우라면 LBYL 방식이 더 좋을 수도 있다. 그러나 파이썬에서는 대부분의 경우 EAFP 방식이 보다 더 의미를 명확하게 드러낸다. EAFP 방식으로 작성된 코드는 사전에 검증하는 대신에 예외 처리가 필요한 부분으로 바로 이동하기 때문에 더 직관적이다. 다시 말해서, 마지막 에제를 보면 파일을 열고 처리한다. 파일이 없는 경우 예외 구문에서서 해당 오류를 처리한다. LBYL 방식으로 구현한 앞의 예제에서는 파일이 존재하는지 먼저 확인하고 이후 작업을 진행한다. 이것 또한 분명하다고 주장할 수 있지만, EAFP 방식이 더 직관적이다. 왜나하면 존재 여부를 체크한 파일은 다른 파일이거나, 다른 계층에서 작업을 하다가 남은 파일일 수도 있기 때문이다. EAFP 방식으로 구현한 코드가 에러를 발생시킬 가능성이 보다 낮고 한 눈에 이해하기 쉽다.
만약 어떤 것을 사용할지 망설인다면, EAFP 방식으로 구현하고 예외를 처리하는 것이 좋다.
상속
객체 지향 소프트웨어를 디자인할 때 다형성, 상속, 캡슐화 같은 주요 개념을 어떻게 사용하여 문제를 해결할 것인지에 대한 논쟁이 오랫동안 있어왔다.
아마도 가장 일반적으로 사용되는 개념은 상속일 것이다. 상속은 강력하고 유용한 개념이지만, 위험도 있다. 가장 주된 위험은 부모 클래스를 확장하여 새로운 클래스를 만들때마다 부모와 강력하게 결합된 새로운 클래스가 생긴다는 점이다. 이미 설명했듯이 소프트웨어를 설계할 때 결합력(coupling)을 최소화해야 한다.
상속과 관련해 개발자들이 가장 많이 사용하는 기능은 코드 재사용이다. 코드 재사용을 염두에 둬야 하지만 단지 부모 클래스에 있는 메서드를 공짜로 얻을 수 있기 때문에 상속을 하는 것은 좋지 않은 생각이다. 코드를 재사용하는 올바른 방법은 여러 상황에서 동작 가능하고 쉽게 조합할 수 있는 응집력 높은 객체를 사용하는 것이다.
상속이 좋은 선택인 경우
파생 클래스를 만드는 것은 양날의 검이 될 수 있으므로 주의해야 한다. 부모 클래스의 메서드를 공짜로 얻을 수 있지만, 다른 한편으로 모든 것을 새로운 클래스로 가져왔기 때문에 불필요하게 너무 많은 기능을 추가하게 되는 단점도 있다.
새로운 하위 클래스를 만들 때 클래스가 올바르게 정의되었는지 확인하고 싶다면 상속된 모든 메서드를 실제로 사용할 것인지 생각해보는 것이 좋다. 만약 대부분의 메서드를 필요로 하지 않고 재정의하거나 대체해야 한다면 다음과 같은 이유로 설계상의 실수라고 할 수 있다.
- 상위 클래스는 잘 정의된 인터페이스 대신 막연한 정의와 너무 많은 책임을 가졌다.
- 하위 클래스는 확장하려고 하는 상위 클래스의 적절한 세분화가 아니다.
상속을 잘 사용한 좋은 예는 다음과 같다. public 메서드와 속성을 인터페이스로 잘 정의한 클래스가 있다. 그리고 이 클래스와 같은 기능을 하지만 일부 기능을 수정하거나 새로운 것을 추가하고 싶어서 상속을 한 경우다.
파이썬 표준 라이브러리에서 상속의 좋은 예를 찾을 수 있다. 예를 들어 http.server 패키지에는 BaseHTTPRequestHandler 부모 클래스와 이 클래스의 일부 기능을 추가하거나 변경하기 위해 확장하는 SimpleHTTPRequestHandler 하위 클래스가 있다.
마지막으로 상속의 또 다른 예는 예외(Exception) 이다. 파이썬의 표준 예외는 Exception 클래스를 상속한다. 이것은 except Exception: 같은 일반 구문을 통해 모든 에러를 catch할 수 있게 해준다. 중요한 것은 모든 예외가 Exception에서 상속받은 클래스라는 것이다. 이것은 requests 같이 잘 알려진 라이브러리에서도 사용된다. 예를 들어 HTTPError는 RequestException을 상속받고, RequestException은 IOError를 상속받는다.
상속 안티패턴
이전 섹션을 한 단어로 요약한다면 전문화가 될 것이다. 상속을 올바르게 사용했다면 파생된 클래스는 부모 클래스와 유사한 기능을 전문화 했거나, 부모 클래스보다 좀 더 구체화된 추상화를 해야 한다.
부모 (또는 기본) 클래스는 새롭게 파생된 클래스의 public 선언의 일부를 담당한다. 왜냐하면 부모 클래스의 public 메서드가 그대로 자식 클래스로 상속되기 때문이다. 이러한 이유로 자식 클래스의 public 메서드는 부모 클래스에서 정의한 것과 일관성을 가져야 한다.
예를 들어, BaseHTTPRequestHandler를 상속 받은 클래스가 handle() 메서드를 구현했다면 이것은 부모 메서드를 오버라이딩한 것이므로 일관성에 아무 문제가 없다. 또는 자식 클래스에서 HTTP 요청과 관련된 것으로 보이는 메서드가 추가되었다면 이것 역시 부모와 자식 간에 일관성을 갖고 있다고 생각할 수 있다. 그러나 process_purchase()와 같이 HTTP와 무관해 보이는 메서드가 추가되었다면 이것은 올바른 상속이라고 볼 수 없다.
예제를 통해 이 문제를 보다 구체적으로 살펴보자. 여러 고객에게 다양한 정책을 적용하는 보험 시스템을 생각해보자. 먼저 이후 처리를 하기 전에 정책을 적용하려는 대상의 고객 정보를 메모리로 불러와야 한다. 새로운 고객 정보를 저장하고, 변경된 정책을 반영하고, 일부 데이터를 수정하는 등의 기본적인 연산이 필요하다. 또한 어떤 작업 중에 정책이 변경되면 해당 트랜잭션에 묶인 고객들에게 변강사항을 반영할 수 있도록 배치 작업 또한 지원해야 한다.
필요한 데이터 구조를 생각해 보면 특정 고객의 정보에 상수 시간에 접근할 수 있어야 한다. 따라서 policy_transaction[customer_id]처럼 구현하는 것이 멋진 인터페이스처럼 보인다.
이러한 요건을 생각하면 첨자형(subscriptable) 객체, 더 나아가 딕셔너리 타입의 객체를 상속받는 것이 좋아보인다.
class TransactionalPolicy(collections.UserDict):
"""잘못된 상속의 예"""
def change_in_policy(self, customer_id, **new_policy_data):
self[customer_id].update(**new_policy_data)
이렇게 클래스를 설계하면, 이제 customer_id로 해당 고객의 정보를 조회할 수 있다.
>>> policy = TransactionalPolicy({"client001": {"fee": 1000}})
>>> policy["client001"]
{'fee': 1000}
>>> policy.change_in_policy("client001", new_fee=2000)
>>> policy["client001"]
{'fee': 2000}
이렇게 함으로써 처음에 원했던 기능을 구현할 수 있게 되었지만, 비용 측면에서는 어떨까?
이제 이 클래스에는 불필요한 수많은 메서드가 포함되어 있다.
>>> dir(policy)
[ # 간략화를 위해 매직 메서드와 특수 메서드는 생략...
"change_in_policy", "clear", "copy", "data", "fromkeys", "get", "items", "keys", "pop", "popitem", "setdefault", "update", "values"
]
이 디자인에는 적어도 두 가지의 주요 문제점이 있다. 하나는 계층 구조가 잘못된 것이다. 기본 클래스에서 새 클래스를 만드는 것은 말 그대로 그것이 개념적이고 확장되고 세부적인 것이라는 것을 의미한다. TransactionalPolicy라는 이름만 보고 어떻게 딕셔너리 타입이라는 것을 알 수 있을까? 사용자가 객체의 public 인터페이스를 통해 노출된 public 메서드들을 확인하게 되면 전문화가 잘못되었다는 것을 알 수 있다.
다른 하나는 결합력(coupling)이다. TransactionalPolicy 클래스는 이제 모든 메서드를 포함한다. TransactionalPolicy에 pop() 또는 items()와 같은 메서드가 실제로 필요할까? 필요하지 않지만 그런 메서드가 포함되어 있다. 이것들은 public 메서드이므로 이 인터페이스의 사용자는 부작용이 있을지도 모르는 이 메서드들을 호출할 수 있다.
이것이 구현 객체를 도메인 객체와 혼합할 때 발생하는 문제이다. 딕셔너리는 특정 유형의 작업에 적합한 객체 또는 데이터 구조로서 다른 데이터 구조와 마찬가지로 트레이드오프가 있다. TransactionalPolicy는 특정 도메인의 정보를 나타내는 것이므로 해결하려는 문제의 일부분에 사용되는 엔티티여야만 한다.
Tip: 동일한 계층에 자료구조를 구현한 것과 도메인 클래스(domain class)를 혼합해서 사용하지 말자.
이 같은 계층 구조는 올바르지 않다. 단지 첨자 기능을 얻기 위해 딕셔너리를 확장하는 것은 충분한 확장의 근거가 되지 않는다. 구현 클래스(implementing class)는 새롭거나, 보다 구체적인 기능을 구현하는 경우에만 사용해야 한다. 즉, 좀 더 구체적이거나 약간 수정된 딕셔너리가 필요한 경우에만 확장해야 한다. 동일한 원칙이 도메인 클래스에 대해서도 적용된다.
올바른 해결책은 컴포지션을 사용하는 것이다. 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].update(**new_policy_data)
def __getitem__(self, customer_id):
return self._data[customer_id]
def __len__(self):
return len(self._data)
이 방법은 개념적으로 정확할 뿐만 아니라 확장성도 뛰어나다. 현재 딕셔너리인 데이터 구조를 향후 변경하려고 해도 인터페이스만 유지하면 사용자는 영향받지 않는다. 이는 결합력을 낮추고 파급 효과를 최소화하며 보다 나은 리팩토링(단위 테스트를 변경하지 않고도)을 가능하게 한다.
파이썬의 다중상속
파이썬은 다중 상속을 지원한다. 부적절하게 사용된 상속은 디자인 문제를 유발하며 특히 다중 상속을 잘못 사용하면 더 큰 문제를 초래할 수도 있다. 다중 상속은 양날의 검이다. 어떤 경우에는 매우 유익할 수 있지만, 어떤 경우에는 매우 위험할 수도 있다.
다중 상속이 어떻게 동작하는지, 복잡한 계층구조에서 메서드 호출은 어떻게 결정되는지 알아보자
메서드 결정 순서
다이아몬드 문제라는 것을 들어본 적이 있을 것이다. 다이아몬드 문제는 다중 상속에서 발생하는 문제이다. 다이아몬드 문제는 다음과 같은 계층 구조에서 발생한다.
class A:
def func(self):
print("A")
class B(A):
def func(self):
print("B")
class C(A):
def func(self):
print("C")
이제 B와 C를 상속받는 D 클래스를 만들어 보자.
class D(B, C):
pass
D 클래스의 인스턴스를 만들고 func() 메서드를 호출해보자.
>>> d = D()
>>> d.func()
B
D 클래스는 B와 C를 상속받았다. 그러나 B와 C는 모두 A를 상속받았다. 따라서 D 클래스는 A를 두 번 상속받는다. 이것이 다이아몬드 문제이다. 이제 D 클래스의 인스턴스에서 func() 메서드를 호출하면 어떤 결과가 나올까? B 클래스의 func() 메서드를 호출할까? 아니면 C 클래스의 func() 메서드를 호출할까? 아니면 A 클래스의 func() 메서드를 호출할까?
파이썬은 이 문제를 해결하기 위해 메서드 결정 순서(Method Resolution Order, MRO)라는 것을 사용한다. MRO는 클래스의 상속 순서를 결정한다. 이 알고리즘은 메서드가 호출되는 방식을 정의한다. 구체적으로 클래스에게 결정 순서를 직접 물어볼 수 있다.
>>> [cls.__name__ for cls in D.mro()]
['D', 'B', 'C', 'A', 'object']
이 결과를 보면 D 클래스의 MRO는 D, B, C, A, object 순서로 결정되었다는 것을 알 수 있다. 이것은 D 클래스의 인스턴스에서 func() 메서드를 호출할 때 B 클래스의 func() 메서드가 호출된다는 것을 의미한다.
믹스인(mixin)
믹스인은 코드를 재사용하기 위해 일반적인 행동을 캡슐화해 놓은 부모 클래스이다. 일반적으로 믹스인 클래스 자체만으로 유용하지 않고, 믹스인 클래스만 확장하는 경우에도 유용하지 않다. 보통은 다른 클래스와 믹스인 클래스를 다중 상속하고, 믹스인 클래스의 메서드와 속성을 다른 클래스에서 활용한다.
문자열을 받아서 하이픈(-)으로 구분된 값을 반환하는 파서를 생각해보자.
class BaseTokenizer:
"""문자열을 받아서 하이픈(-)으로 구분된 값을 반환하는 파서"""
def __init__(self, str_token):
self.str_token = str_token
def __iter__(self):
yield from self.str_token.split("-")
>>> tk = BaseTokenizer("32-256-100")
>>> list(tk)
['32', '256', '100']
이제 기본 클래스를 변경하지 않고 값을 대문자로 변환해보자. 많은 클래스가 BaseTokenizer를 확장했고 모든 클래스를 바꾸고 싶지는 않다고 가정해보자. 이런 경우에 새로운 클래스를 만들어 혼합(mix)할 수 있다.
class UpperIterableMixin:
"""모든 토큰을 대문자로 변환하는 믹스인"""
def __iter__(self):
return map(str.upper, super().__iter__())
class Tokenizer(UpperIterableMixin, BaseTokenizer):
"""BaseTokenizer를 확장하고 UpperIterableMixin을 혼합한 클래스"""
pass
새로운 Tokenizer 클래스는 매우 간단하다. 믹스인을 이용하기 떄문에 새로운 코드가 필요 없다. 이러한 유형의 혼합은 일종의 데코레이터 역할을 한다. 방금 본 것을 바탕으로 Tokenizer는 믹스인에서 __iter__
를 호출하고 __iter__
는 다시 super()를 호출하여 변환을 마친 다음에 BaseTokenizer에 전달한다. 이때는 이미 대분자를 전달하기 때문에 원하는 결과를 얻어낼 수 있다.
함수와 메서드의 인자
이 섹션에서는 파이썬 함수의 인자 전달 메커니즘을 살펴보고 소프트웨어 엔지니어링의 모범 사례에서 발견되는 일반적인 원칙을 살펴본다. 그리고 최종적으로 이 둘의 개념을 함께 검토한다.
파이썬의 함수 인자 동작방식
파이썬이 파라미터를 처리하는 광정을 이해하면 일반적인 규칙을 보다 쉽고 완전하게 이해할 수 있으며, 인자를 처리할 때 좋은 패턴이나 관용구가 무엇인지 쉽게 결론을 내릴 수 있다.
인자는 함수에 어떻게 복사되는가
파이썬은 항상 모든 인자를 값에 의해 전달(pass by value)해야 한다. 함수에 값을 전달하면 함수의 서명에 있는 변수에 할당하고 나중에 사용한다. 함수는 파라미터의 데이터 타입에 따라 값을 변경하기도 하고 변경하지 않아도 된다. 만약 변경 가능한(mutable) 객체를 전달했는데 함수 안에서 값을 변경했다면, 실제로 파라미터 내용이 변경되는 부작용이 발생한다.
다음 코드에서 차이점을 확인할 수 있다.
>>> def function(arg):
... arg += " in function"
... print(arg)
...
>>> immutable = "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 +=
반면에 변형(mutate) 객체인 리스트를 전달하면 해당 문장은 다른 의미를 갖는다(실제로는 lkist의 extend()를 호출하는 것과 같다). 이 연산자는 원래 리스트 객체에 대한 참조를 통해 값을 수정하므로 함수 외부에서도 값을 수정할 수 있다.
두 번째 경우를 좀 더 살펴보면, list의 참조 값이 함수에 값 형태로 (pass by a value) 전달된다. 하지만 그 값 자체가 참조이기 때문에 원본 list 객체를 변경하고 있으므로 함수 호출이 끝난 뒤에도 변경된 값이 남아있게 된다. 이 상황은 다음의 코드를 실행한 것과 비슷하다.
>>> a = list(range(5))
>>> b = a # 변형 객체에 대한 참조 값을 할당.
>>> b.append(99)
>>> b
[0, 1, 2, 3, 4, 99]
>>> a
[0, 1, 2, 3, 4, 99] # b에서 추가한 99 값이 a에도 추가되어 있다.
이렇게 mutable 유형의 파라미터를 사용하면 예상치 못한 부작용을 유발할 수 있으므로 가급적 다른 대안을 찾아야 한다.
Tip. 함수 인자를 함수 안에서 변경하지 말자. 최대한 함수 호출을 통해 발생할 수 있는 부작용을 회피하자.
가변인자
파이썬은 다른 언어와 마찬가지로 가변 인자를 사용할 수 있는 내장 함수와 구조를 가지고 있다. C에서 printf 함수와 유사한 구조를 갖는 문자열 보간 함수를 생각해보자. 이 함수는 문자열과 문자열에 대응하는 값들을 받아서 문자열에 포함된 특정 패턴을 찾아서 값을 대체한다.
>>> def printf(format, *args):
... print(format % args)
...
>>> printf("Hello %s", "world")
Hello world
>>> printf("Hello %s %s", "world", "again")
Hello world again
파이썬에서 제공하는 이러한 함수를 사용하는 대신에 비슷한 방식으로 동작하는 우리 자신만의 가변 함수를 만들 수도 있다. 가변 인자를 사용하려면 패킹(packing)할 변수의 이름 앞에 별표(*)를 사용한다. 이것은 파이썬의 패킹 메커니증에 따른 것이다.
3개의 위치 인자를 갖는 함수가 있다고 가정해보자. 함수에서 기대하는 순서대로 리스트의 값을 편리하게 전달할 수 있다. 첫 번째 요소에 list[0], 두 번째 요소에 list[1], 세 번째 요소에 list[2] … 이것은 전혀 파이썬스러운 코드가 아니다. 여기서 패킹 기법을 사용하면 하나의 명령어로 전달할 수 있다.
>>> def f(first, second, third):
... print(first)
... print(second)
... print(third)
...
>>> l = [1, 2, 3]
>>> f(*l)
1
2
3
패킹하려면 다음과 같이 할당할 수 있다.
>>> a, b, c = [1, 2, 3]
>>> a
1
>>> b
2
>>> c
3
부분적인 언패킹도 가능하다.
>>> a, *b, c = [1, 2, 3, 4]
>>> a
1
>>> b
[2, 3]
>>> c
4
변수 언패킹의 가장 좋은 사용 예는 반복(iteration)이다. 일련의 요소를 반복해야 하고 각 요소가 차례로 있다면 각 요소를 반복할 때 언패킹하는 것이 좋다. 데이터베이스의 결과를 리스트로 받는 함수를 가정해보자. 이 함수는 데이터를 받아서 사용자를 생성한다. 첫 번째 구현에서는 전혀 이상적이지 않지만 레코드가 각 컬럼에 해당하는 값을 받아서 사용자를 생성한다. 두 번째 구현에서는 언패킹을 사용해 반복을 수행한다.
from dataclasses import dataclass
USERS = [
(i, f"first_name_{i}", f"last_name_{i}") for i in range(1_000)
]
@dataclass
class User:
id: int
first_name: str
last_name: str
def bad_users_from_rows(dbrows) -> list:
"""DB 레코드로부터 User를 생성하는 파이썬스럽지 않은 나쁜 코드"""
return [
User(dbrow[0], dbrow[1], dbrow[2])
for dbrow in dbrows
]
def users_from_rows(dbrows) -> list:
"""DB 레코드로부터 User 만들기"""
return [
User(user_id, first_name, last_name)
for user_id, first_name, last_name in dbrows
]
두 번째 버전이 훨씬 읽기 쉽다. 첫 번째 버전의 함수 bad_users_from_rows()는 dbrow[0], dbrow[1], dbrow[2] 전혀 무엇을 뜻하는지 알 수가 없다. 두 번째 버전의 함수 users_from_rows()는 dbrows에서 언패킹한 변수를 사용하여 의미를 명확하게 드러낸다.
또는 User 객체를 생성할 때 튜플의 모즌 위치 인자 (positional argument)를 사용할 수도 있다.
[User(*dbrow) for dbrow in dbrows]
표준 라이브러리에 있는 max 함수에서도 이러한 예를 찾아볼 수 있다. max 함수의 정의는 다음과 같다.
max(...)
max(iterable, *[, default=obj, key=func]) -> value
max(arg1, arg2, *args, *[, key=func]) -> value
인자로 이터러블(iterable) 1개만 넘기면 그 중에 가장 큰 값을 반환.
비어 있는 이터러블(iterable)을 넘겼을 경우는 default 키워드 인자 파라미터 값을 반환.
2개 이상의 인자가 사용되면 가장 큰 값을 반환.
비슷한 표기법으로 이중 별표(**)를 키워드 인자에 사용할 수 있다. 사전에 이중 별표를 사용하여 함수에 전달하면 딕셔너리의 키를 파라미터 이름으로 사용하고, 딕셔너리의 값을 파라미터 값으로 사용한다.
function(**{"key": "value"})
이것은 다음과 동일하다.
function(key="value")
반대로 이중 별표로 시작하는 파라미터를 함수에 사용하면 반대 현상이 벌어진다. 키워드 제공인자들이 딕셔너리로 패킹된다.
>>> def function(**kwargs):
... print(kwargs)
...
>>> function(key="value")
{"key": "value"}
이것은 파라미터를 동적으로 생성할 수 있게 해주는 정말 강력한 기능이지만, 남용하면 코드의 가독성이 떨어질 수 있다.
함수의 정의에 이중 별표(**) 인자를 사용한다는 것은 임의의 키워드 인자(keyword argument)를 허용한다는 것이고, 임의로 접근할 수 있는 딕셔너리를 만들어준다. 다만, 이 딕셔너리에서 직접 어떤 값을 추출하는 용도로 사용하는 것을 추천하지 않는다.
즉, 딕셔너리에서 특정 키로 인자 값을 조회하지 말고, 필요한 경우 함수의 정의에서 직접 꺼내도록 하자.
예를 들어 다음과 같이 하는 대신에
def function(**kwargs): # 안 좋은 예
timeout = kwargs.get("timeout", DEFAULT_TIMEOUT)
...
함수의 서명에서 파이썬이 직접 압축을 풀고(unpack) 값을 할당하도록 하자.
def function(timeout=DEFAULT_TIMEOUT): # 좋은 예
...
이 예에서 timeout은 엄격하게 말하자면 키워드 전용 인자(keyword-only argument)가 아니다. 중요한 점은 kwargs 딕셔너리를 직접 조작하면 안 되고 함수의 서명에서 적절한 언패킹을 해야 한다는 것이다.
위치 전용(positional-only) 인자
이미 살펴본 바와 같이 위치 인자는 함수의 앞 쪽에 제공되는 인자이다. 인수의 값은 함수에 정의된 순서에 따라 차례로 인식된다.
함수 인자를 정의할 때 특별한 구문을 사용하지 않았다면, 기본적으로 위치 또는 키워드로 전달할 수 있다. 예를 들어, 다음의 호출 방법은 모두 동일한 효과를 갖는다.
>>> def function(x, y):
... print(f"{x=}, {y=}")
...
>>> function(1, 2)
x=1, y=2
>>> function(x=1, y=2)
x=1, y=2
>>> function(y=2, x=1)
x=1, y=2
위치 인자는 함수의 서명에서 위치 인자가 정의된 순서대로 전달되어야 한다는 것을 의미한다. function(y=2, 1)처럼 호출된다면 에러가 발생한다.
그러나 파이썬 3.8(PEP-570)부터는 반드시 위치 인자만 사용해야 하는 새로운 구민이 추가되었다. 즉, 값을 전달할 때 키워드를 사용할 수 없다. 이를 사용하려면 마지막 위치 전용 인자의 끝에 /를 추가해야 한다.
>>> def function(x, y, /):
... print(f"{x=}, {y=}")
...
>>> function(1, 2)
x=1, y=2
>>> function(x=1, y=2) # 키워드 인자를 사용하면 다음과 같은 에러 발생
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: function() got some positional-only arguments passed as keyword arguments: 'x, y'
첫 번째 호출 방식은 이전처럼 잘 동작하지만, 두 번째 호출 방식은 에러가 발생한다. 이것은 위치 전용 인자가 키워드 인자로 전달될 수 없다는 것을 의미한다. 일반적으로 키워드 인자를 사용하면 어떤 변수에 어떤 값을 할당하려고 했는지 바로 알 수 있기 때문에 가독성이 높아지지만, 키워드 인자를 사용하는 것이 큰 의미가 없는 경우가 있을 수 있다. 이런 경우는 오히려 이름을 사용하려고 하는 것이 효율적이지 않은 일이 된다.
간단한 예를 들어 두 단어가 아나그램(anagram: 동일한 철자를 가지고 순서만 바꾼 단어. 예를 들어 Listen과 Silent)인지 확인하는 함수를 생각해보자. 이 함수는 두 개의 문자열을 받아서 두 문자열이 아나그램인지 확인한다. 사실 이 두개의 문자열 이름을 무엇으로 하는지는 그다지 중요하지 않다. 첫 번째 단어와 두 번째 단어일 뿐이다. 이러한 인수에 대해서 좋은 이름을 찾기 위해 노력하는 것은 큰 의미가 없으며 키워드를 지정하여 넘길 필요도 없다.
이런 경우가 아니라면 위치 전용 인자(positional-only parameter) 사용은 피해야 한다.
TIP 위치 전용 인자를 사용한 경우 인자에서 특별한 의미를 찾으려고 하지 말자.
키워드 전용(keyword-only) 인자
위치 전용 인자와 정 반대되는 개념인 키워드 전용 인자(keyword-only parameter)가 있다. 함수 호출 시 키워드 인자를 사용하면 그 의미를 명확히 알 수 있다는 점에서 키워드 사용을 강제하는 것은 일리가 있다.
위치 전용 인자와 달리 키워드 전용 인자는 *를 사용하여 그 시작을 알린다. 함수 서명에서 * 뒤에 오는 것들은 키워드 전용 인자가 된다. 아래 예에서 *args 다음에 오는 kw1, kw2가 키워드 전용 인자이다.
예를 들어, 다음 함수 정의는 두 개의 위치 인자를 받고, 다음에 임의의 개수만큼 위치 인자를 사용한다. 그 다음에 마지막에 2개의 인자가 바로 키워드 전용 인자이다. 마지막 인자 kw2는 기본 값을 가지고 있는데 필수 사항은 아니다.
>>> def function(arg1, arg2, *args, kw1, kw2=100):
... print(f"{arg1=}, {arg2=}, {args=}, {kw1=}, {kw2=}")
...
>>> function(1, 2, 3, 4, 5, kw1=6, kw2=7)
arg1=1, arg2=2, args=(3, 4, 5), kw1=6, kw2=7
>>> function(1, 2, 3, 4, 5, kw1=6)
arg1=1, arg2=2, args=(3, 4, 5), kw1=6, kw2=100
>>> function(1, 2, 3, 4, 5, 6, 7, 8, kw1=9, kw2=10)
arg1=1, arg2=2, args=(3, 4, 5, 6, 7, 8), kw1=9, kw2=10
처음 두 개 이후에 더 이상의 위치 인자를 원하지 않으면 *args 대신 *를 넣으면 된다.
>>> def function(arg1, arg2, *, kw1, kw2=100):
... print(f"{arg1=}, {arg2=}, {kw1=}, {kw2=}")
...
>>> function(1, 2, kw1=3, kw2=4)
arg1=1, arg2=2, kw1=3, kw2=4
>>> function(1, 2, 3, 4)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: function() takes 2 positional arguments but 4 were given
위치 인자와 키워드 인자 사이에 임의의 개수만큼 위치 인자를 사용할 수 있지만, *로 정의했을 경우에는 딱 2개의 위치 인자만 사용 가능하다.
이 기능은 하위 호환을 유지하면서 기존 함수를 확장할 수 있기 때문에 유용하다. 예를 들어, 두 개의 인자를 취하는 함수가 있고 위치 인자와 키워드 인자를 섞어서 다양한 형태로 호출되는 함수가 있다고 해보자. 여기서 기존 코드를 수정하지 않으면서 세 번째 인자를 추가하려면 기본값을 지정해야 한다. 그리고 좀 더 좋은 구조로 만들려면 세 번째 파라미터를 키워드 전용 인자로 만들어서 명시적으로 새로운 정의를 사용한다고 알려주는 것이 좋을 것이다.
이와 같은 맥락에서 키워드 리팩토링을 하거나 호환성을 유지하게 하려는 경우에 유용하게 쓸 수 있다. 예를 들어 내부의 구현을 새로 했지만, 호환성을 위해 기존 함수는 래퍼(wrapper) 형태로 그대로 사용하는 경우를 생각해보자.
새로운 함수를 호출하는 다음 두 가지 방법을 비교해보자.
result = function(1, 2, True)
다음과 같이 호출할 수도 있다.
result = function(1, 2, use_new_implementation=True)
두 번째 방식이 훨씬 더 명확하고 함수 호출을 보는 즉시 무슨 일이 일어나고 있는지 알 수 있다. 이러한 이유로 새로운 인자를 추가한다면 키워드 인자 방식을 사용하는 것이 좋다.
함수 인자의 개수
너무 많은 인자를 사용하는 함수나 메서드가 왜 나쁜 디자인(코드 스멜-code smell)의 징후인지를 살펴본다. 그런 다음에 이 문제를 해결할 방법을 제안한다.
첫 번째 대안은 일반적인 소프트웨어 디자인의 원칙을 사용하는 것이다. 즉, 구체화(reification)
하는 것이다. 전달하는 모든 인자를 포함하는 새로운 객체를 만드는 것이다. 인자가 많다는 것은 아마도 추상화를 빼먹었기 때문일 것이다. 여러 인자를 새로운 객체로 압축하는 것은 파이썬만의 고유한 솔루션이 아니라 모든 프로그래밍 언어에서 적용할 수 있는 방법이다.
또 다른 옵션은 이전 섹션에서 보았던 파이썬의 특정 기능을 사용하는 것이다. 가변 인자나 키워드 인자를 사용하여 동적 서명을 가진 함수를 만든다. 이것이 파이썬스러운 방법일지 모르지만 남용하지 않도록 주의해야 한다. 왜냐하면 매우 동적이어서 유지보수하기가 어렵기 때문이다. 이런 경우 함수의 본문을 살펴 봐야한다. 서명에 관계없이 (파라미터가 올바르게 사용되었다 해도) 만약 파라미터의 값에 대응하여 너무 많은 것들을 함수에서 처리하고 있다면 함수를 분리하라는 사인이다. 함수는 오직 한 가지 일만 해야 한다.
함수 인자와 결합력
함수 서명의 인수가 많을수록 호출자 함수와 밀접하게 결합될 가능성이 커진다.
f1이 f2 호출을 위한 모든 정보를 갖고 있다고 해보자. 이런 경우 다음 두 가지 결론을 내릴 수 잇다. 첫째 f2는 아마도 추상화가 부족했을 것이다. f1은 f2가 필요로 하는 모든 것을 알고 있디 때문에 f2 내부적으로 무엇을 하는지 알아낼 수 있으며 거의 자체적으로 수행할 수 있다. 결국 f2는 그렇게 많은 것을 추상화를 하지 않은 것이다. 둘째 f2는 다른 환경에서 사용하기가 어려워 f1에서만 유용하기 때문에 재사용성이 떨어진다.
함수가 보다 일반적인 인터페이스를 제공하고 더 높은 수준의 추상화를 했다면 코드 재사용성이 높아진다.
이것은 클래스의 __init__
메서드를 포함하여 모든 종류의 함수와 객체 메서드에 적용된다. 인자가 많은 메서드가 있다는 것은 일반적으로(예외는 있지만) 새로운 상위 레벨의 추상화가 가능하다거나 누락된 객체가 있음을 의미한다.
TIP 함수가 제대로 동작하기 위해 너무 많은 파라미터가 필요한 경우 코드 스멜(code smell)이 아닌지 검토해보자.
pylint와 같은 정적 분석 도구를 사용하면 기본 설정에 의해 경로를 표시하는 문제이다. 이러한 경고를 만나면 무시하지 말고 리팩토링을 해야 한다.
많은 인자를 가진 함수의 간소화
만약 공통 객체에 파라미터 대부분이 포함되어 있다면 가장 쉽게 수정할 수 있다. 예를 들어 다음과 같은 함수 호출을 생각해보자.
track_request(request.headers, request.ip_addr, request.request_id)
여기에 파라미터가 추가될 수도 있지만 그렇지 않을 수도 있다. 그러나 분명한 것은 모든 파라미터가 request와 관련이 있다는 것이다. 그렇다면 그냥 request를 파라미터로 전달하는 것이 어떨까? 이렇게 하면 함수의 서명을 간소화할 수 있다.
track_request(request)
이와 같이 파라미터를 전달할 것을 권장하지만 변경 가능한 객체를 전달할 때에는 부작용에 주의해야 한다. 함수는 전달받은 객체를 변경해서는 안된다. 부작용이 발생할 수 있기 때문이다. 객체의 무언가를 바꾸고 싶다면 전달된 값을 복사한 다음 새로운 수정본을 반환하는 것이 나은 대안이다.
TIP 변경 불가능한 객체를 사용하여 부작용을 최소화한다.
파라미터 그룹핑이라는 비슷한 방법도 있다. 이전 예제에서 파라미터는 이미 그룹화되었기 때문에 그룹 (앞의 예제에서는 request 객체)을 사용하지 않았다. 그러나 조금 전과 같이 명확한 경우가 아니라면 컨테이너처럼 동작하는 단일 객체에 모든 파라미터를 담을 수도 있다. 이러한 그룹핑 아이디어를 구체화(reify)라고 하면 초기 디자인에서 누락된 추상화를 하는 것이다.
이전 방법들이 통하지 않으면 최후의 수단으로 함수의 서명을 변경하여 다양한 인자를 허용할 수 있다. 인자가 너무 많을 경우 *args 또는 **kwargs를 사용하면 더 이해하기 어려운 상황을 만들 수도 있다. 이런 경우 인터페이스에 대해서 적절히 문서화를 하고 인터페이스를 올바르게 구현했는지 확실히 해야 한다. 그러나 때로는 이렇게 하는 것이 의미가 있는 경우가 있다.
*args와 **kwargs로 함수를 정의하면 매우 유연하고 적응력이 뛰어난 코드를 만들 수 있지만, 단점은 함수 서명으로서의 기능을 잃어버린다는 것과 가독성이 완전히 떨어진다는 것이다. 지금까지 여러 예제에서 좋은 변수의 이름을 사용하면 코드의 가독성이 훨씬 높아진다는 것을 경험했다. 위치 인자든 키워드 인자든 가변인자를 사용하면, 매우 좋은 dostring을 만들어 놓지 않는 한 나중에 해당 함수에 사용된 파라미터를 보고도 정확한 동작을 알 수가 없다.
TIP 가장 일반화된 인자(*args, **kwargs)는 super()를 통해 파라미터를 그대로 부모에 전달해야 하는 래퍼(wrapper) 클래스나 파라미터에 독립적으로 동작해야 하는 데코레이터와 같은 경우에만 사용하자.
소프트웨어 디자인 우수 사례 결론
소프트웨어의 독립성(orthoogonality)
직교(orhtogonality)는 수학에서 매우 중요한 개념이다. 직교는 수학에서 두 벡터가 직교한다는 것을 의미한다. 즉, 두 벡터가 직각을 이룬다는 것이다. 이것은 두 벡터가 서로 독립적이라는 것을 의미한다. 이것은 소프트웨어 디자인에서도 동일하게 적용된다.
모듈, 클래스 또는 함수를 변경하면 수정한 컴포넌트가 외부 세계에 영향을 미치지 않아야 한다. 물론 이것은 바람직하지만 항상 가능한 것은 아니다. 이것이 불가능하다 해도 좋은 디자인은 가능한 한 영향을 최소화하려고 시도해야 한다. 이러한 디자인 원칙으로 관심사의 분리, 응직렵, 컴포넌트의 격리를 살펴보았다.
소프트웨어의 런타임 구조 측면에서 직교성 또는 독립성은 변경(또는 부작용)을 내부 문제로 만드는 것이라고 할 수 있다. 예를 들어 어떤 객체의 메서드를 호출하는 것은 다른 관련 없는 객체의 내부 상태를 변경해서는 안 된다는 것을 의미한다.
앞서 소개한 믹스인 클래스 예제에서는 이터러블을 반환하는 tokenizer 객체를 만들었다. 특히 __iter__
메서드는 새로운 제너레이터를 반환했는데, 이는 기본 클래스, 믹스인 클래스, 구체 클래스 모두가 독립적인 가능성을 높여준다. 그러나 __iter__
메서드에서 일반 리스트를 반환했다고 해보자. 이렇게 하면 나머지 클래스에서 (의도하든 의도하지 않든) 직접 리스트를 변경할 수 있으므로 종속성이 생가게 되고 결과적으로는 독립성이 떨어지게 된다.
간단한 예제를 살펴보자. 파이썬에서 함수는 일반 객체일 뿐이므로 파라미터로 전달할 수 있다. 독립성을 얻기 위해 이 기능을 활용할 수 있다. 세금과 할인율을 고려하여 가격을 계산하는 함수를 가지고, 최종 계산된 값ㅇ르 포매팅하고 싶다고 해보자.
def calculate_price(base_price: float, tax_rate: float, discount: float) -> float:
return (base_price * (1 + tax_rate)) * (1 - discount)
def show_price(price: float) -> str:
return f"$ {price:.2f}"
def str_final_price(
base_price: float, tax_rate: float, discount: float, fmt_function=str) -> str:
return fmt_function(calculate_price(base_price, tax_rate, discount))
위쪽의 두 개의 함수는 독립성을 갖는다. 하나는 가격을 계산하고 다른 하나는 가격을 포매팅한다. 만약 하나를 변경해도 다른 하나는 변경되지 않는다. 마지막 함수는 show_price 파라미터를 지정하지 않으면 str을 기본 포맷 함수로 사용하고, 사용자 정의 함수를 전달하면 해당 함수를 사용해 문자열을 포맷한다. 그러나 show_price의 변경 사항은 calculate_price에 영향을 미치지 않는다. 어느 함수를 변경해도 나머지 함수가 그대로라는 것을 알게되면 어떤함수든 편하게 수정할 수 있다.
>>> str_final_price(100, 0.1, 0.2)
'90.00'
>>> str_final_price(100, 0.1, 0.2, fmt_function=show_price)
'$ 90.00'
코드 구조
코드를 구조화하는 방법은 팀의 작업 효율성과 유지보수성에 영향을 미친다.
특히 여러 정의(클래스, 함수, 상수 등)가 들어있는 큰 파일을 만드는 것은 좋지 않다. 극단적으로 하나의 파일에 하나의 정의만 유지하라는 것은 아니지만, 좋은 코드라면 유사한 컴포넌트끼리 정리하여 구조화해야 한다.
만약 코드의 여러 부분이 해당 파일의 정의에 종속되어 있어도 전체적인 호환성을 유지하면서 패키지로 나눌 수 있다. 해결 방법은 __init__.py
파일을 가진 새 디렉토리를 만드는 것이다. 이렇게 하면 파이썬 패키지가 만들어 진다. 이 파일과 함께 특정 정의를 포함하는 여러 파일을 생성한다. 이때는 각각의 기준에 맞춰 보다 적은 클래스와 함수를 갖게 된다. 그런 다음 __init__.py
파일에 다른 파일에 있던 모든 정의를 가져옴으로써 호환성도 보장할 수 있다. 뿐만 아니라 이러한 정의는 모듈의 __all__
변수에 익스포트가 가능하도록 표시할 수도 있다.
이것은 각 파일을 탐색하고 검색이 쉽다는 것 외에도 다음과 같은 이유 때문에 더 효율적이라고 할 수 있다.
- 모듈을 임포트할 때 구문을 분석하고 메모리에 로드할 객체가 줄어든다.
- 의존성이 줄었기 때문에 더 적은 모듈만 가져오면 된다.
또한 프로젝트를 위한 컨벤션을 갖는 데에도 도움이 된다. 예를 들어 모든 파일에서 상수를 정의하는 대신, 프로젝트에서 사용할 상수 값을 저장할 특정한 파일을 만들고 다음과 같이 임포트하면 된다.
from project.constants import MAX_CONNECTIONS
이와 같이 정보를 중앙화하면 코드 재사용이 쉬워지고 실수로 인한 중복을 피할 수 있다.
요약
이 장에서는 클린 디자인을 달성하기 위한 몇 가지 원칙을 살펴보았다. 코드도 디자인의 일부라는 것을 이해하는 것이 고품질의 소프트웨어를 만들기 위한 키이다.
DbC(계약에 의한 디자인)를 적용하면 주어진 조건 하에서 동작이 보장되는 컴포넌트를 만들 수 있다. 더 중요한 것은 만약 오류가 발생한 경우, 아무런 이유 없이 갑자기 발생했다고 느끼는 것이 아니라 정확히 어떤 부분에서 문제가 있었는지 계약을 확인하여 책임소재를 분명히 할 수 있다는 점이다. 이러한 역할 분담은 효과적인 디버깅에 유리하다.
계약에 의한 디자인과 방어 프로그래밍에 의한 디자인 모두 어설션을 올바르게 처리하는 것이 중요하다. 어설션을 사용할 때는 그 용도를 정확히 이해해야 하고, 특히 프로그램의 흐름을 제어하는 용도로 사용해서는 안 된다. 어설션을 catch하여 처리하지 않는 것에도 유의하자.
객체 지향 디자인에서 상속 또는 컴포지션 중 어떤 것을 선택할지 결정하는 주제에 대해서도 살펴보았다. 여기서 가장 중요한 교훈은 한 가지 옵션을 더 많이 사용하는 것이 아니라, 상황에 알맞는 더 나은 옵션을 선택하는 것이라는 것이다. 또한 파이썬의 높은 동적 특성으로 인해 자주 보게되는 일반적인 안티 패턴을 피해야 한다.
마지막으로, 함수의 파라미터가 많은 경우 어떻게 수정하여 클린 디자인을 이룰 수 있는지 살펴보았다.
Previous
‘2. 파이썬스러운(pythonic) 코드’ -> Previous
Next
‘4. SOLID 원칙’ -> Next