DDD : 4. 부자연스러움을 해결하는 DomainSerivce
- 카테고리 없음
- 2023. 10. 22.
들어가기 전
이 글은 도메인 주도 설계 철저 입문 4장을 공부하며 작성한 글입니다.
요약
- 서비스 계층에는 도메인 서비스, 어플리케이션 서비스가 존재함.
- 도메인 서비스는 도메인 규칙이지만, 도메인 객체 내에 선언하기 애매한 문맥을 표현할 때 사용할 수 있음.
- 도메인 서비스에 도메인 객체의 모든 규칙을 옮길 수 있으나, 좋지 않음.
- 도메인 객체는 단순한 값 객체가 되고, 행동(로직)은 모두 도메인 서비스로 분산됨. 로직이 분산되면, 표현력이 떨어지며 변경점을 반영하기 어려움. . (예를 들면 Validation 로직 같은 것들)
- 도메인 서비스의 각 메서드가 분산된 로직을 각각 사용할 경우, 로직 변경 시 변경 포인트가 너무 많아짐.
- 도메인 객체에 가급적이면 먼저 모든 로직을 집어넣고, 정말 애매한 로직이 있을 경우에만 도메인 서비스를 생성해라.
- 도메인 서비스, 어플리케이션 서비스에 필요한 경우 Persistent Layer에 접근이 필요할 수 있다. 이 때 직접 DB로 접근하면, 특정 데이터 접근 기술에 강하게 의존하게 되며 비즈니스 로직만 읽기 어려움. 비즈니스 로직에만 집중할 수 있도록 Repository 계층을 도입함.
4.1 서비스란?
DDD에서 서비스는 두 가지로 나누어진다.
- 도메인 서비스
- 어플리케이션 서비스
4장에서는 도메인 서비스에 대해서 공부할 것이다.
4.2 도메인 서비스란?
- 도메인 서비스는 도메인과 관련된 규칙이지만, 도메인 객체에 정의하기 애매한 코드들을 정의할 때 사용함.
- 도메인 서비스는 자신의 행위를 바꿀 수 있는 멤버 변수를 가지지 않고, 행위 자체만 정의한다.
- 멤버 변수가 없고, 메서드만 존재함.
- 예시
- User 엔티티는 Username을 필드로 가지고, User 엔티티에서 Username은 유일해야 함.
- 이 경우, UserName에 대한 중복체크를 해야하는데 UserName이 중복인지를 User 엔티티에서 체크하기 애매하다. 새로 만든 도메인 객체가 '스스로 중복 체크'를 하는 것이 어색하기 때문임.
- 이 경우, UserDomainService를 생성하고 중복 체크를 UserDomainService에서 처리하면 됨.
@RequiredArgsConstructor
static class User{
private final String userName;
public boolean isExist(String userName) {
// UserName 중복체크 로직
...
}
}
- 위 코드에서 User 도메인객체는 Username이 중복되었는지 확인할 때, 일단 User 객체를 만들고 만들어진 객체를 통해 User가 존재하는지 확인함. 그런데 이것은 애매하다.
// 도메인 서비스 객체 생성.
static class UserService {
public boolean isExist(String userName) {
// username 중복체크 로직
return true;
}
}
- 그러나 User 도메인 객체가 중복된 Username을 가질 수 없다는 것은 도메인 규칙이다. 따라서 도메인을 다루는 Service를 만들고, 여기서 처리하는 것이 좋다.
4.3 도메인 서비스를 남용하면?
- 요약
- 쉽게 이야기해서 도메인 서비스를 남용하는 경우 도메인 규칙이 바뀔 경우, 코드 수정 부분이 넓어지게 되고 도메인 객체의 표현력이 줄어들게 된다.
- 데이터와 행위가 단절되어 로직이 흩어짐. → 로직이 흩어지면 소프트웨어의 유연성이 저해됨.
static class UserService{
private boolean isExist(String userName) {
return true;
}
public User createUser(String userName) {
// 도메인 객체에 포함된 것이 더 좋음.
if (!StringUtils.hasText(userName)) return null;
// 도메인 서비스에 있어도 좋음.
if (isExist(userName)) return null;
...
}
}
- 도메인 객체에 있는 모든 로직을 도메인 서비스로 옮기는 것은 쉽다. 왜냐하면 도메인 서비스 역시 도메인과 관련된 규칙을 다루기 때문이다.
- 위 코드는 도메인 객체 내부에서 처리하면 좋을만한 Validation까지 도메인 서비스에 포함되었다.
도메인 객체에 모여있어야 할 모든 로직들이 도메인 서비스에 표현되게 되었다. 어떤 문제가 있을까?
- Validation 로직을 각각의 도메인 서비스에서 각각 사용함.
- 예를 들어 유저를 생성, 유저이름을 수정하는 곳에서 UserName이 빈 문자열인지 확인하는 코드가 있다고 하면, 이 부분이 User 도메인 서비스 전체로 퍼지게 됨.
- 도메인 객체만 봤을 때, 아무런 내용이 없어서 이 도메인 객체에 대한 정보를 코드만 보고는 알 수 없음.
쉽게 이야기해서 도메인 서비스를 남용하는 경우 도메인 규칙이 바뀔 경우, 코드 수정 부분이 넓어지게 되고 도메인 객체의 표현력이 줄어들게 된다.
도메인 서비스의 생성은 가급적 피할 것
- 도메인 서비스에 의존하는 코드를 작성하면, 정작 도메인 객체의 표현력이 떨어지고 수정에 취약해진다는 단점이 있다.
- 따라서 도메인 객체 내에 우선적으로 모든 코드를 표현하도록 하고, 정말로 애매한 코드일 경우 도메인 서비스로 옮겨적는다.
4.4 엔티티/값 객체와 함께 UseCase 수립하기
static class User {
private final String userName;
public User(String userName) {
if (!StringUtils.hasText(userName)) {
throw new IllegalArgumentException("잘못된 입력임.");
}
this.userName = userName;
}
}
static class UserDomainService {
public boolean isExisted(String userName) {
// DB에 중복된 UserName있는지 검색. SQL 쿼리 실행.
...
}
}
static class UserApplicationService {
private final UserDomainService userDomainService;
public User createUser(String userName) {
if (userDomainService.isExisted(userName)) {
throw new IllegalArgumentException("이미 있는 유저입니다.");
}
User user = new User(userName);
// 생성한 User를 DB에 저장.
// INSERT INTO USER(user_id, user_name) values(1, "abc")...
}
}
뒤에서 미리 배울 ApplicationService와 DomainService, 그리고 도메인 객체를 이용해서 구조를 작성하면 다음과 같다.
- User 도메인 객체의 규칙이지만 User 객체에 정의하기 애매한 '중복 이름이 있는지 확인'하는 메서드를 UserDomainService에 배치함.
- ApplicationService는 UserDomainService를 이용해 도메인 규칙을 확인하고, 도메인 규칙을 만족하는 경우 User를 생성해서 Persistent 한다.
이 때 한 가지 문제점이 발생한다. 바로 UserDomainService, ApplicationService에 SQL 쿼리가 있고, 이 SQL 문장 때문에 코드를 읽기 어려워진다는 점이다. Service Layer는 도메인, 어플리케이션과 직접적으로 관련된 비즈니스 로직이 있는 것이 좋으므로 이런 SQL 쿼리 문장을 분리시키는 것이 더 읽기 좋은 코드가 된다. 다음 장에서 배울 Repository 계층에서 Persistent 쿼리를 모두 처리하게 하면서 이 문제를 해결할 수 있게 된다.
4.5 물류 시스템의 도메인 서비스 예
물류 시스템에서 거점과 거점 사이에 물류가 이동하고, 거점에서 배송지로 Delivery 되는 형태가 있다고 가정해보자. 그렇다면 여기서 도메인 객체를 어떻게 정의해야하는 것일까?
class DistributionBase{
public Baggaege ship(Baggage baggage){...};
public void receive(Baggage baggage){...};
public void transport(DistributionBase to, Baggage baggage){
Baggage shippedBaggage = ship(baggage); // 출고
to.receive(shippedBaggage); //입고
}
...
}
- 거점 클래스를 DistributionBase라고 가정해보자.
- ship()은 출고, receive() 입고를 의미한다.
이 때, 거점 사이에 물류 이동은 출고되어야 입고되는 것으로 볼 수 있다. 이 때, 거점에서 물류를 직접 출고하고 입고처리하는 것이 애매하다. 거점이 다른 거점으로 물류를 직접 이동시키는 것이 문맥상 어색하기 때문이다. 이 경우, 도메인 서비스를 도입하는 것이 방법이 될 수 있다.
class DistributionBase{
public Baggaege ship(Baggage baggage){...};
public void receive(Baggage baggage){...};
...
}
// 도메인 서비스를 도입해서, 애매한 문맥(도메인 규칙)을 처리해 줄 수 있음.
class TransportService{
public void transport(DistributionBase from, DistributionBase to, Baggage baggage){
// from으로부터 출고
Baggage shippedBaggage = from.ship(baggage); // 출고
// to로 입고
to.receive(shippedBaggage); //입고
}
...
}
도메인 객체에 있기 애매한 문맥을 도메인 서비스 클래스를 생성해서 옮겨주면서, 좀 더 자연스럽게 도메인 규칙을 표현할 수 있게 되었다.