Home > Backend Development > 📚[Backend Development] Java 백엔드 개발자를 위한 DDD 뿌수기 !!

📚[Backend Development] Java 백엔드 개발자를 위한 DDD 뿌수기 !!
Backend Development Domain Design Pattern DDD Domain Driven Design

🏗️ Java 백엔드 개발자를 위한 DDD 뿌수기 !!

📚 DDD란 무엇인가?

Domain-Driven Design(DDD)는 복잡한 비즈니스 도메인을 소프트웨어 모델로 효과적으로 변환하는 설계 방법론입니다.

🎯 핵심 철학

  • 비즈니스 도메인이 설계의 중심
  • 도메인 전문가와 개발자의 긴밀한 협업
  • 지속적인 모델 개선과 발전

⏰ 언제 DDD를 사용할까?

✅ 적합한 상황

  • 복잡한 비즈니스 규칙: 단순 CRUD를 넘어서는 도메인 로직
  • 대규모 시스템: 여러 팀이 협업하는 환경
  • 장기 프로젝트: 지속적인 기능 추가와 변경이 예상됨
  • 도메인 전문성 필요: 비즈니스 규칙의 정확한 이해가 중요

❌ 부적합한 상황

  • 단순한 CRUD 애플리케이션
  • 소규모 프로젝트 (개발자 1-2명)
  • 명확하고 변경이 적은 도메인

🏢 어디서 DDD를 활용할까?

🎯 주요 적용 분야

  • 전자상거래: 주문, 결제, 배송, 재고 관리
  • 금융 서비스: 계좌, 거래, 대출, 보험
  • 의료 시스템: 진료, 예약, 처방, 청구
  • 물류: 운송, 창고, 추적
  • ERP: 인사, 회계, 구매, 판매

🛠️ DDD 핵심 구성요소와 Java 구현

1. 📦 Entity (엔티티)

특징: 고유한 식별자를 가지며, 생명주기 동안 식별성이 유지됩니다.

// ❌ 잘못된 Entity 예시
public class Product {
    private String name;
    private BigDecimal price;
    // getter, setter만 있는 빈약한 도메인 모델
}

// ✅ 올바른 Entity 예시
@Entity
public class Product {
    @Id
    private ProductId id;
    private String name;
    private Money price;
    private Stock stock;
    private ProductStatus status;
    
    // 생성자는 필수 데이터만 받음
    public Product(ProductId id, String name, Money price) {
        this.id = Objects.requireNonNull(id);
        this.name = validateName(name);
        this.price = Objects.requireNonNull(price);
        this.status = ProductStatus.ACTIVE;
        this.stock = Stock.zero();
    }
    
    // 비즈니스 로직을 메서드로 표현
    public void changePrice(Money newPrice) {
        if (newPrice.isLessThanOrEqual(Money.zero())) {
            throw new IllegalArgumentException("상품 가격은 0보다 커야 합니다");
        }
        this.price = newPrice;
    }
    
    public void addStock(int quantity) {
        if (quantity <= 0) {
            throw new IllegalArgumentException("추가할 재고 수량은 양수여야 합니다");
        }
        this.stock = stock.add(quantity);
    }
    
    public boolean isAvailable() {
        return status == ProductStatus.ACTIVE && stock.hasQuantity();
    }
    
    // 도메인 이벤트 발생
    public void discontinue() {
        this.status = ProductStatus.DISCONTINUED;
        // ProductDiscontinuedEvent 발행
    }
}

2. 💎 Value Object (값 객체)

특징: 값 자체로 동일성을 판단하며, 불변성을 가집니다.

// Money Value Object
public class Money {
    private final BigDecimal amount;
    private final Currency currency;
    
    public Money(BigDecimal amount, Currency currency) {
        this.amount = Objects.requireNonNull(amount);
        this.currency = Objects.requireNonNull(currency);
        
        if (amount.compareTo(BigDecimal.ZERO) < 0) {
            throw new IllegalArgumentException("금액은 음수일 수 없습니다");
        }
    }
    
    public static Money won(long amount) {
        return new Money(BigDecimal.valueOf(amount), Currency.getInstance("KRW"));
    }
    
    public Money add(Money other) {
        validateSameCurrency(other);
        return new Money(this.amount.add(other.amount), this.currency);
    }
    
    public boolean isGreaterThan(Money other) {
        validateSameCurrency(other);
        return this.amount.compareTo(other.amount) > 0;
    }
    
    @Override
    public boolean equals(Object obj) {
        if (this == obj) return true;
        if (obj == null || getClass() != obj.getClass()) return false;
        Money money = (Money) obj;
        return Objects.equals(amount, money.amount) && 
               Objects.equals(currency, money.currency);
    }
}

// Address Value Object
public class Address {
    private final String zipCode;
    private final String street;
    private final String city;
    
    public Address(String zipCode, String street, String city) {
        this.zipCode = validateZipCode(zipCode);
        this.street = validateNotEmpty(street, "도로명");
        this.city = validateNotEmpty(city, "도시");
    }
    
    // 불변 객체이므로 변경 시 새로운 인스턴스 반환
    public Address changeStreet(String newStreet) {
        return new Address(this.zipCode, newStreet, this.city);
    }
}

3. 🎯 Aggregate (애그리게잇)

특징: 관련된 객체들을 하나의 일관성 있는 단위로 묶습니다.

// Order Aggregate
@Entity
public class Order {
    @Id
    private OrderId id;
    private CustomerId customerId;
    private OrderStatus status;
    private List<OrderItem> items = new ArrayList<>();
    private Money totalAmount;
    private Address deliveryAddress;
    
    // Aggregate Root는 모든 변경의 진입점
    public void addItem(ProductId productId, int quantity, Money unitPrice) {
        validateCanAddItem();
        
        OrderItem newItem = new OrderItem(productId, quantity, unitPrice);
        items.add(newItem);
        recalculateTotal();
        
        // 도메인 이벤트 발행
        DomainEvents.raise(new ItemAddedToOrderEvent(this.id, productId, quantity));
    }
    
    public void confirm() {
        if (status != OrderStatus.PENDING) {
            throw new IllegalStateException("대기 중인 주문만 확정할 수 있습니다");
        }
        
        if (items.isEmpty()) {
            throw new IllegalStateException("주문 항목이 없습니다");
        }
        
        this.status = OrderStatus.CONFIRMED;
        DomainEvents.raise(new OrderConfirmedEvent(this.id, this.customerId));
    }
    
    // 내부 일관성 유지
    private void recalculateTotal() {
        this.totalAmount = items.stream()
            .map(OrderItem::getSubtotal)
            .reduce(Money.zero(), Money::add);
    }
    
    // Aggregate 내부 Entity
    @Entity
    public static class OrderItem {
        private ProductId productId;
        private int quantity;
        private Money unitPrice;
        
        public Money getSubtotal() {
            return unitPrice.multiply(quantity);
        }
    }
}

4. 🏪 Repository (리포지토리)

특징: 도메인 객체의 컬렉션처럼 동작하는 저장소 추상화입니다.

// 도메인 레이어의 Repository 인터페이스
public interface ProductRepository {
    void save(Product product);
    Optional<Product> findById(ProductId id);
    List<Product> findByCategory(Category category);
    List<Product> findAvailableProducts();
    void remove(Product product);
}

// 인프라스트럭처 레이어의 구현체
@Repository
public class JpaProductRepository implements ProductRepository {
    
    @PersistenceContext
    private EntityManager entityManager;
    
    @Override
    public void save(Product product) {
        entityManager.persist(product);
    }
    
    @Override
    public Optional<Product> findById(ProductId id) {
        return Optional.ofNullable(
            entityManager.find(Product.class, id.getValue())
        );
    }
    
    @Override
    public List<Product> findAvailableProducts() {
        return entityManager.createQuery(
            "SELECT p FROM Product p WHERE p.status = :status AND p.stock.quantity > 0",
            Product.class)
            .setParameter("status", ProductStatus.ACTIVE)
            .getResultList();
    }
}

5. 🔧 Domain Service (도메인 서비스)

특징: 특정 엔티티나 값 객체에 속하지 않는 도메인 로직을 담당합니다.

@Service
public class PricingService {
    
    public Money calculateDiscountedPrice(Product product, Customer customer, DiscountPolicy policy) {
        Money basePrice = product.getPrice();
        
        if (customer.isVip()) {
            basePrice = policy.applyVipDiscount(basePrice);
        }
        
        if (product.isOnSale()) {
            basePrice = policy.applySaleDiscount(basePrice);
        }
        
        return basePrice;
    }
    
    public Money calculateShippingFee(Order order, Address deliveryAddress) {
        Money totalAmount = order.getTotalAmount();
        
        // 비즈니스 규칙: 50,000원 이상 무료배송
        if (totalAmount.isGreaterThanOrEqual(Money.won(50000))) {
            return Money.zero();
        }
        
        // 지역별 배송비 계산 로직
        return ShippingCalculator.calculate(deliveryAddress);
    }
}

🗺️ DDD 아키텍처 레이어

📋 레이어 구조

📁 com.example.shop
├── 📁 interfaces (표현 계층)
│   ├── 📁 rest
│   │   └── ProductController.java
│   └── 📁 dto
│       └── ProductDto.java
├── 📁 application (응용 계층)
│   ├── ProductService.java
│   └── 📁 dto
│       └── CreateProductCommand.java
├── 📁 domain (도메인 계층)
│   ├── 📁 model
│   │   ├── Product.java
│   │   ├── Money.java
│   │   └── ProductRepository.java
│   └── 📁 service
│       └── PricingService.java
└── 📁 infrastructure (인프라 계층)
    ├── 📁 repository
    │   └── JpaProductRepository.java
    └── 📁 config
        └── JpaConfig.java

🎮 Application Service 예시

@Service
@Transactional
public class OrderApplicationService {
    
    private final OrderRepository orderRepository;
    private final ProductRepository productRepository;
    private final PricingService pricingService;
    
    public OrderId createOrder(CreateOrderCommand command) {
        // 1. 고객 검증
        Customer customer = customerRepository.findById(command.getCustomerId())
            .orElseThrow(() -> new CustomerNotFoundException());
        
        // 2. 주문 생성
        Order order = new Order(
            OrderId.generate(),
            command.getCustomerId(),
            command.getDeliveryAddress()
        );
        
        // 3. 상품 추가
        for (OrderItemCommand itemCmd : command.getItems()) {
            Product product = productRepository.findById(itemCmd.getProductId())
                .orElseThrow(() -> new ProductNotFoundException());
            
            if (!product.isAvailable()) {
                throw new ProductNotAvailableException();
            }
            
            Money discountedPrice = pricingService.calculateDiscountedPrice(
                product, customer, DiscountPolicy.standard()
            );
            
            order.addItem(itemCmd.getProductId(), itemCmd.getQuantity(), discountedPrice);
        }
        
        // 4. 주문 저장
        orderRepository.save(order);
        
        // 5. 도메인 이벤트 처리는 별도 핸들러에서
        return order.getId();
    }
}

🔄 도메인 모델링 실습 과정

1단계: 유비쿼터스 언어 정의 📝

비즈니스 팀과 함께 용어 통일

✅ 통일된 용어 예시:
- "상품" → Product
- "재고" → Stock  
- "주문" → Order
- "배송" → Delivery
- "할인" → Discount

❌ 피해야 할 혼용:
- 상품/제품/아이템 혼재
- 주문/오더/요청 혼재

2단계: 핵심 엔티티 식별 🎯

식별자가 중요한 개념들을 찾아보세요

// 쇼핑몰 도메인의 핵심 엔티티들
- Product (상품ID로 식별)
- Customer (고객ID로 식별)  
- Order (주문번호로 식별)
- Category (카테고리ID로 식별)

3단계: 값 객체 추출 💎

엔티티의 속성 중 값 자체가 중요한 것들을 분리

// Money, Address, PhoneNumber, Email 등
// 불변성과 자가 검증 로직 포함

4단계: 애그리게잇 경계 설정 📦

트랜잭션 일관성이 필요한 범위 결정

// Order Aggregate
Order (Root)
├── OrderItem
├── DeliveryInfo  
└── PaymentInfo

// Product Aggregate  
Product (Root)
├── Stock
└── ProductImage

🚀 DDD 구현 시 주의사항

✅ 해야 할 것들

  1. 도메인 로직을 도메인 객체에 위치
    // ✅ 좋은 예
    product.changePrice(newPrice); // Product 내부에서 검증
       
    // ❌ 나쁜 예  
    if (newPrice > 0) product.setPrice(newPrice); // 서비스에서 검증
    
  2. 불변 객체 활용
    // ✅ Value Object는 항상 불변
    public class Money {
        private final BigDecimal amount;
        // setter 없음, 변경 시 새 인스턴스 반환
    }
    
  3. 의미있는 이름 사용
    // ✅ 도메인 언어 반영
    order.confirm();
    product.discontinue();
    customer.upgradeToVip();
    

❌ 하지 말아야 할 것들

  1. 빈약한 도메인 모델
    // ❌ getter/setter만 있는 데이터 클래스
    public class Order {
        private String status;
        public String getStatus() { return status; }
        public void setStatus(String status) { this.status = status; }
    }
    
  2. 애그리게잇 경계 위반
    // ❌ 다른 애그리게잇 직접 수정
    order.getCustomer().changeEmail(newEmail);
       
    // ✅ 각 애그리게잇은 독립적으로 관리
    customerService.changeEmail(customerId, newEmail);
    

🎯 DDD 용어 완전 정리

한글 영어 역할 Java 구현 예시
엔티티 Entity 고유 식별자를 가진 객체 @Entity class Product { @Id private ProductId id; }
값 객체 Value Object 값으로 식별되는 불변 객체 class Money { private final BigDecimal amount; }
애그리게잇 Aggregate 관련 객체들의 일관성 단위 Order + OrderItem 묶음
리포지토리 Repository 도메인 객체 저장소 추상화 interface ProductRepository
도메인 서비스 Domain Service 도메인 로직 서비스 @Service class PricingService
응용 서비스 Application Service 유스케이스 실행 서비스 @Service class OrderService
팩토리 Factory 복잡한 객체 생성 담당 OrderFactory.createFromCart()
도메인 이벤트 Domain Event 도메인에서 발생한 사건 class OrderCreatedEvent

🎉 마무리

DDD는 단순한 설계 패턴이 아니라 비즈니스 중심의 사고방식입니다.

🚦 시작하는 방법

  1. 작은 도메인부터 시작 - 전체를 한번에 바꾸려 하지 마세요
  2. 도메인 전문가와 협업 - 기획자, PM과 긴밀히 소통하세요
  3. 점진적 개선 - 완벽한 모델을 처음부터 만들 필요 없습니다
  4. 테스트 코드 작성 - 도메인 로직의 정확성을 보장하세요

🎯 기대 효과

  • 유지보수성 향상 - 비즈니스 변경이 코드에 자연스럽게 반영
  • 팀 커뮤니케이션 개선 - 개발자와 기획자가 같은 언어로 소통
  • 확장성 확보 - 새로운 기능 추가 시 기존 구조 활용 가능

성공적인 DDD 적용을 위해서는 인내심을 가지고 단계적으로 접근하는 것이 중요합니다! 🚀