5년간 잘 사용한 코드 리팩토링하기

개발 끄적끄적 .. ✍️·2024년 10월 27일
0
post-thumbnail

최근, 서비스의 주요 플로우가 기존과 크게 달라지면서 프로덕트적으로 큰 변화가 있었는데요. 비즈니스 요구사항을 반영하면서 이번 기회를 통해 프로젝트 전체적으로 리팩토링을 하게되었습니다. 그 경험을 공유해보려고 합니다.

이 글을 이해하는데 있어서 이해하면 좋은 것들

  • 공유 전기자전거 도메인에서 개발을 하고 있습니다.
  • Python, Django 를 통해 백엔드를 개발 하고 있습니다.
  • 자사 서비스를 B2C, B2B 등 다양한 형태로 제공하고 있습니다.

이번 프로젝트 태스크를 간단하게 생각하면, 기존의 구조를 유지한채 요구 사항을 수행하면 됩니다. 기존 로직에 if/else 평가를 추가하여 신규 로직을 반영하고 일정 기간이 지나면 기존 로직을 제거하고.. 아마 이러한 방법은 아마 대부분의 조직에서 사용하고 있는 방법이라고 생각됩니다.

하지만 이러한 방식이 반복되고, 비즈니스가 복잡하게 될 수록 남아있는 코드는 유지보수 난이도가 기하 급수적으로 올라갈 뿐만 아니라 작은 변화 하나에도 사이드 이펙트를 예상하기 어려워집니다. 이미 코드 상에는 수많은 비즈니스 요구 사항을 수행하기 위해 중첩된 if/else 가 존재했고, 최종 로직을 확인 하기 위해서는 평가의 평가의 평가의 평가의 … 평가를 이해 해야지만 최종 로직을 이해할 수 있었습니다.

다행히(?) 중요한 프로젝트인만큼 이전 프로젝트들보다 상대적으로 여유로운 개발 기간을 확보받았고, 서비스 앱의 전체적인 플로우의 변화가 예정되어 있었기 때문에 이번 기회가 리팩토링의 적기라고 생각했습니다. 프로젝트 시작 전, 함께하는 동료들과 굳건히 “귀찮음과 타협하지 않으리..” 마음을 먹고 시작하게 되었습니다.

기존 로직의 문제 정의하기

리팩토링을 시작 하기 전, 먼저 기존 서비스 로직의 문제를 정의해보았습니다. 문제점에 따라 리팩토링의 방향성이 달라지기 때문입니다.

  • 중복 코드
    • 주요 기능에 대한 모듈화가 되어 있지 않기 때문에 우리 서비스를 제공하는 여러 endpoint 마다 거의 비슷한 코드가 중복 생성되고 있었습니다.
  • 로직 내부의 강한 결합
    • 주요 도메인 함수 내부에서 외부 의존성이 강한 타 도메인을 직접 호출하고 있어, 테스트 및 재사용이 어려웠고 변경이 발생할 경우 관련 메서드를 모두 찾아 대응하는 등 유지보수 난이도가 매우 높았습니다.

이외에도 행위를 알 수 없는 평가와 방대한 클래스/메서드, 암묵적인 호출 등등 여러 부분들이 있었지만, 우선 순위를 정리하고 보니 대부분의 팀원이 유지 보수성이 떨어지는 것에 대해서 문제 의식이 있었고, 향후 비즈니스의 방향성을 고려했을 때 이번 리팩토링의 방향을 확장성유지보수 용이성 으로 설정했습니다.

사용할 모듈 정의하기

확장성과 유지보수의 용이성을 확보하기 위해서는 우선 도메인 모듈을 만들고 각 모듈을 주입하기로 결정했습니다. 기존 로직에서는 크게 “모듈” 의 개념을 찾아보기는 어려웠습니다. Active-records 패턴을 적용하고 있는 Django의 경우 많은 로직이 Model 내부에 존재하다보니 사실 상 Model이 서비스 모듈로써 주요 비즈니스 로직을 처리하고 있었습니다.

하지만 대부분의 주요 로직이 모델 내부에서 흐르다보니 모델의 역할이 점점 커지고 모델 내부의 방대한 로직을 파악하기 어려웠웠습니다. 또한 너무 방대한 모델(fat-model) 이 전체 로직에서 너무 많은 역할을 하다보니, 각 영역간의 역할이 모호해졌습니다. 그래서 비즈니스 로직을 Django Model이 아닌 외부의 비즈니스 클래스로 위임하기로 결정했고, 이번 글에서는 대표적인 두 모듈만 소개하려고 합니다.

  • RidingHandler: 라이딩 관련 모듈
  • FleetHandler: 기기 제어 모듈

신중하게 개발 순서 정의하기

그 다음으로 일감에 대한 개발 순서를 정해보았습니다. 자칫 일감의 순서를 잘못 설계한 경우 이전 작업물이 현재 작업물에 영향을 미치고, 만약 이전 작업물에서 미쳐 고려하지 못한 부분이 있다면 작게는 수정해야하고, 최악의 경우 다시 개발해야하는 생산성의 저하가 발생할 수 있습니다. 따라서 신중하게 개발 순서를 선정 해야 했습니다.

이전 작업물에 최대한 영향을 받지 않기 위해서는 도메인간 의존성이 없는 부분부터 개발해야합니다. 최대한 간단하게 유저 플로우를 통해 도메인간의 의존성을 정리해보았습니다.

  1. 유저는 라이딩 시작을 한다. 이 때 유저의 상태를 라이딩으로 변경하고, 기기의 잠금의 해제 한다.
  2. 유저는 라이딩을 마무리한다. 이 때 유저의 상태를 결제 상태로 변경하고, 기기를 잠근다.

이렇게 작성하고 보니, 기기 제어 도메인은 이전 도메인의 영향을 받지 않고, 수행의 결과만 전달해주는 도메인이었고, 그 결과를 라이딩 도메인이 영향을 받고 있었습니다. 따라서 개발 순서를 기기 제어 모듈 → 라이딩 모듈 과 같이 정해보았습니다.

Ref 1. TemplateMethod Pattern 으로 중복 제거하기

가장 먼저 중복을 제거하기 위해 template method pattern 을 적용해보았습니다. template method pattern 은 부모 클래스에서 알고리즘의 골격을 정의하지만, 해당 알고리즘의 구조를 변경하지 않고 자식 클래스들이 알고리즘의 특정 단계들을 오버라이드(재정의)할 수 있도록 하는 행동 디자인 패턴입니다.

위의 글 배경과 같이, 현재 자사 서비스를 B2C, B2B(InApp), B2B(API) 등 다양한 형태로 제공하고 있지만 모듈화 되어 있지 않기 때문에 비슷한 코드가 사실상 중복으로 관리되고 있었습니다. 한 예로, 저희 프로덕트에서는 전기 자전거를 타는 행위를 라이딩이라고 하며 이 “라이딩 시작” 로직이 각 endpoint별로 비슷한 형태로 관리되고 있었습니다. 라이딩의 제공 형태마다 조금씩 로직은 다르지만 큰 틀에서는 동일하고, 라이딩을 수행하기 위한 주요 로직이 중복으로 관리되고 있었습니다.

이 부분을 충분히 공통 메서드로 추상화 할 수 있다고 판단했고, 이후 필요하다면 overriding하여 행동을 구분하는 것이 유지 보수에 효율적일 것으로 판단했습니다. 먼저 base 클래스를 생성했습니다. base 클래스의 역할은 abstract class와 template method 를 정의한 클래스입니다.

라이딩 시작을 담당하는 클래스입니다. 이를 상속 받는 클래스는 start() 를 구현해야 합니다. 이전에는 abstract class 와 공통메서드를 담는 Mixin 클래스를 구분했지만 불필요한 상속 계층으로 판단되어 이번에는 base 클래스에 공통메서드를 함께 정의했습니다.

class RidingStartHandler(ABC):
    def __init__(
        self,
        ch: Optional[Thru],
        user: User,
        fleet_controller: AbstractFleetController,
    ):
        self.ch = ch
        self.user = user
        self.fleet_controller = fleet_controller
        self.bike = self.fleet_controller.bike

    @abstractmethod
    def start(self, data: RidingStartData) -> Riding:
        pass

이후 이전에 각각 관리되던 중복 로직을 base 클래스에 정의했습니다. 만약 하위 클래스에서 필요하다면 overriding 하여 구현을 다르게 가져갈 수 있습니다.

class RidingStartHandler(ABC):
		   .
		   .
    def validate(self, validate_data: RidingStartData):
		    """라이딩 시작 전 유효성 검증"""

    def create_riding(self, start_point: Point, start_addr: str, started_at: datetime.datetime, ch: Optional[Thru], extra: RidingStartExtra) -> Riding:
        """라이딩 생성"""

    def create_riding_sequence(self, riding: Riding) -> RidingSequence:
        """라이딩 이력 생성"""

    def set_status(self, riding_id: int, start_point: Point):
        """
        바이크 / 유저의 상태를 라이딩 상태로 변경
        Args:
            riding_id: 대상 라이딩 id
            start_point: 라이딩 시작 위치

        Returns:
            None
        """

    def set_riding_point(self, riding: Riding, start_point: Point, origin: int, gps_accuracy: float):
        """
        라이딩 시작 위치 기록, 라이딩 포인트 캐시 초기화
        Args:
            riding: 대상 라이딩
            start_point:
                - 자사 라이딩: 클라이언트로부터 요청 받은 위치
                - b2b 라이딩: 기기 위치
            origin: 위치 정보 출처(0: 클라이언트, 1: 기기)
            gps_accuracy: 기기 위치 정확도. 낮을 수록 신뢰할 수 있는 값

        Returns:
            None
        """

이 방식을 통해 중복 코드를 제거할 수 있었고, 공통 메서드를 벗어난 경우 하위 구현체에서 메서드를 추가하거나 overriding을 통해 다시 구현하면 되기 때문에 이전 로직을 수행하는데도 문제 없이 처리 할 수 있었습니다.

Ref 2. Factory Pattern으로 동적으로 사용할 모듈 결정하기

다음은 팩토리 패턴을 적용했습니다. 팩토리 패턴이란 객체 생성 로직을 별도의 “팩토리 클래스”나 “메서드”로 분리해 관리하는 디자인 패턴입니다. 객체 생성의 복잡성을 감추고 코드의 유연성과 재사용성을 높이는 패턴입니다.

자사 서비스에서 제공하는 라이딩 형태를 정의하고, 특정 slug 에 따라 라이딩 시작을 하는 행위를 담당하는 팩토리 클래스를 생성했습니다. (실제 코드는 아닙니다. 협력사 정보가 포함되어 일부 수정하여 작성했습니다).

class RidingStartFactory:
    @staticmethod
    def create(ch: Optional[Thru], user: User, fleet_controller: AbstractFleetController) -> RidingStartHandler:
        match ch:
            case Thru.DIRECT | None:
                return DirectRidingStartHandler(ch, user, fleet_controller)
            case Thru.APPLESS:
                return AppLessRidingStartHandler(user, fleet_controller)
            case Thru.B2B_1:
                return B2B1RidingStartHandler(user, fleet_controller)
            case Thru.B2B_2:
                return B2B2RidingStartHandler(user, fleet_controller)
            case _:
                raise PreconditionRequired('지원하지 않는 라이딩입니다.')

라이딩을 시작하고 싶은 호출부에서는 해당 조건을 충족할 slug와 파라미터만 주입 한다면 간편하게 객체를 생성할 수 있습니다.

기기 제어 모듈도 마찬가지입니다. 통신 방식에 따라 구분이 되어 호출하는 모듈이 다르기 때문에 이전에는 하나의 코드 상에서 if/else 로 로직이 전개되었다면, 팩토리 패턴을 통해 보다 깔끔하게 객체를 생성할 수 있습니다.

class FleetControllerFactory:
    @staticmethod
    def create(bike: Bike, logger: ControlLogger, mediator: Mediator, **kwargs) -> AbstractFleetController:
        if bike.interactor.legacy_protocol:
            return LegacyFleetController(bike, logger, mediator, **kwargs)
        else:
            return FleetController(bike, logger, mediator, **kwargs)

호출부의 코드는 아래와 같습니다. 각 endpoint별로 매우 절차지향적으로 객체 생성 부터, 비즈니스 로직까지 전개되던 부분을 다음 layer 인 비즈니스 모듈로 넘기면서 controller 에서는 전체적인 코드 전개만 파악할 수 있게 되면서 코드를 파악할 때 훨씬 이점이 생겼습니다.

def riding_start():
    # 기기 제어 모듈 생성
		fleet_controller = FleetControllerFactory.create(bike, Logger(), Mediator())
		
		# 라이딩 시작 모듈 생성
		riding_handler = RidingStartFactory.create(data.partner_slug, user, fleet_controller)
		
		# 라이딩 시작
		riding = riding_handler.start()

Ref 3. Strategy Pattern 으로 기능 주입 받기

마지막으로 strategy pattern 을 적용해보았습니다. strategy pattern을 찾아보면, 알고리즘들의 패밀리를 정의하고, 각 패밀리를 별도의 클래스에 넣은 후 그들의 객체들을 상호교환할 수 있도록 하는 행동 디자인 패턴.. 이라고 정의하고 있습니다.

매우 어려운 표현인데요. 제가 쉽게 이해한 바는 행위를 인터페이스로 구현한 후, 이를 상속 받은 하위 구현체들을 생성합니다. 그리고 이 모듈에 타 모듈에 주입하여 사용하는 패턴입니다. 전략 패턴의 가장 큰 장점은 수많은 if/else 코드 블럭을 제거하면서 행위 자체에 집중할 수 있다는 점입니다. 또한 신규 기능이 추가되더라도 기존 기능을 손대지 않고도 기능을 추가할 수 있습니다.

전략 패턴의 예시는 이미 위에 모두 표현이 되었는데요. 전략 패턴의 관점에서 다시 한 번 정리하면, 아래와 같습니다. 기기 제어 모듈의 인터페이스는 아래와 같습니다.

class AbstractFleetController(ABC):
    def __init__(self, bike: Bike, logger: ControlLogger, mediator: Mediator):
        self.bike = bike
        self.logger = logger
        self.mediator = mediator

    @abstractmethod
    def lock(self) -> tuple[bool, str]:
        """기기를 잠금한다."""
        pass
    
    @abstractmethod
    def unlock(self) -> tuple[bool, str]:
        """기기를 잠금 해제한다."""
        pass

이를 상속받은 클래스는 FleetController, LegacyFleetController 가 있습니다.

class FleetController(AbstractFleetController):
   def lock(self) -> tuple[bool, str]:
	    if self.bike.status in (self.bike.NEED_REPAIRING, self.bike.IN_REPAIRING):
	        command = self.get_fix_command()
	    else:
	        command = self.get_lock_command()

    return self.execute_command(command, ControlStatus.REQUESTED)

  def unlock(self) -> tuple[bool, str]:
      command = self.get_unlock_command()
      return self.execute_command(command, ControlStatus.REQUESTED)
      

class LegacyFleetController(AbstractFleetController):
    def lock(self) -> tuple[bool, str]:
        if self.bike.status in (Bike.NEED_REPAIRING, Bike.IN_REPAIRING):
            return self.in_repairing()
        return self.execute_command(action='lock_control', value='lock')

    def unlock(self) -> tuple[bool, str]:
        return self.execute_command(action='lock_control', value='unlock')

기기 제어 모듈은 팩토리 패턴으로 생성됩니다.

class FleetControllerFactory:
    @staticmethod
    def create(bike: Bike, logger: ControlLogger, mediator: Mediator, **kwargs) -> AbstractFleetController:
        if bike.interactor.legacy_protocol:
            return LegacyFleetController(bike, logger, mediator, **kwargs)
        else:
            return FleetController(bike, logger, mediator, **kwargs)
            
# 기기 제어 모듈 생성
fleet_controller = FleetControllerFactory.create(bike, Logger(), Mediator())

이렇게 생성된 기기 제어 모듈을 라이딩 모듈에 주입하고, 이후 비즈니스 로직이 수행됩니다.

**# 라이딩 시작 모듈에 기기 제어 모듈 주입**
riding_handler = RidingStartFactory.create(data.partner_slug, user, **fleet_controller**)

# 라이딩 시작
riding = riding_handler.start()

이럴 경우, 라이딩 모듈은 기기 제어 모듈이 어떤 프로토콜로 정의된 모듈인지는 중요하지 않고, unlock, lock 과 같은 행위만 보고 실행하게 됩니다. 또한 새로운 프로토콜이 추가된 기기 제어 모듈이 추가되더라도 라이딩 모듈을 수정할 필요 없이, 기기 제어 모듈을 추가 구현한 후 팩토리 클래스에 이를 추가하기만 한다면 기존 로직을 유지한 채 새로운 로직을 추가할 수 있는 이점이 있습니다.

마무리

이처럼 우연치 않게 좋은 기회를 맞아 서비스 전반의 로직을 리팩토링 해보았는데요. 이번 리팩토링을 진행하며서 사업/기획 팀에게 가장 많이 들은 질문은 “왜 리팩토링을 하시는거에요 ?”, “이번에 꼭 리팩토링이 필요했나요?” 였습니다.

당연히 리팩토링이 모든 태스크의 must-have 는 아닐뿐더러 기존 로직을 크게 변경하는만큼 프로덕트의 리스크를 감수해야할 뿐만 아니라 리팩토링만을 위한 추가 리소스가 필요하기 때문에 비즈니스 관점에서는 크게 환영 받지 못하는 개발 태스크라는 생각이 많이 들었던 것 같습니다.

이번 작업을 하면서 들었던 생각은 앞으로 리팩토링을 오로지 개발팀의 욕구를 충족하는 태스크가 아니라 프로덕트, 비즈니스 관점에서도 충분히 생각할 가치가 있는 태스크라고 생각이 들었습니다. 리팩토링을 통해 사이드 이펙트 없이 신규 기능 변경 및 추가, 빠르게 신규 기능을 추가할 수 있는 부분 등 장기적으로 보았을 때 분명 비즈니스의 전체적인 생산성 증가가 나타날 것이라고 생각이 들었습니다. 이후 다시 리팩토링을 하게 된다면 .. 요런 관점에서 어필을 통해 좀 더 개발 리소스 확보 및 리팩토링에 대한 설득을 할 수 있지 않을까 생각이 들었습니다.

그리고 … 리팩토링을 준비하는 분들이 있다면 반드시 먼저 읽어보시면 좋을 것 같습니다 좋은 리팩토링 vs 나쁜 리팩토링

0개의 댓글