결제는 언제나 복잡합니다. 주행 요금이 최초 산정되면 유저가 소유한 정기권, 쿠폰, B2B 제휴사, 거치대 할인, 각 파트너사 등 모든 경우를 적용하여 최종 요금이 부과됩니다. 그 중 쿠폰은 정기권과 함께 유저가 가장 많이 사용하는 할인 수단 중 하나입니다. 비즈니스 요구사항은 점차 다양해지면서 다양한 유형의 쿠폰이 생겨났고, 현재는 크게 4가지 타입의 쿠폰이 존재합니다.
N분 무료 쿠폰
잠금해제 무료 쿠폰
요율 할인 쿠폰
금액 할인 쿠폰
(적용 시점) 현재 적용 가능한 쿠폰인가 ?
(적용 시점) 적용 시점에 만료가 되었다면 만료 처리를 진행 할 수 있는가 ?
(적용 시점) 각 쿠폰 타입에 따라 요금 할인이 올바르게 적용 되었는가 ?
(사용 시점) 결제 이후 쿠폰에 대한 사용 처리 했는가 ?
쿠폰의 적용 시점만 보더라도 “쿠폰의 타입의 구분”하고 “적용 가능 여부를 확인”하며, “쿠폰의 혜택에 맞는 요금 계산” 을 진행해야합니다. 만약 위의 과정을 하나의 플로우에서 처리한다면 어떨까요 ? 간단하게 생각하면 수많은 if / elif / else 체이닝을 통해 해결 할 수 있을 것 입니다.
하지만 이럴 경우, 테스트 코드 관점에서는 하나의 로직을 통으로 테스트 해야하기 때문에 단위 단위의 테스트 코드를 작성하기에 취약하고, 누군가 새로운 로직을 추가할 때 한 눈에 로직을 파악하기 어렵습니다. 사소한 변경에도 예상치 못한 사이드 이펙트가 발생할 수가 있고, 무엇보다 앞으로 새로운 유형의 쿠폰이 등장한다면 ? 유지보수의 난이도가 급격히 높아질 것입니다. 이시점에서라도 앞으로의 유지보수의 효율을 높이기 위해 쿠폰 적용 로직을 인터페이스 기반으로 리팩토링을 진행하기로 했습니다.
우선 쿠폰의 대표적인 적용 과정을 추상화하여 이를 추상 클래스로 정의했습니다.
class CouponHandlerBase(ABC):
"""interface class"""
@abstractmethod
def can_consume(self) -> bool:
"""쿠폰 적용 가능 여부"""
raise NotImplementedError
@abstractmethod
def consume(self, charge: int) -> int:
"""쿠폰 적용. 쿠폰 할인 금액 반환"""
raise NotImplementedError
@abstractmethod
def finalize(self) -> None:
"""쿠폰 사용 후 처리"""
raise NotImplementedError
이후 주행 쿠폰외에 다른 쿠폰(정기권을 할인 해주는 쿠폰이라던지..)이 생성될 것을 고려하여 공통으로 사용할 메서드들을 담는 CouponHandlerMixin 클래스를 정의했습니다. 그리고 이것들을 상속받는 주행 전용 쿠폰 클래스인 RidingCouponHandler를 정의했습니다.
class CouponHandlerMixin:
"""mixin class"""
def _used(self) -> None:
"""쿠폰 사용 처리"""
...
def _expired(self) -> None:
"""쿠폰 만료 처리"""
...
class RidingCouponHandler(CouponHandlerMixin, CouponHandlerBase):
def __init__(self):
...
def consume(self, charge: int) -> int:
# 세부 구현체에서 overriding
pass
def can_consume(self) -> bool:
# 쿠폰 적용 가능 여부 로직의 경우 공통된 부분이기 때문에 상위 구현체에서 구현
pass
def finalize(self) -> None:
# 쿠폰 사용 후 처리 로직의 경우 공통된 부분이기 때문에 상위 구현체에서 구현
pass
그리고 RidingCouponHandler를 상속 받는 최종적으로 세부 구현체를 구현했습니다.
class UnlockFreeCoupon(RidingCouponHandler):
"""잠금 해제 무료 쿠폰"""
def consume(self, charge: int) -> int:
# 쿠폰 적용 로직 구현
...
이 모든 것을 다이어그램으로 그리면 아래와 같습니다.
어떤가요 ? 하나의 로직에서 쿠폰의 유형을 구분하고, 적용 가능한지 확인 한 후, 실제 적용을 했을 때보다 정리가 되었고, 각 쿠폰 구현체 별 로직을 좀 더 파악 할 수 있지 않나요 ? 하지만 1차 리팩토링 이후 몇 가지 문제점을 발견했습니다.
- 추상 메서드와 일반 메서드가 함께 존재하는 안티패턴
- 불필요한 상속으로 인한 복잡성 증가 및 유연성 감소
그렇게 1차 리팩토링 후 개섬점을 발견했고, 액션 아이템을 도출했습니다.
추상 클래스와 구현체의 명확한 구분
불필요한 상속을 줄여 유지보수에 용이한 단순한 구조 만들기
우선 추상 매서드와 일반 메서드와의 불편한 공존을 제거하고 공통된 로직은 Mixin 클래스에 구현하면서 RidingCouponHandler의 불필요한 상속을 정리했습니다.
class CouponHandlerBase(ABC):
"""interface class"""
@abstractmethod
def consume(self, charge: int) -> int:
"""쿠폰 적용. 쿠폰 할인 금액 반환"""
raise NotImplementedError
class CouponHandlerMixin:
"""mixin class"""
def _used(self) -> None:
"""쿠폰 사용 처리"""
...
def _expired(self) -> None:
"""쿠폰 만료 처리"""
...
def can_consume(self) -> bool:
"""쿠폰 적용 여부"""
...
def finalize(self) -> None:
"""쿠폰 사용 후 처리"""
...
그리고 세부 구현체에서 Mixin 클래스와 추상 클래스와 상속받으면서, 중복을 제거하는 동시에 추상 클래스의 행동을 강제할 수 있었습니다.
class UnlockFreeCoupon(CouponHandlerMixin, CouponHandlerBase):
"""잠금 해제 무료 쿠폰"""
def __init__(self, coupon: Coupon, riding: Riding):
...
def consume(self, charge: int):
# 쿠폰 적용 로직 구현
...
최종 다이어그램은 아래와 같습니다. 어떤가요 ? 1차 리팩토링 이후와 비교해보았을 때 확실히 단순해졌고, 이해하기 쉬워졌습니다.
이전에는 쿠폰 적용 로직을 테스트 하기 위해서는 모든 적용 로직을 한 번에 테스트를 했어야 하나, 이제는 각 단계별로 구분하여 단위 별 테스트를 할 수 있게 되었습니다.
@pytest.fixture
def coupon(user) -> Coupon:
expire_at = (timezone.now() + relativedelta(days=10)).strftime('%Y-%m-%d')
return baker.make(Coupon, user=user, is_active=True, expire_at=expire_at)
@pytest.fixture
def time_free_coupon(coupon) -> Coupon:
coupon.type = Coupon.TypeChoices.TIME_ONCE
coupon.value = 10
coupon.save()
return coupon
@pytest.fixture
def expired_coupon(coupon) -> Coupon:
coupon.expire_at = (timezone.now() - relativedelta(days=1)).strftime('%Y-%m-%d')
coupon.save()
return coupon
@pytest.mark.django_db
class TestCoupon:
def test_10분_무료_쿠폰을_적용한다(self, time_free_coupon, endpoint, auth_client):
response: Response = auth_client.post(endpoint, data={'coupon_id': time_free_coupon.id})
assert response.status_code == status.HTTP_200_OK
assert 600 == response.json()['amount']
def test_적용_시점에_만료된_쿠폰은_사용할_수_없다(self, expired_coupon, endpoint, auth_client):
response: Response = auth_client.post(endpoint, data={'coupon_id': expired_coupon.id})
assert response.status_code == status.HTTP_412_PRECONDITION_FAILED
절차적으로 적용되던 쿠폰 로직를 인터페이스를 적용한 로직으로 리팩토링하면서 얻을 수 있는 효과는 아래와 같습니다.
각 구현체의 크기가 작고, 명확하게 구분되어 있어 테스트 코드를 작성 할 때도, 특정 구현체의 로직을 변경하더라도 안전하게 테스트하고 적용 할 수 있습니다.
물론 이번 리팩토링이 완벽하지는 않습니다. 리팩토링을 하면서도 이게 맞나 싶은 고민점들이 많았고, 공통의 기능을 Mixin으로 빼는 것이, 최종 구현체를 이렇게 구현하는 것이 맞는지 계속 의문이 들고 있습니다. 하지만 언제나 개선을 안한 것 보다는 한 것이 낫고, 저의 고민이 들어간 코드가 반영된 것만으로도 행복합니다. 무엇보다 이번 경험을 통해 이전에는 해보지 못한 고민을 해본 것이 가장 큰 의미라고 생각합니다.