IT/기초 지식

[DDD] DDD를 실천하기 위한 안내서 (리포지터리편)

개발자 두더지 2023. 3. 15. 21:43
728x90

일본의 한 블로그 글을 번역한 포스트입니다. 오역 및 의역, 직역이 있을 수 있으며 틀린 내용은 지적해주시면 감사하겠습니다.

시리즈 포스트입니다. 전편은 여기를 참조해주세요.

 

시작하기에 앞서


 리포지터리 패턴은 DDD로 유명한 도메인 모델의 영속화를 위한 디자인 패턴이다. 지금 여러군데에서 "Repository"이라는 이름을 경우를 꽤 많이 보게 되지만 오남용하는 경우가 많이 있다. 

 따라서 이번 포스트를 통해 리포지터리 패턴의 의도나 본질을 이해하는 것을 목표하고 있다. 그리고 리포지터리 패턴에는 도움이 되는 사고 방식이 있기 때문에 패턴을 채용하지 않아도 알아두면 분명히 도움이 될 것이다. 또한 코드의 예는 Kotlin을 작성하고 있지만, 오브젝트 지향 언어라면 비슷하므로 이해하기는 어렵지 않을 것이다.

 

가볍게 살펴보기

리포지터리는 이런 느낌으로 오브젝트를 출납을 담당하는 것이다.

// 오브젝트를 저장
val user: User = ...
userRepository.save(user)

// 저장한 오브젝트를 획득
val user = userRepository.findById(userId)

 User이라는 모델에 대해 보관창고로서 UserRepository를 준비해뒀다. UserRepository는 User의 집합(콜렉션)을 추상화한 개념으로 User을 보관하거나 꺼낼 수 있다.

UserRepository 뒤에는 DB등의 영속화 기술이 있지만, 사용하는 입장에서 의식하지 않는다. 다음과 같은 느낌이다.

Clint ---------------오브젝트 저장, 획득--------> Repository -------------데이터를 Read/Write---------> Database 

 

Entity, Repository

 User은 Entity라고 불린다. Entity는 "작성" -> "변경(N번)" -> "삭제"와 같은 라이플사이클이 존재한다. 각 때에 따라 Entity를 그대로 보유하고 있는것이 Repository이다.

 

집약

 Entity는 반드시 평면적인 데이터 클래스가 아니면 중첩된 가치 오브젝트나 하위 Entity를 가지고 있는 경우가 있다. 이 Entity를 기점으로 하는 오브젝트 집합을 집약(Aggregate)이라고 부르고, 기점이 되는 Entity를 집약 루트(Aggregate Root)라고 부른다.

 리포지터리는 이 집약 그 자체를 저장, 획득, 삭제하는 역할을 한다. 그러므로 리포지터리는 집약 루트가 되는 Entity와 쌍을 이루도록 준비한다(User와 UserRepository와 같이).

 

 

리포지터리 패턴


 구체적인 사례를 살펴보자. 간단한 업무 관리 어플리케이션을 만드는 것을 예로 한다.

 

전제 : 아키텍처

 DDD나 클린 아키텍처와 같은 구현에서는 도메인층(시스템의 코어 부분)에 작업 룰을 응집시켜나가는 것을 목표로 한다. 그러므로, 그 외의 관심사를 바깥쪽으로 밀어 내 "도메인층을 클린하게 유지"하는 방침의 아키텍처가 채용된다. 

 리포지터리패턴도 코어 부붐에서 영속화라는 관심사를 분리하는 것을 목표로 한다. 여기에서는 레이어드 아키텍처와 같은 용어를 소개하도록 하겠다.

※ 화살표는 의존 방향

※ 프레젠테이션층은 생략

  • 도메인층
    • 시스템의 핵심
    • 도메인 모델(업무 룰을 퓨어한 모델로 표현한 것)을 둔다.
      • 엔티티, 리포지터리(의 인터페이스), 값 객체(Value Object), 도메인 서비스 등
  • 어플리케이션층
    • 어플리케이션으로서 성립하기 위한 전체를 조정하는 역할
    • 도메인 모델을 사용하여 Use Case를 실현한다.
  • 인프라스트럭처층
    • 상위의 레이어를 지원하는 기술적 기능(영석화, 메시지 송신등)
    • 리포지터리의 구현은 여기에 한다.

 각 레이어는 모듈로 나눠서 만드는 것을 추천한다(Kotiln으로 가정한다면 Gradel의 서브 프로젝트. 의존 관계나 공개할 인터페이스를 제약하기 쉬으므로). 

도메인 모델의 구현

먼저 Entity가 있고... 

DDD적인 설계에 있어서 Entity 상태가 변화(작성/변경/삭제)에 따라 기본적인 비즈니스 로직이 실현된다. 주역이라고 할 수 있는 존재이다.

 업무 관리 어플에서 "업무"를 표현하는 Entity는 다음과 같다.

package org.example.domain.task

class Task(
  id: TaskId,
  title: String,
  completed: Boolean,
  createdAt: LocalDateTime,
  completedAt: LocalDateTime?,
) {
  init {
    require(...) // 부정한 상태로 작성되지 않도록 해둔다.
  }

  // 「업무」의 속성
  // 실제는 컨스트럭터 인수에「val」「var」를 붙이고 싶지만, Setter을 private으로 하고 싶으므로 확장으로 정의
  val id: TaskId = id
  val title: String = title
  var completed: Boolean = completed; private set // 부정한 상태가 되지 않도록 sette를 공개하지 않는다.
  val createdAt: LocalDateTime = createdAt
  var completedAt: LocalDateTime? = completedAt; private set

  /** 업무 완료 */
  fun complete() { // sette를 공개하지 않는대신, 모델로 할 수 있는 것을 메소드로 공개
    completed = true
    completedAt = LocalDateTime.now()
  }

  companion object {
    fun create(title: String) = Task(
      id = TaskId(),
      title = title,
      completed = false,
      createdAt = LocalDateTime.now(),
      completedAt = null,
    )
  }
}

 이 Entity는 다른 집역의 일부가 아니므로 집약 루트이다. Entity라는 것을 들으면 RDB의 테이블이 떠오르는 사람이 있을지도 모르겠지만, 일단 RDB는 잊어버리고, 업무상의 개념을 정확히 표현하기 위한 모델이라는 것을 머릿속에 입력해두자.

 다음은 리포지터리가 있으며...

 집약루트가 되는 Entity를 만들었으면 Repository도 만들자. TaskRepository는 Task의 보관창고로, "Task의 집합"을 추상화한 개념이다. 인터페이스의 이미지는 다음과 같다.

package org.example.domain.task // 엔티티와 동일한 패키지(디렉토리)에 두는 것을 추천한다.

interface TaskRepository {
  // 저장(동일 ID의 Entity가 이미 존재하면 치환)
  fun save(task: Task)
  // 삭제
  fun delete(id: TaskId)

  // 획득
  fun findById(id: TaskId): Task?
  fun findCompleted(): List<Task>
  ...
}

※ 여기에서는 영속화 지향 리포지터리로서 모델링하고 있다(나중에 설명하도록 하겠다).

※ 구현은 나중에 생각해두고, 인터페이스가 중요하다.

  • save
    • 받은 Entity를 저장한다.
  • find~
    • 저장한 Entity를 (저장할 때와 완전히 동일한 상태로) 꺼낸다.
  • delete
    • 저장한 Entity를 삭제한다.

 기본적으로는 필요한 메소드만 정의하는 것이 좋다.

Entity와 Repository를 사용한 Use Case를 구현

 어플리케이션층에서는 이러한 느낌으로 Use Case가 구현된다.

1. Entity를 새롭게 만들거나, Entity의 행동을 호출함으로써 상태가 바뀐다.

2. 그 때의 상태를 Repository에 정해둔다.

// 업무를 신규작성
fun createTask(title: String) {
  val task = Task.create(title)
  taskRepository.save(task)
}

// 업무를 완료시킴
fun completeTask(taskId: TaskId) {
  val task = taskRepository.findById(taskId)
  task.complete()
  taskRepository.save(task)
}

 리포지터리의 인스턴스는 (DI등에 의해) 컨스트럭터등으로 전달되는 것을 상정한다. Use Case 로직을 쓸 때에는 리포지터리의 구현을 의식할 필요가 없다.

 

리포지터리의 구현

 여기까지 Use Case를 구현하는 코드를 작성했다. 뒤에는 리포지터리의 구현하고 DI등 바꾸면 완성이다.

구현

 방금의 TaskRepository의 구현을 생각해보자. 이 인터페이스의 의도를 구현하면 다음과 같다.

// Entity를 메모리상(인스턴스 변수)에 두고 구현
class InMemoryTaskRepository: TaskRepository {
  private val tasks = mutableMapOf<TaskId, Task>() // Entity를 인스턴스 변수가 갖도록 해둔다.

  override fun save(task: Task) {
    tasks[task.id] = task
  }

  override fun delete(id: TaskId) {
    tasks.remove(id)
  }

  override fun findById(id: TaskId): Task? {
    return tasks[id]
  }

  override fun findCompleted(): List<Task> {
    return tasks.values.filter { it.completed }
  }
}

 구현 자체는 끝났지만, 이것만으로는 실제로 사용할 수 없다.

  • 영속화되어있지 않으므로, 어플리케이션을 종료하면 삭제되어버린다.
  • 유지할 Entity의 수가 많으면 메모리 상에 올릴 수가 없다.

RDB를 사용한 구현

 이 문제를 해결하기 위해서는 Entity를 메모리상이 아닌, 외부에 영속화하도록 구현하도록 생각했다. 영속화처로는 RDB의 테이블을 사용하는 경우가 많지만, Entity를 영속화 & 복원할 수 있다면 NoSQL에서도 NewSQL에서도 OK이다.

⇓ RDB + Exposed를 사용현 구현

class ExposedTaskRepository : TaskRepository {

  override fun save(task: Task) {
    Tasks.upsert { // upsert메소드는 Extension에서
      it[id] = task.id.value
      it[title] = task.title
      it[completed] = task.completed
      it[createdAt] = task.createdAt
      it[completedAt] = task.completedAt
    }
  }

  override fun delete(id: TaskId) {
    Tasks.deleteWhere { Tasks.id eq id.value }
  }

  override fun findById(id: TaskId): Task? {
    return Tasks.select { Tasks.id eq id.value }.firstOrNull()?.toDomainModel()
  }

  override fun findCompleted(): List<Task> {
    return Tasks.select { Tasks.completed eq true }.map { it.toDomainModel() }
  }

  private fun ResultRow.toDomainModel(): Task {
    return Task(
      TaskId(this[Tasks.id]),
      this[Tasks.title],
      this[Tasks.completed],
      this[Tasks.createdAt],
      this[Tasks.completedAt],
    )
  }
}

 

 

중요 포인트


관심사의 분리

 리포지터리패턴이 생긴 이유는 도메인층을 클린하게 유지하기 위한 "영속화의 수단"이라는 관심사를 그 밖의 것들과 분리하기 위함이다.

  • 도메인층은 시스템의 핵심으로 도메인의 문제에 집중하고 싶다(본질적인 복잡함).
  • 영속화나 문의받은 구현은 복잡해지기 쉽다(부수적인 복잡함).
  •  따라서 도메인층에서 "영속화"이라는 본질이 아닌 복잡한 다른 부분은 분리하고 싶다.

 리포지터리 = "Entity의 집합"이라고 추상화할 수 있으며, 구체적인 수단이나 구현에 대해서는 나중에 생각하면 된다 (= 도메인층의 관심사가 아니다)이라는 구조가 된다.

 

집약 단위를 이용한 획득, 저장

 Repository에서는 Entity(집약)의 부분적인 저장 및 획득을 해서는 안 된다. 집약 그 자체를 저장하고 그 자체를 획득한다. 실무를 "Entity의 입출입"으로 한정하는 것으로 리포지터리의 구현에 업무 로직이 들어가는 불필요한 구현이 없어진다.

interface TaskRepository {
  // 저장 인수는 집약 루트가 되는 Entity(혹은 그 컬렌션)만
  fun save(task: Task)
  fun save(tasks: List<Task>)

  // 획득된 반환값도 집약 루트가 되는 Entity(혹은 그 컬렉션)만
  fun findById(id: TaskId): Task?
  fun findCompleted(): List<Task>
}

 혹시 반대로 updateTitleById(id: TaskId)와 같이 메소드를 리포지터리에 만들어버리면,

  • 각각의 비즈니스 로직이 리포지터리의 구현(구체적으로는 SQL의 UPDATE문 등)등으로 새어나가버리고 만다.
  • Entity 상태의 정합성을 보증하는 것은 Entity의 업무이지만, SQL등으로 각각 갱신하도록 하면 그 업무를 파괴해버리고말 가능성이 있다.

 동일한 이유로 Entity의 갱신일자를 리포지터리의 구현이나 DB쪽에서 자동을 설정하는 것과 같은 것도 NG이다. 테이블의 열 정의에 DEFAULT 구문등을 이용하는 것도 피하자.

  그러한 방법이 아닌 "리포지터리가 Entity의 상태를 갱신"하는 것이 아닌 "Entity 자체의 행동으로 상태가 변경되어, 그 때의 상태를 리포지터리를 사용하여 저장한다"라는 것이다.

 

오브젝트가 메모리 상에 있는 것처럼 보이도록 하기

 에릭 에반스의 도메인 구동 설계에서는 리포지터리에 관한 설명을 다음과 같이 하고 있다.

"어떤 오브젝트를 생성하여, 그 모든 오브젝트로 구성되는 컬렉션이 메모리 상에 있다고 착각하게 할 수 있도록 하는 것"

 실제로는 리포지터를 전달된 Entity의 안을 DB 테이블에 맵핑하여 영속화하고 획됙했을 때에는 테이블에서 읽어 들인 내용에서 기반으로 재구축하여 반환하고 있지만, 사용하는 입장에서는 마치 Entity 인스턴스 그 자체가 리포지터리에 보관되어 있고 필요에 따라 꺼내서 사용할 수 있는 것 처럼 보인다.

 

의존성 역전 원리(Dependency Inversion Principle)

 의존성 역전 원리 (혹은 의존관계 역전의 원리)는 SOLID 원리의 하나로 "Clean Architecture"에 의하면 다음과 같이 정의되어 있다. 

" 상위 레벨 방침의 구현 코드는 하위 레벨의 상세 구현 코드에 의존해서는 안되고, 반대로 상세측이 방침에 의존해야한다는 원칙 "

 "상위"라는 것은 클린 아키텍처의 원 그림에 빗대어 말하자면, 보다 안 쪽에 있는 원이다. 리포지터리 패턴은 이 원리를 구현하고 있다.

 만약 리포지터리를 인터페이스와 구현부분으로 분리하지 않고, 보통의 방법으로 구현하면, 리포지터리(도메인층)은 영속화의 구체적인 기술(RDB, ORM등)에 의존하게 된다.

※ 화살표는 의존 방향

 도메인층을 어떻게든 클린한 상태로 유지하고 싶다 (=영속화 기술에 의존하지 않도록 하고 싶다)라고 생각했을 때, 리포지터리의 인터페이스의 부분만을 도메인층에 두고, 그 인터페이스를 사용한 구현은 도메인 층의 밖에 두는 방법을 취한다.

 도메인층과 인프라스트럭처층 간의 의존 방향이 처음봤던 위 그림과 반대로 되어 있다. 이것이 의존성의 역전이다.

 도메인층에서 영속화 기술로의 의존을 배제하기 위해서는 리포지터리의 인터페이스에 구현된 코드가 의존되도록 한다. 요약하자면 "중요한 것(상위, 방침, 본질적) "에 "중요하지 않은 것(하위, 상세, 부차적)"이 의존하도록 하라는 것이다.

 이러한 사고방식은 프레임 워크나 라이브러리를 만들 때도 유용하다. 로깅 라이브러리를 만들 때에 로깅의 코어가 되는 API부분은 어떤 것도 의존하지도록 클린하게 만들고, 구체적인 로그 출력 구현은 API부분에 정의된 인터페이스에 의존하도록 만드는 것을 예로 들 수 있다.

// 로거의 코어 부분 ===========

class Logger(private val appender: LogAppender) {
  fun debug(message: String) = appender.append(message, Level.DEBUG)
  fun info(message: String) = appender.append(message, Level.INFO)
  fun warn(message: String) = appender.append(message, Level.WARN)
  fun error(message: String) = appender.append(message, Level.DEBUG)
}

enum class Level {
  DEBUG, INFO, WARN, ERROR
}

interface LogAppender {
  fun append(message: String, level: Level)
}

// 구체적인 출력 방법을 구현 ===========

// 콘솔에 출력하도록 구현
class ConsoleAppender : LogAppender {
  override fun append(message: String, level: Level) = println("$level: $message")
}

// 파일으로 출력하도록 구현
class FileAppender(private val file: File) : LogAppender {
  override fun append(message: String, level: Level) = file.appendText("$level: $message\n")
}

// 클라우드 스토리지에 출력하도록 구현
class CloudStorageAppender(private val bucketName: String, private val objectName: String) : LogAppender {
  ...
}

 

 일반적으로 I/O에서 멀수록 안정, 가까울수록 불안정하다. 리포지터리 패턴에서도 로깅 라이브러리에서도 코어 부분이 I/O의 구현에 의존하지않도록 추상화하여 분리하고 있다는 점이 동일하다.

  • 코어 부분
    • 본질적, 안정, 방침, I/O를 포함하지 않음
    • 가능한 클린하게 만듦
  • 구체적인 출력 부분
    • 부차적, 불안정, 상세, I/O를 포함
    • DB로의 액세스, 파일 출력, 외부 API를 호출하는 등 

 

 

기타 노하우


 예를 들어 방금의 Task에 reminders이라는 항목을 추가한다고하자.

class Task(...) {
  val id: TaskId
  val title: String
  val reminders: List<TaskReminder> // 
  ...
}

/** 리마인더의 설정 */
data class TaskReminder(
  /** 리마인드 날짜 */
  val datetime: LocalDateTime,
  /** 리마인더를 반복하는 주기 */
  val repeatFrequency: RepeatFrequency,
) {
  enum class RepeatFrequency { NEVER, HOURLY, DAILY, WEEKLY }
}

 reminders는 기존의 속성과 비교해, 복잡한 구조를 가지고 있지만, reminders를 포함하여 하나의 Task이라는 개념으로 보는 것이 자연스럽다.

 이러한 경우에도 리포지터리는 집약 단위로 저장, 획득한다. 리포지터리의 구현으로 RDB를 사용한다면 reminders의 부붐은 정규화하여 저장하는 것이 좋을 것이다. 반드시 1개의 집약은 1개의 테이블에 영속화된다고 할 수 없다는 의미이다.

 어떠한 테이블 구조로 저장할지는 영속화 기술에 따르며, 사용자 측에서 봤을 때 집약을 통째로 넣고 뺄 수 있어 집약의 일부분에 직접 접근하지 못하도록 하는 것이 중요하다.

 또한, TaskReminder을 독립한 엔티티(집약 루트)로 모델링하여, ID를 통해서 연관짓는 방식도 생각해볼 수 있다.

 

 

Repsitory의 두 가지 추상화 방침


 "실천 도메인 구동 설계"책에서는 리포지터리의 인터페이스의 추상화 방법으로 "영속화 지향", "컬렉션 지향" 두 가지 방법에 대해 소개하고 있다.  오브젝트로의 저장 방식에 차이가 주된 차이이다.

 

영속화 지향 (Persistence-oriented)

 지금까지의 구현예에서 봤던 방식이 이 방식이다. 콜렉션 지향과 비교했을 때 특징은  "리포지터리에서 획득한 오브젝트의 상태를 변경할 때마다 저장해야한다"라는 것이다.

 

컬렉션 지향(Collection-oriented)

 컬렉션과 같이 동작하는 인터페이스를 제공한다. 구체적으로는 Java나 Kotlin으로 말하자면 Set을 모방하고 있다고 할 수 있다(동일 ID를 가진 엔티티는 1개만 저장할 수 있다는 점에서 Set에 가깝다고 할 수 있다).

  • Set과 같이 add나 remove등의 메소드를 가지고 있다.
  • 리포지터리에서 획득한 오브젝트의 상태를 바꿨을 때 다시 작성할 필요가 없다.
    • 즉 리포지터리에서 획득한 오브젝트의 상태를 변경했을 때에 그것을 다시 add하지 않아도 다음에 획득했을 때 변경이 적용된 상태의 오브젝트를 얻을 수 있다.
interface TaskRepository {
  fun add(task: Task)
  fun remove(task: Task)
  
  ...
}

// 쓰는 쪽 ==========

// 태스크를 신규작성
fun createTask(title: String) {
  val task = Task.create(title)
  taskRepository.add(task)
}

// 태스크를 완료
fun completeTask(taskId: TaskId) {
  val task = taskRepository.findById(taskId)
  task.complete()
  // 영속화 지향과 달리 리포지터리를 다시 작성할 필요가 없다.
}

참고사이트

https://zenn.dev/kohii/articles/e4f325ed011db8

728x90