티스토리 뷰

Books

파이썬 클린 코드 (3/10)

최성훈 2022. 3. 18. 04:49
반응형

3장. 좋은 코드의 일반적인 특징

계약에 의한 디자인

계약에 의한 디자인(Design by Contract)이란 이런 것이다. 관계자가 기대하는 바를 암묵적으로 코드에 삽입하는 대신 양측이 동의하는 계약을 먼저 한 다음, 계약을 어겼을 경우는 명시적으로 왜 계속할 수 없는지 예외를 발생시키라는 것이다.

이 책에서 말하는 계약은 소프트웨어 컴포넌트 간의 통신 중에 반드시 지켜져야 할 몇 가지 규칙을 강제하는 것이다. 계약은 주로 사전조건과 사후조건을 명시하지만 때로는 불변식과 부작용을 기술한다.

 

여기서 말하는 계약이란 쉽게 말해 docstring과 같은 문서화를 의미한다.

 

정확한 결과를 보장하기 위해 컴포넌트(함수, 메서드 등) 간 상호작용 중에 반드시 지켜야 할 규칙을 문서화하고, 이를 기반으로 컴포넌트의 동작을 검증하는 설계 방식이다.

 

즉, 계약(= 문서화)을 통해 각 컴포넌트의 동작을 명시하고, 이를 기반으로 설계를 한다.

 


👉 사전조건(precondition): 코드가 실행되기 전에 체크해야 하는 것들이다. 함수가 진행되기 전에 처리되어야 하는 모든 조건을 체크한다. 일반적으로 파라미터에 제공된 데이터의 유효성을 검사하지만 유효성 체크를 통해 부작용이 최소화된다는 점을 고려할 때 유효성 검사를 많이 하는 것이 좋다. 예를 들어 데이터베이스, 파일, 이전에 호출된 다른 메서드의 검사 등이다. 이러한 작업은 호출자에게 부과된 임무이다.

👉 사후조건(postcondition): 사전조건과 반대로 여기서는 함수 반환 값의 유효성 검사가 수행된다. 사후조건 검증은 호출자가 이 컴포넌트에서 기대한 것을 제대로 받았는지 확인하기 위해 수행한다.

👉 불변식(invariant): 때로는 함수의 docstring에 불변식에 대해 문서화하는 것이 좋다. 불변식은 함수가 실행되는 동안에 일정하게 유지되는 것으로 함수의 로직에 문제가 없는지 확인하기 위한 것이다.

👉 부작용(side-effect): 선택적으로 코드의 부작용을 docstring에 언급하기도 한다.

 

이렇게 계약에 의한 디자인을 하는 이유는 오류가 발생할 때 쉽게 찾아낼 수 있기 때문이다.

 

불변식이나 부작용은 가능하다면 함께 작성하는 것이 좋다.

 

만약 사전조건 검증에 실패한다면 이는 클라이언트의 결함(또는 실수)에 의한 것이고, 사후조건 검증에 실패한다면 사용된 모듈이나 클래스에 문제가 있다는 것이다.

 

사전조건의 경우엔 런타임에 확인할 수 있다는 것을 주의해야 한다. (잘 생각해보면 당연한 이유다. 🧐)

 

나는 기회가 있을 때마다 docstring을 자세하게 작성하려고 하는데, 아래는 레거시 코드를 리팩토링 하면서 작성한 docstring이다.

 

사전조건, 사후조건, 불변식이 포함된 docstring
사전조건, 사후조건, 부작용이 포함된 docstring

이처럼 계약(= docstring)을 미리 작성한다면 함수의 책임을 사전에 명확하게 정의할 수 있는 장점이 있다.

 

만약 오류가 발생한다면 함수를 호출한 곳에서 문제가 있는지, 함수 내부 로직상에 문제가 있는지 원인 분석에도 도움이 된다.

 

사전조건(precondition)

이제 문제는 이 유효성 검사를 어디서 할지이다. 클라이언트가 함수를 호출하기 전에 모든 유효성 검사를 하도록 할 것인지, 함수가 자체적으로 로직을 실행하기 전에 검사하도록 할 것인지에 대한 문제이다. 전자는 관용적인(tolerant) 접근법이다. 왜냐하면 함수가 어떤 값이라도 (심지어 깨진 데이터도) 수용하기 때문이다. 반면 후자는 까다로운(demanding) 접근 방법에 해당한다.

분석을 위해 DbC에 대한 까다로운 접근법을 사용해보자. 일반적으로 가장 안전하고 견고한 방법이며 업계에서도 가장 널리 쓰이는 방법이다.

 

음.. 나는 함수가 실행되는 가장 첫 부분에 유효성 검사(validation check)를 주로 넣는데, 운이 좋게도 올바른 방법이었구나 싶다. (함수 첫 부분에서 유효성 검사를 하는 이유는 딱히 없었고 그냥 그게 더 합리적으로 보였다. ㅋㅋ... 😂)

 

여기서 주의해야 할 점이 있는데, 책에서는 다음과 같이 말한다.

 

어떤 방식을 택하든 중복 제거 원칙을 항상 마음속에 간직해야 한다. 중복 제거 원칙은 사전조건 검증을 양쪽에서 하지 말고 오직 어느 한쪽에서만 해야 한다는 것이다. 즉, 검증 로직을 클라이언트에 두거나 함수 자체에 두어야 한다. 어떤 경우에도 중복해서는 안 된다. 이것은 뒤쪽에서 다룰 DRY 원칙과 관련이 있다.

 

DRY 원칙이란 "Do Not Repeat Yourself"를 의미한다.

 

즉, 이미 있는 코드를 스스로 다시 짜지 말라는 말이다.

 

어떤 형태로든 중복된 로직을 제거하라는 의미이며, 사전조건을 검증할 때 (1) 함수를 호출하는 곳, (2) 함수 내부 둘 중 한 곳에서만 검증을 하라는 것이다.

 

DRY 원칙은 Django의 설계 철학 중 하나이다.

 


방어적(defensive) 프로그래밍

방어적 프로그래밍의 주요 주제는 예상할 수 있는 시나리오의 오류를 처리하는 방법과 (불가피한 조건에 의해서) 발생하지 않아야 하는 오류를 처리하는 방법에 대한 것이다. 전자는 에러 핸들링 프로시저에 대한 것이며, 후자는 어썰션(assertion)에 대한 것이다.

예외처리

마지막으로 중요한 개념이 하나 더 있다. 예외는 대개 호출자에게 잘못을 알려주는 것이다. 예외는 캡슐화를 약화시키기 때문에 신중하게 사용해야 한다. 함수에 예외가 많을수록 호출자가 호출하는 함수에 대해 더 많은 것을 알아야만 한다. 그리고 함수가 너무 많은 예외를 발생시키면 문맥에서 자유롭지 않다는 것을 의미한다. 왜냐하면 호출할 때마다 발생 가능한 부작용을 염두에 두고 문맥을 유지해야 하기 때문이다.

 

꽤 공감되는 말이다.

 

만약 함수 내부에서 발생시키는 예외가 많을수록, 함수를 호출하는 곳에서는 일어날 수 있는 예외 개수만큼 추가적인 예외처리를 해야 한다.

 

책에서는 이를 두고 "문맥을 유지"한다라고 표현한 것 같다.

 

예를 들면, 만약 아래와 같이 예외를 발생시킬 수 있는 something() 함수가 있다고 치자.

try:
something()
except KeyError:
handle_key_error()
except AttributeError:
handle_attribute_error()

something()을 호출하는 곳에서는 something()이 어떤 예외를 일으키는지 알고 있어야 하며, 만약 발생할 수 있는 예외가 누락된다면 프로그램에 장애를 유발할 수 있다.

 

이러한 예외가 많아질수록 책에서는 다음과 같이 말한다.

 

이것은 함수가 응집력이 약하고 너무 많은 책임을 가지고 있다는 것을 알기 위한 경험적 확인 방법이 될 수도 있다. 예외가 너무 많이 발생하면 여러 개의 작은 것으로 나눠야한다는 신호일 수 있다.

 


관심사의 분리

소프트웨어에서 관심사를 분리하는 목표는 파급 효과를 최소화하여 유지보수성을 향상시키는 것이다. 파급(ripple) 효과는 어느 지점에서의 변화가 전체로 전파되는 것을 의미한다. 이러한 오류나 예외는 다른 예외를 유발하거나 혹은 먼 지점의 결함을 초래한다. 함수 정의를 약간만 변경해도 코드의 여러 부분에 영향을 미쳐 많은 코드를 변경해야 할 수도 있다.

 

음.. 요즘 내가 고민하고 있는 문제다.

 

아래와 같이 함수의 리턴 값을 dict에서 tuple로 변경하고 싶은데, 이 함수를 호출하는 부분이 너무 많다 보니 함부로 변경하기가 어렵다는 것이다.

def something():
a = do_something()
return a

위 함수를

def something():
a = do_something()
b = do_something_else()
return a, b

이렇게 변경하고 싶지만, 그렇게 하려면 something()의 리턴값을 사용하는 모든 부분을 수정해야 한다.

 

좋은 방법은 없을까?

 


개발 지침 약어

DRY/OAOO

DRY(Do not Repeat Yourself)OAOO(Once and Only Once)는 밀접한 관련이 있으므로 함께 다룬다. 자명한 원리로써 중복을 반드시 피해야 한다.

 

YAGNI

YAGNI(You Ain't Gonna Need it)는 과잉 엔지니어링을 하지 않기 위해 솔루션 작성 시 계속 염두에 두어야 하는 원칙이다.

 

확장성을 고려한다면 가끔씩 고도화된 설계가 필요할 때가 있다.

 

책에서는 "오직 현재의 요구사항을 잘 해결하기 위한 소프트웨어를 작성하고 가능한 나중에 수정하기 쉽도록 작성하는 것이다."라고 했는데, 이는 '지금 당장은 일단 구현만 하면 되겠네'같은 오해의 소지를 불러일으킬 수 있다.

 

가령 내가 어떤 요구사항을 구현하기 위해서 (1) DB에 칼럼을 추가할 것인지, (2) 스키마를 정규화할 것인지 고민한다고 가정해보자.

 

그렇다면 나는 아래와 같은 경우를 두고 고민을 할 것이다.

 

  1. DB에 단순히 칼럼만 추가했는데 더 복잡한 요구사항이 발생하는 경우 (예: 정규화)
  2. 스키마를 정규화했는데 추가 요구사항이 없는 경우

개인 성향 차이겠지만, 난 2번을 선호한다.
(즉, 일단 정규화를 한다는 뜻이다. 🧐 )

 

1번과 같은 방법으로 계속 코드를 작성한다면, 언젠가는 돌이킬 수 없는 코드가 될 수도 있다고 생각하기 때문이다.

 

그렇지만 다른 누군가는 '굳이 지금 필요하지도 않은데 일을 더 크게 벌려야 하나?'라고 생각할 수도 있다.

 

난 아직도 정답을 잘 모르겠지만, 책에서는 다음과 같이 말한다.

 

다시 말해 디자인을 할 때 내린 결정으로 특별한 제약 없이 개발을 계속할 수 있다면, 굳이 필요 없는 추가 개발을 하지 말라는 것이다.

 

KIS

KIS(Keep It Simple)는 이전 원칙과 매우 흡사하다. 소프트웨어 컴포넌트를 설계할 때 과잉 엔지니어링을 피해야 한다. 선택한 솔루션이 문제에 적합한 최소한의 솔루션인지 자문해보자.

..(중략).. 기억해야 할 점은 디자인이 단순할수록 유지 관리가 쉽다는 것이다.

 

다시 책에서는 디자인을 단순하게 유지하라고 한다.

 

KIS에서 말하는 것을 보니, 코드 구조를 단순하게 유지하라는 말인 것 같다.

 

나는 디자인 패턴과 같은 거시적 관점에서의 설계를 생각했는데, 예제 코드를 보아하니 그저 코드를 단순하게 짜라는 말인 것 같다.

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()을 사용 중인데, 굳이 이렇게 작성할 필요가 없다는 것이다.

class ComplicatedNamespace2:
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)
파이썬의 철학을 기억하자: 단순한 것이 복잡한 것보다 낫다.

 


컴포지션과 상속

상속은 강력한 개념이지만 위험도 있다. 가장 주된 위험은 부모 클래스를 확장하여 새로운 클래스를 만들 때마다 부모와 강력하게 결합된 새로운 클래스가 생긴다는 점이다. 이미 설명했듯이 소프트웨어를 설계할 때마다 결합력(coupling)을 최소한으로 줄이는 것이 중요하다.

..(중략).. 코드를 재사용하는 올바른 방법은 여러 상황에서 동작 가능하고 쉽게 조합할 수 있는 응집력 높은 객체를 사용하는 것이다.

상속이 좋은 선택인 경우

파생 클래스를 만드는 것은 양날의 검이 될 수 있으므로 주의해야 한다. 한편으로는 부모 클래스의 메서드를 공짜로 전수 받을 수 있는 장점이 있지만 그러나 다른 한편으로 모든 것을 새로운 클래스로 가져왔기 때문에 새로운 정의에 너무 많은 기능을 추가하게 되는 단점도 있다.

 

맞는 말이다.

 

자식 클래스가 부모 클래스를 상속한다면 부모 클래스의 모든 것(속성, 메소드)을 가져온다.

 

만약 자식 클래스에서 확장하는 범위가 적고, 자식 클래스가 부모 클래스의 메소드를 100% 활용하지 않는다면 이는 올바르지 않은(혹은 불필요한) 상속이다.

 

만약 그렇다면 자식 클래스가 확장하는 범위가 적절한지, 부모 클래스가 너무 많은 책임을 가지고 있는 건 아닌지 다시 확인해야 한다.

 

믹스인(mixin)

믹스인은 코드를 재사용하기 위해 일반적인 행동을 캡슐화해놓은 기본 클래스이다. 일반적으로 믹스인 클래스는 그 자체로는 유용하지 않으며 대부분이 크래스에 정의된 메서드나 속성에 의존하기 때문에 이 클래스만 확장해서는 확실히 동작하지 않는다. 보통은 다른 클래스와 함께 믹스인 클래스를 다중 상속하여 믹스인에 있는 메서드나 속성을 사용한다.

 

믹스인은 활용도가 높은 유용한 클래스다.

 

믹스인 클래스가 어떤 클래스인지는 예제를 통해서 이해하는 게 더 쉽다.

class BaseTokenizer:
"""하이픈(-)을 기준으로 문자열을 나눈다."""
def __init__(self, str_token):
self.str_token = str_token
def __iter__(self):
yield from self.str_token.split("-")
class UpperIterableMixin:
"""부모 클래스의 __iter__() 결과를 대문자로 변환한다."""
def __iter__(self):
return map(str.upper, super().__iter__())
class Tokenizer(UpperIterableMixin, BaseTokenizer):
"""믹스인에서 __iter__()를 호출하고 super()를 통해 BaseTokenizer에 위임한다."""
pass

위 코드는 아래와 같이 실행된다.

>>> tk = Tokenizer("a-b-c-d-e")
>>> list(tk)
['A', 'B', 'C', 'D', 'E']

 


함수와 메서드의 인자

인자는 함수에 어떻게 복사되는가

파이썬의 첫 번째 규칙은 모든 인자가 값에 의해 전달(passed by a value)된다는 것이다. 즉 함수에 값을 전달하면 함수의 서명에 있는 변수에 할당하고 나중에 사용한다. 인자를 변경하는 함수는 인자의 타입에 따라 다른 결과를 낼 수 있다. 만약 변경 가능한(mutable) 객체를 전달하고 함수에서 값을 변경하면 함수 반환 시 실제 값이 변경되는 부작용이 생길 수 있다.

 

여기서 흔한 실수가 발생한다.

 

파이썬에서 함수 기본 인자는 함수가 호출되는 순간마다가 아니라 함수가 정의되는 순간에(한 번)만 평가된다.

 

따라서 만약 함수의 기본 인자가 변경 가능한(mutable)한 객체로 선언된다면 이후에 함수가 호출될 때마다 그 객체를 참조한다.

 

예를 들면 아래와 같다.

def append_to(element, to=[]):
to.append(element)
return to
>>> first_list = append_to(12)
>>> first_list
[12]
>>> second_list = append_to(42)
>>> second_list # [42]가 나오길 기대한다.
[12, 42]

리스트 객체는 append() 함수가 정의될 때 한 번만 생성되고, 이후에 모든 append() 호출에 똑같은 리스트 객체가 참조된다.

 

이를 방지하기 위해 우리는 함수의 기본 인자로 변경 가능한(mutable)한 객체를 사용하지 않고, 다음과 같이 사용해야 한다.

 

def append_to(element, to=None):
if not to:
to = []
to.append(element)
return to
반응형

'Books' 카테고리의 다른 글

파이썬 클린 코드 (5/10)  (0) 2023.01.16
파이썬 클린 코드 (4/10)  (0) 2022.05.07
파이썬 클린 코드 (2/10)  (3) 2022.03.04
파이썬 클린 코드 (1/10)  (0) 2022.03.04
댓글