Skip to content

도메인 주도 개발 시작하기 정리 #1

@backtony

Description

@backtony

엔티티와 밸류

  • 엔티티 : id 식별자를 갖고 자신의 라이프 사이클을 갖는 객체
  • 밸류 : 엔티티를 구성하는 값 객체로 개념적으로 하나의 값을 표현할 때 사용되는 객체
    • 수정이 불가능한 불변 객체로 구성되며 엔티티에서 교체가 필요한 경우 객체 자체를 교체한다.

엔티티 설계하다 보면 의미가 있는 객체로 묶이는 것들이 있는데 이것들을 값 객체로 분리한다. 밸류는 불변이라 인자가 없는 생성자는 사실상 필요가 없는데 JPA에서는 기술적인 제약으로 기본 생성자가 필요하기 때문에 protected로 기본 생성자를 만들어야한다.

애그리거트

  • 연관된 엔티티와 밸류 객체를 개념적으로 하나로 묶은 것
    • 주문과 관련된 order 엔티티, orderLine 엔티티, orderer 밸류 객체를 주문 애그리거트로 묶을 수 있다.
    • 애그리거트는 경계와 루트를 갖으며 경계는 애그리거트에 포함되는 대상을 결정하는 경계이고 루트는 애그리거트에 포함되는 특정한 객체다. 위에서는 order 엔티티가 주문 애그리거트의 루트에 해당한다.

하나의 애그리거트 안에 묶인 것들은 오로지 애그리거트를 통해서만 수정이 가능하다. 애그리거트 안의 객체를 외부에서 꺼내서 수정하는 경우도 막아야하므로 애그리거트의 메서드로 내부 요소들을 수정할 수 있는 메서드로 제공하고 내부 요소를 불변으로 만들거나, 보통 한 애그리거트에 속하는 모델은 한 패키지에 속하기 때문에 패키지나 protected 접근 제한을 사용하면 외부에서 상태 변경 기능을 방지할 수 있다.

웬만하면 애그리거트 단일 하나 단위로 트랜잭션을 잡는 것이 좋다. 만약 두 가지 애그리거트를 수정해야한다면 이벤트를 발행해서 처리하는 방법과 도메인 서비스에서 둘을 각각 수정하는 방법이 있다. 하나의 애그리거트 루트 도메인 안에서 다른 애그리거트를 수정하는 것은 지양해야 한다. 애그리거트 안에서 다른 애그리거트를 수정하게 되면 의존 결합도가 증가하면서 애그리거트 변경을 어렵게 만들게 된다. 서비스의 성장으로 하위 도메인마다 다른 저장소를 사용하는 경우가 있을 수 있으므로 다른 애그리거트 참조를 위해 JPA같은 단일 기술만을 사용할 수 없을 수 있다. 이를 방지하는 방법은 타 애그리거트 객체를 가지고 있는게 아니라 id만 가지고 있는 것이다. 애그리거트 간의 의존을 제거하고 응집도를 높여주는 이점이 있고 복잡한 로딩방식도 고려하지 않아도 된다. 로딩이 필요한 경우 응용계층에서 id를 이용해 로딩하면 된다. id만 참조하게 되면 응용서비스에서는 실제 객체의 데이터를 조회하기 위해 id를 가지고 여러번 조회하는 경우(N+1)가 발생할 수 있는데 이 경우에는 인프라에서 구성할때 조회전용 쿼리로 조인된 것을 가져오는 쿼리를 별도로 짜는 방식으로 처리할 수 있다.

애그리거트는 단 하나의 리포지토리를 가져야한다. 애그리거트 하위 객체에 대한 리포지토리가 따로 생겨서는 안된다. 시스템이 성숙해갈수록 애그리거트는 대부분 하나의 엔티티로 표현되고 하나 이상의 엔티티를 갖은 애그리거트는 드물어진다.

바운디드 컨텍스트

도메인이 어떤 문맥에서 사용되느냐에 따라 사용에 대한 의미가 달라질 수 있다. 사용자가 회원 도메인에서는 유저가 될 수 있고, 주문 도메인에서는 구매자가 될 수도 있다. 같은 도메인이 문맥마다 다른 의미로 사용될 수 있으므로 이를 구분하는 경계를 만드는 것이 바운디드 컨택스트다. 바운디드 컨텍스트에는 도메인 모델만 포함하는 것이 아니라 도메인 기능을 사용자에게 제공하는데 표현, 응용, 인프라 영역 모두를 포함한다.

콘텍스트 맵

바운디드 컨텍스트 간의 관계를 그려낸 것을 콘텍스트 맵이라고 한다.

인프라스트럭처와 도메인간의 의존성

도메인과 응용 영역에서 인프라스트럭처의 기능을 직접적으로 사용하는 것보다는 인터페이스를 두고 인프라스트럭처에서 구현한 것을 사용하는 방식을 사용하면 시스템이 유연하고 테스트하기 쉬워진다. 하지만 무조건 인프라스트럭처에 대한 의존성을 없앨 필요하는 없다. 예를 들어 스프링에서 사용할 경우 응용 서비스는 트랜잭션 처리를 위해 트랜잭션 애노테이션을 사용하는 것이 편리하다. JPA를 사용할 경우 JPA 엔티티 애노테이션을 도메인 모델 클래스에 사용하는 것이 편리하다.

구현의 편리함은 DIP가 주는 장점(변경 유연함, 테스트가 쉬움)만큼 중요하기 때문에 장점을 해치지 않는 범위에서 응용 영역과 도메인 영역에서 구현 기술에 대한 의존을 가져가는 것도 나쁘지 않다.(trade off) 응용과 도메인이 인프라에 대한 의존을 완전히 갖지 않도록 시도하는 것은 자칫 구현을 더 복잡하고 어렵게 만들 수 있다. 사실상 DIP를 적용하는 이유는 저수준 구현이 변경되더라도 고수준이 영향받지 않도록 하기 위함인데 리포지토리와 도메인 모델의 구현 기술은 거의 바뀌지 않는다.

DIP(의존 관계 역전)
고수준과 저수준이 있을 때, 고수준이 저수준을 의존하여 구현하게 되어있다면 저수준의 기술(로직)을 교체해야할 경우 고수준에서 많은 코드의 변경이 필요할 수 있다. 고수준 입장에서는 저수준의 어떤 기술을 가지고 구현하는지는 관심사가 아니므로 이를 추상화하여 고수준에 인터페이스를 두고 저수준에서 고수준의 인터페이스를 구현하도록 만들면 기존에 저수준에 의존하고 있던 고수준은 저수준이 아니라 고수준의 인터페이스를 사용하게 되므로 저수준을 의존하지 않게 되고, 저수준은 고수준의 인터페이스를 구현하게 된다. 즉, 역으로 저수준이 고수준을 의존하게 된다. 이것을 DIP라고 한다.

DIP를 통해서 테스트의 용이성, 변경에 용이한 유연한 구조의 이점을 가져갈 수 있다.

응용 서비스, 도메인 서비스, 명세

응용 서비스

응용 서비스는 표현 영역과 도메인 영역을 연결하는 매개채 역할을 한다. 디자인 패턴에서 파사드(facade) 같은 역할을 한다. 응용 서비스는 주로 도메인 객체 간의 흐름을 제어하기 때문에 최대한 얇고 단순한 형태를 갖는다.

// 리포지토리에서 애그리거트를 구한다.
val agg = repositoryt.find(id)

// 비어있는지 확인
checkNull(agg)

// 특정 도메인 기능 수행
agg.doFunc(..)

// 리턴
return ..

응용 서비스가 복잡하다면 응용 서비스에서 도메인 로직을 구현하고 있을 가능성이 높다. 도메인 로직을 응용 서비스에서 구현하게 된다면 코드 중복, 로직 분산 등 코드 품질에 안 좋은 영향을 미친다. 회원 도메인은 생각해보면 회원 가입, 탈퇴, 암호 변경, 비밀번호 초기화 등의 기능이 있는데 이것을 구현하는 방식은 두가지가 있다.

  1. 하나의 응용 서비스에서 모두 구현하기
  2. 기능별로 응용 서비스 클래스 따로 구현하기

1번의 경우에는 구현 코드가 하나의 클래스에 위치하니 중복 코드는 함수로 뽑아서 중복을 쉽게 제거할 수 있다. 하지만 클래스 크기가 커지면서 연관성이 적은 코드가 한 클래스에 위치할 가능성이 높아진다. 결국 관련 없는 코드가 뒤섞여 코드를 이해하는 데 방해가 된다.
2번째 방법의 경우 응용 서비스 클래스에는 한개 내지 2~3개의 기능을 구현하는 방법이다. 응용 서비스 클래스의 수가 늘어나지만 코드 품질은 좋아진다. 각 기능마다 동일한 로직을 구현할 경우 여러 클래스에 중복되는 코드가 발생할텐데 이때는 MemberServiceHelper같은 클래스를 만들어 공통 로직을 모아두고 가져가서 사용하는 방식을 채택할 수 있다.

두 방법은 개발자의 선택이지만 대부분의 책에서는 2번 방식을 권장하는 것으로 보인다.

도메인 서비스

도메인 서비스는 엔티티나 값 객체에 정의하기 부자연스러운 로직을 정의하기 위해 사용한다. 즉, 도메인에 로직을 넣을 수 있는 경우 되도록이면 도메인 안쪽으로 로직을 넣어야하고, 부자연스러운 경우에만 도메인 서비스를 사용해야한다. 그렇지 않으면 모든 처리가 도메인 서비스에 정의되는 결과를 낳으면서 데이터와 행위가 단절돼 로직이 흩어지는 현상이 발생한다.

도메인 서비스는 주로 다음과 같은 상황에 사용한다.(도메인 서비스는 대부분 도메인을 인자로 받는다.)

  • 하나의 로직에 여러 애그리거트를 사용해야하는데 특정 애그리거트에 책임을 부여하기 어려운 경우
  • 하나의 로직에 같은 타입의 2가지 객체를 처리해야하는 경우
  • 외부 시스템이나 타 도메인과의 연동 기능이 도메인과 밀접한 경우

첫번째 예시로는 주문 금액 계산 로직으로 할인 금액 계산은 상품, 쿠폰, 회원 등급, 구매 금액 등 다양한 애그리거트들을 함께 묶여서 계산되어야 할 수 있다. 이 경우에는 어디에 책임을 주어야할지 애매한 상황이 발생한다. 이때 calculateService 같은 도메인 서비스를 만들고 필요한 애그리거트를 인자로 받아서 처리할 수 있다.
두번째 예시로는 계좌간의 송금이다. 계좌 A에서 계좌 B로 송금하려면 한쪽에서는 돈을 빼고 한쪽에는 추가해야 한다. 계좌 객체의 함수로 다른 계좌 객체를 받아서 처리할 수도 있지만 보통 실제 로직을 이렇게 단순하기 보다는 로깅을 추가한다던지 같은 부가적인 코드들이 필요한 경우가 많다. 따라서 transferService라는 도메인 서비스를 만들고 두 객체를 인자로 받아서 처리할 수 있다.

도메인 서비스는 응용 서비스에서 사용될수도 있고 애그리거트의 함수 인자로 넣어서 사용할 수도 있다. 후자의 경우 응용서비스에서 도메인 함수를 호출할때 넣어줘야 하는 책임이 있다. 어떤 방식을 선택하든 개발자의 선택이지만 대부분의 책에서는 첫번째 예시의 경우, 도메인의 인자로 도메인 서비스를 넣어주고 두번째 예시의 경우에는 응용서비스에서 도메인 서비스를 호출하는 형식으로 처리한다. 그리고 도메인 서비스는 결국 응용 서비스에서 사용되는 것이라고 봐도 무방하므로 트랜잭션 처리는 응용 서비스에서 처리해야 한다.

특정 기능이 응용 서비스인지 도메인 서비스인지 감을 잡기 어려울때는 해당 로직이 애그리거트의 상태를 변경하거나 애그리거트의 상태 값을 계산하는지 검사해보면 된다. 예를 들어 계좌 이체 로직은 계좌 애그리거트의 상태를 변경한다. 결제 금액 로직은 주문 애그리거트의 주문 금액을 계산한다. 이 두 로직은 각각 애그리거트를 변경하고 값을 계산하는 도메인 로직이다. 도메인 로직이면서 한 애그리거트에 넣기 적합하지 않다면 로직을 도메인 서비스로 구현하면 된다.

외부 시스템이나 타 도메인과의 연동 기능도 도메인 서비스가 될 수 있다. 예를 들어 설문 조사 시스템과 사용자 역할 관리 시스템이 분리되어 있다고 하자. 설문 조사 시스템은 설문 조사를 생성할 때 사용자가 생성 권한을 가진 역할인지 확인하기 위해서 역할 관리 시스템과 연동해야 한다. 시스템 간 연동은 http 호출로 이뤄질 수 있지만 설문 조사 도메인 입장에서는 사용자가 설문 조사 생성 구너한을 가졌는지 확인하는 도메인 로직으로 볼 수 있다.

응용 서비스 {
    
    함수 {
        도메인 서비스 호출(사용자Id)
    }
}

명세

로직에서 도메인 함수만으로 처리가 불가능해서 인프라(repository)를 사용해야하는 경우가 있다. 예를 들어, 닉네임 중복확인을 생각해보면 도메인 자체로는 해결할 수 없고 응용서비스에서 repository를 사용해서 확인해봐야 한다. 응용 서비스는 최대한 간결해야하므로 repository를 도메인 함수의 인자로 받아서 처리를 시도해볼 수 있지만 이 방법은 좋지 않다. 리포지토리는 도메인 설계에 포함되는 점에서 도메인 객체라고 할 수 있지만, 도메인 개념에서 유래한 객체가 아니므로 도메인 모델이 리포지토리를 갖게되면 도메인 모델을 나타내는 데 전념하지 못한다. 따라서 도메인 모델이 리포지토리를 갖는 것은 지양해야 한다. 이를 해결하기 위한 것이 명세이다.

// 또는 validator?
XXXSpecification {

    isSatisfiedBy(도메인 객체) {

    }
}

응용서비스에서는 도메인 객체를 생성하고 위 클래스를 사용해 도메인의 부가 검증을 사용한 이후에 save 하는 등의 작업을 처리할 수 있다. 이외의 다른 방법으로 도메인 모델을 생성하는 과정에서 도메인 모델의 생성에 도메인에 담을 수 없는 조건들이 포함되어 있다면, 별도의 Factory 클래스를 사용해서 처리할 수 있다. 명세라고 했지만 사실상 도메인 서비스라고 봐도 무방하다.

다른 예시로 닉네임 중복건을 생각해보자. 사용자가 처음 가입할 때도 닉네임 중복을 확인해야하고 사용자 정보를 수정할 때도 닉네임 중복을 확인해야 한다. 응용 서비스에서 이 두 로직을 메서드로 각각 구현하고 닉네임 중복 로직을 각 메서드에 담으면 중복 코드가 발생한다. 그리고 사용자 중복 체크가 닉네임 중복이 아니라 다른 것으로 바뀌거나 추가되는 경우 두 메서드 모두 수정해야 한다. 결국 도메인 로직이 응용서비스에 있는 것을 의미한다. 이 경우에는 도메인 서비스 또는 명세로 빼내고 네이밍 자체도 닉네임 중복이 아니라 user.Exists 같은 메서드로 뽑아낼 수 있다. 따라서, 요청을 받았을때 응용 서비스는 도메인 모델 내부에서 처리할 수 있는 부분을 먼저 선 작업 한 뒤에, 해당 도메인을 도메인 서비스에 넘겨서 검증 하는 과정을 거치고 save를 하는 등의 flow로 처리할 수 있다.

응용 서비스 로직의 가장 첫 단에서 validator로 각 로직 케이스마다 별도의 validate 함수를 만들어서 케이스별로 처리한 뒤 도메인 로직을 호출할 수도 있고, 도메인 로직을 처리하고 나온 도메인을 명세에 넘겨서 도메인을 검증할 수도 있다.
개발자의 선택이지만 전자의 경우는 명확하게할 수 있다는 장점이 있지만 케이스별로 로직이 분산될 수도 있다. 후자의 경우는 결국 처리 완료된 도메인을 인자로 받을 것이기 때문에 공통으로 사용할 수 있다는 장점이 있지만 응용 서비스 함수의 가장 첫줄이 아닌 중간에 존재하기 때문에 실수할 수 있는 여지가 있어보인다.

정리

  • 도메인에 관련된 로직은 도메인 안에 넣어서 응집성있게 만들어야 하고, 응용 서비스는 이를 호출하는 역할만 한다.
  • 하나의 도메인 안에 녹이기 어려운 로직들은 도메인 서비스를 만들어서 처리한다.
  • 도메인 서비스는 응용 계층에서 사용하거나 응용계층에서 도메인을 호출할때 도메인 서비스를 인자로 주는 방향으로 진행한다.
  • 도메인 모델 자체적으로 해결하기 어려운 로직은 명세(도메인 로직)로 해결한다.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions