8 min read

파이썬과 DDD로 라이프 캘린더 만들기 #5 기능 완성

레파지토리까지 준비 되었으니 이제 정말로 '캘린더 생성 기능'을 완성해봅시다. '캘린더 생성 기능'은 유저 입장에서 하나의 완결된 시나리오입니다. 이와 같은 '하나의 완전한 기능'을 두고 계층형 아키텍쳐에서는 흔히 '응용 계층' 혹은 '유즈케이스' 같은 말을 쓰기도 합니다. 또는 아예 'API'라고 부르기도 합니다.

흔히 HTTP 인터페이스를 API라고 부르는 것을 생각한다면 의아하게 생각할 수도 있을 것 같습니다. 그러나 저는 응용 계층을 API라고 부르는 것을 아주 자연스럽다고 생각하는데요, 그 이유는 다음에 이야기해 보겠습니다!

일단 저는 주로 '기능'이라는 말을 쓰겠습니다. 대신 application이라는 말이 너무 길어서 appl이라고 줄여쓰겠습니다. 응용 계층에 대한 자세한 설명은 이 문서의 해당 섹션를 참고하시면 좋을 것 같습니다. 아주 전형적인 기능의 모습이라면 대개는 이런 순서로 작업을 진행하게 됩니다.

  1. 트랜잭션 시작
  2. 도메인 모델(주로 집계Aggregate)을 레파지토리로부터 조회
  3. 도메인 모델의 메소드를 통해 비즈니스 코드 실행
  4. 트랜잭션 끝 (변경된 정보를 데이터베이스에 반영)
  5. 응답 반환 (응답이 필요한 경우)

캘린더 생성 기능 작성 시작

'캘린더 생성 기능' 코드를 작성하기 위한 파일을 src/appl/create_calendar.py 경로에 생성하고 다음과 같이 클래스의 껍데기를 만들어 줍니다.

class CreateCalendar:
    def run(self) -> None:
        pass

캘린더 생성의 핵심은 무엇일까요? 물론 도메인 메소드인 Calendar.create()를 호출하는 것입니다. 작성해 봅시다.

import datetime

from src.domain.entity.calendar import Calendar


class CreateCalendar:
    def run(self, name: str, birthday: datetime.date, lifespan: int) -> None:
        cal = Calendar.create(name, birthday, lifespan)

이런 모습이 되었습니다.

데이터베이스 컨텍스트 연결

이제 생성한 cal 객체를 영속화 시키는 작업이 필요합니다. 아까 만들었던 데이터베이스 컨텍스트 객체를 사용해 봅시다.

import datetime

from src.domain.entity.calendar import Calendar
from src.domain.repo.i_db_context import IDBContext


class CreateCalendar:
    def __init__(self, db_context: IDBContext) -> None:
        self.db_context = db_context

    def run(self, name: str, birthday: datetime.date, lifespan: int) -> None:
        with self.db_context.begin_tx():
            cal = Calendar.create(name, birthday, lifespan)

생성자를 통해서 IDBContext 타입의 객체가 필요하다는 것(의존성)을 명시해 줍니다. 왜 SAContext 같은 구현체가 아니라 인터페이스일까요? (이미 이전 글에서 언급한 내용입니다만)그것은 CreateCalendar라는 기능이 특정한 데이터베이스 컨텍스트 기술에 의존하지 않게 만들기 위해서입니다.

DIP

객체지향 프로그래밍에서는 이를 두고 흔히 'DIP 원칙'이라고 말하는데요, 요약하자면 '객체가 다른 객체에 직접 의존하는 것이 아니라, 다른 객체의 추상화에 의존하게 만드는 것'이라고 할 수 있습니다. 이렇게 추상화(인터페이스)에 의존하게 만들면, CreateCalendar의 코드를 전혀 변경하지 않고도, 같은 인터페이스를 구현한 다른 구현체를 넣어서 동작을 쉽게 변경할 수 있습니다.

테스트 코드를 작성하는 입장에서도, IDBContext를 만족하는 mock 객체를 쉽게 만들고 주입해줄 수 있습니다. 이렇게 하면 테스트를 실행하기 위해서 데이터베이스 인프라를 설정할 필요가 없어 간편하고 실행 속도도 빠릅니다. 또 응용 계층의 코드의 테스트에만 집중할 수 있는 장점이 있습니다.

그런데 컨텍스트와 레파지토리 같은 구체적 데이터베이스 코드들을 mock으로 대체하는 것이 항상 좋은 방법일까요? 마틴 파울러의 UnitTest를 읽어보시면 다양한 의견이 있을 수 있다는 것을 알 수 있습니다.

mock을 이용한 단위 테스트는 통합에 대해 이상적인 상황을 가정합니다. 이런 면에서, 단위 테스트 보다는 통합 테스트가 항상 더 안전할 수 밖에 없습니다. 그리고 우리에게 익숙한 PostgreSQL이나 Redis 같은 인프라들은 충분히 신뢰할 수 있고 속도도 빠른 편입니다.

그래서 상황에 따라 혹은 테스트 케이스에 따라, 데이터베이스 코드를 mock으로 대체하지 않고, 통합 테스트(실제로 데이터베이스 인스턴스를 실행하고, row를 INSERT 하는 등 쿼리를 실제로 실행함)하는 것이 좋은 선택일 수 있습니다. 이 글은 레파지토리 패턴과 응용 계층 사이의 DIP 적용 방법을 소개하는 목적이니 처음 기획했던 대로 계속 작성해 나가겠습니다.

캘린더 레파지토리 연결

이제 우리가 만들어준 캘린더 레파지토리 역시 같은 방법으로 사용해줍니다.

import datetime

from src.domain.entity.calendar import Calendar
from src.domain.repo.i_calendar_repo import ICalendarRepo
from src.domain.repo.i_db_context import IDBContext


class CreateCalendar:
    def __init__(self, db_context: IDBContext, calendar_repo: ICalendarRepo) -> None:
        self.db_context = db_context
        self.calendar_repo = calendar_repo  # 추가

    def run(self, name: str, birthday: datetime.date, lifespan: int) -> None:
        with self.db_context.begin_tx():
            cal = Calendar.create(name, birthday, lifespan)
            self.calendar_repo.save(cal)  # 추가

우리의 기능이 이제 정말로 완성 되었습니다! 잘 동작하는지 실행해 볼까요? test/appl/test_create_calendar.py 경로에 파일을 하나 만들고 테스트 코드를 작성해 봅시다. 실행에 문제가 없는지만을 판단하기 위해서, 일단은 mock 객체를 만들지 않고 처음에 만들어둔 데이터베이스를 그대로 이용해 보겠습니다.

import datetime

from src.appl.create_calendar import CreateCalendar
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 TestCreateCalendar:
    def test_run(self):
        sa = SA("postgresql://username@localhost/lifecalendar", {})
        db_context = SAContext(sa)
        calendar_repo = SACalendarRepo(sa)
        command = CreateCalendar(db_context, calendar_repo)

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

테스트를 실행해보면 아마 sqlalchemy.orm.exc.UnmappedInstanceError: Class 'src.domain.entity.calendar.Calendar' is not mapped라는 메세지와 함께 실패할거에요! 이는 Calendar 모델과 calendar 테이블이 각각 존재하지만, 서로 연결해주지 않았기 때문에 발생하는 문제입니다.

모델-스키마 매핑

src/infra/db/mapper.py 경로에 파일을 만들어 연결 작업을 진행해 주겠습니다.

from sqlalchemy.orm import registry

from src.domain.entity.calendar import Calendar
from src.infra.db.schema import calendar


def map_between_model_and_schema():
    mapper_registry = registry()
    mapper_registry.map_imperatively(Calendar, calendar)

이와 매핑 방식을 SQLAlchemy에서는 Imperative Mapping이라고 부릅니다. (Base 클래스를 사용하는 방식은 Declarative Mapping이라고 부릅니다) 여기서 커밋하고, 이제 매핑 코드를 놓고 다시 테스트를 실행해 봅시다.

import datetime

from src.appl.create_calendar import CreateCalendar
from src.infra.db.mapper import map_between_model_and_schema
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 TestCreateCalendar:
    def test_run(self):
        map_between_model_and_schema()  # 추가!

        sa = SA("postgresql://qodot@localhost/lifecalendar", {})
        db_context = SAContext(sa)
        calendar_repo = SACalendarRepo(sa)
        command = CreateCalendar(db_context, calendar_repo)

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

이제 테스트가 성공하는 것을 확인할 수 있습니다. 실제 데이터베이스를 사용했기 때문에 calendar 테이블에 실제로 row가 생성된 것 까지 확인할 수 있습니다. 이 테스트 케이스가 맘에 안 드실수도 있을 것 같습니다. 예를 들면 map_between_model_and_schema나 , sa = SA("postgresql://qodot@localhost/lifecalendar", {}) 같은 코드를 테스트 Suite 이전에 실행될 수 있도록 코드를 분리할 수 있을 것 같습니다. 아니면 아예 mock 객체를 사용하게 만들 수도 있을 것 같습니다. 다만 당장은 필요하지 않으니 그냥 두겠습니다. 여기서 커밋합시다!

드디어 기능을 완성했습니다! 그런데 이 기능에서만 4개의 객체를 생성했습니다. 혹시 앞으로도 이렇게 기능을 사용할 때마다 수많은 객체를 반복적으로 생성해야 하는 걸까요? 다음 글에서 이런 문제를 해결해 보겠습니다.