🏗️ 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 구현 시 주의사항
✅ 해야 할 것들
-
도메인 로직을 도메인 객체에 위치
// ✅ 좋은 예 product.changePrice(newPrice); // Product 내부에서 검증 // ❌ 나쁜 예 if (newPrice > 0) product.setPrice(newPrice); // 서비스에서 검증
-
불변 객체 활용
// ✅ Value Object는 항상 불변 public class Money { private final BigDecimal amount; // setter 없음, 변경 시 새 인스턴스 반환 }
-
의미있는 이름 사용
// ✅ 도메인 언어 반영 order.confirm(); product.discontinue(); customer.upgradeToVip();
❌ 하지 말아야 할 것들
-
빈약한 도메인 모델
// ❌ getter/setter만 있는 데이터 클래스 public class Order { private String status; public String getStatus() { return status; } public void setStatus(String status) { this.status = status; } }
-
애그리게잇 경계 위반
// ❌ 다른 애그리게잇 직접 수정 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는 단순한 설계 패턴이 아니라 비즈니스 중심의 사고방식입니다.
🚦 시작하는 방법
- 작은 도메인부터 시작 - 전체를 한번에 바꾸려 하지 마세요
- 도메인 전문가와 협업 - 기획자, PM과 긴밀히 소통하세요
- 점진적 개선 - 완벽한 모델을 처음부터 만들 필요 없습니다
- 테스트 코드 작성 - 도메인 로직의 정확성을 보장하세요
🎯 기대 효과
- 유지보수성 향상 - 비즈니스 변경이 코드에 자연스럽게 반영
- 팀 커뮤니케이션 개선 - 개발자와 기획자가 같은 언어로 소통
- 확장성 확보 - 새로운 기능 추가 시 기존 구조 활용 가능
성공적인 DDD 적용을 위해서는 인내심을 가지고 단계적으로 접근하는 것이 중요합니다! 🚀