• [파이썬으로 살펴보는 아키텍처 패턴] 챕터 1 도메인 모델링

    2023. 7. 30.

    by. haong_

    도메인 모델이란?

    도메인 : 우리가 해결하려는 문제

    모델 : 유영한 특성을 포함하는 프로세스나 현상의 지도(map)

    • 도메인 모델은 비즈니스를 수행하는 사람이 자신의 비즈니스에 대해 마음속에 가지고 있는 지도로 이 지도는 인간이 복잡한 프로세스에 대해 생각하는 방식
    • 복잡한 시스템을 다루기 위해 전문용어가 생기고 도메인 모델링이 필요해짐

    도메인 언어 탐구

    비즈니스 전문가들과의 대화를 통해 도메인 모델에 사용할 용어와 규칙을 정해야 함

    → 비즈니스 전문용어(DDD 용어로는 유비쿼터스 언어(ubiquitous language))

    • 제품 - SKU(재고 유지 단위- stock keeping unit)
    • 고객 - 주문
    • 주문 참조 번호(order reference) - 한줄 이상의 주문라인(order line) - sku - 수량
    • 구매 부서 - 재고를 배치로 주문 - 유일한 ID, SKU, 수량

    배치에 주문 라인을 할당하고, 주문 라인을 배치에 할당하면 해당 배치에 속하는 재고를 고객의 주소로 배송

    어떤 배치의 재고를 주문 라인에 x 단위로 할당하면 가용 재고 수량도 x만큼 줄어듬

    도메인 모델 단위 테스트

    OrderLine은 동작이 없는 불변 데이터 클래스

    Batch는 allocate가 일어날때마다 OrderLine의 qty만큼 available_quantity를 감소시킨다.

    def test_allocating_to_a_batch_reduces_the_available_quantity():
        batch = Batch("batch-001", "SMALL-TABLE", qty=20, eta=date.today())
        line = OrderLine("order-ref", "SMALL-TABLE", 2)
    
        batch.allocate(line)
    
        assert batch.available_quantity == 18
    @dataclass(frozen=True)
    class OrderLine:
        orderid: str
        sku: str
        qty: int
    
    
    class Batch:
        def __init__(self, ref: str, sku: str, qty: int, eta: Optional[date]):
            self.reference = ref
            self.sku = sku
            self.eta = eta
            self.available_quantity = qty
    
        def allocate(self, line: OrderLine):
          self.available_quantity -= line.qty

    할당을 할 수 있으려면, 라인의 sku와 배치의 sku과 같거나, available_quantity가 라인에 들어온 수보다 크거나 같아야 한다.

    def can_allocate(self, line: OrderLine) -> bool:
            return self.sku == line.sku and self.available_quantity >= line.qty

    할당할 수 있는 대상을 보여주는 테스트 코드

    def make_batch_and_line(sku, batch_qty, line_qty):
        return (
            Batch("batch-001", sku, batch_qty, eta=date.today()),
            OrderLine("order-123", sku, line_qty),
        )
    
    def test_can_allocate_if_available_greater_than_required():
        large_batch, small_line = make_batch_and_line("ELEGANT-LAMP", 20, 2)
        assert large_batch.can_allocate(small_line)
    
    def test_cannot_allocate_if_available_smaller_than_required():
        small_batch, large_line = make_batch_and_line("ELEGANT-LAMP", 2, 20)
        assert small_batch.can_allocate(large_line) is False
    
    def test_can_allocate_if_available_equal_to_required():
        batch, line = make_batch_and_line("ELEGANT-LAMP", 2, 2)
        assert batch.can_allocate(line)
    
    def test_cannot_allocate_if_skus_do_not_match():
        batch = Batch("batch-001", "UNCOMFORTABLE-CHAIR", 100, eta=None)
        different_sku_line = OrderLine("order-123", "EXPENSIVE-TOASTER", 10)
        assert batch.can_allocate(different_sku_line) is False

    deallocate를 하려면 라인에 할당되지 않은 배치를 해제할때 배치의 가용 수량에 영향을 주지 않아야 한다.

    def test_can_only_deallocate_allocated_lines():
        batch, unallocated_line = make_batch_and_line("DECORATIVE-TRINKET", 20, 2)
        batch.deallocate(unallocated_line)
        assert batch.available_quantity == 20

    테스트가 제대로 작동하려면 Batch가 자신이 할당된 라인을 알고 있어야 한다.

    class Batch:
        def __init__(self, ref: str, sku: str, qty: int, eta: Optional[date]):
            self.reference = ref
            self.sku = sku
            self.eta = eta
            self._purchased_quantity = qty
            self._allocations = set()  # type: Set[OrderLine]
    
        def allocate(self, line: OrderLine):
            if self.can_allocate(line):
                self._allocations.add(line)
    
        def deallocate(self, line: OrderLine):
            if line in self._allocations:
                self._allocations.remove(line)
    
        @property
        def allocated_quantity(self) -> int:
            return sum(line.qty for line in self._allocations)
    
        @property
        def available_quantity(self) -> int:
            return self._purchased_quantity - self.allocated_quantity
    
        def can_allocate(self, line: OrderLine) -> bool:
            return self.sku == line.sku and self.available_quantity >= line.qty

    배치는 OrderLine 객체들의 set으로 유지하고 배치 할당시 가용 수량이 충분하면 이를 set에 추가하기만 하면된다. available_quantity는 구매 수량에서 할당 수량을 빼는 공식에 의해 제공되는 property가 된다.

    set안에 있는 원소는 모두 유일하기 때문에 아래와 같은 테스트를 수행하는게 쉬워짐

    def test_allocation_is_idempotent():
        batch, line = make_batch_and_line("ANGULAR-DESK", 20, 2)
        batch.allocate(line)
        batch.allocate(line)
        assert batch.available_quantity == 18

    값 객체로 사용하기 적합한 데이터 클래스

    주문에는 여러 라인이 원소로 있고 한 라인은 한 쌍의 SKU와 수량으로 이루어짐.

    Order_reference: 12345
    
    Lines:
     - sku : RED-CHAIR
       qty : 25
     - sku : BLUE-CHAIR
       qty : 25
    • 주문에는 그 주문을 식별할 수 있는 참조 번호가 있지만, 라인은 그렇지 않음.
    • 이처럼 데이터는 있지만, 유일한 식별자가 없는 비즈니스 개념은 값 객체 패턴을 선택하는 경우가 있음.
    • 값 객체는 안에 있는 데이터에 따라 유일하게 식별될 수 있는 도메인 객체. 보통 값 객체를 불변 객체(immutable object)로 만들곤 함
    @dataclass(frozen=True)
    class OrderLine:
        orderid: str
        sku: str
        qty: int

    데이터 클래스의 장점은 값 동등성을 부여할 수 있다는 것 → orderid, sku, qty가 같은 두 라인은 같다라는 의미

    값 객체와 엔티티

    주문 라인은 그라인의 주문 ID, SKU, 수량에 의해 유일하게 식별됨 → 하나라도 달라지면 새로운 라인 생성

    값 객체는 내부에 있는 데이터에 의해 결정되며 오랫동안 유지되는 정체성이 존재하지 않음

    오랫동안 유지되는 정체성이 존재하는 도메인 객체를 설명할 때, 엔티티(entity)라는 용어를 사용

    사람의 경우, 이름은 바꿀 수 있지만 사람은 영속적인 정체성이 있음

    값 객체와 달리 엔티티에는 정체성 동등성(identity equality)가 있음 → 엔티티의 값을 바꿔도, 바뀐 엔티티는 이전과 같은 엔티티로 인식됨

    현재 예시에서 배치가 엔티티임 → 라인을 배치에 할당할 수 있고, 배치 도착 예정 날짜를 변경할 수도 있지만, 이런 값을 바꿔도 배치는 여전히 정체성이 같은 배치

    class Batch:
        def __eq__(self, other):
            if not isinstance(other, Batch):
                return False
            return other.reference == self.reference
            
        def __hash__(self):
            return hash(self.reference)

    이 코드에서 __eq__ 메서드는 두 개의 Batch 객체가 동일한지를 판단하기 위해 사용

    other.reference == self.reference는 다른 객체의 reference 속성이 현재 객체의 reference 속성과 동일한지 확인하는 것을 의미

    __hash__ 메서드는 객체를 해시 가능(hashable)하게 만듬

    객체를 딕셔너리의 키로 사용하거나, 해시 기반의 자료구조(예: set, frozenset)에 포함시키는 데 필요한 속성

    해시 가능한 객체는 그 값이 생명 주기 동안 변경되지 않으므로, 불변성(immutable)을 가져야 함

    __hash__ 메서드는 Batch 객체의 해시 값을 계산 → hash(self.reference)reference 속성의 해시 값을 반환

    이 클래스에서 Batch 객체의 동등성은 reference 속성에 의해 결정되며, Batch 객체의 해시 값은 reference의 해시 값에 의해 결정 -> Batch 객체를 딕셔너리의 키로 사용하거나, 해시 기반의 자료구조에 저장하는 등의 작업이 가능해짐

    모든 것을 객체로 만들 필요는 없다: 도메인 서비스 함수

    배치를 표현하는 모델을 만들었지만, 실제로는 모든 재고를 표현하고 구체적인 배치 집합에서 주문 라인을 할당할 수 있어야 함. 이런 함수를 테스트 하는 방법은 다음과 같음

    def test_prefers_current_stock_batches_to_shipments():
        in_stock_batch = Batch("in-stock-batch", "RETRO-CLOCK", 100, eta=None)
        shipment_batch = Batch("shipment-batch", "RETRO-CLOCK", 100, eta=tomorrow)
        line = OrderLine("oref", "RETRO-CLOCK", 10)
    
        allocate(line, [in_stock_batch, shipment_batch])
    
        assert in_stock_batch.available_quantity == 90
        assert shipment_batch.available_quantity == 100
    
    
    def test_prefers_earlier_batches():
        earliest = Batch("speedy-batch", "MINIMALIST-SPOON", 100, eta=today)
        medium = Batch("normal-batch", "MINIMALIST-SPOON", 100, eta=tomorrow)
        latest = Batch("slow-batch", "MINIMALIST-SPOON", 100, eta=later)
        line = OrderLine("order1", "MINIMALIST-SPOON", 10)
    
        allocate(line, [medium, earliest, latest])
    
        assert earliest.available_quantity == 90
        assert medium.available_quantity == 100
        assert latest.available_quantity == 100
    
    
    def test_returns_allocated_batch_ref():
        in_stock_batch = Batch("in-stock-batch-ref", "HIGHBROW-POSTER", 100, eta=None)
        shipment_batch = Batch("shipment-batch-ref", "HIGHBROW-POSTER", 100, eta=tomorrow)
        line = OrderLine("oref", "HIGHBROW-POSTER", 10)
        allocation = allocate(line, [in_stock_batch, shipment_batch])
        assert allocation == in_stock_batch.reference

    그리고 서비스는 다음과 같음

    def allocate(line: OrderLine, batches: List[Batch]) -> str:
        batch = next(b for b in sorted(batches) if b.can_allocate(line))
        batch.allocate(line)
        return batch.reference

    파이썬 마법 메서드 사용 시 모델과 파이썬 숙어 함께 사용 가능

    sorted()가 작동하게 하려면 ‘__gt__’를 도메인 모델이 구현해야 함

    class Batch:
        def __gt__(self, other):
            if self.eta is None:
                return False
            if other.eta is None:
                return True
            return self.eta > other.eta

    sorted() 함수는 Python에서 리스트나 그 외 iterable 객체를 정렬하는 데 사용되는 내장 함수

    "작다", "크다", "같다" 등의 관계를 정의하는 특수 메서드인 ‘__lt__’, ‘__gt__’, ‘__le__’, ‘__ge__’, ‘__eq__’, ‘__ne__’를 통해 이루어짐 ‘__gt__’ 메서드는 "greater than"을 의미하며, 두 객체의 대소 관계를 비교할 때 사용

    예외를 사용해 도메인 개념 표현 가능

    예외로 도메인 개념을 표현하는 것 → 품절로 주문을 할당할 수 없는 개념을 도메인 예외를 사용해 찾아낼 수 있음

    품절 예외 테스트

    def test_raises_out_of_stock_exception_if_cannot_allocate():
        batch = Batch("batch1", "SMALL-FORK", 10, eta=today)
        allocate(OrderLine("order1", "SMALL-FORK", 10), [batch])
    
        with pytest.raises(OutOfStock, match="SMALL-FORK"):
            allocate(OrderLine("order2", "SMALL-FORK", 1), [batch])

    도메인 예외 발생

    class OutOfStock(Exception):
        pass
    
    def allocate(line: OrderLine, batches: List[Batch]) -> str:
        try:
            batch = next(b for b in sorted(batches) if b.can_allocate(line))
            batch.allocate(line)
            return batch.reference
        except StopIteration:
            raise OutOfStock(f"Out of stock for sku {line.sku}")

    도메인 모델링 정리

     
    • 도메인 모델링
      • 비즈니스와 가장 가까운 코드 부분
      • 변화가 생길 가능성이 높은 부분
      • 비즈니스에 가장 큰 가치를 제공하는 부분
    • 엔티티와 값 객체 구분
      • 값 객체는 배수 속성들에 의해 결정 → 불변 타입을 사용해 값 객체를 구하는 것이 좋음
      • 값 객체의 속성을 변경하면 새로운 값 객체가 됨
      • 엔티티는 시간에 따라 변하는 속성이 포함될 수 있고, 이런 속성이 바뀌더라도 여전히 똑같은 엔티티
      • 어떤 요소가 엔티티를 유일하게 식별하는지 정의하는 것이 중요
    • 모든 것을 객체로 만들 필요가 없음
      • 파이썬은 다중 패러다임 언어 → 코드에서 동사에 해당하는 부분을 표현하려면 함수를 사용하는 것이 좋음
      • 관리 객체, 빌더 객체, 팩토리 객체 대신에 각각 함수로 쓰는 편이 가독성이 더 좋고 표현력이 좋음
    • 가장 좋은 OO 설계 원칙을 적용할 때
      • SOLID 원칙이나 ‘has-a와 is-a의 관계’, ‘상속보다는 구성을 사용하라’와 같은 좋은 설계법을 다시 살펴보자
    • 일관성 경계나 애그리게이트 → 7장에서

    댓글