반응형

계약에 의한 디자인

관계자가 기대하는 바를 암묵적으로 코드에 삽입 X

양측이 동의하는 계약을 먼저 한 다음, 계약을 어겼을 경우는 명시적으로 왜 계속할 수 없는지 예외를 발생시키라는 것

책에서 말하는 계약은 소프트웨어 컴포넌트 간의 통신 중에 반드시 지켜져야 할 몇 가지 규칙을 강제하는 것

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

사전조건(precondition)

  • 함수나 메소드가 제대로 동작하기 위해 보장해야 하는 모든 것들
  • 함수는 처리할 정보에 대한 적절한 유효성 검사를 해야 하는데 어디서 할지에 대해 2가지로 나뉨
    • 관대한(tolerant) 접근법: 클라이언트가 함수를 호출하기 전에 모든 유효성 검사를 진행
    • 까다로운(demanding) 접근법: 함수가 자체적으로 로직을 실행하기 전에 검사를 진행

⇒ 어디에서 유효성 검사를 진행하든 어느 한쪽에서만 진행해야 함

사후조건(postcondition)

  • 함수나 메소드가 반환된 후의 상태를 강제하는 것

파이썬스러운 계약

  • 메소드, 함수, 클래스에 제어 메커니즘을 추구하고 검사에 실패할 경우 RuntimeError나 ValueError를 발생시키는 것
  • 사전조건, 사후조건 검사, 핵심 기능 구현은 가능한 한 격리된 상태로 유지하는 것이 좋음

계약에 의한 디자인(DbC) - 결론

  • 문제가 있는 부분을 효과적으로 식별하는데 가치가 있음
  • 명시적으로 함수나 메소드가 정상적으로 동작하기 위해 필요한 것이 무엇인지, 무엇을 반환하는지를 정의해 프로그램의 구조를 명확히 할 수 있음
  • 원칙에 따라 추가적인 작업이 발생하지만 이방법으로 얻은 품질은 장기적으로 보상됨

방어적(defensive) 프로그래밍

  • 계약에 의한 디자인과는 다른 접근 방식
  • 계약에서 예외를 발생시키고 실패하게 되는 모든 조건을 기술하는 대신 코드의 모든 부분을 유효하지 않은 것으로부터 스스로 보호할 수 있게 하는 것
    • 예상할 수 있는 시나리오의 오류를 처리 - 에러 핸들링 프로시져
    • 발생하지 않아야 하는 오류를 처리하는 방법 - assertion error

에러 핸들링

  • 일반적으로 데이터 입력확인 시 자주 사용
  • 목적은 예상되는 에러에 대해서 실행을 계속할지/ 프로그램을 중단할지 결정하는 것

에러처리방법

  • 값 대체(value substitution)
  • 에러 로깅
  • 예외 처리

값 대체

  • 일부 시나리오에서 오류가 있어 소프트웨어가 잘못된 값을 생성하거나 전체가 종료될 위험이 있을 경우 결과 값을 안전한 다른 값으로 대체하는 것
  • 항상 가능하지는 않고 신중하게 선택해야 함 (견고성과 정확성 간의 trade-off)
  • 정보가 제공되지 않을 경우 기본 값을 제공할 수도 있음
import os

configuration = {"dbport": 5432}
print(configuration.get("dbhost", "localhost"))  # localhost
print(configuration.get("dbport"))  # 5432

print(os.getenv("DBHOST"))  # None

print(os.getenv("DPORT", 5432))  # 5432
  • 두번째 파라미터 값을 제공하지 않으면 None을 반환

사용자 정의함수에서도 파라미터의 기본 값을 직접 정의할 수 있음

def connect_database(host="localhost", port=5432):
    pass
  • 일반적으로 누락된 파라미터를 기본 값으로 바꾸어도 큰 문제가 없지만 오류가 있는 데이터를 유사한 값으로 대체하는 것을 더 위험하여 일부 오류를 숨겨버릴 수 있음

예외처리

어떤 경우에는 잘못된 데이터를 사용하여 계속 실행하는 것보다는 차라리 실행을 멈추는 것이 더 좋을 수 있음

  • 입력이 잘못되었을 때만 함수에 문제가 생기는 것이 아님 (외부 컴포넌트에 연결되어 있는 경우)
  • 이런 경우에는 함수 자체의 문제가 아니기 때문에 적절하게 인터페이스를 설계하면 쉽게 디버깅 할 수 있음

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

정상적인 시나리오나 비즈니스 로직을 예외처리하려고 하면 프로그램의 흐름을 읽기가 어려워짐

→ 예외를 go-to문처럼 사용하는 것과 같다. 올바른 위치에서 추상화를 하지 못하게 되고 로직을 캡슐화하지도 못하게 됨.

마지막으로 예외를 대게 호출자에게 잘못을 알려주는 것으로 캡슐화를 약화시키기 때문에 신중하게 사용해야 함→이는 함수가 너무 많은 책임을 가지고 있다는 것을 의미할 수도 있음. 함수에서 너무 많은 예외를 발생시켜야 한다면 여러개의 작은 기능으로 나눌 수 있는지 검토해야 함

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

  • 예외는 오직 한가지 일을 하는 함수의 한 부분이어야 함
  • 서로 다른 수준의 추상화를 혼합하는 예제. deliver_event 메소드를 중점적으로 살펴보면
import logging
import time

logger = logging.getLogger(__name__)

class DataTransport:
    """다른 레벨에서 예외를 처리하는 객체의 예"""

    _RETRY_BACKOFF: int = 5
    _RETRY_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_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 ConnectionError(f"연결실패 재시도 횟수 {self._RETRY_TIMES} times")

    def send(self, data):
        return self.connection.send(data)
    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
  • ConnectionError와 ValueError는 별로 관계가 없음
  • 매우 다른 유형의 오류를 살펴봄으로써 책임을 어떻게 분산해야 하는지에 대한 아이디어를 얻을 수 있음
    • ConnectionError는 connect 메소드 내에서 처리되어야 함. 이렇게 하면 행동을 명확하게 분리할 수 있다. 메소드가 재시도를 지원하는 경우 메소드 내에서 예외처리를 할 수 있음
    • ValueError는 event의 decode 메소드에 속한 에러로 event를 send 메소드에 파라미터로 전달 후 send 메소드 내에서 예외처리를 할 수 있음
  • 위 내용처럼 구현을 수정하면 deliver_event 메소드에서 예외를 catch할 필요가 없음
def connect_with_retry(connector, retry_n_times: int, retry_backoff: int = 5):
    """<connector>를 사용해 연결을 시도함.
    연결에 실패할 경우 <retry_n_times>회 만큼 재시도
    재시도 사이에는 <retry_backoff>초 만큼 대기

    연결에 성공하면 connection 객체를 반환
    재시도 횟수를 초과하여 연결에 실패하면 ConnectionError 오류 발생

    :param connector: connect() 메소드를 가진 객체
    :param retry_n_times: 연결 재시도 횟수
    :param retry_backoff: 재시도 사이의 대기 시간(초)

    """
    for _ in range(retry_n_times):
        try:
            return connector.connect()
        except ConnectionError as e:
            logger.info("%s: 새로운 커넥션 시도 %is", e, retry_backoff)
            time.sleep(retry_backoff)

    exc = ConnectionError(f"연결 실패 ({retry_n_times}회 재시도)")
    logger.exception(exc)
    raise exc
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 contains incorrect data: %s", event, e)
            raise
  • deliver_event 메소드 내에서 예외 catch 하는 부분 없어짐

엔드 유저에게 Traceback 노출 금지

  • 보안을 위한 고려사항으로 예외가 전파되도록하는 경우는 중요한 정보를 공개하지 않고 “알 수 없는 문제가 발생했습니다” 또는 “페이지를 찾을 수 없습니다”와 같은 일반적인 메세지를 사용해야 함

비어있는 except 블록 지양

  • 파이썬의 안티패턴 중 가장 악마같은 패턴(REAL 01)으로 어떠한 예외도 발견할 수 업슨 문제점이 있음
try:
    process_data()
except: 
    pass
  • 아무것도 하지 않는 예외 블록을 자동으로 탐지할 수 있도록 CI 환경을 구축하면 좋음
더보기

flake8
pylint

https://pylint.pycqa.org/en/latest/user_guide/messages/warning/bare-except.html

name: Lint Code

on: [push, pull_request]

jobs:
  lint:
    runs-on: ubuntu-latest

    steps:
    - name: Checkout code
      uses: actions/checkout@v2

    - name: Set up Python
      uses: actions/setup-python@v2
      with:
        python-version: '3.x'

    - name: Install dependencies
      run: |
        python -m pip install --upgrade pip
        pip install -r requirements.txt

    - name: Run flake8
      run: |
        flake8 . --select=E722
      
    - name: Run pylint
      run: |
        find . -name "*.py" | xargs pylint --disable=all --enable=W0702

대안으로 아래 두 항목 동시에 적용하는 것이 좋다

  1. 보다 구체적인 예외처리 (AttributeError 또는 KeyError)
  2. except 블록에서 실제 오류 처리
  • pass를 사용하는 것은 그것이 의미하는 바를 알 수 없기 때문에 나쁜 코드이다
  • 명시적으로 해당 오류를 무시하려면 contextlib.suppress 함수를 사용하는 것이 올바른 방법
import contextlib

with contextlib.suppress(KeyError):
    process_data()

원본 예외 포함

  • raise <e> from <original_exception> 구문을 사용하면 여러 예외를 연결할 수 있음
  • 원본 오류의 traceback 정보가 새로운 exception에 포함되고 원본 오류는 새로운 오류의 원인으로 분류되어 cause 속성에 할당 됨
class InternalDataError(Exception):
    """업무 도메인 데이터의 예외"""

def process(data_dictionary, record_id):
    try:
        return data_dictionary[record_id]
    except KeyError as e:
        raise InternalDataError("데이터가 존재하지 않음") from e

test_dict = {"a": 1}

process(test_dict, "b")

Traceback (most recent call last):
File "/Users/woo-seongchoi/Desktop/CleanCode/ch3/main.py", line 7, in process
return data_dictionary[record_id]
~~~~~~~~~~~~~~~^^^^^^^^^^^
KeyError: 'b'*

The above exception was the direct cause of the following exception:
Traceback (most recent call last):
File "/Users/woo-seongchoi/Desktop/CleanCode/ch3/main.py", line 14, in <module>
process(test_dict, "b")
File "/Users/woo-seongchoi/Desktop/CleanCode/ch3/main.py", line 9, in process
raise InternalDataError("데이터가 존재하지 않음") from e
InternalDataError: 데이터가 존재하지 않음*

 

파이썬에서 assertion 사용하기

  • 절대로 일어나지 않아야 하는 상황에 사용되므로 assert 문에 사용된 표현식을 불가능한 조건을 의미로 프로그램을 중단시키는 것이 좋다
try: 
    assert condition.holds(), "조건에 맞지 않음"
except AssertionError:
    alternative_procedure() # catch 후에도 계속 프로그램을 실행하면 안됨

위 코드가 나쁜 또 다른 이유는 AssertionError를 처리하는 것 이외에 assertion 문장이 함수라는 것

assert condition.holds(), "조건에 맞지 않음"
  • 함수 호출은 부작용을 가질 수 있으며 항상 반복가능하지 않음. 또한 디버거를 사용해 해당 라인에서 중지하여 오류 결과를 편리하게 볼 수 없으며 다시 함수를 호출한다 하더라도 잘못된 값이었는지 알 수 없음
result = condition.holds()
assert result > 0, f"Error with {result}"

예외처리와 assertion의 차이

  • 예외처리는 예상하지 못한 상황을 처리하기 위한 것 ⇒ 더 일반적
  • assertion은 정확성을 보장하기 위해 스스로 체크하는 것
반응형

+ Recent posts