최근 인기 있는 설계 방식 DDD를 알아보자
실제 채용 공고에 적혀있는 도메인 주도 설계(DDD)에 관한 내용이다. 당근 마켓 채용 공고에 적혀있는 내용이기에, DDD에 관한 관심도를 알아볼 수 있다.
도메인 주도 설계라는 내용을 잘 다루고 있다는
"도메인 주도 설계 철저 입문" 라고 하는 책을 다룰 예정이다
이 책은 도메인 주도 개발의 입문서로 쓰면 정말 괜찮은 책이다. 단점은 C#으로 되어있어서 다른 프로그래밍 언어를 처음 보는 사람에게는 힘들 수도 있지만, 기초적인 class, 문을 다룬 경험이 있다면 충분히 이해할 만하다
간단한 개념부터 잡고 가자
도메인 주도 개발이란?
"도메인" + "지식에 초점"을 맞추어 개발한다.
도메인이란?
프로그램에 쓰이는 대상 분야를 의미한다. 하지만 더 중요한 부분은 도메인에 무엇이 포함되는 가?이다.
회계 시스템에는 금전, 장부 같은 개념이 있고
물류 시스템에는 창고, 화물, 운송수단 같은 것들이 있다.
지식에 초점을 맞춘다?
이용자들의 문제를 정확하게 이해하는 것이다.
이용자들의 문제를 이해하고, 그 과정에서 문제 해결에 유용한 것을 뽑아낸 지식을 소프트웨어에 반영하는 것이다.
도메인 주도 설계에서 소개하는 예시는 사용될 "도메인"의 지식에 초점을 맞추는 과정을 도와주는 수단일 뿐이다.
당연히 해야 할 내용을 돕는 수단이다.
도메인 모델링이란?
모델은 현실에 일어나는 사건 또는 개념을 추상화한 개념이다.
당연히 모든 부분이 들어갈 필요는 없고, 할 수도 없다.
이는 그냥 단순히 추상화해서 표현한 지식일 뿐이다. 실제 사무랑은 점점 떨어질 수밖에 없다는 문제가 있다.
이를 위한 구체적인 사례가 도메인 객체이다.
도메인 객체 = 도메인 지식 + 소프트웨어 형태로 동작하는 모듈
도메인에 발생한 변화는 도메인 보델로 전달돼야 하고, 이렇게 된다면 변화를 충실히 반영할 수 있다.
이 정도만 알면 기초적인 배경 지식은 끝났고, 실제로 들어가 보자
값 객체
JS로 프로그래밍을 해봤거나, 다른 언어를 사용한 경우에도
값이라는 말과, 객체라는 말이 같이 쓰이기는 어렵다는 것을 알 수 있을 것이다.
큰 차이로는
값은 불변, 객체는 대부분의 경우 가변적이다. 그렇다면 왜 이 저자는 값 객체 라는 말을 만들었을까?
코드를 보면서 이해해보자
class UserId {
#id;
constructor(id) {
if (typeof id !== "string") {
throw new Error("Id must be string");
}
if (id.length < 10) {
throw new Error("Id must be longer than 10");
}
this.#id = id;
}
get getId() {
return this.#id;
}
// setId(){
// }
}
// const userId1=new UserId(5);//this will cause Error
// const userId2 = new UserId("5");//this will cause Error
const userId = new UserId("hello world");
console.log(userId.getId);
//hello world
console.log(userId["#id"]);
//undefined
여기서 봤을 때 userId라고 하는 값은 사실 그냥 단순 문자열일 뿐이다.
그냥 "hello world"라고 했을 때와 무슨 차이가 있을까?
검증 로직을 어디다 둘 수 있느냐에 차이가 있다.
이렇게 했을 경우 모든 상황에서 그냥 UserId("asdf");라고 하는 것은 에러를 만들어 낼 것이고, 규칙이 바뀌더라도 그냥 UserId만 와서 수정하면 끝이다
당연히 추후 변경에 대응하기 쉬워진다. 이것을 바깥에 빼둔다면 검증 로직이 매번 코딩될 수밖에 없다.
당연히 함수로 빼서
function validate(id){
if (typeof id !== "string") {
throw new Error("Id must be string");
}
if (id.length < 10) {
throw new Error("Id must be longer than 10");
}
}
같은 함수를 통해서 검증하는 것보다는 저렇게 모아두는 것이 객체 지향적인 관점에서 보아도 훨씬 좋은 코드가 된다
여기서 Setter가 없는 이유는 명확하다. 우리는 객체는 객체인데 값인 객체를 다루기 때문인데, "값"의 특징인 불변성을 유지시키기 위해서이다.
값이 변하면 어떤 문제가 발생할까?
"Hello". changeValue("world"); 를 했을 경우 "Hello"는 "world"일까? 아니면 바뀌지 않은 "Hello"일까?
라는 질문에 대한 답을 할 수가 없고, 추후 참조 과정에서도 문제가 생길 수밖에 없다.
그렇기에 setter를 두지 않고, 그냥 새로운 객체를 남기는 방식으로 불변성을 유지하는 방향으로 가독성과 안정성을 높인다.
불변성을 유지하기 위해서 당연하지만 setter를 두는 것보다는 대입을 통한 교환을 추천한다
저자의 코드는 C#이기에, 사실 간단하게 Equality를 override 할 수 있다. 그래서
userId 객체끼리 비교하는 과정을 override 할 수 있다는 의미이다.
그렇기에 userId1==userId2 같은 비교가 가능하지만, js는 prototype 기반 언어이고, class라는 것이 사실 존재하지 않아서?(이렇게 표현해도 되나?) override 할 수 없다.
그래서 직접 userId1==userId2 같은 것을 사용할 수 없다.
그렇기에 userId1.equals(userId2) 같은 메서드를 통해 비교해야 된다는 커다란 단점이 생긴다.
그럼에도 이것만 정하고 간다면 모든 로직을 모을 수 있다는 점에서 충분한 장점을 가지기도 한다.
값 객체 VS 원시 값
그런데 어디까지 그러면 원시 값을 쓰고, 어디까지 그냥 값을 써도 괜찮을까?
예를 들면 이름을 봤을 때 모든 글자 하나하나를 다 값 객체로 만들어서 알파벳만 올 수 있다 같은 조건을 달 수도 있지 않을까?
class NameChar {
constructor(char) {
if (typeof char !== "string" || char.length !== 1) {
throw new Error("not a part of name");
}
this.char = char;
}
}
이렇게 말이다.
당연하지만 정말 정말 귀찮고, 끔찍한 일이 될 것이다.
물론 어느 한쪽이 무조건 옳고, 틀린 것은 아니지만 기준이 있으면 좋을 수 있다.
저자는
1. 규칙이 존재하는가?
2. 낱개로 다루어야 하는가?
를 기준으로 둔다.
위의 예제를 보면 이름 한 글자는 당연히 규칙이 있지만(영어나 한글이 되어야 한다... 이모티콘은 안된다... 같은 예가 있을 수 있다)
낱개로 다룰 필요가 없기에 이렇게 둘 필요가 없다는 것이다.
물론 제약이 있다면 이름 전체를 하나의 값 객체로, 그리고 그 값 객체가 모든 validation을 담당하는 것으로 처리하면 간단해진다
당연하지만 규칙을 정하는 과정에서 개념이 새롭게 발견된다면 도메인 모델을 다시 수정할 필요도 있다.
값 객체의 장점
1. 표현력이 증가한다
당연히 그냥 이름보다는 여러 제약조건을 미리 알려주는 쪽이 더 가독성이 좋다
2. 무결성이 유지된다
constructor에서 모든 과정이 일어나기에, 잘못된 값을 만들기가 어려워지며 안정성이 높아진다
3. 잘못된 대입을 방지한다
원래는 그냥 string만 집어넣을 수 있다면 다 괜찮은 곳이었다가 UserId 형태만 가능하다 라는 조건이 붙는다면 당연하지만 안정성을 훨씬 높일 수 있다 타이핑을 추가로 받을 수 있다는 장점이 있다. (물론 Typescript에 한에서 의미가 있다)
4. 로직이 코드 곳곳에 흩어지는 것을 방지한다.
엔티티
엔티티란?
도메인 모델을 구현한 도메인 객체를 의미한다.
가변적이고, 속성이 같아도 구별할 수 있고, 동일성을 통해서 구별된다
그런데 값 객체도 도메인 객체이다. 그렇다면 차이는 뭘까?
키워드는 "동일성"이다.
동일성
값이란 1,2,3 같은 숫자부터 "ㅁㄴㅇㄹ"같은 문자열까지 다양한 부분에 있다.
그렇다면 값이 서로 같다는 것은 뭘까? 1은 1과 같고, 1은 2와 다르다. 같이 바로 생각할 수 있을 것이다.
그렇지만 엔티티에서는 그렇지 않다. 예를 들면 "철수"라고 하는 사람이 있으면 다른 "철수"라는 이름을 가진 사람과 같을까?
전혀 아니다. 그렇기에 속성이 아니라 동일성을 통해 식별된다라는 표현을 사용하며, "철수"와 "철수"를 구별할 수 있는 것을 가지게 된다.
당연하지만 "철수"가 "영희"로 이름을 바꾸는 것은 언제든 일어날 수 있기에, 영구적으로 남아있을 필요도 없다.
보통 시스템상으로 동일성을 구현하기 위해서 Identity(식별자)가 쓰인다 편하게 Id라고 생각해도 된다
어떨 때 값 객체를, 어떨 때 엔티티를 써야 할까?
1. 생애주기의 존재 여부 + 생애 주기의 연속성
값은 생애주기가 없거나 생애주기가 무의미하다면 값 객체로, 생애주기가 있다면 엔티티로 다루면 된다
예시를 보자
사용자를 회원 가입을 하면 사용자가 생기고, 탈퇴를 하면 삭제된다. 당연히 의미 있는 생애주기가 된다 따라서 이를 엔티티로 정하면 된다
당연하지만 엔티티도, 값도 상황에 따라, 우리가 주목하고 있는 도메인에 따라 바뀐다.
실 사용자는 모르지만 사이트 접속자 수를 보여주는 경우라고 생각하면 사용자 1명 1명이 생애주기가 있을까? 그냥 단순히 그 순간에 몇 명이 들어가 있는지만 중요할 것이다. 이럴 때는 그냥 사용자 수에 해당하는 정보만 있고, 숫자만이 중요하기에 값 객체로 나타내도 문제없을 것이다
당연하지만 도메인 객체를 정의하면
자기 서술적인 코드가 되고, 도메인에 변경사항이 있을 경우 훨씬 쉽게 반영할 수 있다는 장점이 있다
"도메인"+"서비스"
서비스
너무너무 다양한 의미로 범용적으로 사용되기에 이를 조금 구체화할 필요가 있다.
도메인 주도 설계에서는 2가지 종류의 서비스가 있다.
1. 도메인을 위한 서비스 => 도메인 서비스라 칭한다
2. 애플리케이션을 위한 서비스 => 애플리케이션 서비스라 칭한다
도메인 서비스
값 객체, 엔티티로 나타내기 매우 애매한 행동, 사항들이 존재한다. 보통 이런 부분을 다루어 주는 역할을 한다.
위에도 ID를 언급했기에 ID를 통해서 진행할 예정
ID는 사람마다 모두 달라야 한다라고 하는 조건이 있다고 생각했을 때 ID 중복 확인은 어디서 하게 되는 건가요? 라는 질문이 필요하고, 당연하지만 엔티티도, 값 객체도 어색하고 애매하다
이럴 때에만 도메인 서비스를 정의하고, 여기에 넣는다.
이는 굳이 인스턴스화 할 필요도 없기에, Static 필드로 정의하면 오히려 더 맞을 수 있다.
class UserService{
static #userRepository;
static{
this.#userRepository=import (#userRepository);
}
static Exist=(another)=>{
if(this.#userRepository.find(another)){
return true;
}
return false;
}
}
간단히 코드로 표현하면 이런식으로 해도 괜찮을 것 같다.
당연하지만 도메인 서비스는 모든 처리를 다 담당할 수도 있다. 예를들면, ID의 validation 역시도 도메인 서비스에 할당해버릴 수도 있고, 그렇게 되었을 때 값 객체로 지정해야했을 ID 객체는 그냥 constructor와 getter만 남게 된다.
이렇게 텅 비어버린 객체를 빈혈 도메인 객체라고 하고, 최대한 피해야 할 구현 사항이다.
Repository 패턴
당연하지만 ORM을 쓰는 거의 대부분의 경우 Repository 패턴을 적용해서 신경쓰지 않아도 되는 경우가 대부분이다.
그렇기에 간단하게 적을 것이다.
왜 사용할까? 위의 UserService 코드를 참고해보면 더욱 명확하게 볼 수 있다.
만약 Repository 패턴이 없었으면 어떻게 되었을까?
Select * from USERDB where id=userid 같은 문이 find 하는 부분에 들어갔을 것이다.
그렇게 되면 DB를 교체해야할 때마다 모든 코드들을 다 수정해야 한다.
그럴 때 이 Repository 패턴을 쓰게 되면 훨씬 간단하게 문제를 해결해야 한다.
직접 모든 코드에서 Repository 를 다루는 과정에서 추상화된 인터페이스를 통해서 접근한다는 점의 차이다
추상화 된 interface를 통해서 접근하기에, Repository 객체 자체에서는 DB종속적인 코드를 아무렇게나 써도 괜찮다.
또한 테스트 코드를 작성할 때도 모킹 과정이 훨씬 간단해진다. Repository 객체 자체를 모킹해버리면 그 내부 코드와 관계없이 동작하기에 안정적이게 된다.
Repository 의 책임
Repository의 책임은 도메인 객체를 저장하고, 복원하는 퍼시스턴시이다.
이떄 퍼시스턴시는 당연하지만 관계형 데이터베이스를 떠올리는 경우가 많지만, 다양한 데이터베이스에 사용된다
Repository 객체는 보통 저장, 복원과 같은 행동을 다루게 된다.
로직이 특정 인프라에 종속되면 유연성이 떨어진다. 이를 해결하기 위한 패턴이다.
애플리케이션 서비스
애플리케이션 서비스란?
유스케이스를 구현하는 객체이다.
다양한 도메인 객체를 실제로 조합해서 실행하는 스크립트 역할을 한다.
이때 주의할 점은 어플리케이션 서비스에서 웬만하면 도메인 객체를 사용하지 않는 것이 좋다.
"도메인 객체"를 다루는 것은 "도메인 서비스"에서만, "도메인 서비스"를 다루는 것은 "어플리케이션 서비스"에서만
이렇게 객체를 나누면 된다.
DTO를 통해서 데이터를 옮겨 주는 쪽이 훨씬 좋아진다.
'프로그래밍 방법' 카테고리의 다른 글
MSA에서 필수로 알아야 하는 Circuit Breaker 패턴 (0) | 2023.09.11 |
---|---|
Oauth의 등장 배경과, 변화 과정에 대해 알아보자 (0) | 2023.05.24 |
명확한 판단 근거를 만들자(feat: chatgpt) (0) | 2023.03.29 |
GraphQL (0) | 2022.09.22 |
도메인 주도 설계 철저 입문 Domain Driven Design(DDD) - 2 (0) | 2022.09.13 |