지금까지 다룬 개념들을 종합해, 실무에서 자주 보이는 안티패턴(잘못된 습관으로 굳은 설계)을 고친다. 대표적인 것이 하나의 클래스가 모든 일을 떠안는 god object(신 객체)와 상속을 남용한 클래스 폭발이다. 안티패턴은 처음엔 편해 보이지만 코드가 커질수록 변경 한 번에 사방이 무너지는 지뢰가 된다. 이번 편은 잘못된 코드를 먼저 보이고 리팩터링으로 고친 뒤 무엇이 좋아졌는지 짚는다.
15.1 God Object 분해
쇼핑몰의 모든 기능을 한 클래스에 몰아넣은 ShopGod를 보자. 상품 관리, 사용자 관리, 구매, 정산을 혼자 다 한다. 무엇을 바꾸든 이 거대한 클래스를 열어야 한다.
class ShopGod:
def __init__(self):
self.items = {}
self.users = {}
def add_item(self, name, price):
self.items[name] = price
def add_user(self, name):
self.users[name] = []
def buy(self, user, item):
self.users[user].append(item)
return f"{user}가 {item} 구매"
def total_spent(self, user):
return sum(self.items[i] for i in self.users[user])
shop = ShopGod()
shop.add_item("책", 15000)
shop.add_user("철수")
print(shop.buy("철수", "책"))
print("총액:", shop.total_spent("철수"))
실행 결과는 다음과 같다.
철수가 책 구매
총액: 15000
상품, 사용자, 정산이 한 클래스에 엉켜 있다. 정산 방식만 바꾸려 해도 사용자 관리 코드 옆을 건드려야 해서 사고가 나기 쉽다. 이제 역할별로 Catalog(상품), Cart(장바구니), Checkout(정산)으로 나눈다. 각자 한 가지 책임만 지는 단일 책임 원칙을 적용한 것이다.
class Catalog:
def __init__(self):
self.items = {}
def add(self, name, price):
self.items[name] = price
def price(self, name):
return self.items[name]
class Cart:
def __init__(self):
self.items = []
def add(self, name):
self.items.append(name)
class Checkout:
def __init__(self, catalog):
self.catalog = catalog
def total(self, cart):
return sum(self.catalog.price(i) for i in cart.items)
catalog = Catalog(); catalog.add("책", 15000)
cart = Cart(); cart.add("책")
print("총액:", Checkout(catalog).total(cart))
실행 결과는 다음과 같다.
총액: 15000
큰 클래스를 책임 단위로 쪼개고, 필요한 관계는 합성(한 객체가 다른 객체를 부품으로 소유하는 것)으로 잇는다. Checkout이 Catalog를 가진 것이 합성이다. 정산 규칙은 Checkout만, 상품은 Catalog만 손대면 되고, 각 조각을 따로 테스트하고 재사용할 수 있다.
15.2 과한 상속을 합성과 전략으로
게임 캐릭터의 능력을 상속으로 표현하면 불과 얼음을 둘 다 쓰는 캐릭터마다 새 클래스를 만들어야 한다. 조합이 늘수록 클래스가 폭발한다.
class Character:
def attack(self): return "기본 공격"
class FireCharacter(Character):
def attack(self): return "불 공격"
class IceCharacter(Character):
def attack(self): return "얼음 공격"
print("before:", FireCharacter().attack())
능력을 상속으로 고정하는 대신, 부품으로 조합한다. 능력 하나하나를 Skill이라는 추상의 구현으로 만들고, 캐릭터는 능력 여러 개를 리스트로 소유한다. 갈아 끼우는 이 방식을 전략 패턴이라 한다.
class Skill(ABC):
@abstractmethod
def use(self): ...
class Fire(Skill):
def use(self): return "불"
class Ice(Skill):
def use(self): return "얼음"
class Hero:
def __init__(self, skills):
self.skills = skills
def attack(self):
return " + ".join(s.use() for s in self.skills)
print("after 불+얼음:", Hero([Fire(), Ice()]).attack())
두 코드를 이어 실행한 결과는 다음과 같다. Skill은 앞서 from abc import ABC, abstractmethod로 불러온 추상을 쓴다.
before: 불 공격
after 불+얼음: 불 + 얼음
능력을 상속으로 고정하면 조합 수만큼 클래스가 필요하지만, 부품으로 소유하면 리스트 조합만으로 무한히 확장된다. 불과 얼음과 번개 같은 새 조합도 클래스 추가 없이 리스트에 넣기만 하면 되고, 실행 중에 능력을 바꿔 끼우는 것도 가능하다.
15.3 데이터클래스로 보일러플레이트 제거
단순히 값을 담기만 하는 클래스인데 __init__, __repr__, __eq__ 같은 메서드를 손으로 다 쓰면 반복이 지루하다. 이렇게 매번 똑같이 반복되는 뻔한 코드를 보일러플레이트라 한다. 파이썬의 @dataclass가 이를 자동으로 만들어 준다.
from dataclasses import dataclass
class PointOld:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f"PointOld({self.x}, {self.y})"
def __eq__(self, other):
return self.x == other.x and self.y == other.y
@dataclass
class PointNew:
x: int
y: int
print("dataclass repr:", PointNew(1, 2))
print("dataclass eq:", PointNew(1, 2) == PointNew(1, 2))
실행 결과는 다음과 같다.
dataclass repr: PointNew(x=1, y=2)
dataclass eq: True
@dataclass를 붙이고 필드만 선언하면 생성자, 표현, 값 비교 메서드가 자동으로 만들어진다. PointOld가 손으로 쓴 세 메서드가 데코레이터 한 줄로 사라진다. 필드가 늘어도 __init__을 손으로 고치지 않는다. 데이터 위주 클래스의 반복 코드를 없애는 것이 무엇이 좋아졌는지의 핵심이다.
15.4 정리
안티패턴을 고치는 열쇠는 앞서 배운 원칙들을 다시 꺼내 쓰는 것이다. 모든 일을 떠안은 god object는 단일 책임 원칙으로 역할을 쪼개고 합성으로 잇는다. 조합마다 클래스가 폭발하는 과한 상속은 능력을 부품으로 소유하는 합성과 전략 패턴으로 편다. 손으로 반복하던 뻔한 코드는 데이터클래스 같은 언어 도구로 지운다. 안티패턴은 하나의 신호로 읽으면 된다. 변경 한 번에 여러 곳이 함께 흔들린다면, 그 자리를 책임 단위로 나누고 추상 뒤로 옮길 때가 된 것이다.