본문 바로가기
Concepts/SW Design

DDD - Specification

by ocwokocw 2022. 9. 25.

- 출처: 도메인 주도 설계 - 에릭 에반스

- Specification

간단한 규칙을 검사하는 코드를 작성할때 Boolean을 반환하는 anInvoice.isOverdue() 와 같은 형태로 작성하는 경우가 많다. 규칙이 단순하다면 이정도로 충분하지만 어플리케이션의 규칙이 항상 간단한것만은 아니다.
 
송장(Invoice)의 지불유예기간에 대한 정책은 고객의 계정상태나 제품군에 따라 달라질 수 있다. 이런 규칙들이 늘어나다보면 송장이라는 모델은 송장 자체를 표현하기 보다 송장에 대한 규칙만 늘어놓는 코드로 전락하고 송장의 본질적인 특성이 무엇인지 파악할 수 없게 된다.
 
다시말해서 원래 모델링했던 Entity, VO 책임에 맞지 않은 규칙의 다양성과 조합이 본질적인 모델의 의미를 압도하게 된다. 이런 규칙들은 업무규칙이므로 엄연히 domain 계층에 포함되어야 해서application 계층으로 밀어내버릴 수도 없다.
 
이를 해결하기 위해 술어의 개념을 차용해서 boolean 결과를 내는 특별한 객체를 생각해낼 수 있는데 이 객체가 바로 Specification이다. Specification은 어떤 객체가 특정 기준을 만족하는지 판단하는 술어라고할 수 있다.
 
어떤 객체에 메소드를 계속 나열하지 않고 Specification을 별도로 사용하면 다음과 같은 이점이 있다.
 
  • 규칙(ex - 송장에 대한 규칙)이 본질적인 모델(ex - 송장)과 명확하게 분리된채로 도메인 계층에 유지된다.
  • 설계가 모델을 더욱 명확하게 반영한다.
  • Factory에서 고객계정이나 기업 정책 DB같은 외부 정보에 따라 Specification을 설정할 수 있다. 만약 Invoice가 직접 외부 정보에 접근한다면 지불 요청과는 무관한 객체와 결합성을 갖게될것이다.

- Specification 의 적용과 구현

Specificatoin 은 크게 3 가지 방법으로 사용할 수 있다.
 
  • 검증: 어떤 요건을 충족시키거나 특별한 목적으로 사용할 수 있는지 검증
  • 선택: 컬렉션 내에서 객체를 선택
  • 생성: 특별한 요구사항을 만족하는 객체를 생성
 
3 가지 방법으로 사용된다고 해서 각 목적에 따라 Specification 개념이 달라지는것은 아니다. 개념적인 차원에서는 동일하다. 선뜻 이해가 안갈수도 있지만 각 방법을 살펴보면 결국 개념적으로 동일하다는것을 이해할 수 있을것이다.

- 검증

Specification의 가장 단순한 용도이며 명세라는 의미를 가장 직관적으로 이용하는 형태이다.
 
 
 
Delinquent는 채무를 이행하지 않았다는 의미의 용어이다. Delinquent Invoice Specification을 구현하는 예제를 통해 코드로 표현하면 어떤 형태가 되는지 감을 익혀보자.
 
type Invoice struct {
	amount int
	due time.Time
}

type InvoiceSpecification interface {
	IsSatisfiedBy(invoice Invoice) bool
}

type DelinquentInvoiceSpecification struct {
	currentDate time.Time	
}

func NewDelinquentInvoiceSpecification(currentDate time.Time) InvoiceSpecification {
	return &DelinquentInvoiceSpecification{
		currentDate: currentDate,
	}
}

func (d *DelinquentInvoiceSpecification) IsSatisfiedBy(invoice Invoice) bool {
	return d.currentDate.After(invoice.due)
}
 
위 예제의DelinquentInvoiceSpecification 정책은 현재 일자가 Invoice의 기한을 넘겼는지 여부만 판단하는 간단한 정책만 사용하였다. currentDate는 검증이 사용된 후 폐기된다.
 

- 선택

Specification을 좀더 응용하면 컬렉션내에서 특정 조건만 부합하는 객체를 테스트하여 선택할 수 있다. 
 
체납된 송장을 보유한 모든 고객 목록을 나열하는 요구사항이 있다고 가정해보자. 가장 직관적으로 구현하면 모든 Invoice를 순회하면서 위의 예제 코드에서 생성한 Delinquent Invoice Specification을 이용하는것이다.
 
type InvoiceRepository struct {
	invoices []Invoice
}

func (i *InvoiceRepository) selectSatisfying(
	specification InvoiceSpecification) []Invoice {
	var result []Invoice
	for _, invoice := range i.invoices {
		if specification.IsSatisfiedBy(invoice) {
			result = append(result, invoice)
		}
	}
	return result
}

func TestFilterInvoiceBySpecification(t *testing.T) {
	repo := InvoiceRepository{invoices: []Invoice{}}
	spec := NewDelinquentInvoiceSpecification(time.Now())
	delinquentInvoices := repo.selectSatisfying(spec)
}
 
위의 코드는 Specification을 컬렉션에서 특정 객체를 잘 걸러내고 있지만 한 가지 구현 세부사항 관련해서 생각해야할 점이 있다. Batch 작업을 많이 처리해보았다면 알겠지만 위의 코드와 같은 방식은 시스템에서 관리하는 Invoice가 적은 경우에만 가능하다는것이다. 위의 예제처럼 사용하려면 컬렉션에서 Specification을 만족하는 Invoice를 선택하기 위해 모든 Invoice 후보군을 메모리에 올려야 한다. 하지만 컴퓨터의 메모리는 한정되어있다. 
 
일반적으로 어플리케이션을 구성할때에는 데이터를 영구적으로 저장해놓고 필요한 데이터만 가져와서 메모리에 올려놓고 연산할 수 있도록 데이터베이스를 사용한다. Specification의 모델을 유지하면서 이런 문제를 해결하려면 Specification에서 해당 SQL을 제공하도록 할 수 있다.
 
type InvoiceSpecification interface {
	IsSatisfiedBy(invoice Invoice) bool
	AsSQL() string
}

func (d *DelinquentInvoiceSpecification) AsSQL() string {
	return "SELECT *" +
		"FROM invoice, customer" +
		"WHERE invoice.cust_Id = customer.id" +
		" AND invoice.due_date < " + d.currentDate.String()
}
 
하지만 이러면 문제점이 있는데 Domain 계층에 Infra 계층 세부사항이 포함된다는점이다. 만약 Infra 를 RDB에서 NoSQL로 변경하면 Infra 계층뿐만 아니라 Domain 계층도 코드를 모두 변경해야 한다.
 
이를 해결하기 위해서 Repository에 특수 목적의 query 메소드를 추가할 수 있다. 사실 완벽히 깔끔하다는 느낌이 들지는 않지만 Repository 메소드명을 잘 설계하면 충분히 의도를 알 수 있고, 관련 SQL도 실제 해당 책임이 있는 Infra 계층에서 생성되게 할 수 있다. 이때 query 메소드를 가능하면 일반화해주는것이 좋은데 그렇지 않으면 업무규칙이 발생할때마다 query method를 추가해야하기 때문이다.
 
Specification 에서 구현하고 있는 SQL 생성을 Repository 책임으로 이동해보자.
 
type InvoiceSpecification interface {
	SatisfyingElementsFrom(repository *InvoiceRepository) []Invoice
	IsSatisfiedBy(invoice Invoice) bool
}

type DelinquentInvoiceSpecification struct {
	currentDate time.Time
}

func NewDelinquentInvoiceSpecification(currentDate time.Time) InvoiceSpecification {
	return &DelinquentInvoiceSpecification{
		currentDate: currentDate,
	}
}

func (d *DelinquentInvoiceSpecification) SatisfyingElementsFrom(
	repository *InvoiceRepository) []Invoice {

	var delinquentInvoices []Invoice
	invoices := repository.selectWhereDueDateIsBefore(time.Now())
	for _, invoice := range invoices {
		if d.IsSatisfiedBy(invoice) {
			delinquentInvoices = append(delinquentInvoices, invoice)
		}
	}
	return delinquentInvoices
}

func (d *DelinquentInvoiceSpecification) IsSatisfiedBy(invoice Invoice) bool {
	return d.currentDate.After(invoice.due)
}

type InvoiceRepository struct {
	invoices []Invoice
}

func (i *InvoiceRepository) selectWhereDueDateIsBefore(date time.Time) []Invoice {
	sql := i.selectWhereDueDateIsBeforeSql(date)
	resultSet := executeSQL(sql)
	return buildInvoicesFromResultSet(resultSet)
}

func (i *InvoiceRepository) selectWhereDueDateIsBeforeSql(date time.Time) string {
	return "SELECT *" +
		" FROM invoice" +
		" WHERE invoice.due_date < " + date.String()
}

func TestFilterInvoiceBySpecification(t *testing.T) {
	repo := &InvoiceRepository{invoices: []Invoice{}}
	spec := NewDelinquentInvoiceSpecification(time.Now())
	delinquentInvoices := spec.SatisfyingElementsFrom(repo)
}
 
코드를 살펴보면 변화한 부분은 아래와 같다.
 
  • Specification: SQL로 변환하는 부분을 없애는 대신 repository에 관련있는 질의 메소드를 호출했다. Specfication에서 repository를 사용하기 위해 인자로 받는다.
  • Repository: DelinquentSpecification 의 목적만을 위해서 사용할 수 있는 특수 질의 메소드가 아닌 일반화시킨 메소드를 생성했다.
 
 DelinquentSpecification만을 위한 특수 목적의 질의 메소드를 일반화 질의 메소드로 변경했을 때 장점은 재사용성이 높아지고 Repository의 메소드가 Domain 계층의 목적을 표현하지 않는다는점이 장점이다. 하지만 단점도 존재하는데 특수 질의 메소드보다 메모리에 올려야하는 Invoice 객체가 많아지게 되므로 성능저하를 불러올 수 있다. 이는 구현 세부사항의 선택 문제이므로 적절한 조율이 필요하다.

- 생성

하나의 명세를 갖고 여러 회사에 부탁해서 제품을 설계해달라고 요청하면 명세가 하나라고 해도 모든 회사의 제품 설계서가 동일하지는 않을 것이다.
 
우리가 작성중인 문서에 이미지를 삽입하면 글자는 겹치지 않고 어디론가 배치된다. 글자가 어디에 배치될것인지는 모르지만 "이미지와 글자는 겹치지 않는다"는 명세는 만족하게 된다.
 
생성의 경우 Specification의 특수한 경우인것 같지만 앞에서 살펴본 "검증"과 "선택"의 개념과 다르지 않다. 대신 Specification에 명시된 조건을 만족하는데 그치지 않고 새로운 객체를 생성한다는점이 다르다.
 
객체를 생성할때는 단순히 생성자를 사용하거나 생성로직이 복잡한 경우 Factory, 즉 일종의 Generator를 사용한다. Client 입장에서 이런 생성기를 사용했을 때 불변식이 만족하는 객체를 얻게된다. 하지만 이런 불변식은 생성된 객체의 구현 세부사항을 알고 있는 Factory나 Generator 내부적으로 검사하는 것이므로 Client 입장에서는 생성기의 행위가 암시적이 된다.
 
Client 입장에서 암시적인 행위라는것이 생성된 객체의 구현 세부사항을 가려주는 역할을 하지만 만약 Client가 특정한 Specification을 만족하는 객체가 생성되는가 라는 질문에 대해 답을 얻고 싶다면 걸림돌이된다. 이를 해결하려면 생성기의 인터페이스에 Specification을 추가하면 된다.
 
Specification을 포함해서 생성기의 인터페이스를 정의하면 다음과 같은 이점이 있다.
 
  • 생성기의 구현을 I/F로 부터 분리한다. Specification이 생성할 결과물에 대한 요구사항을 선언하지만 생성을 어떻게 할것인가를 정의하지는 않는다.
  • 세부적인 연산 없이 생성 규칙을 명시적으로 전달한다.
  • 생성기에 대한 입력을 정의하는 명시적인 방법이 모델에 있게 된다. 동시에 이를 그대로 다시 사용해서 결과물을 검증하는데 사용할 수 있다.
 
 

'Concepts > SW Design' 카테고리의 다른 글

DDD - Side effect free function  (0) 2022.10.22
DDD - Inteface  (0) 2022.10.01
DDD - 불명확한 개념  (0) 2022.09.19
DDD - Repository  (0) 2022.09.18
DDD - Factory  (0) 2022.09.12

댓글