1편에 이어서 작성된 글입니다 못 보신 분은 1편을 먼저 보시고 오시는 것을 추천드려요
https://be-student.tistory.com/17
도메인 주도 설계 철저 입문 글을 마무리 해보고자 합니다
나머지 책을 정리하다 보니 네이밍 컨벤션에 대해서 말을 하고 가는 것이 좋아 보여서 먼저 정리를 하고 난 다음에 해보고자 합니다
도메인 서비스 이름 정하기
정하는 방법은 책에서 나온 바로 무려 3가지나 있는데요
1. 도메인 개념만
2. 도메인 개념+Service
3. 도메인 개념 + DomainService
1번 같은 경우는 도메인 개념만으로 진행하는 의미상은 가장 적절하지만, 이 클래스가 Service다 라는 것을 미리 모두에게 공유되어야 한다는 점이 문제가 된다
2번 같은 경우는 도메인 서비스 자체도 서비스이기에, 생략해도 괜찮다고 보는 입장이다
3번같은 경우는 도메인 서비스라는 부분을 강조하기 위한 부분이다. 해당 클래스의 코드가 도메인 서비스다는 것을 확실히 알려줄 수 있다는 장점이 있다.
당연히 어떤 것을 써도 괜찮고, 팀원과의 합의가 최우선이 되어야 합니다
애플리케이션 서비스
애플리케이션 서비스란?
유스케이스를 구현하는 객체이다.
다양한 도메인 객체를 실제로 조합해서 실행하는 스크립트 역할을 한다.
이때 주의할 점은 어플리케이션 서비스에서 웬만하면 도메인 객체를 사용하지 않는 것이 좋다.
"도메인 객체"를 다루는 것은 "도메인 서비스"에서만, "도메인 서비스"를 다루는 것은 "어플리케이션 서비스"에서만
이렇게 객체를 나누면 된다.
DTO를 통해서 데이터를 옮겨 주는 쪽이 훨씬 좋아진다.
까지가 전에 적은 글이었는데요
어플리케이션 서비스에 대해서 조금 구체적으로 다루면서 시작을 해보고자 합니다.
도메인 객체를 왜 어플리케이션 서비스에서 다루면 안될까요?
간단한 코드를 보면서 진행해보도록 하겠습니다.
class UserService{
static #userRepository;
static{
this.#userRepository=import (#userRepository);
}
static Exist=(another)=>{
if(this.#userRepository.find(another)){
return true;
}
return false;
}
}
class UserApplicationService{
#userRepository
constructor(userRepository) {
this.#userRepository=userRepository;
}
Get(userId){
const targetId=new UserId(userId);
const user=this.#userRepository.find(targetId);
return user;
}
}
class Client{
#userApplicationService;
constructor(userApplicationService){
this.#userApplicationService=userApplicationService;
}
changeName(id,name){
let target=this.#userApplicationService.get(id);
const newName=new UserName(name);
target.changeName(newName);
}
}
간단한 client를 만들었다. 생략된 부분이 많기에 그냥 내용만 보면 된다.
여기서 조금 고민해봐야 될만한 부분이 있네요
도메인 객체의 메서드를 어플리케이션 서비스에서 호출하게 되었습니다. 이러면 우리가 처음에 생각했던 계층형 구조를 가진 애플리케이션을 효과적으로 만들지 못합니다. 당연히 간단한 애플리케이션 단위에서는 편한 편하고, 간단한 코드가 됩니다.
코드가 복잡해질 수록 계층을 무시하고 막 짠 코드는 결국 로직의 분산을 가져와 변경을 하기 어렵게 만들게 됩니다.
만약 changeName이라는 메서드가 수정이 필요해 changeName1 이라는 메서드가 되어야 한다면 모든 코드를 전부 다 돌면서 확인해야하기에 당연하지만 버그가 터질 가능성이 높아지겠죠
DTO를 통해서 관리한다면 어떻게 될까요?
class UserData{
constructor(user){
this.id=user.id;
this.name=user.name;
}
}
class UserApplicationService{
#userRepository
constructor(userRepository) {
this.#userRepository=userRepository;
}
Get(userId){
const targetId=new UserId(userId);
const user=this.#userRepository.find(targetId);
// return user;
const userData=new UserData(user);
return userData;
}
}
이런 식으로 코드를 바꿀 수 있습니다.
바깥에서는 이제 메소드가 없기에, 그냥 Data 만 남겨두겠죠 그러면 변경시에 당연하지만 애플리케이션 레이어를 건드릴 이유가 전혀 없어지니 코드 관리가 편해지죠
단점 역시 명확합니다
Data를 통해서 접근하고, 전송하는 과정이 다 추가될 겁니다. 그렇기에 이런 상황을 해결하기 위한 수단을 마련해주는 과정도 생각해볼만 합니다
Update 를 해야 할 경우는 어떻게 할까요?
update(id,name,date,...extra){
}
지금 방식으로 간다면?
UserApplicationService 라고 하는 애플리케이션 레이어에 Update 라는 함수를 두고, 업데이트된 필드가 있는지 변경하고, 그것을 Repository 에 연결하는 코드까지가 작성되겠죠.
물론 userData.id=1234 같은 형식으로 변경한 이후에, Repository.save(userData) 까지 할 수도 있을겁니다
그런데 필드가 늘어나면 어떻게 될까요?
애플리케이션 레이어에 필드가 하나하나 늘어날겁니다 어플리케이션 시그니처가 변경되는 것은 많은 변경을 가져오기에, 주의하는 것이 좋습니다(도메인보다 바깥쪽 레이어이기 때문에 가장 추상화된 레이어의 인자가 매번 변경되겠죠)
이럴 때 command 패턴을 사용할 수 있습니다
command 패턴은 쉽게 말하면 id, name, date... 같은 필요한 데이터를 모아주는 역할을 한다고 생각해도 무방합니다
class userCommand{
id;
name;
date;
extra;
constructor(){}
}
적어도 이런 식으로 인자를 모아줄 필요는 있겠죠
그러면 인자가 늘어나지 않고, 무조건 숫자가 고정되니 적어도 애플리케이션을 호출하는 쪽에서는 함수가 바뀐 것을 알 필요가 없겠죠
중요하니 다시 한 번 정하죠
애플리케이션 레이어는 도메인 서비스 만 건드려야 한다.
즉 도메인 로직은 절대로 다뤄서는 안됩니다. 그냥 모아서 컨트롤 하는 역할만 해야 합니다
응집도란?
응집도는 정말 중요한 개념인데요 코드의 핵심 원칙중 하나인 결합도는 낮게, 응집도는 높게 라는 말이 있을 정도인데요
LCOM이라고 하는 방법으로 측정할 수가 있습니다
간단히 정리하면 인스턴스 변수는 모든 메서드에서 전부 다 사용되어야 한다라는 규칙을 가지고 진행하게 됩니다.
안 쓰인다면 응집도가 떨어진다는 얘기 인거죠
만약 다 쓰지 않는다면 클래스를 쪼개는 것을 통해서 응집도를 높일 수 있겠죠
다시 한 번 서비스란?
클라이언트를 위해 무언가를 해주는 존재. 자신만을 위한 뭔가가 아닌 경우가 많습니다. 행동이나 활동인 경우가 많습니다
예를 들면 도메인 관련된 활동 => 도메인 서비스
애플리케이션 관련된 활동 => 애플리케이션 서비스
서비스는 자신의 행동을 변화시키는 것을 목적으로 하는 상태를 갖지 않는 무상태라는 특징이 있습니다.
무상태 != 상태가 없다
라는 것을 주의해야 합니다. 오히려 순수 함수쪽에 가깝다 로 보는게 낫겠죠 Repository 라는 상태를 가질 수 있지만, Repository 에 특정 값이 있는지에 따라 행동이 바뀌면 안된다고 생각하면 될 것 같네요
의존성 관리
의존이란?
class ObjectA{
ObjectB;
};
라는 거를 이런 방식으로 ObjectA가 ObjectB에 의존한다 라고 할 수 있습니다. ObjectB가 없으면 ObjectA가 완성되지 않는다 로 봐도 될거 같고요
의존관계는 소프트웨어에서 필수적입니다. 인터페이스를 가지고 있어도, 변수를 가지고 있어도 의존 관계가 발생하니까요
의존 관계 역전 원칙
정말 유명한 원칙중에 하나입니다. 정의가 생각보다 어려운데요
1. 추상화 수준이 높은 모듈이 낮은 모듈에 의존해서는 안된고, 두 모듈 모두 추상 타입에 의존해야 한다
2. 추상 타입이 구현의 세부 사항에 의존해서는 안 된다. 구현의 세부사항이 추상 타입에 의존해야 한다.
추상화 수준이라는게 뭘까요?
입출력으로 부터 떨어진 거리를 의미합니다. 그냥 입출력을 직접 하는 쪽이 가장 구체적이고, 실제 로직같은 쪽이 가장 추상화 되어있다고 볼 수 있죠
의존 관계 역전 원칙을 적용했을 때
간단하게 보면 이런 형태로 바꿔준다는 겁니다. 직접 특정 객체를 참조하는 하지 않고, 추상타입을 참조하는 방향으로 변경해야 한다는 건데요
적용하기 전을 분석해봅시다
ObjectB를 확장한 부분이 ObjectA 라고 볼 수 있습니다.
ObejctB를 실제 데이터, ObjectA 가 그것을 다룰만한 changeName같은 메서드라고 봐도 괜찮겠죠
이런 상황에서 추상화 수준은 ObjectA 가 더 높다고 볼 수 있습니다. 단순하게 데이터만 있는 것보다는 추가적인 메서드도 있는 상황이니까요. 또한 ObjectB가 추상화 수준이 높다 라고 볼 수 있겠죠. 그러면 추상화 수준이 높은 모듈이, 낮은 모듈에 의존하면 안된다는 원칙을 위배하게 됩니다.
그러면 이런 방식으로 바꿨을 때는 어떻게 될까요?
Interface는 그냥 타이핑만 되어있고, 실제 구현 자체가 없으니 가장 추상적이라고 봐도 무방합니다. 그러므로 해결이 된 상황이겠죠
변경을 하지 않았을 때 어떤 문제가 터질까요?
가장 낮은 수준에서 변경을 했다고 생각해 봅시다. ObjectB에 변화가 생기면 그것을 반영하기 위해서 ObjectA까지 같이 변경되게 됩니다. 이때 interface를 참조하게 된다면 중간에 Adaptor같은 것을 끼워 넣어서라도 변경을 유지할 수 있겠죠. 이런 식으로 변경을 조금 자유롭게 할 수 있습니다
Service Locator 패턴
객체를 미리 만들어 두고, Service Locator 을 통해서 받아서 쓰는 패턴입니다.
미리 셋업단계에서 대부분의 인스턴스를 만들어 두고, 돌려쓴다고 봐도 괜찮습니다
여기서 중요한 부분이 "셋업 단계" 에서 만들어 두고 라는 부분인데요 이를 미리 등록해두지 않는다면 당연하지만 에러가 납니다. 직접 실행시키기 전에는 왜 터지는 지도 모르고 그냥 터지고 작동이 안 되겠죠
그렇기에 가독성과, 영향을 정확하게 파악하는 과정이 어렵기에 안티패턴으로 볼 수 있습니다.
Dependency Injection이란?
모든 관계를 미리 다 만들어서 적용하는 것이 아닌
모듈을 직접 만들어서 생성된 모듈을 집어 넣는다 는 건데요
const userRepository=new SqlDbInstance();
const userApplicationService=new UserApplicationService(userRepository);
이런 식으로 생성자를 통해서 생성된 코드를 넣는다는 겁니다.
당연히 장점으로는 생성 단계에서 어떤 변수가 필요한지가 정리가 되기에,
단점은 생성자가 많아진다는 점이 있겠네요
IoC 에 대해서는 다음 번에 적어야 겠네요
싱글톤 VS Static
싱글톤을 쓰는 이유는 일반적인 객체처럼 다루었을 때 생기는 다형성같은 객체지향의 장점을 누리기 위해서 입니다
비슷하지만 다른 차이가 있죠
팩토리 패턴
class User{
constructor(id,name){
this.id=id;
this.name=name;
}
}
class UserFactory{
#userRepository
create(name){
//db와 관련된 로직들
const userId=this.#userRepository.getId();
return new User(userId,name);
}
}
위에 적혀 있는게 팩토리 패턴을 구현한 건데요, 원래 User만 있었다면 User 내부에 db를 직접 건드리는 로직이 이상하게 들어가있게 됩니다.
이를 Factory로 분리하게 된다면 User클래스가 더 깔끔해지죠
Factory를 나눌 필요가 있는 기준은 생성자에서 다른 객체를 참조하는지 를 보면 좋다고 생각합니다.
데이터의 무결성
유일키 제약
DB자체에 있는 Unique Key를 검사하는 기능을 통해서 받아들이는 방법이 있죠. 당연하지만, Unique Key 를 이용한 검사는 실제 코드가 아닌, DB의 모델링과 관련된 영역이다보니, 코드상에 전혀 나타나지 않는다는 문제가 있습니다. 그래서 안전망으로 사용하는 것이 가장 좋습니다.
트랜잭션
트랜잭션은 다양한 작업을 하나로 묶어서 하나의 작업처럼 진행하는 패턴입니다.
모든 과정이 다 성공하거나, 다 실패하거나만 남죠.
Unique Key를 사용하는 것과 같이 사용하는 편이 좋습니다. DB 자체의 영역이나, 여러 에러가 발생했을 때, 일부까지만 진행되었던 진행과정을 롤백하기에, 안정성을 많이 높일 수 있습니다.
당연하지만 Transaction은 너무 큰 범위를 잡게 된다면 너무 자주 실패하기에, 최소한으로 잡는 쪽이 좋습니다.
당연하지만, 위의 두 방법을 쓰더라도 명확하게 특정 제약이 있다는 부분을 볼 수가 없는데요 명시적인 코드를 이용해서 방어하는 과정을 보여서 가독성을 높일 수 있습니다.
애그리게이트(Aggregate)
Aggregate란 불변 조건을 유지하는 단위로 꾸려지며, 객체 조작의 질서를 유지한다.
외부에서 애그리게이트를 다룰 때는 모두 다 루트를 거쳐야 한다.
불변조건이란? 어떤 처리를 수행하는 동안 참을 유지해야하는 명제를 의미합니다
User의 예시를 보죠
User이라는 루트(모든 경우의 최상단에 위치합니다)
UserId라는 값 객체가 있고, UserName이라는 값 객체가 있습니다.
이때 user.Name이라는 것을 통해서 접근하는 과정을 용납하지 않고, user.setName같은 모든 처리를 다 담당해줘야 합니다.
User객체에 요청하면, User객체가 처리를 해주는 거죠
당연하지만 추상화가 잘 되어있어서 외부에서 알아야 될 필요성이 있는게 User하나뿐이라 가독성이 좋아집니다.
그렇다면 애그리게이트를 어디까지 설정해야 될까요? 얼마나 설정해야될까요?
변경의 단위로 구별ㅇ르 해보면 좋습니다.
데메테르의 법칙
객체간의 메서드 호출에 질서를 부여하기 위한 가이드라인입니다.
어떤 컨텍스트에서 아래에 있는 객체의 메서드만 호출할 수 있도록 합니다.
1. 객체 자신
2. 인자로 전달받은 객체
3. 인스턴스 변수
4. 직접 생성한 객체
User의 예를 보죠, User.Name.changeName같은 메서드가 있다고 하면, 이것을 바로 호출하는 것은 위에 해당하지 않기에 어긋납니다.
이를 지켰을 때 얻는 효과는 당연하지만 로직이 흩어지지 않도록 만들어 준다는 거죠
Notification 객체
Getter를 만드는 것을 주의해야 하는 부분이 있는데요, 직접 참조하는 부분이 생기고 나면, 계속해서 로직이 바뀔 때마다 모든 곳을 수정해야 할 수도, 같은 로직이 중복 구현될 수도 있습니다. 이를 해결하기 위해서는 Getter를 외부로 내지 않아서, 컨트롤 하는 로직을 거의 다 클래스 내부로 모아주는 작업을 하는 것입니다.
당연히 모든 로직을 다 모을 수는 없습니다.
정 필요한 상황을 대비한 것이 Notification객체입니다.
class UserNotification{
constructor(id,name){
this.id=id;
this.name=name;
}
}
class User{
constructor(id,name){
this.#id=id;
this.#name=name;
}
notification(){
return new UserNotification(this.#id,this.#name);
}
}
같은 방식을 통해서 Getter와 Setter가 있는 것을 둘 수있죠. 당연히 새로운 객체이기에 메서드를 자유롭게 호출할 수도 없을겁니다. 진짜 중요한 id는 빼고 name만 외부로 내보낼 수도 있죠
명세
명세가 나오게 된 배경은 간단합니다.
객체를 평가하는 절차가 너무 복잡해진 경우인데요
특정 유튜브에 정기 가입자가 있는 경우를 생각해보죠.
그 유튜버의 동영상 목록을 보여줄 때, 가입자에게만 모든 영상을 보여줘야되고, 아닌 경우에는 제한된 영상을 보여줘야 된다 같은 상황이 되거나... 같은 다양한 조건이 있다고 생각해봅시다. 그냥 쓰기 어려워집니다.
VideoList 안에 그냥 Find를 둬서 그 내부에서 할 수도 있지만, 그랬을 경우에 당연하지만 Find 메서드가 너무 비대해집니다.
아니면 Repository 안에 FindAndFiltered같은 메서드를 넣을 수도 있지만, 비즈니스 로직이 Repository 에 들어가는 것 또한 이상해지죠.
이를 해결하기 위해서 모든 영상 리스트를 받아오고, 그 List를 명세 안에 넣어서 필터링을 해주는 절차를 거칠 수도 있습니다
class UserSpecification{
static findAndFilter(data){
this.userRepository.filter(data);
}
}
SQL 단위에서 필터링을 할 수 없다는 단점이 있기에, Select * 같은 쿼리를 날리게 되는데, 이 부분은 커다란 성능상의 저하를 불러일으킬 수 있기에, 잘 선택하는것이 좋습니다.
위와 같은 특수한 상황에서는 도메인 객체의 제약에서 넘어가는 것도 괜찮습니다.
이를 해결하기 위해 나온게 CQS, CQRS 패턴이기도 하지만 추가적으로 작성하도록 하겠습니다.
아키텍쳐
Smart UI - 안티패턴
똑똑한 UI가 말만 들으면 당연히 좋아보이지만, 안티패턴입니다.
UI에다가 Filter 라는 로직을 전부 다 붙혀버리면 UI가 기능을 담당하는 상황이 발생합니다. UI가 기능을 알게 되니 똑똑해지지만, 당연히 로직이 중복 작성되기에, 좋지 않은 패턴입니다.
여기서 메인으로 다루는 아키텍쳐는 계층형 아키텍쳐, 클린 아키텍쳐가 있습니다
https://github.com/wikibook/ddd
를 통해서 보시는 것을 추천드립니다
'프로그래밍 방법' 카테고리의 다른 글
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) | 2022.09.09 |