PyCon korea 2023 신동현
해당 스피치 영상 시청 후 추가로 자료 조사해서 내용 추가
일반적으로 많이 사용하는 데이터 중심의 접근법을 탈피해서 순수한 도메인의 모델과 로직에 집중하는 것으로 ,소프트웨어가 해결하는 문제(domain)에 집중하는 개발 방법론(ex. 온라인 쇼핑 시스템 에서는 주문, 결제 등)이다.
모듈간의 의존성은 최소화하고 응집선을 최대화 하려는 아키텍처.
지금까지 내가 개발해온 방식이 데이터 중심 개발이기 때문에 비교
DB 설계에 초점을 맞추는 개발 방식으로, DB 모델링이 어플리케이션 구조와 기능을 결정 짓는 핵심요소
핵심적인 비즈니스 도메인을 중심으로 설계, 비즈니스 로직과 비즈니스가 직면한 문제를 해결하는 것을 최우선으로 여기고, 이를 기반으로 개발
시스템의 중심이 되는 도메인 비즈니스 로직을 다루는 계층이고, 기술적인 제약을 받지 않아야 하기에 순수 Python으로 구현하는 것을 추천
기술적 인프라 제공 및 에플리케이션을 도와주기 위한 계층으로, 도메인과 의존성이 생겨서는 안됨.
도메인 비즈니스 로직과 인프라 계층을 활용해서 Use Case를 구현하는 계층
유저 인터페이스 제공(Rest API, GraphQL 등) 및 input/output validation/serialization 등을 담당하는 계층
특정한 도메인 모델이 적용될 수 있는 영역.
위 예시 이미지에서 보이듯이, Display context
와 Reception Context
에서 같은 Room 모델이지만 Bounded Context가 달라지면 서로 다른 의미를 가지게 된다.
from dataclasses import dataclass, field
class Entity:
# entity의 고유 식별자
# init=False 옵션을 설정해 객체가 생성될 때 id 값이 자동할당 되지 않고
# 클래스의 인스턴스가 다른 방식(예: 데이터베이스에서 엔티티를 로드하는 과정 중)으로 초기화되거나 명시적으로 값을 설정한 후에 값을 가지게 된다.
id: int = field(init=False)
# 두 객체가 동일한 객체인지 판단할 때 id 필드를 사용하도록 정의
def __eq__(self, other: Any) -> bool:
if isinstance(other, type(self)):
return self.id == other.id
return False
# __eq__ 매직 메서드를 구현한 경우, 일간된 해시 값을 제공해야 하기 때문에 반드시 구현해야함.
def __hash__(self):
return hash(self.id)
class AggregateRoot(Entity):
pass
# eq 메서드를 집적 오버라이드하여 사용할 것이기 때문에 eq=False
# 불필요한 메모리 낭비 줄이기 위해 slots=True
@dataclass(eq=False, slots=True)
class Reservation(AggregateRoot):
room: Room
reservation_number: ReservationNumber
reservation_status: ReservationStatus
date_in: datetime
date_out: datetime
guest: Guest
# Display Context에 정의된 Room
@dataclass(eq=False, slots=True)
class Room(AggregateRoot):
number : str
room_status : RoomStatus
image_url : str
description : str | None = None
# Reception Context에 정의된 Room
@dataclass(eq=False, slots=True)
class Room(Entity):
number : str
room_status : RoomStatus
def reserve(self):
if not self.room_status.is_avaliable:
raise RoomStatusException
self.room_status = RoomStatus.RESERVED
Display Context의 Room 모델은 테이블의 모든 데이터가 매핑되어 있지만, Reception Context의 Room 모델은 예약에 필요한 데이터만 매핑되어 있다.
@dataclass(eq=False, slots=True)
class Reservation(AggregateRoot):
# ...
@classmethod
def make(
cls, room: Room, date_in: datetime, date_out: datetime, guest: Guest
) -> Reservation
# reserve()가 성공하면, Reservation 객체 생성
room.reserve()
return cls(
room=room,
date_in=date_in,
date_out=date_out,
guest=guest,
reservation_number=ReservationNumber.generate(),
reservation_status=ReservationStatus.IN_PROGRESS,
)
def cancel(self):
if not self.reservation_status.in_progress:
raise ReservationStatusException
self.reservation_status = ReservationStatus.CANCELLED
def check_in(self):
# ...
def check_out(self):
# ...
def change_guest(self, guest: Guest):
위 코드처럼 Reservaion를 생성하거나 상태를 변경하는 기능을 메소드화 하여 Entity에 정의해두면 코드 응집력을 향상시킬 수 있고, 각 Entity가 어떤 life cycle을 겪으면서 시스템에 사용되는지 파악하기 쉽다.
from sqlalchemy import MetaData, Table, Column, Integer, String, Text, ForeignKey, DateTime
from sqlalchemy.orm import registry
metadata = MetaData()
mapper_registry = registry()
# Table 정의
room_table = Table(
"hotel_room",
metadata,
Column("id", Integer, primary_key=True, autoincrement=True),
Column("number", String(20), nullable=False),
Column("status", String(20), nullable=False),
Column("image_url", String(200), nullable=False),
Column("description", Text, nullable=True),
UniqueConstraint("number", name="uix_hotel_room_number"),
)
reservation_table = Table(
"room_reservation",
metadata,
Column("id", Integer, primary_key=True, autoincrement=True),
Column("room_id", Integer, ForeignKey("hotel_room.id"), nullable=False),
Column("number", String(20), nullable=False),
Column("status", String(20), nullable=False),
Column("date_in", DateTime(timezone=True)),
Column("date_out", DateTime(timezone=True)),
Column("guest_mobile", String(20), nullable=False),
Column("guest_name", String(50), nullable=True),
)
# Table - Entity 매핑
def init_orm_mappers():
from reception.domain.entity.room import Room as ReceptionRoomEntity
from reception.domain.entity.reservation import Reservation as ReceptionReservationEntity
# ReceptionRoomEntity 클래스는 room_table 데이터베이스 테이블과 매핑
# properties 매개변수를 통해 room_status 속성이 설정
mapper_registry.map_imperatively(
ReceptionRoomEntity,
room_table,
properties={
"room_status": composite(RoomStatus.from_value, room_table.c.status),
}
)
# ReceptionReservationEntity 클래스는 reservation_table 데이터베이스 테이블과 매핑
mapper_registry.map_imperatively(
ReceptionReservationEntity,
reservation_table,
properties={
# Room 엔티티와의 관계를 정의하며, reservations로의 역참조(backref), 정렬(order_by), 그리고 lazy="joined" 옵션을 통한 조인 로딩 전략이 설정
"room": relationship(
Room, backref="reservations", order_by=reservation_table.c.id.desc, lazy="joined"
),
"reservation_number": composite(ReservationNumber.from_value, reservation_table.c.number),
"reservation_status": composite(ReservationStatus.from_value, reservation_table.c.status),
# guest_mobile, guest_name이라는 2개의 컬럼을 'Guest'라는 하나의 VO로 병합해서 가져옴
"guest": composite(Guest, reservation_table.c.guest_mobile, reservation_table.c.guest_name),
}
)
from display.domain.entity.room import Room as DisplayRoomEntity
mapper_registry.map_imperatively(
DisplayRoomEntity,
room_table,
properties={
"room_status": composite(RoomStatus.from_value, room_table.c.status),
}
)
위 매핑은 sqlalchemy가 자동으로 해주는 것이 아니라, from_value
같은 메소드를 만들어서 매핑함. 이것은 VO 단에서 자세히 설명.
from pydantic import constr
mobile_type = constr(regex=r"\+[0-9]{2,3}-[0-9]{2}-[0-9]{4}-[0-9]{4}")
# instance의 동등성 비교하지 않고, 주로 값을 비교하기 때문에 dataclass의 default eq 메서드 사용
@dataclass(slots=True)
class Guest(ValueObject):
mobile: mobile_type
name: str | None = None
class ValueObject:
# sqlalchmey의 composite() 메서드를 사용하기 위한 함수
def __composite_values__(self):
return self.value,
@classmethod
def from_value(cls, value: Any) -> ValueObjectType | None:
# Enum객체도 처리할 수 있도록 구현해둠
if isinstance(cls, EnumMeta):
for item in cls:
if item.value == value:
return item
raise ValueObjectEnumError
instance = cls(value=value)
return instance
@dataclass(slots=True)
class Guest(ValueObject):
mobile: mobile_type
name: str | None = None
def __composite_values__(self):
return self.mobile, self.name
Sqlalchemly에서 composite()
메서드는 여러 개의 데이터베이스 컬럼을 하나의 Python 객체로 매핑할 때 사용되고, 이를 위해 SQLAlchemy는 해당 객체가 어떤 컬럼들로 구성되어 있는지 알아야 하며, __composite_values__
메서드는 이 정보를 SQLAlchemy에 제공한다.
__composite_values__
메서드는 객체가 데이터베이스의 여러 컬럼에 매핑될 때, 그 컬럼들의 값을 순서대로 나열한 튜플을 반환해야 하고, 이 메서드는 SQLAlchemy에 의해 호출되며, 반환된 값은 데이터베이스에 저장되거나 쿼리에 사용된다.
Infra structure 계층에서 기술적인 구현을 추상화 하는 방법 중 가장 쉬운 방법으로 Repository 패턴 사용
class RDBRepository:
@staticmethod
def add(session, instance: EntityType):
return session.add(instance)
@staticmethod
def commit(session):
return session.commit()
class ReservationRDBRepository(RDBRepository):
@staticmethod
def get_reservation_by_reservation_number(
session: Session, reservation_number: ReservationNumber
) -> Reservation | None:
return session.query(Reservation).filter_by(reservation_number=reservation_number).first()
app 계층에서 사용되는 Use Case는 domain, infra 계층을 적절히 조합해서 요구사항에 맞는 기능을 구현한다.
# 예약 번호로 예약을 조회하는 기능
class ReservationQueryUseCase:
def __init__(
self,
reservation_repo: ReservationRDBRepository,
db_session: Callable[[], ContextManager[Session]],
):
self.reservation_repo = reservation_repo
self.db_session = db_session
def get_reservation(self, reservation_number: str) -> Reservation:
# str 로 입력받은 예약번호를 VO로 변환해줌
reservation_number = ReservationNumber.from_value(value=reservation_number)
with self.db_session() as session:
# repository 활용해서 예약 번호로 조회하고, entity나 None을 반환받음
reservation: Reservation | None = (
self.reservation_repo.get_reservation_by_reservation_number(
session=session, reservation_number=reservation_number
)
)
if not reservation:
raise ReservationNotFoundException
return reservation
class CreateReservationRequest(BaseModel):
room_number: str
date_in: datetime
date_out: datetime
guest_mobile: mobile_type
guest_name: str | None = None
class ReservationSchema(BaseModel):
room: RoomSchema
reservation_number: str
status: ReservationStatus
date_in: datetime
date_out: datetime
guest: GuestSchema
@classmethod
def build(cls, reservation: Reservation) -> ReservationSchema:
return cls(
room=RoomSchema.from_entity(reservation.room),
reservation_number=reservation.reservation_number.value,
status=reservation.reservation_status,
date_in=reservation.date_in,
date_out=reservation.date_out,
guest=GuestSchema.from_entity(reservation.guest),
)
class ReservationResponse(BaseResponse):
result: ReservationSchema
entity를 바로 반환하지 않고 build() 라는 메서드로 다수의 entity를 조합해 반환하고 아래와 같은 과정으로 동작함
@router.get("/reservations/{reservation_number}")
@inject
def get_reservation(
reservation_number: str,
reservation_query: ReservationQueryUseCase = Depends(
Provide[AppContainer.reception.reservation_query]
),
):
try:
reservation: Reservation = reservation_query.get_reservation(
reservation_number=reservation_number
)
except ReservationNotFoundException as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=e.message,
)
return ReservationResponse(
detail="ok",
result=ReservationSchema.build(reservation=reservation),
)
실제 로직은 Use Case에 모두 구현되어 있음.
- https://github.com/qu3vipon/python-ddd/
- https://blog.doctor-cha.com/introduction-to-domain-driven-design-for-everyone