Unit Testing 7장: 가치 있는 단위 테스트를 위한 리팩토링

    들어가기 전

    이 글은 단위 테스트 7장을 공부하며 작성한 글입니다.


    7.1 리팩터링할 코드 식별하기

    제품 코드를 리팩토링하지 않고서는 테스트 스위트를 크게 개선할 수 없다. 테스트 코드와 제품 코드가 본질적으로 관련되어 있기 때문이다. 이 절에서는 리팩터링의 방향을 설명하고자 코드를 네 가지 유형으로 분류하는 방법을 소개한다.


    7.1.1 코드의 네가지 유형

    모든 제품 코드는 다음으로 분류할 수 있다. 

    • 복잡도 : 코드 내 의사 결정(분기) 지점 수로 정의함. 결정 지점수가 많을 수록 복잡도는 증가함.
    • 도메인 유의성 : 프로젝트의 문제 도메인에 얼마나 의미있는지를 나타냄.
    • 협력자 수 : 가변 의존성, 프로세스 외부 의존성.

    복잡도가 높은 코드, 도메인 유의성이 높은 코드가 테스트할 가치가 가장 크다. 복잡도와 도메인 유의성은 독립적이다. 예를 들어 주문 가격을 계산하는 메서드에 조건문이 없다면 복잡도는 낮은데, 중요한 비즈니스 로직을 수행하는 것이기 때문에 도메인 유의성은 높다. 

    협력자도 고려해야 한다. 협력자가 많다면 테스트를 위해서 준비해야 할 설정값들이 많다. 또한 협력자가 많은 경우라면 협력자의 상태, 상호작용을 검증하는 테스트를 해야한다. 도메인 코드에 한정해서는 외부 프로세스를 사용하면 안된다는 제약도 있다. 이 경우, Mock 체계가 복잡해지기 때문에 더 많은 유지비용이 발생하기 때문이다. 

    이것들을 기반으로 코드를 분류해보면 다음과 같다. 

    • 도메인 모델과 알고리즘 : 도메인과 관련 없는 복잡한 알고리즘일 수 있음. 도메인 코드는 보통 복잡함. 
    • 간단한 코드 : 복잡도나 도메인 유의성이 거의없다. 따라서 의미 없는 테스트다.
    • 컨트롤러 : 도메인 클래스와 외부 프로세스와의 협력을 조정한다. 
    • 지나치게 복잡한 코드 : 협력자가 많으며 복잡하거나 중요하다. 

    테스트 했을 때 좋은 효과가 있는 것은 도메인 모델과 알고리즘이다. 해당 코드가 복잡하거나 중요한 로직을 수행하기 때문에 이걸 테스트 하면 회귀 방지가 향상된다. 또한 코드에 협력자가 적기 때문에 테스트하는데 필요한 비용도 적다. 

    지나치게 복잡한 코드가 가장 문제가 된다. 복잡하기 때문에 테스트를 하지 않으면 문제가 발생할 가능성이 높은데, 협력자수가 많기 때문에 테스트하는 비용이 크다. 주로 이런 코드 때문에 단위 테스트에 큰 어려움을 겪는다. 


    7.1.2 험블 객체 패턴을 사용해 지나치게 복잡한 코드 분할하기

    험블 객체 패턴은 이런 느낌인 것 같다.

    • 로직  : 함수형 코어와 유사하게 작성한다. 하지만, 도메인 내부끼리의 사이드 이펙트는 발생할 수 있다. 도메인의 상태가 변경된 것이 끝까지 메모리에 남아있기 때문에 테스트에 용이함. 
    • 테스트 하기 어려운 의존성 : 이 부분은 함수형 아키텍쳐의 가변쉘처럼 분리한다. 사이드 이펙트는 외부 프로세스와 통신할 때 넘는다.  

    지나치게 복잡한 코드를 쪼개려면 험블 객체 패턴을 사용해야한다. 험블 객체 패턴은 테스트 하기 어려운 의존성 / 비즈니스 로직을 분리하고, 험블 객체를 이용해서 의존성과 비즈니스 로직을 연결해주는 역할을 한다. 앞장에서 공부했던 헥사고날 아키텍쳐, 함수형 아키텍쳐도 험블 객체 패턴을 따른다. 

    예를 들면 왼쪽에 있는 처음 코드를 오른쪽과 같이 나누는 것이다. 각 객체를 책임에 따라 나누고, 험블 객체로 이 둘을 연결시켜주는 역할을 한다.

    험블 객체 패턴은 비즈니스 로직과 오케스트레이션을 분리해주는 용도에도 사용된다. 이것이 정말 중요하다. 예를 들어 MVC 패턴에서 Model과 View를 Controller(험블객체)가 이들을 연관시켜준다. 비즈니스 로직과 오케스트레이션을 분리시켜주면 테스트 용이성도 증가하지만, 코드 복잡도를 해결할 수 있게 되고 이것은 궁극적으로 어플리케이션이 더욱 확장 가능하도록 해준다. 


    7.2 가치 있는 테스트를 위한 리팩토링 하기

    이 절에서는 복잡한 코드를 험블 객체 패턴을 이용해서 분리해서 코드 복잡도를 떨어뜨리고 테스트 하기 쉬운 형태로 작성한다. 


    7.2.1 초기 코드

    • MyUser와 Company 객체가 있다.
    • 사용자 이메일이 회사 도메인과 동일하면 직원으로 표시된다. 그렇지 않으면 고객이다.
    • 시스템은 사원수를 알려줘야한다. 만약 사용자가 고객에서 직원으로 바뀌면 사원수도 바껴야 한다.
    • 이메일이 변경되면 시스템은 메세지 버스로 메세지를 보내 외부 시스템에 알린다. 

     

    public class MyUser {
    
        private int userId;
        private String email;
        private UserType type;
    
    
        public void changeEmail(int userId, String newEmail) {
    
            MyUser user = Database.findUserById(userId);
            String email = user.getEmail();
            UserType type = user.getType();
    
            if (email.equals(newEmail)) {
                return;
            }
    
            Company company = Database.getCompany();
            String companyDomainName = company.getCompanyDomainName();
            int numberOfEmployee = company.getNumberOfEmployee();
    
            String emailDomain = newEmail.split("@")[1];
            boolean isEmailCorporate = emailDomain == companyDomainName;
            // 결정하는 부분
            UserType newType = isEmailCorporate ? UserType.EMPLOYEE : UserType.CUSTOMER;
    
            if (type != newType) {
                // 결정하는 부분
                int delta = newType == UserType.EMPLOYEE ? 1 : -1;
                int newNumber = numberOfEmployee + delta;
    
                company.setNumberOfEmployee(newNumber);
                Database.save(company);
            }
    
            user.setEmail(newEmail);
            user.setType(newType);
            Database.save(user);
            MessageBus.sendEmailChangedMessage(userId, newEmail);
        }
    }

    위 코드를 앞에서 이야기 했던 관점으로 살펴보면 어떨까? 

    • 두 가지 결정 존재
      • 유저의 새로운 타입 결정
      • 회사의 직원수 결정
    • 4가지 의존성 존재
      • 명시적 의존성(주입되는 의존성)은 2개있다.
      • 암시적 의존성은 2개 있다. (Message Bus, DataBase)
    • 도메인 유의성
      • 어플리케이션의 요구 사항을 구현하는 핵심 비즈니스 로직.

    코드 분류를 통해서 현재 위 코드가 어디에 존재하는지를 살펴보면 다음과 같다. 현재 코드를 험블 객체 패턴을 이용해서 분리해서 코드의 복잡도를 떨어뜨리고, 테스트 하기 좋도록 바꾸는 것이 목표다.


    7.2.2 1단계 암시적 의존성을 명시적 의존성으로 바꾸기

    암시적 의존성이던 DataBase, MessageBus를 생성자에서 입력받는 형식으로 명시적 의존성으로 바꾼다. 하지만 이 코드에서는 이 변경만으로는 큰 의미가 없다. 왜냐하면 DataBase, MessageBus는 여전히 외부 프로세스에 있는 의존성이고, MyUser는 도메인 클래스이기 때문이다. 이런 형태는 복잡한 Mock 객체를 요구하기 때문에 테스트 유지 비용이 비싸진다. 

    public MyUser(int userId, String email, Database database, MessageBus messagebus) {
        this.userId = userId;
        this.email = email;
        this.database = database;
        this.messagebus = messagebus;
    }

    7.2.3 2단계 : 어플리케이션 서비스 계층 도입.

    도메인 모델이 외부 프로세스와 통신하고 있는 문제점을 해결하기 위해서 험블 객체 패턴을 사용한다. 다음과 같이 분류해서 해결해 볼 수 있다. 

    • 험블 컨트롤러(Application Service)에 외부 프로세스와의 통신을 옮긴다.
    • 도메인 클래스는 도메인 클래스끼리 의존하면서 비즈니스 로직을 수행한다. 

    이 형태로 코드를 재수정 하면 다음과 같이 생성된다. 

    // 단순 오케스트레이션 목적
    public class MyUserController {
    
        private Database database = new Database();
        private MessageBus messageBus = new MessageBus();
    
        public void changeEmail(int userId, String newEmail) {
    
            // 외부 의존성 + Entity Mapping
            // Entity Mapping은 복잡한데, 여기서 너무 많은 책임을 가진다.
            MyUser user = database.findUserById(userId);
            String email = user.getEmail();
            MyUser.UserType type = user.getType();
            MyUser newUser = new MyUser(userId, email, type);
    
            Company company = Database.getCompany();
            String companyDomainName = company.getCompanyDomainName();
            int numberOfEmployee = company.getNumberOfEmployee();
    
            // EmailChange의 결과로 사원수를 받는게 이상하다.
            int newNumberOfEmployee = user.changeEmail(newEmail, companyDomainName, numberOfEmployee);
    
            // 외부 의존성
            company.setNumberOfEmployee(newNumberOfEmployee);
            database.save(company);
            database.save(user);
            messageBus.sendEmailChangedMessage(userId, newEmail);
        }
    }
    

    이전 코드에 비해서는 개선되었다. 개선된 점과 아쉬운 점을 살펴보면 다음과 같다.

    • 아쉬운 점
      • 외부 프로세스를 주입 받는 것이 아니라 본인이 직접 생성해서 사용한다. 이것은 테스트 코드에 문제를 야기하고, 안티 패턴이다.
      • 컨트롤러에서 엔티티를 맵핑하는 큰 작업을 한다. 이 작업은 복잡한 작업이기 때문에 단순 오케스트레이션을 하는 컨트롤러에서 하면 안된다. 
      • user.changeEmail()의 결과로 사원수를 반환받는 것이 이상한다. 
    • 잘된 점.
      • 외부 프로세스와의 통신을 도메인 계층과 완전 분리했다. 
    public class MyUser {
    
        private int userId;
        private String email;
        private UserType type;
    
    
        public int changeEmail(String newEmail, String companyDomainName, int numberOfEmployee) {
    
            if (email.equals(newEmail)) {
                return numberOfEmployee;
            }
    
            String emailDomain = newEmail.split("@")[1];
            boolean isEmailCorporate = emailDomain.equals(companyDomainName);
            UserType newType = isEmailCorporate ? UserType.EMPLOYEE : UserType.CUSTOMER;
    
            if (type != newType) {
                int delta = newType == UserType.EMPLOYEE ? 1 : -1;
                numberOfEmployee = numberOfEmployee + delta;
            }
    
            this.setEmail(newEmail);
            this.setType(newType);
    
            return numberOfEmployee;
        }

    User의 코드는 다음과 같아진다.

    • User 도메인 클래스는 더 이상 외부 프로세스 협력자를 가지지 않는다.
    • 아직 로직은 복잡하기 때문에 복잡한 코드에 속한다. 

    이렇게 클래스를 분리한 후의 코드 분류표를 보면 다음과 같다. 

    • User 클래스는 외부 협력자를 다 제거해버렸기 때문에 협력자수가 굉장히 적어진다. 즉, 테스트 했을 때 높은 가치를 가지는 코드가 된다.
    • UserController 클래스는 Entity를 맵핑하는 복잡한 작업이 있기 때문에 아직까지 복잡도가 높은 상태다. 이 부분의 개선이 필요하다. 

     


    7.2.4 3단계: 어플리케이션 서비스 복잡도 낮추기

    앞서 UserController는 EntityMapping을 하는 복잡한 작업을 하고 있다. 컨트롤러 치고는 다소 높은 코드 복잡도를 가지고 있는데, 이 부분 역시 분리를 통해서 해결할 수 있다. 책에서는 두 가지 방법을 제안한다. 

    • ORM을 이용한 맵핑 → JPA 사용
    • Raw DB 데이터를 맵핑해서 도메인 인스턴스를 생성하는 팩토리 클래스 작성 

    첫번째 방법은 JPA를 이용하면 단순히 처리가 가능하다. 팩토리 클래스는 예를 들면 다음과 같이 작성해볼 수 있다. 

    public class MyUserFactory {
    
        public static MyUser createMyUser(Object ... objects) {
    
            assert objects.length >= 3;
    
            List<Object> collect = Arrays.stream(objects).collect(Collectors.toList());
            int userId = (int) collect.get(0);
            String email = (String) collect.get(1);
            MyUser.UserType userType = (MyUser.UserType) collect.get(2);
    
            return new MyUser(userId, email, userType);
        }
    
    }

    팩토리 클래스를 하나 만들어서 처리해볼 수 있다.

    • 이 로직은 맵핑 하는 작업이기 때문에 다소 복잡해 질 수 있다. 즉, 복잡한 코드가 될 수 있다.
    • 외부 프로세스와 의존하지 않는다.
    • 도메인 의존성은 아주 미미하다. 왜냐하면 사용자 이메일을 변경하라는 클라이언트의 목표와 직접적인 관련이 없다. 

    7.2.5 4단계 : 새 Company 클래스 소개

    아직까지는 개선할만한 부분이 많은 코드다.

    • CompanyFactory, UserCompany 클래스를 도입해서 컨트롤러의 맵핑 책임을 덜었다. 즉, UserController의 복잡성이 줄어들게 되었다.
    • User 클래스의 메서드 실행 결과는 사원수를 반환한다. 이것은 Company 클래스에 있는 값인데, 이걸 반환하는 것이 어색하다. 

    위에서 볼 수 있듯이 아직은 어색한 부분이 존재한다. 어색한 부분이 존재하는 것은 User 클래스에서 잘못된 책임을 가져가고 있는 상황이기 때문이다. 이런 책임을 Company 클래스에게 나누어주고 코드를 개선하고자 한다. Company와 User는 모두 도메인 계층이기 때문에 서로 의존 관계를 가져도 아무 문제가 없다. 코드 개선의 최종 꼴은 다음과 같을 것이다. 

    하나씩 값을 살펴본다. 

    Company 클래스

    public class Company {
    
        private String companyDomainName;
        private int numberOfEmployee;
    
        public boolean isEmailCorporate(String newEmail) {
            String emailDomain = newEmail.split("@")[1];
            return emailDomain.equals(companyDomainName);
        }
    
        public void changeNumberOfEmployess(int delta) {
            this.numberOfEmployee += delta;
        }
    ...
    }

    Company 도메인에 다음 코드들을 추가한다.

    • Tell, don't ask 원칙에 알맞는 코드다. Company의 상태가 어떤지 물어보기 보다는 '그냥 해라!'라고 시키는 형태의 코드다. 
    • 묻고 답하는 경우라면, 코드가 굉장히 복잡해진다. 따라서 User 도메인에서는 Company 도메인에게 단지 하라고 시키고, Company 도메인은 받은 값을 가지고 처리하면 된다. 

    User 클래스

    public class MyUser {
    
        private int userId;
        private String email;
        private UserType type;
    
    
        public void changeEmail(String newEmail, Company company) {
    
            if (this.email.equals(newEmail)) {
                return;
            }
    
            UserType newType = company.isEmailCorporate(newEmail) ? UserType.EMPLOYEE : UserType.CUSTOMER;
    
            if (newType != this.type) {
                int delta = newType == UserType.EMPLOYEE ? 1 : -1;
                company.changeNumberOfEmployess(delta);
            }
    
            this.setEmail(newEmail);
            this.setType(newType);
        }

    user 클래스는 다음과 같이 작성한다.

    • 이전에는 user 클래스에서 새롭게 받은 이메일이 회사 이메일인지를 확인했다. 하지만 그 책임을 Company 클래스에게 넘겼다. 
    • 이전에는 user 클래스에서 직접 사원수를 가져와서 계산했다. 이제는 사원수 계산 책임을 Company 클래스에게 넘겼다. User 클래스는 단지 Company 클래스에게 Delta를 넘겨서 계산하라고 한다. (이전에는 얼마인지를 묻고, 그것에 대해서 계산을 직접해서 반환했다.)

     UserController 클래스

    // 단순 오케스트레이션 목적
    public class MyUserController {
    
        private Database database = new Database();
        private MessageBus messageBus = new MessageBus();
    
        public void changeEmail(int userId, String newEmail) {
    
            // 외부 의존성
            MyUser findUser = database.findUserById(userId);
            MyUser user = MyUserFactory.createMyUser(findUser.getUserId(), findUser.getEmail(), findUser.getType());
    
            Company findCompany = Database.getCompany();
            Company company = CompanyFactory.createCompany(findCompany.getCompanyDomainName(), findCompany.getNumberOfEmployee());
    
            user.changeEmail(newEmail, company);
    
            // 외부 의존성
            database.save(company);
            database.save(user);
            messageBus.sendEmailChangedMessage(userId, newEmail);
        }

    UserController 클래스는 단순 오케스트레이션 목적이다. 따라서 복잡한 코드는 모두 Factory와 도메인으로 넘겼고, 이 녀석은 외부 프로세스와 도메인 프로세스 사이의 오케스트레이션만 처리해준다. 

     

    정리

    험블 객체 패턴을 적용해서 다음을 달성했다.

    1. 외부 프로세스와 도메인 간의 의존성을 분리했다.
    2. 컨트롤러(쉘 같은 느낌)는 단순히 객체 간의 오케스트레이션만 담당하도록 바꾸었다. 
    3. 컨트롤러에 복잡한 코드가 들어오면 팩토리 코드 등을 이용해서 분리한다.
    4. 비즈니스 로직을 잘 수행하기 위해서 도메인 클래스 간의 책임을 잘 나누도록 조절했다. 
    5. 협력을 할 때, 묻고 답하기 보다는 '묻지 말고 명령해라'라는 형태로 접근하는게 좀 더 가독성 있다. 

    7.3 최적의 단위 테스트 커버리지 분석

    앞서서 험블 객체 패턴을 사용해 리팩토링을 마쳤다. 험블 객체 패턴은 비즈니스 로직과 오케스트레이션 로직을 완벽히 분리한 상태를 만들어주는데, 이 상태에서 코드를 살펴보면 어떤 것을 테스트 해야하는지를 좀 더 명확히 알 수 있다. 현재 코드를 분류해보면 다음과 같다. 

      협력자가 거의 없음 협력자가 많음
    복잡도
    도메인 유의성
    높음
    User.ChangeMail(newEmail, Company)
    company.ChangeNumberOfEmployees(delta)
    Company.isEmailCorporate(email)
    CompanyFactory.Create(data)
     
    복잡도
    도메인 유의성
    낮음
    User 생성자
    Company 생성자
    UserController.changeEmail(userId, newEmail)

    코드는 위와 같이 분류가 된다. 그럼 이 코드를 모드 테스트 해야할까? 아래에서 살펴보고자 한다.


    7.3.1 도메인 계층과 유틸리티 코드 테스트하기

    복잡도 + 도메인 유의성이 높은 영역에 분포한 코드를 테스트 하는 것이 가장 좋다. 코드의 복잡도, 도메인 유의성이 높으면 회귀 방지가 뛰어나다. 또한, 협력자가 거의 없기 때문에 유지비도 가장 낮다. 만약 User.changeMail()을 테스트 한다면 다음 4가지를 테스트 하면 된다. 

    • 사원이 아닌 경우 → 사원인 경우로 변경
    • 사원인 경우 → 사원이 아닌 경우로 변경
    • 유저 타입 변경 없이 이메일 변경
    • 동일한 상태로 이메일 변경 

    위 테스트 케이스는 이 코드를 사용하는 클라이언트가 기대하는 '식별할 수 있는 동작'을 의미한다. 예를 들어 사원이 아닌 경우 → 사원인 경우의 테스트 코드는 다음과 같이 작성해 볼 수 있다.

    @Test
    void changeEmailFromCorporateToNonCorporate() {
        // given
        Company company = new Company("mycorp.com", 1);
        MyUser sut = new MyUser(1, "user@gmail.com", MyUser.UserType.CUSTOMER);
        
        // when
        sut.changeEmail("new@myCorp.com", company);
        
        // then
        assertThat(2).isEqualTo(company.getNumberOfEmployee());
        assertThat("new@mycorp.com").isEqualTo(sut.getEmail());
        assertThat(MyUser.UserType.EMPLOYEE).isEqualTo(sut.getType());
    }

    7.3.2 나머지 세 사분면에 대한 코드 테스트 하기

    1. 복잡도가 낮고 협력자가 거의 없는 코드
    2. 복잡도가 높고 협력자가 많은 코드
    3. 복잡도가 낮고 협력자가 많은 코드 

    세 가지 경우가 존재한다. 

    1번 케이스는 너무 단순하기 때문에 회귀 방지가 떨어진다. 따라서 테스트 할 필요가 없는 코드다. 3번은 테스트를 해야하는데, 주로 테스트 대역을 이용해서 처리한다. 이것들은 아래에서 공부하도록 한다.  2번은 코드 리팩토링을 해서 반드시 개선해야 하는 부분이며, 따라서 이를 테스트 할 일은 없다. 


    7.3.3 전제 조건을 테스트해야 하는가?

    도메인 유의성이 있는 전제조건은 반드시 테스트 해야하고, 도메인 유의성이 없는 전제조건은 테스트 하지 않아도 괜찮다고 한다. 그렇다면 어떤 것이 도메인 유의성 있는 전제조건일까? 도메인 유의성이 있는 전제조건은 도메인의 불변성(항상 참이어야 함)과 관련된 전제조건이다. 도메인의 불변성을 만족시키지 못하면 시스템 전체에 장애가 있는 상태가 유지될 수 있기 때문이다. 

    "직원 수는 항상 0보다 크거나 같아야 한다" 라는 전제조건을 가정해보자. 이 때, 아래 코드는 반드시 테스트 해야하는 도메인 유의성 있는 전제조건이다.

    public void changeNumberOfEmployess(int delta) {
        // 도메인 불변성과 관련된 전제조건 → 반드시 테스트 필요.
        assert this.numberOfEmployee + delta > 0; 
        this.numberOfEmployee += delta;
    }

    아래에 있는 전제조건은 테스트 하지 않아도 괜찮은 전제조건이다. UserController에서 User를 생성하는 요청과 함께 외부에서 들어온 입력이 올바른지를 확인하는 전제조건이다. 이 전제조건은 필요한 전제조건이지만 도메인 유의성이 없기 때문에 테스트 하지 않아도 괜찮다. 만약 이 전제조건을 제대로 만족하지 못하더라도 도메인 계층에서 불변성 관련 전제 조건이 있을 것이고 그것을 테스트 하기 때문이다. 즉, 도메인 유의성이 없는 전제조건은 만들어져도 상관없으나 테스트는 하지 않는 것이 좋다. 

    public static MyUser createMyUser(Object ... objects) {
    
        assert objects.length >= 3;
     	...   
    }

    7.4 컨트롤러에서 조건부 로직 처리

    비즈니스 로직과 오케스트레이션의 분리는 연산이 다음과 같이 세 단계로 나누어져 있을 때 가장 적합하다. 

    • 저장소에서 데이터 검색
    • 비즈니스 로직 실행
    • 데이터를 다시 저장소에 저장 

    그렇지만 이렇게 단계가 명확하게 나누어지지 않는 경우가 더욱 많다. 비즈니스 로직에서 의사 결정을 하는 과정에서 프로세스 외부 의존성에서가 추가 정보가 필요한 경우가 존재하기 때문이다. 이런 문제는 어떻게 해결해야할까? 문제를 해결하는 과정에서 중요시 생각해야 하는 세 가지 정보가 있다. 

    • 도메인 모델 테스트 유의성
    • 컨트롤러 단순성
    • 성능 

    단순하지 않는 상황일 때, 이 부분을 해결하는 방법은 다음이 있을 수 있고, 위의 세 가지 요소와 함께 고려해보자. 

    • 외부 프로세스와 통신을 모두 가장자리로 밀어내기
      • 외부 프로세스의 정보가 필요하지 않은 경우에도 항상 정보를 요청한다. 따라서 성능이 떨어진다. 그렇지만 도메인 모델 테스트 유의성, 컨트롤러 단순성은 좋은 상태를 유지한다.
    • 도메인 모델에 프로세스 외부 의존성 주입
      • 성능을 유지하면서 컨트롤러를 단순하게 한다. 하지만 외부 프로세스 의존성이 추가되며, 도메인 모델을 테스트 하기 어려워진다.
    • 의사 결정 프로세스 단계를 더욱 세분화하기
      • 성능과 도메인 모델 테스트 유의성에는 좋은 효과를 준다. 하지만 컨트롤러에서 의사 결정 지점이 생긴다. 

    첫번째는 성능이 떨어지기 때문에 사용할 수 없고, 두번째는 대부분의 코드가 복잡 + 의존성이 많은 영역 + 도메인 유의성이 높은 영역에 존재하기 때문에 테스트 코드를 작성하기 어려워진다. 따라서 세번째 옵션인 '의사 결정 프로세스 단계를 더욱 세분화 하기'만 남게 된다. 

    의사 결정 프로세스 단계를 더욱 세분화하면, 컨트롤러가 '복잡 + 의존성이 많은 영역'에 다가가면서 테스트 하기가 어려워진다. 그렇지만 컨트롤러에서는 코드의 복잡도를 완화할 수 있는 방법이 존재한다.

     

    7.4.1 CanExecute / Execute 패턴 사용

    이 패턴은 컨트롤러에서 의사 결정을 할 수 있도록 하는 대신, 의사 결정 로직은 모두 도메인 영역에 존재하도록 작성한다. 컨트롤러는 도메인 영역에 있는 의사 결정 로직을 호출해서 실행할 수 있는지 확인(CanExecute)하고 가능하다면 실행(Execute)한다. 

    컨트롤러에서 의사 결정을 할 수 있도록 하면 성능 + 도메인의 테스트 코드는 작성하기 쉬우나 컨트롤러 코드 자체가 복잡해 질 수 있는데, 이 부분을 해결하는 것이다. 또한, 실행 가능 여부가 도메인 코드에 모두 응집되어 있기 때문에 캡슐화 관점에서도 좋으며 실행 가능 조건이 필요하다면 도메인의 CanExecute() 메서드 영역에 추가해주기만 하면 된다. 

    // UserController.java
    public void changeEmail(int userId, String newEmail) {
    
        MyUser findUser = database.findUserById(userId);
        MyUser user = MyUserFactory.createMyUser(findUser.getUserId(), findUser.getEmail(), findUser.getType());
    
    	// 도메인 의사 결정 지점
        if (user.isEmailConfirmed) {
            return; 
        }
        
        Company findCompany = Database.getCompany();
        Company company = CompanyFactory.createCompany(findCompany.getCompanyDomainName(), findCompany.getNumberOfEmployee());
    
        user.changeEmail(newEmail, company);
    
        // 외부 의존성
        database.save(company);
        database.save(user);
        messageBus.sendEmailChangedMessage(userId, newEmail);
    }

    위 코드에서 user.isEmailConfirmed를 직접 확인하는 것은 canExecute()를 실행하는 것이 아니다. 도메인이 현재 가지고 있는 조건을 컨트롤러가 직접 확인하고 의사결정을 직접한다. 이 때, user의 여러 가지 조건을 확인해야한다고 하면 위의 조건문이 Controller에 더 추가되면서 더욱 복잡해진다. canExecute() / Execute()는 다음과 같이 작성하는 것이 좋다.

    // MyUser.java
    
    // CanExecute()용 함수
    public boolean canChangeEmail() {
        return isEmailConfirmed;
    }
    
    public void changeEmail(String newEmail, Company company) {
    
    	// 도메인에서도 체크
        assert canChangeEmail();
    
        if (this.email.equals(newEmail)) {
            return;
        }
    
        ...
    }
    
    // MyUserController.java
    public class MyUserController {
    
        public void changeEmail(int userId, String newEmail) {
    
            ...
    
            if (!user.canChangeEmail()) {
                return;
            }
            
            ...
        }
    }

    도메인 클래스(User)에 CanExecute용으로 새로운 메서드를 하나 생성하고, 이 메서드가 잘 실행되는 것을 이메일 변경의 전제조건으로 작성한다. 이렇게 하면 두 가지 이점이 생긴다.

    • 컨트롤러는 더 이상 이메일 변경 프로세스를 알 필요가 없다. 
      • CanChangeEmail() 메서드를 호출해서 연산을 수행할 수 있는지 확인하기만 하면 된다. 이 메서드에는 여러 유효성 검사를 포함할 수 있고, 모든 도메인 유효성 검사는 컨트롤러로부터 캡슐화 되어있다. 
    • ChangeEmail()에 몇 가지 전제 조건이 추가되어도 이것을 먼저 확인하지 않으면 이메일을 변경할 수 없도록 보장한다. 

    의사 결정 지점은 모두 도메인으로 넘어간다. 따라서 컨트롤러에서 도메인에서 해야 할 의사결정이 정상적으로 되는지 확인할 필요가 없어진다. 이것은 컨트롤러에 If 문이 존재한다고 하더라도, 그 If문이 정상 실행되는지를 테스트 할 필요가 없다는 것을 의미한다. 


    7.4.2 도메인 이벤트를 사용해 도메인 모델 변경 사항 추적 

    어플리케이션에서 무슨 일이 일어났는지를 외부 프로세스에 (클라이언트가 식별할 수 있는 프로세스) 알려야 하는 경우가 있을 수 있다. 앞서 외부 프로세스와의 작업을 오케스트레이션 해주는 계층이 컨트롤러 계층이었기 때문에 컨트롤러 계층을 통해서 이것을 알리는 것이 좋다. 그런데 컨트롤러 계층에 '이걸 알려야 할지 말아야 할지'를 결정하는 의사 결정 책임이 있다면, 컨트롤러의 코드가 복잡해지는 상황을 만든다. 이런 상황에서는 도메인 이벤트를 사용해서 문제를 해결할 수 있다. 

    도메인 이벤트는 컨트롤러에서 의사 결정 책임을 제거하고 해당 책임을 도메인 모델에 적용함으로써 외부 시스템과의 통신에 대한 단위 테스트를 간결하게 한다. 의사 결정 책임은 '해야하는지 아닌지'를 판단하는 역할로 이해하면 될 것 같다. 

    예시 

    public class MyUserController {
    
        private Database database = new Database();
        private MessageBus messageBus = new MessageBus();
    
        public void changeEmail(int userId, String newEmail) {
    
            ...
    
            user.changeEmail(newEmail, company);
    
            // 외부 의존성
            database.save(company);
            database.save(user);
            
            // 이메일이 바뀐 경우에만 외부에 메세지를 보내야 함. 
            messageBus.sendEmailChangedMessage(userId, newEmail);
            
        }
    }

    이메일이 바뀐 경우에만 메세지 버스를 통해서 이메일 변경 메세지를 보내야한다. 그렇지만 현재의 코드에서는 이 요구사항을 만족시키지는 못한다. 만족시키려면 컨트롤러에서 코드를 아래처럼 변경해야한다. 

    • 컨트롤러에 의사결정 로직이 추가되기 때문에 컨트롤러의 코드 복잡도가 올라간다. 
    public void changeEmail(int userId, String newEmail) {
    
        // 외부 의존성
        MyUser findUser = database.findUserById(userId);
        MyUser user = MyUserFactory.createMyUser(findUser.getUserId(), findUser.getEmail(), findUser.getType());
    
    	// 컨트롤러에 의사결정 로직 추가
        if (user.getEmail().equals(newEmail)) {
            return;
        }
        
        if (!user.canChangeEmail()) {
            return;
        }
    
    	...
    		
        user.changeEmail(newEmail, company);
    
        ...
        messageBus.sendEmailChangedMessage(userId, newEmail); // 보내기
    }

    또 다른 방법으로는 CanExecute / Execute 패턴의 CanExecute에 조건 검사를 넣는 방법을 생각해 볼 수도 있다. 그렇지만 이 방법도 추후에는 복잡해 질 수 있다. 

    • 새로운 이메일인지를 확인하는 과정에서 CanExecute()에 매개변수가 들어왔는데, 이런 조건이 증가할수록 많은 매개변수가 생긴다. 따라서 시간이 흐르면 흐를수록 CanExecute()를 유지보수 하기가 어려워진다. 
    public boolean canChangeEmail(String newEmail) { // 매개변수가 추가됨. 
    
        boolean isNewEmail = this.email.equals(newEmail);
        if (isEmailConfirmed && isNewEmail) {
            return true;
        }
        return false;
    }

     

    좋은 방법은 다음과 같이 도메인 이벤트를 사용하는 것이다. 

    // User.java
    public void changeEmail(String newEmail, Company company) {
    
        ...
    
        if (this.email.equals(newEmail)) {
            return;
        }
    
        ...
        
        this.setEmail(newEmail);
        this.setType(newType);
    
        // 의사 결정 부분을 여기서 만듦.
        this.domainEventList.add(new DomainEvent(userId, newEmail));
    }
    
    UserController.java
    public void changeEmail(int userId, String newEmail) {
    
        ...
    
        Company findCompany = Database.getCompany();
        Company company = CompanyFactory.createCompany(findCompany.getCompanyDomainName(), findCompany.getNumberOfEmployee());
    
        user.changeEmail(newEmail, company);
    
        ...
        user.domainEventList.forEach(domainEvent -> messageBus.sendEmailChangedMessage1(domainEvent.getUserId(), domainEvent.getNewEmail()));
    }
    • User 클래스에서는 새 이메일로 변경된 경우, User 클래스의 필드 변수인 List<DomainEvent>에 새로운 이벤트를 추가한다. 
    • UserController 클래스는 user.changeEmail()을 호출한 이후에 user 인스턴스가 가지고 있는 모든 domainEvent에 대해서 send()하는 작업을 한다. 
      • 이 작업은 '묻지 말고 실행해라'라는 것과 일맥상통한다. 
      • 의사 결정은 이미 도메인 클래스에서 다 실행되어 있으며, 컨트롤러에서는 이 의사결정 결과를 따르기만 하면 된다. 

     


    7.5 결론

    이 장에서는 외부 시스템에 대한 어플리케이션의 사이드 이펙트를 추상화 하는 것이다. 비즈니스 연산이 끝날 때까지 사이드 이펙트를 추상화 해서 메모리에 저장한다. 추상화 된 사이드 이펙트는 프로세스 외부 의존성 없이 단순한 단위 테스트로 테스트 할 수 있게 된다. 

    • 도메인 이벤트 
      • 이메일 변경에 대한 이벤트를 메세지 버스에 보내야한다. 메세지 버스의 상태를 '도메인 이벤트'로 추상화 해서 메모리에 두었고, 개발자는 단위 테스트로 '도메인 이벤트'를 검증하기만 하면 된다. 

    항상 CanExecute / Execute 패턴이 가능한 것은 아니다. 비즈니스 로직의 파편화가 불가피한 상황이 존재한다. 비즈니스 로직의 파편화는 비즈니스 로직이 도메인 클래스 / 컨트롤러 클래스에 둘다 존재하는 것을 의미한다. 예를 들어 도메인 클래스에스 비즈니스 로직을 처리하는데, 반드시 DB에서 데이터를 읽어와야 의사 결정을 할 수 있는 부분이 존재한다. 하지만 도메인 클래스에서 외부 프로세스를 호출하지 않도록 하기 때문에 비즈니스 로직의 파편화가 발생한다. 

    도메인 클래스 호출(비즈니스 로직) → 컨트롤러에서 외부 프로세스 호출 → 도메인 클래스 다시 호출(비즈니스 로직)

    위와 같은 흐름으로 이동하기 때문에 비즈니스 로직이 파편화 될 수 있다는 것을 의미한다. 이런 것들은 각각을 단위 테스트 하고, 전체를 통합 테스트 해서 해결한다. 이처럼 비즈니스 로직의 잠재적인 파편화는 존재하지만 비즈니스 로직과 오케스트레이션을 분리하는 것은 의미가 있다. 단위 테스트 프로세스가 크게 간소화되기 때문이다. 

    도메인 클래스에서 모든 협력자를 제거할 수 있는 것은 아니다. 도메인 클래스가 외부 프로세스를 참조하지 않는다면 충분히 테스트 할 수 있는 코드가 된다. 도메인 클래스에서는 협력자끼리의 상호 작용이 만약 구현 세부 사항이라면 절대로 테스트 해서는 안된다. 이것은 리팩토링 내성을 떨어뜨리는 결과를 가져온다. 

    외부 클라이언트가 호출했을 때, 식별할 수 있는 동작만 테스트를 하도록 작성한다. 예를 들어 UserController를 테스트 한다면, UserController가 가져오는 '이메일 버스로의 호출'을 검증하는 작업은 해도 좋지만 user.changeEmail() 메서드가 정상적으로 호출되는지는 테스트 하지 않는다. 

     


    7장 요약

    • 코드 복잡도는 코드에서 의사 결정 지점 수에 따라 명시적으로, 그리고 암시적으로 정의된다.
    • 도메인 유의성은 프로젝트의 문제 도메인에 대해 코드가 얼마나 중요한지를 보여준다. 
    • 복잡한 코드와 도메인 유의성을 갖는 코드는 해당 테스트의 회귀 방지가 뛰어나기 때문에 단위 테스트에서 가장 이롭다. 
    • 협력자가 많은 코드를 다루는 단위 테스트는 유지비가 많이 든다.
    • 모든 제품 코드는 복잡도 또는 도메인 유의성과 협력자 수에 따라 네 가지 유형의 코드로 분류할 수 있다. 
      • 도메인 모델 및 알고리즘은 단위 테스트를 반드시 해야한다.
      • 간단한 코드는 테스트 할 가치가 전혀 없다.
      • 컨트롤러는 통합 테스트를 통해 간단히 테스트해야 한다. 
      • 지나치게 복잡한 코드는 컨트롤러와 복잡한 코드로 분할해야한다. 
    • 코드가 중요하거나 복잡할수록 협력자가 적어야 한다. 
    • 험블 객체 패턴은 해당 코드에서 비즈니스 로직을 별도의 클래스로 추출해 복잡한 코드를 테스트 할 수 있는데 도움이 된다. 그 결과, 나머지 코드는 비즈니스 로직을 둘러싼 얇은 험블 래퍼, 즉 컨트롤러가 된다.
    • 코드의 깊이와 너비의 관점에서 비즈니스 로직과 오케스트레이션 책임을 생각하라. 코드는 깊을 수도 있고(복잡하거나 중요함), 넓을 수도 있지만(협력자 많음), 둘 다는 아니다
    • 도메인 유의성이 있으면 전제 조건을 테스트하고, 그 외의 경우에는 테스트하지 않는다.
    • 비즈니스 로직과 오케스트레이션을 분리할 때는 다음과 같이 세 가지 중요한 특성이 있다.
      • 도메인 모델 테스트 유의성: 도메인 클래스 내 협력자 수와 유형에 대한 함수
      • 컨트롤러 단순성 : 컨트롤러에 의사 결정 지점이 있는지에 따라 다름
      • 성능 : 프로세스 외부 의존성에 대한 호출 수로 정의
    • 항상 세 가지 특성 중 최대 두 가지를 가질 수 있다.
      • 외부에 대한 모든 읽기와 쓰기를 비즈니스 연산 가장자리로 밀어내기 : 성능 저하
      • 도메인 모델에 프로세스 외부 의존성을 주입하기 : 테스트하기가 어려워짐. 
      • 의사 결정 프로세스 단계를 더 세분화하기 : 컨트롤러의 단순성을 포기함. 
    • 의사 결정 프로세스 단계를 더 세분화 하는 것이 장단점을 고려할 때 가장 효과적인 절충이다.
      • CanExecute()/Execute() 패턴은 각 Do() 메서드에 대해 CanDo()를 두고, CanDo()가 성공적으로 실행되는 것을 Do()의 전제조건으로 한다. 이 패턴은 Do() 전에 CanDo()를 호출하지 않을 수 없기 때문에 컨트롤러의 의사 결정을 근본적으로 제거한다. 
      • 도메인 이벤트는 도메인 모델의 중요한 변경 사항을 추적하고 해당 변경 사항을 프로세스 외부 의존성에 대한 호출로 변환한다. 이 패턴은 컨트롤러에서 추적에 대한 책임이 없어진다.
    • 추상화 할 것을 테스트 하기보다는 추상화를 테스트 하는 것이 더 쉽다. 

     

     

     

     

     

     

    댓글

    Designed by JB FACTORY