"이 책은 파이썬을 이용한 소프트웨어 개발에 대해 다룬다. 좋은 소프트웨어는 좋은 디자인으로부터 나온다. 클린 코드에 대해서 말하면 디자인보다는 세부 구현의 모범 사례에 대해서만 생각하기 쉽다. 그러나 이러한 생각은 잘못된 생각이다. 왜냐하면 코드가 디자인이고, 디자인이 코드이기 때문이다."
3장에서는 더 높은 수준의 추상화를 할 수 있도록 도와주는 디자인 원칙에 대해 알아본다. 특히 훌륭한 소프트웨어 디자인을 위한 몇 가지 원칙을 검토한다.
3장의 목표는 다음과 같다.
"소프트웨어는 사용자가 만들어서 직접 사용하기도 하지만 다른 레이어나 컴포넌트에서 호출하는 경우도 있다. 이런 경우 이들 간의 교류를 어떻게 해야 하는지 고민해보자"
컴포넌트는 기능을 숨겨 캡슐화하고 함수를 사용할 클라이언트에게는 API를 노출해야 하는데, 컴포넌트의 함수, 클래스, 메서드는 특별한 유의사항에 따라 동작해야 한다. 반대로 코드를 호출하는 클라이언트는 특정 형태의 응답이나 실패를 기대하고 해당 형태와 다른 형태를 받는 경우 함수 호출에 실패하게 되고 부가적인 결함이 생기는 경우가 있다. (정수를 파라미터로 사용하는 함수에 문자열을 파라미터로 전달하는 등)
물론 API를 디자인할 때 예상되는 입출력과 부작용을 문서화해야한다. 그러나 문서화가 런타임 시의 동작까지 강제할 수는 없으니 코드가 정상적으로 동작하기 위해 필요한 것과 클라이언트가 반환 받게 될 형태는 모두 디자인에 포함이 되어야 한다. 여기에서 계약 이라는 개념이 생겼다.
계약에 의한 디자인(Design by Contract)이란 관계자가 기대하는 바를 암묵적으로 코드에 삽입하는 대신 양측이 동의하는 계약을 먼저 한 다음, 계약을 어겼을 경우는 명시적으로 왜 계속할 수 없는지 예외를 발생시키라는 것이다.
여기서 말하는 계약은 소프트웨어 컴포넌트 간의 통신 중에 반드시 지켜져야 할 몇 가지 규칙을 강제하는 것이다. 계약에서 명시하는 내용은 다음과 같다.
사전조건(precondition)
- 코드가 실행되기 전에 체크해야 하는 것. 함수가 진행되기 전에 처리되어야 하는 모든 조건을 체크한다. 일반적으로 파라미터에 제공된 데이터의 유효성을 검사하는 것이다.
사후조건(postcondition)
- 함수의 반환값의 유효성을 검사하는 것. 이는 호출자가 이 컴포넌트에서 기대한 것을 제대로 받았는지 확인하기 위해 수행한다.
불변식(invariant)
- 불변식은 함수가 실행되는 동안에 일정하게 유지되는 것으로 함수의 로직에 문제가 없는지 확인하기 위한 것. 때로는 함수의 docstring에 불변식에 대해 문서화하는 것이 좋다.
부작용(side-effect)
- 선택적으로 코드의 부작용을 docstring에 언급하기도 한다.
이상적으로는 모든 것들을 소프트웨어 컴포넌트 계약서의 일부로 문서화해야 하지만, 사전조건과 사후조건만 저수준 레벨에서 강제한다.
계약에 의한 디자인을 하는 이유는 오류를 쉽게 찾아낼 수 있기 때문이다. 사전조건 또는 사후조건 검증에 실패한 경우 오류를 쉽게 찾아서 수정할 수 있다. 또한 잘못된 가정하에 코드의 핵심 부분이 실행되는 것을 방지하기 위해서이다. 이렇게 하면 단지 애플리케이션의 어떤 부분에서 실패했다는 에러를 발생시키는데서 그치는 것이 아니라 책임의 한계를 명확히 하는데 도움이 된다. (호출자가 잘못된 인자를 제공한 경우 호출자의 코드를 고쳐야한다.)
사전조건 검증에 실패하면 클라이언트의 결함에 의한 것, 사후조건 검증에 실패하면 특정 모듈이나 제공 클래스 자체에 문제가 있다는 것
특히 사전조건은 런타임 중에 확인할 수 있다는 점이 중요하다. 만약 사전조건에 맞지 않는다면 실행하지 않아야 한다.
사전조건은 함수나 메서드가 제대로 동작하기 위해 보장해야 하는 모든 것을 말한다. 일반적으로는 제공하는 데이터가 적절한 형태여야 하는 것이다. (객체가 초기화 되어 있어야 한다거나 null이 아니어야 한다거나 하는 등의 조건)
mypy와 같은 도구가 하는 타입 체킹과는 달리, 필요로 하는 값이 정확한지 확인하는 것에 가깝다.
함수는 처리할 정보에 대한 적절한 유효성 검사를 해야 한다. 문제는 이 유효성 검사를 어디서 할지인데, 1.) 클라이언트가 함수를 호출하기 전에 모든 유효성 검사를 하도록 할 것인지 아니면 2.) 함수가 자체적으로 로직을 실행하기 전에 검사하도록 할 것인지에 대해서 고민할 수 있다.
1.)은 관대한(tolerant) 접근법이다. 왜냐하면 함수 입장에서는 여전히 어떤 값이라도 수용하기 때문이다. 반면 2.)는 까다로운(demanding) 접근법이다. 일반적으로 까다로운 접근법이 가장 안전하고 견고한 방식이며, 널리 쓰이는 방법이다.
어떤 방식을 택하든 중복 제거 원칙을 항상 생각해야 한다. 중복 제거 원칙은 사전조건 검증을 양쪽에서 하지 말고 오직 어느 한쪽에서만 해야 한다는 것이다. 즉 검증 로직을 클라이언트에 두거나 함수 자체에 두어야 한다.
사후조건은 메서드 또는 함수가 반환된 후의 상태를 강제하는 것이다. 함수 또는 메서드가 적절하게 호출되었다면(사전조건을 만족한다면) 사후조건은 특정 속성이 보존되어야 한다.
위 디자인 원칙을 구현하는 가장 좋은 방법은 메서드, 함수, 클래스에 제어 메커니즘을 추가하고 검사에 실패할 경우 RuntimeError나 ValueError를 발생시키는 것이다. 또한 코드를 가능한 한 격리된 상태로 유지하는 것이 좋다. 즉 사전조건에 대한 검사와 사후조건에 대한 검사 그리고 핵심 기능에 대한 구현을 구분하는 것이다. 더 작은 함수를 생성하여 해결할 수도 있지만, 데코레이터를 사용하는 것도 좋은 방법이다.
방어적 프로그래밍은 DbC와는 다소 다른 접근 방식을 따른다. 계약에서 예외를 발생시키고 실패하게 되는 모든 조건을 기술하는 대신 객체, 함수 또는 메서드와 같은 코드의 모든 부분을 유효하지 않은 것으로부터 스스로 보호할 수 있게 하는 것이다.
방어적 프로그래밍의 주요 주제는 예상할 수 있는 시나리오의 오류를 처리하는 방법과 (불가피한 조건에 의해서) 발생하지 않아야 하는 오류를 처리하는 방법에 대한 것이다. 전자는 에러 해들링 프로시저에 대한 것이며, 후자는 어설션(assertion)에 대한 것이다.
오류가 발생하기 쉬운 상황에서 에러 핸들링 프로시저를 사용하는데 일반적으로 데이터 입력 확인 시 자주 사용된다.
에러 핸들링의 주요 목적은 예상되는 에러에 대해서 실행을 계속할 수 있을지 아니면 극복할 수 없는 오류여서 프로그램을 중단할지를 결정하는 것이다.
프로그램에서 에러를 처리하는 방법은 여러가지가 있지만, 모든 방법이 항상 적용 가능한 것은 아니다. 에러 처리 방법으로는 다음과 같은 것들이 있다.
값 대체(value substitution)
일부 시나리오에서는 오류가 있어 소프트웨어가 잘못된 값을 생성하거나 전체가 종료될 위험이 있을 경우 결과 값을 안전한 다른 값으로 대체하는 것을 값 대체라고 한다. 예를 들어 결과 값을 누적시키는 경우 0을 반환하여 결과에 영향을 미치지 않게 만드는 것이다.
값 대체가 항상 가능하지는 않다. 대체 값이 실제로 안전한 옵션인 경우인지 신중하게 선택해야 한다. 이 결정은 예상치 못한 상황에서도 실패하지 않아야 하는 견고성과 적확성 간의 트레이드오프 관계에 있다. 어떤 소프트웨어는 부정확한 결과를 그대로 내보낼 수 없기 때문에 중요한 정보를 다루는 경우 잘못된 대체 값을 사용하는 것보다 정확성을 선택해야 한다.
안전한 방법의 하나로 정보가 제공되지 않을 경우 기본 값을 제공할 수 있다. 설정되지 않은 환경 변수의 기본 값, 설정 파일의 누락된 항목 또는 함수의 파라미터와 같은 것들은 기본 값으로 동작이 가능한 것들이다. 예를 들어 딕셔너리의 get 메서드는 두 번째 파라미터에 기본값을 지정할 수도 있다.
config = {'dbport': 5432}
config.get('dbport':"localhost")
config.get('dbport')
환경 변수에도 유사한 API가 있다.
import os
os.getenv("DBHOST")
os.getenv("DPORT",5432)
위 두 예제 모두 두 번째 파라미터를 제공하지 않으면 None을 반환한다. None이 함수에서 정의한 기본 값이기 때문이다.
사용자 정의 함수에서도 기본 값을 직접 정의할 수 있다.
def connect_database(host = 'localhost', port=5432):
...
일반적으로 누락된 파라미터를 기본 값으로 바꾸어도 큰 문제가 없지만 오류가 있는 데이터를 유사한 값으로 대체하는 것은 더 위험하며 일부 오류를 숨겨버릴 수 있다.
예외 처리
어떤 경우에는 잘못된 데이터를 사용하여 계속 실행하는 것보다는 차라리 실행을 멈추는 것이 더 좋을 수 있다. 이런 경우에는 호출자에게 실패했음을 빨리 알리는 것이 좋은 선택이다. 함수는 심각한 오류에 대해 명확하고 분명하게 알려줘서 적절하게 해결할 수 있도록 해야 한다.
비지니스 로직을 처리하기 위해 예외를 go-to문처럼 사용해서는 안된다. 호출자가 알아야만 하는 실질적인 문제가 있을 경우에만 예외를 발생시켜야 한다.
예외는 대게 호출자에게 잘못을 알려주는 것이다. 예외는 캡슐화를 약화시키기 때문에 신중하게 사용해야 한다.
예외는 오직 한 가지 일을 하는 함수의 한 부분이어야 한다. 함수가 처리하는(또는 발생시키려는)예외는 함수가 캡슐화하고 있는 로직에 대한 것이어야 한다.
서로 다른 수준의 추상화를 혼합하는 예제를 살펴보자
class DataTransport:
""" 다양한 수준의 예외를 처리하는 예"""
_RETRY_BACKOFF : int =5
_RETRY_TIMES : int = 3
def __init__(self, connector: Connector)->None:
self.__connector = connector
self.connection = None
def diliever_event(self, envent: Event):
try:
self.commect()
data = event.decode()
self.send()
axcept ConnectionError as e:
logger.info("커넥션 오류 발견 : %s",e)
raise
axcept ValueError as e:
logger.error("%r 이벤트에 잘못된 데이오 파ㅎ")
raise
def connect(self):
for _ in range(self._RETRY_TIMES):
try:
self.connection = self._connector.connect()
except ConnectionError as e:
logger.info("$s: 새로운 커넥션 시도 %is",e,self._RETRY_BACKOFF)
time.sleep(self,_RETRY_BACKOFF)
else:
return self.connection
raise ConnectorError(f"연결실패 재시도 횟수 {self._RETRY_TIMES} times")
def send(self,data:bytes):
return self.connection.send(data)
deliver_event() 메서드가 예외를 처리하는 방법에 초점을 맞추어 분석해본다.
ValueError와 ConnectionError는 별로 관계가 없는데, 이렇게 매우 다른 유형의 오률를 분석함으로 책임을 어떻게 분석해야 하는지에 대한 아이디어를 얻을 수 있다.
ValueError는 decode 메서드에 속한 에러이다.
ConnectionError는 connect 메서드 내에서 처리되어야한다. 이렇게 하면 행동을 명확하게 분리할 수 있다.
def connect_sith_retry(connector: Connector, retry_n_times: int, retry_backoff:int=5)
"""<connector>를 사용해 연결을 시도함.
연결에 실패할 경우 <retry_n_times>회 만큼 재시도
재시도 사이에는 <retry_backoff>초 만큼 대기
연결에 성곡하면 connection 객체를 반환
재시도 횟수를 초과하여 연결에 실패한 경우 ConnectionError 오류 발생
:param connector: connect() 메서드를 가진 객체
:param retry_n_times int: 연결 재시도 횟수
:param retry_backoff int: 재시도 사이의 대기 시간(초)
"""
for _ in range(retry_n_times):
try:
return connector.connect()
except ConnectionError as e:
logger.info(%s: 연결 싪패 (%i 초 후에 연결 재시도",e,retry_backoff)
time.sleep(retry_backoff)
exc = ConnectionError(f"연결 실패 ({retry_n_times)
logger.exception(exc)
raise exc
이제 원래 deliver event메서드에서 이 함수를 호출하면 된다. event의 ValueError예외에 대해서도 새로운 객체로 분리할 수 있지만, 일단 다른 메서드로 분리할 수 있지만 다른 메서드로 분리하는 것으로 한다.
두 가지를 적용해 새 메서드는 훨씬 더 작고 읽기 쉽다.
class DataTransport:
""" 추상화 수준에 따른 예외 분리를 한 객체"""
_RETRY_BACKOFF: int = 5
_RETRY_TIMES: 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, self._RETRY_TIMES, 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 정보, 메시지 및 기타 수집 가능한 정보를 최대한 로그로 남기는 것이 중요하다 그러나 이러한 세부사항은 절대 사용자에게 보여서는 안 된다.
비어있는 except 블록 지양
이것은 파이썬의 안티패턴 중에서도 가장 악마 같은 패턴이다. 일부 오류에 대비하여 프로그램을 방어하는 것은 좋은 일이지만 너무 방어적인 것은 더 심각한 문제로 이어질 수 있다. 특히 너무 방어적이어서 아무것도 하지 않은 채로 조용히 지나쳐버리는 비어있는 except블록은 가장 안 좋은 예이다.
아무것도 하지 않는 예외 블록을 자동으로 탐지할 수 있도록 CI 환경을 구축하자
이에 대해서는 보다 구체적인 예외를 사용하여 처리하거나, except 블록에서 실제 오류를 처리하는 대안이 있고 만약 명시적으로 해당 블록을 무시하려면 contextlib.suppress 함수를 사용하는 것이 올바른 방법이다.
try:
process_data()
except:
pass
import contextlib
with contextlib.suppress(KeyError): # 여기서도 구체적인 예외를 지정해야한다.
process_data()
원본 예외 포함
오류 처리 과정에서 기존 오류와 다른 새로운 오류를 발생시키고 오류 메시지를 변경할 수 있는데, 이런 경우 원래 어떤 오류가 있었는지에 대한 정보를 포함하는 것이 좋다.
예를 들어 기본 예외를 사용자 정의 예외로 래핑하고 싶다면 루트 예외에 대한 정보를 다음과 같이 포함할 수 있다.
class InternalDataError(Exception):
""" 업무 도메인 데이터의 예외 """
def process(data_dictionary, record_id):
try:
return data_dictionary[record_id]
except KeyError as e:
raise InternalDataError("데이터가 존재하지 않음") from e
예외 처리 중에 새로운 예외를 다시 정의하는 경우 항상 raise from 구문을 사용하자
이 구문을 사용하면 traceback에 방금 발생한 오류에 대해서 보다 많은 정보를 전달 가능하다.
파이썬에서 어설션(assertion) 사용하기
어설션은 절대로 일어나지 않아야 하는 상황에 사용되므로 assert 문으로 사용된 표현식은 불가능한 조건을 의미한다. 이 상태가 된다는 건 소프트웨어에 결함이 있음을 의미한다.
예외처리 방식과 비교해보면 어떤 특정 상황이 발생했을 때에는 더 이상 프로그램을 실행하는 것이 의미가 없을 수도 있다. 더 이상 극복할 수 없는 오류이거나 프로그램 내에서 스스로 치유할 수 있는 다른 방법을 찾기 어려운 경우인데, 이런 경우는 빨리 실패하고 다음 버전에서 수정이 가능하도록 그 상황을 알려주는 것이 더 나은 선택일 수 있다.
문법상으로 어설션은 항상 참이어야만 하는 Boolean 조건이다. 만약 이 조건이 False가 되어 AssertionError 가 발생했다면, 프로그램에서 극복할 수 없는 치명적인 결함이 발견되었다는 뜻이다.
이러한 이유로 어설션을 비지니스 로직과 섞거나 소프트웨어의 제어 흐름 메커니즘으로 사용해서는 안 된다. 다음 예제는 좋지 못한 예제이다.
try:
assert condition.holds(), "조건에 맞지 않음"
except AssertionError:
alternative_procedure()
assertion에서 함수를 직접 호출하지 말고, 로컬 변수에 저장한 다음에 비교하자
일반적으로 예외는 예상하지 못한 상황을 처리하기 위한 것이고 assertion은 정확성을 보장하기 위해 스스로 체크하기 위한 것임을 알고 가자 어설트 구문은 항상 고정된 조건을 검증할 때 사용된다.
여기까지 파이썬 방어적 프로그래밍과 계약에 의한 디자인을 살펴보았다.