[DDD] 도메인 주도 설계 - 09. 암시적인 개념을 명확하게

11 분 소요

도메인주도 설계(에릭 에반스) 개발도서 관련 : 더 심층적인 통찰력을 얻기 위한 리팩터링 중 하나인 “암시적인 개념을 명확하게”하는 방법을 알아보자.
소프트웨어의 복잡성을 다루는 지혜

결론

9장을 통해 학습할 수 있는 것은 “심층 모델을 만들기 위한 개념을 발견하는 방법”이다.


들어가며,

심층 모델의 중요성

심층 모델이 중요한 이유는

  1. 사용자의 행위
  2. 해결하고자 하는 문제
  3. 문제 해결 방법

등에 대한 본질적인 지식을 표현하는 중심 개념추상화가 담겨 있기 때문이다.

=> 즉, “심층 모델”에는 문제를 해결하기 위한 핵심 개념들이 들어 있다.

그렇다면, 이러한 심층 모델을 얻으려면 어떻게 해야할까?

심층 모델을 얻으려면?

이러한 심층 모델을 얻기 위해 선행 되어야 하는 것은 도메인의 본질적인 개념을 모델 내에 표현하는 것이다.

=> 다시 말해, 일단 중요한 개념이 무엇인지 부터 파악을 하고 이를 모델과 설계 내에 표현해야 한다.

그렇다면, 중요한 개념이 무엇인지 어떻게 알 수 있을까?


개념 파헤치기

개발자들은 겉으로는 들어나지 않는 암시적인 개념을 발견하기 위해 노력해야 한다.

암시적인 개념을 발견하기 위한 방법으로는 아래와 같은 순서로 하나씩 알아보도록 하겠다.

  1. 언어에 귀 기울여라
  2. 어색한 부분을 조사하라
  3. 모순점에 대해 깊이 고민하라
  4. 서적을 참고하라
  5. 시도하고 또 시도하라

1. 언어에 귀 기울여라

도메인 전문가가 사용하는 언어에 귀를 기울여야 한다.

  • 복잡하게 뒤얽힌 개념들을 간결하게 표현하는 용어가 있는가?
  • 우리가 선택한 단어를 적절하게 고쳐주는가?
  • 특정 문구를 이야기할 때 도메인 전문가의 얼굴에서 곤혹스러운 표정이 사라지는가?

이 모두가 모델에 기여하는 개념의 실마리에 해당한다.

새로운 단어를 듣게 되면 명료하고 유용한 개념을 찾기 위해 대화와 지식탐구를 진행해야 한다.

사용자나 도메인 전문가가 설계상의 어디에도 표현돼 있지 않은 어휘를 사용하고 있다면 그것은 곧 경고 신호다.
또한, 개발자와 도메인 전문가가 설계상에 표현돼 있지 않은 어휘를 사용하고 있다면 그것은 더욱 더 위험한 경고다.

아니면 이것은 하나의 기회이기도 하다. 어떤 용어가 설계에 누락돼 있다면 누락된 용어를 설계에 포함시켜 모델과 설계를 향상시키는 기회가 될 수 있다.

=> 새로운 언어가 사용되는지 대화에 귀를 기울이고, 만약 존재한다면 이는 위기이자 기회로 받아들이면 된다.

2. 어색한 부분을 조사하라

설계에서 가장 어색한 부분을 조사해야 한다.

예를들면, 설명하기 힘들 만큼 복잡한 작업을 수행하는 프로시저나 새로운 요구사항 탓에 복잡성이 증가하는 부분이 어색한 부분에 해당한다.

  • 가끔 누락된 개념이 존재하는지 조차 인식을 못한다거나
  • 객체에 할당된 일부 책임이 어색하거나
  • 누락됐다는 사실을 깨닫아도 모델과 관련된 문제를 어떻게 풀어야 할지 감이 잡히지 않을 수도 있다.

이제 적극적으로 나서서 도메인 전문가가 그러한 개념을 발견할 수 있게 해야 한다.

운이 좋으면, 도메인 전문가가 다양한 아이디어를 고안해서 여러 가지 모델을 시도할 수 있고,
운이 나쁘다면, 동료 개발자와 함께 직접 아이디어를 제안하고 도메인 전문가의 얼굴을 예의주시하며 아이디어를 검증해야 할 것이다.

책의 예제에서는 개발자가 필요한 새로운 개념들을 찾아내고자 문제 도메인을 직접 파해쳐야만 했다.
특정 모델이 어색하다는 사실을 알고 있었기에 깊이 있는 해답을 발견하고자 헌신적인 노력을 기울였다.

다행히 개발자에게는 지적이고 의욕적인 은행 전문가가 곁에 있었다. 소극적인 전문가를 만났다면 개발자는 잘못된 출발점에서 일을 시작했을 것이며, 브레인스토밍을 위해 전문가가 아닌 다른 개발자에게 좀더 의지해야 할 것이다.

더디긴 해도 어색한 부분을 파헤치는게 불가능하지는 않을 것이다.

=> 어색한 부분이 있다면 도메인 전문가에게 도움을 요청한다. 그럴 수 없다면 개발자들 간의 브레인스토밍을 통해서 극복한다.


3. 모순점에 대해 깊이 고민하라

도메인 전문가는 자신의 경험과 필요에 따라 각기 다른 방식으로 사물을 바라본다.

심지어 동일한 전문가조차도 논리적으로 모순되는 정보를 제공하기도 한다.

우리가 프로그램 요구사항을 파해칠 때 마주하는 이와 같은 모순은 더 심층적인 모델에 이르는 중요한 단서로 활용될 수 있다.

  • 어떤 모순은 용어를 다르게 사용하는 데서 발생하며,
  • 어떤 모순은 도메인을 잘못 이해 하는데서 발생한다.
  • 두 경우 외에도 도메인 전문가가 서로 모순되는 사실을 진술하는 경우도 있다.

예를들어, 지구는 정지해 있다는 주장과 지구는 태양 주위를 빠르게 공전한다는 두 가지 주장간의 모순이 존재하는데,
이러한 모순이 조화되는 순간 우주의 원리와 관련된 심오한 뭔가를 밝혀낼 수 있게 된다.

일반적으로 모순은 중요하지 않게 여겨질 수 있지만 종종 심오한 사고를 통해 겉으로 들어나지 않는 도메인 문제를 해결하는 더 심층적인 통찰력에 이를 수도 있다.

모든 모순을 해소하는 것은 현실적이지도, 바람직하지도 않지만 모순되는 양측의 주장을 그대로 받아들이고 구현하는 과정에서 숨겨진 사실들을 밝혀내는 계기가 될 수도 있다.

=> 모순을 해결하고자 사고하는 것은 도메인에 대한 심층적인 통찰력을 얻게 해준다.

4. 서적을 참고하라

모델의 개념을 조사하기 위해 근본 개념일반적인 통념을 설명하는 책을 참고할 수 있다.

서적을 통해 일관성 있고 사려 깊은 관점에서 작업을 시작할 수 있게 된다.

예제

투자 관리 애플리케이션에 대한 시나리오에서 이자 계산기에 대한 모델을 다루기 어려워졌으나 그렇다고 도메인 전문가가 개발자와 소통할 수 있는 상황이 아니였다.

이때 개발자는 겉으로 드러나지 않는 누락된 개념을 조사하기 위해 도메인 전문가에게 의지할 수 없기에 서점으로 발걸음을 옮겼다.

회계 관련 입문서를 찾았고 그 안에서 잘 정의된 개념들의 전체적인 체계를 발견할 수 있었다.

책을 통해 누락된 개념을 찾을 수 있었고 이를 UBIQUITOUS LANGUAGE에 추가할 수 있었다.

이후 도메인 전문가와 의논할 수 있는 자리가 마련되었을 때 도메인 전문가는 결과물에 놀라움을 느꼈고, 이는 그동안 도메인 전문가의 업무에 개발자가 조금이나마 관심을 기울인것이 처음이었기 때문입니다.

이후 개발자는 책에서 습득한 지식을 토대로 더 나은 질문을 할 수 있었고 도메인 전문가도 적극적으로 관심을 기울이기 시작했다.

도메인 관련 서적을 읽는 것 외에도 해당 도메인을 경험한 다른 소프트웨어 전문가의 책을 읽는 것도 하나의 방법이 될 수 있다.

책을 읽는다고 해서 그대로 이용할 수 있는 해법을 얻는 것은 아니지만, 해당 분야를 경험한 사람의 정제된 경험을 통해 개발자가 직접 시도해볼 만한 출발점 정도는 제시할 수 있을 것이다.

덕분에 개발자는 바퀴를 다시 발명하는 수고를 아낄 수 있다.

=> 서적을 통한 간접 경험으로 효율적이고 전문적인 개념을 확립하자.

5. 시도하고 또 시도하라

모델에서 명확하고 유용한 지식을 발견하기까지 수많은 시행착오를 반복해야 한다.

새로운 경험의 축적지식탐구를 거쳐 더 훌륭한 아이디어가 떠오르면 기존의 결과를 바꾸게 될 일이 생긴다. 모델러/설계자는 자신의 아이디어에 집착해서는 안 된다.

이러한 기존 결과의 변경는 모델에 좀더 심층적인 통찰력을 반영했음을 의미한다.

  • 리팩터링하기는 더 유연해지고,
  • 변경하기는 더 수월해지며,
  • 수정사항은 바로 수정할 수 있게 된다.

실험은 유용한 것이 무엇이고 유용하지 않은 것이 무엇인지를 배우는 방법이다.

설계 과정에서 실수를 피하려고 발버둥치면 더 적은 경험을 바탕으로 설계한 탓에 품질이 더 낮은 결과물을 얻게 될 것이다.

심지어 신속한 실험을 여러 번 거친 설계보다 오히려 더 오래 걸릴 수도 있다.

=> 기존 확립된 결과에 집착하지 말고 여러번의 시도와 실패를 통해 더 심층적인 통찰력을 얻도록 하자.


다소 불명확한 개념을 모델링하는 법

객체지향 설계 입문서에는 개념을 찾기 위해 “명사와 동사”를 조사해보라고 설명하지만 그 것으로도 표현되지 않는 중요한 개념들도 모델 내에 명시적으로 표현되어야 한다.

객체지향 설계를 시작할 때 명확한 개념으로 인식하지 못했던 설계를 더욱 명확하게 만들어줄 세 가지 범주에 대해 살펴보도록 하자.

명시적인 제약조건

제약조건(constraint)은 암시적인 상태로 존재하는데 이를 명시적으로 표현하면 설계를 대폭 개선할 수 있다.

예제: Bucket

image

  • 로직내 제약조건이 포함되어 있는 경우
    class Bucket {
      private float capacity; // 용량(= 담을 수 있는 물의 양)
      private float contents; // 내용물(= 현재 담겨있는 물의 양)
    
      public void pourIn(float addedVolume) {
          if (contents + addedVolume > capacity) { // 제약조건이 암시적으로 표현됨
              contents = capacity;
          } else {
              contents = contents + addedVolume;
          }
      }
    }
    
  • 제약조건을 별도의 메서드로 분리한 경우
    class Bucket {
      private float capacity;
      private float contents;
    
      public void pourIn(float addedVolume) {
          float volumePresent = contents + addedVolume;
          contents = constrainedToCapacity(volumePresent);
      }
    
      // 제약조건 별도 메서드로 명시
      private float constrainedToCapacity(float volumePlacedln) {
          if (volumePlacedln > capacity) return capacity;
          return volumePlacedln;
      }
    }
    

위 두가지 예제를 보았을때 제약조건이 간단한 경우 첫 번째 예제와 같이 로직내에 제약조건을 명시하여도 충분히 파악할 수 있다.

그러나 제약조건이 복잡해지면 제약조건을 표현한 부분을 파악하기 어려워지게 된다.

그래서 두 번째 예제와 같이 제약조건을 별도의 메서드로 분리하고 제약조건의 의미를 명확하게 표현 할 수 있는 이름을 짓게 되면,

  • 제약과 모델과의 관계가 좀더 명확하게 표현
  • 설계 내에 제약조건을 명확하게 표현
  • 부여된 이름을 사용해서 제약조건에 대한 토의 가능
  • 설계에 더 복잡한 제약조건을 수용할 수 있는 여지 제공

위와 같은 이점을 얻을 수 있습니다.

이를통해 제약조건의 복잡도가 높아져 메서드가 비대해진다 해도 호출 메서드는 단순한 상태를 유지할 수 있어 본연의 작업에만 집중할 수 있게 된다.(단일 책임 원칙 유지)

그치만 특정 상황에서는 제약조건으로 인해 제약조건을 포함한 객체를 해칠 염려가 있는데, 그러한 상황은 아래와 같다.

  1. 제약조건 내에 객체의 정의에 적합하지 않은 데이터가 필요한 경우
  2. 관련 규칙이 여러 객체에 걸쳐 나타나거나 다른 계층의 객체 간에 중복 또는 상속 관계를 강요하는 경우
  3. 설계 또는 요구사항에 관한 논의는 제약조건에 초점을 두고 이루어지지만 정작 구현은 절차적인 코드에 묻혀 명시적으로 표현되지 않는 경우

이렇듯 제약조건이 객체가 담당하는 기본 책임을 모호하게 만들거나, 도메인 관련 대화에서는 중요하게 다루어 지지만 모델 내에 명확하게 표현돼 있지 않다면 제약사항을 명시적인 객체로 분리할 수 있다.

=> 암시적으로 존재하는 제약조건을 명시적으로 드러내자. 제약조건을 명시화 할 때 객체를 해칠 우려가 있는 상황을 주의해서 작업하자.

예제: 초과 예약 정책

1장에서 다룬 운송 수단이 처리할 수 있는 양보다 10% 많은 양의 화물을 예약받는 일반적인 해운 업무의 관행을 살펴보면,

image

제약조건을 표현하는 새로운 클래스를 추가해서 Voyage와 Cargo 간의 관계에 대한 제약조건이 다이어그램과 코드에 모두 명시적으로 표현됐다.

도메인 객체로서의 프로세스

절차(procedure)를 모델의 주요한 측면으로 삼고 싶지 않다.

객체는 절차를 캡슐화해서 절차 대신 객체의 목표나 의도에 관해 생각하게 만들어야 한다.

여기서 이야기하고 싶은 대상은 도메인에 존재하는 프로세스(process)이며, 우리는 모델 내에 프로세스를 표현해야 하는데 프로세스가 나타나면 객체를 어색하게 설계하는 경향이 있다.

프로세스를 수행하는 방법이 한 가지 이상일 때 취할 수 있는 접근법 중 하나는 알고리즘 자체 또는 일부를 하나의 객체로 만드는 것이다.

어떤 프로세스를 선택할 것인가는 곧 어떤 객체를 선택할 것인가가 되고, 각 객체는 각기 다른 STRATEGY를 표현한다.

명시적으로 표현해야 할 프로세스와 숨겨야 할 프로세스를 구분하는 비결은 간단하다.

  • 이것이 바로 도메인 전문가가 이야기하고 있는 프로세스인가? (명시)
  • 아니면 단순히 컴퓨터 프로그램상의 메커니즘의 일부일 뿐인가? (숨김)

제약조건프로세스는 객체지향 언어로 프로그래밍할 때 확연히 떠오르지 않는 두 가지 넓은 범주의 모델 개념이지만, 모델의 요소로 간주하면 설계를 매우 명확하게 만들 수 있다.

=> 제약조건과 프로세스를 모델의 요소로 간주하고 명시하자.


SPECIFICATION (명세)

image

SPECIFICATION은 다른 객체에 대한 제약조건을 기술하며, 제약조건은 존재할 수도 존재하지 않을 수도 있다.

SPECIFICATION은 다양한 용도로 사용할 수 있지만 가장 기본적인 개념은 다른 객체가 SPECIFICATION에 명시된 기준을 만족하는지 검사할 수 있다는 것이다.

SPECIFICATION은 규칙이 복잡한 경우 단순한 명세들을 결합하여 개념을 확장할 수 있고, 이것은 단순한 모델에서 더 복잡한 모델로 나아갈 수 있는 방향을 제시한다.

SPECIFICATION을 이용하면 규칙을 도메인 계층에 유지할 수 있고 아울러 완전한 객체를 사용하여 규칙을 표현하므로 설계가 모델을 더욱 명확하게 반영할 수 있다.

SPECIFICATION의 적용과 구현

SPECIFICATION의 주된 가치는 매우 상이해 보이는 애플리케이션 기능을 하나로 통합해주는 것이다.

객체의 상태를 다음과 같이 세 가지 목적으로 명시하고 싶을 때 사용한다.

  1. 검증(validation) : 객체가 어떤 요건을 충족시키거나 특정 목적으로 사용할 수 있는지 가늠하고자 객체를 검증할 때
  2. 선택(selection) : 컬렉션 내의 객체를 선택할 때(특정 조건을 만족하는 목록 조회)
  3. 요청 구축(building to order) : 특정한 요구사항을 만족하는 새로운 객체의 생성을 명시할 때

SPECIFICATION은 위 세 가지 용도에 대해 개념적으로 통일성을 부여하고 일관된 모델을 사용할 수 있도록 만들어주는 패턴이라고 보면 된다.

1. 검증

SPECIFICATION의 가장 단순한 용도는 검증(validation) 이다.

이는 SPECIFICATION의 개념을 가장 직관적으로 설명해주는 방식이기도 하다.

image

Invoice(송장)을 만들기 위해 Invoice Specification(송장 명세)의 boolean isSatisfiedBy(invoice) 검증 메서드를 통과해야 한다.

Invoice Specification으로는 Delinquent Invoice Specification, Big Invoice Specification과 같은 특화된 조건의 명세들이 존재할 수 있다.

(cc. Delinquent : 체납, grace period : 유예기2)

 class DelinquentInvoiceSpecification extends InvoiceSpecification {
    private Date currentDate;
    // 인스턴스는 한 날짜를 대상으로 사용된 후 폐기된다.

    public DelinquentInvoiceSpecification(Date currentDate) {
        this.currentDate = currentDate;
    }

    // 체납된 송장인지 여부를 검증해 줌.
    public boolean isSatisfiedBy(Invoice candidate) {
        int gracePeriod = candidate.customer().getPaymentGracePeriod();
        Date firmDeadline = DateUtility.addDaysToDate(candidate.dueDate(), gracePeriod);
        return currentDate.after(firmDeadline);
    }
}

고객에게 체납된 청구서가 있는 경우 특별한 처리를 하고 싶다고 하면 아래와 같이 클라이언트 소스를 작성할 수 있다.

class Client {
    // 체납 계좌인지 여부 확인
    public boolean accountIsDelinquent(Customer customer) {
        // 고객으로부터 송장 목록을 추출
        Iterator it = customer.getInvoices().iterator();
        
        // 체납 명세 생성
        Date today = new Date();
        Specification delinquentSpec = new DelinquentInvoiceSpecification(today);
        
        while (it.hasNext()) {
            Invoice candidate = (Invoice) it.next();
            
            // 송장 중 하나라도 체납된 경우 true 리턴
            if (delinquentSpec.isSatisfiedBy(candidate)) return true;
        }
        return false;
    }
}

SPECIFICATION을 이용하여 특정 조건에 해당하는지 여부를 검증하는 방법에 대해 살펴보았다.

2. 선택(질의)

SPECIFICATION을 사용하여 특정 조건을 기반으로 객체 컬렉션의 일부를 선택할 수도 있다.

체납된 송장을 보유한 모든 고객 목록을 나열하는 요구사항이 있다고 해보자.

public class DelinquentInvoiceSpecification {
    public String asSQL() {
        return
                "SELECT * FROM INVOICE, CUSTOMER" +
                        " WHERE INVOICE.CUST_ID = CUSTOMER.ID" +
                        " AND INVOICE.DUE_DATE + CUSTOMER.GRACE_PERIOD" +
                        " < " + SQLUtility.dateAsSQL(currentDate);
    }
}

이렇게 SPECIFICATION내에 SQL을 정의하게 되면 데이터베이스에 대한 인터페이스를 제공하는 REPOSITORY와 자연스럽게 의존성이 생성 된다.

이 경우 세부적인 테이블 구조가 DOMAIN LAYER에 노출된다는 문제가 발생한다.

테이블 구조는 도메인 객체와 관계형 테이블 간의 관계를 책임지는 매핑 계층 내부로 격리해야 한다.

이 경우와 같이 암시적으로 두 계층에 동일 코드가 중복되면 매핑 정보가 변경될 경우 여러 곳에서 변경된 정보를 반영해야 하는 유지보수의 어려움을 겪게될 것이다.

이를 해결하기 위해 SQL을 REPOSITORY LAYER 내부에 위치 시킨다. 이를 통해 SQL은 도메인 객체(SPECIFICATION)로부터 분리될 수 있게 된다.

public class InvocieRepository {
    public Set selectWhereDueDateIsBefore(Date aDate) {
        String sql = whereDueDateIsBefore_SQL(aDate);
        ResultSet queryResultSet =
                SQLDatabaselnterface.instance().executeQuery(sql);
        return buildInvoicesFromResultSet(queryResultSet);
    }


    public String whereDueDateIsBefore_SQL(Date aDate) {
        return
                "SELECT * FROM INVOICE" +
                        " WHERE INVOICE.DUE DATE" +
                        " < " + SQLUtility.dateAsSQL(aDate);
    }

    public Set selectSatisfying(InvoiceSpecification spec) {
        return spec.satisfyingElementsFrom(this);
    }
}
public class DelinquentInvoiceSpecification {
    // 기본적인 DelinquentInvoiceSpecification 코드
    public Set satisfyingElementsFrom(InvoiceRepository repository) {
        Collection pastDueInvoices = repository.selectWhereDueDateIsBefore(currentDate);

        Set delinquentInvoices = new HashSet();
        Iterator it = pastDueInvoices.iterator();
        while (it.hasNext()) {
            Invoice anInvoice = (Invoice) it.next();
            if (this.isSatisfiedBy(anInvoice))
                delinquentInvoices.add(anInvoice);
            return delinquentInvoices;
        }
    }
}

이렇게 SPECIFICATION은 특정 조건(질의)을 만족하는 컬렉션을 조회하는 용도로 사용되기도 한다.

3. 요청 구축(생성)

미 국방부에서 새로운 전투기를 개발해야 한다면 담당자는 명세서를 작성한다. 이 명세서는 개발과 관련된 항목들이 포함되어 있을 것이다.

명세서는 항공기의 설계가 아니며, 항공기 그 자체는 더더욱 아니다.

오히려 명세서를 토대로 여러 개의 설계도를 작성할 것이고 그 종류도 다양할 수 있다.

요청 구축은 검증, 선택과 개념상 다르지 않지만 아직 존재하지 않는 객체에 대한 기준을 명시한다는 점에서 차이가 있다.

여기서 SPECIFICATION는 명시된 조건을 만족하는 완전히 새로운 객체객체 집합을 새로 만들어 내거나 재구성하는 것이 목적이다.

이를 생성기(generator)를 통해서 객체를 생성하는 절차적인 명령어를 통해 작성할 수도 있지만, 이는 생성에 대한 행위를 암시적으로 규정할 수 밖에 없다.

반면, SPECIFICATION을 사용해서 생성기의 인터페이스를 정의하면 생성할 결과물을 명시적으로 인터페이스에 포함시킬 수 있다.

이 접근법에는 여러 가지 이점이 있다.

  • 생성기의 구현을 인터페이스로부터 분리(decouple)할 수 있다. SPECIFICATION은 생성할 결과물에 대한 요구사항은 선언하지만 결과물을 생성하는 방법은 정의하지 않는다.
  • SPECIFICATION을 사용한 인터페이스는 생성 규칙을 명시적으로 전해주므로 생성기의 결과물을 예상할 수 있다.
  • 생성기는 SPECIFICATION에 포함된 조건에 따라 객체를 생성하는 반면 생성 요청을 표현하는 코드는 클라이언트에 존재하므로 더 유연한 인터페이스를 얻거나 개선할 수 있다.
  • 객체 생성의 방법이 모델에 포함되어 있어 테스트하기가 더 수월하다. SPECIFICATION 내에 생성 조건이 존재하기 때문에 생성된 객체가 올바른지 자체적인 검증도 가능하다.

댓글남기기