DDD : 4. 부자연스러움을 해결하는 DomainSerivce

    들어가기 전

     이 글은 도메인 주도 설계 철저 입문 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); //입고
      }
      ...
    }

    도메인 객체에 있기 애매한 문맥을 도메인 서비스 클래스를 생성해서 옮겨주면서, 좀 더 자연스럽게 도메인 규칙을 표현할 수 있게 되었다.

    댓글

    Designed by JB FACTORY