파이썬과 DDD로 라이프 캘린더 만들기 #2 도메인 모델

지난 글에서 우리의 문제 의식을 확인하고 그 해결책으로 '라이프 캘린더'라는 제품을 기획했습니다. 이번 글 부터는 파이썬 프로젝트를 생성하고 본격적으로 개발을 시작해 보겠습니다. 개발한 프로젝트는 아래 공개 레파지토리에 올려두었습니다. 중간중간 커밋마다 링크를 달아두었으니 참고해 주세요.

GitHub - qodot/lifecalendar
Contribute to qodot/lifecalendar development by creating an account on GitHub.

프로젝트 생성

파이썬 3.11과 Poetry를 이용해서 프로젝트를 생성합니다. 파이썬 설치 과정은 pyenv를 사용했습니다만 여기에 따로 적지 않겠습니다.

이 프로젝트는 폴더나 파일의 생성을 어떤 프레임워크를 통해서(e.g. django-admin startproject mysite) 시작하지 않습니다. 단지 폴더와 파이썬 파일을 하나씩 만들어갈 예정입니다. 우리 서버 앱의 핵심 동작은 특정 프레임워크의 위에서만 동작해서는 안 됩니다. 프레임워크는 우리 앱의 일부를 구성하는 것을 도와줄 뿐입니다. 다음의 명령어를 실행합니다.

mkdir lifecalendar && poetry init

어떤 의존성도 설치하지 않은 빈 프로젝트가 생성되었습니다. 여기서 커밋합니다.

캘린더 엔티티 작성

이전 글에서 정의한 요구사항 중 처음 두개를 다시 살펴보겠습니다.

  • 위 페이지의 주 매트릭스(matrix)를 포함하는 개념을 캘린더(calendar)라고 한다.
  • 캘린더는 아이디(id), 이름(name), 생년월일(birthday), 예상 수명(lifespan)을 포함해야 한다.

이를 구현하기 위한 코드를 작성해 보겠습니다. 가장 중요한 비즈니스 코드가 위치하는 장소를 일컬어 흔히 '도메인 계층' 혹은 '도메인 모델' 같은 말을 사용합니다. 이에 대한 자세한 설명은 이 문서의 해당 섹션을 참고하시면 좋을 것 같습니다. 또, 우리의 '캘린더' 같이, 아이디로 객체를 구별하는 경우 이 '도메인 계층' 중 '엔티티' 개념에 대응하게 됩니다. 엔티티에 대한 설명은 이 문서의 해당 섹션에 훌륭하게 설명되어 있습니다. 먼저 다음과 같이 '도메인 계층'과 '엔티티'를 표현하는 폴더 트리를 구성해주고, 캘린더 모델을 작성할 파일도 생성합니다.

작성한 코드는 다음과 같은 모습이 되겠습니다. 그리고 커밋합니다.

import datetime
import uuid


class Calendar:
    id: uuid.UUID
    name: str
    birthday: datetime.date
    lifespan: int

이 엔티티에 SQLAlchemyDjango ORM 같은 개념이 전혀 포함되지 않았다는 점에 주목해 주세요. 지금은 비즈니스 로직을 구현하는 단계이므로, 데이터베이스에 대한 생각은 하지 않고 단순한 파이썬 클래스를 사용하겠습니다. 이렇게 지속성 무시 혹은 인프라 무시라고 불리는 원칙에 의거한 코드를 작성하면 비즈니스 코드와 데이터베이스 코드가 분리됩니다. 그러면 별도의 지식 없이 순수한 파이썬으로만 비즈니스 코드를 작성하여, 읽기 쉽고 이해하기 쉽고 테스트하기 쉬운 코드를 작성할 확률을 높일 수 있습니다.

다음은 연도와 주 개념을 포함시킬 차례입니다. 다음 요구사항을 기억합시다.

  • 캘린더 안에는 연도(year) 개념이 있다.
  • 연도는 캘린더마다 (예상 수명 + 1)개 존재한다.

우리의 모델을 다음과 같이 확장합니다.

from __future__ import annotations

import datetime
import uuid


class Calendar:
    id: uuid.UUID
    name: str
    birthday: datetime.date
    lifespan: int
    years: list[Year]


class Year:
    yearnum: int
    weeks: list[Week]


class Week:
    yearnum: int
    weeknum: int

연도와 주 개념에는 ID가 없으므로 값 객체처럼 다룹니다. 값 객체에 대한 자세한 설명은 이 문서를 참고해주세요. 물론 상황에 따라 ID를 부여해서 엔티티처럼 다룰수도 있겠으나 현재로서는 그럴 필요가 없어 보입니다. 여기서 커밋합니다.

캘린더 객체 생성 구현

이제 캘린더를 생성하는 동작을 정의할 생각입니다. 다음 요구사항을 기억합시다.

  • 유저는 캘린더를 생성할 수 있다.
  • 캘린더를 생성하기 위해서는 캘린더의 이름, 생년월일, 예상 수명이 필요하다.
  • 연도는 캘린더마다 (예상 수명 + 1)개 존재한다.
  • 주는 연도마다 52개 존재한다.

이 동작을 정의하기 위한 테스트 코드를 작성합시다. 저는 가장 유명한 테스트 도구인 pytest를 사용할 생각입니다. 다음 명령어로 의존성을 설치하고 커밋합니다.

poetry add pytest --group dev

다음과 같이 캘린더 생성을 위한 테스트를 작성합니다. 정의된 요구사항을 테스트 케이스와 assert문으로 그대로 옮길 수 있다면, 나중에는 테스트 코드만 읽고도 스펙을 파악하기 쉬워집니다.

import datetime

from src.domain.entity.calendar import Calendar


class TestCalendar:
    def test_create(self):
        name = '고도'
        birthday = datetime.date(1988, 6, 21)
        lifespan = 80
        calendar = Calendar.create(name, birthday, lifespan)

        assert calendar.name == name
        assert calendar.birthday == birthday
        assert calendar.lifespan == lifespan
        assert len(calendar.years) == lifespan + 1
        assert len(calendar.years[0].weeks) == 52

테스트를 실행해서 실패를 확인한 후(Calendar.create 메소드가 존재하지 않으므로), 실제 코드를 구현해 봅시다.

from __future__ import annotations

import datetime
import uuid


class Calendar:
    id: uuid.UUID
    name: str
    birthday: datetime.date
    lifespan: int
    years: list[Year]

    def __init__(
        self,
        *,
        id: uuid.UUID,
        name: str,
        birthday: datetime.date,
        lifespan: int,
        years: list[Year],
    ) -> None:
        self.id = id
        self.name = name
        self.birthday = birthday
        self.lifespan = lifespan
        self.years = years

    @classmethod
    def create(cls, name: str, birthday: datetime.date, lifespan: int) -> Calendar:
        return cls(
            id=uuid.uuid4(),
            name=name,
            birthday=birthday,
            lifespan=lifespan,
            years=[
                Year(yearnum=yearnum)
                for yearnum in range(birthday.year, birthday.year + lifespan + 1)
            ],
        )


class Year:
    yearnum: int
    weeks: list[Week]

    def __init__(self, *, yearnum: int) -> None:
        self.yearnum = yearnum
        self.weeks = [
            Week(yearnum=yearnum, weeknum=weeknum) for weeknum in range(1, 53)
        ]


class Week:
    yearnum: int
    weeknum: int

    def __init__(self, *, yearnum: int, weeknum: int) -> None:
        self.yearnum = yearnum
        self.weeknum = weeknum

각 객체에 생성자를 구현하고 그를 통해 Calendar.create 메소드를 구현했습니다. 테스트가 통과하는 것을 확인하고 커밋합시다.

우리는 중요한 코드를 작성했습니다. 그렇다면 이제 '유저가 캘린더를 생성할 수 있다'이라는 기능을 완전히 구현한 걸까요? 그렇지는 않습니다. 우리는 중요한 코드를 도메인 계층에 표현했을 뿐입니다. 도메인 코드는 우리 앱의 핵심으로 매우 중요하지만, 완전한 기능으로 동작하기 위해서는 생성한 캘린더를 어딘가 영구 보관해야 합니다. 즉, 도메인 모델 가진 데이터(메모리)를 어딘가 영구적으로 저장(아마도 디스크)할 필요가 있습니다. 다음 글에서는 이 작업을 진행해 보겠습니다.