공부/System Architecture

Domain Driven Design 개요

흑개1 2025. 6. 7. 20:36

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