DDD Start! 도메인 주도 개발 시작하기 읽기

들어가며

  • TDD, 리팩터링 등 OOP를 위한 다양한 패러다임을 학습하고 익히면서 도메인 주도 개발에 대한 욕구가 생겼다.
  • 최범균 개발자님의 “테스트 주도 개발 시작하기”를 잘 읽었었다. 이번 신규 서적인 “도메인 주도 개발 시작하기: DDD 핵심 개념 정리부터 구현까지” 를 읽었다.
  • 아래 정리한 내용은 내가 이해하고 실무에 적용할만한 부분들에 대하여 간단하게 정리하였다.

도메인 주도 개발과 도메인

  • 도메인이란 개발자가 해결해야하는 일종의 문제 영역이다.
  • 도메인은 작은 도메인으로 나뉜다. 쇼핑몰을 만드는 개발자에게 있어서, 주문, 배송, 회원, 할인 등 하위 문제 영역이 존재한다.
  • 도메인 주도 개발은 이러한 문제 영역을 분리하고 문제 영역을 기반으로 구현하는 프로그래밍을 의미한다.

단일책임원칙와 도메인 개발에서의 원칙들

  • 단일책임원칙은 도메인에도 적용된다. 하나의 도메인은 그것에 대하여 홀로 온전하게 책임져야 한다. 예를 들면 주문 기능은 주문 도메인만 가진다.
  • 도메인의 책임의 범위는 넓다. 비지니스 로직, DB와 테이블, 도메인을 이루는 데이터와 객체 관리 등, 그 모든 것을 도메인이 관리해야 한다.
  • 이러한 도메인의 총괄은 애그리거트(루트 엔티티)가 수행한다.
  • 그렇다고 모든 로직을 루트 엔티티가 독점하거나 부담하는 것은 아니다. 루트 엔티티를 이루는 데이터 덩어리로서의 벨류(Value, VO)가, 그 내부에서 로직을 처리하기 위한 기능을 가질 수 있다. 이러한 내부 기능 역시 단일책임을 지켜야 한다. 이때 루트 엔티티는 도메인 내부의 흐름을 통제하는 역할을 한다.
  • Entity와 Value는 분리된다. 엔티티의 중요한 특징은 식별값이 존재하는 것이며 벨류는 이와 달리 단순한 정보의 조합만을 가진다.
  • Value를 적극적으로 활용한다. 기본 타입을 특정 객체로 감싸는 것은 좋은 방법이다. 예를 들면 long money를 Money money로 감쌀 수 있다. 이럴 경우 add(money); exchange(DOLLAR); 등 다양한 기능을 부여할 수 있다. 소박한 기능을 하나의 벨류로 묶고 적절하게 기능을 분배하는 것이 도메인 주도 개발의 중요한 요소이다.
  • 도메인의 이러한 결합도를 높히기 위해서는 객체지향 개발이 요구하는 여러 기준을 따라야 하는데, 예를 들면 setter/getter와 제한하여 불변 객체로 구현해야 한다.

DI의 필요성

  • 도메인 주도 개발에 DI가 적극적으로 활용된다. DI의 가장 중요한 특징은 관심사 분리이다. 도메인의 구현 과정에서 인프라스트럭처를 직접 사용하거나 구현하는 것이 아닌, 인프라스트럭쳐에 요구하는 행위를 인터페이스로 표현해야 한다. 인터페이스를 기반으로 인프라스트럭처를 제어할 경우, 자연스럽게 특정 라이브러리나 코드에 종속되지 않는 개발을 할 수 있다.

  • 예를 들어, 아래와 같이 ABCPrinter을 사용하는 도메인이 있다고 가정하자.

public class OrderService{
    public void printOrders(){
        ABCPrinter printer = new ABCPrinter(); // 특정 라이브러리에 의존하는 코드이다.
        printer.print(args...); // 특정 라이브러리의 특정 메서드를 사용한다. 무슨 코드인지 이해할 수 있으나 해당 코드의 사용 의도는 파악할 수 없다.
    }
}
  • ABCPrinter라는 특정 라이브러리에 의존할 경우 다양한 문제가 발생한다. 예를 들면 해당 라이브러리의 버전 변경이 변경될 경우 도메인 코드에 영향을 미친다. ABCPrinter에서 ZXYPrinter로 변경하고자 할 경우 그 교체가 어렵다. AbcPrinter란 구현된 객체를 가지고 테스트 코드를 작성하는 일도 어렵다.
  • 여러 실용적인 문제가 있지만, 그 무엇보다 더 큰 문제는 해당 코드의 관심사가 무엇인지 명확하게 보여주지 못한다는 것에 있다. 도대체 print(arg...)는 무엇을 위하여 있는 것인가? 주석을 작성하고 그 주석에 따라 알 수밖에 없는 것 아닌가?
@RequiredArgsConstructor
public class OrderService{
    private final OrderPrinter printer;

    public void printOrders(){
        printer.printOrderListForCustomer(args...); // 고객을 위한 프린터를 하는 것이 명확하게 드러난다.
    }
}
  • 위와 같이 코드를 작성할 경우 좀 더 객체지향적이고 도메인주도개발에 가까워 진다. OrderService가 기대하는 OrderPrinter에 대한 업무가 무엇인지 명확해진다. 고객을 위한 주문 리스트를 출력한다는 것을 우리는 알 수 있다. OrderPrinter#printOrderListForCustomer를 구현할 때, ABCPrinter는 해당 업무에 대한 책임을 지키기 위한 방식으로 코드 작성이 제한 및 집중된다.
  • 이처럼 메서드의 명칭이 인터페이스에 요구되는 책임으로서 우리는 이해하기 때문에, 해당 명칭을 정하는 것에 신중을 가해야 한다. 예를 들면 단순하게 Printer로 하는 것보다 OrderPrinter란 명칭이 더 분명하고, 필요하다면 CustomersOrderPrint라는 식의 구체적인 이름을 지정할 수도 있다. 구현 객체 역시 단순하게 OrderPrinterImpl로 짓지 말고 OrderPrintXML 등 좀 더 해당 기능의 역할을 드러내는 방식으로 정해야 한다.

항상 DI를 적용해야 하는 것은 아니다.

  • DI를 통해 인터페이스로 소통하는 것은 결합도를 높히는 효과적인 수단 중 하나이다.
  • 하지만 이는 복잡도를 높히며 코드의 양이나 작성 시간을 늘린다.
  • 더 나아가 인프라스트럭쳐에 명백하게 의존적인 구현체가 존재한다. 이런 경우 굳이 DI로 구현할 필요는 없다.
  • 인터페이스가 과도하게 구현할 경우, 각각의 인터페이스에 대한 구현체가 하나만 있는 상태가 된다.
  • 인터페이스를 사용하지 않더라도 mockito를 적극적으로 사용하면 다양한 조건을 극복하고 테스트를 수행할 수 있다.

애그리거트

애그리거트란

  • 애그리거트란 연관 도메인을 하나로 묶은 것이다.
  • 엔티티와 밸류보다 더 큰 개념으로서 세세한 내용보다 전체적인 그림을 그릴 때 활용한다. 특히 다양한 테이블로 구성된 ERD를 묶는 경계로서 사용한다.
  • 애그리거트는 대체로 하나의 엔티티를 가지며 이를 루트 엔티티라 한다. 루트 엔티티는 애그리거트의 일종의 대리인 역할을 하며, 해당 애그리거트가 요구하는 로직을 수행한다.
  • 애그리거트에 소속된 값들은 동일한 라이프사이클을 가진다. 루트 엔티티에 속한 밸류들은 루트 엔티티와 함께 조회되고 저장되고 변경된다.
  • 애그리거트는 자신의 애그리거트만을 관리한다.
  • 애그리거트의 부분이 변경되면 전체가 변경될 수 있다. 이러한 변경에 대응하여 루트 엔티티가 벨류를 관리해야 한다.
  • 자주 동시에 함께 사용된다고 하여 같은 애그리거트라고 볼 수 없다. 상품의 주문 후 상품 리뷰가 이뤄진다고 하여 주문과 리뷰가 같은 애그리거트는 아니다.

좋은 애그리거트의 특징

  • 애그리거트의 내부를 외부에서 변경할 수 없다. 반드시 애그리거트를 통해 수정해야 한다.
  • 트랜잭션 하나에 애그리거트 하나만 존재하는 것이 이상적이다. 만약 두 개를 동시에 처리해야 한다면 응용 서비스단에서 처리해야 한다. 다른 애그리거트의 문제를 자신의 애그리거트 내부 도메인까지 가져와서는 안된다.

루트 엔티티의 하위 객체에 대한 참조

  • 루트 엔티티는 도메인의 업무를 수행하기 위하여 하위 도메인이나 벨류를 필요로 한다.
  • JPA를 기준으로 이를 참조하는 방법은 크게 두 가지이다 : 객체, pk

객체를 통한 탐색

  • 객체를 필드로 가진 루트 엔티티는 객체를 쉽게 탐색할 수 있다. DB를 쉽게 쿼리한다. 지연로딩의 편의성을 적극적으로 활용할 수 있다.
  • 객체 그래프는 오용의 여지가 있다. 그래프를 통해 탐색한다는 의미는 자신이 해야할 책임을 외부에 전가하는 것이며, 자신의 필드를 드러내 변경에 취약해진다는 것과 같다.
  • 더티체킹으로 인한 데이터가 오염 가능성이 존재한다.
public class Order{
    Member orderer(){        
        return member;
    }
}

public void static main(String[] args){
    Member member = order.orderer();
    member.setSomething(...args); 
}

PK를 통한 탐색

  • JPA의 객체 그래프를 활용한 편의성을 잃어버리지만, 결합도를 낮추고 유연한 구현을 할 수 있다.
  • 예를 들면 리포지토리를 다른 기술로 구현할 수 있다. Order는 MariaDB로 사용하고 OrderLine은 몽고 DB를 사용하는 형태이다.

팩토리를 활용하여 객체 탐색을 우회

  • 참조한 객체를 사용할 때, 해당 객체를 애그리거트 외부로 노출하는 것은 좋은 개발이 아니다.
  • 아래는 특정 주문에 대한 주문자의 유효성을 검증하는 로직이다. 루트 엔티티가 자신의 객체(주문자)를 밖으로 꺼내서 검증한다.
public void order(OrderRequest request){
    Member member = memberRepository.getMember(request.getOrdererId());
    if(member.isBlocked())
        throw new SomeException();
    Order order = request.newOrder(member, ...);
    // 후략...
}
  • orderer에 대한 검증을 외부에 노출하는 것보다, Order의 팩터리 메서드를 사용하여 검증하는 것이 더 나은 방법일 수 있다.
public void order(OrderRequest request){
    Member member = memberRepository.getMember(request.getOrdererId());
    Order order = Order.newOrder(member, request);
}

public class Order{
    public static Order newOrder(Member member, OrderRequest request){
        if(member.isBlocked())
            throw new SomeException();
        // 후략...
    }
}

응용 서비스의 구현

로직은 도메인에

  • 도메인과 관련한 로직은 도메인이 부담한다.
  • 도메인에 로직이 잘 배치될 경우, 응용 서비스는 단순하게 흐름을 제어한다.

서비스의 크기

  • 응용 서비스가 크면 공통 로직을 활용하기 좋다.
  • 응용 서비스가 작으면 관리하기 쉽다.
  • 권장하는 public 메서드의 갯수는 1 - 3개이다.
  • 응용 서비스의 공통로직은 static method를 활용할 수 있다.

표현영역의 역할

  • 사용자나 외부 API와 연결되는 영역
  • 응용 서비스의 데이터를 적절한 형태로 변환하여 전달하는 역할을 수행한다.

데이터 검증은 누구의 책임일까?

  • 사용자나 외부 API에 의존적인 데이터 타입(json, html 등)을 표현 영역이 응용 서비스에 적합한 형태로 데이터를 변환해야 할 책임이 있다.
  • 하지만 변환된 데이터의 유효성 검증의 주체를 결정함에 있어 표현 영역과 응용 서비스 각각은 트레이드 오프가 있다.
  • 응용 서비스가 검증할 경우 해당 서비스를 사용하는 클라이언트의 검증에 대한 부담을 없앤다. 더 나아가 응용 서비스가 검증 수준이 높으면 높을 수록 완성도 있는 도메인을 구현할 수 있다. 기본적으로 응용 서비스가 책임지는 것을 권장한다.
  • 다만, 스프링의 경우 표현영역인 컨트롤러에서의 검증 기능이 사용성에 있어서 무척 탁월하다.
    • BindingResult 등 검증을 위한 훌륭한 기능을 컨트롤러의 인자로 받을 수 있다.
    • 서비스는 예외를 통해서 단 하나의 오류를 전달하지만, 컨트롤러는 BindingResult를 통해 다수의 오류를 전달할 수 있다.
  • 다수의 에러를 수용할 수 있는 적절한 예외나 자료구조를 구현하여, 응용 서비스에 다수의 오류를 수집할 수 있는 기능을 구현할 수 있다.

CQS

  • CQS는 Command Query Separation의 약자로 커맨드와 쿼리를 분리하는 원칙을 의미한다. 이 원칙의 특징은 한 메서드에 하나의 명령만을 수행해야 함을 의미하는데, 쿼리를 할 때는 어떤 데이터 변경 없이 데이터를 리턴해야 하며, 커맨드의 경우 어떤 데이터 호출 없이 단순한 데이터 변경만을 수행해야 한다는 원칙이다. 이를 통해 명령에 대한 신뢰성을 확보하는 기술이다.
  • CQS는 도메인 주도 개발에 적용 가능하다. 자신의 애그리거트에서 루트 엔티티였던 객체가 다른 애그리거트에서 단순한 벨류로 동작할 수 있다. 만약 벨류로 사용될 경우 자기 자신은 절대로 변경되어서는 안된다. 왜냐하면 도메인 주도 개발의 원칙 중 하나로서 객체는 자신의 애그리거트 내부에서만 변경되어야 하기 때문이다.
  • 그러므로 도메인 주도 개발에서 CQS의 원칙을 지킨다면, 특정 루트 엔티티가 벨류로 사용될 경우 엔티티가 아닌 DTO 의 형태로서 값을 전달하며 해당 DTO로 해당 도메인을 변경하는 기능을 넣어서는 안된다. 이를 도식적으로 정리하면 다음과 같다.
    • 객체는 자신의 애그리거트 내부에서만 수정 가능하며 이 경우 커맨드로서 관리한다.
    • 객체가 벨류로서 사용될 경우 변경불가능한 형태의 값으로 제공되어야 하며 이는 쿼리로서 관리된다.
  • CQS로서 도메인을 관리할 때는 별도의 리포지토리에서 관리하는 것이 낫다. 예를 들면 에그리거트로서 리포지토리는 MemberRepository지만, 쿼리로서의 리포지토리는 OrderRepository 혹은 MemberQueryRepository의 형태를 가질 수 있다.
  • JPA를 사용할 경우 엔티티는 대체로 CRUDRepository나 JPARepository를 상속하지만, 쿼리의 경우 JDBCTemplate을 사용하거나 기타 자유로운 형태로 구현할 수 있고 레코드를 호출하는 방식 역시 복잡한 쿼리를 사용할 수 있다.