영감을 (inspire) 주고픈 개발 블로그

DDD? 이게 왜 좋은 건가요? 본문

개발/기획-개발 flow

DDD? 이게 왜 좋은 건가요?

inspire12 2025. 1. 22. 01:47

좋은 코드를 찾아서 

처음 회사에 취업하고 상용 소프트웨어를 만들며 다른 개발자와 협업을 하면서 작성한 코드가 단순히 돌아가는 것만으로는 충분하지 않다 는사실을 느꼈습니다.

작은 변경인데 다른 API가 의존성을 가지고 있어서 장애가 났던 경험, 기획의 변화무쌍한 요구에 서비스가 순환참조에 빠진 경험, 분명 수정을 했는데 다른 쪽에 중복 코드가 있고 그곳 수정이 되지 않아 기능 적용이 안된 경험, 그리고 새로 오신 분들께 인수인계를 하면서 레거시를 설명하기 위한 답답함 등 이러한 문제들을 마주하면서 좋은 소프트웨어를 만들기 위한 원칙들에 대해 고민하게 되었습니다.

 

좋은 소프트웨어를 만들기 위해 여러 가지 원칙을 배우고 적용하려 노력했습니다.

  1. 단순성 (Simplicity)
    • 코드는 간결해야 하며, 불필요한 복잡성을 줄이고 유지보수하기 쉬워야 한다.
    • 한 함수에 20~30라인, 한 라인에 140이하 같은 제약 등등
  2. 확장성 (Scalability)
    • 새로운 요구사항이 추가될 때 최소한의 변경으로 확장 가능해야 한다.
  3. 유지보수성 (Maintainability)
    • 코드가 명확하고 일관성이 있어야 하며, 다른 개발자가 쉽게 이해할 수 있어야 한다.
  4. 재사용성 (Reusability)
    • 중복을 최소화하고, 모듈화된 구조를 통해 여러 곳에서 쉽게 활용할 수 있어야 한다.
  5. 일관성 (Consistency)
    • 코드 스타일, 네이밍, 아키텍처가 일관되게 유지되어야 한다.

이러한 원칙들을 적용하려 했지만, 실제 프로젝트에서는 제약이 많았습니다. 마감 기한을 맞추기 위해 빠른 개발이 요구되었고, 코드 품질보다는 기능 구현이 우선시되었습니다. 점점 코드의 복잡도가 증가하고, 유지보수가 어려워지는 현실을 마주하면서 기획을 쳐내고 타협하게 되었던 것 같습니다. 무엇보다 기획이 말하는 언어와 개발되어 있는 코드가 많이 달랐고 이 얽힌 부분을 푸는데 시간이 많이 들었습니다.

 

도메인 주도 설계(DDD, Domain-Driven Design)

도메인 주도 설계(DDD)는 비즈니스 로직을 서비스 계층에 집중시키는 기존 방식에서 벗어나, 도메인 객체(엔티티, 값 객체, 애그리게이트)를 중심으로 로직을 분산하고 캡슐화하는 접근 방식입니다.

이를 통해 서비스 계층의 역할을 최소화하고, 도메인 모델이 스스로 비즈니스 규칙을 처리하도록 하여 코드의 단순성과 응집도를 높일 수 있습니다.

 

도메인 객체는 Bean이 아니라 순수한 객체(POJO) 입니다.

 

의존성 주입을 통한 관리가 아닌, 메서드 인자를 통해 필요한 객체를 전달받아 사용합니다. 이를 통해 보다 범용적인 재사용이 가능하고, 순환 참조 문제에서 자유로울 수 있습니다.

결과적으로, 도메인 객체는 비즈니스 로직을 책임지고, 서비스 계층은 이를 호출하여 흐름을 조율하는 역할만 수행하도록 설계됩니다.

이러한 접근 방식을 통해 기존 소프트웨어의 문제점을 어떻게 해결할 수 있는지 좀 더 구체적으로 살펴보겠습니다.

 

비대해지는 서비스 계층, 계속 변하는 요구사항... DDD로 해결해볼까?

서비스가 점점 비대해지는 문제

고전적인 Layered Architecture(레이어드 아키텍처)를 사용하면 보통 다음과 같은 계층으로 구성됩니다

  • Controller (입력 처리)
  • Service (비즈니스 로직)
  • Repository (데이터 접근)
초기에는 이런 구조가 깔끔해 보이지만, 비즈니스 요구사항이 증가하고 변경이 잦아질수록 서비스 계층이 점점 비대해지는 문제가 발생합니다.
  • 모든 비즈니스 로직이 하나의 서비스에 집중되면서 코드가 복잡해지고, 유지보수가 어렵습니다.
  • 서비스가 거대해질수록 기능별로 나누기 어렵고, SRP(단일 책임 원칙)이 깨지게 됩니다.
  • 특정 기능을 수정할 때, 다른 기능에 미치는 영향을 파악하기 어려워집니다.

DDD를 적용하면?

DDD는 서비스 계층의 역할을 최소화하고, 비즈니스 로직을 도메인 모델 자체로 옮기는 방법을 제공합니다.
이렇게 하면 도메인 객체가 책임을 가지게 되고, 서비스는 단순히 조율자 역할만 수행합니다.

서비스 간 의존 관계 얽힘 방지 (순환 참조)

"서비스가 너무 커지니까 분리하자!"라고 생각하고 여러 개의 작은 서비스로 나누다 보면 새로운 문제가 발생합니다.

서비스 간 의존 관계가 복잡해지면서 순환 참조(Circular Dependency) 이슈가 발생할 가능성이 커집니다.

 

예시:

  • OrderService가 InventoryService를 호출하고,
  • InventoryService가 다시 PaymentService를 호출하며,
  • PaymentService가 다시 OrderService를 참조하는 상황.

이러한 순환 참조는 결국 서비스 간 강한 결합도를 초래하고, 하나의 서비스 변경이 여러 서비스에 영향을 미치게 만듭니다. 나아가 특정 서비스를 테스트하거나 확장하기 어렵게 합니다.

 

DDD를 적용하면?

  • Bounded Context(경계 설정)을 통해 도메인 간의 명확한 책임과 의존성을 정의할 수 있습니다.
  • 도메인 간의 직접적인 호출을 줄이고, 도메인 이벤트를 활용하여 서비스 간의 결합도를 줄입니다.
  • Port와 Adapter 패턴을 통해 필요한 인터페이스만 정의하고, 인프라와 도메인을 분리할 수 있습니다.

비즈니스 로직을 코드로 명확하게 표현 가능

레이어드 아키텍처에서는 비즈니스 로직이 서비스 계층에 흩어져 있거나, 심지어 컨트롤러나 리포지토리에까지 침범하는 경우가 많습니다. 이러한 구조에서는 코드만 보고는 비즈니스 요구사항을 파악하기 어려워집니다.

 

DDD를 적용하면?

  • 비즈니스 로직을 애그리게이트, 엔티티, 값 객체로 명확하게 모델링할 수 있습니다.
  • 도메인 용어를 코드에 반영하여 비즈니스 요구사항을 더 직관적으로 표현할 수 있습니다.
  • Ubiquitous Language(보편 언어)를 통해 개발자와 비즈니스 담당자가 같은 용어를 사용할 수 있습니다.
    • Ubiquitous Language 는 비즈니스 담당자와 이야기하고 이벤트 스토밍등을 통해 정의하고 공유해야합니다.
    • 비즈니스 로직을 코드로 옮겨놓으면 좀 더 유연하게 요구사항 변경을 처리할 수 있니다.

이렇게 하면 서비스 계층의 복잡한 로직을 도메인 모델로 옮기고, 코드만 보더라도 비즈니스가 어떻게 동작하는지를 쉽게 이해할 수 있습니다.

 

DDD 는 Layered와 어떻게 다른가요? : 각 책임(계층)에 대한 관심사

패키지 구조 예시 

├── application          (애플리케이션 계층)
│   ├── service/usecase  (유스케이스 조합, 도메인 호출)
│   └── port             (입출력 포트 인터페이스 - 다른 모듈에 요청을 보냄, 구현은 infrastructure의 adapter가 맡음)
│
├── domain               (도메인 계층)
│   ├── model            (도메인 모델 - 엔티티, 값 객체, 애그리게이트)
│   │   ├── object       (도메인 객체 - 실제 비즈니스 로직을 가짐, 순수 객체)
│   │   ├── context      (도메인 Context - 도메인 객체 역할이 복잡하고 확장이 필요할 때)
│   │   ├── vo           (값 객체)
│   │   ├── aggregate    (애그리게이트 - 도메인 객체를 묶고 관계를 알려줌)
│   ├── repository       (infra에서 값을 가져와 도메인객체로 바꾸어 주는 리포지토리 인터페이스)
│   ├── mapper           (jpa entity를 domain object로 변환, repository에서 사용)
│   ├── domain service   (도메인 서비스, domain object의 역할 확장이 필요할 때)
│   └── event            (도메인 이벤트)
│
├── infrastructure       (인프라 계층)
│   ├── persistence      (DB 접근 및 리포지토리 구현체, jpa repository)
│   ├── configuration    (설정 및 외부 연결)
│   ├── adapter          (외부 시스템과의 통신)
│   └── event            (kafka 같은 외부 이벤트 처리 관련 인프라)
│
├── presentaion          (프레젠테이션 계층)
│   ├── controller       (REST 컨트롤러, 웹 인터페이스)
│   ├── dto              (데이터 전송 객체)
│   └── mapper           (DTO와 도메인 변환)
│
└── common               (공통 패키지)
    ├── exception        (전역 예외 처리)
    ├── util             (공통 유틸리티)
    ├── constants        (상수 및 설정값)

 

구현 단계에서 햇갈리는 포인트

  • 사용하는 쪽에서 Interface를 만들고 구현은 직접 다루는 곳에서 합니다. Port는 Application 계층에 구현을 담당하는 adapter는 infrasturcture 에 존재합니다.
  • entity 와 jpa entity, repository와 jpa repository는 다릅니다.

아래 "도메인 주도 설계(DDD)는 왜 어렵고 어색하게 느껴질까요?" 에서 디테일한 내용을 설명하고 우선 패키지에 대한 설명을 먼저하겠습니다. 

 

애플리케이션 레이어(Application Layer) - 유스케이스 조율하는 역할

역할:

  • 도메인 모델을 활용하여 애플리케이션의 유스케이스를 구현.
  • 트랜잭션 관리를 담당하고, 도메인 로직을 조합 및 호출.
  • 클라이언트(프론트엔드, API)와 도메인 계층 사이에서 중재 역할.
  • 여러 도메인 객체를 조합하여 고수준의 비즈니스 프로세스를 수행.

구성 요소:

  • 애플리케이션 서비스(Application Service / Usecase): 유스케이스의 흐름을 조율.

관심사 분리의 목적:

  • 비즈니스 로직을 애플리케이션 흐름과 분리.
  • 도메인 계층이 애플리케이션 요구사항 변경으로부터 독립적이도록 보장.

구현시 주의사항

  • 도메인 객체는 Jpa 엔티티를 직접 사용하지 않습니다. Application 계층에서 Repository를 불러서 Domain 객체로 변환하고 다른 Domain 객체로 넘기는 식으로 infrastructure와 분리합니다.
  • @Transactional 은 Domain 객체의 집합인 Aggregate에서 하는게 맞지만 실용적인 관점에서 ApplicationService 에서 처리해도 괜찮습니다.
  • Application Service 혹은 UseCase라는 이름으로 객체를 작성합니다.
@Service
public class OrderApplicationService {
    private final OrderRepository orderRepository;

    public OrderApplicationService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    @Transactional
    public void completeOrder(Long orderId) {
        Order order = orderRepository.findById(orderId);
        order.completeOrder();
        orderRepository.save(order);
    }

    public List<Order> getAllOrders() {
        return orderRepository.findAll();
    }
}

도메인 계층 (Domain Layer) - 비즈니스 규칙에 집중

역할:

  • 비즈니스 규칙과 로직을 캡슐화하여 도메인의 핵심 가치를 유지.
  • 기술적인 세부 사항을 배제하고, 비즈니스 개념을 표현하는 데 초점.
  • 순수한 Java/Kotlin 클래스를 통해 도메인 로직을 구현.

구성 요소:

  • 엔티티(Entity): 고유한 식별자를 가지는 객체 (예: Order, Customer).
  • 값 객체(Value Object): 불변성을 가지며 식별자가 없는 객체 (예: Money, Address).
  • 애그리게이트(Aggregate): 여러 엔티티를 묶어 일관성을 보장하는 단위.
  • 도메인 서비스(Domain Service): 여러 애그리게이트에 걸친 복잡한 비즈니스 로직 처리.
  • 도메인 이벤트(Domain Event): 도메인 상태의 변경 사항을 알리는 객체.

관심사 분리의 목적:

  • 도메인 모델을 기술적 요소로부터 독립적으로 유지.
  • 비즈니스 로직을 서비스 계층, 인프라 계층으로부터 격리.

구현시 주의사항

  • 도메인 객체는 Bean 이 아닌 순수한 객체입니다.
  • 실제 비즈니스 로직을 담고 있습니다.
  • 직접적으로 Jpa entity를 참조하지 않습니다.  
public class Order {
    private List<OrderItem> items;
    private OrderStatus status;

    public void completeOrder() {
        if (this.status != OrderStatus.PENDING) {
            throw new IllegalStateException("Cannot complete an order that is not pending");
        }
        this.status = OrderStatus.COMPLETED;
    }
}

 

public interface OrderRepository {
    Order findById(Long id);
    List<Order> findAll();
    void save(Order order);
    void deleteById(Long id);
}

인프라스트럭처 계층 (Infrastructure Layer) - 기술적 세부 사항 처리

역할:

  • 데이터베이스, 메시징, 파일 시스템, 외부 API와의 상호작용을 처리.
  • 데이터 영속성, 외부 통신, 설정 등 기술적 세부 사항을 캡슐화하여 도메인 계층을 보호.

구성 요소:

  • 리포지토리 구현체(Repository Implementation): JPA, MyBatis 등을 이용한 영속성 처리 및 도메인 객체로 변환
    • 예: OrderRepositoryAdapter가 OrderJpaRepository를 호출하여 도메인 객체로 변환
  • 데이터베이스 접근 (Persistence): JPA, JDBC, NoSQL 등의 기술을 이용한 데이터베이스 작업을 수행.
    • OrderJpaRepository
  • 어댑터(Adapter): 외부 시스템(API, 메시지 브로커)과의 통신 구현.
  • 설정(Configuration): 스프링 빈 설정, 데이터소스 설정 등.

관심사 분리의 목적:

  • 도메인 계층이 기술적인 세부 사항(JPA, 외부 API)으로부터 독립적으로 유지될 수 있도록 함.
  • 테스트 용이성을 높이고, 인프라 변경 시 도메인 로직에 미치는 영향을 최소화.

인프라 계층 - 인터페이스 구현을 위한 어댑터 작성

@Repository
public class OrderRepositoryAdapter implements OrderRepository {
    private final OrderJpaRepository orderJpaRepository;

    public OrderRepositoryAdapter(OrderJpaRepository orderJpaRepository) {
        this.orderJpaRepository = orderJpaRepository;
    }

    @Override
    public Order findById(Long id) {
        return orderJpaRepository.findById(id)
            .map(OrderEntity::toDomain)  // 엔티티를 도메인 객체로 변환
            .orElseThrow(() -> new EntityNotFoundException("Order not found"));
    }

    @Override
    public List<Order> findByCustomerId(Long customerId) {
        return orderJpaRepository.findByCustomerId(customerId)
            .stream()
            .map(OrderEntity::toDomain)
            .collect(Collectors.toList());
    }

    @Override
    public void save(Order order) {
        orderJpaRepository.save(OrderEntity.fromDomain(order));  // 도메인 객체 → 엔티티 변환 후 저장
    }

    @Override
    public void deleteById(Long id) {
        orderJpaRepository.deleteById(id);
    }
}

엔티티와 도메인 객체 변환 (Entity to Domain Mapping)

Mapper 를 두고 변환 할수도있고 Entity 내에 직접 변환 함수를 만들 수 도 있습니다. 아래는 Entity 내부에서 직접 변환한 예제입니다.

@Entity
@Table(name = "orders")
public class OrderEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private Long customerId;
    private String status;

    public static OrderEntity fromDomain(Order order) {
        OrderEntity entity = new OrderEntity();
        entity.id = order.getId();
        entity.customerId = order.getCustomerId();
        entity.status = order.getStatus().name();
        return entity;
    }

    public Order toDomain() {
        return new Order(this.id, this.customerId, OrderStatus.valueOf(this.status));
    }
}

4. Presentation 계층 (Presentation Layer) - 입출력 및 클라이언트 인터페이스

역할:

  • 사용자와의 상호작용을 담당하는 계층(API, Web UI 등).
  • 클라이언트 요청을 받아 애플리케이션 계층에 전달.
  • 도메인 모델을 직접 노출하지 않고, DTO를 사용하여 표현.

구성 요소:

  • 컨트롤러(Controller): HTTP 요청/응답 처리 (예: REST API).
  • DTO(Data Transfer Object): 데이터 전달을 위한 객체.
  • 프레젠테이션(웹, 모바일 UI): 사용자 인터페이스 처리.

관심사 분리의 목적:

  • 비즈니스 로직과 UI를 분리하여 프론트엔드와 백엔드를 독립적으로 유지.
  • 클라이언트와의 상호작용을 도메인 로직으로부터 격리.
@RequiredArgsConstructor
@RestController
@RequestMapping("/orders")
public class OrderController {
    private final OrderApplicationService orderApplicationService;

    @PostMapping("/{orderId}/complete")
    public ResponseEntity<Void> completeOrder(@PathVariable Long orderId) {
        orderApplicationService.completeOrder(orderId);
        return ResponseEntity.ok().build();
    }
}

 

도메인 주도 설계(DDD)는 왜 어렵고 어색하게 느껴질까요?

도메인 주도 설계(DDD)는 처음 접하는 개발자들에게 생소하고 복잡하게 느껴질 수 있습니다.

같은 단어, 다른 의미 (Spring/JPA vs DDD 같은 단어 다른 개념)

DDD에서 사용하는 용어들은 우리가 평소에 Spring / JPA 같은 기술 스택에서 쓰던 단어들과 똑같지만, 의미가 전혀 다릅니다.

아키텍처에서 사용되는 개념과 용어를 비교한 표입니다.

 

용어 Spring/JPA 관점 DDD 관점
Entity DB 테이블 매핑 객체 (CRUD 중심), @Entity 도메인 객체, 비즈니스 규칙을 포함, 순수 객체
Repository JPA 인터페이스, 데이터 접근 계층
extends JpaRepository
애그리게이트의 영속성 관리, 도메인 반환
Service 비즈니스 로직 처리 계층 도메인 객체를 조율하는 애플리케이션 흐름, Usecase
Domain 패키지명, 코드 분리 정도 비즈니스 개념의 핵심 모델링 영역
Aggregate 없음 일관성을 유지하는 도메인 객체의 집합

 

비슷해 보이지만 접근 방식이 완전히 달라서 혼란스럽습니다. JPA에서는 엔티티를 단순히 CRUD로 다루지만, DDD에서는 해당 객체가 비즈니스 규칙을 스스로 지켜야 합니다. 이런 개념적 차이를 제대로 이해하지 않으면 코드가 엉켜버리고, 결국 원래 의도와는 다른 형태로 흘러가게 됩니다.

도메인(Domain) 이라는 낯선 개념

우리가 평소 개발할 때는 기능 중심으로 고민하는 경우가 많아요. 예를 들어, "사용자가 로그인하면 JWT 토큰을 발급해야 해!" 같은 방식이죠. 그런데 DDD에서는 비즈니스의 핵심 개념(도메인)이 뭔지 먼저 정의하고, 그걸 중심으로 시스템을 설계 해야 합니다.

DDD에서 가장 중요한 것은 도메인 전문가(비즈니스 담당자)와의 협업입니다. 개발자의 관점에서 보면 "이걸 왜 이렇게 복잡하게 해?"라는 생각이 들 수도 있어요. 하지만 이 과정이 없으면 나중에 코드가 비즈니스와 동떨어진 방향으로 가게 되고, 유지보수가 어려워지는 상황을 피할 수 없습니다.

경계를 정하는 게 생각보다 어렵다 (Bounded Context)

DDD에서는 시스템을 여러 Bounded Context(경계)로 나누는데, 이게 쉽지 않습니다.

  • "이 기능은 어디까지 이 팀에서 담당해야 하지?"
  • "이 도메인은 다른 도메인과 어떻게 소통해야 하지?"

DDD를 적용하기 위해선 도메인에 대한 명확한 이해와 팀원들간 공유가 필요합니다. 그래서 이벤트 스토밍 같은 활동을 해보는 걸 추천합니다. 

DDD를 적용하다 보면 여러 도메인 객체들이 서로 얽히면서 하나의 서비스가 점점 무거워지는 경우가 많아요.

예를 들어, "주문(Order)"을 처리하려는데, "결제(Payment)"와 "재고(Inventory)"까지 같이 관리하다 보면 결국 모든 걸 처리하는 거대한 서비스가 탄생하게 됩니다. 이런 상황을 방지하려면 도메인 간의 결합도를 낮추고, 독립적인 모듈로 분리해야 합니다.

이를 위해 도메인 서비스, 애그리게이트, 이벤트 발행 등을 활용해야 하지만, 처음엔 어떤 기준으로 나눠야 할지 막막할 수 있어요.

"기술보다 비즈니스" 라는 사고 전환의 어려움

DDD는 기술적인 해결보다는 "비즈니스 가치를 어떻게 코드로 잘 표현할 것인가?"를 중요하게 생각합니다.
하지만 대부분의 개발자는 기술 중심적인 사고에 익숙합니다.

"이걸 JPA로 어떻게 저장하지?"
"트랜잭션을 어디서 관리하지?"

이런 고민이 앞서다 보면, 정작 중요한 비즈니스 로직의 본질을 놓치는 경우가 많습니다. DDD에서는 기술적인 세부 사항을 뒤로 미루고, 먼저 도메인 모델을 제대로 잡는 것이 중요합니다.

애그리게이트와 도메인 서비스의 역할 구분이 어렵다

DDD는 원론적으로는 훌륭하지만, 실제 프로젝트에 적용하려면 여러 현실적인 문제에 부딪힙니다.

DDD에서는 애그리게이트라는 개념을 통해 도메인의 일관성을 유지해야 하는데, 어떤 로직을 애그리게이트 안에 넣고 어떤 걸 도메인 서비스로 분리해야 할지 고민이 많아집니다.

"이 로직은 어디에 둬야 하지?"
"애그리게이트에 너무 많은 책임을 부여하는 게 아닌가?"

이런 질문에 답을 찾기 위해 여러 가지 원칙을 따라야 하지만, 경험이 없으면 쉽지 않죠.

  • 팀원들이 DDD 개념을 모두 이해해야 한다.
  • 짧은 개발 일정 속에서 제대로 된 도메인 모델을 설계하기 어렵다.

이런 이유로 많은 팀들이 결국 "DDD 스타일로 일부 적용"하는 방식으로 타협하기도 합니다.

DDD 에서도 비즈니스 로직이 복잡해지면 도메인들끼리 간섭하게되는 똑같은 문제가 생기지 않나요? (Advanced)

DDD에도 비즈니스가 복잡해지면 각 도메인 및 서비스들이 비대해지고 간섭할 수도 있습니다. 

아래와 같은 방법으로 복잡해진 객체를 나누고 해결할 수 있습니다. 다만 아래 내용까지 공부하면 너무 어려워질 수 있어서 참고만 해주시면 될 것 같습니다.

 

Bounded Context의 재검토 및 분리
  • 현재의 도메인 경계를 다시 평가하고, 각 도메인의 책임을 분리할 필요가 있습니다.
  • 간섭이 심해지는 부분을 독립적인 모듈(또는 컨텍스트)로 분리하고, 컨텍스트 간 통신을 이벤트 또는 메시징을 통해 해결합니다.
  • OrderService → OrderContext, PaymentContext, InventoryContext로 나누고 각 컨텍스트가 독립적으로 동작하도록 리팩토링

도메인 객체와 애그리게이트(Aggregate) 재설계

  • 애그리게이트는 여러 도메인 객체(엔티티, 값 객체)의 집합입니다. 내부에서 여러
  • 애그리게이트의 크기를 조정하고, 한 애그리게이트는 하나의 트랜잭션 단위 라는 원칙을 다시 검토해야 합니다.
  • 여러 애그리게이트가 하나의 트랜잭션에서 조작되고 있다면, 이를 이벤트 기반으로 분리합니다.
  • Order 애그리게이트 내부에서 Payment나 Shipping을 직접 처리하는 것이 아니라, 관련 이벤트(OrderPlacedEvent)를 발생시켜 비동기적으로 처리.

도메인 서비스(Domain Service) 활용

  • 하나의 애그리게이트에 포함하기 어려운 비즈니스 로직을 도메인 서비스로 분리하여 관리합니다.
  • 도메인 서비스는 도메인 객체 간의 협력을 조율하고, 해당 로직이 특정 애그리게이트에 속하지 않도록 합니다.
public class OrderPaymentService {
    private final OrderRepository orderRepository;
    private final PaymentService paymentService;

    public void processPayment(Order order, PaymentDetails paymentDetails) {
        if (order.canBePaid()) {
            paymentService.pay(order.getId(), paymentDetails);
        }
    }
}
 
애플리케이션 서비스로 책임 이동
  • 여러 도메인 객체가 협력해야 하는 복잡한 흐름은 애플리케이션 서비스(Application Service)에서 조율하고, 도메인 객체 간의 직접적인 참조를 최소화합니다.
  • 도메인 객체는 순수한 비즈니스 로직만 유지하고, 조합과 흐름은 서비스에서 처리합니다.
public class OrderApplicationService {
    private final OrderRepository orderRepository;
    private final InventoryService inventoryService;
    private final PaymentService paymentService;

    public void placeOrder(PlaceOrderCommand command) {
        Order order = new Order(command.getProductId(), command.getQuantity());
        orderRepository.save(order);

        inventoryService.reserveStock(order.getProductId(), order.getQuantity());
        paymentService.processPayment(order);
    }
}

이벤트 기반 아키텍처 적용

  • 코드가 너무 복잡해져서 의존하는 객체들이 많아지면 사용합니다. 
  • 여러 도메인 객체가 하나의 서비스에 간섭하지 않도록, 도메인 이벤트(Domain Events)를 활용하여 로직을 분리할 수 있습니다.
  • 서비스 간의 느슨한 결합을 유지하며, 관심 있는 서비스가 이벤트를 구독하도록 합니다.
public class OrderService {
    public void placeOrder(Order order) {
        orderRepository.save(order);
        eventPublisher.publish(new OrderPlacedEvent(order.getId()));
    }
}

이벤트 리스너:

@EventListener
public void handleOrderPlaced(OrderPlacedEvent event) {
    paymentService.process(event.getOrderId());
}

CQRS 패턴 적용

  • 읽기/쓰기 작업이 복잡해질 경우, CQRS(Command Query Responsibility Segregation) 패턴을 적용하여 쓰기 모델과 읽기 모델을 분리할 수 있습니다.
  • 이를 통해 도메인 모델을 단순화하고, 성능을 최적화할 수 있습니다.
  • OrderService → OrderContext, PaymentContext, InventoryContext로 나누고 각 컨텍스트가 독립적으로 동작하도록 리팩토링.

이벤트를 통해 요청을 넘기는 방식을 쓰면 객체간 의존성 없이 요청을 넘길 수 있습니다.

마무리

기존의 레이어드 아키텍처에서는 시간이 지날수록 서비스 계층이 비대해지고, 의존성이 복잡해지는 문제가 발생했습니다. 이를 해결하기 위해 도메인 주도 설계(DDD)를 도입하여 비즈니스 로직을 도메인 객체로 이동시키고 명확한 경계와 책임 분리를 통해 유지보수성을 높이는 실전적인 방법을 정리해봤습니다.

 

DDD를 도입에 어려움이 있을 수 있습니다. 적용 과정에서 도메인 모델링의 어려움, 조직 내 협업, 경계 설정과 같은 도전들이 따릅니다. DDD는 단순한 기술이 아니라, 소프트웨어의 복잡성을 관리하는 철학과 원칙이며, 이를 효과적으로 적용하기 위해서는 비즈니스에 대한 깊은 이해와 지속적인 노력이 필요합니다.

 

저한테도 DDD가 어렵고 엄청 막연한 개념이었습니다. 이번에 SIPE라는 개발동아리에서 스터디 미션을 수행하며, DDD를 공부한 분과 이벤트 스토밍과 페어 프로그래밍을 진행하며 비즈니스를 코드로 옮겨본 경험이 큰 도움이 되었습니다. 덕분에 DDD가 생각보다 더 실용적이고 현실적인 접근 방식이라는 점을 깨닫게 되었습니다. 

 

이 글이 DDD를 이해하고 적용하려는 분들에게 작은 도움이 되었으면 합니다.

질문이 있으시면 댓글로 달아주세요! 감사합니다. 

참고자료

반응형