IT/기초 지식

[DDD] DDD를 실천하기 위한 안내서 (개념/도입편)

개발자 두더지 2023. 3. 18. 22:27
728x90

  이 포스트는 일본의 한 블로그 글을 번역한 포스트입니다. 오역 및 의역, 직역이 있을 수 있으며 틀린 내용은 지적해주면 감사하겠습니다.
이 포스팅은 DDD를 신천하기 위한 안내서(리포지터리편)의 전편입니다. 후편은 여기를 참조해주세요.


  DDD란 Domain-Driven Design(도메인 기반 설계)의 약어로 에릭 에반스라는 사람이 제시한 소프트웨어 설계를 집약한 개념이다.

 그러나 이 개념은 폭이 넓고, 추상적인 부분이 많아 이해하기 힘들다는 목소리가 왕왕 들린다.  이번 포스팅에서는 이런 어려움을 겪는 사람들을 위해 수 년에 걸쳐 DDD를 실천한필자의 독단적인 해석과 실천 방법에 대해 소개하고자한다.

 

DDD란?


DDD가 커버하는 영역

DDD가 다루는 범위는 앞서 말했듯 꽤 넓다. 에릭 에반스가 말한 내용을 분류하면 아래와 같이 나눌 수 있다.

1. 사상 철학으로서의 DDD : 복잡해지기 마련인 시스템 개발을 어떻게 다룰 것인가. 개발의 고려점이나 나아갈 방향

2. 설계 전략으로서의 DDD(전략적 DDD) : 도메인 모델링과 연결하기 위한 접근 방침 (도메인 전문가와의 협력, 유비쿼터스 언어, 경계가 나눠진 컨텍스트)

3. 구현 패턴으로서 DDD(전기적 DDD) :  도메인 모델을 구현 레벨로 구현하기 위한 패턴(엔티티, 리포지터리, 레이어 아키텍처등)


DDD의 정의

에릭의 말에 따르면 DDD는 아래의 4개 원리가 바탕이 되어 성립됐다.

1. 도메인(*1)의 핵심이 되는 복잡성과 기회에 초첨을 맞춘다.

2. 도메인 전문가(*2)와 소프트웨어 전문가의 협동을 통해 모델을 탐구한다.

3. 그 모델을 그대로 표현하는 소프트웨어를 만든다.

4. 경계가 나눠진 컨텍스트(*3)에 있어서 유비쿼터스 언어(*4)를 이용한다.

(*1) 시스템 대상이 되는 업무 영역

(*2) 도메인에 관련하여 깊은 지식을 가지고 있는 사람

(*3) 특정 모델이 정의되어 적용되는 환경 (서브시스템등)

(*4) 팀에서 사용되는 공통 언어

 

실천적인 의미

  •  시스템이 적용되는 유저의 업무나 문맥(도메인)를 깊이 이해하고 있는 것부터 시작한다.
  • 도메인에 관련된 지식을 가진 사람들과 대화, 협력해가면서 시스템의 업무 룰을 모델/개념으로 만들어나간다.
  • WEB이나 DB와 같은 “수단”이나 “기술”은 일단 두고, 시스템의 업무사양(도메인)을 단순화한 모델을 만들어나간다.
  • 오브젝트 지향 언어를 이용해 표현하여, 그 모델을 그대로 코드로 작성한다.
  • 말이 중요하다. 엔지니어도 비엔지니어도 구체적인 언어를 사전에 정의해두고 공통 사용 언어를 사용하여 이야기한다.

 또한, 꽤 하는 착각으로는 “DDD란 기존의 업무 룰을 그대로 모델링, 소프트웨어로 만든다”가 있다. 그렇게 되면 현재 상태를 모델링화하여 디지털화하는 것으로 끝나고 만다.

그러나 무언가를 만드는 것은 크리에이티브한 행위이다. 프로덕트의 베스트를 계속해서 생각하면서 만들어가는 것이 최종적으로는 크리에이터의 사명으로, 이를 바탕으로  만들어 진 프로덕트는 지금까지 없었던 새로운 도메인으로 이끈다.

 그러므로 DDD란, “도메인(=대상이 되는 작업의 세계나 컨텍스트)를 파악한 뒤에, 그 지식을 소재로 프로덕트를 다룰 업무 사양을 도메인 모델로 구체화하여, 그 모델이 움직이는 소프트웨어를 만드는 것”이라고 정의할 수 있다.


중요한 포인트


모델과 모델링

 조금 추상적인 이야기이지만, 모든 프로덕트는 고유의 개념(모델)을 가지고 있다. 트위터를 예로 들자면, 트위터나 타임라인, 유저등이다.

 프로덕트는 개념의 집합체 그 자체로, 프로덕트를 보고 조작하는 것은 개념 그 자체를 보고 조작하는 것이라고 말할 수 있다.

 OOUI(오브젝트 지향 유저 인터페이스)는 이러한 사고방식을 바탕으로 한 UI를 설계하는 것을 상정하는 것으로 OOUI에 대해 조사해보면 모델링이 어떤 것인가에 대해 이해하기 쉬울지도 모른다.

 DDD에서는 규정을 만드는 것은 곧 모델을 만드는 것으로, 그 모델은 코드로 표현되게 된다. 그렇게 “규정 = 모델 = 코드”가 되어 코드가 규정을 표현하게 된다.


모델링의 흐름

책에 따르면 다음의 단계를 거쳐 모델링하게 된다.

1. 시스템 대상의 업무, 활동, 영향(도메인 지식)을 조사/파악

2. 1에서 얻은 정보를 선별해나가면서 시스템 업무 규정을 모델화

3. 모델을 코드로 표현

4. 계속해서 수정/개선

1. 시스템화할 업무, 활동, 영향(도메인 지식)을 조사/파악

 도메인 전문가(대상 영역의 지식을 가진 사람, 실제로 그 업무를 해나가고 있는 사람들)과 대화하며, 필요한 도메인 지식을 얻는다.

2. 1에서 얻은 정보를 선별해나가면서 시스템 업무 규정을 모델화

  • 1의 내용에서 얻은 통찰을 바탕으로 선별해나가면서 시스템에서 다룰 도메인의 규정을 개념화한다(도메인 모델).
  • 도메인 모델은 그림이나 말 등으로 표현하여 전달할 수 있다.
  • 도메인 모델은 기술 사양이 아닌, 업무 규정을 표현하는 것이다. 그러므로 개발 전문가가 아니더라도 동일하게 도메인 모델을 보면서 말할 수 있을 것이다.

3. 모델을 코드로 표현

  • 오브젝트 지향 언어를 사용하여 모델을 코드로 만든다.
  • 코드는 “처리를 기재”하는 것이 아닌 “모델을 표현”하는 느낌으로 한다.

4. 계속해서 수정/개선

- 모델은 한번 정의하는 것으로 끝난 것이 아니다. 보다 깊은 통찰을 얻으려고 노력하며 그 내용을 바탕으로 개선해나간다.

 

유비쿼터스 언어

  • 관계자 사이에서 공유되는 말인 유비쿼터스 언어로 말한다.
  • 비즈니스쪽의 사람과 말할 때, 엔지니어와 말할 때, 프로그램을 만들 때, 문서를 작성할 때 일관성 있는 유비쿼터스 언어를 사용한다.
  • 유비쿼터스 언어는 원래 도메인 전문가가 사용한 언어로, 모델링의 과정에서 만들어진 말이다.


레이어를 구분하기

 DDD으로 시스템을 구축하는 경우, 도메인의 정보를 클린하게 유지하기 위해서는 도메인층(도메인 모델을 기재하는 곳)을 핵심과 그 외의 것들로 나누는 것이 좋다.

 DDD에서 자주 채용되는 것이 레이어 아키텍처라고 생각한다. 그 외에도 여러가지 아키텍처가 존재하지만, 어떤 방식을 선택하든 무엇보다 제일 중요한 것이 도메인 층이 다른 어떤 것에도 의존하지 않도록 하는 것이 중요하다.

레이어 아키텍처의 경우

※ 화살표 방향이 의존 방향

프레젠테이션 층(UI등)

어플리케이션 층(전체를 컨트롤)

도메인층(모델화된 업무 규정)

인프라층(DB등)

  •  도메인층이 제일 중요하다. 기술적인 문제가 아닌 도메인의 가치로, 복잡한 부분에 포커스를 맞춘다.
  •  도메인층은 다른 층에 의존하지 않는다. 의존할 필요가 없다.
  • 도메인 층에 업무 규정을 표현하는 퓨어한 모델을 만들어나가는 것을 고집한다.
  • 도메인층 이외에 업무 로직이 노출되지 않도록 도메인층에 모은다.

 도메인층을 만드는 작업은 시스템의 업무규정을 추출하여 라이브러리화, API화한다는 이미지에 가깝다.위 그림의 의존 방향을 보면 알 수 있듯, 도메인층은 다른 층에서 사용되기 위해 존재한다. 그러므로 도메인층의 구현할 때는 사용하는 쪽에서 이해하기 쉽도록, 틀린 방법으로 사용할 여지가 없는 개념, 인터페이스가 되도록 신경을 써야한다.



그럼 어떻게 모델링/구현해 나가는가?


먼저 모델링 룰

DDD에서는 기본적으로 다음의 방법으로 모델링한다.

Entity

  • 도메인 모델의 주역
  • 아이덴티티를 가진 오브젝트
  • 시간에 따라 변화한다고 해도 “아이덴티티(ID)”가 동일한 것은 동일한 Entity
    • 예를 들어 “사원”을 다루는 시스템에 있어서 홍길동이라는 사원이 있다고 했을 때, 이동에 의해 소속 부서나 직급이 바꼈을 때 동일한 홍길동이라는 사원이다.
    • “사원”은 아이덴티티에 따라 식별되는 동일성을 가진 Entity라고 할 수 있다.
  • 반대로, 속성이 완전히 동일해도 아이덴티티가 다르면 완전히 다른 것이 된다.
  • 동명이인으로 생년월일등 속성이 완전히 동일한 사원이 2명 있는 경우, 2명은 동일 인물이 아닌 다른 사람이다.
  • 데이터베이스나 ORM의 엔티티과 의미가 다르다. 단순한 대입 데이터가 아니다.
  • Entity는 값 객체나 자식 Entity를 가지는 경우가 있는데, 이 Entity를 기점으로 하는 오브젝트의 집합을 집약(Aggregate)라고 부르며, 기점이 되는 Entity를 집약 루트(Aggregat Root)라고 부른다.

값 객체(Value Object)

  • 값이나 속성을 표현하는 것
    •  예를 들어 “색”이나 “양”과 같이 그 속성만이 중요하며, 아이덴티티를 고려하지 않는 오브젝트이다.
  • 불변한다.
  • 1개의 값(값 각체))가 복합적인 값에서 성립된 경우도 있다.
    • 예를 들어 “주소”라는 값은 “우편번호” “도” “시” “지번” “건물명”이라는 값으로 구성되도록 모델링할 수 있다.


리포지터리

  •  집약 루트가 되는 Entity와 쌍으로 준비된다.
  • Entity는 작성 > 변경(N번) > 삭제와 같은 라이프 사이클에 따라 그 Entity(집약)의 지금 상태를 저장해두는 곳이 리포지터리이다.
  • 저장된 Entity(집약)을 필요할 때에 복원하여 반환한다.
  • 일반적으로 리포지터리의 인터페이스만 도메인 층에 위치하게 된다.
    • 리포지터리의 구현은 인프라층에 위치한다.
    • 도메인층에 있어서 굉장히 중요한 것은 리포지터리의 인터페이스뿐이며, 그게 어떻게 구현되어 있는지는 중요하지 않다.
    • 리포지터리이 구현된 백엔드에는 RDB가 주로 사용되지만, NoSQL도, KVS도Entity(집약)을 저장, 복원할 수 있다면 사용해도 괜찮다.
  • 리포지터리는 Entity를 전달하면 그대로 보관해주며, 필요할 때 저장된 것과 완전히 동일한 상태로 꺼낸다는 단순한 사양이므로, 복잡한 로직을 넣을 필요가 없다. 즉 쓰는 쪽에서는 어떤 로직이 적혀 있는지 알 필요가 없다.


도메인 서비스

  • 오브젝트가 아닌 단순한 처리
  • 1개의 기능이나 처리의 전체 부분이 존재하여, 오브젝트로서 다루기에는 부자연스러운 것을 서비스로 정의한다.
  • 단순한 처리로 상태를 가지지 않는다.

 



샘플 : 실제로 어떻게 구현하는가?


“통화”를 관리하는 시스템을 생각해보자. 언어는 Kotlin을 이용하였다. 시스템으로서 다음과 같은 업무를 실현하고자한다.

  •  전화를 건다 (상대를 지정하여 호출한다).
  • 취소한다 (상대가 응답하기 전에 통화를 끊는다).
  • 통화를 시작한다 (상대가 응답하여, 통화가 시작된다).
  • 통화가 종료된다 (통화가 끝난다).

 먼저 “통화” (통화가 걸리고 끝날 때 까지)를 표현한 모델을 만들어보자.도메인 전문가와의 대화를 통해 도출해낸 정보를 통해 “통화”에 필요한 요소는 다음과 같다고 상정하자.

  • 전화를 건 사람
  • 전화를 받을 사람
  • 상태 (전화가 걸려서 연결될 때 까지 혹은 취소 혹은 통화중 혹은 종료)
  • 전화를 건 일시
  • 통화 시작한 일시(연결된 일시)
  • 통화 종료된 일시
  • 통화 요금

 

일반적인 데이터 중심의 방법

 먼저 일반적인 데이터 중심 방법에 가까운 방법을 생각해보자.

통화를 표현한 모델

/** 통화 */
class PhoneCall {
  /** ID */
  var id: UUID? = null

  /** 전화를 건 사람(의 유저ID) */
  var callerUserId: UUID? = null

  /** 전화를 받을 사람(의 유저ID) */
  var receiverUserId: UUID? = null

  /** 상태 */
  var status: Int? = null

  /** 발신 일시 */
  var createdAt: Calendar? = null

  /** 연결 일시 */
  var talkStartedAt: Calendar? = null

  /** 종료 일시 */
  var finishedAt: Calendar? = null

  /** 통화 요금 */
  var callCharge: Int? = null
}

/**
 * 통화상태
 */
object PhoneCallStatus {
  const val WAITING_RECEIVER: Int = 1
  const val CANCELED: Int = 2
  const val TALK_STARTED: Int = 3
  const val FINISHED: Int = 4
}

유즈 케이스 로직

/**
 * 통화를 걸다.
 *
 * @param callerUserId   발신인
 * @param receiverUserId 수신인
 * @return 통화ID
 */
fun makePhoneCall(callerUserId: UUID, receiverUserId: UUID): UUID {
  val phoneCall = PhoneCall()
  phoneCall.id = UUID.randomUUID()
  phoneCall.callerUserId = callerUserId
  phoneCall.receiverUserId = receiverUserId
  phoneCall.createdAt = Calendar.getInstance()
  phoneCall.status = PhoneCallStatus.WAITING_RECEIVER
  phoneCallRepository.save(phoneCall)
  return phoneCall.id!!
}

/**
 * 통화를 취소하다.
 *
 * @param phoneCallId 통화ID
 */
fun cancelPhoneCall(phoneCallId: UUID) {
  val phoneCall = phoneCallRepository.findById(phoneCallId)
  phoneCall.status = PhoneCallStatus.CANCELED
  phoneCall.finishedAt = Calendar.getInstance()
  phoneCallRepository.save(phoneCall)
}

/**
 * 통화를 시작하다.
 *
 * @param phoneCallId 통화ID
 */
fun startTalking(phoneCallId: UUID) {
  val phoneCall: PhoneCall = phoneCallRepository.findById(phoneCallId)
  phoneCall.status = PhoneCallStatus.TALK_STARTED
  phoneCall.talkStartedAt = Calendar.getInstance()
  phoneCallRepository.save(phoneCall)
}

/**
 * 통화를 종료하다.
 *
 * @param phoneCallId 통화ID
 */
fun finishPhoneCall(phoneCallId: UUID) {
  val phoneCall: PhoneCall = phoneCallRepository.findById(phoneCallId)
  phoneCall.status = PhoneCallStatus.FINISHED
  phoneCall.finishedAt = Calendar.getInstance()
  phoneCall.callCharge = calculateCharge(phoneCall)
  phoneCallRepository.save(phoneCall)
}

요건을 만족하고 있으며, 한 번 봤을 때 문제가 없는 코드가 됐다. 그러나, 아직 DDD적이라고 할 수는 없다.

문제1  : “통화”라는 모델을 정확하 나타내고 있지 않다(도메인 모델의 불충분)

통화 클래스는 단순한 데이터를 넣는 것으로 되어있다. 그 클래스에는 속성 이외의 정보가 없으며 모델을 제대로 표현하고 있지 않다.

⇒ “통화”이라는 모델을 보다 정확히 표현하도록 하자.

문제2 : 어떤 것이든 할 수 있게 되어 있다.

 예를 들어, 상태를 단순히 “통화 종료”로 하는 경우, “통화종료일시” “통화요금”이 데이터에 포함되지 않게 되어버릴 가능성이 있다.

 이 모델을 사용하는 쪽에서는 이와 관련된 지식이 없으면 간단히 버그가 생성되기 쉽다.

⇒ 이해하기 쉽게, 쓰는 쪽에서 틀리지 않는 모델을 만들자. 도메인 모델은 사용하는 쪽을 위해 만드는 것이다.

문제3 : mutable한 값을 필드로 갖고 있다.

 immutable하지 않은 값을 속성으로 가지고 있어, 그 값이 어디에서 변경되어 버렸는지 알 수 없으므로, 조심해야할 부분이 늘어있다.

val phoneCall = PhoneCall()
val now = Calendar.getInstance()
phoneCall.finishedAt = now
// 이렇게 한 다음
now.set(Calendar.HOUR, 1)
// 이렇게 하면, `phoneCall`의 값이 바껴버린다.





개선법

통화 Entity

/** 통화 */
class PhoneCall(
  id: PhoneCallId,
  caller: UserId,
  receiver: UserId,
  status: PhoneCallStatus,
  createdAt: LocalDateTime,
  talkStartedAt: LocalDateTime?,
  finishedAt: LocalDateTime?,
  callCharge: Price?,
) {
  /** ID  */
  // 일반적인 UUID가 아닌, 속성의 의미를 정확히 표현하는 값 오브젝트를 만들었다.
  val id: PhoneCallId = id

  /** 발신인  */
  // 통화를 생성했을 때 반드시 지정되어, 변경되지 않고, null이 불가능한 final로 한다.
  val caller: UserId = caller 

  /** 수신인  */
  val receiver: UserId = receiver

  /** 상태  */
  var status: PhoneCallStatus = status; private set

  /** 발신 일시  */
  val createdAt: LocalDateTime = createdAt

  /** 연결 일시  */
  var talkStartedAt: LocalDateTime? = talkStartedAt; private set

  /** 종료 일시  */
  var finishedAt: LocalDateTime? = finishedAt; private set

  /** 통화 요금  */
  var callCharge: Price? = callCharge; private set

  /** 취소 */
  fun cancel() { // setter를 공개하지 않는 대신, 모델이 할 수 있는 것을 public메소드로 공개
    // 상태를 체크하여, 사용법이 틀리지 않도록 않다.
    check(this.status == PhoneCallStatus.WAITING_RECEIVER)
    // 반드시 한꺼번에 갱신되도록 한다.
    this.status = PhoneCallStatus.CANCELED
    this.finishedAt = LocalDateTime.now()
  }

  /** 통화개시 */
  fun startTalking() {
    check(this.status == PhoneCallStatus.WAITING_RECEIVER)
    this.status = PhoneCallStatus.TALK_STARTED
    this.talkStartedAt = LocalDateTime.now()
  }

  /** 통화종료 */
  fun finishCalling(callCharge: Price) {
    check(this.status == PhoneCallStatus.TALK_STARTED)
    this.status = PhoneCallStatus.FINISHED
    this.finishedAt = LocalDateTime.now()
    this.callCharge = callCharge
  }

  /** 통화가 끝나지 않았을 때 어떻게 할까 */
  val isInProgress: Boolean // 필요하다면 정보를 추출하는 메소드를 추가
    get() = (status === PhoneCallStatus.WAITING_RECEIVER || status === PhoneCallStatus.TALK_STARTED)

  /** 통화시작 */
  val durationTime: Duration
    get() = if (isInProgress) {
      Duration.between(createdAt, LocalDateTime.now())
    } else {
      Duration.between(createdAt, finishedAt)
    }

  companion object {
    // 신규 생성용 팩토리를 준비
    /** 신규생성 */
    fun create(caller: UserId, receiver: UserId): PhoneCall { // 신규 생성시에는 caller와 receiver를 반드시 지정하여, 완전한 상태로 생성되도록 강제한다.
      return PhoneCall(
        PhoneCallId.next(),
        caller,
        receiver,
        PhoneCallStatus.WAITING_RECEIVER,
        LocalDateTime.now(),
        null,
        null,
        null
      )
    }
  }
}

/** 통화ID(값 객체) */
@JvmInline
value class PhoneCallId(val value: UUID) {
  companion object {
    fun next() = PhoneCallId(UUID.randomUUID())
  }
}

/** 통화 상태(값 객체) */
enum class PhoneCallStatus {
  WAITING_RECEIVER, CANCELED, TALK_STARTED, FINISHED
}

유즈케이스 로직

/**
 * 電話をかける
 *
 * @param caller   かける人
 * @param receiver かけられる人
 * @return 通話ID
 */
fun makePhoneCall(caller: UserId, receiver: UserId): PhoneCallId {
  val phoneCall = PhoneCall.create(caller, receiver)
  phoneCallRepository.save(phoneCall)
  return phoneCall.id
}

/**
 * 취소한다.
 *
 * @param phoneCallId 통화ID
 */
fun cancelPhoneCall(phoneCallId: PhoneCallId) {
  val phoneCall: PhoneCall = phoneCallRepository.findById(phoneCallId)
  phoneCall.cancel() // 데이터를 세팅하는 것이 아닌, 동작을 호출하여 처리를 실현
  phoneCallRepository.save(phoneCall)
}

/**
 * 통화를 시작한다.
 *
 * @param phoneCallId 통화ID
 */
fun startTalking(phoneCallId: PhoneCallId) {
  val phoneCall: PhoneCall = phoneCallRepository.findById(phoneCallId)
  phoneCall.startTalking()
  phoneCallRepository.save(phoneCall)
}

/**
 * 통화를 종료한다.
 *
 * @param phoneCallId 통화ID
 */
fun finishPhoneCall(phoneCallId: PhoneCallId) {
  val phoneCall: PhoneCall = phoneCallRepository.findById(phoneCallId)
  phoneCall.finishCalling(calculateCharge(phoneCall))
  phoneCallRepository.save(phoneCall)
}

참고자료
https://zenn.dev/kohii/articles/b96634b9a14897

728x90