1. 계약에 의한 디자인
소프트웨어는 사용자가 직접 호출하기도 하지만 코드의 다른 부분에서 호출하는 경우도 있다. 애플리케이션의 책임을 나눠 레이어나 컴포넌트로 분리한 경우가 그렇다.
컴포넌트는 기능을 숨겨 캡슐화하고 함수를 사용할 고객에게는 API(Application Programming Interface) 를 노출해야 한다. 컴포넌트의 함수, 클래스, 메서드는 특별한 유의사항에 따라 동작해야 하며, 그렇지 않을 경우 코드가 깨지게 된다. 예를 들어 정수를 파라미터로 사용하는 함수에 문자열을 파라미터로 전달하면 기대와 다르게 동작할 것이다.
물론 API 를 디자인할 때 예상되는 입력, 출력 및 부작용을 문서화해야 한다. 그러나 문서화가 런타임 시의 소프트웨어 동작을 강제할 수는 없다. 따라서 코드가 정상적으로 동작하기 위해 기대하는 것과 호출자가 반환 받기를 기대하는 것은 디자인의 하나가 되어야 한다. 여기서 계약(contract) 이라는 개념이 생긴다.
계약에 의한 디자인(Design By Contract) 이란 이런 것이다. 관계자가 기대하는 바를 암묵적으로 코드에 삽입하는 대신 양측이 동의하는 계약을 먼저 한 다음, 계약을 어겼을 경우는 명식적으로 왜 계속할 수 없는지 예외를 발생시키라는 것이다. 계약은 주로 사전조건과 사후조건을 명시하지만 때로는 불변식과 부작용을 기술한다.
- 사전조건(precondition) : 코드가 실행되기 전에 체크해야 하는 것들이다. 함수가 진행되기 전에 처리되어야 하는 모든 조건을 체크한다. 일반적으로 파라미터에 제공된 데이터의 유효성을 검사하지만 유효성 체크를 통해 부작용이 최소화된다는 점을 고려할 때 유효성 검사를 많이 하는 것이 좋다.
- 사후조건(postcondition) : 사전조건과 반대로 여기서는 함수 반환 값의 유효성 검사가 진행된다. 사후조건 검증은 호출자가 이 컴포넌트에서 기대한 것을 제대로 받았는지 확인하기 위해 수행한다.
- 불변식(invariant) : 때로는 함수의 docstring 에 불변식에 대해 문서화하는 것이 좋다. 불변식은 함수가 실행되는 동안에 일정하게 유지되는 것으로 함수의 로직에 문제가 없는지 확인하기 위한 것이다.
- 부작용(side-effect) : 선택적으로 코드의 부작용을 docstring 에 언급하기도 한다.
이상적으로는 이 모든 것들을 소프트웨어 컴포넌트 계약서의 일부로 문서화해야 하지만, 처음 2개인 사전/사후 조건만 코드 레벨에서 강제한다.
이렇게 계약에 의해 디자인을 하는 이유는 오류가 발생할 때 쉽게 찾아낼 수 있기 때문이다. 사전/사후 조건 검증에 실패할 경우 오류를 쉽게 찾아서 수정할 수 있다. 더 중요한 것은 잘못된 가정 하에 코드의 핵심 부분이 실행되는 것을 방지하기 위해서이다. 이렇게 하면 단지 애플리케이션의 어떤 부분에서 실패했다는 에러를 발생시키는데서 그치지 않고 책임의 한계를 명확히하는데 도움이 된다.
사전조건은 클라이언트와 연관되어 있다. 클라이언트는 코드를 실행하기 위해 사전에 약속한 조건을 준수해야 한다. 반대로 사후조건은 컴포넌트와 연관되어 있다. 컴포넌트는 클라이언트가 확인하고 강제할 수 있는 값을 보장해야 한다. 이를 통해 책임소재를 신속히 파악할 수 있다.
1-1. 사전조건(precondition)
사전조건은 함수나 메서드가 제대로 동작하기 위해 보장해야 하는 모든 것을 말한다. 쉽게 말해, 적절한 데이터를 전달하는 것이다.
이 데이터에 대한 유효성 검사가 필요한데, 문제는 어디서 할지이다. 클라이언트가 함수를 호출하기 전에 모든 유효성 검사를 하도록 할 것인지, 함수가 자체적으로 로직을 실행하기 전에 검사하도록 할 것인지에 대한 문제이다.
전자는 관용적이 접근이다. 왜냐하면 함수가 어떤 값이라도 수용하기 때문이다. 반면 후자는 까다로운 접근 방법에 해당한다.
일반적으로 까다로운 접근법을 사용한다.
1-2. 사후조건(postcondition)
사후조건은 메서드 또는 함수가 반환된 후의 상태를 강제하는 계약의 일부이다. 사전조건이 맞는다면 사후조건은 특정 속성이 보존되도록 보장해야 한다.
1-3. 파이썬스러운 계약
Programming by Contract for Python 이라는 PEP-316 은 연기(deferred) 상태이다.
(https://www.python.org/dev/peps/pep-0316/ )
그렇다고 일반적인 디자인 원칙하에 파이썬으로 구현할 수 없다는 뜻은 아니다. 이를 적용하기 가장 좋은 방법은 메서드, 함수 및 클래스에 RuntimeError 예외 또는 ValueError 예외를 발생시키는 제어 메커니즘을 추가하는 것이다. 올바른 예외 타입이 무엇인지 일반적인 규칙을 만드는 것은 애플리케이션에 종속적인 부분이 많으므로 어려운 일이다. 문제를 정확하게 특정하기 어려우면 사용자 정의 예외를 만드는 것이 가장 좋다.
또한 코드를 가능한 한 격리된 상태로 유지하는 것이 좋다. 즉 사전조건에 대한 검사와 사후조건에 대한 검사 그리고 핵심 기능에 대한 구현을 구분하는 것이다. 더 작은 함수를 생성하여 해결할 수도 있지만 데코레이터를 사용하는 것이 대안이 될 수도 있다.
1-4. 계약에 의한 디자인(DbC) - 결론
디자인 원칙의 주된 가치는 문제가 있는 부분을 효과적으로 식별하는데 있다. 계약을 정의함으로써 런타임 시 오류가 발생했을 때 코드의 어떤 부분이 손상되었는지 계약이 파손되었는지가 명확해진다.
또한 프로그램의 구조를 명확히 하는 목적으로도 사용된다. 즉흥적으로 유효성 검사를 해보거나 가능한 모든 실패 시나리오를 검증하는 대신 계약은 명시적으로 함수나 메서드가 정상 동작하기 위해 기대하는 것이 무엇인지, 무엇을 기대할 수 있는지 정의한다.
물론 추가 작업이 필요하다. 애플리케이션의 핵심 논리뿐만 아니라 계약을 작성해야 하기 때문이다. 또한 이러한 계약에 대한 단위 테스트를 추가해야 할 수도 있다. 그러나 이 방법을 통해 얻은 코드의 품질은 장기적으로 보장된다. 따라서 애플리케이션의 중요한 구성 요소에 대해서는 이 원칙을 따르는 것이 좋다.
그럼에도 불구하고 이 방법이 효과적이기 위해서는 무엇을 검증할 것인지 신중히 검토해야한다. 단순히 함수에 제공된 파라미터의 올바른 데이터 타입만 검증하는 계약을 정의하는 것은 큰 의미가 없다. 오히려 파이썬을 정적 타입을 가진 자바와 같은 언어로 만드는 것과 비슷하다고 주장하는 이들도 있다. 이런 주장과는 또 별개로 타입을 체크해주는 도구를 함께 사용하면 훨씬 효과적으로 목적을 이룰 수 있는 것 또한 사실이다. 따라서 이를 염두해두고 함수에 전달되는 객체의 속성과 반환 값을 검사하는 등의 작업을 하는 것은 충분히 가치 있는 작업이라고 볼 수 있다.
2. 방어적(defensive) 프로그래밍
방어적 프로그래밍은 DbC 와는 다소 다른 접근 방식을 따른다. 계약에서 예외를 발생시키고 실패하게 되는 모든 조건을 기술하는 대신 객체, 함수 또는 메서드와 같은 코드의 모든 부분을 유효하지 않은 것으로부터 스스로 보호할 수 있게 하는 것이다.
방어적 프로그래밍은 여러 측면을 고려한 기술이며 다른 디자인 원칙과 결합된 경우 특히 유용하다. DbC 철학을 가졌다는 것은 아니지만 다른 디자인 원칙과 서로 보완 관계에 있을 수 있다는 것을 의미한다.
방어적 프로그래밍의 주요 주제는 예상할 수 있는 시나리오의 오류를 처리하는 방법과 발생하지 않아야 하는 오류를 처리하는 방법에 대한 것이다. 전자는 에러 핸들링 프로시저에 대한 것이며, 후자는 assertsion 에 대한 것이다.
2-1. 에러 핸들링
오류가 발생하기 쉬운 상황에서 에러 핸들링 프로시저를 사용하는데 일반적으로 데이터 입력 확인 시 자주 사용된다.
에러 핸들링의 주요 목적은 예상되는 에러에 대해서 실행을 계속할 수 있을지 아니면 극복할 수 없는 오류여서 프로그램을 중단할지 결정하는 것이다.
프로그램에서 에러를 처리하는 방법에는 여러 가지가 있지만 모든 것을 처리할 수 있는 것은 아니다. 에러 처리 방법의 일부는 아래와 같다.
- 값 대체(subsitution)
- 에러 로깅
- 예외 처리
# 값 대체
일부 시나리오에서는 오류가 있어 소프트웨어가 잘못된 값을 생성하거나 전체가 종료될 위험이 있을 경우 결과 값을 안전한 다른 값으로 대체할 수 있다. 이것을 값 대체라고 한다. 잘못된 결과를 정합성을 깨지 않는 다른 값으로 대체하기 때문이다. 기본 값 또는 잘 알려진 상수, 초기 값으로 바꾸는 것이다.
값 대체가 항상 가능한 것은 아니다. 대체 값이 실제로 안전한 옵션인 경우에 한하여 신중하게 선택해야 한다. 이 결정의 기준은 견고성과 정확성 간의 트레이드 오프이다. 소프트웨어 프로그램은 예기치 못한 상황에서도 실패하지 않아야 견고하다고 할 수 있다. 그러나 무조건 실패하지 않는 것이 항상 옳은 것은 아니다.
어떤 소프트웨어는 모든 것을 허용하는게 어려울 수 있다. 애플리케이션이 민감하고 중요한 정보를 다루는 경우 부정확한 결과를 그대로 내보낼 수 없기 때문이다. 이런 경우는 잘못된 결과를 생산하는 것보다는 정확성을 선택해야 한다.
약간 다른 방향이지만, 안전한 방법을 택하자면 제공되지 않은 데이터에 기본 값을 사용하는 것이다. 설정되지 않은 환경 변수의 기본 값, 설정 파일의 누락된 항목 또는 함수의 파라미터 같은 것들은 기본 값으로 동작이 가능한 것들이다.
>>> configuration = {"dbport": 5432}
>>> configuration.get("dbhost", "localhost")
'localhost'
>>> configuration.get("dbport")
5432
사전의 get 메서드를 통해 위와 같이 기본 값을 나타낼 수 있다.
환경 변수에도 유사한 API 가 있다.
>>> import os
>>> os.getenv("DBHOST")
'localhost'
>>> os.getenv("DPORT", 5432)
5432
앞의 두 예제 모두 두 번째 파라미터를 제공하지 않으면 None 을 반환한다. None 이 함수에서 정의한 기본 값이기 때문이다. 사용자 정의 함수에도 파라미터의 기본 값을 직접 정의할 수 있다.
>>> def connection_database(host="localhost", port=5432):
logger.info("다음 정보로 데이터베이스에 접속: %s:%i", host. port)
일반적으로 누락된 파라미터를 기본 값으로 바꾸는 것은 큰 문제가 없으나 오류가 있는 데이터를 유사한 값으로 대체하는 것은 더 위험하여 일부 오류를 숨겨버릴 수 있다. 이러한 점을 고려해야 한다.
# 예외 처리
잘못되거나 누락된 입력 데이터가 있는 경우 이전 섹션에서 언급한 것과 같이 복구 처리가 가능한 경우가 있다. 그러나 어떤 경우에는 에러가 발생하기 쉽다는 가정으로 계속 실행하는 것보다 차라리 실행을 멈추는 것이 더 나을 때가 있다. 이런 경우에는 실패를 알리는 것이 좋다. DbC 에서 보았듯이 사전조건 검증에 실패한 경우를 예로 들 수 있다.
그러나 입력이 잘못되었을 때만 함수에 문제가 생기는 것은 아니다. 함수는 단순히 데이터를 전달받는 것이 아니라 외부 컴포넌트에 연결되어 있으며 부작용 또한 가지고 있다.
함수 호출 실패는 함수 자체의 문제가 아니라 외부 컴포넌트 중 하나의 문제로 인한 것일 수 있다. 이런 경우 적절한 인터페이스를 설계하면 쉽게 디버깅할 수 있다. 함수는 오류에 대해 명확하게 알려줘서 적절히 해결할 수 있도록 해야 한다.
이것이 예외 메커니즘이다. 예외적인 상황을 알려주고 비즈니스 로직에 따라 흐름을 유지하는 것이 중요하다.
예외를 사용하여 시나리오나 비즈니스 로직을 처리하려고 하면 프로그램의 흐름을 읽기 어려워진다. 그리고 프로그램이 꼭 처리해야 하는 정말 예외적인 비즈니스 로직을 except 블록과 혼합하여 사용하면 상황이 더욱 악화될 수 있다.
비즈니스 로직의 일부로 go-to 문을 사용하여 예외처리를 하면 안된다. 호출자가 알아야만 하는 실질적인 문제가 있을 경우에는 예외를 발생시켜야 한다.
예외는 대개 호출자에게 잘못을 알려주는 것이다. 예외는 캡슐화를 약화시키기 때문에 신중하게 사용해야 한다. 함수에 예외가 많을수록 호출자가 호출하는 함수에 대해 더 많은 것을 알아야만 한다. 그리고 함수가 너무 많은 예외를 발생시키면 문맥에서 자유롭지 못하다는 것을 의미한다.
다음은 파이썬의 예외와 관련된 몇 가지 권장 사항이다.
- 올바른 수준의 추상화 단계에서 예외 처리
예외는 오직 한 가지 일을 하는 함수의 한 부분이어야 한다. 함수가 처리하는 예외는 캡슐화된 로직과 일치해야 한다.
다음은 서로 다른 수준의 추상화를 혼합하는 예제이다.
class DataTransport:
"""An example of an object badly handling exceptions of different levels."""
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("connection error detected: %s", e)
raise
except ValueError as e:
logger.error("%r contains incorrect data: %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: attempting new connection in %is",
e,
self.retry_threshold,
)
time.sleep(self.retry_threshold)
else:
return self.connection
raise ConnectionError(
f"Couldn't connect after {self.retry_n_times} times"
)
def send(self, data):
return self.connection.send(data)
애플리케이션에서 디코딩한 데이터를 외부 컴포넌트에 전달하는 객체이다. deliver_event() 메서드가 예외를 처리하는 방법에 초점을 맞춰 분석해보자.
ValueError 와 ConnectionError 는 무슨 관계인가? 아무 관계가 없다. 이렇게 매우 다른 유형의 오류를 분석해봄으로써 책임을 어떻게 분산해야 하는지에 대한 아이디어를 얻을 수 있다.
ConnectionError 는 connect 메서드 내에서 처리되어야 한다. 이렇게 하면 행동을 명확하게 분리할 수 있다. 이 메서드가 재시도를 지원하는 경우 이 안에서 처리를 할 수 없다. 반대로 ValueError 는 event 의 decode 메서드에 속한 에러이다. 따라서 마찬가지로 decode 메서드 안에서 처리되어야 한다.
이렇게 수정하면, deliver_event 는 예외를 catch 할 필요가 없다. 따라서 deliver_event 메서드는 다른 메서드나 함수로 분리해야만 한다. 연결 관리는 작은 함수로 충분하다. 이 작은 함수는 연결을 맺고 발생 가능한 예외를 처리하고 로깅을 담당한다.
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: attempting new connection in %is", e, retry_threshold
)
time.sleep(retry_threshold)
exc = ConnectionError(f"Couldn't connect after {retry_n_times} times")
logger.exception(exc)
raise exc
이제 원래 deliver_event 메서드에서 이 함수를 호출하면 된다. event 의 ValueError 예외에 대해서도 새로운 객체로 분리할 수 있지만 일단 다른 메서드로 분리하는 것으로 대신한다.
class DataTransport:
"""An example of an object that separates the exception handling by
abstraction levels.
"""
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
- Traceback 노출 금지
이것으 보안을 위한 고려 사항이다. 예외를 처리할 때 오류가 매우 중요하다면 전파해도 된다. 또한 특정 시나리오에서 검토된 내용이거나 견고함보다 정확성이 더 중요한 상황이라면 프로그램을 중료하게 할 수도 있다.
특정 문제를 나타내는 예외가 있는 경우 문제를 효율적으로 해결하기 위해 traceback 정보, 메시지 및 기타 수집 가능한 정보를 로그로 남기는 것이 중요하다. 그러나 이러한 세부사항은 절대 사용자에게 보여서는 안된다. traceback 은 매우 유용하고 많은 디버깅 정보를 제공한다. 동시에 악의적인 사용자에게도 유용한 정보여서 중요 정보나 지적 재산의 유출이 발생할 위험이 있다.
일반적으로 웹에서는 HTTP 오류가 났을 때 사용자게에 문제를 알리기 위해 무엇이 잘못되었다거나 페이지를 찾을 수 없다는 등의 일반적인 메시지를 사용한다.
- 비어있는 except 블록 지양
파이썬의 안티패턴 중에서도 가장 안좋은 패턴이다. 일부 오류에 대해 프로그램을 방어하는 것은 좋은 일이지만 지나치게 방어적인 것은 더 심각한 문제로 이어질 수 있다. 너무 방어적이어서 아무것도 하지 않은 채 조용히 지나쳐버리는 비어있는 except 블록은 가장 안좋은 예이다.
파이썬은 매우 유연하여 결함이 있는 코드를 작성할 수 있으며, 오류를 발생시키지 않을 수 있다.
try:
process_data()
except:
pass
실패를 해야할 때 조차도 실패를 하지 않는다. 에러는 결코 조용히 전달되어서는 안된다는 파이썬의 철학을 비추어볼 때 이는 파이썬스러운 코드가 아니다.
진짜 예외가 발생해도 코드가 실패하지 않기를 의도했을 수 있다. 그러나 코드에 결함이 있다면 문제가 될 것이다. 이에 대하여 두 가지 대안이 있다.
- 보다 구체적인 예외를 사용하는 것이다. (ValueError, TypeError, KeyError 등)
- except 블록에서 실제 오류 처리를 한다.
가장 좋은 방법은 두 항목을 동시에 적용하는 것이다.
보다 구체적인 예외를 사용하면 사용자는 무엇을 기대하는지 알게 되기 때문에 프로그램을 더욱 유지보수하기 쉽다. 또한 다른 종류의 예외가 발생하면 바로 버그로 판단할 수 있으므로 쉽게 대응할 수 있다.
예외를 자체적으로 처리하는 것은 여러 가지를 의미할 수 있다. 가장 간단한 예로 단지 예외 상황을 로깅할 수 있다. 다른 방법으로는 기본 값을 반환하는 것이다. 여기서 말하는 기본 값은 오직 오류를 발견한 뒤에만 사용하는 값을 뜻한다. 그렇지 않은 경우는 다른 예외를 발생시켜야 한다.
- 원본 예외 포함
오류 처리 과정에서 다른 오류를 발생시키고 메시지를 변경할 수도 있다. 이 경우 원래 예외를 포함하는 것이 좋다.
PEP-3134 에서는 raise <e> from <original_exception> 구문을 사용하도록 권고한다. 이 구문을 사용하면 원본의 traceback 이 새로운 exception에 포함되고 원본 예외는 __cause__ 속성으로 설정된다.
예를 들어 기본 예외를 사용자 정의 예외로 래핑하고 싶다면 루트 예외에 대한 정보를 다음과 같이 포함할 수 있다.
class InternalDataError(Exception):
"""An exception with the data of our domain problem."""
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> 구문을 사용한다.
2-2. 파이썬에서 어설션 사용하기
어설션은 절대로 일어나지 않아야 하는 상황에서 사용되므로 assert 문에 사용된 표현식은 불가능한 조건을 의미한다. 이 상태가 된다는 건 소프트웨어에 결함이 있음을 의미한다.
에러 핸들링과 다르게 여기서는 프로그램이 계속 진행될 가능성이 있다. 이러한 오류가 발생하면 프로그램을 중지하는 것이 좋다. 어설션을 비즈니스 로직과 섞거나 소프트웨어의 제어 흐름 메커니즘으로 사용해서는 안 된다. 다음의 예제는 좋지 않은 예시이다.
try:
assert condition.holds(), "조건에 맞지 않음"
except AssertionError:
alternative_procedure()
AssertionError 예외를 처리하지 않는 것이 좋다.
어설션에 실패하면 반드시 프로그램을 종료해야 한다.
위의 예제 코드가 나쁜 이유는 AssertionError 를 처리하는 것 외에도 어설션 문장이 함수라는 것이다. 함수 호출은 부작용을 가질 수 있으며 항상 반복 가능하지는 않다. 또한 디버깅 시에 해당 라인을 중지하여 오류 결과를 바로 볼 수 없으며, 다시 함수를 호출한다 하더라도 잘못된 값이었는지 알 수 없다.
보다 나은 방법은 코드를 줄이고 유용한 정보를 추가하는 것이다.
result = condition.holds()
assert result > 0, "에러 {0}".format(result)
3. 관심사의 분리
책임이 다르면 컴포넌트, 계층 또는 모듈로 분리되어야 한다. 프로그램의 각 부분은 기능의 일부분(관심사)에 대해서만 책임을 지며 나머지 부분에 대해서는 알 필요가 없다.
소프트웨어에서 관심사를 분리하는 목표는 파급 효과를 최소화하여 유지보수성을 향상시키는 것이다. 파급 효과는 어느 지점에서의 변화가 전체로 전파되는 것을 의미한다. 파급 효과가 클수록 어느 한 부분에서 발생한 오류나 예외가 또다른 오류 혹은 예외를 유발한다. 게다가 더 먼 지점의 결함을 초래하기도 한다. 함수 정의를 약간만 변경해도 코드의 여러 부분에 영향을 미쳐 많은 코드를 변경해야 할 수도 있다.
소프트웨어는 쉽게 변경될 수 있어야 한다. 애플리케이션의 나머지 부분에 대한 영향성을 최소화하면서 코드를 수정하거나 리팩토링을 하고 싶다면 적절한 캡슐화가 필요하다.
이 개념은 각 관심사가 계약에 의해 시행될 수 있다는 점에서 DbC 원칙과 관련된다. 유사한 부분이 있지만 관심사의 분리는 좀 더 넓은 부분을 포함한다. 일반적으로 함수, 메서드 또는 클래스 간의 계약을 생각한다. 그러나 관심사의 분리는 기본적으로 파이썬 모듈, 패키지 그리고 모든 소프트웨어 컴포넌트에 대해서 적용된다.
3-1. 응집력(cohension)과 결합력(coupling)
응집력이란 객체가 작고 잘 정의된 목적을 가져야 하며 가능하면 작아야 한다는 것을 의미한다. 유닉스 명령어가 한 가지 일만 잘 수행하려는 것과 비슷한 철학을 따른다. 응집력이 높을 수록 더 유용하고 재사용성이 높아지므로 더 좋은 디자인이다.
결합력이란 두 개 이상의 객체가 서로 어떻게 의존하는지를 나타낸다. 이 종속성은 제한을 의미한다. 객체 또는 메서드의 두 부분이 서로 너무 의존적이라면 다음과 같은 좋지 않은 결과를 초래할 수 있다.
- 낮은 재사용성 : 만약 어떤 함수가 특정 객체에 지나치게 의존하는 경우 또는 너무 많은 파라미터를 가진 경우 이 함수는 해당 객체에 결합되게 된다. 즉 재사용이 매우 어렵다는 뜻이다.
- 파급(ripple) 효과 : 너무 가깝게 붙어 있게 되면 두 부분 중 하나를 변경하면 다른 부분도 영향을 미친다.
- 낮은 수준의 추상화 : 두 함수가 너무 가깝게 관련되어 있으면 서로 다른 추상화 레벨에서 문제를 해결하기 어렵기 때문에 관심사가 분리되어 있다고 보기 어렵다.
잘 정의된 소프트웨어는 높은 응집력과 낮은 결합력을 갖는다. (hign cohension and low coupling)
4. 개발 지침 약어
좋은 소프트웨어 관행을 약어를 통해 쉽게 기억할 수 있도록 몇 가지 원칙에 대해 알아본다.
4-1. DRY/OAOO
DRY(Do not Repeat Yourself) 와 OAOO(Once and Only Once) 는 밀접한 관련이 있다. 당연한 원리로서 중복을 반드시 피해야 한다. 코드에 있는 지식은 단 한번, 단 한곳에 정의되어야 한다. 코드를 변경하려고 할 때 수정이 필요한 곳은 단 한군데만 있어야 한다.
코드 중복은 유지보수에 직접적인 영향을 미치는 문제이다. 코드 중복의 경우 다음과 같은 부정적인 영향이 있다.
- 오류가 발생하기 쉽다 : 어떤 로직이 여러 번 반복되어 있을 때 이를 수정하면 하나라도 빠뜨렸을 때 버그가 발생할 수 있다.
- 비용이 비싸다 : 여러 번 정의했을 경우 변경에 더 많은 시간이 소요된다. 이는 전체적인 개발 속도를 더디게 한다.
- 신뢰성이 떨어진다 : 사림이 모든 위치를 기억해야 한다. 단일 데이터 소스(single source of truth) 가 아니므로 데이터의 완결성이 떨어진다.
중복은 기존 코드의 지식을 무시 혹은 잊어버림으로써 발생한다. 코드의 특정 부분에 의미를 부여함으로써 해당 지식을 식별하고 표시할 수 있다.
아래의 예제는 연구 센터에서 학생들을 다음과 같은 기준으로 평가한다고 가정한다. 시험 통과 11점, 시험 통과실패 -5점, 1년이 지날 때마다 -2점이다.
def process_students_list(students):
# 생략 ...
students_ranking = sorted(
students, key=lambda s: s.passed * 11 - s.failed * 5 - s.years * 2
)
# 학생별 순위 출력
for student in students_ranking:
print(
"이름: {0}, 점수: {1}".format(
student.name,
(student.passed * 11 - student.failed * 5 - student.years * 2),
)
)
sorted 함수의 key 로 사용되는 lambda 가 특별한 도메인 지식을 나타내지만 아무런 정의가 없다. 특별히 할당된 이름이 있는 코드 블록도 없고 어떤 의미도 부여하지 않았다. 코드에서 의미를 부여하지 않았기 때문에 순위를 출력하는 동안 중복이 발생한다.
이렇게 도메인 문제에 대한 지식이 사용된 경우 의미를 부여해야 한다.
def score_for_student(student):
return student.passed * 11 - student.failed * 5 - student.years * 2
def process_students_list(students):
# 생략 ...
students_ranking = sorted(stuents, key=score_for_student)
# 학생별 순위 출력
for student in students_ranking:
print(
"이름: {0}, 점수: {1}".format(
student.name, score_for_student(student)
)
)
이 예제에서는 중복을 제거하는 가장 간단한 방법인 함수 생성 기법을 사용했다. 경우에 따라 최선의 해결책은 달라질 수 있다. 전체적으로 추상화를 하지 않은 경우 완전히 새로운 객체를 만드는 것이 좋다. 어떤 경우에는 컨텍스트 관리자를 사용하여 중복을 제거할 수 있다.
4-2. YAGNI
YAGNI(You Ain't Gonna Need It) 는 과잉 엔지니어링을 하지 않기 위해 솔루션 작성 시 계속 염두해야 하는 원칙이다.
우리는 프로그램을 쉽게 수정하기를 원하므로 미래 보장성이 높기를 바란다. 많은 개발자들이 미래의 모든 요구사항을 고려하여 매우 복잡한 솔루션을 만들고, 추상화를 하여 읽기 어렵고, 유지보수가 어렵고, 이해하기 어려운 코드를 만든다. 그러나 나중에 예상되는 요구사항이 나타나지 않거나 동작하기는 하지만 다른 방식으로 동작하며, 정교하게 처리할 것이라 믿었던 원래의 코드가 제대로 동작하지 않는다고 가정하면 리팩토링하고 확장하는 것이 매우 어려울 것이다.
유지보수가 가능한 소프트웨어를 만드는 것은 미래의 요구사항을 예측하는 것이 아니다. 오직 현재의 요구사항을 잘 해결하기 위한 소프트웨어를 작성하고 가능한 나중에 수정하기 쉽도록 작성하는 것이다. 즉, 굳이 필요 없는 추가 개발을 하지 말라는 것이다.
4-3. KIS
KIS(Keep It Simple) 는 이전 원칙과 유사하다. 과잉 엔지니어링을 피해야 한다. 선택한 솔루션이 최적의 솔루션인지 자문할 필요가 있다.
문제를 올바르게 해결하기 위한 최소한의 기능을 구현하고 필요한 것 이상으로 솔루션을 복잡하게 만들지 않아야 한다. 디자인이 단순할수록 유지 관리가 쉽다.
이 원칙은 높은 수준의 다지안을 할 때에도 특정 코드 라인을 다루는 디자인을 할 때에도 모든 추상화 수준에서 염두에 두어야 할 원칙이다.
일반적으로 코드 측면의 단순함이란 문제에 맞는 가장 작은 데이터 구조를 사용하는 것을 의미한다. 대부분 표준 라이브러리에서 찾을 수 있다.
다음의 예시에 작성된 클래스는 제공된 키워드 파라미터 세트에서 네임 스페이스를 작성하지만 다소 복잡한 코드 인터페이스를 가지고 있다.
class ComplicatedNamespace:
"""프로퍼티를 가진 객체를 초기화하는 복잡한 예제"""
ACCEPTED_VALUES = ("id_", "user", "location")
@classmethod
def init_with_data(cls, **data):
instance = cls()
for key, value in data.items():
if key in cls.ACCEPTED_VALUES:
setattr(instance, key, value)
return instance
객체를 초기화하기 위해 추가 클래스 메서드를 만드는 것은 꼭 필요해 보이지 않는다. 반복을 통해 setattr을 호출하는 것은 더 이상하게 보인다.
>>> cn = ComplicatedNamespace.init_with_data(
id_=42, user="root", location="127.0.0.1", extra="excluded"
)
>>> cn.id_, cn.user, cn.location
(42, 'root', '127.0.0.1')
>>> hasattr(cn, "extra")
False
초기화를 위해 init_with_data 라는 일반적이지 않은 메서드의 이름을 알아야 하는 것 또한 불편하다. 파이썬에서 객체를 초기화 할 때는 __init__ 메서드를 사용하는 것이 훨씬 간편하다.
class Namespace:
"""Create an object from keyword arguments"""
ACCEPTED_VALUES = ("id_", "user", "location")
def __init__(self, **data)"
accepted_data = {
k: v for k, v in data.items() if k in ACCEPTED_VALUES
}
self.__dict__.update(accepted_data)
파이썬의 철학 : 단순한 것이 복잡한 것보다 낫다.
4-4. EAFP/LBYL
EAFP(Easier to Ask Forgivness than Permission) 는 허락보다는 용서를 구하는 것이 쉽다는 뜻이다. LBYL(Look Before You Leap) 는 도약하기 전에 살피라는 뜻이다.
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:
logger.error(e)
파이썬에서는 LBYL 보다 EAFP 원칙이 더 바람직하다고 권고한다.
5. 컴포지션과 상속
객체 지향 소프트웨어를 디자인 할 때 다형성, 상속, 캡슐화 같은 주요 개념을 어떻게 사용하여 문제를 해결할 것인지에 대한 논쟁이 있었다.
그 중 가장 일반적인 개념이 상속일 것이다. 상속은 강력한 개념이지만 위험도 있다. 가장 주된 위험은 부모 클래스를 확장하여 새로운 클래스를 만들 때마다 부모와 강력하게 결합된 새로운 클래스가 생긴다는 점이다. 위에서 설명했듯 소프트웨어를 설계할 때 결합력(coupling) 을 최소한으로 줄이는 것이 중요하다.
상속과 관련하여 개발자들이 가장 많이 사용하는 기능은 코드 재사용이다. 그러나 코드 재사용을 위해 상속을 하는 것은 좋지 않는 생각이다. 코드를 재사용하는 올바른 방법은 여러 상황에서 동작 가능하고 쉽게 조합할 수 있는 응집력 높은 객체를 사용하는 것이다.
5-1. 상속이 좋은 선택인 경우
새로운 하위 클래스를 만들 때 클래스가 올바르게 정의되었는지 확인하기 위해 상속된 모든 메서드를 실제로 사용할 것인지 생각해보는 것이 좋다. 만약 대부분의 메서드를 필요로 하지 않고 재정의하거나 대체해야 한다면 다음과 같은 이유로 설계상의 실수라고 할 수 있다.
- 상위 클래스는 잘 정의된 인터페이스 대신 막연한 정의와 너무 많은 책임을 가졌다.
- 하위 클래스는 확장하려고 하는 상위 클래스의 적절한 세분화가 아니다.
상속을 잘 사용한 예는 다음과 같은 경우이다. public 메서드와 속성 인터페이스를 정의한 컴포넌트가 있다. 그리고 이 클래스의 기능을 그대로 물려받으면서 추가 기능을 더하려는 경우 또는 특정 기능을 수정하려는 경우이다.
파이썬 표준 라이브러리에서 좋은 상속의 예를 찾아볼 수 있다. 예를 들어 http.server 패키지에서 BaseHTTPRequestHandler 기본 클래스와 이 기본 인터페이스의 일부를 추가하거나 변경하여 확장하는 SimpleHTTPRequestHandler 하위 클래스가 있다.
https://docs.python.org/ko/3/library/http.server.html#http.server.BaseHTTPRequestHandler
인터페이스 정의는 또다른 상속의 좋은 예이다. 어떤 객체에 인터페이스 방식을 강제하고자 할 때 구현을 하지 않은 기본 추상 클래스를 만들고, 실제 이 클래스를 상속하는 하위 클래스에서 적절한 구현을 하도록 하는 것이다.
마지막으로 상속의 또 다른 사용 예는 예외이다. 파이썬 표준 예외는 Exception 에서 파생된다는 것을 알 수 있다. 이것은 except Exception : 같은 일반 구문을 통해 모든 에러를 catch 할 수 있게 해준다. 중요한 것은 모든 예외가 Exception 에서 상속받은 클래스라는 것이다. requests 같이 잘 알려진 라이브러리에서도 잘 동작한다. 예를 들어 HTTPError 는 RequestException 를 상속받고, RequestExcetpion은 IOError 를 상속받는다.
5-2. 상속 안티패턴
상속을 올바르게 사용하면 객체를 전문화하고 기본 객체에서 출발하여 세부적인 추상화를 할 수 있다.
부모 클래스는 새 파생 클래스의 공통 정의의 일부가 된다. 예를 들어 BaseHTTPRequestHandler 에서 파생된 클래스가 handle() 이라는 메서드를 구현했다면 부모 클래스의 일부를 오버라이딩한 것이다. HTTP 요청과 관련되어 보이는 이름의 메서드가 있다면 올바르게 위치했다고 볼 수 있다. 그러나 process_purchase() 와 같은 메서드의 경우에는 상속된 메서드라고 생각하지 않을 것이다.
당연해보이지만 코드 재사용만을 위해 상속하는 경우 자주 발생한다.
아래의 예제는 파이썬의 전형적인 안티 패턴을 보여준다. 도메인 문제를 해결하기 위해 적절한 데이터 구조를 만든 다음에 이 데이터 구조를 사용하는 객체를 만드는 것이 아니라 데이터 구조 자체를 객체로 만드는 경우이다.
class TransactionalPolicy(collections.UserDict):
"""Example of an incorrect use of inheritance."""
def change_in_policy(self, customer_id, **new_policy_data):
self[customer_id].update(**new_policy_data)
여러 고객에게 정책을 적용하는 기능을 가진 위와 같은 시스템이 있다. 위 코드에는 두 가지 문제점이 있다.
하나는 계층 구조가 잘못된 것이다. 기본 클래스에서 새 클래스를 만드는 것은 말 그대로 그것이 개념적으로 확장되고 세부적이라는 것을 의미한다. TransactionPolicy 라는 이름만 보고 어떻게 사전 타입이라는 것을 알 수 있을까?
또 다른 문제점은 결합력에 대한 문제이다. TransactionPolicy 는 이제 사전의 모든 메서드를 포함한다. TransactionPolicy 에 pop() 또는 items() 와 같은 메서드가 필요한가? 필요하지 않음에도 포함되어 있다. 이러한 메서드들은 public 이므로 이 인터페이스의 사용자는 부작용이 있을지도 모르는 이 메서드들을 호출할 수 있다. 게다가 사전 타입의 확장으로 얻는 이득도 없다. 현재 정책에 영향을 받는 모든 고객을 업데이트하는 메서드가 유일한 추가 메서드로서 부모 클래스와는 전혀 상관이 없다.
이것이 구현 객체를 도메인 객체와 혼합할 때 발생하는 문제이다.
올바른 해결책인 컴포지션을 사용하는 것이다. TransactionPolicy 자체가 사전이 되는 것이 아니라 사전을 활용하는 것이다. 사전을 private 속성에 저장하고 __getitem__() 으로 사전의 프록시를 만들고 나머지 필요한 public 메서드를 추가적으로 구현하는 것이다.
class TransactionalPolicy:
"""Example refactored to use composition."""
def __init__(self, policy_data, **extra_data):
self._data = {**policy_data, **extra_data}
def change_in_policy(self, customer_id, **new_policy_data):
self._data[customer_id].update(**new_policy_data)
def __getitem__(self, customer_id):
return self._data[customer_id]
def __len__(self):
return len(self._data)
이 방법은 개념적으로 정확할 뿐만 아니라 확장성도 뛰어나다. 현재 사전인 데이터 구조를 변경하려고 해도 인터페이스만 유지하면 사용자는 영향을 받지 않는다. 이는 결합력을 줄이고 파급 효과를 최소화하며 더 나은 리팩토링을 허용하고 코드의 유지 관리를 쉽게 만든다.
5-3. 파이썬의 다중상속
다중 상속을 사용한 가장 강력한 애플리케이션의 하나는 믹스인을 활용한 것이다.
# 메서드 결정 순서(MRO)
일부 개발자들은 다이아몬드 문제와 같은 제약조건이 생기기 때문에 다중 상속을 선호하지 않기도 한다.
다이아몬드 문제 : 두 개의 부모 클래스가 동일한 이름의 메서드를 가진 경우, 자식 클래스 입장에서 어떤 메서드를 사용할지 모호해지는 문제
class BaseModule:
module_name = "top"
def __init__(self, module_name):
self.name = module_name
def __str__(self):
return f"{self.module_name}:{self.name}"
class BaseModule1(BaseModule):
module_name = "module-1"
class BaseModule2(BaseModule):
module_name = "module-2"
class BaseModule3(BaseModule):
module_name = "module-3"
class ConcreteModuleA12(BaseModule1, BaseModule2):
"""Extend 1 & 2
>>> str(ConcreteModuleA12('name'))
'module-1:name'
"""
class ConcreteModuleB23(BaseModule2, BaseModule3):
"""Extend 2 & 3
>>> str(ConcreteModuleB23("test"))
'module-2:test'
"""
파이썬은 C3 linearization 또는 MRO 라는 알고리즘을 사용하여 이 문제를 해결한다.
>>> [cls.__name__ for cls in ConcreteModuleA12.mro()]
['ConcreteModuleA12', 'BaseModule1', 'BaseModule2', 'BaseModule', 'object']
# 믹스인(Mixin)
믹스인은 코드를 재사용하기 위해 일반적인 행동을 캡슐화해놓은 기본 클래스이다. 일반적으로 믹스인 클래스는 그 자체로는 유용하지 않으며 대부분이 클래스에 정의된 메서드나 속성에 의존하기 때문에 이 클래스만 확장해서는 확실히 동작하지 않는다. 보통은 다른 클래스와 함께 믹스인 클래스를 다중 상속하여 믹스인에 있는 메서드나 속성을 사용한다.
문자열을 받아서 하이픈으로 구분된 값을 반환하는 파서를 예로 들어 보자.
class BaseTokenizer:
def __init__(self, str_token):
self.str_token = str_token
def __iter__(self):
yield from self.str_token.split("-")
>>> tk = BaseTokenizer("28a2320b-fd3f-4627-9792-a2b38e3c46b0")
>>> list(tk)
['28a2320b', 'fd3f', '4627', '9792', 'a2b38e3c46b0']
이번에는 값을 대문자로 변환한다고 가정한다.
class UpperIterableMixin:
def __iter__(self):
return map(str.upper, super().__iter__())
class Tokenizer(UpperIterableMixin, BaseTokenizer):
pass
>>> tk = Tokenizer("28a2320b-fd3f-4627-9792-a2b38e3c46b0")
>>> list(tk)
['28A2320B', 'FD3F', '4627', '9792', 'A2B38E3C46B0']
새로운 Tokenizer 클래스는 믹스인을 이용하기 때문에 새로운 코드가 필요 없다. 이러한 유형의 혼합은 일종의 데코레이터 역할을 한다. Tokenizer 는 믹스인에서 __iter__ 를 호출하고 다시 super() 호출을 통해 다음 클래스 BaseTokenizer 에 위임한다. 이때는 이미 대문자를 전달하기 때문에 원하는 결과를 얻어낼 수 있다.
6. 함수와 메서드의 인자
소프트웨어 엔지니어링에서 함수의 인자를 정의하는 것과 관련해 업계에서 널리 사용하고 있는 관행이 있다.
6-1. 파이썬의 함수 인자 동작방식
# 인자는 함수에 어떻게 복사되는가
파이썬의 첫 번째 규칙은 모든 인자가 값에 의해 전달(passed by value) 된다는 것이다. 즉, 함수에 값을 전달하면 함수의 서명에 있는 변수에 할당하고 나중에 사용한다. 인자를 변경하는 함수는 인자의 타입에 따라 다른 결과를 낼 수 있다.
>>> 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 += <expression>" 문장은 사실 "arg + <expression>" 형태의 새로운 객체를 만들어서 arg 에 다시 할당한다. 이 시점에서 arg 는 단지 함수 스코프 내에 있는 로컬 변수이며 호출자의 원래 변수와는 아무런 관련이 없다.
반면에 mutable 객체인 리스트를 전달하면 list 의 extend() 를 호출하는 것과 같다. 원래 리스트 객체에 대한 참조를 보유하고 있는 변수를 통해 값을 수정하므로 함수 외부에서도 실제 값을 수정할 수 있다.
이러한 방식으로 변경하는 것이 절대적으로 옳은 상황이 아니라면 가급적 피하고 다른 대안을 찾는 것이 좋다.
함수 인자를 변경하지 않아야 한다. 최대한 함수에서 발생할 수 있는 부작용을 피해야 한다.
# 가변인자
가변 인자를 사용하려면 해당 인자를 패킹할 변수의 이름 앞에 별표를 사용한다.
>>> def f(first, second, third):
print(first)
print(second)
print(third)
>>> l = [1, 2, 3]
>>> f(*l)
1
2
3
부분적인 언패킹도 가능하다.
>>> def show(e, rest):
print("요소: {0} - 나머지: {1}".format(e, rest))
>>> first, *rest = [1, 2, 3, 4, 5]
>>> show(first, rest)
요소: 1 - 나머지: [2, 3, 4, 5]
>>> *rest, last = range(6)
>>> show(last, rest)
요소: 5 - 나머지: [0, 1, 2, 3, 4]
>>> first, *middle, last = range(6)
>>> first
0
>>> middle
[1, 2, 3, 4]
>>> last
5
>>> first, last, *empty = (1, 2)
>>> first
1
>>> last
2
>>> empty
[]
변수 언패킹의 가장 좋은 사용 예는 반복이다.
USERS = [(i, f"first_name_{i}", f"last_name_{i}") for i in range(1_000)]
class User:
def __init__(self, user_id, first_name, last_name):
self.user_id = user_id
self.first_name = first_name
self.last_name = last_name
def __repr__(self):
return f"{self.__class__.__name__}({self.user_id!r}, {self.first_name!r}, {self.last_name!r})"
def bad_users_from_rows(dbrows) -> list:
"""A bad case (non-pythonic) of creating ``User``s from DB rows."""
return [User(row[0], row[1], row[2]) for row in dbrows]
def users_from_rows(dbrows) -> list:
"""Create ``User``s from DB rows."""
return [
User(user_id, first_name, last_name)
for (user_id, first_name, last_name) in dbrows
]
참고) Underscores in Numeric Literals
https://www.python.org/dev/peps/pep-0515/
두 번째 버전이 훨씬 읽기 쉽다. 첫 번째 버전의 row[0], row[1], row[2] 는 어떤 의미인지 전혀 알 수가 없다.
위와 같은 사용 예시를 표준 라이브러리의 max 함수에서 찾을 수 있다.
max 함수의 정의는 다음과 같다.
max(...)
max(iterable, *[, default=obj, key=func]) -> value
max(arg1, arg2, *args, *[, key=func]) -> value
With a single iterable argument, return its biggest item.
The default keyword-only argument specifies an object to return if the provided iterable is empty.
With two or more arguments, return the largest argument.
비슷한 표기법으로 이중 별표를 키워드 인자에 사용할 수 있다.
function(**{"key": "value"})
이것은 다음과 동일하다
function(key="value")
반대로 이중 별표로 시작하는 파라미터를 함수에 사용하면 키워드 인자들이 사전으로 패킹된다.
>>> def function(**kwargs):
print(kwargs)
>>> function(key="value")
{'key': 'value'}
6-2. 함수 인자의 개수
너무 많은 인자를 사용하는 함수나 메서드가 왜 나쁜 코드인지, 그 해결 방법이 무엇인지 알아본다.
# 함수 인자와 결합력
함수 인자가 많을수록 호출자 함수와 밀접하게 결합될 가능성이 커진다.
f1, f2 두 함수가 있고, 후자는 다섯 개의 파라미터를 사용한다고 가정해보자. f2 가 더 많은 파라미터를 사용할수록 호출자는 정상 동작을 위한 모든 정보를 수집하는 것이 점점 더 어려워질 것이다.
f1 이 f2 호출을 위한 모든 정보를 갖고 있다면, f2 는 추상화가 부족했을 것이다. f1 은 f2가 필요로 하는 모든 것을 알고 있고, f2 내부적으로 무엇을 하는지 알아낼 수 있으며 거의 자체적으로 수행 가능하다. 결국 f2 는 그렇게 많은 것을 추상화하지 못한 것이다. 또한 f2는 다른 환경에서 사용하기가 어려워 f1 에서만 유용하기 때문에 코드 재사용성이 떨어진다.
함수가 제대로 동작하기 위해 너무 많은 파라미터가 필요한 경우 좋지 않은 코드라고 볼 수 있다.
# 많은 인자를 취하는 작은 함수의 서명
너무 많은 파라미터를 사용하는 함수를 리팩토링하기 위해서는 어떻게 해야 하는가
만약 공통 객체에 파라미터 대부분이 포함되어 있다면 가장 쉽게 수정할 수 있다.
track_request(request.headers, request.ip_addr, request.request_id)
모두 request 와 관련이 있다. 그렇다면 그냥 request 를 파라미터로 전달하는 것이 낫다. 간단하지만 코드의 질을 크게 향상시킨다.
이처럼 변경 가능한 객체를 전달할 때에는 함수가 전달받은 객체를 변경하지 않도록 주의해야 한다. 명시적으로 의도한 경우가 아니라면 권장하지 않는다. 실제로 객체의 무언가를 바꾸고 싶다면 전달된 값을 복사한 다음 새로운 수정본을 반환하는 것이 더 낫다.
변경 불가능한(immutable) 객체를 사용하여 부작용을 최소화하는 것이 좋다.
최후의 수단으로 *args, **kwargs 를 사용하여 다양한 인자를 받도록 할 수 있다. 이런 경우 문서화를 해야한다. *args, **kwargs 로 정의된 함수가 융통성 있고 적응력이 좋은 것은 사실이지만, 가독성을 거의 상실한다. docstring 을 만들어 놓지 않는다면 파리미터를 보고 정확한 동작을 알 수가 없다.
7. 소프트웨어 디자인 우수 사례 결론
앞서 본 일반 원칙과 더불어 권장사항 몇 가지를 추가한다.
7-1. 소프트웨어의 독립성(orthogonality)
모듈, 클래스 또는 함수를 변경하면 수정한 컴포넌트가 외부 세계에 영향을 미치지 않아야 한다.
파이썬에서 함수는 일반 객체일 뿐이므로 파라미터로 전달할 수 있다. 독립성을 얻기 위해 이 기능을 활용할 수 있다.
def calculate_price(base_price: float, tax: float, discount: float) -> float:
return (base_price * (1 + tax)) * (1 - discount)
def show_price(price: float) -> str:
return "$ {0:,.2f}".format(price)
def str_final_price(
base_price: float, tax: float, discount: float, fmt_function=str
) -> str:
return fmt_function(calculate_price(base_price, tax, discount))
위의 두 개의 함수는 독립성을 갖는다. 하나는 가격을 계산하는 함수이고, 다른 하나는 가격을 어떻게 표현할 지에 대한 함수이다. 만약 하나를 변경해도 다른 하나는 변경되지 않는다. 마지막 함수는 아무것도 전달하지 않으면 문자열 변환을 기본 표현 함수로 사용하고 사용자 정의 함수를 전달하면 해당 함수를 사용해 문자열을 포맷한다. 그러나 show_price 의 변경 사항은 calculate_price에 영향을 미치지 않는다.
>>> str_final_price(10, 0.2, 0.5)
'6.0'
>>> str_final_price(1000, 0.2, 0)
'1200.0'
>>> str_final_price(1000, 0.2, 0.1, fmt_function=show_price)
'$ 1,080.00'
코드의 두 부분이 독립적이라는 것은 변경된 부분의 단위 테스트가 나머지 단위 테스트와도 독립적임을 뜻한다. 이러한 가정 하에 두 개의 테스트가 통과하면 전체 회귀 테스트를 하지 않고도 애플리케이션에 문제가 없다고 어느 정도 확신할 수 있다.
7-2. 코드 구조
코드를 구조화하는 방법은 팀의 작업 효율성과 유지보수성에 영향을 미친다.
특히 여러 정의(클래스, 함수, 상수 등)가 들어있는 큰 파일을 만드는 것은 좋지 않으므로 권장하지 않는다. 극단적으로 하나의 파일에 하나의 정의만 유지하라는 것은 아니지만, 좋은 코드라면 유사한 컴포넌트끼리 정리하여 구조화해야 한다.
파이썬에서는 __init__.py 파일을 가진 새 디렉토리를 생성함으로써 작은 파일 단위로 나눌 수 있다. 또한 __all__ 변수에 익스포트 가능하도록 표시할 수도 있다.
이를 통해 얻을 수 있는 장점은 아래와 같다.
- 모듈을 임포트할 때 구문을 분석하고 메모리에 로드할 객체가 줄어든다.
- 의존성이 줄었기 때문에 더 적은 모듈만 가져오면 된다.
또한 프로젝트를 위한 컨벤션을 갖는 데에도 도움이 된다. 예를 들어 모든 파일에서 상수를 정의하는 대신, 프로젝트에서 사용할 상수 값을 저장할 특정한 파일을 만들고 다음과 같이 임포트하면 된다.
from myproject.constants import CONNECTION_TIMEOUT
'python' 카테고리의 다른 글
파이썬 클린 코드 - 5장 (데코레이터를 사용한 코드 개선) (0) | 2022.02.18 |
---|---|
파이썬 클린 코드 - 4장 (SOLID 원칙) (0) | 2022.02.09 |
파이썬 클린 코드 - 2장 (Pythonic code) (0) | 2022.01.27 |
파이썬 클린 코드 - 1장 (코드 포매팅과 도구) (0) | 2022.01.25 |
Architecture Patterns with Python(13장) (0) | 2022.01.20 |