Domain Driven Design 개요
DDD
DDD는 비즈니스 도메인을 중심으로 소프트웨어를 모델링하는 설계 방법
엔티티, 밸류 객체, 애그리거트, 도메인 서비스 등으로 구조화하여
비즈니스 복잡성을 효과적으로 다루고 유지보수를 쉽게 만듦
1. 도메인(Domain)
- 비즈니스의 문제 영역 (ex: 금융, 쇼핑몰, 물류 등)
2. 서브도메인(Subdomain)
- 도메인을 구성하는 하위 영역 (ex: 결제, 주문, 배송 등)
3. 유비쿼터스 언어(Ubiquitous Language)
- 도메인 전문가와 개발자가 같은 단어를 같은 의미로 사용하는 공통 언어
- 예: "주문 승인", "상품 할인", "잔액 부족"
4. 엔티티(Entity)
- 고유 ID로 식별되는 객체 (ex: 사용자, 주문, 상품 등)
5. 밸류 오브젝트(Value Object)
- 고유 ID 없이 값 자체로 의미가 있는 객체 (ex: 주소, 기간 등)
6. 애그리거트(Aggregate)
- 하나 이상의 엔티티/VO를 묶어 일관성을 관리하는 단위
- 하나의 루트 엔티티(Aggregate Root)만 외부와 통신
7. 도메인 서비스(Domain Service)
- 여러 엔티티/밸류 객체 간 로직이 필요한 경우, 그걸 담당하는 도메인 로직 보관소
8. 도메인 이벤트(Domain Event)
- "주문 생성됨", "결제 완료됨"처럼 도메인 상태가 변했음을 나타내는 이벤트
애그리거트(Aggregate)란?
하나의 트랜잭션 경계이자 일관성을 유지하는 도메인 객체들의 집합
즉, 관련 있는 엔티티와 밸류 오브젝트들을 하나의 그룹으로 묶은 것
이 그룹에는 반드시 하나의 루트 엔티티(Aggregate Root) 가 존재하고,
외부에서는 오직 루트를 통해서만 내부 객체에 접근할 수 있다
public class Order {
private final OrderId id;
private final List<OrderLine> orderLines = new ArrayList<>();
private OrderStatus status;
public void addOrderLine(Product product, int quantity) {
orderLines.add(new OrderLine(product.getId(), quantity));
}
public void complete() {
if (status != OrderStatus.PENDING) {
throw new IllegalStateException("Order already completed");
}
this.status = OrderStatus.COMPLETED;
// 도메인 이벤트 발행 가능
}
}
OrderLine 은 외부에서 직접 추가/삭제 못하고 반드시 Order 메서드를 통해서만 가능!
- Entity: 같은 ID 를 가지면 같은 Entity
- Value Object : (Value Object Instance 1 == Value Object Instance 2) 이면 같은 value object (보통 Java에서는 hashCode(), equals() 로 오버라이딩 됨)
- Aggregate
- 관련 있는 entity와 value object를 하나의 그룹으로 묶은 것으로 항상 Valid 한 상태를 가져야 함. Transactional boundary 이기도 함, 즉 Aggregate 내부에서 일어나는 모든 변경은 한 트랜잭션 내에서 일어나야 하며 성공하면 커밋되고 실패하면 롤백해야 함.
- 비즈니스 적으로 일관성을 보장해야 하는 최소 단위
Hexagonal Architecture
"Hexagonal Architecture" (헥사고날 아키텍처)는 애플리케이션의 핵심 도메인 로직을 외부 환경(웹, DB, 메시지 큐 등)으로부터 완전히 분리하기 위한 소프트웨어 아키텍처 패턴.
도메인 로직을 중심에 두고, 외부 시스템과의 연결을 포트(Ports)와 어댑터(Adapters)로 추상화해서 연결하는 구조
[ Adapter: REST API ]
|
[ Port: Inbound (Input) ]
|
[ Application ] ← 핵심 비즈니스 로직 (도메인)
|
[ Port: Outbound (Output) ]
|
[ Adapter: DB / Kafka / 외부 API ]
- 도메인: 비즈니스 규칙이 존재하는 핵심 영역. Aggregate, Entities, Value Object 가 포함됨.
- 포트: 핵심 로직과 외부를 연결하는 인터페이스
- Inbound Port: 애플리케이션에 명령을 전달 (Controller → UseCase)
- Outbound Port: 애플리케이션이 외부에 요청을 보냄 (Repository, 외부 API 호출 등)
- 어댑터: 실제로 포트를 구현 해서 외부 시스템과 연결하는 부분
- Inbound Adapter: REST API Controller, CLI, Kafka Consumer 등
- Outbound Adapter: JPA Repository, Kafka Producer, 외부 REST Client 등
Driving Side 의 Adapter 는 Port 를 use 하고, Driven Side의 Adapter 는 Port를 implement 한다
즉 포트란 인터페이스이고, 어댑터 는 클라이언트에게 제공해야 할 인터페이스를 따르면서도 내부 구현은 서버의 인터페이스로 위임하는 것
public class TotalRentalServiceImpl implements TotalRentalService {
private final CustomerRepository customerRepository;
private final RentalRepository rentalRepository;
private final InventoryService inventoryService;
private final RentalHistoryRepository rentalHistoryRepository;
public TotalRentalServiceImpl(CustomerRepository customerRepository,
RentalRepository rentalRepository,
InventoryService inventoryService,
RentalHistoryRepository rentalHistoryRepository) {
this.customerRepository = customerRepository;
this.rentalRepository = rentalRepository;
this.inventoryService = inventoryService;
this.rentalHistoryRepository = rentalHistoryRepository;
}
@Override
public RentalHistory rent(RentalTarget target) {
Customer borrower = customerRepository.find(target.customerId())
.orElseThrow(() -> new NotFoundException(target.customerId()));
Rental rental = rentalRepository.find(target.rentalId())
.orElseThrow(() -> new NotFoundException(target.rentalId()));
Item rentedItem = inventoryService.rent(rental, borrower)
.orElseThrow(AlreadyRentedException::new);
RentalHistory history = RentalHistory.of(UUID.randomUUID().toString(),
RentalSpec.of(borrower, rental),
rentedItem);
rentalHistoryRepository.save(history);
return history;
}
}
주 포트 / 주 어댑터
컨트롤러는 TotalRentalService 의 rent() 를 사용하고 HTTP 를 통한 인터페이스를 클라이언트에게 제공하여 클라이언트가 TotalRentalService 를 이용할 수 있게 하고 있는 역할을 하는 어댑터임
TotalRentalService 는 인터페이스를 제공하므로 포트임 외부에서 요청해야 동작하는 포트와 어댑터를 주요소(primary) 라고 함
부 포트 / 부 어댑터
Repository는 TotalRentalService 가 사용할 인터페이스를 제공하고 있기 때문에 포트
RedisRepository 는 Repository 의 인터페이스를 따르면서 내부적으로 Redis 프로토콜과 연결되는 어댑터
애플리케이션이 호출하면 동작하는 포트와 어댑터를 부요소(secondary) 라고 함
Primary의 경우, 만약 HTTP 외에 RPC 를 제공한다고 해도 RPC 어댑터만 만들고, 포트는 하나의 포트로 사용하면 됨..
Secondary의 경우도, MySQL 의 어댑터를 포트의 인터페이스에 준하는 Redis 어댑터로 교체해서 사용할 수 있다 ..
주의할 점
어댑터를 애플리케이션에 맞출 것. (어댑터 DTO를 애플리케이션 내부로 넘기지 말것)
- Primary
어댑터에서 도메인 모델로 변환한다. 어댑터 DTO 를 애플리케이션 도메인 모델로 전환하여, 어댑터가 전환되더라도 애플리케이션 내부 코드는 변하지 않도록 함
public class RentalController {
private final TotalRentalService totalRentalService;
// ...
public Response<RentalHistoryView> rent(@RequestBody RentParam param) {
// ...
totalRentalService.rent(param); // 애플리케이션이 어댑터를 알게 되는 상황
// ...
}
}
이렇게 어댑터 DTO 가 내부 애플리케이션 로직에 스며들게 되면 안됨
public class RentalController {
private final TotalRentalService totalRentalService;
// ...
public Response<RentalHistoryView> rent(@RequestBody RentParam param) {
// ...
totalRentalService.rent(param.toRentTarget());
// ...
}
}
위와 같이 도메인 모델로 변환하여 어댑터 변경에도 애플리케이션은 영향을 받지 않도록 함
- Secondary
HttpInventoryService 가 연동한 서비스의 DTO 를 그대로 반환한다면, API Spec 이 변경될 경우 새로 생성한 어댑터에서는 StoreItem 을 사용할 수 없다
public class HttpInventoryService implements InventoryService {
// ...
@Override
public Optional<StoredItem> rent(Rental rental, Customer borrower) {
// ... HTTP 통신
// ... JSON 역직렬화
return Optional.of(storedItem);
}
}
이 역시 부어댑터가 부포트를 준수하도록 한다
public class HttpInventoryService implements InventoryService {
// ...
@Override
public Optional<Item> rent(Rental rental, Customer borrower) {
// ... HTTP 통신
// ... JSON 역직렬화
return Optional.of(storedItem.toItem());
}
}
Item 이라는 객체를 리턴하는 부포트를 준수하도록 하여 변경에 대응할 수 있게 함
즉 인터페이스 자체를 특정 Adapter 에 치우치게 설계하지 말고 도메인 관점에서 도메인이 필요로 하는 인터페이스를 설계하자
참고:
https://www.youtube.com/watch?v=8Z5IAkWcnIw
https://www.youtube.com/watch?v=bDWApqAUjEI
https://engineering.linecorp.com/ko/blog/port-and-adapter-architecture