이 장에서는 훌륭한 소프트웨어 디자인을 위한 몇 가지 원칙들을 살펴본다.
개인적으로 개발적으로 정리하고 유념해야 할게 많았던 장이라 내용이 조금 길다.
책의 내용을 따라가되, 내가 추가적으로 살을 붙인 것들도 있다.
1. 계약에 의한 디자인
컴포넌트는 기능을 숨겨 캡슐화하고 함수를 사용할 고객에게는 API를 노출한다.
API를 디자인할 때는 예상되는 입력과 출력 그리고 부작용을 문서화해야 한다.
코드가 정상 동작하기 위해 기대하는 입력과 호출자가 반환받기를 기대하는 것은 디자인의 하나가 되어야 한다. 여기서 계약이라는 개념이 생긴다.
계약에 의한 디자인이란 양측이 동의하는 계약을 먼저한 다음, 계약을 어겼을 경우 명시적으로 왜 계속할 수 없는지 예외를 발생시키는 것이다. 계약은 주로 사전 조건과 사후 조건을 명시하지만 때로는 불변식과 부작용을 기술한다.
1.1. 사전조건과 사후조건
사전조건은 함수나 메서드가 제대로 동작하기 위해 보장해야 하는 모든 것을 말한다. 쉽게 말해 함수에 올바른 데이터를 전달하는 것이다. 예를 들어, 초기화된 객체, null이 아닌 값 등이 조건이 된다.
이러한 작업은 호출자(클라이언트)에게 부과된 임무다.
하지만 런타임 환경에서 올바른 입력 값을 전달하는지 알 수 없다. 따라서 기대하는 입력 값이 맞는지 확인해야 하는데, 클라이언트에서 함수 호출 전에 할지, 함수가 자체적으로 로직을 실행하기 전에 할 지 선택해야 한다.
일반적으로 업계에서는 견고한 소프트웨어를 위해 후자를 택한다. 즉 입력에 대해 신뢰하지 않기 때문에, 함수 자체적으로 확인을 한다. 만약 본인이 이 모든 걸 제어할 수 있는 위치에 있다면, 한쪽에서만 하면 된다. 즉 사전 검증을 호출하는 쪽과 호출받는 쪽 모두에서 중복 구현하지 않고 한쪽에서만 구현하면 된다.
다음은 사전조건을 검증하는 예다.
def func(a: int, b: str):
if not isinstance(a, int) or not isinstace(b, str):
raise ValueError("입력 파라미터 타입이 올바르지 않습니다.")
사후조건은 함수나 메서드가 반환된 후의 상태를 강제하는 계약이다. 로직 처리 후, 호출자(클라이언트)가 원하는 결과를 전달하는 것이다. 호출자가 사전 조건만 잘 지키면 아무 문제없이 원하는 결과를 받아 사용할 수 있어야 한다.
이러한 작업은 컴포넌트(로직을 처리하는 함수나 메서드)에게 부과된 임무다.
1.2. 계약에 의한 디자인을 하는 이유
디자인 원칙의 주된 가치는 문제가 있는 부분을 효과적으로 식별하는 데에 있다. 계약에 의한 디자인을 사용하는 이유는, 런타임 오류가 발생했을 시 어디가 문제 있는지 빠르게 식별할 수 있기 때문이다. 즉 오류가 사전 조건 검증에서 발생했는지, 사후 조건 검증에서 발생했는지 알 수 있다. 전자라면 호출 쪽에 문제가 있는 것이고, 후자라면 함수 내부에 문제가 있는 것이다.
또한 함수나 메서드가 정상 동작하기 위해 기대하는 것이 무엇인지, 무엇을 기대할 수 있는지 명시적으로 정의한다. 이로써 코드를 읽는 사람은 프로그램의 구조와 의도를 명확히 알 수 있다.
2. 방어적 프로그래밍
방어적 프로그래밍은 계약에 의한 디자인과 달리, 계약이라는 것을 전제하지 않는다. 계약에서 예외를 발생시키고 실패하게 되는 모든 조건을 기술하는 대신, 객체나 함수 또는 메서드와 같은 코드의 모든 부분을 그저 유효하지 않은 것으로부터 스스로 보호하도록 하는 것이다.
방어적 프로그래밍의 주요 이슈는 2가지다.
- 예상할 수 있는 시나리오의 오류를 처리하는 방법 (에러 핸들링)
- 발생하지 않아야 하는 오류를 처리하는 방법 (어설션, Assertion)
2.1. 에러 핸들링
오류가 발생하기 쉬운 상황에서 에러 핸들링 프로시저를 사용한다. 일반적으로 데이터 입력 확인 시에 자주 사용된다.
에러 핸들링의 주요 목적은 다음을 결정하는 것이다.
- 예상되는 에러에 대해서 실행을 계속할 수 있을지
- 아니면 극복할 수 없는 오류여서 프로그램을 중단할지
에러 처리 방법의 일부는 다음과 같다.
1) 값 대체
잘못된 값을 생성하거나 프로그램 전체가 종료될 위험이 있을 경우, 결과 값을 안전한 다른 값으로 대체하는 것이다. 일반적으로 '기본 값' 을 쓰는 것을 말한다.
예를 들어 dict.get(key, default)
의 두 번째 파라미터를 사용하면 기본 값을 나타낼 수 있다.
configuration = {"db_port": 5432}
...
# db_host 가 configuration 없을 시 기본적으로 "localhost" 를 값을 가져온다.
db_host = configuration.get("db_host", "localhost")
일반적으로 누락된 파라미터를 기본 값으로 바꾸어도 큰 문제는 없지만, 오류가 있는 데이터를 유사한 값으로 대체하는 것은 위험하며 일부 오류가 숨겨져 버릴 수 있다. 이 접근법을 사용할 때는 이러한 기준을 고려해야 한다.
2) 예외 처리
어떤 경우에는 에러가 발생하기 쉽다는 가정으로 계속 실행하는 것보다 차라리 실행을 멈추는 게 낫다. 이런 경우 호출자에게 오류에 대해 명확하고 분명하게 알려줘야 한다. 이것이 예외 메커니즘이다. 이로써 호출자는 명확한 예외를 받아 호출자 나름의 예외 처리하여 원래의 비즈니스 로직이 끊기지 않도록 할 수 있다.
다음은 예외와 관련된 몇 가지 권장 사항이다.
a. 올바른 수준의 추상화 단계에서 예외 처리
예외 발생과 처리는 캡슐화된 로직과 일치해야 한다.
예외를 발생시키거나, 처리할 때 이 예외를 이 클래스가 발생시키는 게 맞는지, 또 이 클래스에서 처리하는 게 맞는지 등을 고려해야 한다.
b. Traceback 노출 금지
파이썬에서 traceback은 매우 유용하고 많은 디버깅 정보를 포함한다. 하지만 이 정보는 악의적인 사용자에게 매우 유용한 정보여서 중요 정보나 지적 재산의 유출이 발생할 위험이 있다.
오류가 너무 중요하다면 전파해도 된다. 다만 일반적으로는 사용자에게 문제를 알리려면 일반적인 메시지를 사용해야 한다.
c. 비어있는 except 블록 지양
말 그대로 다음과 같은 경우다.
try:
process_data()
except:
pass
이런 코드는 실패해야만 할 때조차도 결코 실패하지 않는다.
에러는 결코 조용히 전달되어서는 안 된다는 파이썬의 철학을 떠올리면 이는 파이썬스러운 코드가 아니다.
되도록이면 다음과 같이 처리하는 것이 옳다.
Exception
을 상속받은 구체적인 예외를 정의하고, (예를 들어,AttributeError
등)- 이러한 각 예외에 대해서
except
처리를 하는 것
d. 원본 예외 포함
오류 처리 과정에서 다른 오류를 발생시키고 메시지를 변경할 수도 있다.
이 경우 원래 예외를 포함하는 것이 좋다.
만약 파이썬에서 제공하는 기본 예외를 사용자 정의 예외로 래핑 하고 싶다면, 루트 예외에 대한 정보를 다음과 raise ... from ...
구문을 사용하여 같이 포함할 수 있다.
class InternalDataError(Exception):
""" 사용자 정의 예외"""
pass
def process(data_dictionary, record_id):
try:
return data_dictonary[record_id]
except KeyError as e:
raise InternalDataError("Record not present") from e
[더 참고하면 좋을 내용]
사실 예외 처리는 간단하게 넘어갈 내용은 아니다.
하마님 블로그에 예외 처리와 관련된 나름 생각해볼 만한 내용들이 있어서 링크를 걸어둔다.
2.2. 어설션 사용하기
어설션은 절대로 일어나지 않아야 하는 상황에 사용되므로, assert 문에 사용된 표현식은 항상 참이여야 한다. 즉 어설션 조건은 프로그램의 실행 전제 조건을 설명한다.
def process(data_dictionary, record_id):
assert isinstance(data_dictionary, dict) # data_dictionary 는 dict 라는 가정을 설명
return data_dictionary[record_id]
에러 핸들링과 다르게, 어설션 조건 판별이 False
가 될 경우, AssertionError
을 발생시키고 프로그램을 중지시킨다. 이 AssertionError
를 try ... except
로 처리하는 것은 옳지 않다. 이 에러가 발생한다는 것은 애초에 프로그램 어딘가에 결함이 있다는 의미 있다는 것이다. 그리고 이러한 신호를 사전에 캐치하기 위해 어설션을 사용하는 것이기도 하다.
3. 관심사 분리
책임이 다르면 컴포넌트, 계층 또는 모듈로 분리되어야 한다.
프로그램의 각 부분은 기능의 일부분(관심사)에 대해서만 책임을 지며 나머지 부분에 대해서는 알 필요가 없다.
소프트웨어에서 관심을 분리하는 목표는 "파급 효과"를 최소화하여 "유지보수성"을 향상시키는 것이다.
여기서 파급효과는 어느 지점에서의 변화가 전체로 전파되는 것을 의미한다. 즉 쉽게 말해, 소프트웨어는 쉽게 변경될 수 있어야 한다.
응집력
- 컴포넌트는 잘 정의된 한 가지의 목적을 가져야 하며
- 가능하면 작아야 한다는 것을 의미한다.
- 응집력이 높을수록 재사용성은 높아진다.
결합력
- 두 개 이상의 객체가 서로 얼마나 의존적인지를 나타낸다.
- 결합력이 높으면 다음과 같은 문제를 일으킨다.
- 낮은 재사용성
- 파급 효과
- 낮은 수준의 추상화
4. 개발지침 약어
DRY / OAOO
- Do not Repeat Yourself
- Once And Only Once
- 코드를 변경하려고 할 때 수정이 필요한 곳은 단 한 군데만 있어야 한다.
- 즉, 중복을 최대한 피해야 한다.
YAGNI
- You Ain't Gonna Need It
- 오직 현재의 요구사항을 잘 해결하기 위한 소프트웨어를 작성해야 한다.
- 내가 짜고 있는 코드가 일어나지도 않을 미래의 일을 예상하고, 코드를 더 복잡하게 만들고 있는 건 아닌지,
- 즉, 지금 필요하지도 않은 오버 엔지니어링을 하고 있는 건 아닌지 염두해야 한다.
KIS
- Keep It Simple
- YAGNI 원칙과 비슷하다. 디자인이 단순할수록 유지 관리가 쉽다는 것이다.
- 모든 확장 가능성, 좀 더 일반화적인 추상화가 지금 기능 개발 시점에서는 섣부를 수 있다.
- 코드 측면의 단순함이란 문제에 맞는 가장 작은 데이터 구조(표준 라이브러리 등)를 사용하는 것을 의미한다.
EAFP / LBYL
- Easier to Ask Forgiveness than Permisson
- 허락보다 용서를 구하는 게 쉽다.
- 일단 코드를 실행하고 실제 동작하지 않을 경우를 대응한다는 뜻이다.
try:
with open(filename) as f:
pass
except:
pass
- Look Before You Leap
- 도약하기 전에 살펴라.
- 코드를 실행하기 전에, 먼저 무엇을 사용하려고 하는지 확인하라는 뜻이다.
if os.path.exists(filename):
with open(filename) as f:
pass
- 파이썬은 EAFP 방식으로 만들어졌다.
5. 컴포지션과 상속
5.1. 상속이 좋은 선택인 경우
부모 클래스의 메서드를 공짜로 전수받을 수 있는 장점이 있지만, 한편으론 새로운 정의에 너무 많은 기능을 추가하게 되는 단점도 있다.
다음과 같은 경우, 상속은 좋은 선택의 예가 된다.
- 클래스의 기능을 그대로 물려받으면서 충분히 사용할 상황이 있고, 추가 기능을 더하거나 기존 기능을 수정하는 경우
- 인터페이스용 클래스를 정의하고, 이를 하위 클래스에서 이를 상속받아 기능을 강제하려는 경우
- 다형성을 통해 로직을 유연하게 설계하려는 경우 (
Exception
을 상속받아 예외를 처리하는 경우가 대표적인 예다.)
상속이 좋은 일반적인 경우를 한 단어로 표현한다면, "전문화"가 될 것이다.
즉, 상속을 통하여 기본 객체에서 출발하여 세부적인 추상화를 할 수 있다.
5.2. 상속 안티 패턴
Bad Case
다음 예를 보자.
class TransactionPolicy(collections.UserDict):
"""잘못된 상속의 예"""
def change_in_policy(self, customer_id, **new_policy_data):
self[customer_id].update(**new_policy_data)
이 코드는 UserDict
를 상속받아, 도메인에 맞는 구체적인 Dict
형태의 클래스를 정의했다.
위 코드는 2가지의 문제가 있다.
- TransactionPolicy 이름만 보고 어떻게 사전 타입인지 알 수 있을까?
UserDict
에 있는pop()
,items()
와 같은 메서드가 이 클래스에 실제로 필요할까?
즉, 상속을 잘못 사용한 것이다. 단지 첨자 기능을 얻기 위해 사전을 확장하는 것은 충분한 근거가 되지 않는다.
이것이 구현 객체를 도메인 객체와 혼합할 때 흔히 발생하는 문제다.
Good Case
상속이 아닌 해결책은 바로 컴포지션을 이용하는 것이다.
그리고 첨자 기능은 __getitem__
을 구현함으로써 충분히 이용할 수 있다.
class TransactionPolicy:
"""컴포지션을 통한 해결 예"""
def __init__(self, policy_data, **extra_data):
self._data = {**policy_data, **extra_data} # Dict 의 기능을 _data 에 위임한다.
def change_in_policy(self, customer_id, **new_policy_data):
self._data[customer_id].update(**new_policy_data)
# __getitem__ 과 __len__ 을 구현함으로써 Dict 의 첨자 기능을 이용할 수 있도록 한다.
def __getitem__(self, customer_id):
return self._data[customer_id]
def __len__(self):
return len(self._data)
5.3. 파이썬의 다중 상속
메서드 결정 순서 MRO
파이썬에서 다중 상속이 발생할 때, 부모의 어떤 메서드가 먼저 사용될까?
한 마디로 말하면, 상속 순서가 앞쪽에 있는 부모 클래스의 메서드를 사용한다.
정확히는 MRO 알고리즘을 사용하여 메서드 우선순위를 정한다.
다음 예를 살펴보면 쉽게 이해할 수 있다.
class A:
name = "class A"
def __init__(self):
print("class A init")
class B:
name = "class B"
def __init__(self):
print("class B init")
class C(A, B):
def __init__(self):
super().__init__()
>>> C.name
class A
>>> C()
class A init
메서드의 결정 순서는 다음과 같이 확인해볼 수 있다.
>>> [cls.__name__ for cls in C.mro()]
['C', 'A', 'B', 'object'] # C -> A -> B -> object 순으로 메서드를 찾아 실행한다.
믹스인 (Mixin) 클래스
믹스인 클래스는 코드를 재사용하기 위해 일반적인 행동을 캡슐화해놓은 기본 클래스다.
보통 유틸리티성 메서드를 구현하고, 이러한 메서드가 필요한 다른 클래스는 이 클래스를 상속받으면 된다.
예를 들면 다음과 같다.
class Serializer:
def to_json(self):
return json.dumps(self.__dict__)
def to_pickle(self, path):
with open(path, 'wb') as f:
pickle.dump(self, path)
위 클래스는 직렬화 관련 메서드를 제공하는 유틸리티성 클래스다.
이러한 직렬화 메서드를 사용하고 싶은 클래스는 이 클래스를 상속받으면 된다.
class A(Serializer):
def __init__(self, a, b):
self.a = a
self.b = b
>>> A(1, 2).to_json()
{"a": 1, "b": 2}
6. 함수와 메서드의 인자
6.1. 파이썬의 함수 인자 동작 방식
파이썬에서는 함수에 인자가 넘어온 경우 기본적으로 "참조" 형태로 가져온다.
다만 이 함수 내에서 인자의 값이 바뀔 경우, 이 인자가 가변형(mutable) 이냐 불변형(immutable) 이냐에 따라 그 결과가 달라진다. 아래 예를 보면 바로 확인해보자.
인자가 가변형(mutable) 인 경우
def func(a):
print(f"함수 내에서 값 할당 전 id : {id(a)}")
a += "4"
print(f"함수 내에서 값 할당 후 id : {id(a)}")
mutable = [1, 2, 3]
print(f"함수 호출 전 id : {id(mutable)}")
func(mutable)
print(f"함수 호출 후 id : {id(mutable)}")
print(mutable)
# 결과
함수 호출 전 id : 4424245064
함수 내에서 값 할당 전 id : 4424245064
함수 내에서 값 할당 후 id : 4424245064
함수 호출 후 id : 4424245064
[1, 2, 3, '4']
가변형일 경우, 함수 내에서 값을 수정하면 그대로 참조 객체에 수정된다.id
도 모두 동일한 것을 알 수 있다.
인자가 불변형(immutable) 일 경우
def func(a):
print(f"함수 내에서 값 할당 전 id : {id(a)}")
a += "4"
print(f"함수 내에서 값 할당 후 id : {id(a)}")
immutable = "1 2 3"
print(f"함수 호출 전 id : {id(immutable)}")
func(immutable)
print(f"함수 호출 후 id : {id(immutable)}")
print(immutable)
# 결과
함수 호출 전 id : 4475811800
함수 내에서 값 할당 전 id : 4475811800
함수 내에서 값 할당 후 id : 4476143240
함수 호출 후 id : 4475811800
1 2 3
불변형일 경우, 함수 내에 할당 전 id
가 동일하지만 인자 값을 수정하는 순간 id
가 바뀌는 것을 알 수 있다. 즉, 값을 수정하는 순간 새로운 객체를 할당하는 것이다. 따라서, 기존 인자에 수정한 것이 반영되지 않는다.
[Tip] 일반적으로 함수 인자를 변경하지 않아야 한다. 최대한 함수에서 발생할 수 있는 부작용을 피하자.
6.2. 함수 인자의 개수
함수 인자는 적을수록 좋다. 함수 인자가 많을수록 호출자와 밀접하게 결합된다는 신호이다.
예를 들어 다음 함수를 보자.
def func(a, b, c, d, e, f, g, h, i, j, k):
pass
호출자가 이 함수를 사용하려면 변수 a
부터 k
까지 모두 필요하다.
이런 문제를 어떻게 해결해야 할까?
크게 2가지 방법이 있다.
전달하는 모든 인자를 포함하는 새로운 객체를 만드는 것
이 방법을 사용하면 다음처럼 코드가 바뀐다.
def func(class_param):
pass
위 a
~ k
를 포함하는 객체 class_param
를 전달함으로써, 함수 인자 개수가 줄어들었다.
다만 이렇게 객체 자체가 넘어가는 경우, 함수 내부에 사용되지 않는 정보까지 같이 넘어갈 수 있다.
따라서, 애초에 객체를 올바르게 추상화하여 이런 오버헤드를 막을 필요가 있다.
가변 인자나 키워드 인자를 사용하여 동적 서명
이 방법을 사용하면 다음처럼 코드가 바뀐다.
def func(*args, **kwargs):
pass
이 방법은 파이썬스럽기는 하지만, 남용하지 않도록 주의해야 한다. 왜냐하면 매우 동적이어서 유지 보수하기가 어렵기 때문이다. 예를 들어 kwargs
에 어떤 변수가 담겨오는지 코드 자체에 명시되지 않기 때문에, 이 함수를 설계하는 사람은 이 정보를 코드 외적으로 알아야 할 필요가 있다.
[Tip]
사실 애초에 함수에 너무 많은 인자가 오는 거 자체가 여러 작은 함수로 분리하라는 신호일 수 있다.
함수는 오직 한 가지 일만 해야 한다는 점을 기억하자.
7. 결론
좋은 코드의 일반적인 특징 및 권장사항을 추가적으로 요약하면 다음과 같다.
- 소프트웨어를 독립성 있게 짜자.
- 모듈, 클래스 또는 함수를 변경하면 수정한 컴포넌트가 외부 세계에 영향을 미치지 않아야 한다.
- 이것은 바람직하지만 항상 가능한 것은 아니다. 하지만 최소화하기 위한 시도는 해야 한다.
- 컴포넌트의 응집도를 높이고, 결합도를 낮추는 이유가 여기에 있다.
- 또한, 입력되는 파라미터를 내부에서 수정하지 않아야 하는 이유도 이와 같은 목적이다.
- 컴포넌트가 독립적이라면, 테스트도 독립적으로 구현할 수 있다. 이는 소프트웨어의 신뢰성과도 연결된다.
- 코드 구조를 잘 나누어 구조화 하자.
- 여러 정의(클래스, 함수, 상수 등)가 들어있는 큰 파일을 만들지 말 자.
- 유사한 컴포넌트끼리 정리하여 구조화 하자.
- 유사한 정보를 중앙화 하자.
- 이를 통해 필요한 모듈만 임포트 해올 수 있고, 메모리에 로드할 객체를 줄일 수 있다.
사실.. 당연하고 기본이 되는 이야기들이지만, 늘 "제대로" 하는 것은 어려운 거 같다.
'더 나은 엔지니어가 되기 위해 > 파이썬을 파이썬스럽게' 카테고리의 다른 글
슬기로운 파이썬 트릭 2 - 효과적인 함수 (2) | 2020.05.22 |
---|---|
슬기로운 파이썬 트릭 1 - 파이썬 코드를 정돈하기 위한 패턴 (0) | 2020.05.19 |
파이썬 클린 코드 2 - 클린 코드와 코딩 가이드라인 (2) | 2020.05.09 |
파이썬 클린 코드 1 - 파이썬스러운 코딩을 파이썬 문법 컨셉 (0) | 2020.05.03 |
df.iterrows() 대신, df.itertuples()? Pandas Iteration 성능비교 (0) | 2019.09.10 |