[Python] Python Clean code - 좋은 코드의 일반적인 특징

Sean park·2021년 5월 5일
0
post-thumbnail

이 글은 책 '파이썬 클린코드'를 읽고 일부를 정리한 내용입니다.
Python 코드를 작성할때 도움이 되기를 바랍니다.

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

1. 계약에 의한 디자인(DbC)

컴포넌트는 기능을 숨겨 캡슐화하고 함수를 사용하는 사용자에게는 API를 노출해야 한다. API를 디자인할 때 예상되는 입력, 출력 및 부작용을 문서화해야 한다.

코드가 정상적으로 동작하기 위해 기대하는 것과 호출자가 반환 받기를 기대하는 것은 디자인의 하나가 되어야 한다. 여기서 '계약'이라는 개념이 생긴다.

계약에 의한 디자인은 양측이 동의하는 계약을 먼저 한 다음, 계약을 어겼을 경우는 명시적으로 왜 계속할 수 없는지 예외를 발생시켜야 한다는 것이다.

이상적으로는 사전조건, 사후조건, 불변식, 부작용등을 모두 컴포넌트의 계약서의 일부로 문서화해야 하지만, 사전조건과 사후조건만 코드 레벨에서 강제한다.(불변식 예. while문의 isRunning과 같은 해당 함수가 작동하는 동안 유지되는 것)

  • 사전조건

    사전조건은 함수나 메서드가 제대로 동작하기 위해 보장해야하는 모든 것을 말한다. 예를들어 초기화된 객체, null이 아닌 값 등의 조건이 있다. 이러한 사전 조건은 메서드나 함수를 호출하는 호출자(클라이언트)에게 책임이 있다.

    사전 조건은 데이터의 유효성 검사를 어디서 할지가 문제이다. 클라이언트가 함수를 호출하기 전에 유효성 검사를 하도록 할 것인지, 함수가 자체적으로 로직을 실행하기 전에 검사하도록 할 것인지에 대한 문제이다. 전자는 관용적인 접근법, 후자는 까다로운 접근법에 해당한다. 전자의 경우 깨진 데이터라도 일단 수용하기 때문에 후자의 방법이 일반적으로 가장 안전하고 견고하다.

    def func(a: int, b: str): #관용적인 접근
    	if not isinstance(a, int) or not isinstace(b, str): #까다로운 접근
    	raise ValueError("입력 파라미터 타입이 올바르지 않습니다.")
  • 사후조건

    사후조건은 메서드 또는 함수가 반환된 후의 상태를 강제하는 계약의 일부이다. 클라이언트가 함수 또는 메서드가 사전조건에 맞게 호출 했다면, 클라이언트는 반환된 객체를 아무 문제없이 사용할 수 있어야 한다.

2. 방어적 프로그래밍

방어적 프로그래밍은 예외를 발생시키고 실패하게 되는 모든 조건을 기술하는 계약에 의한 디자인과는 달리, 객체, 함수 또는 메서드와 같은 코드의 모든 부분을 유효하지 않은 것으로부터 스스로 보호할 수 있게 하는 것이다.

방어적 프로그래밍은 크게 두 가지 주제로 살펴볼 수 있다.

  1. 예상할 수 있는 시나리오의 오류를 처리하는 방법(에러 핸들링)
  2. 발생하지 않아야 하는 오류를 처리하는 방법(assertion)

#에러 핸들링

오류가 발생하기 쉬운 상황에서 에러 핸들링 프로시저를 사용한다. 에러 핸들링의 주요 목적은 예상되는 에러에 대해서 실행을 계속할 수 있을지, 프로그램을 중단할지를 결정하는 것이다.

에러 핸들링의 방법에는 다음과 같은 방법들이 있다.

  1. 값 대체

    잘못된 값을 생성하거나 프로그램 전체가 종료될 위험이 있을 경우 결과 값을 안전한 값으로 대체할 수 있다. 일반적으로 '기본 값(default)'을 사용한다.

    >>> configuration = {"db_port": 5432}
    >>> configuration.get("dbhost", "localhost")
    'localhost'
    >>> configuration.get("dbport")
    5432

    일반적인 경우 값 대체를 통해 문제 해결이 가능하지만, 민감하고 중요한 정보를 다루는 경우 정확하지 않은 값은 프로그램을 폭파시킬수 있으므로 유의해야 한다. 또한 일반적으로 누락된 파라미터를 기본 값으로 바꾸어도 큰 문제가 없지만 오류가 있는 데이터를 유사한 값으로 대체하는 것은 일부 오류를 숨겨버릴 위험이 있다.(오류가 나야하는데 값 대체로 인해 오류가 발생하지 않는 것이다!)

  2. 예외 처리

    어떤 경우에는 에러가 발생하기 쉽다는 가정으로 계속 실행하는 것보다 차라리 실행을 멈추는 것이 더 좋다. 이런 경우에는 호출자에게 실패했음을 알리는 것이 좋은 선택이다. 함수는 심각한 오류에 대해 명확하고 분명하게 알려줘서 적절하게 해결할 수 있도록 해야 한다.

    이것이 바로 예외 매커니즘이다. 하지만 무분별한 예외의 사용은 코드의 가독성과 프로그램의 질을 저하 시킬 수 있다.

    다음은 파이썬의 예외와 관련된 몇 가지 권장 사항이다.

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

    함수가 처리하는 예외는 캡슐화된 로직과 일치해야 한다. 예외를 발생시키거나, 처리할 때 예외를 해당 클래스에서 발생시키는 것이 맞는지 고려해야 한다.

    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는 별로 관계가 없는 에러이다. ConnectinError는 connect 메서드에서 처리되어야 하며, ValueError는 event의 decode 메서드에 속한 에러이다. 이렇게 각자의 책임에 맞게 예외 처리를 한다면 deliver_event에서는 예외를 발생시킬 필요가 없다.

    b. Trackback 노출 금지

    파이썬에서 traceback은 매우 유용하고 많은 디버깅 정보를 포함한다. 하지만 이 정보는 악의적인 사용자에게도 매우 유용한 정보여서 중요 정보나 지적 재산의 유출이 발생할 위험이 있다.

    예외를 처리할 때 오류가 너무 중요하다면 전파해도 된다. 하지만 예외를 전파할 경우 중요한 정보를 공개하지 않도록 주의해야 한다. 사용자에게 문제를 알리려면 무엇이 잘못되었다거나 페이지를 찾을 수 없다는 등의 일반적인 메시지를 사용해 사용자에게 문제를 알려야 한다.

    c. 비어있는 except 블록 지양

    비어있는 except 블록은 파이썬의 안티패턴 중에서도 가장 최악의 패턴이다.

    try:
    	process_data()
    except:
    	pass

    위의 코드를 보면 예외 상황이 발생해도 아무런 일이 발생하지 않는다는 것을 알 수 있다. 이 말은 실패해야 하만 하는 상황일때도 문제가 없는것 처럼 조용히 지나갈 수 있다는 것을 의미한다. 이러한 코드는 문제를 숨기고 유지보수를 더 어렵게 만든다.

    예외를 처리할 경우 다음의 두 가지 대안을 지키는 것이 좋다.

    • 보다 구체적인 예외 사용(Exception과 같이 광법위한 예외를 사용하기 보다 KeyError와 같이 구체적인 예외를 사용하자)
    • except 블록에서 실제 오류 처리를 한다.

    d. 원본 예외 포함

    오류 처리 과정에서 다른 오류를 발생시키고 메시지를 변경할 수도 있다. 이 경우 원래 예외를 포함하는 것이 좋다.

    예를 들어 기본 예외를 사용자 정의 예외로 래핑하고 싶다면 루트 예외에 대한 정보를 다음과 같이 포함할 수 있다.

    def process(data_dictionary, record_id):
    	try:
    		return data[dictionary[record_id]
    	except KeyError as e:
    		raise InternalDataError("Record not present") from e

#파이썬에서 어설션 사용하기

어설션은 절대로 일어나지 않아야 하는 상황에 사용되므로 assert 문에 사용된 표현식은 항상 참이여야 한다. 어설션이 AssertionError를 발생 시킨다것은 소프트웨어에 결함이 있음을 의미한다. 에러 핸들링과는 다르게 어설션에서 오류가 발생하면 프로그램을 중지해야 한다.

어설션은 에러 핸들링은 비슷해 보일 수 있지만, 어설션 조건이 False로 판단된 경우를 대비해 try...except 문으로 처리하는 것은 옳지 않다.

# Bad Case
try:
	assert condition.holds(), "조건에 맞지 않음"
except AssertionError:
	alternative_rocedure() 

위 코드는 AssertsionError를 예외 처리한 것 외에 어설션 문장이 함수라는 것이다. 함수 오출은 부작용이 발생하거나 항상 반복이 가능하지는 않다. 또한 디버거를 사용해 오류 결과를 편리하게 볼 수 없으며, 다시 함수를 호출한다 하더라도 값이 잘못되었는지 알 수 없다.

보다 나은 방법으로 코드를 줄이고 유용한 정보를 추가하자.

result = condition.holds()
assert result > 0, f'error: {result}'

3. 관심사의 분리

책임이 다르면 컴포넌트, 계층 또는 모듈로 분리되어야 한다. 프로그램의 각 부분은 자신의 관심사에 대해서만 책임을 지며 나머지 부분에 대해서는 알 필요가 없다.

소프트웨어에서 관심사를 분리하는 목표는 파급 효과를 최소화하여 유지보수성을 향상시키는 것이다. 파급 효과는 어느 지점의 변화가 프로그램 전체로 전파되는 것을 의미한다. 소프트웨어는 쉽게 변경될 수 있어야 한다.

  • 응집력

    응집력이란 객체가 잘 정의된 목적을 가져야 하며 가능하면 작아야 한다는 것을 의미한다. 응집력이 높을수록 코드의 재사용성은 높아진다.

  • 결합력

    결합력이란 두 개 이상의 객체가 서로 어떻게 의존하는지를 나타낸다. 두 개 이상의 객체간의 의존도가 높으면 낮은 재사용성과 파급효과가 발생하여 우리가 원하는 소프트웨어 디자인과는 다른 방향으로 개발될 가능성이 크다.

4. 개발 지침 약어

  • DRY/OAOO

    'Do not Repeat Yourself'와 'Once and Only Once'의 약자로 위 약어들은 코드의 중복을 피하자는 의미이다. 코드의 중복이 많아질수록 코드의 신뢰성과 오류 발생률이 높아진다. 코드를 변경하려고 할 때 수정이 필요한 곳은 단 한군데만 있어야 한다.

    # Bad Case
    
    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(
    		f'이름: student.name 점수: {(students, key=lambda s: s.passed * 11 - s.failed * 5 - s.years * 2}}'
    		)

    위의 코드를 보면 sorted의 lambda 함수와 학생의 점수를 출력하는 부분에서 중복이 발생 한다.

    def score_for_student(student):
    	return student.passed * 11 - student.failed * 5 - student.years * 2
    
    def process_students_list(students):
    	students_ranking = sorted(students, key=score_for_student)
    	for student in students_ranking:
    		print(
    		f'이름: {student.name} 점수: {score_for_student(student}'
    		)

    다음과 같이 중복되는 코드를 하나의 함수로 분리하면 중복을 피하고 가독성이 높은 코드를 작성할 수 있다.

  • YAGNI

    'You Ain't Gonna Need it'은 과잉 엔지니어링을 하지 않기 위해 솔루션 작성 시 계속 염두에 두어야 하는 원칙이다. 오직 현재의 요구상황을 해결하기 위한 소프트웨어를 작성해야 한다. 일어나지도 않은 미래의 요구사항을 고려하여 오버 엔지니어링 하고 있지는 않은지 생각해봐야 한다.

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

  • KIS

    'Keep It Simple' 원칙은 YANGI 원칙과 흡사하다. 우리는 코드를 작성할 때 문제를 올바르게 해결하는 최소한의 기능을 구현하고 필요한 것 이상으로 솔루션을 복잡하게 만들지 않도록 해야 한다.

    코드 측면의 단순함이란 문제에 맞는 가장 작은 데이터 구조(표준 라이브러리등)를 사용하는 것을 의미한다.

    # Bad Case
    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

    위의 코드를 보면 객체를 초기화 하기위해 추가 클래스 메서드를 만드는 것은 불필요해 보이며, init_with_data라는 일반적이지 않은 메서드의 이름을 알아야 하는 불편함이 있다. 위 상황에서 객체를 초기화 할 때는 init메서드를 사용하는 것이 훨씬 간편할 것이다.

  • EAFP/LBYL

    EAFP와 LBYL은 각각 'Easier to Ask Forgiveness than Permission'과 'Look Before You Leap'의 약어이다. 전자는 일단 코드를 실행하고 동작하지 않을 경우에 대응한다는 뜻으로 해석할 수 있다. 후자는 전자와 반대의 뜻을 가지고 있다.

    if os.path.exists(filename):
    	with open(filename) as f:
    	...

    위 코드는 LBYL방식의 코드로 파이썬스러운 방식은 아니다.

    try:
    	with open(filename) as f:
    	...
    except FileNotFoundError as e:
    	logger.error(e)

    위 코드는 EAFP방식의 코드로 우선 실행하고 예외 발생시 except문으로 처리하는 파이썬스러운 코드이다. 파이썬은 EAFP방식으로 만들어졌으며, 이렇게 코드를 작성할 것을 권한다.

5. 컴포지션과 상속

  1. 상속이 좋은 선택인 경우

    상속은 부모 클래스의 메서드를 공짜로 전수 받을 수 있는 장점이 있지만 새로운 정의에 너무 많은 기능을 추가하게 되는 단점도 있다.

    다음과 같은 경우는 좋은 상속의 예가 된다

    • 상속받은 대부분의 메서드를 필요로 하고, 추가 기능을 더하려는 경우 또는 특정 기능을 수정하려는 경우
    • Exception 클래스(Excetpion을 상속받아 다양한 예외를 구현할 수 있다.)

    ** 인터페이스와 관련된 예시는 이해가 되지 않아 패스...

  2. 상속 안티 패턴

    class TransactionPolicy(collections.UserDict):
    
    	def change_in_policy(self, customer_id, **new_policy_data):
    		self[customer_id].update(**new_policy_data)

    위 코드는 좋지 않은 상속의 예시다. TransactionPolicy라는 이름을 보고 Dict타입인지 알 수 없으며, UserDict를 상속받아 실제 사용하지 않는 pop(), items()와 같은 필요하지 않은 메서드가 포함되어 있다.

    온전히 기본 클래스에 추가되는 그리고 보다 특화된 것을 구현할 때에만 확장해야 한다.

    다음은 컴포지션을 사용한 리팩토링 예제 코드이다.

    class TransactionPolicy:
        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)

    위 코드는 UserDict를 상속받지 않아 불필요한 메서드를 포함하고 있지 않다. TransactionPolicy 자체가 사전이 되는 것이 아니라 사전을 활용하여 원하는 기능을 구현하였다.

  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'

    위의 코드를 살펴보면 C 클래스는 A, B 두 클래스를 상속받아 두 클래스 모두 갖고 있는 메서드를 실행 했을때 충돌이 발생하지 않고 A 클래스의 메서드가 실행된 것을 확인할 수 있다. MRO 알고리즘을 통해 상속받은 인자의 순서대로 메서드의 우선순위를 갖게 되며, 메서드의 결정 순서는 다음과 같이 확인할 수 있다.

    >>> [cls.__name__ for cls in C.mro()]
    ['C', 'A', 'B', 'object']
  • 믹스인 클래스

    믹스인은 코드를 재사용하기 위해 일반적인 행동을 캡슐화해놓은 기본 클래스이다. 간단하게 말하면 재사용이 잦은 메서드등을 구현해 놓은 클래스로, 다른 클래스가 믹스인의 기능을 손쉽게 추가할 수 있도록 만든 클래스이다.

    class Serializer:
    	def to_json(self):
    		return json.dumps(self.__dict__)
    
    class A(Serializer):
    	def __init__(self, a, b):
    		self.a = a
    		self.b = b
    
    >>> A(1, 2).to_json()
    {"a": 1, "b": 2}

    위의 코드를 보면 A 클래스는 Serializer를 상속받아 손쉽게 to_json() 메서드를 사용할 수 있게 되었다.

6. 함수와 메서드의 인자

  • 파이썬의 함수 인자 동작방식

    파이썬은 모든 인자가 값을 '참조' 형태로 가져온다. 즉 함수에 값을 전달하면 함수의 서명에 있는 변수에 할당하고 나중에 사용한다. 때문에 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가 출력되는 것을 확인할 수 있을것이다. mutable한 객체는 함수의 내부에서 값을 변경하면, 원래 있던 객체의 값이 변경되므로 유의해야 한다.

    반면 imuutable한 객체는 인자의 값을 변경하는 순간 새로운 객체를 할당하기 때문에, 기존에 있던 객체의 값과는 상관이 없다.

  • 가변인자

    파이썬은 가변인자 기능을 제공하며, 이를 잘 활용하면 파이썬스러운 코드를 작성할 수 있다. 가변인자를 활용하는 방법중 몇가지를 살펴보자

    def f(first, second, third):
    	print(first)
    	print(second)
    	print(third)
    
    >>> list = [1,2,3]
    >>> f(*l)
    1
    2
    3

    위와 같이 패킹기법을 이용하여 3개의 인자를 하나의 가변인자로 모두 사용할 수 있다.

    다음은 언패킹에 대한 코드를 살펴보자. 변수 언패킹의 가장 좋은 사용 예는 반복이다.

    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 users_from_rows(dbrows):
    	return [
    		User(user_id, first_name, last_name)
    		for (user_id, first_name, last_name) in dbrows
    	]
    
    users_from_rows(USERS)

    위와 같이 코드를 작성하면 직관적이고, 파이썬스럽게 작성할 수 있다.

  • 함수 인자의 개수

    함수의 인자는 가능한 적을수록 좋다. 하나의 함수가 작동하기 위해 너무 많은 파라미터가 필요하거나 반대로 파라미터의 값에 대응하여 너무 많은 것들을 함수에서 처리하고 있다면 함수를 작게 쪼개는 것이 좋다.

    함수 인자의 개수가 많은 경우 크게 두 가지 방법이 있다. 우선 문제의 함수를 살펴보자

    def func(a,b,c,d,e,f):
    	pass

    위 함수는 너무 많은 인자를 받고 있다. 이런 경우 전달하는 모든 인자를 포함하는 새로운 하나의 객체를 인자로 주면 깔끔해진다.

    def func(class_param): # == def func(a,b,c,d,e,f):
    	pass

    다른 한가지 방법은 가변인자 혹은 키워드 인자를 사용하는 방법이다. 하지만 이는 매우 동적이고, kwargs에 어떤 변수가 남겨오는지 명시되지 않기 때문에 남용하지 않도록 유의해야 한다.

    def func(*args, **kargs):
    	pass

    여기까지 책 '파이썬 클린코드' 3장 좋은 코드의 일반적인 특징에 대한 내용에 대해 정리해 보았습니다.

profile
제 코드가 세상에 보탬이 되면 좋겠습니다.

0개의 댓글