Golang DDD를 배워보자 - 3일차 Entity, Value, Aggregates

0

golang ddd

목록 보기
3/5

Entities, Value Objects, Aggregates

https://www.amazon.com/Domain-Driven-Design-Golang-maintainable-business/dp/1804613452
이번에는 DDD의 핵심 요소 중 하나인 Entity와 Value object, Aggreagtes를 배워보도록 하자.

1. Working with entities

DDD에서 entity는 변하지 않는 identity로 정의된다. entity는 수많은 attributes를 가질 수 있으며, 이 attribute들은 계속해서 달라진다.

가령, 다음의 ER모델을 보도록 하자. 경매 사이트를 만든다고 하자.
1. auctions: 경매
2. user: 유저
3. bids: 경매 입찰

[그림 3.1]
일부 동작들은 각 entity들의 attribute들을 변경할 수 있다. 가령, user의 email address를 변경할 수 있으며, 경매의 end time을 업데이트 할 수도 있다.

그럼에도 불구하고, 각 entity들의 identity는 변하지 않는다. 여기서 identity는 userid, auctionsid, seller_id 등이 있다.

identity는 entity의 attributes이지 별다른 것은 아니다. 시스템이 설정해줄 수 있고, 사용자가 직접 입력할 수도 있다. 단, 변하지않는 규칙은 지켜야 한다. 가령 email을 보면 하나의 식별자로 쓸 수 있을 것 같지만 email은 식별자가 되지 못한다. email은 user가 맘대로 변경할 수 있는 부분이기 때문이다.

그러나 entity의 identity는 생각보다 정의하기 어려운데, 이는 겹치면 안되고 유추하기 쉬우면 안된다는 특성이 있기 때문이다. 특히나 컴퓨터는 수 표현이 제한적이기 때문에, 수많은 이용자가 이용하는 대규모 시스템에서는 이 한계가 명확하다. 때문에 UUID와 같은 identity를 사용하는 것이 좋다.

2. A warning when defining entities

identity를 가진 entity에 집중하기 때문에, database design이 entity(domain model)을 어떻게 구성할 지 강제할 수 있는 문제가 생길 수 있다. 이는 anemic domain model로 잘 알려져 있다.

anemic models는 model의 디자인 자체에 어떠한 domain behavior이 없는 것을 말한다. 이는 DDD의 이점이 없는 것을 의미하며, DDD을 하는 이유가 사라져버린다. anemic model은 진단하기 매우 쉬운데, 만약 model이 어떠한 비지니스 로직이 없이 getter, setter 밖에 없거나 비지니스 로직이 있다하더라도 다른 model의 비지니스 로직을 가져오기만 하는 경우는 anemic model이라고 한다. anemic model은 간단히 말해서 자체적인 비지니스 로직이 없는 객체라고 볼 수 있다.

아래는 anemic entity를 나타낸다.

  • main.go
package main

import (
	"time"

	"github.com/Rhymond/go-money"
)

type AnemicAuction struct {
	id            int
	startingPrice money.Money
	sellerID      int
	createdAt     time.Time
	auctionStart  time.Time
	auctionEnd    time.Time
}

func (a *AnemicAuction) GetID() int {
	return a.id
}
func (a *AnemicAuction) StartingPrice() money.Money {
	return a.startingPrice
}
func (a *AnemicAuction) SetStartingPrice(startingPrice money.Money) {
	a.startingPrice = startingPrice
}
func (a *AnemicAuction) GetSellerID() int {
	return a.sellerID
}
func (a *AnemicAuction) SetSellerID(sellerID int) {
	a.sellerID = sellerID
}
func (a *AnemicAuction) GetCreatedAt() time.Time {
	return a.createdAt
}
func (a *AnemicAuction) SetCreatedAt(createdAt time.Time) {
	a.createdAt = createdAt
}
func (a *AnemicAuction) GetAuctionStart() time.Time {
	return a.auctionStart
}
func (a *AnemicAuction) SetAuctionStart(auctionStart time.Time) {
	a.auctionStart = auctionStart
}
func (a *AnemicAuction) GetAuctionEnd() time.Time {
	return a.auctionEnd
}
func (a *AnemicAuction) SetAuctionEnd(auctionEnd time.Time) {
	a.auctionEnd = auctionEnd
}

비지니스 로직없이 getter와 setter가 쓰인 code이다. 코드 자체에는 문제가없지만 domain model로서 어떠한 이점을 얻을 수 없기 때문에 anemic model이다. 왜 anemic model일까? DDD에서 domain model은 logic에 초점을 둔다. 로직이 어떻게 동작할 것이고, 이를 제어하고 설계하는 것이 개발자의 몫인 것이다. 그러나 이러한 비지니스 로직이 없는 위 코드는 attribute를 사용자가 개발자가 원치 않는 방향으로 설정할 수 있으며, 고정된 behavior이 없이 사용자의 behavior로 설정할 수 있다. 만약, createAt을 사용자가 BC 100년으로 설정하면 어떻게 될까?? 정말 원치않은 로직이 동작하게 될 것이다. 그렇기 때문에 DDD로 domain model을 설계하는 의미가 없어지는 것이고, 이점이 사라지는 것이다.

아래는 위 코드의 anemic model을 수정한 코드이다.

  • main.go
package main

import (
	"errors"
	"time"

	"github.com/Rhymond/go-money"
)

type AuctionRefactored struct {
	id            int
	startingPrice money.Money
	sellerID      int
	createdAt     time.Time
	auctionStart  time.Time
	auctionEnd    time.Time
}

func (a *AuctionRefactored) GetAuctionElapsedDuration() time.Duration {
	return a.auctionStart.Sub(a.auctionEnd)
}
func (a *AuctionRefactored) GetAuctionEndTimeInUTC() time.Time {
	return a.auctionEnd
}
func (a *AuctionRefactored) SetAuctionEnd(auctionEnd time.Time) error {
	if err := a.validateTimeZone(auctionEnd); err != nil {
		return err
	}
	a.auctionEnd = auctionEnd
	return nil
}
func (a *AuctionRefactored) GetAuctionStartTimeInUTC() time.Time {
	return a.auctionStart
}
func (a *AuctionRefactored) SetAuctionStartTimeInUTC(auctionStart time.Time) error {
	if err := a.validateTimeZone(auctionStart); err != nil {
		return err
	}
	// in reality, we would likely persist this to a database
	a.auctionStart = auctionStart
	return nil
}
func (a *AuctionRefactored) GetId() int {
	return a.id
}
func (a *AuctionRefactored) validateTimeZone(t time.Time) error {
	tz, _ := t.Zone()
	if tz != time.UTC.String() {
		return errors.New("time zone must be UTC")
	}
	return nil
}

수정되지 않고 싶은 attributes들은 노출하지 않도록 하며, 원치 않는 입력값이 오게되면 이를 유연하게 처리하도록 코드를 변경한 것이다. 이렇게 되면 domain model의 로직이 타인에 의해서 결정되는 것이 아니라, 개발자가 직접 로직을 설정할 수 있다는 장점이 생긴다. 이것이 바로 DDD의 이점이 되는 것이다.

그럼 왜 database design 대로 domain model을 design하면 anemic model이 만들어질 수 있다는 것일까? 시스템이 대규모로 개발되고 점점 발전하면서 여러 attribute들이 database에 추가되게 된다. 가령, 광고를 위해 위 AuctionRefactored객체에 얼마나 사람들이 많이 봤는 지에 대한 views attributes를 추가한다고 하자. 이러한 attributes가 추가되는 것은 사실 AnemicAuction의 비지니스 로직에 아무런 영향을 미치지 않는다. 때문에, DDD의 domain 입장에서는 어떠한 변화도 없는 것이다. 그러나, 대부분 이러한 부분을 무시하고 database design의 변화를 domain model에 적용하려고 한다. 이 때문에 실제 비지니스 로직과는 관련없는 데이터들이 entity(domain model)에 붙어 anemic model 현상이 발생하는 것이다. 이는 또한, database model은 data 중심으로 설계하는 반면, domain은 비지니스 로직, 행동 중심으로 domain을 설계하기 때문에 발생하는 불일치 문제인 것이다. 따라서, database design의 설계 자체는 문제가 없을지라도 이를 DDD에 적용하는 것은 문제가 있다.

이제 entity를 이해했으니 value object를 보도록 하자.

3. Working with value objects

value objects는 entity의 반대로, identifier가 없고 value로만 가득찬 객체이다. 이는 entity들과 aggregate들의 접속사로 쓰이며 우리의 domain model을 풍부하게 만들어준다. 재밌는 것은 identifier가 없기 때문에 두 value object들이 같은 value로만 이루어져 있다면, 이들이 동일한 value라고 평가할 수 있다.

  • main.go
package main

type Point struct {
	x int
	y int
}

func NewPoint(x, y int) *Point {
	return &Point{
		x: x,
		y: y,
	}
}

다음의 Point객체가 바로 value object이다. 식별자가 없고 값으로만 이루어져 있다. 위에서도 언급했듯이 value object는 value로만 평가가 이루어지기 때문에, 같은 value를 가진 value object들은 서로 동일한 것으로 평가된다는 것이다. 여기서 한 가지 조심해야할 점이 있다.

먼저, test code를 만들어보도록 하자.

  • main_test.go
package main

import "testing"

func TestPoint(t *testing.T) {
	a := NewPoint(1, 1)
	b := NewPoint(1, 1)
	if a != b {
		t.Fatal("a and b were not equal")
	}
}

다음의 테스트 코드는 서로 다른 a,b value object를 만들고 서로 같은 지 비교하는 코드이다. 문제는 서로 같은 value를 가지지만, 서로 같지 않다는 결과가 나올 것이다.

이는 golang의 특성인데, NewPoint의 반환값이 *Point이고 메모리 위치가 반환된다는 것이다. 즉, ab는 서로 다른 메모리 위치를 가지고 있으며, 서로 다른 메모리를 가지고 비교했기 때문에 서로 다르다라는 테스트 결과가 나온 것이다.

이를 해결하기 위해서는 NewPoint의 반환값을 Point로만 바꾸면 된다. 이렇게되면 포인터를 비교하는 것이 아닌, 구조체가 가진 value를 하나하나 비교하게 된다. 따라서, value object를 사용할 때는 포인터를 가급적 사용하지 않는 것이 좋거나, 따로 비교 연산자를 만들어주는 것이 좋다.

type Point struct {
   x int
   y int
}
func NewPoint(x, y int) Point {
   return Point{
      x: x,
      y: y,
   }
}

다시 테스트 코드를 실행하면 성공할 것이다.

value object의 또 다른 특징으로는 immutable한 특징이 있다. 위의 Point객체는 attribute x, y를 소문자로 사용해 일반 client가 변경하지 못하도록 한 것을 알 수 있다. 이는 immutability 즉, 불변성을 지키기 위한 것으로 client가 예기치 못한 결과를 발생시키지 않도록 하는 것이다.

immutability를 지키기 위해 value object는 또 다른 특징을 가지는데, 바로 replaceable해야 한다는 것이다. 즉, value object의 상태를 바꿔가면서 사용하는 것이 아니라, 새로운 value object로 통채로 변경하여, 하나의 value object의 상태 변화에 의존하는 것이 아닌, value 그 자체에만 의존하도록 하는 것이다.

다음의 예제를 보도록 하자.

  • main.go
package main

type Point struct {
	x int
	y int
}

func NewPoint(x, y int) Point {
	return Point{
		x: x,
		y: y,
	}
}

const (
	directionUnknown = iota
	directionNorth
	directionSouth
	directionEast
	directionWest
)

func TrackPlayer() {
	currLocation := NewPoint(3, 4)
	currLocation = move(currLocation, directionNorth)
}
func move(currLocation Point, direction int) Point {
	switch direction {
	case directionNorth:
		return NewPoint(currLocation.x, currLocation.y+1)
	case directionSouth:
		return NewPoint(currLocation.x, currLocation.y-1)
	case directionEast:
		return NewPoint(currLocation.x+1, currLocation.y)
	case directionWest:
		return NewPoint(currLocation.x-1, currLocation.x)
	default:
		//do a barrel roll
	}
	return currLocation
}

move 함수는 currLocation을 받아서 direction대로 움직이고 Point로 반환한다. 재밌는 것은 currLocation 객체 자체를 수정하는 것이 아니라, 방향을 움직인 Point객체를 새로 반환하는 것이다. 즉, 원래의 객체를 새로운 객체로 replace한 것이다.

이는 currLocation 자체를 변경하지 않았고, 새로운 객체를 반환했다는 측면에서 immutability를 성립 보장한다. 이렇게 만든다면, move함수에 의해서 발생하는 side effect가 없고, 원치 않는 결과를 얻지 않을 수 있다.

또한, immutability가 보장되므로 side effects가 없어 테스트할 때도 어렵지 않게 unit test를 만들 수 있다.

마지막으로 value object가 보장해야하는 특성 3가지를 정리하면 다음과 같다.
1. value object는 같은 값을 가지면 같은 객체로 취급되어야 한다.
2. value object는 immutable하여 클라이언트가 마음대로 변경하지 못하도록 해야한다.
3. value object는 replacable하여 새로운 value object로 대체할 수 있어야 한다.

4. How should i decide whether to use an entity or value object?

domain 모델링을 할 떄, value object를 가능하면 자주 사용해야하는데 value object는 immutability를 보장하기 때문에 가장 안전한 구조체로 여겨지기 때문이다. 사실 가능하면 value object로 만들고, 막상 만들었더니 별로일 때는 entity로 만드는 것이 좋다.

value object로 만들지 entity로 만들지에 대한 기준은 다음과 같다.
1. domain concept를 설명하거나, 평가하기 위해 사용하는가?
2. immutability가 지켜지는가?
3. 같은 타입을 가진 객체끼리 value로만 비교가 가능한가?

이 모든 질문들에 yes라고 대답한다면 value object로 사용하면 된다. 이제 entity와 value object 지식을 토대로 aggregate pattern을 알아보도록 하자.

5. The aggregate pattern

aggregate는 이름에도 유추할 수 있듯이 하나의 집약체이다. 문제는 DDD개념에서 가장 어려운 개념이라서 많은 사람들이 이를 헷갈려하고, 잘못된 구현을 한다는 것이다. 잘못된 구현을 한 순간부터는 겉잡을 수 없이 구현이 이상해지며, 개발 속도에 큰 방해가 된다.

aggregate pattern은 domain object들의 그룹을 말하며, 이들을 같은 behavior에 대한 하나의 집합체로 다루기 위해 사용한다. 가령 다음과 같다.

  1. An order: 어떠한 주문은 일반적으로 개별적인 아이템들로 이루어진다. 음식점에서 주문에 대해 처리하고 계산할 때에 하나의 주문으로 처리하듯이 이 개별 아이템들을 어떠한 목적에 맞게 하나로 처리하는 것이 좋다.
  2. A team: team은 여러 종사자들로 이루어진다. 시스템에서 종사자들을 하나의 domain object로 보고, 이들을 하나로 그룹핑하여 이들의 행동을 하나의 team으로서 적용시킨다면, '부서'와 같은 시스템에서 매우 효율적으로 처리할 수 있는 일들이 많을 것이다.
  3. A wallet: 일반적으로 wallet은 수많은 카드들과 각 나라들의 현금, 또는 암호화폐로 이루어져 있다. 다음의 그림처럼 처리할 수 있을 것이다.

[그림3.2]

aggregates가 집약체 역할을 하는 것을 보고 종종, array나 maps, slice과 같은 collection으로 오해할 수 있다. aggregate가 collection을 사용한다해도 aggregate는 DDD concept이지 어떠한 collection이 아니다. 즉, aggregate는 여러 collection들과 fields, functions, method들을 가질 수 있다. aggregate pattern은 domain object들의 transaction boundary로 동작한다. aggregates의 로딩, 저장, 수정, 삭제는 반드시 aggregate안의 모든 item들에게 발생해야하거나 또는 전혀 발생하지 않아야 한다. 예시를 보도록 하자.

  1. 만약 order(주문)이 취소되면, 주문안에 있는 모든 item들을 재고로 돌려보내야 한다.
  2. 만약 새로운 종업원이 team에 합류하면, team 조직도를 변경해야한다.
  3. 만약 새로운 카드가 wallet에 추가된다면 wallet의 새로운 카드의 잔금이 wallet 전체의 잔금에 합산되어야 한다.

다음은, wallet aggregate를 구현한 코드이다.

  • main.go
type WalletItem interface {
	GetBalance() (money.Money, error)
}
type Wallet struct {
	id          uuid.UUID
	ownerID     uuid.UUID
	walletItems []WalletItem
}

func (w Wallet) GetWalletBalance() (*money.Money, error) {
	var bal *money.Money
	for _, v := range w.walletItems {
		itemBal, err := v.GetBalance()
		if err != nil {
			return nil, errors.New("failed to get balance")
		}
		bal, err = bal.Add(&itemBal)
		if err != nil {
			return nil, errors.New("failed to increment balance")
		}
	}
	return bal, nil
}

Wallet은 aggregates이고, id를 가지고 있어, aggregate도 identity를 가질 수 있다는 것을 알 수 있다. ownerID는 wallet을 소유한 사용자의 identity로 entity에 속한다. WalletItem은 interface로 Wallet에 필요한 연산을 담고있는 domain object이다.

WalletGetWalletBalance라는 연산을 통해서 잔금이 얼마나 있는 지 확인할 수 있는데, 이 연산이 호출될 때마다 자신의 walletItems를 모두 가져와 합산한다. 이 과정은 transcation으로 이루어져야 하기 때문에 balance를 더하는 도중에 error가 발생하면, 이전까지 합산했던 balance가 아닌, nil이 반환된다.

DDD 설계를 하다보면 aggregates가 무엇인지 찾기는 매우 힘들다. 그래서 aggregates를 설정하는 꿀팁 중 하나는 먼저, bounded context's invariants를 찾는 것이다. 이는 어떠한 비지니스 로직의 상수를 말하며 반드시 지켜져야할 사항이다. 가령, 음식점에서 음식을 시키기 위해서는 해당 음식의 재고가 남아있어야 한다. 만약, 재고가 없다면 음식을 제공할 수 없으며 order가 있을 수 없다.

이러한 businness invariants를 바탕으로 aggregate를 찾을 수 있다. 또한, aggregates는 transactional consistency boundary로 생각할 수 있다. 따라서, 우리의 domain안에서 변화가 이루어 질 때마다, aggregates 안에서의 context만 변화하는 것이지, 외부의 다른 aggregates에게는 영향을 미치지 않게된다. 즉, 하나의 transaction 당 오직 하나의 aggregate만 수정하면 된다는 것이다. 만약, 다른 aggregate도 영향을 미치면 이는 잘못된 설계이다.

즉, aggregates는 bounded context안에서 transaction을 실행하여 domain model의 비지니스 로직 영향을 제한하며, 이는 context의 invariant를 보장하도록 하는 것이다.

6. Designing aggregates

일반적으로 aggregates를 작게두는 것을 목표로 삼아야 한다. aggregates를 작게 유지하는 것은 시스템을 더욱 scalable하게 만들고, 성능을 향상시키며 transaction의 성공률을 높인다. 여러 멀티 유저가 사용하는 order system을 예시로 들어보자. 이 유저들은 동시에 order을 웹사이트에서 신청할 수 있다고 한다.

type item struct {
	name string
}

type Order struct {
	items          []item
	taxAmount      money.Money
	discount       money.Money
	paymentCardID  uuid.UUID
	customerID     uuid.UUID
	marketingOptIn bool
}

Order aggregates는 언뜻보면 문제가 없어 보이지만, marketingOptInOrder과 크게 관련이 없어보인다. marketingOptIn는 마케팅에 Order가 쓰이는 지 동의를 구하는 변수인데, 다음의 이유로 Order에 있어서는 안된다.

  1. marketingOptInOrder의 bounded context perspective에서 관련이 없다. 즉, 주문에 영향을 미칠 사항이 아니라는 것이다.
  2. 만약 order의 transaction중에 유저가 마케팅에 대해서 동의를 안한다고, Order가 취소되거나 진행이 되지 않는다면 이는 사용자가 원하는 바가 아니다. 즉, 위와 같이 marketingOptIn는 주문에 영향을 미칠 사항이 전혀없다는 것이다. 따라서, marketingOptIn을 삭제하는 것이 좋다.

0개의 댓글