DDD를 적용하며 실수한것들
단순한 CRUD만 하는 앱이라면 DDD는 명백하게 OverEngineering 이다
인증권한외에 로직이 데이터 입력,수정,삭제,조회가 전부고 그 사이에 어떤 제약조건이나 비즈니스로직, 계산이 들어가지 않을때는 적용해선 안된다.
kiss(keep it small and simple, keep it simple stupid)법칙을 생각하자.
비즈니스로직이 존재하지 않아서 dto랑 entity의 차이가 없을 정도라면 layer를 굳이 나눠야 할까? 오히려 복잡성을 추가하게 된다.
active record도 repository layer를 줄였고 entity와 repository를 분리하지 않기에 생산성을 얻었다.
유닛테스트가 어려워진다는 단점이 있다고는 하지만 단순 crud가 전부인 앱에서는 유닛 테스트가 의미가 없다.
public property에 값이 할당되었나 안되었나 테스트하는게 무슨의미가 있겠나
다들 unit test짜면 바보 같다는 부분이 이런 부분이다.
별도 비즈니스로직이 있는게 아니라면 e2e테스트 짜는게 훨씬 이득이다.
그렇기에 초기에 시작할때 어느정도 설계 및 모델식별활동을 진행하여 어플리케이션의 복잡도가 어느정도 인지 파악할 필요가 있다.
Aggregate범위인지 헷갈린다면 도원결의를 생각하자
💡 (entity가) 태어난 시간은 달라도 죽는건(삭제) 한날한시에
aggregate가 여러개의 Entity로 구성되어야 하고 optional한 요소라서 생성타이밍도 다르다면 이것이 분리되어야 할 aggreate인지 헷갈릴 수 있다.
게다가 보통 생성이 함께되어야 하는 케이스에 aggregate라고도 하지만 해당 케이스는 혼란스럽게 만든다.
비즈니스로직 중심으로 생각하는것도 방법이지만 개인적으로는 심플하게 삭제되어야할 시간을 생각한다면 간단하게 이해할수 있다. (다만 회원탈퇴 같은 경우는 다를 수 있다.)
데이터와 데이터이력의 관계에서 aggregate로 묶어서 생각 했었는데 데이터가 삭제되어도 이력데이터는 남아야 한다. 그러므로 데이터와 데이터이력은 다른 aggregate다.
하지만 주문과 주문항목을 생각해보자. 주문 aggregate root가 없는 주문항목이 존재한다면 데이터 무결성이 깨진다. 이둘은 한 aggregate다.
Aggregate 내에서 다른 객체를 직접참조 하는것에 대해서 깊게 생각하지 않았다.
Aggregate를 정의할때 읽기모델과 비즈니스모델은 반드시 일치하지 않는다는 것을 간과하였다.
무슨말인가 하면 orm을 사용하면서 읽기모델 기준으로 relation표현을 위해서 각각의 entity에 다른 entity를 주구장창 연결하고 entity를 프로퍼티로 표현했지만 어디까지가 aggregate의 경계인지 불분명 하기에 옳은 방법이 아니었다.
모든 실제테이블은 relation으로 연결되어 있지만 비즈니스 로직을 중심으로 생각하는 객체를 표현할때는 그리해서는 안된다
세부사항 미루기 From. Clean Architecture💡
읽기모델도Infra Layer
로 취급하고 나중으로 미루거나 고려하는게 좋다.
조회로직이 대부분 infra구현체에 의존하고 별다른 로직이 없기 때문에 (es, rds, nosql 등등)
아직 데이터와 비즈니스로직도 구현및 테스트가 끝나지 않았는데 조회로직을 정하기 위해서 시스템 세부사항을 고려하는건 이르다.
시스템 세부사항은 최대한 미루자.
소프트웨어를 부드럽게 유지하는 방법은 **중요치 않은 세부사항** 을 가능한 많이, 그리고 가능한 한 오랫동안 열어두는 것이다.
아키텍트의 목표는 시스템에서 정책을 가장 핵심적인 요소로 식별하고, 동시에 세부사항은 정책에 무관하게 만들 수 있는 형태의 시스템을 구축하는데 있다. 이를 통해 세부사항을 결정하는 일은 미루거나 연기할 수 있게 된다.
- 개발 초기에는 데이터베이스 시스템을 선택할 필요가 없다.
- 개발 초기에는 웹 서버를 선택할 필요가 없다.
- 개발 초기에는 REST를 적용할 필요가 없다.
- 개발 초기에는 의존성 주입 *dependency injection* 프레임워크를 적용할 필요가 없다.
세부사항에 몰두하지 않은 채 고수준의 정책을 만들 수 있다면, 이러한 세부사항에 대한 결정을 오랫동안 미루거나 연기할 수 있다.
이를 통해 다양한 실험을 시도해볼 수 있는 선택지도 열어 둘 수 있다.
/// Don't
class User {
id: string
orders: Order[]
}
class Order {
orderer: User
items: OrderLineItem[]
}
// Do
class User {
id: string
}
class Order {
id: string
userId: string // 다른 aggregate는 id만 참조한다.
items: OrderLineItem[]
}
읽기모델을 중심으로 생각하고 orm이 주는대로 사용했기에 별 문제 없이 사용했었지만 실제 '비즈니스로직'이 들어간다면
위의 코드는 객체관계가 많아질수록 응집도가 낮고 결합도가 높은 코드가 되어버린다.
주문로직에서 user의 세부사항을 알 필요가 있는가?
user가 order에서 생성되고 삭제될때 함께 삭제되어야 하는 객체인가?
user와 order는 서로가 서로를 포함하지않는 명백한 각각의 Aggregate이다.
포함관계가 아니기에 참조로 연결되어야 한다. 그렇기에 order는 user의 id만 가지고 있는다.
같이 로그성데이터 처럼 저장되어야 하는 정보라면 order context안에서 관리하는 orderer라고 하는 user의 일부정보를 가지고 있는 entity를 만들어서 order의 포함관계로 표현하는게 맞다.
// user context
class User {}
// order context
class Orderer {
id: string
userId: string
name: string
}
class Order {
orderer: Orderer
}
물론 orm에 따라서 위와 같이 표현하는게 제약이 걸릴 수 있다.
따로 직접 mapping layer를 두고 사용하는것도 방법이지만 이렇게 할만한 가치가 있을정도로 도메인의 정책이 복잡할때만 진행해야 한다.
Repository의 역할에 대해서 깊게 생각하지 않았다.
Repository는 말그대로 Aggregate
에 대해서 조회,저장,수정,삭제하는 역할이다!
너무나도 중요하기에 다시한번 말하자면
Repository는 Aggregate
를 조회,저장,수정,삭제하는 역할이다.
만약 내가 읽기모델을 기준으로 생각해서 다른 aggregate인 Order와 User를 같이 가져온다면 이것은 DDD에서 말하는 repository의 책임이 깨진것이다.
// Don't
// order를 조회하면서 orm이 제공하는 repository에서 조회 api를 사용하여 user를 같이 가져온다.
// aggregate를 조회하는 repository의 역할이 깨진다.
orderRepository.findOne({
where: {
id: orderId
},
relations: ['user']
})
// Do
// Dao를 만들고 별도의 로우쿼리나 쿼리빌더, entityManager등을 사용해서 order와 user를 조회하는 로직을 작성한다.
entityManager
.createQueryBuilder(Order)
.leftJoinAndSelect('order.orderer', 'user')
.where('order.id = :orderId', {orderId})
//or
orderRepository.findOne(...)
userRepository.findOne(...)
그럼 이것은 무슨 객체의 역할인가? DAO (data access object)의 역할에 가까운것이다.
여태까지 나는 dao와 repository는 서로 다른이름을 가진 같은 추상화 정도라고 생각했지만 절대로 아니다.
crud를 하는것은 같지만 범위가 정해져 있다.
dao는 넓은의미의 데이터 접근객체이고 repository는 dao이긴 하지만 좀더 좁은 의미로 aggregate를 관리하기 위한 개념인것이다.
위에가 더 심플하고 간단하게 해결할 수 있는데 왜 밑으로 해야하는지 생각해본다면,
조회로직이 추가되면 추가될수록 그 역할의 경계가 모호해지기 때문이다.
order, user, policy, product등 aggregate 4개이상을 join하는 로직이 있다고 한다면 이 로직을orderRepository가 가져가는것이 맞나 생각해보면 간단하다.
이런 조회화면이 많아질수록 orderRepository의 책임을 아득히 넘어서게 된다.
orderRepository는 order 집합체만 잘 관리하면 된다. 단일책임원칙(SRP)에서 벗어나지 말자.
DDD에서 Repository는 Entity저장소가 아닌 Aggregate 저장소다.
// Don't
const order = orderRepository.findOne(orderId)
const orderLineItems = orderLineItemRepository.findByOrderId(orderId)
orderRepository.save(order)
orderLineItemRepository.save(orderLineItems)
orderRepository.remove(order)
orderLineItemRepository.remove(orderLineItems)
// Do
const order = orderRepository.findOne(orderId)
orderRepository.save(order)
orderRepository.remove(order)
만약 Aggregate범위를 order와 orderLineItem을 한 Aggregate로 잡았는데
각각의 repository를 만드는것은 잘못된 것이다.
주문값계산을 하는데 order만 가져오고 orderLineItem을 안가져 오는것도 역할이 잘못된것이다.
repository에서 조회할떄 반드시 aggregate를 온전히 다 가져와야 한다.
그럼 orderRepository에서 orderLineItem 저장처리를 하는건가? 맞다.
orderRepository.save()
로 orderLineItem항목이 변경된다면 향후 구현에서 기존 orderLineItem의 비교후 삭제 및 새 item의 추가도 해당 save에서 구현해야 한다.
order가 order, orderLineItem table두개로 저장되든 json array로 한컬럼에 밀어넣는것으로 표현되던간에 orderRepository.save() order와 orderLineItem이 하나의 aggregate로 취급되고 저장된다면 orderRepository가 책임을 지킨것이라 볼 수 있다.
로직이 aggregate에 포함되기 어렵다면 도메인 서비스를 꼭 사용하자
분명 여러 aggregate간의 조합으로 연산해야 하는 로직이 존재한다.
주문총합가격도 단순 상품만이 아니라 유저등급, 쿠폰, 할인정책등이 혼재되어있다고 해보자.
그럼 이 로직은 온전히 order에 있어야 할까?
order에서 모든 aggregate를 프로퍼티에 포함해서 계산로직을 만들어야 할까?
할인가 계산 로직은 유저등급의 것인가? 할인정책의 것인가? 주문의 것인가? 쿠폰의 것인가?
이런케이스를 위해서 존재하는것이 도메인 서비스이다.
대부분 infra의존성을 배제한 순수 유틸함수 같은 존재일수도 있지만 꼭 그렇지는 않다.
class TotalPriceCalculationServie {
caculate(order: Order, coupon: Coupon, rating: UserRating, policy: DiscountPolicy): number {...}
}
// or
class Order {
totalPrice(coupon: Coupon, rating: UserRating, policy: DiscountPolicy) {
}
}
주문 로직은 외부 aggregate에 대한 정보를 최소한으로 가지게 된다.
통상의 oop서적을 보면서 실제로 적용하기 어려웠던 부분이 이 부분이었던듯하다.
리팩토링 책만봐도 order객체내에 할인정책이나 다른 객체안에 다른 객체가 property로써 전부들어가서 참조하고 있는 객체를 타고타고 들어가서 계산했는데 얼추보면 맞는듯 하면서도 잘못된 방법이다.
마치며
리팩토링 책을 보면서 oop를 우아하게 해내는것을 보며 동경했었지만 웹앱에서는 멀고먼 이야기인줄 알았다.
용어가 왠지 생소하기도 하고 실무에 적용해도 비즈니스로직에 비해 지나치게 나눠진 레이어 떄문에 이런 방법이 잘 와닿지 않았었는데 비즈니스로직이 복잡할수록 ddd는 진가를 발휘하는 방법이었다.
이것은 단순 crud밖에 존재하지 않는 비즈니스 모델이라면 절대로 권장하지 않는다. 정말 빈혈도메인 모델로 끝나도 되는 모델이라면 다 부질없는 이야기다.
우선 유스케이스와 도메인로직이 정리가 되고 도메인의 복잡도가 어느정도인지 파악된다면 아키텍쳐 수준을 맞춰서 선택해야 한다.
Author And Source
이 문제에 관하여(DDD를 적용하며 실수한것들), 우리는 이곳에서 더 많은 자료를 발견하고 링크를 클릭하여 보았다 https://velog.io/@float_letter/DDD를-적용하며-실수한것들저자 귀속: 원작자 정보가 원작자 URL에 포함되어 있으며 저작권은 원작자 소유입니다.
우수한 개발자 콘텐츠 발견에 전념 (Collection and Share based on the CC Protocol.)