Codong's Development Diary RSS 태그 관리 글쓰기 방명록
2022-01-12 16:57:54

개요


소프트웨어 개발 일을 시작한 지 1년이 다 되어가는 시점이다. 처음에는 기능 구현만 한다면 끝인 줄 알았는데, 협업을 하거나 하루가 다르게 변하는 요구사항에 의해 코드의 수정이 많다 보니 불편함을 느낄 수밖에 없었다. 가령, 예전에 작성한 코드를 수정해야 할 때 처음 본 것 마냥 새롭게 느껴지고, 하나를 수정하면 에러가 나서 타고 타고 들어가서 하나씩 전부 수정하는 고생을 했다. 그러다 문득 '어떻게 하면 이런 고생을 안 하도록 코드를 잘 짤 수 있을까?' 라는 생각이 들었다.

제일 먼저 떠오른게 '클린 코드'였다.

하지만 클린 코드는 '군더더기 없이 깔끔하여 읽기 쉽고, 유지/보수하기 쉬운 코드다' 라고만 막연하게 알고 있지, 구체적으로 어떤 식으로 작성하는지에 대해 알고 있지 않았다. 그래서 이 책을 통해 내가 코드를 짤 때 무지성으로 짜기보다, 앞으로는 좀 더 생각하여 클린한 코드를 짤 수 있기를 기대하며 읽었다.



본문


이 책은 총 10개의 챕터로 구성되어있다. 책의 모든 내용을 다 담기에는 양이 많을 수 있으니, 내가 기억에 남는 부분만 요약해둘 예정이다.


1️⃣ 소개. 코드 포매팅과 도구


클린코드의 의미와 중요성

  • 이 책에서는 '프로그래밍 언어의 진정한 의미는 아이디어를 다른 개발자에게 전달하는 것이다' 라고 말했다. 또한 여기에 클린 코드의 진정한 본질이 있으며, 클린 코드인지 아닌지는 다른 엔지니어가 코드를 읽고 유지 관리할 수 있는지 여부에 달려 있다고 말한다.
  • 중요한 이유는 내가 느꼈던 것과 같이 대부분은 유지보수성 향상, 기술 부채의 감소, 애자일 개발을 통한 효과적인 작업 진행, 성공적인 프로젝트 관리로 이어진다는 등등 굉장히 많다.

Docstring과 annotation

  • Docstring은 comment가 아니라 문서이다. 코드의 특정 컴포넌트에 대한 문서화이다.
  • 코드에 주석(comment)을 다는 것은 나쁜 습관이다. 첫째. 주석은 코드로 아이디어를 제대로 표현하지 못했음을 나타내는 것이다. 둘째. 오해의 소지가 있다. 주석 업데이트를 깜빡하는 경우, 코드와 주석의 내용이 다를 수 있다.

2️⃣ 파이썬스러운(pythonic) 코드


✔️ 파이썬에서의 밑줄

public, private, protected 프로퍼티를 가지는 다른 언어들과 다른게 파이썬 객체의 모든 프로퍼티와 함수는 public이다. 즉 호출자가 객체의 속성을 호출하지 못하도록 할 방법이 없다. 엄격한 강제사항은 없지만 몇 가지 규칙이 있다. 밑줄로 시작하는 속성은 해당 객체에 대해 private을 의미하며, 외부에서 호출하지 않기를 기대하는 것이다. 기대할 뿐이지 금지하는 것은 아니다.

그러나 일부 속성과 메서드를 이중 밑줄을 이용해 private으로 만들 수 있다는 오해가 있다. 밑줄 두 개를 사용하면 실제로 파이썬은 다른 이름을 만든다. 이를 이름 맹글링(name mangling)이라 한다. 이것이 하는 일은 다음과 같은 이름의 속성을 만드는 것이다.

_<class-name>__<attribute-name>

Connector 라는 클래스의 __timeout 이라는 속성을 만들었다고 가정하자. 그러면 '_Connector__timeout' 이라는 속성이 만들어지며 저 이름으로 속성에 접근이 가능해진다. 파이썬에서 이중 밑줄을 사용하는 것은 완전히 다른 경우이다. 여러 번 확장되는 클래스의 메서드를 이름 충돌 없이 오버라이드 하기 위해 만들어졌다.

결과적으로 이중 밑줄은 파이썬스러운 코드가 아니다. 속성을 private으로 정의하려는 경우 하나의 밑줄을 사용하는 파이썬스러운 관습을 지키도록 하자.

✔️ 컨테이너 객체

컨테이너는 __contains__ 메서드를 구현한 객체로 __contains__ 메서드는 일반적으로 Boolean 값을 반환한다. 이 메서드는 파이썬에서 in 키워드가 발견될 때 호출된다. 예제를 통해 이 메서드를 잘 사용하면 얻을 수 있는 효과를 살펴보자.

2차원 게임 지도에서 특정 위치에 표시를 해야 한다고 생각해보자. 보통 이런 함수를 생각할 수 있다.

def mark_coordinate(grid, coord):
    if 0 <= coord.x < grid.width and 0 <= coord.y < grid.height:
        grid[coord] = MARKED

if 문이 상당히 난해하고 의도가 무엇인지 쉽게 판단하기 어려워 보인다. 그러면 어떻게 바꿀 수 있을까? 지도에서 자체적으로 grid라 부르는 영역을 판단해주면 어떨까? 그리고 이 일을 더 작은 객체에 위임하면 어떨까? (위임을 통해 응집력도 높아진다.) 이렇게 하면 지도에게 특정 좌표가 포함되어 있는지만 물어보면 된다.

class Boundaries:
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def __contains__(self, coord):
        x, y = coord
        return 0 <= x < self.width and 0 <= y < self.height

class Grid:
    def __init__(self, width, height):
        self.width = width
        self.height = height
        self.limits = Boundaries(width, height)

    def __contains__(self, coord):
        return coord in self.limits

이렇게 구성이 간단하고 위임을 통해 문제를 해결한다. 두 객체 모두 최소한의 논리를 사용했고, 매서드는 짧고 응집력이 있다. 또한 외부에서도 다음과 같이 사용 가능하다.

def mark_coordinate(grid, coord):
    if coord in grid:
        grid[coord] = MARKED

✔️ 파이썬에서 유의할 점

  1. 변경 가능한(mutable) 파라미터의 기본 값
def wrong_user_display(user_metadata: dict = {"name":"john","age":30}):
name = user_metadata.pop("name")
age = user_metadata.pop("age")
return f"{name} ({age})"

이렇게 파라미터의 기본값을 변경 가능한 값으로 준다면 처음 호출할 때만 동작한다. 그렇기 때문에 다음과 같은 문제가 일어난다.

>>> user_metadata() # 'john (30)'
>>> user_metadata({"name":"jane", "age":25}) # 'jane (25)'
>>> user_metadata() # KeyError: "name"

처음 기본 값인 딕셔너리가 만들어지고, 요소들은 pop으로 삭제되었다. 그렇기 떄문에 다시 기본값으로 호출되었을 때 빈 딕셔너리기 때문에 KeyError가 발생한 것이다.

그렇기에 초기값으로 None을 사용하고 함수 본문에서 기본 값을 할당하면 된다. 각 함수는 자체 스코프와 생명주기를 가지기 때문이다.

  1. 내장(built-in) 타입 확장

리스트, 문자열, 사전과 내장 타입을 확장하는 올바른 방법은 collection 모듈을 사용하는 것이다. 예를 들어 dict를 직접 확장하는 클래스를 만들면 예상하지 못한 결과를 얻을 수 있다. 그 이유는 Cpython에서는 클래스의 메서드를 서로 호출하지 않기 때문에 메서드 중에 하나를 오버라이드하면 나머지에는 반영이 되지 않아서 예기치 않은 결과가 발생한다. ex) __getitem__을 오버라이드하고 for 루프를 사용해 객체를 반복하려고 하면 해당 로직이 적용되지 않는 것을 알게 된다. collection.UserDict를 사용해서 문제를 해결할 수 있다.

...

✍️ 장을 마무리 하며

이 장을 읽으면서 파이썬을 그래도 조금 알고 있다고 생각했는데, 구동 원리까진 생각을 안 해봤었다. with, in 키워드가 어떻게 작동하는지, iterable 객체가 어떻게 요소들을 반환하는지 알게 되었다. 신기하면서도 어렵다고 생각이 들었다. 알면 알수록 더 모르는 것 투성이니까...😂

하지만 파이썬의 매직 메서드들을 잘 이용하면 파이썬스럽게 코드를 짤 수 있다! 이번에 배운 것들을 잘 이용할 수 있도록 노력하자!


3️⃣ 좋은 코드의 일반적인 특징


사용자에게 API를 제공할 때 코드가 정상적으로 동작하기 위해 기대하는 것과 호출자가 반환받기를 기대하는 것은 디자인의 하나가 되어야 한다. 여기서 계약(contract)이라는 개념이 생긴다. 계약에 의한 디자인(Design by Contract)이란 관계자가 기대하는 바를 암묵적으로 코드에 삽입하는 대신 양측이 동의하는 계약을 먼저 한 다음, 계약을 어겼을 경우는 명시적으로 왜 계속할 수 없는지 예외를 발생시키라는 것이다.

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

이렇게 하는 이유는 오류가 발생할 때 쉽게 찾아낼 수 있고, 잘못된 가정 하에 코드의 핵심 부분이 실행되는 것을 방지하기 위해서이다. 이것은 에러를 발생시키는데서 그치는 것이 아니라 책임의 한계를 명확히 하는데 도움 된다. 사전 조건 실패 -> 클라이언트 결함 / 사후 조건 실패 -> 컴포넌트 결함.

  1. 사전 조건(Precondition) : 함수가 자체적으로 로직을 실행하기 전에 검사하도록 까다로운(demanding) 접근 방법을 일반적으로 사용한다. DRY원칙을 잊지 말자. 검증을 양쪽에서 하지 말고 함수에만 두던, 클라이언트에만 두던 오직 어느 한쪽에서만 해야 한다.
  2. 사후 조건(Postcondition) : 사전 조건이 맞는다면 사후 조건은 특정 속성이 보존되도록 보장해야 한다.

이를 적용할 때 사전 조건에 대한 검사와 사후 조건에 대한 검사 그리고 핵심 기능에 대한 구현을 구분하는 것이 좋을 것이다. 이것을 파이썬스럽게 데코레이터를 사용하는 것이 흥미로운 대안이 될 수 있다.

이런 디자인 원칙의 주된 가치는 문제가 있는 부분을 효과적으로 식별하는 데 있다.

✔️ 에러 핸들링

프로그램에서 에러를 처리하는 방법에는 여러 가지가 있지만 모든 것을 처리할 수 있는 것은 아니다. 에러 처리 방법의 일부를 살펴보자.

  1. 값 대체 (substitution) : 일반적으로 기본 값으로 바꾸어도 큰 문제가 없지만 오류가 있는 데이터를 유사한 값으로 대체하는 것은 더 위험하며 일부 오류를 숨겨버릴 수 있기에 이러한 기준을 잘 고려해야 한다.
  2. 에러 로깅
  3. 예외 처리 : 예외는 오직 한 가지 일을 하는 함수의 한 부분이어야 한다. 또한 캡슐화를 약화시키기 때문에 신중하게 사용해야 한다. 마지막으로 traceback이 노출되면 안 된다.

파이썬의 예외와 관련된 몇 가지 권장 사항에 대해 알아보자.

  • 비어있는 except 블록 지양
    • 이것의 문제는 결코 실패하지 않는다는 것이다. 심지어 실패해야만 할 때조차도. 대안으론 보다 구체적인 예외를 사용하거나 실제 오류 처리를 진행한다.
  • 원본 예외 포함
    • 오류 처리 과정에서 다른 오류를 발생시키고 메시지를 변경할 수도 있다. raise <e> form <original_exception> 구문을 사용하면 된다.
  • 어설션 사용하기
    • 절대로 일어나지 않아야 하는 상황에 사용되므로 assert 문에 사용된 표현식은 불가능한 조건을 의미한다. 즉, 잘못된 가정 하에 처리를 계속하기보다는 프로그램을 중단시키는 것이 좋을 때 사용한다. 어설션에 실패하면 반드시 프로그램을 종료시켜야 한다.

✔️ 관심사의 분리

이것은 여러 수준에서 적용되는 설계 원칙이다. 책임이 다르면 컴포넌트, 계층 또는 모듈로 분리되어야 한다. 프로그램의 각 부분은 기능의 일부분(관심사)에 대해서만 책임을 지며 나머지 부분에 대해서는 알 필요가 없다. 이런 관심사 분리의 목표는 파급 효과를 최소화하여 유지보수성을 향상시키는 것이다.

  • 응집력(cohesion)과 결합력(coupling)

이것은 훌륭한 소프트웨어 설계를 위한 중요한 개념이다.

응집력이란 객체가 작고 잘 정의된 목적을 가져야 하며 가능하면 작아야 한다는 것을 의미한다. 유닉스 명령어가 한 가지 일만 잘 수행하려는 것과 비슷한 철학을 따른다. 응집력이 높을수록 더 유용하고 재사용성이 높아지므로 더 좋은 디자인이다.

결합력이란 두 개 이상의 객체가 서로 어떻게 의존하는지를 나타낸다. 객체 또는 메서드의 두 부분이 서로 너무 의존적이라면 낮은 재사용성, 파급효과, 낮은 수준의 추상화와 같은 바람직하지 않은 결과를 가져온다.

✔️ 개발 지침 약어

이 섹션에서는 좋은 디자인 아이디어를 주는 몇 가지 원칙을 검토한다. 요점은 좋은 소프트웨어 관행을 약어를 통해 쉽게 기억하자는 것이다.

1️ DRY(Do not Repeat Yourself) / OAOO(Once and Only Once)

중복은 반드시 피하라 / 코드에 있는 지식은 단 한번, 단 한 곳에 정의되어야 한다. 그렇지 않다는 것은 잘못된 시스템의 징조이다.

2️ YAGNI(You Ain't Gonna Need it)

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

3️ KIS (Keep It Simple)

선택한 솔루션이 문제에 적합한 최소한의 솔루션인지 자문해보자. 단순한 것이 복잡한 것보다 낫다.

4️ EAFP(Easier to Ask Forgiveness than Permission) / LBYL(Look Before You Leap)

허락보다는 용서를 구하는 것이 낫다 / 도약하기 전에 살피라. EAFP는 일단 코드를 실행하고 실제 동작하지 않을 경우에 대응한다는 뜻이다. 일반적으로는 코드를 실행하고 발생한 예외를 catch 하여 except 블록에서 바로잡는 코드를 실행하게 된다. LBYL은 그 반대이다.

예를 들어 파일을 사용하기 전에 먼저 파일을 사용할 수 있는지 확인하라는 것이다.

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

이것은 다른 프로그래밍 언어에서는 유용할 수 있지만 파이썬스러운 방식은 아니다. 파이썬은 EAFP 방식으로 만들어졌으며, 여러분도 그렇게 할 것을 권한다.(암묵적인 것보다 명시적인 것이 좋다는 것을 기억하자.) 즉 다음과 같이 작성할 수 있다.

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

✔️ 컴포지션(Composition)과 상속(Inheritance)

상속은 강력한 개념이지만 위험도 있다. 가장 주된 위험은 부모 클래스를 확장하여 새로운 클래스를 만들 때마다 부모와 강력하게 결합된 새로운 클래스가 생긴다는 점이다. 그럼 어떤 때에 상속을 사용하면 좋을까?

  • 상속이 좋은 선택인 경우

새로운 하위 클래스를 만들 때 상속된 모든 메서드를 실제로 사용할 것인지 생각해보는 것이 좋다. 만약 대부분의 메서드를 필요로 하지 않고 재정의하거나 대체해야 한다면 다음과 같은 이유로 설계상의 실수라고 할 수 있다.

  1. 상위 클래스는 잘 정의된 인터페이스 대신 막연한 정의너무 많은 책임을 가졌다.
  2. 하위 클래스는 확장하려고 하는 상위 클래스의 적절한 세분화가 아니다.

즉, 상속을 잘 사용한다는 것은 부모 클래스의 기능을 그대로 물려받으면서 추가 기능을 더하려는 경우 또는 특정 기능을 수정하려는 경우이다.

➕ 책에서 말하는 상속의 좋은 예 : https://docs.python.org/3/library/http.server.html#http.server.BaseHTTPRequestHandler

인터페이스 정의는 상속의 또 다른 좋은 예로, 어떤 객체에 인터페이스 방식을 강제하고자 할 때 구현을 하지 않은 기본 추상 클래스를 만들고, 실제 이 클래스를 상속하는 하위 클래스에서 적절한 구현을 하도록 하는 것이다.

상속을 올바르게 사용하면 객체를 전문화하고 기본 객체에서 출발하여 세부적인 추상화를 할 수 있다. 하지만 코드 재사용만을 목적으로 상속을 사용하려고 할 때 매우 자주 발생하는 문제는 부모 클래스와 성격이 다른 메서드를 추가할 때이다. 이런 상황을 피하기 위해 파이썬의 전형적인 안티 패턴은 데이터 구조 자체를 객체로 만드는 경우이다. 고객에게 정책을 적용하는 기능을 가진 보험관리 시스템을 예로 살펴보자.

# 잘못된 상속의 예
class TransactionPolicy(collections.UserDict):
    def change_in_policy(self, customer_id, **new_policy_data):
        self[customer_id].update(**new_policy_data)

이와 같이 구현을 하면 두 가지의 주요 문제점이 생긴다.

  1. 새 클래스를 만드는 것은 개념적으로 확장되고 세부적인 것이라는 것을 의미한다. 즉, 계층 구조가 잘못된 것이다. TransactionPolicy라는 이름만 보고 사전 타입이라는 것을 알 수 있을까?
  2. 결합의 문제가 생긴다. TransactionPolicy는 dictionary의 모든 메서드를 포함한다. pop(), items()가 필요할까? 사전 타입을 확장함으로써 얻은 이득도 별로 없다.

이것이 구현 객체를 도메인 객체와 혼합할 때 발생하는 문제이다. TransactionPolicy는 특정 도메인 정보를 나타내는 것이므로 해결하려는 문제의 일부분에 사용되는 엔티티여야만 한다. 그럼 어떻게 해결할까? 이럴 때 컴포지션을 사용하는 것이다.

즉, TransactionPolicy이 dictionary가 되는 것이 아니라 사전을 활용하는 것이다. 사전을 private 속성에 저장하고 __getitem__()으로 사전의 프록시를 만들고 나머지 필요한 public 메서드를 추가적으로 구현하는 것이다.

➕ 컴포지션(Composition) 이란 여러 객체를 합하여 다른 하나로 만드는 것을 말한다. 어떤 객체가 다른 객체의 일부분인 경우다. 자동차가 엔진, 트랜스미션, 헤드라이트 등으로 구성되는 것 같은 경우이다.

➕ 프록시(Proxy) 는 우리말로 대리자, 대변인이라는 뜻이다. 메서드를 호출하고 반환 값을 받는 것이 실제 클래스에서 처리하는지, 대리자 클래스의 메서드가 처리하는지 전혀 모르게 처리하는 것이다. 중요한 것은 흐름 제어만 할 뿐 결과값을 조작하거나 변경시키면 안 된다.

# 컴포지션 사용 리팩토링
class TransactionPolicy:
    def __init__(self, policy_data, **extra_data):
        self._data = {**policy_data, **extra_data} # dictionary 객체 composittion

    def change_in_policy(self, customer_id, **new_policy_data):
        self[customer_id].update(**new_policy_data)

    def __getitem__(self, customer_id):
        return self._data[customer_id]

    def __len__(self):
        return len(self._data)

이 방법은 개념적으로 정확할 뿐만 아니라 확장성도 뛰어나다. 현재는 dictionary지만, 향후 다른 자료구조로 변경하려 해도 인터페이스만 유지하면 사용자는 영향을 받지 않는다. 이는 결합을 줄이고 파급 효과를 최소화하며 코드를 유지 관리하기 쉽게 만든다.

✔️ 파이썬의 다중 상속

파이썬은 다중 상속을 지원한다. 다중 상속을 통해 새로운 패턴(ex. 어댑터 패턴 등)과 믹스인(mixin)을 활용하여 강력한 애플리케이션을 만들 수 있다. 다중 상속시 파이썬은 C3 linearization 또는 MRO라는 알고리즘을 사용하여 메서드 호출 순서에 대한 문제를 해결한다. 우선은 상속받는 순서가 빠른 클래스부터 우선순위를 가진다고 생각하고 넘어가자. 다중 상속에 대해 좀 더 자세히 알고 싶으면 정리를 잘해둔 다른 블로그를 추천한다.

믹스인은 그럼 무엇인가? 나는 장고를 하면서 내부 클래스들을 살펴보다가 본 적이 있었다. 이름만 봐서는 뭔가를 섞어서 만드는 느낌이다. 믹스인은 코드를 재사용하기 위해 일반적인 행동을 캡슐화해놓은 기본 클래스이다. 보통은 다른 클래스와 함께 믹스인 클래스를 다중 상속하여 믹스인에 있는 메서드나 속성을 사용한다. 상속의 계층구조를 잘 이해하고 사용한다면 효과적으로 설계가 가능하다.

다른 것을 제쳐 놓고 단순하게 말하자면 믹스인은 데코레이터처럼 기존 클래스에 직접적으로 추가하지 않고 다중 상속으로 메서드를 추가시킬 수 있다.

✔️ 함수의 인자

함수가 제대로 작동하기 위해 너무 많은 파라미터가 필요한 경우 추상화가 부족했다던지 설계가 잘못되었을 확률이 크다. 이럴 경우 리팩터링을 할 수 있도록 하자. 그리고 파라미터로 객체를 전달하여 사용하는 것이 좋은 방법이 될 수 있다. 하지만 부작용 방지를 위해 전달받은 객체를 변경해서는 안 된다.

...

✍️ 장을 마무리하며

이번 장에서는 독립성에 대해 강조를 하는 것 같다. 각각의 기능들은 변경 시 영향을 끼쳐서는 안 된다는 것이다. 이러한 결합성을 가지지 않고 독립적이라는 것은 정말 좋은데 말이 쉽지 실제로 적용시키기 힘들었었다. 하지만 이번 장에서 배운 믹스인, 상속 등을 알게 되어 이용해 잘 추상화, 캡슐화시킬 수 있도록 해야 봐야겠다. 중요한 포인트는 "파급 효과를 최소화하여 유지보수성을 향상시키자" 인 것 같다.


4️⃣ SOLID 원칙

이 장에서는 파이썬에 적용된 클린 디자인의 원리를 계속 탐구할 것이다. SOLID 원칙을 검토하고 이를 파이썬스러운 방식으로 구현하는 방법을 설명한다. SOLID의 뜻은 다음과 같다.

  1. S : 단일 책임 원칙 (Single responsibility principle)
  2. O : 개방-폐쇄 원칙 (Open/closed principle)
  3. L : 리스코프 치환 원칙 (Liskov substitution principle)
  4. I : 인터페이스 분리 원칙 (Interface segregation principle)
  5. D : 의존관계 역전 원칙 (Dependency inversion principle)

이것들에 대해 하나씩 살펴보자.

✔️ 단일 책임 원칙(SRP)

단일 책임 원칙은 소프트웨어 컴포넌트(일반적으로 클래스)가 단 하나의 책임을 져야 한다는 원칙이다. 클래스가 유일한 책임이 있다는 것은 변화해야 할 이유는 단 하나뿐이라는 말을 의미한다.

하나의 클래스 안의 메서드들이 각각이 독립적인 동작을 한다면, 이 메서드들을 다른 클래스로 분리하여 각 클래스마다 단일 책임을 갖게 할 수 있다. 이 경우 책임이 나눠진 클래스들을 조합하여 동일한 기능을 하는 객체를 만들 수 있다.

✔️ 개방/폐쇄 원칙(OCP)

개방/폐쇄 원칙은 모듈이 개방되어 있으면서도 폐쇄되어야 한다는 원칙이다. 간단히 말해서 확장 가능하고, 새로운 요구사항이나 도메인 변화에 잘 적응하는 코드를 작성해야 한다는 뜻이다. 예를 들면 새로운 기능을 추가하다가 기존 코드를 수정했다면 그것은 기존 로직이 잘못 디자인되었다는 것을 뜻한다.

기능이 추가되었을 때 기존 로직이 변경되지 않는 것에 초점을 맞춰봤을 때, if elif ... 의 체인 형식으로는 가독성도 떨어지고 메서드가 계속 커짐을 알 수 있다. 뭔 소리인지 잘 모르겠으니 예제로 살펴보겠다. SystemMonitor라는 클래스에서 event에 따라 분류를 한다고 가정하자.

class Event:
    def __init__(self, raw_data):
        self.raw_data = raw_data

class LoginEvent(Event):
    ...

class logoutEvent(Event):
    ...

class UnknownEvent(Event):
    ...


class SystemMonitor:
    def __init__(self, event_data):
        self.event_data = event_data

    def identify_event(self):
        if(
            self.event_data["before"]["session"] == 0
            and self.event_data["after"]["session"] == 1
        ):
            return LoginEvent(self.event_data)
        elif(
            self.event_data["before"]["session"] == 1
            and self.event_data["after"]["session"] == 0
        ):
            return LogoutEvent(self.event_data)
        return UnknownEvent(self.event_data)

이와 같은 경우 다른 event class 들이 생기면 메서드를 수정해줘야 한다. (+ elif의 남발은 사용하면 가독성이 최악)
이것을 개방/폐쇄 원칙에 맞춰 코드를 변경해보도록 하겠다.

class SystemMonitor:
    ...

    def identify_event(self):
        for event_cla in Event.__subclasses__():
            try:
                if event_cls.meets_condition(self.event_data):
                    return event_cls(self.event_data)
            except KeyError:
                continue
        return UnknownEvent(self.event_data)

__subclasses__ 메서드와 해당 클래스를 분류하는 로직이 meets_condition이라는 메서드로 각 클래스별 구현하도록 함으로써(다형성) event class가 늘어나도 수용할 수 있도록 유연해졌다. 핵심은 identify_event 메서드의 수정 없이 event class를 확장시켜나갈 수 있다는 것이다.

추가로 이 원칙은 다형성의 효과적인 사용과 밀접하게 관련되어 있다. 다형성을 따르는 형태의 계약을 만들고 모델을 쉽게 확장할 수 있는 일반적인 구조로 디자인하는 것이다.

✔️ 리스코프 치환 원칙(LSP)

리스코프 치환 원칙은 설계 시 안정성을 유지하기 위해 객체 타입이 유지해야 하는 일련의 특성을 말한다. 이 원칙의 주된 생각은 어떤 클래스에서든 클라이언트는 특별한 주의를 기울이지 않고도 하위 타입을 사용할 수 있어야 한다는 것이다.

느낌아 잘 안 온다. 이외에도 위키를 살펴보면 이 원칙을 만족하는 조건들이 있다.

  • 하위형에서 메서드 인수의 반공변성
  • 하위형에서 반환형의 공변성
  • 하위형에서 메서드는 상위형 메서드에서 던져진 예외의 하위형을 제외하고 새로운 예외를 던지면 안 된다.
  • 하위형에서 선행조건은 강화될 수 없다.
  • 하위형에서 후행조건은 약화될 수 없다.
  • 하위형에서 상위형의 불변조건은 반드시 유지되어야 한다.

말이 진짜 어렵다. 나는 여기 있는 모든 것을 이해하는 것은 살짝 내려놓았다. 하지만 읽어보면서 내가 생각하는 핵심은 어떤 인터페이스에서 하나의 클래스를 쓰는데, 그 클래스가 동일한 부모의 다른 자식클래스로 대체되어도 사용자가 제공하는 파라미터를 바뀐 클래스에서도 처리할 수 있어야 하고, 사용자와 약속한 결과를 반환해야 한다는 것이다.

하위형에서 메서드 인수의 반공변성, 반환형의 공변성이 이해가 잘 안 가므로 이 부분만 조금 더 살펴보겠다. 예를 들어 이해해보자면 부모 클래스가 파라미터로 backend|frontend 만 처리할 때, 하위클래스들은 이 두 개를 처리할 수 있어야 한다. 그리고 리턴 값으로는 True, False, ValueError만 반환한다 가정하면 다른 하위 클래스에서도 저 세 개의 결과값을 벗어나선 안된다는 것이다.

이것이 지켜지지 않으면 앞서 말한 개방 폐쇄 원칙도 자연스럽게 깨지게 된다. 당연하다. 다른 클래스로 바뀌었다고 작동을 안 하니까 기존 인터페이스가 수정되어버리는 상황이 생기지 않는가.

✔️ 인터페이스 분리 원칙(ISP)

객체 지향적인 용어로 인터페이스는 객체가 노출하는 메서드의 집합이다. 다중 메서드를 가진 인터페이스를 매우 정확하고 구체적인 구분에 따라 더 적은 수의 메서드를 가진 여러 개의 메서드로 분할하는 것이 좋다. SRP와 유사하지만 주요 차이점은 ISP는 인터페이스에 대해 이야기하고 있다는 점이다. 따라서 이것은 행동의 추상화이다.

그럼 인터페이스는 얼마나 작아야 할까? 나누고, 나누면 하나만 있는 것이 최고인가? 의미를 오해해서 극단적으로 받아들이면 안 된다. 즉, 반드시 딱 하나의 메서드만 있어야 한다는 뜻은 아니다. 하나 이상의 메서드라 하더라도 적절하게 하나의 클래스에 속해 있을 수 있다. 핵심은 관련이 없는 메서드는 분리하자는 것이다.

✔️ 의존성 역전 원칙(DIP)

의존성 역전 원칙은 코드가 깨지거나 손상되는 취약점으로부터 보호해주는 흥미로운 디자인 원칙을 제시한다. 의존성을 역전시킨다는 것은 코드가 세부 사항이나 구체적인 구현에 적응하도록 하지 않고, 대신 API 같은 것에 적응하도록 하는 것이다. A, B 두 객체가 상호교류한다 가정할 때, 인터페이스를 개발하고 인터페이스에 의존적일 수 있도록 한다. 인터페이스를 준수하는 것은 B의 책임이다.

그림으로 보면 이해가 쉽다.

이것과 같이 Syslog로 데이터를 보내는 방식이 변경되면 EventStreamer를 수정해야 한다. 이러한 문제를 해결하려면 EventStreamer를 구체 클래스가 아닌 인터페이스와 대화하도록 하는 것이 좋다.

위 사진과 같이 설계하면 인터페이스의 구현은 세부 구현 사항을 가진 저수준 클래스가 담당하게 된다.

주의 깊게 살펴본 독자는 실제로 이것이 왜 필요한지 궁금해할 것이다. 파이썬은 충분히 융통성이 있으며, 동적인 타입의 언어이기 때문에 인터페이스를 사용하지 않고도 간단하게 send() 메서드를 가진 객체를 넘기면 되는데 왜 굳이 추상 기본 클래스(인터페이스)를 정의하는 것일까?

엄밀히 말하면 이것 또한 사실이다. 그러나 추상 기본 클래스를 사용하는 것이 좋은 습관이다. 상속은 is a 관계임을 기억하자. 위 예시에서 Syslog는 DataTargetClient 라고 말할 수 있다. 이와 같이 모델의 가독성이 높아지고 그 결과 코드 사용자는 코드를 읽고 이해할 수 있다.

결과적으로 추상 기본 클래스(인터페이스)를 사용하는 것이 필수는 아니다. 그러나 클린 디자인을 위해 바람직하다. 이것이 이 책이 있는 이유 중 하나로 단지 파이썬이 너무 유연하여 자주 발생하는 실수를 줄이기 위함이다.

...

✍️ 장을 마무리하며

SOLID 원칙에 대해 알아보았다. 들어만 본 상태였는데 좀 더 구체적으로 생각해보는 계기가 되었다. 쭉 보고 나서 인터페이스 설계를 할 때 객체들의 책임을 적절히 나누고 의존성을 줄이도록 해야겠다는 생각이 들었다. 말이 쉽지만 이것이 가능하려면 크게 봐야한다. 전체적인 프로세스가 어떻게 진행되는지 어떤 객체들이 필요하고 어떤 소통을 해야하는지 먼저 파악해야 한다.

예를 들어 소셜 로그인 기능을 만든다고 생각해보자. 기능에 집중하지말고 구조에 집중하자. 로그인이라는 메서드가 실행되었을 때, 로그인을 하려는 client가 되는 객체가 있을 거고, 인증을 하는 객체가 있을 것이고, 그 데이터를 저장하는 또다른 객체가 있을 것이다. 인터페이스를 잘 만들어서 의존성을 줄이도록 노력하자.

잘못된 디자인은 미래의 많은 비용을 초래한다. 소프트웨어 공학에서 만능 해결책은 없다. 다만 과거 프로젝트에서 검증된 좋은 가이드라인을 따름으로서 성공할 가능성이 높은 소프트웨어를 만들도록 도와준다. SOLID 원칙을 통해 성공적인 소프트웨어를 만들 수 있도록 해보자.



마무리


내용이 한 번에 다 적을 수 있을 정도가 아니어서 끊어서 작성하려고 한다. 우선 지금까지 보면서 개념적인 부분을 많이 봤던 것 같다. 이후 좀 더 구체적으로 파이썬으로 이런 개념적인 부분을 구현할 수 있는 방법을 소개한다고 하니 끝까지 읽어봐야겠다.

이제까지 제일 기억에 남는 것은 SOLID 원칙이다. 객체지향 프로그래밍에 대해 괸심을 가지게 되는 계기가 되었고, 어떻게하면 객체들을 사용하여 유지/보수성, 생산성을 높일 수 있는지에 대해 고민을 해보게 되었다.

계속해서 이 책을 읽으면서 파이썬으로 코드를 짤 때 유지보수를 유용하게 할 수 있도록 하고, 생산성을 높일 수 있는 꿀팁들을 얻어가자.


reference