python / / 2024. 9. 8. 18:40

Python OOP에서 피해야 할 나쁜 습관

아래는 Avoid These BAD Practices in Python OOP의 강좌를 번역한 내용입니다.

https://www.youtube.com/watch?v=yFLY0SVutgM&t=1264s

객체 지향 파이썬 코드가 관리하기 힘든 스파게티 코드로 변하고 있나요?
오늘은 반드시 피해야 할 나쁜 관행들과 그 대신 해야 할 것들에 대해 다루겠습니다.

첫 번째 나쁜 관행은, 함수가 클래스인 척하는 경우입니다.

1. 클래스인 척하는 함수

기본 코드

from pathlib import Path

class DataLoader:
    def __init__(self, file_path: Path) -> None:
        self.file_path = file_path

    def load(self) -> str:
        with open(self.file_path, "r") as file:
            return file.read()

def main() -> None:
    loader = DataLoader(Path("data.txt"))
    data = loader.load()
    print(data)

if __name__ == "__main__":
    main()

사실 이런 경우는 꽤 흔합니다. 처음에는 클래스를 사용하기 시작하죠. 이 예시에서는 DataLoader라는 클래스가 있습니다. 사실 이 DataLoader 클래스를 보면 매우 단순합니다. 메서드 하나와 초기화자만 가지고 있죠. 만약 클래스에 더 많은 메서드를 추가할 예정이고, 이 메서드들이 모두 접근해야 하는 데이터가 있으며, 이 클래스를 여러 인스턴스로 만들어야 한다면 클래스를 사용하는 것이 의미가 있습니다. 그렇지 않다면, 함수로 사용하는 것이 더 나은 선택입니다. 이 경우, DataLoader 클래스는 필요하지 않습니다. 이것을 함수로 바꿀 수 있습니다. 지금 바로 해보겠습니다.

개선 코드

from pathlib import Path

def load(file_path: Path) -> str:
    with open(file_path, "r") as file:
        return file.read()

def main() -> None:
    data = load(Path("data.txt"))
    print(data)

if __name__ == "__main__":
    main()

먼저 메서드를 가져와서, self 대신 파일 경로를 전달합니다. 참고로, 여기서 파일 경로는 pathlibpath 타입 객체입니다. 이제 self는 더 이상 필요하지 않습니다. 그리고 이 전체 클래스를 제거할 수 있습니다.
이제 더 이상 인스턴스를 만들 필요가 없고, 그냥 load를 호출하고 필요한 데이터를 전달하면 됩니다. 이렇게 하면 코드가 훨씬 간단해집니다.
이제 이것이 제대로 동작하는지 확인하기 위해 실행해 보겠습니다. 문제없이 잘 동작합니다. 파일에서 데이터를 로드했습니다.
이렇게 메서드의 컨테이너로만 클래스를 자주 사용하면, 불필요한 복잡성과 보일러플레이트 코드가 추가됩니다.
왜냐하면 메서드를 호출하려면 항상 클래스 인스턴스를 만들어야 하기 때문입니다.
대안으로 정적 메서드를 추가할 수 있지만, 그럼에도 여전히 className.method 형식으로 호출해야 합니다.
클래스가 매우 기본적이고 메서드 하나만 가지고 있다면, 함수를 사용하는 것이 더 쉽고 가독성도 높아집니다

2. 클래스처럼 위장한 모듈

기본 코드

class StringProcessor:
    @staticmethod
    def reverse_and_uppercase(s: str) -> str:
        return s[::-1].upper()

    @staticmethod
    def remove_vowels(s: str) -> str:
        vowels = "aeiouAEIOU"
        return "".join([char for char in s if char not in vowels])

    @staticmethod
    def count_words(s: str) -> int:
        return len(s.split())


def main() -> None:
    s = "Hellow, World!"
    print(StringProcessor.reverse_and_uppercase(s))
    print(StringProcessor.remove_vowels(s))
    print(StringProcessor.count_words(s))


if __name__ == "__main__":
    main()

두 번째 예시는 이와 약간 관련이 있습니다. 이는 클래스를 사용하는 또 다른 예시지만, 적어도 파이썬에서는 더 간단한 방법이 있습니다.
만약 이런 유형의 클래스가 있다면, 이번 경우에는 문자열을 처리하는 몇 가지 유용한 메서드를 가진 StringProcessor 클래스가 있습니다.
하지만 실제로 이 클래스의 인스턴스를 생성할 필요는 전혀 없습니다, 그렇죠? 그런데 우리가 단어를 세는 메서드를 호출하고 싶다면, 그 메서드를 작성하는 main 함수를 만들어야 합니다.
그리고 이러한 메서드를 호출하려면 실제로 StringProcessor.reverseAndUpperCase나 모음을 제거하는 메서드 또는 단어를 세는 메서드를 호출해야 합니다. 그것들을 추가해 보죠, 그리고 코드를 실행해서 실제로 어떻게 동작하는지 보겠습니다. 물론 이 방식도 매우 유용하지만, 이 코드를 보면 클래스와 정적 메서드 데코레이터가 복잡함을 더할 뿐입니다.
파이썬에서는 대신 모듈을 사용할 수 있습니다.
즉, 별도의 파일에 코드를 작성하고 다른 곳에서 이를 import하는 방식이죠.
따라서 이 예시에서는 이 코드를 복사해서 string_utils.py라는 다른 파일에 붙여넣을 수 있습니다. 그 후에 StringProcessor 클래스를 완전히 제거할 수 있습니다. 그리고 들여쓰기를 제거하고, 이제 이건 그냥 파이썬 모듈이 되었습니다. 그리고 여기 있는 코드에서 string_utils로부터 reverse_and_upgrade, remove_vowels, 그리고 count_words를 import할 수 있습니다. 이제 더 이상 StringProcessor 클래스 이름을 사용할 필요도 없습니다.

개선 코드

[string_utils.py]

def reverse_and_uppercase(s: str) -> str:
    return s[::-1].upper()

def remove_vowels(s: str) -> str:
    vowels = "aeiouAEIOU"
    return "".join([char for char in s if char not in vowels])

def count_words(s: str) -> int:
    return len(s.split())
from arjanCodes.avoid_bad_practice.string_utils import (
    reverse_and_uppercase,
    remove_vowels,
    count_words,
)


def main() -> None:
    s = "Hellow, World!"
    print(reverse_and_uppercase(s))
    print(remove_vowels(s))
    print(count_words(s))


if __name__ == "__main__":
    main()

이 상태로 코드를 다시 실행하면 이전과 동일한 결과가 나오지만, 이제는 별도의 모듈로 구성되었습니다.
클래스 대신 모듈을 사용하고자 하는 이유는 객체 지향 개념을 잘못 사용하고 있기 때문입니다. 클래스의 전체 개념은 그것의 인스턴스를 생성하는 것입니다. 그리고 인스턴스가 필요 없다면, 클래스가 정말로 도움이 되는지 의문입니다.
Java와 같은 일부 언어에서는 모든 것이 클래스에 있어야 하기 때문에 선택의 여지가 없습니다. 물론 다른 프로그래밍 언어로 전환할 수도 있겠지만, 여러 제약 때문에 항상 가능한 건 아닙니다.
파이썬에서는 주로 클래스보다는 함수와 모듈로 시작하는 경향이 있습니다. 그것이 일반적으로 더 간단한 코드를 만들 수 있기 때문입니다. 데이터와 밀접하게 관련된 동작이 있거나 여러 인스턴스가 필요할 때에만 클래스가 좋은 선택이 됩니다. 그리고 이러한 주제들은 소프트웨어 설계에서 자주 논의되는 사항들입니다.

3. 복잡한 상속 구조

기본 코드

class Employee:
    def get_details(self) -> str:
        return "Employee"

class Manager(Employee):
    def get_details(self) -> str:
        return "Manager"

class SeniorManager(Manager):
    def get_details(self) -> str:
        return "Senior Manager"

class Director(SeniorManager):
    def get_details(self) -> str:
        return "Director"

def main() -> None:
    manager = Manager()
    senior_manager = SeniorManager()
    director = Director()
    print(manager.get_details())  # Manager
    print(senior_manager.get_details())  # Senior Manager
    print(director.get_details())  # Director


if __name__ == "__main__":
    main()

사람들은 종종 상속을 사용하여 코드를 회피하거나 분리하려고 시도하는데, 이는 상황을 더욱 악화시키는 경우가 많습니다. 여기에는 SOLID 원칙이라고 불리는 일련의 원칙이 있습니다. 이 원칙들은 로버트 마틴에 의해 제안되었습니다. 그중 S는 단일 책임 원칙(Single Responsibility Principle)을 의미합니다. 각 부분이 특정한 책임을 가지도록 코드를 분리하고 싶겠지만, 상속은 그리 좋은 방법이 아닙니다. 상속은 많은 결합을 초래하기 때문입니다. 하위 클래스는 상위 클래스에 매우 의존적입니다.

여기서 예시로 직원(Employee) 클래스를 가지고 있습니다. 이 클래스에는 get_details라는 메서드가 있습니다. 아주 간단한 예시이지만, 보통은 더 복잡한 내용이 있을 것입니다. 그런데 상속 메커니즘을 남용해서 특정 직원의 역할을 설명하려고 하고 있습니다. 우리는 매니저(Manager), 선임 매니저(Senior Manager), 그리고 이사(Director)를 가지고 있으며, get_details의 구현은 각 역할에 따라 달라집니다. 이것이 그 역할을 결정합니다. 이 코드를 실행하면 아주 단순한 결과를 얻게 됩니다. 하지만 이것은 코드 구조를 정리하는 좋은 방법이 아닙니다.

역할을 설명하기 위해 상속을 사용하는 것은 적절하지 않은 방법입니다. 그래서 상속 구조를 평탄화하고, 상속 대신 컴포지션(구성)을 사용하는 것이 더 좋습니다. 예를 들어, 하위 클래스에 따라 다른 일을 하는 메서드를 재정의하는 대신, 역할(Role) 클래스를 도입할 수 있습니다. 사실 열거형(Enum)을 사용할 수도 있습니다.

개선 코드

from enum import StrEnum

class Role(StrEnum):
    EMPLOYEE = "Employee"
    MANAGER = "Manager"
    SENIOR_MANAGER = "Senior Manager"
    DIRECTOR = "Director"

class Employee:
    def __init__(self, role: Role) -> None:
        self.role = role

    def get_details(self) -> str:
        return self.role

def main() -> None:
    manager = Employee(Role.MANAGER)
    senior_manager = Employee(Role.SENIOR_MANAGER)
    director = Employee(Role.DIRECTOR)
    print(manager.get_details())  # Manager
    print(senior_manager.get_details())  # Senior Manager
    print(director.get_details())  # Director

if __name__ == "__main__":
    main()

우선 문자열 열거형(String Enum)을 가져오겠습니다. 그리고 역할(Role) 클래스를 정의할 건데, 이는 문자열 열거형이 될 것입니다. 여기에는 직원(Employee), 매니저(Manager), 선임 매니저(Senior Manager), 이사(Director)가 포함됩니다. 그런 다음, 이 다양한 하위 클래스를 삭제하고, 초기화 메서드에서 역할을 받아서 직원에게 할당하는 방식으로 바꿀 것입니다. 이제 get_details 메서드는 단순히 역할을 반환하게 됩니다. 혹은 더 짧게 이렇게 작성할 수도 있습니다.

이제 이러한 인스턴스를 생성하는 대신, 특정 역할을 가진 직원을 생성하게 됩니다. 그리고 이 코드를 실행하면, 물론 동일한 결과를 얻게 됩니다. 하지만 이제는 상속 계층 구조가 사라지고, 역할 인스턴스 변수를 가진 직원 클래스 하나만 남게 됩니다. 이것이 바로 컴포지션의 기본 아이디어입니다.

이 예시는 매우 간단하지만, 코드 전체에 상속 계층을 추가하면 복잡성이 증가하고 코드를 유지 관리하기가 더 어려워집니다. 또한, 나중에 코드를 이해하거나 확장하기도 어려워질 것입니다. 새로운 역할 유형마다 새로운 하위 클래스를 도입해야 하기 때문입니다. 컴포지션은 보통 더 쉽고, 설정하기도 간단하며 관리하기도 쉽습니다. 따라서 상속을 사용할 때, 이것이 정말 적절한 방법인지 생각해 보아야 합니다. 많은 경우에 컴포지션을 사용한 더 나은 해결책을 찾을 수 있습니다.

그리고, 파이썬에서 상속의 또 다른 형태가 있는데, 제 생각에는 이것이 더 나쁩니다. 그 내용은 이 비디오의 끝에서 설명할 예정이니 끝까지 시청해 주세요. 네 번째로 나쁜 관행은 추상화에 의존하지 않는다는 것입니다.

4. 추상화에 의존하지 않기

기본 코드

from dataclasses import dataclass

@dataclass
class Order:
    customer_email: str
    product_id: int
    quantity: int

class SmtpEmailService:
    def connect_to_smtp_server(self) -> None:
        print("Connecting to SMTP server...")

    def send_email(self, recepient: str, message: str) -> None:
        print(f"Sending email to {recepient}: {message}")

def process_order(order: Order) -> None:
    email_service = SmtpEmailService()
    email_service.connect_to_smtp_server()
    email_service.send_email(order.customer_email, "Your order has been processed")

def main() -> None:
    order = Order(customer_email="test@test.com", product_id=123, quantity=3)
    process_order(order)

if __name__ == "__main__":
    main()

기본적으로, 메서드를 직접 호출하거나, 메서드나 함수 내에서 다른 클래스의 객체를 생성하는 경우입니다. 여기에서 그 예를 볼 수 있습니다.

제가 가지고 있는 Order 클래스는 고객 이메일, 제품 ID, 그리고 수량을 가지고 있습니다. 아주 기본적인 클래스죠. 또한 SMTP 이메일 서비스도 있는데, 이 서비스는 서버에 연결하고 고객에게 이메일을 보내는 데 사용됩니다. 그리고 process_order 함수가 있는데, 이 함수는 이메일 서비스를 생성하고, 서버에 연결한 후, 특정 주문에 대해 고객에게 이메일을 보냅니다. 그다음, 간단한 main 함수가 있어 주문을 생성하고 process_order를 호출합니다. 그러면 이것을 실행해보겠습니다.

이제 우리가 얻는 결과는 "주문이 처리되었습니다."입니다.

하지만 이 이메일 서비스 객체를 이곳에서 생성하는 문제는 process_order 함수의 유연성을 많이 떨어뜨린다는 점입니다. 이제 이 함수는 오직 특정한 이메일 서비스에서만 사용할 수 있습니다. 또한 이 함수의 테스트도 훨씬 더 어려워지는데, 왜냐하면 이 함수를 호출할 때마다 실제 이메일 서비스를 생성하고 실제 이메일을 보내기 때문입니다. 특히 이 특정 주문의 이메일을 받는 사람이라면 더 문제가 될 수 있습니다.

개선 코드

from dataclasses import dataclass

@dataclass
class Order:
    customer_email: str
    product_id: int
    quantity: int

class SmtpEmailService:
    def connect_to_smtp_server(self) -> None:
        print("Connecting to SMTP server...")

    def send_email(self, recepient: str, message: str) -> None:
        print(f"Sending email to {recepient}: {message}")

def process_order(email_service: SmtpEmailService, order: Order) -> None:
    email_service.connect_to_smtp_server()
    email_service.send_email(order.customer_email, "Your order has been processed")

def main() -> None:
    order = Order(customer_email="test@test.com", product_id=123, quantity=3)
    email_service = SmtpEmailService()
    process_order(email_service, order)

if __name__ == "__main__":
    main()

객체지향 코드에서 할 수 있는 것은 객체를 생성하는 것과 사용하는 것을 동일한 장소에서 하지 않도록 하는 것입니다. 이 경우에는 이메일 서비스를 생성하는 코드를 process_order 함수 밖으로 옮겨야 합니다. 좋은 위치는 main 함수가 될 수 있습니다. 그리고 당연히 process_order 함수는 이메일 서비스를 인자로 받아야 합니다.

이제 이메일 서비스를 main 함수에서 생성하고 그것을 인자로 전달할 수 있습니다. 그러면 이것을 실행해서 여전히 동일한 결과를 얻는지 확인해봅시다. 결과는 동일합니다.

이제 코드 변경이 더 쉬워졌습니다. 예를 들어, SMTP 이메일 서비스의 서브클래스를 만들고 변경한 다음, 그것을 process_order에 전달할 수 있습니다. 하지만 이메일 서비스를 이곳에 전달하는 것보다 더 나은 해결책은 추상화를 사용하는 것입니다.

그 전에, 제가 이 코드에 할 또 다른 변경은, 사실 서버에 연결하는 것은 process_order에서 할 일이 아니라고 생각합니다. 이것을 main 함수에서 처리할 수 있습니다. 이제 process_order 함수는 이메일을 보내는 일만 처리합니다. 또한 주문이 처리되었다는 것을 표시하는 몇 가지 줄을 더 추가할 수도 있습니다.

추상화를 사용하면 이제 process_order 함수가 SMTP 이메일 서비스에 의존하지 않게 됩니다. 우리는 이를 위해 파이썬에서 예를 들어 프로토콜 클래스를 사용할 수 있습니다. 그러면 새로운 클래스를 만들겠습니다. 이 클래스를 EmailService라고 부르고, 프로토콜 클래스입니다. 이 클래스에는 send_email 메서드가 있습니다. 이제 SMTP 이메일 서비스를 전달하는 대신, EmailService 프로토콜을 전달합니다.

이제 여전히 동일한 결과를 얻습니다. 기본적으로 타입 주석만 변경했고, 이 추가 클래스를 도입했을 뿐입니다. 하지만 이제 process_order는 어떤 종류의 이메일 서비스든 받을 수 있습니다. send_email 메서드만 있으면 IMAP 이메일 서비스도 사용할 수 있습니다. 그리고 이것이 process_order가 의존하는 것이 됩니다.

파이썬에서는 추상 베이스 클래스를 사용할 수도 있습니다. 이러한 것들의 일반적인 아이디어는 계약이나 인터페이스처럼 작동한다는 점입니다. 한 번 이를 설정하면 나중에 다른 것으로 대체하기가 더 쉬워집니다. 예를 들어, process_order의 테스트를 작성해야 한다면, 간단히 모의 이메일 서비스를 생성해 process_order 함수에 제공할 수 있고, 실제 이메일 서비스를 생성할 필요가 없습니다.

5. 캡슐화를 무시하기

기본 코드

class BankAccount:
    def __init__(self, balance: int) -> None:
        self.balance = balance

def main() -> None:
    account = BankAccount(100)
    account.balance -= 150
    account.balance += 100
    print(account.balance)

if __name__ == "__main__":
    main()

다섯 번째 나쁜 관행은 캡슐화를 하지 않는 것이다. 예를 들어, 은행 계좌 클래스가 있고, 이 클래스에는 잔액이 있다고 하자.
이 예시에서 우리가 은행 계좌를 다루는 방식은 잔액을 직접 수정하는 것이다. 잔액에서 50을 빼거나 100을 더하는 방식이다. 캡슐화란 외부로부터 구현 세부 사항을 숨기는 것을 의미한다. 이것이 클래스에서 메서드와 속성이 허용하는 것이다.
만약 우리가 다른 코드가 내부 표현, 즉 이 경우에는 잔액을 직접 수정할 수 있도록 허용한다면, 다양한 문제를 초래할 수 있다.

예를 들어, 우리는 단순히 훨씬 더 많은 금액을 뺄 수 있다. 이 경우에는 아무런 체크가 없다. 하지만 잔액이 100이라면, 우리는 150을 인출할 수 없어야 한다. 물론, 대출과 같은 시스템이 있다면 이야기가 다를 수 있겠지만 말이다.
잔액을 직접 수정하는 대신, 이 은행 계좌를 캡슐화된 버전으로 만들 수 있다. 예를 들어, 잔액 앞에 밑줄을 붙여서 이 변수가 내부적으로 사용되는 것임을 클래스 사용자에게 명확히 할 수 있다.

개선 코드

class BankAccount:
    def __init__(self, balance: int) -> None:
        self._balance = balance

    @property
    def balance(self) -> int:
        return self._balance

    def withdraw(self, amount: int) -> None:
        if amount > self._balance:
            raise ValueError("Insufficient funds")
        self._balance -= amount

    def deposit(self, amount: int) -> None:
        if amount < 0:
            raise ValueError("Deposit amount must be positive")
        self._balance += amount

def main() -> None:
    account = BankAccount(100)
    account.withdraw(50)
    account.deposit(100)
    print(account.balance)

if __name__ == "__main__":
    main()

Java와 같은 언어에서는 이런 인스턴스 변수에 대해 protected나 private 수식어를 추가할 수 있다. Python에서는 이러한 기능이 없지만, 최소한 이렇게 표시하는 것이 가능하다. 이것이 완전한 보장은 아니지만, 코드상에서 이것이 직접 변경되어서는 안 된다는 신호를 줄 수 있다. 다음으로 할 수 있는 것은 balance라는 속성을 추가하는 것이다. 이 속성은 잔액 값을 반환한다. 그래서 여전히 계좌의 잔액을 출력할 수 있다.

그다음으로는 인출 메서드를 추가한다. 인출할 금액을 제공하고, 그 금액을 잔액에서 빼는 것이다. 하지만 당연히 잔액이 충분할 때만 이를 수행해야 한다. 그래서 만약 인출 금액이 잔액보다 크다면, value error를 발생시킨다. 그리고 입금 메서드도 비슷한 방식으로 작동한다. 여기에서는 단순히 금액을 잔액에 더해 주면 되지만, 금액이 양수인지 확인해야 한다. 만약 금액이 0보다 작다면, 0은 허용된다고 할 수 있지만 0보다 작다면 금액이 양수여야 한다는 value error를 발생시킨다.

이제 잔액을 직접 수정하는 대신, 이러한 메서드를 호출한다. 그래서 withdraw와 deposit 메서드를 사용한다. 이 코드를 실행해보면, 현재 잔액인 75가 출력된다. 이제 잔액이라는 내부 표현을 캡슐화했다. 즉, 클래스 외부에서는 메서드와 속성을 통해 상호작용하게 된다.

이 접근 방식의 좋은 점은 이러한 검증을 추가하여 은행 계좌 인스턴스의 내부 일관성을 보장할 수 있다는 것이다. 또한 버그를 찾기도 더 쉬워진다. 다른 이유로는 외부에서는 여전히 정수 값을 다루고 싶지만, 내부적으로는 소수 값을 사용하고 싶을 수도 있다. 이 경우에는 내부 표현을 변경하고, 인출 및 입금 메서드가 정수와 함께 작동하도록 변경하면 된다. 메인 함수에서는 아무것도 변경할 필요가 없다.

또 다른 예시로, 데이터베이스와 상호작용할 때 JSON 데이터를 포함한 필드를 저장하고 싶다고 가정해보자. 관계형 데이터베이스가 이를 지원하지 않을 경우 문자열로 저장해야 한다. 그러면 클래스는 데이터베이스 테이블 위에 있는 ORM 계층이 되어 JSON 데이터를 받고 문자열로 저장하는 getter와 setter를 포함할 수 있다. 즉, 파서와 문자열화 메서드를 사용해 JSON을 문자열로 변환하고 그 반대로 변환하는 것이다. 이처럼 캡슐화를 사용하는 이유는 매우 많다. 물론, 캡슐화가 필요하지 않은 상황도 존재한다.

6. getter와 setter의 과도한 사용

기본 코드

class Person:
    def __init__(self, name: str, age: int) -> None:
        self._name = name
        self._age = age

    def get_name(self) -> str:
        return self._name

    def set_name(self, name: str) -> None:
        self._name = name

    def get_age(self) -> int:
        return self._age

    def set_age(self, age: int) -> None:
        self._age = age

def main() -> None:
    person = Person(name="John", age=30)
    print(person.get_name())
    print(person.get_age())
    person.set_name("Jane")
    person.set_age(25)
    print(person.get_name())
    print(person.get_age())

if __name__ == "__main__":
    main()

이건 정말 데이터 중심의 클래스일 때 해당됩니다. 여기서 저는 Person 클래스를 예로 들고 있습니다. Person 클래스는 단순히 이름과 나이를 가지고 있습니다. 그게 전부입니다.

그리고 저는 여기 이름과 나이를 캡슐화했습니다. 여기 보시면 언더스코어(_)로 표시해두었죠. 하지만 이 값을 수정하는 방식을 보면 getName, setName, getAge, setAge를 사용하고 있습니다. 사실상 아무런 일도 일어나지 않습니다. 왜냐하면 Person은 그냥 데이터를 담고 있는 컨테이너일 뿐이니까요.

여기서 캡슐화는 아무런 목적을 갖고 있지 않고, 저는 단지 이런 코드를 계속해서 타이핑하느라 손가락만 아플 뿐입니다. 이건 특히 예전 Java 코드에서 흔하게 보이는 현상입니다. 그때는 이런 식으로 게터와 세터를 많이 만들었고, 그 코드 작성에 많은 시간이 들었습니다. 하지만 요즘은 이런 방식을 덜 선호하게 되었습니다. 만약 특별한 동작이나 검증이 필요하지 않다면, 이런 게터와 세터를 사용하지 않고 그냥 속성에 직접 접근할 수 있습니다. 그래서 이 경우에 저는 Person을 데이터 클래스로 바꾸는 게 더 낫다고 생각합니다.

개선 코드

from dataclasses import dataclass

@dataclass
class Person:
    name: str
    age: int

def main() -> None:
    person = Person(name="John", age=30)
    print(person.name)
    print(person.age)
    person.name = "Jane"
    person.age = 25
    print(person.name)
    print(person.age)

if __name__ == "__main__":
    main()

그러면 Person 클래스는 단순히 이름과 나이를 가지고 있을 뿐이고, 모든 코드를 다 지울 수 있습니다. 이제 이렇게 속성에 직접 접근할 수 있습니다. 이렇게 하면 코드가 훨씬 간단해집니다. 이제 실행하면 이렇게 동작합니다.

비록 Person 클래스의 캡슐화가 줄어들었지만, 기본적인 데이터 표현에 불과하기 때문에, 굳이 불필요하게 복잡한 보일러플레이트 코드를 추가할 필요가 없습니다.

7. 믹스인에 과도하게 의존하기

기본 코드

from dataclasses import dataclass

@dataclass
class Order:
    customer_email: str
    product_id: int
    quantity: int

class LogMixin:
    def log(self, message: str) -> None:
        print(f"INFO: {message}")

class SaveMixin:
    def save(self) -> None:
        print("Data saved")

class ProcessOrder(LogMixin, SaveMixin):
    def process(self, order: Order) -> None:
        self.log(f"Processing order {order}")
        self.save()

class CancelOrder(LogMixin, SaveMixin):
    def cancel(self, order: Order) -> None:
        self.log(f"Cancelling order {order}")
        self.save()

def main() -> None:
    order = Order(customer_email="test@test.com", product_id=123, quantity=2)
    processor = ProcessOrder()
    processor.process(order)
    canceler = CancelOrder()
    canceler.cancel(order)

if __name__ == "__main__":
    main()

기능을 기존 클래스에 추가하기 위해 믹스인을 남용하면 복잡하고 추적하기 어려운 클래스 계층 구조를 만들 수 있습니다.
예를 들어, 여기 Order 클래스가 있습니다. Log 메서드를 가진 로그 믹스인이 있고, Save 메서드를 가진 세이브 믹스인이 있습니다. 그리고 저는 이 기능들을 주문 처리나 취소와 같은 클래스에 믹스인하고 있습니다.

이렇게 여러 클래스에 작은 기능을 추가하기 위해 믹스인을 사용하고 있는 것이죠. 이러한 믹스인의 문제는 아주 쉽게 복잡하고 취약한 계층 구조를 만들 수 있다는 점입니다. 앞서 언급했듯이 상속은 가장 강력한 결합 방식 중 하나입니다. 그리고 이제는 다중 상속을 사용하고 있습니다.
일부 언어에서는 이런 다중 상속을 아예 금지하기도 합니다. 이것은 다양한 문제를 일으킬 수 있기 때문입니다. 사실, 많은 경우 믹스인을 사용하지 않고도 해결책을 찾을 수 있습니다.

대신 컴포지션(구성)을 사용하는 방법이 있죠. 예를 들어, 여기서는 믹스인을 사용하지 않고도 process_order를 초기화할 때 세이버와 로거를 받아서 저장하는 방식으로 해결할 수 있습니다. 이 방법은 상속 대신 객체에 인스턴스를 저장하는 것이죠. cancel_order에서도 동일하게 처리할 수 있습니다.

개선 코드

from dataclasses import dataclass
from typing import Callable

@dataclass
class Order:
    customer_email: str
    product_id: int
    quantity: int

def log(message: str) -> None:
    print(f"LOG: {message}")

def save() -> None:
    print("Data saved")

type SaveFn = Callable[[], None]
type LogFn = Callable[[str], None]

def process_order(order: Order, saver: SaveFn, logger: LogFn) -> None:
    logger(f"Processing order {order}")
    saver()

def cancel_order(order: Order, saver: SaveFn, logger: LogFn) -> None:
    logger(f"Cancelling order {order}")
    saver()

def main() -> None:
    order = Order(customer_email="test@test.com", product_id=123, quantity=2)
    process_order(order, save, log)
    cancel_order(order, save, log)

if __name__ == "__main__":
    main()

상속 관계를 제거하고, 객체를 생성할 때 로거와 세이버를 전달하는 방식으로 변경하는 것입니다. 그리고 이런 방식으로 코드를 실행하면 로그를 확인할 수 있고, 데이터가 저장되었다는 메시지도 받을 수 있습니다.

이제 이 클래스들은 더 이상 믹스인이 아니므로 이름을 변경할 필요가 있습니다. 그래서 이것을 LoggerSaver로 변경하겠습니다. 이제 코드가 훨씬 나아졌습니다. 사실 이 경우 클래스 대신 함수로 처리하는 것이 더 좋을 수 있습니다.
LoggerSaver 클래스는 너무 간단하기 때문에 클래스 대신 함수로 바꾸는 것이 더 적합합니다. 그래서 단순히 함수를 사용하는 방식으로 코드를 간소화할 수 있습니다.

클래스에서도 동일한 방식으로 처리할 수 있습니다. 인스턴스를 생성하고 저장하는 대신 함수 자체를 전달하는 것이죠. 주문을 처리할 때 함수들을 전달할 수 있고, 주문을 취소할 때도 동일한 방식으로 적용할 수 있습니다. 이제 더 이상 클래스 인스턴스 생성이 필요하지 않고, 단순히 함수를 전달할 수 있게 되었습니다.

코드를 실행하면 동일한 결과가 나옵니다. 물론 이 예시는 매우 간단해서 가능한 일이지만, 모든 경우에 클래스를 없애고 함수로 대체할 수 있는 것은 아닙니다. 여기서 코드를 조금 더 깔끔하게 만들고 싶은데, 타입 주석이 없는 것이 마음에 들지 않습니다.

그래서 Python 3.12에서 추가된 방법을 사용하여 타입 별칭을 정의해보겠습니다.
typing 모듈에서 Callable을 가져오고, 세이브 함수와 로그 함수를 정의한 후 이를 사용하여 타입을 명확하게 지정하겠습니다. 코드를 실행하면 이전과 동일한 결과가 나옵니다. 이렇게 복잡한 상속 구조에서 간단한 함수로 전환했습니다. 이것이 제가 함수를 좋아하는 이유 중 하나입니다. 함수를 사용하면 코드가 훨씬 단순해지고, 항상 객체 지향 코드를 사용할 필요는 없습니다. 물론 객체 지향 코드가 나쁘다는 것은 아닙니다. 데이터와 그 데이터에 작용하는 메서드가 있고 여러 인스턴스가 필요할 때는 클래스가 적합합니다. 저는 또한 구조화되지 않은 딕셔너리 대신 기본적인 데이터 구조를 위해 클래스 사용을 선호하는데, 이것이 IDE에서 필드 접근 오류를 빠르게 찾는 데 도움이 되기 때문입니다. 하지만 단순히 함수를 사용할 수 있는 경우에는 굳이 클래스를 만들지 말고, 모듈이나 함수를 사용하는 것이 좋습니다. 깊은 상속 계층 구조는 피하고, 추상 기반 클래스나 프로토콜과 같은 추상화를 사용해 코드를 분리하는 것이 좋습니다.

그리고 단순한 데이터 객체일 경우 많은 setter와 getter를 추가하지 않도록 하세요. 마지막으로 믹스인 대신 컴포지션을 사용하는 것이 좋습니다.

반응형
  • 네이버 블로그 공유
  • 네이버 밴드 공유
  • 페이스북 공유
  • 카카오스토리 공유