목차

카테고리 없음

전략 패턴을 도입해보자

천만일 2025. 3. 2. 21:40

개발을 하다 보면 디자인 패턴을 분명 들어보셨을 것 같습니다.


전에 어떤 분께서 말씀해 주시길, 디자인 패턴은 선배 개발자들의 삽질이 이뤄낸 결과물이라고 해주셨던 것이 기억에 남습니다.

선배들이 삽질을 통해 좋은 패턴들을 만들어냈으니,

우리는 그것을 적극적으로 활용하여 효율을 높여야 한다는 것이죠.


개인적으로, 일을 하다 보면 디자인 패턴을 적용할 기회가 많지 않다고 느꼈습니다.

반대로, 프레임워크나 라이브러리를 뜯어보면 패턴이 눈에 들어왔던 기억이 납니다.


최근 동료 개발자분께서 전략 패턴을 활용하여 새로운 시스템을 설계하고 계시다는 이야기를 해주셨습니다.

그런 이야기를 듣다 보니 저도 적용해볼 수 있는 영역이 보여서 적용해보며 느낀 점에 대해 남겨보려고 합니다.



전략 패턴

전략 패턴은 동일한 상황에서 적용할 수 있는 다양한 알고리즘을 별도의 클래스로 분리하는 것입니다.


예를 들어, 국내 은행 사이트에서 금융 상품 목록을 스크랩핑 하는 수집기가 있다고 가정해보겠습니다.
각 은행마다 DOM 구조가 다르기 때문에 각자 다른 방식으로 상품의 이름을 파싱해야합니다.
이러한 파싱 로직을 하나의 전략이라고 볼 수 있겠습니다.


만약 파싱 로직을 전략이라는 별도 클래스로 관리하지 않는다면 새로운 은행 상품을 추가할 때마다 코드가 복잡해지고 관리하기 어려워질 것입니다.

이를 위해 도입되는 것이 전략 패턴입니다.


전략 패턴은 다음과 같은 구조를 가집니다.

구성요소들을 하나씩 살펴보겠습니다.

  1. context

    context는 전략을 수행하는 주체입니다.

    위 예시에서는 금융 상품 수집기가 context의 역할을 한다고 볼 수 있습니다.

    단, 전략의 구현에 의존하지 않고 strategy 인터페이스를 통해서만 통신합니다.

  2. strategy 인터페이스

    모든 전략은 공통적으로 실행할 수 있는 공동의 메서드를 가집니다.

    이를 통해 context가 전략을 실행할 수 있습니다.

  3. strategy 구현체

    여러 전략들을 구현합니다.

  4. client는 context를 통해 전략을 실행하지만, 어떤 전략과 그 알고리즘에 대해서는 알 지 못합니다.

  5. client는 런타임에서 strategy를 생성하고 대체할 수 있습니다.

    이를 위해 context는 전략 setter를 노출해야 합니다.


적용 사례 1) 금융 상품 수집기

제가 첫 번째로 적용해 본 사례는 위에서 간단히 설명한 금융 상품 수집기입니다.


위 설명에 따라 대입해보겠습니다.


금융 상품 수집기 → context

금융 상품 수집 전략 → strategy interface

은행별 금융 상품 수집 → strategy 구현체


위 방식을 통해 점진적으로 은행을 추가할 수 있는 구조를 기대했습니다.

또한 각 은행별로 html 구조가 변경되어도, 해당 은행의 수집 전략만 수정하면 되기 때문에 이후 운영 과정에서도 쉽게 대응할 수 있을 것으로 생각됩니다.


위 작업은 비동기 작업 큐에 등록해서 사용하기 때문에 런타임에서 전략이 변경될 가능성은 없었습니다.

따라서 context가 전략 세터를 가지지는 않도록 했습니다.



class FinancialProductScrapeStrategy(abc.ABC):
    @abc.abstractmethod
    def scrape(
        self,
        html_element,
    ) -> list[dict[str, Any]]:


class ABankFinancialProductScrapeStrategy(FinancialProductScrapeStrategy):
    def scrape(
        self,
        html_element,
    ) -> list[dict[str, Any]]:
            # html_element 파싱

class FinancialProductScraper:
    def __init__(
        self,
        scrape_strategy: "FinancialProductScrapeStrategy",
    ):
        self._scrape_strategy = scrape_strategy

    def run(
        self,
        bank: Bank,
    ) -> List[Dict[str, Any]]:
            full_dom_element = get_html(url = bank.product_list_url)
        return self._scrape_strategy.scrape(
            html_element=html_element,
        )


적용 사례 2 - Redis 명령어 대응

두 번째는 redis를 직접 만들어보는 builid_my_redis 프로젝트에서 적용해본 사례입니다.

redis-server가 수신한 명령어에 따라 처리 로직을 전략으로 관리하는 구조입니다.


명령어를 파싱한 결과에 따라 작업을 수행하는 RedisProcessor가 context로 역할을 합니다.

RedisCommandStrategy라는 인터페이스를 통해 각 명령어에 대한 처리 로직을 구현합니다.


명령 메시지를 파싱하는 로직과는 분리했기 때문에, 데이터 저장소와 인자에 대한 정보는 따로 넘겨받도록 설계했습니다.


build_my_redis 프로젝트는 아직 초반부를 진행중입니다.

이 프로젝트에 적용하게 된 이유는 이후 기능의 복잡도가 커졌을 때,

제가 전략 패턴의 한계를 직접 체감할 수 있을 것이라는 기대감 때문입니다.


예를 들어, 이미 각 전략들은 공통된 인터페이스를 위해 data_store라는 key-value 자료구조를 매개변수로 받고 있습니다.

점차 전략의 인터페이스 혹은 매개변수를 통일하기 어려워 질 수 있다고 생각합니다.


class RedisCommandStrategy(abc.ABC):
    @abstractmethod
    def execute(self, data_store, args):
        ...

class PingCommandStrategy(RedisCommandStrategy):
    def execute(self, data_store, args):
        return "+PONG\r\n"

class EchoCommandStrategy(RedisCommandStrategy):
    def execute(self, data_store, args):
        return f"+{args[0]}\r\n"


class RedisProcessor:
    _strategy: Optional[RedisCommandStrategy]

    def __init__(self):
        self._strategy = None

    def set_strategy(self, strategy: RedisCommandStrategy):
        self._strategy = strategy

    def process(self, data_store, *args) -> Optional[str or int]:
        return self._strategy.execute(
            data_store=data_store,
            args=args,
        )


결론

전략 패턴은 비슷한 패턴의 결과물을 기대하지면 결과물을 도출하는 과정이 다양할 때 적용하기 적절한 패턴입니다.


strategy 인터페이스로 모든 전략들을 구현해야 하기 때문에, 인터페이스의 통일을 이뤄내는 과정이 적용 난이도를 결정한다고 느꼈습니다.


토이 프로젝트를 통해 다양한 패턴을 적용해보는 과정을 통해 한계를 직접 체감해보는 것이 좋은 학습 전략이라는 것을 배울 수 있었습니다.



참고

https://refactoring.guru/ko/design-patterns/strategy