주문 시스템 예제를 통한 DDD 구조 톺아보기

박병현·2025년 4월 1일
0

주문 시스템 예제를 통한 DDD 구조 톺아보기

도메인 주도 설계(Domain-Driven Design, DDD)는 복잡한 비즈니스 도메인을 효과적으로 모델링하기 위한 접근 방식입니다. 이 글에서는 주문 시스템을 예제로 DDD의 핵심 개념과 구현 방법을 살펴보겠습니다. 특히 바운디드 컨텍스트, 애그리게이트, 엔티티, 값 객체 등의 개념이 실제 코드에서 어떻게 구현되는지 알아보겠습니다.

목차

  1. DDD 핵심 개념
  2. DDD 설계 프로세스
  3. 폴더 구조와 바운디드 컨텍스트
  4. 도메인 모델 구현
  5. 리포지토리 패턴
  6. 서비스 계층과 비즈니스 로직
  7. 바운디드 컨텍스트 간 통합
  8. 결론 및 고려사항

1. DDD 핵심 개념

도메인 모델(Domain Model)

비즈니스 도메인의 개념, 규칙, 행동을 소프트웨어로 표현한 것입니다. 도메인 모델은 기술적 구현 세부사항보다 비즈니스 개념에 중점을 둡니다.

유비쿼터스 언어(Ubiquitous Language)

개발자와 도메인 전문가가 공통으로 사용하는 언어로, 코드와 대화에서 일관되게 사용됩니다.

바운디드 컨텍스트(Bounded Context)

특정 도메인 모델이 적용되는 명확한 경계를 정의합니다. 각 바운디드 컨텍스트 내에서는 용어와 규칙이 일관되게 적용됩니다.

엔티티(Entity)

고유한 식별자를 가지는 객체로, 보통 데이터베이스 테이블로 표현됩니다. 엔티티는 다른 속성이 변경되어도 식별자를 통해 동일한 객체로 인식됩니다.

애그리게이트(Aggregate)

트랜잭션 일관성을 유지해야 하는 엔티티와 객체의 집합으로, 애그리게이트 루트를 통해서만 접근할 수 있습니다.

리포지토리(Repository)

애그리게이트의 지속성을 관리하는 객체로, 데이터 액세스 로직을 캡슐화합니다.

도메인 서비스(Domain Service)

각 바운디드 컨텍스트 내에서 비즈니스 로직과 유스케이스를 구현하는 객체입니다.


2. DDD 설계 프로세스

DDD 기반 시스템을 설계할 때 일반적으로 다음과 같은 단계를 거칩니다:

  1. 도메인 탐색 및 지식 추출

    • 도메인 전문가와 협업하여 핵심 비즈니스 개념, 규칙, 프로세스 파악
    • 유비쿼터스 언어 정의 및 도메인 용어집 작성
  2. 바운디드 컨텍스트 식별

    • 도메인을 논리적으로 분리하여 각 컨텍스트의 경계 정의
    • 컨텍스트 간 관계 매핑(컨텍스트 맵)
  3. 도메인 모델링

    • 각 바운디드 컨텍스트 내의 엔티티, 값 객체, 애그리게이트 식별
    • 도메인 이벤트 및 도메인 서비스 정의
  4. 폴더 구조 설계

    • 바운디드 컨텍스트별로 폴더 구조 생성 (src/{컨텍스트}/)
    • 각 컨텍스트 내 책임에 따라 파일 분리 (entity.py, domain.py, service.py, repository.py)
  5. 세부 구현

    • 도메인 모델 구현 (entity.py, domain.py)
    • 리포지토리 구현 (repository.py)
    • 도메인 서비스 구현 (service.py)
    • 바운디드 컨텍스트 간 통합 구현
  6. 테스트 및 검증

    • 도메인 규칙 및 비즈니스 프로세스가 올바르게 구현되었는지 검증

이제 주문 시스템 예제를 통해 각 단계를 자세히 살펴보겠습니다.


3. 폴더 구조와 바운디드 컨텍스트

DDD에서 폴더 구조는 바운디드 컨텍스트를 명확하게 반영해야 합니다. 우리의 주문 시스템은 다음과 같은 바운디드 컨텍스트로 구성됩니다:

src/
├── order/           # 주문 바운디드 컨텍스트
│   ├── entity.py    # 주문 엔티티 (Order, OrderItem)
│   ├── domain.py    # 도메인 객체 (OrderStatus, 값 객체)
│   ├── repository.py # 주문 리포지토리
│   └── service.py   # 주문 서비스
│
├── customer/        # 고객 바운디드 컨텍스트
│   ├── entity.py    # 고객 엔티티
│   ├── domain.py    # 도메인 객체 
│   ├── repository.py # 고객 리포지토리
│   └── service.py   # 고객 서비스
│
├── product/         # 상품 바운디드 컨텍스트
│   ├── entity.py    # 상품 엔티티
│   ├── domain.py    # 도메인 객체
│   ├── repository.py # 상품 리포지토리
│   └── service.py   # 상품 서비스
│
└── infrastructure/  # 인프라스트럭처 계층
    └── database.py  # 데이터베이스 연결 및 트랜잭션 관리

이 구조에서 각 폴더(order/, customer/, product/)는 하나의 바운디드 컨텍스트를 나타냅니다. 각 바운디드 컨텍스트는 독립적인 도메인 모델, 리포지토리, 서비스를 가지고 있습니다.

바운디드 컨텍스트는:

  • 특정 도메인 모델이 적용되는 명확한 경계를 정의합니다
  • 각 컨텍스트 내에서는 용어와 규칙이 일관되게 적용됩니다
  • 폴더 구조로 표현했을 때 상위 수준의 구분이 됩니다

반면 애그리게이트(Aggregate)는:

  • 트랜잭션 일관성을 유지해야 하는 엔티티와 값 객체의 집합입니다
  • 애그리게이트 루트를 통해서만 접근할 수 있습니다
  • 예를 들어, OrderOrderItem이 하나의 애그리게이트를 형성하며, Order가 애그리게이트 루트입니다

정리하자면:

  • order, product, customer는 각각 바운디드 컨텍스트입니다
  • 각 바운디드 컨텍스트 안에 하나 이상의 애그리게이트가 존재할 수 있습니다
    • order 바운디드 컨텍스트 안에는 Order-OrderItem 애그리게이트가 있습니다. (Order가 애그리게이트 루트 엔티티)
    • product 바운디드 컨텍스트 안에는 Product가 애그리게이트이자 동시에 루트 애그리게이트 엔티티로 존재합니다.
    • customer 바운디드 컨텍스트 안에는 Customer가 애그리게이트이자 동시에 루트 애그리게이트 엔티티로 존재합니다.

4. 도메인 모델 구현

이제 각 바운디드 컨텍스트의 도메인 모델을 구현해 보겠습니다. SQLAlchemy를 사용한 구현을 살펴보겠습니다.

Order 바운디드 컨텍스트

# src/order/domain.py
import enum
from datetime import datetime

class OrderStatus(enum.Enum):
    CREATED = "created"
    PAID = "paid"
    SHIPPED = "shipped"
    DELIVERED = "delivered"
    CANCELLED = "cancelled"
# src/order/entity.py
from sqlalchemy import Column, String, Float, Integer, ForeignKey, DateTime, Enum as SQLAlchemyEnum
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
import uuid
from datetime import datetime

from src.infrastructure.database import Base
from src.order.domain import OrderStatus

class OrderItem(Base):
    __tablename__ = "order_items"
    
    id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
    order_id = Column(UUID(as_uuid=True), ForeignKey("orders.id"), nullable=False)
    product_id = Column(UUID(as_uuid=True), ForeignKey("products.id"), nullable=False)
    quantity = Column(Integer, nullable=False)
    unit_price = Column(Float, nullable=False)
    
    # Relationships
    order = relationship("Order", back_populates="items")
    product = relationship("Product")
    
    @property
    def subtotal(self) -> float:
        return self.quantity * self.unit_price

class Order(Base):
    __tablename__ = "orders"
    
    id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
    customer_id = Column(UUID(as_uuid=True), ForeignKey("customers.id"), nullable=False)
    status = Column(SQLAlchemyEnum(OrderStatus), default=OrderStatus.CREATED)
    created_at = Column(DateTime, default=datetime.now)
    total_amount = Column(Float, default=0)
    
    # Relationships
    customer = relationship("Customer")
    items = relationship("OrderItem", back_populates="order", cascade="all, delete-orphan")
    
    def add_item(self, product_id: uuid.UUID, quantity: int, unit_price: float) -> None:
        """주문에 상품 항목 추가"""
        item = OrderItem(
            product_id=product_id,
            quantity=quantity,
            unit_price=unit_price
        )
        self.items.append(item)
        self.recalculate_total()
    
    def recalculate_total(self) -> None:
        """주문 총액 재계산"""
        self.total_amount = sum(item.subtotal for item in self.items)
    
    def change_status(self, status: OrderStatus) -> None:
        """주문 상태 변경"""
        self.status = status

Customer 바운디드 컨텍스트

# src/customer/entity.py
from sqlalchemy import Column, String, Integer
from sqlalchemy.dialects.postgresql import UUID
import uuid

from src.infrastructure.database import Base

class Customer(Base):
    __tablename__ = "customers"
    
    id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
    name = Column(String, nullable=False)
    email = Column(String, nullable=False, unique=True)
    address = Column(String, nullable=False)
    points = Column(Integer, default=0)
    
    def add_points(self, points: int) -> None:
        """고객 포인트 적립"""
        self.points += points

Product 바운디드 컨텍스트

# src/product/entity.py
from sqlalchemy import Column, String, Float, Integer
from sqlalchemy.dialects.postgresql import UUID
import uuid

from src.infrastructure.database import Base

class Product(Base):
    __tablename__ = "products"
    
    id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
    name = Column(String, nullable=False)
    price = Column(Float, nullable=False)
    stock_quantity = Column(Integer, default=0)
    
    def decrease_stock(self, quantity: int) -> bool:
        """상품 재고 감소, 성공 여부 반환"""
        if self.stock_quantity >= quantity:
            self.stock_quantity -= quantity
            return True
        return False

도메인 모델에서 주목할 점

  1. 엔티티: Order, Customer, Product는 고유 식별자를 가진 엔티티입니다. OrderStatus는 Enum으로 표현된 상태입니다.

  2. 애그리게이트 경계: OrderOrderItem은 하나의 애그리게이트를 형성합니다. Order가 애그리게이트 루트이며, OrderItemOrder를 통해서만 접근할 수 있습니다.

  3. 비즈니스 규칙 캡슐화: 각 엔티티는 자신의 비즈니스 규칙을 캡슐화합니다. 예를 들어, Productdecrease_stock 메서드는 재고가 충분한지 확인하는 규칙을 캡슐화합니다.

  4. 바운디드 컨텍스트 내 일관성: 각 바운디드 컨텍스트 내의 모델은 일관된 용어와 규칙을 사용합니다.


5. Repository 패턴

repository는 도메인 모델과 데이터 저장소 사이의 매개체 역할을 합니다. 각 바운디드 컨텍스트는 자체 리포지토리를 가집니다.

Order 리포지토리

# src/order/repository.py
from typing import Optional
from uuid import UUID
from sqlalchemy.orm import Session

from src.order.entity import Order

class OrderRepository:
    def __init__(self, session: Session):
        self.session = session
    
    def find_by_id(self, order_id: UUID) -> Optional[Order]:
        """ID로 주문 조회"""
        return self.session.query(Order).filter(Order.id == order_id).first()
    
    def save(self, order: Order) -> None:
        """주문 저장 또는 업데이트"""
        if order.id is None or not self.session.query(Order.id).filter(Order.id == order.id).first():
            self.session.add(order)
        # session.commit()은 서비스 계층에서 처리

Customer 리포지토리

# src/customer/repository.py
from typing import Optional
from uuid import UUID
from sqlalchemy.orm import Session

from src.customer.entity import Customer

class CustomerRepository:
    def __init__(self, session: Session):
        self.session = session
    
    def find_by_id(self, customer_id: UUID) -> Optional[Customer]:
        """ID로 고객 조회"""
        return self.session.query(Customer).filter(Customer.id == customer_id).first()
    
    def save(self, customer: Customer) -> None:
        """고객 정보 저장 또는 업데이트"""
        if customer.id is None or not self.session.query(Customer.id).filter(Customer.id == customer.id).first():
            self.session.add(customer)
        # session.commit()은 서비스 계층에서 처리

Product 리포지토리

# src/product/repository.py
from typing import List, Optional
from uuid import UUID
from sqlalchemy.orm import Session

from src.product.entity import Product

class ProductRepository:
    def __init__(self, session: Session):
        self.session = session
    
    def find_by_id(self, product_id: UUID) -> Optional[Product]:
        """ID로 상품 조회"""
        return self.session.query(Product).filter(Product.id == product_id).first()
    
    def find_by_ids(self, product_ids: List[UUID]) -> List[Product]:
        """여러 ID로 상품 목록 조회"""
        return self.session.query(Product).filter(Product.id.in_(product_ids)).all()
    
    def save(self, product: Product) -> None:
        """상품 정보 저장 또는 업데이트"""
        if product.id is None or not self.session.query(Product.id).filter(Product.id == product.id).first():
            self.session.add(product)
        # session.commit()은 서비스 계층에서 처리

리포지토리 패턴의 이점

  1. 데이터 액세스 추상화: 도메인 모델이 데이터 액세스 메커니즘에 의존하지 않게 합니다.
  2. 테스트 용이성: 리포지토리를 모킹하여 도메인 로직을 독립적으로 테스트할 수 있습니다.
  3. 영속성 메커니즘 교체 용이성: 데이터베이스 구현을 변경해도 도메인 로직에 영향을 미치지 않습니다.

6. 서비스 계층과 비즈니스 로직

서비스 계층은 도메인 모델이 직접 처리하기 어려운 비즈니스 로직을 담당합니다. 각 바운디드 컨텍스트는 자체 서비스를 가집니다.

Customer 서비스

# src/customer/service.py
from typing import Optional
from uuid import UUID

from src.customer.entity import Customer
from src.customer.repository import CustomerRepository

class CustomerService:
    def __init__(self, repository: CustomerRepository):
        self.repository = repository
    
    def get_customer(self, customer_id: UUID) -> Optional[Customer]:
        """고객 정보 조회"""
        return self.repository.find_by_id(customer_id)
    
    def add_points(self, customer_id: UUID, points: int) -> bool:
        """고객 포인트 적립"""
        customer = self.repository.find_by_id(customer_id)
        if not customer:
            return False
        
        customer.add_points(points)
        self.repository.save(customer)
        return True

Product 서비스

# src/product/service.py
from typing import List, Dict, Optional, Tuple
from uuid import UUID

from src.product.entity import Product
from src.product.repository import ProductRepository

class ProductService:
    def __init__(self, repository: ProductRepository):
        self.repository = repository
    
    def get_products(self, product_ids: List[UUID]) -> List[Product]:
        """여러 상품 정보 조회"""
        return self.repository.find_by_ids(product_ids)
    
    def get_product(self, product_id: UUID) -> Optional[Product]:
        """단일 상품 정보 조회"""
        return self.repository.find_by_id(product_id)
    
    def check_and_decrease_stock_bulk(self, items: List[Dict]) -> Tuple[bool, List[Product]]:
        """여러 상품의 재고 확인 및 차감"""
        product_ids = [item['product_id'] for item in items]
        products = self.repository.find_by_ids(product_ids)
        
        # 상품 ID별 매핑
        product_map = {product.id: product for product in products}
        
        # 모든 상품의 재고 확인
        for item in items:
            product = product_map.get(item['product_id'])
            if not product or product.stock_quantity < item['quantity']:
                return False, []
        
        # 모든 상품의 재고 차감
        for item in items:
            product = product_map[item['product_id']]
            product.decrease_stock(item['quantity'])
            self.repository.save(product)
        
        return True, list(product_map.values())

Order 서비스

# src/order/service.py
from typing import List, Dict, Optional
from uuid import UUID
from src.infrastructure.database import transaction

from src.order.entity import Order, OrderStatus
from src.order.repository import OrderRepository
from src.customer.service import CustomerService
from src.product.service import ProductService

class OrderService:
    """주문 서비스: 애그리게이트 루트 역할을 수행"""
    
    def __init__(
        self,
        order_repository: OrderRepository,
        customer_service: CustomerService,
        product_service: ProductService
    ):
        self.repository = order_repository
        self.customer_service = customer_service
        self.product_service = product_service
    
    @transaction
    def place_order(self, customer_id: UUID, items: List[Dict]) -> Optional[UUID]:
        """주문 생성 및 처리
        
        이 메서드는 애그리게이트 경계 내에서 다음 작업을 수행:
        1. 고객 검증
        2. 주문 생성
        3. 상품 재고 확인 및 차감
        4. 고객 포인트 적립
        
        모든 작업은 하나의 트랜잭션으로 처리되며, 어느 한 단계라도 실패하면
        전체 트랜잭션이 롤백됩니다.
        """
        # 1. 고객 검증
        customer = self.customer_service.get_customer(customer_id)
        if not customer:
            return None
        
        # 2. 주문 생성
        order = Order(customer_id=customer_id)
        
        # 3. 상품 정보 조회 및 주문 항목 추가
        product_ids = [item['product_id'] for item in items]
        products = self.product_service.get_products(product_ids)
        
        if len(products) != len(product_ids):
            return None  # 일부 상품이 존재하지 않음
        
        # 상품 ID별 매핑
        product_map = {product.id: product for product in products}
        
        # 주문 항목 추가
        for item in items:
            product = product_map[item['product_id']]
            order.add_item(product.id, item['quantity'], product.price)
        
        # 4. 상품 재고 확인 및 차감
        success, _ = self.product_service.check_and_decrease_stock_bulk([
            {'product_id': item.product_id, 'quantity': item.quantity}
            for item in order.items
        ])
        
        if not success:
            # 트랜잭션은 데코레이터에 의해 롤백됨
            return None  # 재고 부족
        
        # 5. 주문 저장
        order.change_status(OrderStatus.PAID)
        self.repository.save(order)
        
        # 6. 고객 포인트 적립 (주문 금액의 1%)
        points_to_add = int(order.total_amount * 0.01)
        self.customer_service.add_points(customer.id, points_to_add)
        
        return order.id
    
    @transaction
    def cancel_order(self, order_id: UUID) -> bool:
        """주문 취소"""
        # 1. 주문 조회
        order = self.repository.find_by_id(order_id)
        if not order or order.status != OrderStatus.PAID:
            return False
        
        # 2. 주문 상태 변경
        order.change_status(OrderStatus.CANCELLED)
        self.repository.save(order)
        
        return True

서비스 계층에서 주목할 점

  1. 애그리게이트 루트로서의 서비스: OrderService.place_order 메서드는 주문 애그리게이트 루트 역할을 수행합니다. 주문 생성 시 여러 엔티티(주문, 고객, 상품)를 일관되게 변경합니다.

  2. 트랜잭션 관리: @transaction 데코레이터를 통해 트랜잭션을 관리합니다. 이는 애그리게이트 일관성을 보장하는 핵심 메커니즘입니다.

  3. 바운디드 컨텍스트 간 협력: OrderServiceCustomerServiceProductService를 주입받아 사용하는 것은 바운디드 컨텍스트 간 협력의 한 방식입니다.

  4. 도메인 규칙 적용: 서비스 계층은 엔티티의 단순한 CRUD를 넘어, 복잡한 비즈니스 규칙을 적용합니다. 예를 들어, 주문 처리 시 재고 확인, 포인트 적립 등의 규칙이 적용됩니다.


7. 바운디드 컨텍스트 간 통합

DDD에서는 바운디드 컨텍스트 간 통합을 위한 여러 패턴을 제공합니다. 우리 예제에서는 공유 커널(Shared Kernel) 접근 방식을 사용했습니다.

공유 커널(Shared Kernel)

공유 커널은 여러 바운디드 컨텍스트가 공유하는 모델이나 코드입니다. 우리 예제에서는 서비스 주입 방식을 통해 공유 커널을 구현했습니다:

def __init__(
    self,
    order_repository: OrderRepository,
    customer_service: CustomerService,  # 다른 컨텍스트의 서비스 주입
    product_service: ProductService     # 다른 컨텍스트의 서비스 주입
):

이 접근 방식은 간단하고 직관적이지만, 컨텍스트 간 결합도가 높아질 수 있다는 단점이 있습니다.

다른 통합 패턴

더 큰 시스템이나 더 엄격한 컨텍스트 분리가 필요한 경우, 다음과 같은 대안을 고려할 수 있습니다:

1. 안티코럽션 계층(Anti-Corruption Layer, ACL)

한 컨텍스트가 다른 컨텍스트의 모델을 직접 사용하지 않고, 변환 계층을 통해 통신합니다:

# 안티코럽션 계층 예시
class CustomerAdapter:
    def __init__(self, customer_service: CustomerService):
        self.customer_service = customer_service
    
    def verify_customer(self, customer_id: UUID) -> bool:
        # 주문 컨텍스트의 필요에 맞게 고객 서비스 변환
        return self.customer_service.get_customer(customer_id) is not None

2. 이벤트 기반 통합

느슨한 결합을 위해 이벤트 발행/구독 패턴을 사용합니다:

# 이벤트 기반 통합 예시
# src/order/service.py
def place_order(self, customer_id: UUID, items: List[Dict]) -> Optional[UUID]:
    # ... 주문 생성 로직 ...
    
    # 이벤트 발행
    self.event_publisher.publish(
        "order.created",
        {
            "order_id": order.id,
            "customer_id": customer_id,
            "total_amount": order.total_amount
        }
    )
    
    return order.id

# src/customer/event_handlers.py
def handle_order_created(event_data):
    customer_id = event_data["customer_id"]
    total_amount = event_data["total_amount"]
    points_to_add = int(total_amount * 0.01)
    
    customer_service = get_customer_service()
    customer_service.add_points(customer_id, points_to_add)

기술 인프라스트럭처

DDD에서는 인프라스트럭처가 도메인 모델을 지원하는 역할을 합니다. 데이터베이스 연결, 트랜잭션 관리 등의 기술적 관심사를 도메인 모델과 분리함으로써, 도메인 모델이 특정 기술에 의존하지 않도록 합니다.

# src/infrastructure/database.py
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker

# 모든 모델의 기본 클래스
Base = declarative_base()

# 트랜잭션 관리를 위한 데코레이터
def transaction(func):
    """트랜잭션 관리 데코레이터"""
    # ...트랜잭션 관리 로직...

이러한 분리를 통해 도메인 로직은 기술 인프라의 변경에 영향을 받지 않고 비즈니스 요구사항에 집중할 수 있습니다.


8. 결론 및 고려사항

지금까지 DDD 기반의 주문 시스템 구현을 살펴보았습니다. 이 접근 방식의 주요 이점과 고려사항을 정리해 보겠습니다.

이점

  1. 비즈니스 중심 설계: 기술보다 비즈니스 도메인에 집중하여, 소프트웨어가 실제 비즈니스를 더 정확히 반영합니다.

  2. 모듈성과 유지보수성: 바운디드 컨텍스트를 통한 명확한 경계 설정으로 시스템의 모듈성과 유지보수성이 향상됩니다.

  3. 확장성: 각 바운디드 컨텍스트가 독립적으로 발전할 수 있어, 시스템 확장이 용이합니다.

  4. 도메인 전문가와의 효과적인 협업: 유비쿼터스 언어를 통해 개발자와 도메인 전문가 간 의사소통이 향상됩니다.

고려사항

  1. 복잡성: DDD는 초기 학습 곡선이 가파르고, 간단한 CRUD 애플리케이션에는 과도할 수 있습니다.

  2. 성능 고려: 엄격한 경계와 캡슐화가 때로는 성능 최적화를 어렵게 할 수 있습니다.

  3. 통합 복잡성: 바운디드 컨텍스트 간 통합이 복잡해질 수 있으며, 적절한 통합 패턴 선택이 중요합니다.

9. 개인적 관점: DDD의 애그리게이트 vs 엔티티별 Repository

DDD를 공부하면서 가장 오래 고민했던 부분은 Repository와 Entity의 관계였습니다. 일반적인 개발 방식에서는 하나의 Entity마다 하나의 Repository를 만드는 패턴을 많이 사용합니다. 예를 들어 Order와 OrderItem이 있다면, OrderRepository와 OrderItemRepository를 각각 만들어 책임을 분리하는 것이 직관적으로 느껴졌습니다.

그런데 DDD에서는 다른 접근법을 취합니다. 애그리게이트(Aggregate)라는 개념이 중심에 있고, 애그리게이트 루트(Aggregate Root)만이 Repository의 직접적인 대상이 됩니다. 즉, Order가 애그리게이트 루트라면 OrderRepository만 존재하고, OrderItem은 Order를 통해서만 접근하는 구조입니다.

이 패턴은 트랜잭션 일관성과 도메인 불변성을 유지하는 데 강점이 있지만, 실무에서는 몇 가지 어려움이 있습니다:

  1. 특정 OrderItem만 조회하고 싶을 때: 항상 Order를 통해 접근해야 하므로 비효율적일 수 있습니다.
  2. 복잡한 쿼리 처리: 특정 조건의 OrderItem을 찾는 쿼리가 OrderRepository에 모두 구현되어야 합니다.
  3. Repository 크기 증가: 애그리게이트에 속한 모든 엔티티 관련 메서드가 하나의 Repository에 모이면서 복잡해질 수 있습니다.

그래서 저는 다음과 같은 실용적인 접근법을 선호합니다:

역할 분리에 중점을 둔 접근법

# OrderItemRepository 구현
class OrderItemRepository:
    def __init__(self, session: Session):
        self.session = session
    
    def find_by_id(self, item_id: UUID) -> Optional[OrderItem]:
        return self.session.query(OrderItem).filter(OrderItem.id == item_id).first()
    
    def find_by_order_id(self, order_id: UUID) -> List[OrderItem]:
        return self.session.query(OrderItem).filter(OrderItem.order_id == order_id).all()
    
    def find_by_product(self, product_id: UUID) -> List[OrderItem]:
        """특정 상품이 포함된 주문 항목 조회"""
        return self.session.query(OrderItem).filter(OrderItem.product_id == product_id).all()
    
    def save(self, item: OrderItem) -> None:
        if item.id is None or not self.session.query(OrderItem.id).filter(OrderItem.id == item.id).first():
            self.session.add(item)
# OrderItemService 구현
class OrderItemService:
    def __init__(self, repository: OrderItemRepository):
        self.repository = repository
    
    def get_item(self, item_id: UUID) -> Optional[OrderItem]:
        return self.repository.find_by_id(item_id)
    
    def get_items_by_order(self, order_id: UUID) -> List[OrderItem]:
        return self.repository.find_by_order_id(order_id)
    
    def update_item_quantity(self, item_id: UUID, quantity: int) -> bool:
        item = self.repository.find_by_id(item_id)
        if not item:
            return False
        
        item.quantity = quantity
        self.repository.save(item)
        return True
# OrderService에 OrderItemService 주입
class OrderService:
    def __init__(
        self,
        order_repository: OrderRepository,
        order_item_service: OrderItemService,  # OrderItemService 주입
        customer_service: CustomerService,
        product_service: ProductService
    ):
        self.repository = order_repository
        self.order_item_service = order_item_service
        self.customer_service = customer_service
        self.product_service = product_service
    
    # 주문 도메인 로직 구현...

이 접근법의 장점

  1. 역할 분리: 각 엔티티는 자신만의 Repository와 Service를 가지므로 책임이 명확히 분리됩니다.
  2. 쿼리 유연성: OrderItem에 대한 특정 쿼리를 쉽게 추가할 수 있습니다.
  3. 가독성과 유지보수성: 코드가 직관적이고 특정 엔티티 관련 코드는 해당 Repository에서 찾기 쉽습니다.
  4. 점진적 도입: 팀이 DDD 개념에 익숙하지 않더라도 기존 개발 패턴과 유사하여 적용이 쉽습니다.

트랜잭션 일관성 유지하기

이 접근법에서 중요한 점은 트랜잭션 일관성을 어떻게 유지할 것인가 하는 문제입니다. DDD의 애그리게이트 패턴이 이 문제를 자연스럽게 해결하는 반면, 엔티티별 Repository 패턴에서는 추가 작업이 필요합니다:

@transaction
def update_order_with_items(self, order_id: UUID, updated_items: List[Dict]) -> bool:
    # 주문 조회
    order = self.repository.find_by_id(order_id)
    if not order:
        return False
    
    # 주문 항목 업데이트
    for item_data in updated_items:
        item = self.order_item_service.get_item(item_data['id'])
        if item and item.order_id == order_id:  # 중요: 주문에 속한 항목인지 확인
            item.quantity = item_data['quantity']
            self.order_item_service.repository.save(item)
    
    # 주문 총액 재계산
    items = self.order_item_service.get_items_by_order(order_id)
    order.total_amount = sum(item.quantity * item.unit_price for item in items)
    self.repository.save(order)
    
    return True

이런 식으로 트랜잭션 데코레이터와 명시적인 일관성 체크를 통해 애그리게이트의 일관성을 유지할 수 있습니다.

선택 기준

다음과 같은 기준으로 접근법을 선택하는 것이 좋다고 생각됩니다:

DDD의 애그리게이트-Repository 패턴이 적합한 경우

  • 복잡한 도메인 규칙과 불변 조건이 많은 경우
  • 트랜잭션 일관성이 절대적으로 중요한 경우
  • 도메인 모델의 순수성과 이론적 정확성을 우선시하는 경우
  • 팀이 DDD 개념에 충분히 익숙한 경우

엔티티별 Repository 패턴이 적합한 경우

  • 단순한 CRUD 작업이 대부분인 경우
  • 엔티티별 조회 및 조작이 빈번한 경우
  • 역할 분리와 코드 직관성이 더 중요한 경우
  • 팀이 전통적인 계층형 아키텍처에 더 익숙한 경우

결론

DDD의 개념적 순수함과 실용적인 개발 사이의 균형을 찾는 것이 중요합니다. 엄격한 DDD 원칙을 따르는 것도 좋지만, 프로젝트의 특성과 팀의 상황에 맞게 유연하게 적용하는 것이 현실적인 접근법이라고 생각합니다.

DDD는 단순한 설계 방법론이 아닌, 복잡한 비즈니스 도메인을 효과적으로 다루기 위한 철학적 접근법입니다. 바운디드 컨텍스트, 애그리게이트, 도메인 모델 등의 개념은 도메인 복잡성을 관리 가능한 단위로 나누고, 소프트웨어가 비즈니스 요구사항을 더 정확히 반영할 수 있게 합니다.

제 개인적인 선호는 시스템의 복잡한 부분에는 DDD 패턴을, 단순한 부분에는 엔티티별 Repository 패턴을 혼합해서 사용하는 것입니다. 그리고 무엇보다, 팀이 이해하고 효율적으로 작업할 수 있는 방식을 선택하는 것이 가장 중요합니다.

소프트웨어 설계에는 '완벽한' 방법론보다는 '적합한' 방법론이 있을 뿐이며, 각 프로젝트의 요구사항과 팀의 역량에 맞게 최적의 접근법을 찾아가는 과정이 진정한 소프트웨어 설계의 핵심이라고 생각합니다.

profile
AI Application Engineer

0개의 댓글