5 min read

파이썬과 DDD로 라이프 캘린더 만들기 #6 기능 객체 생성 위임

지난 글에서 CreateCalendar 객체를 작성해서 기능을 완성했습니다. 그러나 이 기능을 사용하려면, 의존된 모든 객체를 실제로 생성해서 알맞게 주입해 주어야 합니다. 매번 객체를 생성할 때마다 어떤 객체에 의존하고 있는지 전부 살펴보는 것은 아주 번거로운 일입니다.

해결책은 단순하게 말해서, 모든 객체를 미리 생성해두면 됩니다. 이런 일을 담당하는 것을 'IoC 컨테이너', 혹은 'DI 컨테이너'라고 합니다. 저는 'DI 컨테이너'라는 이름이 좀 더 직관적인 것 같아서 선호합니다. 마틴 파울러의 유명한 이 글에서 IoC(제어 역전) 혹은 DI(의존성 주입)에 대한 아주 자세한 내용을 확인할 수 있습니다.

DI 컨테이너

DI 컨테이너는 여러 기능을 가질 수 있겠지만, 여기서는 아주 기본적인 기능만을 가지는 단순한 컨테이너를 작성해 보겠습니다. src/appl/i_container.py 경로에 다음과 같이 컨테이너 코드를 작성해 줍시다.

from __future__ import annotations

import abc
import inspect
from typing import Any, Type, TypeVar

T = TypeVar("T")


class IContainer(abc.ABC):
    def __init__(self) -> None:
        self.obj_map = {}

    def register(self, obj: Any) -> None:
        self.obj_map[type(obj)] = obj

    def resolve(self, type_: Type[T]) -> T:
        impl_type = type_
        if inspect.isabstract(type_):
            impl_types = type_.__subclasses__()
            if len(impl_types) == 0:
                raise NotRegisteredTypeError(f"type: {type_}")

            impl_type = impl_types[0]

        try:
            obj = self.obj_map[impl_type]
        except KeyError:
            raise NotRegisteredTypeError(f"type: {type_}")

        return obj

    @abc.abstractmethod
    def compose(self) -> None:
        pass


class NotRegisteredTypeError(Exception):
    pass

register로 생성한 객체를 등록하고 get으로 등록한 객체를 가져오는 단순한 코드입니다. 인터페이스 타입(abc.ABC 타입)으로 get을 호출할 경우 구현체 중에 그냥 첫번째 것을 가져오게 했습니다(이 부분에서 개선의 여지가 있겠네요). 여기서 커밋합니다.

이제 이 IContainer 타입을 상속한 컨테이너를 만들고 실제로 객체를 생성하고 등록해 봅시다. src/appl/container.py 경로에 실제 컨테이너 객체를 선언해 봅시다.

from src.appl.create_calendar import CreateCalendar
from src.appl.i_container import IContainer
from src.infra.repo.sa import SA
from src.infra.repo.sa_calendar_repo import SACalendarRepo
from src.infra.repo.sa_context import SAContext


class Container(IContainer):
    def compose(self) -> None:
        # repository
        self.register(SA("postgresql://qodot@localhost/lifecalendar", {}))
        self.register(SAContext(self.resolve(SA)))
        self.register(SACalendarRepo(self.resolve(SA)))

        # application
        self.register(
            CreateCalendar(self.resolve(SAContext), self.resolve(SACalendarRepo))
        )

먼저 필요한 객체부터 나중에 만들어야 하는 객체까지 compose 함수 안에 순서대로 등록해 줍니다. 이제 아까 작성했던 CreateCalendar의 테스트 코드에서도 이 컨테이너를 사용할 수 있게 되었습니다.

import datetime

from src.appl.container import Container
from src.appl.create_calendar import CreateCalendar
from src.infra.db.mapper import map_between_model_and_schema


class TestCreateCalendar:
    def test_run(self):
        map_between_model_and_schema()
        container = Container()
        container.compose()
        command = container.resolve(CreateCalendar)

        name = "고도"
        birthday = datetime.date(1988, 6, 21)
        lifespan = 80
        command.run(name, birthday, lifespan)

CreateCalendar를 직접 생성할 필요가 없어졌기 때문에, CreateCalendar가 어떤 의존성을 가지고 있는지 일일히 파악할 필요도 없어졌습니다. 그저 가져와서 사용하면 됩니다. 여기서 커밋합시다.

서비스 로케이터

이런식으로 기능을 작성하다 보면, 모든 기능이 방금 작성한 컨테이너에 등록되게 됩니다. 즉, 컨테이너만 참조하고, 컨테이너가 제공하는 객체만 사용해도 우리 앱의 기능 전체를 사용할 수 있다는 뜻입니다.

이렇게 되면 (위에서 수정했던 테스트 코드처럼)우리 앱의 기능을 사용하는 곳에서는 컨테이너를 '직접' 참조하고 resolve하는 순서로 우리 기능을 사용하게 됩니다. CreateCalendar를 만들 때 처럼 생성자를 통한 의존성 주입을 사용하지 않고 말입니다. 이런 방식은 우리가 곧 만들게 될 HTTP 핸들러에서도 동일하게 적용됩니다. 이런 모습은 코드의 형태상 서비스 로케이터 패턴을 사용하고 있다고 볼 수 있습니다.

이런식의 서비스 로케이터 패턴은 안티패턴이라는 글이 있으니 참고하시면 좋을 것 같습니다. 하지만 저는 앱의 사용처인 pytest와 HTTP 프레임워크의 핸들러에 DI를 사용할 수 있는 방법을 복잡하게 연구하기 보다는 그냥 서비스 로케이터 패턴을 사용하기로 결정하겠습니다. 테스트 코드에 DI를 적용해서 의존성을 명시하는 것은 실익이 부족하다는 판단 때문이고, HTTP 핸들러는 우리가 만든 기능의 HTTP wrapper 역할만 최소한으로 수행하게 만들 것이기 때문입니다.

다음 글에서는 이제 드디어 우리의 '캘린더 생성 기능'을 HTTP로 공개하도록 하겠습니다. 최근 가장 인기가 많고 저도 좋아하는 FastAPI를 사용할 생각입니다!