옌의 로그

DDD와 Hexagonal Architecture, 상품관리 시스템에 적용해보기 본문

스터디/기타

DDD와 Hexagonal Architecture, 상품관리 시스템에 적용해보기

dev-yen 2025. 8. 8. 20:25

 

DDD(Domain-Driven Design) ?

DDD는 도메인(업무 규칙과 의미) 에 집중해서 시스템을 설계하자는 접근 방식이다.

단순히 “이런 기능이 필요하다”는 시각이 아니라, 비즈니스의 언어와 규칙을 코드에 녹이는 것이 핵심이다.

 

 

주요 개념 정리

개념설명예시 (상품 관리)

도메인(Domain) 해결하고자 하는 비즈니스 영역 상품 등록, 메타데이터 수집, 발행
엔티티(Entity) 고유 ID가 있는 객체 Product, Copyright
밸류 오브젝트(Value Object) 고유 ID 없이 값으로 의미 ProductName, Price, Period
애그리거트(Aggregate) 엔티티들의 일관성을 묶는 루트 Product가 루트, 내부에 ProductMetadata 포함
도메인 서비스 엔티티에 넣기 애매한 복잡한 비즈니스 규칙 상품 발행 정책, 정산 방식
리포지토리(Repository) 애그리거트를 저장/조회하는 인터페이스 ProductRepository
애플리케이션 서비스 유스케이스 조합 및 트랜잭션 관리 상품 등록, 상품 발행 등

 

더보기

애그리거트(Aggregate) ?

 

“하나의 트랜잭션 경계를 기준으로 뭉쳐서 관리되어야 하는 도메인 객체들의 집합”

 

예시 1: 상품(Product)과 상품 메타데이터(ProductMetadata)

- 상품은 이름, 가격, 메타데이터를 가진다.
- 메타데이터는 외부 API에서 가져온다.
- 상품 저장 시, 메타데이터까지 같이 저장되어야 한다.

이 경우, Product 가 루트(aggregate root)고, ProductMetadata는 내부 구성요소다.

 

 

여기서 “애그리거트”는 다음과 같다:

  • Product + ProductMetadata는 하나의 논리적 단위로 관리되어야 한다.
  • 외부에서는 ProductMetadata를 직접 수정하면 안 되고, 반드시 Product를 통해서만 접근해야 한다.
  • 트랜잭션 경계도 Product 단위로 묶인다.

 

즉, 애그리거트 루트가 도메인 안의 일관성을 책임지는 중심 객체다.

 

 

규칙 3가지

1. 외부에서는 루트만 접근한다 외부에서 내부 구성요소에 직접 접근 금지
2. 루트가 일관성을 보장한다 내부 객체의 상태 변화는 루트를 통해서만 일어난다
3. 트랜잭션 경계는 애그리거트 단위로 하나의 애그리거트는 하나의 트랜잭션 안에서 처리

 


 

잘못된 애그리거트 설계

예를 들어, 아래처럼 Product와 Category 를 같은 애그리거트로 묶는다면?

- 상품 등록 시, 카테고리도 같이 저장하려고 함
- 카테고리는 공용으로 사용되며 여러 상품에서 공유됨

이건 잘못된 설계다.

 

왜?

  • Category는 여러 Product에서 참조하는 별도 애그리거트로 관리되어야 한다.
  • Product 저장할 때 Category까지 트랜잭션으로 묶으면 DB 충돌/락 문제 생김.
  • 한 애그리거트는 되도록 작고 일관성 있는 단위로 유지하는 게 원칙이다.

 

 

예시: 상품 애그리거트 다시 정리

// Aggregate Root
public class Product {
    private Long id;
    private ProductName name;
    private Money price;
    private ProductMetadata metadata;

    public void attachMetadata(ProductMetadata metadata) {
        this.metadata = metadata;
    }

    public boolean isMetadataAttached() {
        return metadata != null;
    }
}
  • 이때 ProductMetadata는 외부에서 직접 수정 못함
  • ProductRepository는 오직 Product만 저장/조회

 

→ 이게 바로 “애그리거트 루트를 통해서만 상태 변화가 일어나도록 하는 것”이다.

 

 

📌 핵심 정리

질문답변
애그리거트는 뭐야? 관련 도메인 객체들을 하나의 트랜잭션으로 묶는 일관성 경계다
루트는 뭐야? 외부에서 접근 가능한 유일한 진입점이다
왜 중요해? 복잡한 도메인 구조를 안정적으로 유지하기 위해
실무에서 언제 쓰지? 예를 들어 상품-메타데이터, 주문-주문내역, 사용자-주소 등 관계가 있는 객체들을 묶고 싶을 때

 

 


 

Hexagonal Architecture ?

Hexagonal Architecture (또는 포트-어댑터 아키텍처)는 애플리케이션 내부와 외부를 명확히 구분하는 설계다.

핵심은 도메인과 애플리케이션 로직은 외부 기술 (DB, API 등)에 전혀 의존하지 않게 설계하는 것이다.

 

 

✅ 왜 중요한가?

  • 도메인 로직이 외부 기술에 종속되지 않아야 테스트가 쉽고 변경에 유연하다.
  • 외부 시스템 교체(Firebase → SNS, RDB → NoSQL 등)에 내부 로직이 영향을 받지 않는다.

 

 

구성 요소 요약

도메인 모델 핵심 로직 상품, 발행 정책
애플리케이션 서비스 유스케이스 조합, 트랜잭션 상품 등록, 메타데이터 요청
포트 (Port) 내부가 사용하는 인터페이스 MetadataClient, ProductRepository
어댑터 (Adapter) 외부 구현체 외부 API 호출, JPA 리포지토리, REST 컨트롤러

 

 

 

예시: 상품 생성 + 외부 메타데이터 요청

상품을 생성하고, 외부 API를 통해 해당 상품의 메타데이터를 불러와 저장하는 흐름이다.

 

1) Domain Layer

Product.java - 애그리거트 루트

public class Product {
    private final Long id;
    private final ProductName name;
    private final Money price;
    private ProductMetadata metadata;

    public Product(Long id, ProductName name, Money price) {
        this.id = id;
        this.name = name;
        this.price = price;
    }

    public void attachMetadata(ProductMetadata metadata) {
        this.metadata = metadata;
    }

    public boolean isMetadataAttached() {
        return metadata != null;
    }
}

 

Value Object 예시 - ProductName, Money

public class ProductName {
    private final String value;

    public ProductName(String value) {
        if (value == null || value.isBlank()) throw new IllegalArgumentException("상품명 필수");
        this.value = value;
    }

    public String getValue() {
        return value;
    }
}

 

 

2) Port 정의 (Interface)

ProductRepository.java

public interface ProductRepository {
    Product save(Product product);
    Optional<Product> findById(Long id);
}

 

MetadataClient.java

public interface MetadataClient {
    ProductMetadata fetchMetadata(Product product);
}

 

 

3) Application Layer

RegisterProductUseCase.java

public interface RegisterProductUseCase {
    Long register(String name, BigDecimal price);
}

 

RegisterProductService.java

@Transactional
public class RegisterProductService implements RegisterProductUseCase {

    private final ProductRepository productRepository;
    private final MetadataClient metadataClient;

    public RegisterProductService(ProductRepository productRepository, MetadataClient metadataClient) {
        this.productRepository = productRepository;
        this.metadataClient = metadataClient;
    }

    @Override
    public Long register(String name, BigDecimal price) {
        Product product = new Product(null, new ProductName(name), new Money(price));
        Product saved = productRepository.save(product);

        ProductMetadata metadata = metadataClient.fetchMetadata(saved);
        saved.attachMetadata(metadata);

        return saved.getId();
    }
}

 

 

4) Adapter Layer

JpaProductRepository.java

@Repository
public class JpaProductRepository implements ProductRepository {

    private final SpringDataJpaRepository jpaRepository;

    public JpaProductRepository(SpringDataJpaRepository jpaRepository) {
        this.jpaRepository = jpaRepository;
    }

    @Override
    public Product save(Product product) {
        return jpaRepository.save(ProductEntity.from(product)).toDomain();
    }

    @Override
    public Optional<Product> findById(Long id) {
        return jpaRepository.findById(id).map(ProductEntity::toDomain);
    }
}

 

ExternalMetadataClient.java

@Component
public class ExternalMetadataClient implements MetadataClient {

    private final WebClient webClient;

    public ExternalMetadataClient(WebClient.Builder builder) {
        this.webClient = builder.baseUrl("https://api.metadata.com").build();
    }

    @Override
    public ProductMetadata fetchMetadata(Product product) {
        return webClient.get()
                .uri("/products/{id}/metadata", product.getId())
                .retrieve()
                .bodyToMono(ProductMetadata.class)
                .block();
    }
}

 

ProductController.java

@RestController
@RequiredArgsConstructor
@RequestMapping("/products")
public class ProductController {

    private final RegisterProductUseCase registerProductUseCase;

    @PostMapping
    public ResponseEntity<Void> register(@RequestBody ProductRequest request) {
        registerProductUseCase.register(request.name(), request.price());
        return ResponseEntity.ok().build();
    }
}

 

 

이런 식으로 나누면 뭐가 좋은데?

  • 도메인(상품)의 책임과 상태 변화가 깔끔하게 관리된다.
  • 외부 API 호출 방식이 바뀌어도 MetadataClient만 바꾸면 된다.
  • 테스트 시에도 실제 API 호출 없이 도메인 테스트가 가능하다.
  • 컨트롤러와 DB는 단순한 어댑터일 뿐, 핵심 로직은 모두 애플리케이션 & 도메인 계층에 집중된다.

 


 

마무리하며

MSA 전환을 위해 DDD, hexagonal architecture에 대해 공부해보았다. 
이전 프로젝트였던 상품관리 시스템이 도메인 중심 설계를 어느정도 적용해놨던 터라 크게 어색하게 느껴지지 않았는데,

헥사고날 아키텍처는 많이 낯설다.. ㅋㅋㅋ 

Comments