Skip to content

Domain Driven Design

JooHyung Kim edited this page Aug 30, 2018 · 2 revisions

Concepts

it-chain-Engine에서 적용 중인 아키텍쳐, 디자인에 대해서 조금 더 자세하게 알아보고 개념에 대해서 적어보려고 한다. 그 중에 첫 번째가 DDD(Domain-Driven-Design)이다.

Domain-Driven Design과 관련해서 엄청나게 많은 중요한 컨셉들이 있지만 여기서 그것들에 대해서 모두 다루는 것은 아니고 중요하다고 생각하는 개념들에 대해서 나열하고 그것들에 대해서 설명해보려고 한다. 또한 it-chain/engine 에서는 그것들이 어떻게 적용되었는지 살펴보려고한다.

먼저 다음과 같은 개념들에 대해서 적어보려고 한다.

  • Ubiquitous language
  • Layers
  • Bounded contexts
  • Anti-Corruption Layer
  • Shared Kernel

Ubiquitous language

소프트웨어 개발에서 계속 생기는 문제 중 하나는 코드를 보면서 이것이 무엇을 하는 것이고, 어떻게 동작하는지에 대해서 이해하는 것이 어렵다는 것이다. 만약 코드를 만든 사람이 어떤 코드에 대해서 'A'라고 말하는데 실제로 코드는 'B'와 관련된 것이라면, 그 코드를 보는 사람들은 더더욱 혼란스러워질 것이다. 하지만 이러한 문제들은 class와 method에 더욱 적절한 네이밍을 해주면 어느정도 사라지게 될 것이다. 여기서 '적절한 네이밍'이란 도메인 컨텍스트에 대해서 어떤 object가 어떤 일을 하고 어떤 method가 어떤 일을 하는지 더욱 명확하게 표현하는 것을 말한다.

Ubiquitous Language의 메인 아이디어는 앞으로 구현하게 될 application 및 technology와 요구사항에 해당하는 business을 매칭시키는 것이다. 매칭시킨다는 의미는 둘 사이간에 의미를 공유할 수 있는 공용어(common language)를 두는 것이다. 그리고 이렇게 만들어진 공용어를 이용해 코드를 작성하게 된다. 요구사항에 해당하는 business에서 용어의 컨셉을 가져온다 그 다음 그것을 구현할 technology 쪽에서 그것을 다듬고 확정하게 된다. (business에서 가져온 컨셉으로는 항상 좋은 네이밍을 가져가기 힘들다.) 이렇게 둘 사이의 입장을 반영한 용어를 만듦으로써 우리는 business와 technology에서 사용할 수 있고, 앞으로 우리가 작성할 코드에서도 모호함 없이 사용할 수 있다. 이것이 Ubiquitous Language라고 할 수 있다. 코드에서 사용될 class, methods, properties, modules의 네이밍은 Ubiquitous Language와 매칭돼야 한다.

예를 들어보자. blockchain 컴포넌트에서 해결해야하는 문제 중에 한 가지는 block들이 모두 똑같은 상태가 아니라는 것이다. 첫 번째로 다음과 같은 상태의 block이 있을 수 있다. 네트워크의 리더가 transaction들을 모아서 막 block을 만들었을 때다. 이 때의 block은 어디에 저장되지도 않았으며 심지어 네트워크 구성원들에게 합의되지도 않았다. 즉 '저장되지 않았고, 합의되지도 않은 block'이 그것이다. 두 번째로는 '합의는 되었지만 아직 저장되지 않은' 상태이고 마지막으로는 '합의가 되어서 저장된' 상태이다.

blockchain 컴포넌트를 개발해야하는 개발자들은 이 세 가지 상태에 대해서 용어를 만들어 내고 그것을 이용해서 코드로 표현해야했다. 그래서 각각의 상태에 대해서 개발자들은 'Created', 'Staged', 'Commited' 라는 용어로 정의했다. 이 용어가 Ubiquitous language라고 할 수 있다. blockchain 컴포넌트를 개발하는 개발자들은 다른 개발자가 CreatedBlock 과 같은 표현을 코드에서 발견했을 때 이 변수가 어떤 일을 해야하고 어떤 일은 하지말아야할지 예상할 수 있다. 왜냐하면 'Created'라는 Ubiquitous language로 우리는 어떤 상태를 정의했기 때문이다.

Layers

다른 디자인에서도 layer라는 컨셉은 사용되지만, DDD에서 특징적인 layer는 다음과 같다.

  • User Interface

    User Interface에서는 사용자들이 상호작용할 수 있는 screen을 만들고 사용자들의 input을 application의 명령들(commands)로 변환한다. 여기서 중요한 점은 **사용자(user)**들은 사람이 될 수도 있지만 어떤 application이 다른 application의 api를 사용한다면 그 application도 사용자가 될 수 있다.

  • Application Layer

    사용자들이 요구하는 tasks들을 수행하기위해 domain object들을 사용한다. Application Layer는 busniess logic을 가지고 있지 않고 Application Service가 포함된다. Application Service는 domain object에 해당하는 repository, domain service, entity, value object을 가지고 그것들을 조합함으로써 필요한 목표를 달성한다.

  • Domain Layer

    Domain Layer에서 Domain Services, Entities, Events와 같은 domain object들은 필요한 모든 business logic들을 담고 있다. 그렇기 때문에 Domain layer가 전체 시스템에서 핵심이라고 할 수 있다. Domain Services는 Entity에 딱 맞지 않는 로직이 들어가거나 혹은 몇몇 Entity들을 이용해서 business logic을 처리한다.

  • Infrastructure

    persistence나 messaging과 같이 상위 계층에서 layers들을 서포트해주는 것들이 포함된다.

Application Layer는 각 컴포넌트의 api 패키지에 들어있다.

func (t TransactionApi) CreateTransaction(txData txpool.TxData) (txpool.Transaction, error) {

	transaction, err := txpool.CreateTransaction(t.publisherId, txData)

	if err != nil {
		log.Printf("fail to transaction: [%v]", err)
		return txpool.Transaction{}, err
	}

	err = t.transactionRepository.Save(transaction)

	return transaction, err
}

txpool의 CreateTransaction api 함수이다. 이 함수가 하는 일은 다음과 같다. domain의 CreateTransaction 함수로 transaction을 만들어내고 성공적으로 만들어내면 transactionRepository 로 해당 trasnaction을 저장한다. 위에서 볼 수 있듯이 Application Layer에서는 domain들을 이용해서 'transaction을 만든다'라는 application의 요구사항을 처리하고 있다. 그리고 business logic들은 모두 감추었다는 것을 알 수 있다.

type Transaction struct {
	ID        TransactionId
	TimeStamp time.Time
	Jsonrpc   string
	ICodeID   string
	Function  string
	Args      []string
	Signature []byte
	PeerID    string
}

func CreateTransaction(publisherId string, txData TxData) (Transaction, error) {

	id := xid.New().String()
	timeStamp := time.Now()

	transaction := Transaction{
		ID:        id,
		PeerID:    publisherId,
		TimeStamp: timeStamp,
		ICodeID:   txData.ICodeID,
		Jsonrpc:   txData.Jsonrpc,
		Signature: txData.Signature,
		Args:      txData.Args,
		Function:  txData.Function,
	}

	return transaction, nil
}

Domain Layer는 각 컴포넌트의 루트에 위치하고 있다. 위의 코드는 txpool의 transaction.go이다. Application layer에서 사용되던 txpool.CreateTransaction의 함수의 내부를 볼 수 있다. 코드를 보면 알 수 있듯이 TxData를 받아서 transaction을 만드는 작업을 하고 있다. 이와 같이 business logic들은 모두 domain 내부로 숨어든다.

마지막으로 Infrastructure layer이다. Infrastructer layer는 각 컴포넌트의 infra 패키지에 위치하고 있다.

type blockRepository struct {
	mux *sync.RWMutex
	yggdrasill.BlockStorageManager
}
...
func (br *blockRepository) Save(block blockchain.DefaultBlock) error {
	br.mux.Lock()
	defer br.mux.Unlock()
	err := br.BlockStorageManager.AddBlock(&block)
	if err != nil {
		return ErrAddBlock
	}

	return nil
}

다음은 blockchain 컴포넌트의 BlockRepository이다. 코드를 보면 알 수 있듯이 내부에 yggdrasill 외부 라이브러리를 가지고 있다. 그리고 BlockRepsoitory가 하는 일은 block들을 level-db에 저장하는 역할을 한다.

br.BlockStorageManager.AddBlock(&block) 를 보면 알 수 있듯이 외부 라이브러리의 wrapper와 같은 역할을 해서 blockchain 컴포넌트의 business를 처리하는데 도움을 준다.

Bounded Contexts

application에서는 model이 커질 수 있고 그에 따라 같은 코드베이스에서 작업을 하는 개발자의 수가 늘어날 수 있다. 그런데 그에 따라서 두 가지 문제점이 발생할 수 있다.

  1. 한 코드 베이스에서 작업하는 개발자의 수가 늘어날수록 한 사람이 알아야하는 코드의 양이 많아지고, 여러명이 작업하기 때문에 코드를 이해하기도 어려워진다. 그렇기 때문에 버그나 에러가 생길 가능성이 커지게된다.
  2. 같은 코드 베이스에서 작업하는 개발자가 많아질수록 작업을 조율하기가 힘들어지고 같이 쓰는 domain 코드들이나 기술적인 부분이 많아지게 된다.

위와 같은 상황을 해결할 수 있는 방법 중 하나는 개발자들이 공동으로 작업해야하는 코드베이스들을 쪼개는 것이다. 그리고 이렇게 쪼갤 수 있게 도와주는 것이 "bounded context"이다.

Bounded context는 model들이 분리되어 적용될 수 있는 context를 말한다. 이 '분리'는 기술적인 분리나 코드의 분리, 데이터베이스 schema의 분리 그리고 팀의 분리 등을 말한다. 이러한 분리의 여러 단계 중에서 어디까지 실제 프로젝트에서 적용할지는 그때 그때 상황과 필요에 맞춰서 이뤄진다.

그런데 이러한 bounded context를 이용한 분리라는 개념은 완전히 새로운 것은 아니다. Ivar Jacobson 저자가 DDD라는 개념이 나오기 전에 저술한 에 다음과 같은 구체적인 아이디어를 가지고 있었다:

  • 전체 system은 여러 개의 subsystem들로 이루어져있다. 그리고 그 각각의 subsystem들은 또다시 subsystem을 가질 수 있다. (중략...) Subsystem들로 전체 system을 구성하는 것은 그렇기 때문에 전체 system을 개발하고 유지보수할 수 있는 방법이다.
  • Subsystem의 과제는 객체들을 패키징하는 것이다. 그렇게 함으로써 전체 시스템의 복잡도가 줄어들게 된다.
  • 특정 기능과 관련된 모든 객체들은 같은 subsystem에 두어야한다.
  • 이렇게 특정 기능과 관련된 객체들을 묶는 것은 같은 subsystem에서는 strong function coupling을 가지고 다른 subsystem과는 weak coupling을 가지게하기 위해서다. (오늘날에는 low coupling, high cohesion으로 알려지고 있는 개념이다.)
  • (중략...) control object들을 subsystem에 두기 시작하였고 그것들과 강한 결합을 가지는 entity object들과 interface들을 같은 subsystem 내에 두었다.
  • object 사이에서 기능적으로 강한 결합을 가진 것들은 같은 subsystem 내에 두어야한다. (중략...)
    • 어떤 한 object의 변화가 다른 object에도 영향을 미치는가?(지금 이 개념은 현재 Robert C. Martin이 1996년도에 "Granularity"라는 논문에 서술한 The Common Closure Principle ― 같이 변하는 class들은 같은 package로 묶여야한다.―로 알려져있다.)
    • 어떤 object가 다른 object들에게 영향을 주고 있는가?(지금 이 개념은 현재 Robert C. Martin이 1996년도에 "Granularity"라는 논문에 서술한 The Common Reuse Principle ― 같이 사용되는 class들은 같은 package로 묶여야한다.―로 알려져있다.)
  • 또다른 subsystem 분리 기준은 그들 사이에서 최소한의 커뮤니케이션만 일어나야한다는 것이다.(low coupling)
  • 사이즈가 큰 프로젝트에서는 그렇기 때문에 subsystem을 나누는 다른 기준이 있을 수 있다. 예를 들어:
    • 다른 두 개발 팀은 다른 능력과 자원을 보유하고있고, 그에 따라 적절하게 작업량을 분배해야할 것이다.(두 팀이 지리적으로 떨어져있을 수도 있다.)

it-chain/engine 에서도 비슷한 기능을 하는 것들끼리 묶어서 bounded context를 만들어냈고 현재에는 blockchain, icode, consensus, p2p, txpool 등이 있다.

Anti-Corruption Layer

Anti-corruption layer는 기본적으로 두 subsystem 사이에서 middleware역할을 한다. 그리고 나눠진 subsystem들은 서로 직접적으로 의존하는 대신 anti-corruption layer에 의존하게된다. 이렇게 하게되면 한 subsystem을 완전히 다른 것으로 바꾸더라도 우리가 수정해야할 부분은 anti-corruption layer밖에 없다. 다른 subsystem들은 그대로 두어도 된다.

Anti-corruption layer는 legacy 시스템과 새로운 시스템을 합쳐야할 때 유용하다. legacy 구조가 새롭게 디자인한 구조에 영향을 주지 않으려면 anti-corruption layer를 두어서 새로운 시스템에 꼭 필요한 legacy system의 API만 가져오면 된다.

이것은 세 가지 역할을 가지고 있다:

  1. Client subsystem이 필요한 API를 또다른 subsystem에서 가져오는 것
  2. Subsystem 사이에서 data와 command를 전달하는 것
  3. 단방향이든 양방향이든 필요함에 따라 서로 다른 subsystem들 사이에 communication을 형성해주는 것

이것은 여러 subsystem들을 직접 제어하고 싶지 않을 때 더욱 유용하다. 그런데 이것은 모든 subsystem들을 컨트롤하고 싶을 때에도 유용할 수 있다. 그리고 아주 성격이 다른 model들이 있을 때와 한 model을 바꿨을 때 나머지 시스템에 줄 수 있는 영향을 줄이고 싶을 때에도 좋다.

it-chain/engine에서도 각 컴포넌트들이 서로 communication 해야할 때가 있다. 이 때 두 컴포넌트 혹은 다수의 컴포넌트들이 직접적으로 데이터를 주고 받으면 서로에 대해서 의존성이 생기기 때문에 bounded context가 깨지고 한 컴포넌트 내에서 수정해야할 사항이 생기면 그 변화가 다른 컴포넌트에 직접적으로 영향을 미치게 된다.

it-chain/engine에서는 common/rabbitmq 패키지의 pubsubrpc를 통해 anti-corruption layer을 두고 각 컴포넌트들은 서로 직접 communication을 하는 대신 이 layer와 communication을 하게 된다.

Shared Kernel

우리가 아무리 컴포넌트를 분리시키고 decouple 하려고해도 어떤 domain 코드들은 여러 컴포넌트에서 공유하는게 더 좋을 때도 있다.

이렇게 공유가 필요한 코드들을 여러 컴포넌트에 공유하는 부분이 Shared Kernel이고, Shared Kernel을 두게 되면 shared kernel과는 강하게 결합이 생기지만 나머지 컴포넌트끼리는 여전히 분리시킬 수 있다.

예를 들어, 어떤 컴포넌트가 event를 전파시키고 다른 컴포넌트들이 그 event를 listen하는 경우라면 event가 shared kernel 부분에 들어갈 수 있다. 물론 service interface나 entity들도 shared kernel에 포함될 수 있다.

그렇지만 shared kernel은 가능한 작게 두는 것이 좋다. 그리고 shared kernel 코드는 다른 컴포넌트들에서 사용될 가능성이 높기 때문에 이 부분을 수정할 때 어떤 다른 부분에 영향을 주는지 확인하는 것이 좋다. 그렇기 때문에 shared kernel 코드들을 수정할 땐 전체 팀원과 충분한 상의와 합의 후에 작업하는 것이 좋다.

it-chain/engine에서 대부분의 shared kernel은 common 패키지에 위치해있다. 최근의 큰 결정 중 하나는 컴포넌트끼리 communication 할 때 사용되는 event와 command를 primitive 타입으로 바꾸고 common 패키지로 위치를 옮긴 것이다. 원래 위치는 각 컴포넌트 내에서 각자가 사용하는 event와 command를 두었는데 이를 밖으로 꺼내고 primitive 타입을 사용하는 이유는 다음과 같다:

  • 각 컴포넌트 내에 두었을 때는 상대 컴포넌트와 소통하기 위해서 상대 컴포넌트가 사용하는 event, command의 모양을 알아야했다. 이는 실제 개발을 할 때 여러 컴포넌트의 패키지를 열어봐야한다는 번거로움이 있다. 또한 상대방의 정보를 알아야한다는 점에서 bounded context가 깨진 것처럼 느껴진다.
  • primitive 타입을 사용하기전에는 각 컴포넌트에서 정의한 domain 객체들이 event와 command에 들어갔다. 이것은 첫 번째 이유보다 더 심각하다. 예를 들어, blockchain 컴포넌트 내에서 txpool과 communication 하기 위한 event를 만들려면 txpool.Transaction을 알아야할 필요가 있었다. 이렇게 되면 blockchain 컴포넌트 내에 txpool을 import 해야하는데 이것은 서로에 대해서 더 심각하게 의존성이 생기게 된다.

Reference

Author

@zeroFruit