티스토리 뷰

테스트

Unit Testing - 7장

perseverance 2024. 9. 17. 15:29

1. 리팩터링할 코드 식별하기

기반 코드를 리팩터링하지 않고서는 테스트 스위트를 크게 새선할 수 없다. 이 절에서는 리팩터링의 방향을 설명하고자 코드를 네 가지 유형으로 분류하는 방법을 소개한다.

1.1 코드의 네 가지 유형

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

  • 복잡도 또는 도메인 유의성
  • 협력자 수

코드 복잡도는 코드 내 의사 결정(분기) 지정 수로 정의한다. 이 숫자가 클수록 복잡도는 더 높아진다.

 

도메인 유의성은 코드가 프로젝트의 문제 도메인에 대해 얼마나 의미 있는지를 나타낸다. 일반적으로 도메인 계층의 모든 코드는 최종 사용자의 목표와 직접적인 연관성이 있으므로 도메인 유의성이 높다. 반면에 유틸리티 코드는 그런 연관성이 없다.

 

복잡한 코드와 도메인 유의성을 갖는 코드가 단위 테스트에서 가장 이롭다. 해당 테스트가 회귀 방지에 뛰어나기 때문이다. 도메인 코드는 복잡할 필요가 없으며, 복잡한 코드는 도메인 유의성이 나타나지 않아도 테스트할 만하다. 예를 들어 주문 가격을 계산하는 메서드에 조건문이 없다면 순환 복잡도는 1이다. 그러나 이러한 메서드는 비즈니스에 중요한 기능이므로 테스트하는 것이 중요하다.

 

두 번째 차원은 클래스 또는 메서드가 가진 협력자 수다. 협력자는 가변 의존성이거나 프로세스 외부 의존성(또는 둘 다)이다. 협력자가 많은 코드는 테스트 비용이 많이 든다. 테스트 크기에 따라 달라지는 유지 보수성 지표 때문이다. 협력자를 예상되는 조건으로 두고 상태나 상호 작용을 확인하게끔 코드를 작성해야 한다. 협력자가 많을수록 테스트도 커진다.

 

협력자의 유형도 중요하다. 도메인 모델이라면 프로세스 외부 협력자를 사용하면 안 된다. 테스트에서 목 체계가 복잡하기 때문에 유지비가 더 든다. 또한 리팩터링 내성을 잘 지키려면 아주 신중하게 목을 사용해야 하는데, 애플리케이션 경계를 넘는 상호 작용을 검증하는 데만 사용해야 한다.

 

프로세스 외부 의존성을 가진 모든 통신은 도메인 계층 외부의 클래스에 위임하는 것이 좋다.

 

암시적 협력자와 명시적 협력자 모두 이 숫자에 해당한다. 테스트 대상 시스템(sut)이 협력자를 인수로 받거나 정적 메서드를 통해 암시적으로 참조해도 상관없지만, 테스트에서 이 협력자를 설정해야 한다. 반대로 불변 의존성(값 또는 값 객체 등) 은 해당하지 않는다. 불변 의존성은 설정과 검증이 훨씬 쉽다.

 

코드 복잡도, 도메인 유의성, 협력자 수의 조합으로 네 가지 코드 유형을 볼 수 있다.

  • 도메인 모델과 알고리즘: 보통 복잡한 코드는 도메인 모델이지만, 100%는 아니다. 문제 도메인과 직접적으로 관련이 없는 복잡한 알고리즘이 있을 수 있다.
  • 간단한 코드: C#에서 이러한 코드의 예로 매개변수가 없는 생성자와 한 줄 속성 등이 있다. 협력자가 있는 경우가 거의 없고 복잡도나 도메인 유의성도 거의 없다.
  • 컨트롤러: 이 코드는 복잡하거나 비즈니스에 중요한 작업을 하는 것이 아니라 도메인 클래스와 외부 애플리케이션 같은 다른 구성 요소의 작업을 조정한다.
  • 지나치게 복잡한 코드: 이러한 코드는 두 가지 지표 모두 높다. 협력자가 많으며 복잡하거나 중요하다. 한 가지 예로 덩치가 큰 컨트롤러(복잡한 작업을 어디에도 위임하지 않고 모든 것을 스스로 하는 컨트롤러)가 있다.

image

좌측 상단 사분면(도메인 모델 및 알고리즘)을 단위 테스트하면 노력 대비 가장 이롭다. 이러한 단위 테스트는 매우 가치 있고 저렴하다. 해당 코드가 복잡하거나 중요한 로직을 수행해서 테스트의 회귀 방지가 향상되기 때문에 가치 있다. 또한 코드에 협력자가 거의 없어서 테스트 유지비를 낮추기 때문에 저렴하다.

 

간단한 코드는 테스트할 필요가 전혀 없다. 이러한 테스트는 가치가 0에 가깝다. 컨트롤러의 경우, 포괄적인 통합 테스트의 일부로서 간단히 테스트해야 한다.

 

가장 문제가 되는 코드 유형은 지나치게 복잡한 코드다. 단위 테스트가 어렵겠지만, 테스트 커버리지 없이 내버려두는 것은 너무 위험하다. 이러한 코드는 많은 사람이 단위 테스트로 어려움을 겪는 주요 원인 중 하나다. 이 장에서는 주로 어떻게 이 딜레마를 우회할 수 있는지에 대해 초점을 맞추고 있다.

 

때때로 실제 구현이 까다로울 수 있지만, 지나치게 복잡한 코드를 알고리즘과 컨트롤러라는 두 부분으로 나누는 것이 일반적이다.

코드가 더 중요해지거나 복잡해질수록 협력자는 더 작아야 한다.

 

지나치게 복잡한 코드를 피하고 도메인 모델과 알고리즘만 단위 테스트하는 것이 매우 가치 있고 유지 보수가 쉬운 테스트 스위트로 가는 길이다. 하지만 이 방법으로는 테스트 커버리지를 100% 달성할 수 없으며, 이를 목표로 해서도 안 된다. 목표는 각각의 테스트가 프로젝트 가치를 높이는 테스트 스위트다.

image

좋지 않은 테스트를 작성하는 것보다는 테스트를 전혀 작성하지 않는 편이 낫다.

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

지나치게 복잡한 코드를 쪼개려면, 험블 객체(Humble Object) 패턴을 써야 한다.

테스트하기 어려운 의존성(비동기, 멀티 스레드, 사용자 인터페이스, 프로세스 외부 의존성 등등)과 결합된 코드는 테스트하기 어렵다. 테스트는 해당 의존성도 다뤄야 하기 때문에 유지비가 증가한다.

 

테스트 대상 코드의 로직을 테스트하려면, 테스트가 가능한 부분을 추출해야 한다. 결과적으로 코드는 테스트 가능한 부분을 둘러싼 얇은 험블 래퍼(humble wrapper)가 된다. 이 험블 래퍼가 테스트하기 어려운 의존성과 새로 추출된 구성 요소를 붙이지만, 자체적인 로직이 거의 없거나 전혀 없으므로 테스트할 필요가 없다.

험블 객체 패턴은 지나치게 복잡한 코드에서 로직을 추출해 코드를 테스트할 필요가 없도록 간단하게 만든다. 추출된 로직은 테스트하기 어려운 의존성에서 분리된 다른 클래스로 이동한다.

 

험블 객체 패턴을 보는 또 다른 방법은 단일 책임 원칙을 지키는 것이다. 이는 각 클래스가 단일한 책임만 가져야 한다는 원칙이다. 그러한 책임 중 하나로 늘 비즈니스 로직이 있는데, 이 패턴을 적용하면 비즈니스 로직을 거의 모든 것과 분리할 수 있다.

2. 가치 있는 단위 테스트를 위한 리팩터링하기

2.1 고객 관리 시스템 소개

이번 샘플 프로젝트는 사용자 등록을 처리하는 고객 관리 시스템이며, 모든 사용자가 데이터베이스에 저장된다. 현재 시스템은 사용자 이메일 변경이라는 단 하나의 유스케이스만 지원한다. 이 연산에는 세 가지 비즈니스 규칙이 있다.

  • 사용자 이메일이 회사 도메인에 속한 경우 해당 사용자는 직원으로 표시된다. 그렇지 않으면 고객으로 간주한다.
  • 시스템은 회사의 직원 수를 추적해야 한다. 사용자 유형이 직원에게 고객으로, 또는 그 반대로 변경되면 이 숫자도 변경해야 한다.
  • 이메일이 변경되면 시스템은 메시지 버스로 메시지를 보내 외부 시스템에 알려야 한다.

다음 예제닌 CRM 시스템의 초기 구현이다.

namespace Book.Chapter7.SampleProject
{
    public class User
    {
        public int UserId { get; private set; }
        public string Email { get; private set; }
        public UserType Type { get; private set; }

        public void ChangeEmail(int userId, string newEmail)
        {
            object[] data = Database.GetUserById(userId);
            UserId = userId;
            Email = (string)data[1];
            Type = (UserType)data[2];

            if (Email == newEmail)
                return;

            object[] companyData = Database.GetCompany();
            string companyDomainName = (string)companyData[0];
            int numberOfEmployees = (int)companyData[1];

            string emailDomain = newEmail.Split('@')[1];
            bool isEmailCorporate = emailDomain == companyDomainName;
            UserType newType = isEmailCorporate
                ? UserType.Employee
                : UserType.Customer;

            if (Type != newType)
            {
                int delta = newType == UserType.Employee ? 1 : -1;
                int newNumber = numberOfEmployees + delta;
                Database.SaveCompany(newNumber);
            }

            Email = newEmail;
            Type = newType;

            Database.SaveUser(this);
            MessageBus.SendEmailChangedMessage(UserId, newEmail);
        }
    }

    public enum UserType
    {
        Customer = 1,
        Employee = 2
    }

}

코드 복잡도는 그리 높지 않다. ChangeEmail 메서드에는 사용자를 직원으로 식별할지 또는 고객으로 식별할지와 회사의 직원 수를 어떻게 업데이트할지 등 두 가지의 명시적 의사 결정 지점만 포함돼 있다. 간단하지만 이러한 결정은 중요하다. 애플리케이션의 핵심 비즈니스 로직이므로, 이 클래스는 복잡도와 도메인 유의성 측면에서 점수가 높다.

 

반면에 User 클래스에는 네 가지 의존성이 있으며, 그중 두 개는 명시적이고 나머지 두개는 암시적이다.

  • 명시적 의존성은 userId와 newEmail 인수다. 그러나 이 둘은 값이므로 클래스의 협력자 수에는 포함되지 않는다.
  • 암시적인 것은 Database와 MessageBus이다. 이 둘은 프로세스 외부 협력자다.

앞에서 언급했듯이 도메인 유의성이 높은 코드에서 프로세스 외부 협력자는 사용하면 안 된다. 따라서 User 클래스는 협력자 측면에서도 점수가 높으므로 이 클래스는 지나치게 복잡한 코드로 분류된다.

 

도메인 클래스가 스스로 데이터베이스를 검색하고 다시 저장하는 이러한 방식을 활성 레코드(Active Record) 패턴이라고 한다. 단순한 프로젝트나 단기 프로젝트에서는 잘 작동하지만 코드베이스가 커지면 확장하지 못하는 경우가 많다.

 

비즈니스 로직과 프로세스 외부 의존성과의 통신 사이에 분리가 없기 때문이다.

2.2 1단계: 암시적 의존성을 명시적으로 만들기

테스트 용이성을 개선하는 일반적인 방법은 암시적 의존성을 명시적으로 만드는 것이다. 즉, 데이터베이스와 메시지 버스에 대한 인터페이스를 두고, 이 인터페이스를 User에 주입한 후 테스트에서 목으로 처리한다.

 

코드 유형 도표 관점에서 도메인 모델이 프로세스 외부 의존성을 직접 참조하든 인터페이스를 통해 참조하든 상관없다. 해당 의존성은 여전히 프로세스 외부에 있다. 아직 메모리에 데이터가 없는 프록시 형태다. 이러한 클래스를 테스트하려면 복잡한 목 체계가 필요한데, 여기서 테스트 유지비가 증가한다. 그리고 목을 데이터베이스 의존성에 사용하면 테스트 취약성을 야기할 수 있다.

 

결국 도메인 모델은 직접적으로든 간접적으로든 프로세스 외부 협력자에게 의존하지 않는 것이 훨씬 더 깔끔하다. 이것이 바로 육각형 아키텍처에서 바라는 바다. 도메인 모델은 외부 시스템과의 통신을 책임지지 않아야 한다.

2.3 2단계: 애플리케이션 서비스 계층 도입

도메인 모델이 외부 시스템과 직접 통신하는 문제를 극복하려면 다른 클래스인 험블 컨트롤러(hunble controller)로 책임을 옮겨야 한다. 일반적으로 도메인 클래스는 다른 도메인 클래스나 단순 값과 같은 프로세스 내부 의존성에만 의존해야 한다. 이 애플리케이션 서비스의 첫 번째 버전을 보면 다음과 같다.

public class UserController
{
        private readonly Database _database = new Database();
        private readonly MessageBus _messageBus = new MessageBus();

        public void ChangeEmail(int userId, string newEmail)
        {
            object[] data = _database.GetUserById(userId);
            string email = (string)data[1];
            UserType type = (UserType)data[2];
            var user = new User(userId, email, type);

            object[] companyData = _database.GetCompany();
            string companyDomainName = (string)companyData[0];
            int numberOfEmployees = (int)companyData[1];

            int newNumberOfEmployees = user.ChangeEmail(
                newEmail, companyDomainName, numberOfEmployees);

            _database.SaveCompany(newNumberOfEmployees);
            _database.SaveUser(user);
            _messageBus.SendEmailChangedMessage(userId, newEmail);
        }
}

괜찮은 첫 시도다. User 클래스로부터 프로세스 외부 의존성과의 작업을 줄이는 데 애플리케이션 서비스가 도움이 됐다, 그러나 이 구현에는 몇 가지 문제가 있다.

  • 프로세스 외부 의존성(Database와 MessageBus)이 주입되지 않고 직접 인스턴스화된다. 이는 이 클래스를 위해 작성할 통합 테스트에서 문제가 될 것이다.
  • 컨트롤러는 데이터베이스에서 받은 원시 데이터를 User 인스턴스로 재구성한다. 이는 복잡한 로직이므로 애플리케이션 서비스에 속하면 안 된다. 애플리케이션 서비스의 역할은 복잡도나 도메인 유의성의 로직이 아니라 오케스트레이션만 해당한다.
  • 회사 데이터도 마찬가지다. 이 데이터의 다른 문제는 다음과 같다. User는 이제 업데이트된 직원 수를 반환하는데, 이 부분이 이상해 보인다. 회사 직원 수는 특정 사용자와 관련이 없다. 이 책임은 다른 곳에 있어야 한다.
  • 컨트롤러는 새로운 이메일이 전과 다른지 여부와 관계없이 무조건 데이터를 수정해서 저장하고 메시지 버스에 알림을 보낸다.

User 클래스는 더 이상 프로세스 외부 의존성과 통신할 필요가 없으므로 테스트하기가 매우 쉬워졌다. 실제로 프로세스 외부든 내부든 어떤 협력자도 없다.

 

다음 그림은 현재 도표에서 User와 UserController의 위치를 보여준다. User는 더 이상 협력자를 처리할 필요가 없기 때문에 도메인 모델 사분면으로 수직축에 가깝게 이동했다. 하지만 UserController가 문제다. 컨트롤러 사분면에 들어갔지만, 아직 로직이 꽤 복잡하므로 지나치게 복잡한 코드의 경계에 걸쳐 있다.

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

UserController가 컨트롤러 사분면에 확실히 있으려면 재구성 로직을 추출해야 한다. ORM라이브러리를 사용해 데이터베이스를 도메인 모델에 매핑하면, 재구성 로직을 옮기기에 적절한 위치가 될 수 있다. 모든 ORM 라이브러리는 데이터베이스 테이블을 도메인 클래스에 어떻게 매핑하는지를 지정할 수 있게 한다.

 

ORM을 사용하지 않거나 사용할 수 없으면, 도메인 모델에 원시 데이터베이스 데이터로 도메인 클래스를 인스턴스화하는 팩토리 클래스를 작성하라. 이 팩토리 클래스는 별도 클래스가 될 수도 있고, 더 간단한 경우 기존 도메인 클래스의 정적 메서드가 될 수도 있다.

public class UserFactory
{
        public static User Create(object[] data)
        {
            Precondition.Requires(data.Length >= 3);

            int id = (int)data[0];
            string email = (string)data[1];
            UserType type = (UserType)data[2];

            return new User(id, email, type);
        }
}

이 코드는 이제 모든 협력자와 완전히 격리돼 있으므로 테스트가 쉬워졌다. 이 메서드에는 데이터 배열에 최소 세 개의 요소가 있어야 한다는 요구 사항에 대해 안전 장치가 있다.

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

컨트롤러에서 User에서 업데이트된 직원 수를 반환하는 부분이 어색했다. 이는 책임을 잘못 뒀다는 신호이자 추상화가 없다는 신호다. 이 문제를 해결하려면, 다음 예제와 같이 회사 관련 로직과 데이터를 함께 묶는 또 다른 도메인 클래스인 Company를 만들어야 한다.

public class Company
{
        public string DomainName { get; private set; }
        public int NumberOfEmployees { get; private set; }

        public Company(string domainName, int numberOfEmployees)
        {
            DomainName = domainName;
            NumberOfEmployees = numberOfEmployees;
        }

        public void ChangeNumberOfEmployees(int delta)
        {
            Precondition.Requires(NumberOfEmployees + delta >= 0);

            NumberOfEmployees += delta;
        }

        public bool IsEmailCorporate(string email)
        {
            string emailDomain = email.Split('@')[1];
            return emailDomain == DomainName;
        }
}

이 클래스에는 ChangeNumberOfEmployees()와 IsEmailCorporate()라는 두 가지 메서드가 있다. 이러한 메서드는 문지 말고 말하라라는 원칙을 준수하는데 도움이 된다. 이 원칙을 따르면 데이터와 해당 데이터에 대한 작업을 묶는다. User 인스턴스는 직원 수를 변경하거나 특정 이메일이 회사 이메일인지 여부를 파악하도록 회사에 말하며, 원시 데이터를 묻지 않고 모든 작업을 차체적으로 수행한다.

 

컨트롤러는 이제 다음과 같다.

public class UserController
{
        private readonly Database _database = new Database();
        private readonly MessageBus _messageBus = new MessageBus();

        public void ChangeEmail(int userId, string newEmail)
        {
            object[] userData = _database.GetUserById(userId);
            User user = UserFactory.Create(userData);

            object[] companyData = _database.GetCompany();
            Company company = CompanyFactory.Create(companyData);

            user.ChangeEmail(newEmail, company);

            _database.SaveCompany(company);
            _database.SaveUser(user);
            _messageBus.SendEmailChangedMessage(userId, newEmail);
        }
}

이제 모든 복잡도가 팩토리로 이동했기 때문에 UserController는 확실히 컨트롤러 사분면에 속한다. 이 클래스가 담당하는 것은 모든 협력자를 한데 모으는 것이다.

 

이 구현과 이전 장의 함수형 아키텍처 간에 비슷한 점을 생각해보자. 감사 시스템의 함수형 코어도, CRM의 도메인 계층도 프로세스 외부 의존성과 통신하지 않는다. 두 가지 구현 모두에서 애플리케이션 서비스 계층이 해당 통신을 담당한다.

 

두 가지 구현의 차이는 사이드 이펙트 처리에 있다. 함수형 코어는 어떠한 사이드 이펙트도 일으키지 않는다. CRM의 도메인 모델은 사이드 이펙트를 일으키지만, 이러한 모든 사이드 이펙트는 변경된 사용자 이메일과 직원 수의 형태로 도메인 모델 내부에 남아있다.

 

마지막 순간까지 모든 사이드 이펙트가 메모리에 남아있다는 사실로 인해 테스트 용이성이 크게 향상된다. 테스트가 프로세스 외부 의존성을 검사할 필요가 없고 통신 기반 테스트에 의존할 필요도 없다. 메모리에 있는 객체의 출력 기반 테스트와 상태 기반 테스트로 모든 검증을 수행할 수 있다.

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

험블 객체 패턴을 사용해 리팩터링을 마쳤으니, 프로젝트의 어느 부분이 어떤 코드 범주에 속하는지와 해당 부분을 어떻게 테스트해야 하는지를 분석해보자. 다음 표는 샘플 프로젝트의 모든 코드 유형 도표의 위치별로 그룹핑해서 보여준다.

  협력자가 거의 없음 협력자가 많음
복잡도와 도메인 유의성이 높음 User의 ChangeEmail
Company의 ChangeNumberOfEmployees, IsEmailCorporate
CompanyFactory의 Create
 
복잡도와 도메인 유의성이 낮음 User와 Company의 생성자 UserController의 ChangeEmail

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

코드의 복잡도나 도메인 유의성이 높으면 회귀 방지가 뛰어나고 협력자가 거의 없어 유지비도 가장 낮다.

다음은 User를 어떻게 테스트하는지에 대한 예다.

[Fact]
public void Changeing_emain_from_non_corporate_to_corporate()
{
  var company = new Company("mycorp.com", 1);
  var sut = new User(1, "user@gmail.com", UserType.Customer);

  sut.ChangeEmail("new@mycorp.com", company);

  Assert.Equal(2, company.NumberOfEmployees);
  Assert.Equal("new@mycorp.com", sut.Email);
  Assert.Equal(UserType.Employee, sut.Type);
}

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

복잡도가 낮고 협력자가 거의 없는 코드는 다음과 같이 User와 Company의 생성자를 들 수 있다.

public User(int userId, string email, UserType type)
{
  UserId = userId;
  Email = email;
  Type = type;
}

이러한 생성자는 단순해서 노력을 들일 필요가 없으며, 테스트는 회귀 방지가 떨어질 것이다.

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

특별한 종류의 분기점(전제 조건)을 살펴보고 이를 테스트해야 하는지 확인해보자. 예를 들어 Company에 있는 이 메서드를 다시 한 번 살펴본다.

public void ChangeNumberOfEmployees(int delta)
{
    Precondition.Requires(NumberOfEmployees + delta >= 0);

    NumberOfEmployees += delta;
}

회사의 직원 수가 음수가 돼서는 안 된다는 전제 조건이 있다. 이 전제 조건은 예외 상황에서만 활성화되는 보호 장치다. 이러한 예외 상황은 보통 버그의 결과다.

 

그럼 전제 조건에 대한 테스트가 테스트 스위트에 있을 만큼 충분한 가치가 있는가?

 

일반적으로 권장하는 지침은 도메인 유의성이 있는 모든 전제 조건을 테스트하라는 것이다. 직원 수가 음수가 되면 안 된다는 요구 사항이 이러한 전제 조건에 해당한다. 다만 도메인 유의성이 없는 전제 조건을 테스트하는 데 시간을 들일 필요는 없다.

 

예시)

public static User Create(object[] data) {
  Precondition.Requires(data.Lenth > 3);

}

이 전제 조건에 도메인 의미가 없으므로 테스트하기에는 별 가치가 없다.

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

조건부 로직을 처리하면서 동시에 프로세스 외부 협력자 없이 도메인 계층을 유지 보수하는 것은 까다롭고 절충이 있기 마련이다. 이 절에서는 그 절충이 무엇인지 살펴보고 프로젝트에서 어떤 것을 선택할지 결정하는 방법을 소개한다.

 

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

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

그러나 이렇게 단계가 명확하지 않은 경우가 많다. 6장에서 다룬 것처럼, 의사 결정 프로세스의 중간 결과를 기반으로 프로세스 외부 의존성에서 추가 데이터를 조회해야 할 수도 있다. 프로세스 외부 의존성에 쓰기 작업도 종종 그 결과에 따라 달라진다.

이러한 상황에서는 다음과 같이 세 가지 방법이 있다.

  • 외부에 대한 모든 읽기와 쓰기를 가장자리로 밀어낸다. 이 방법은 읽고-결정하고-실행하기 구조를 유지하지만 성능이 저하된다. 필요 없는 경우에도 컨트롤러가 프로세스 외부 의존성을 호출한다.
  • 도메인 모델에 프로세스 외부 의존성을 주입하고 비즈니스 로직이 해당 의존성을 호출할 시점을 직접 결정할 수 있게 한다.
  • 의사 결정 프로세스 단계를 더 세분화하고, 각 단계별로 컨트롤러를 실행하도록 한다.

문제는 다음 세 가지 특성의 균형을 맞추는 것이다.

  • 도메인 모델 테스트 유의성: 도메인 클래스의 협력자 수와 유형에 따른 함수
  • 컨트롤러 단순성: 의사 결정(분기) 지점이 있는지에 따라 다름
  • 성능: 프로세스 외부 의존성에 대한 호출 수로 정의

위에서 언급한 방법은 세 가지 특성 중 두 가지 특성만 갖는다

  • 외부에 대한 모든 읽기와 쓰기를 비즈니스 연산 가장자리로 밀어내기: 컨트롤러를 계속 단순하게 하고 프로세스 외부 의존성과 도메인 모델을 분리하지만(그래서 테스트할 수 있도록 하지만) 성능이 저하된다.
    • 대부분의 소프트웨어 프로젝트에서는 성능이 매우 중요하므로 고려 X
  • 도메인 모델에 프로세스 외부 의존성 주입하기: 성능을 유지하면서 컨트롤러를 단순하게 하지만, 도메인 모델의 테스트 유의성이 떨어진다.
    • 비즈니스 로직과 외부 의존성이 분리되지 않아 테스트 유지 보수가 훨씬 어려워지므로 피하는 것이 좋음
  • 의사 결정 프로세스 단계를 더 세분화하기: 성능과 도메인 모델 테스트 유의성에 도움을 주지만, 컨트롤러가 단순하지 않다. 이러한 세부 단계를 관리하려면 컨트롤러에 의사 결정 지점이 있어야 한다.
    • 이전에 샘플 프로젝트에서 했던 바와 같이 컨트롤러를 제외한 모든 복잡도를 고려할 수는 없지만, 그 복잡도는 관리할 수 있다.

4.1 CanExecute/Excute 패턴 사용

컨트롤러 복잡도가 커지는 것을 완화하는 첫 번째 방법은 CanExecute/Excute 패턴을 사용해 비즈니스 로직이 도메인 모델에서 컨트롤러로 유출되는 것을 방지하는 것이다.

 

이메일은 사용자가 확인할 때까지만 변경할 수 있다고 하자. 사용자가 확인한 후에 이메일을 변경하려고 하면 오류 메시지가 표시돼야 한다.

public class User
{
    public int UserId { get; private set; }
    public string Email { get; private set; }
    public UserType Type { get; private set; }
    public bool IsEmailConfirmed { get; private set; }
}

 

사용자가 이메일을 확인한 위치를 정하는 데 두 가지 옵션이 있다.

 

첫 번째 옵션으로, User의 ChangeEmail 메서드에 넣을 수 있다.

public string ChangeEmail(String newEmail, Company company)
{
  if (IsEmailConfirmed) 
    return "Can't change a confirmed email";

  /* ... */
}

 

그런 다음 이 메서드의 출력에 따라 컨트롤러는 오류를 반환하거나 필요한 모든 사이드 이펙트를 낼 수 있다.

public class UserController
{
        private readonly Database _database = new Database();
        private readonly MessageBus _messageBus = new MessageBus();

        public string ChangeEmail(int userId, string newEmail)
        {
            object[] userData = _database.GetUserById(userId);
            User user = UserFactory.Create(userData);

            object[] companyData = _database.GetCompany();
            Company company = CompanyFactory.Create(companyData);

            string error = user.ChangeEmail(newEmail, Company);
            if (error != null)
                return error;

            _database.SaveCompany(company);
            _database.SaveUser(user);
            _messageBus.SendEmailChangedMessage(userId, newEmail);
        }
}

이 구현으로 컨트롤러가 의사 결정을 하지 않지만, 성능 저하를 감수해야 한다. 이메일을 확인해 변경할 수 없는 경우에도 무조건 데이터베이스에서 Company 인스턴스를 검색한다. 이는 외부 읽기와 쓰기를 비즈니스 연산 끝으로 밀어내는 예다.

 

두 번째 옵션은 IsEmailConfirmed 확인을 User에서 컨트롤러로 옮기는 것이다.

public class UserController
    {
        private readonly Database _database = new Database();
        private readonly MessageBus _messageBus = new MessageBus();

        public string ChangeEmail(int userId, string newEmail)
        {
            object[] userData = _database.GetUserById(userId);
            User user = UserFactory.Create(userData);

            string error = user.IsEmailConfirmed();
            if (error != null)
                return error;

            object[] companyData = _database.GetCompany();
            Company company = CompanyFactory.Create(companyData);

            user.ChangeEmail(newEmail, company);

            _database.SaveCompany(company);
            _database.SaveUser(user);
            _messageBus.SendEmailChangedMessage(userId, newEmail);

            return "OK";
        }
    }

이러한 구현으로 성능은 그대로 유지된다. Company 인스턴스는 이메일 변경이 가능한 후에만 데이터베이스에서 검색된다. 그러나 이제 의사 결정 프로세스는 두 부분으로 나뉜다.

  • 이메일 변경 진행 여부 (컨트롤러에서 수행)
  • 변경 시 해야 할 일(User에서 진행)

이러한 파편화로 비즈니스 로직과 오케스트레이션 간의 분리가 방해되고 지나치게 복잡한 위험 영역에 더 가까워진다.

 

이러한 파편화를 방지하려면 User에 새 매서드를 둬서, 이 메서드가 잘 실행되는 것을 이메일 변경의 전제 조건으로 한다. 다음 예제는 CanExecute/Execute 패턴을 따르게끔 수정한 버전이다.

public string CanChangeEmail()
{
    if (IsEmailConfirmed)
        return "Can't change email after it's confirmed";

    return null;
}

public void ChangeEmail(string newEmail, Company company)
{
    Precondition.Requires(CanChangeEmail() == null);

    /* ... */
}

 

이 방법에는 두 가지 중요한 이점이 있다.

  • 컨트롤러는 더 이상 이메일 변경 프로세스를 알 필요가 없다. CanChangeEmail() 메서드를 호출해서 연산을 수행할 수 있는지 확인하기만 하면 된다.
  • ChangeEmail()의전제 조건이 추가돼도 먼저 확인하지 않으면 이메일을 변경할 수 없도록 보장한다.

이 패턴을 사용하면 도메인 계층의 모든 결정을 통합할 수 있다. 이제 컨트롤러에 이메일을 읽었는지 확인하는 일이 없기 때문에 더 이상 의사 결정 지점이 없다. 따라서 컨트롤러에 CanChangeEmail()을 호출하는 if문이 있어도 if 문을 테스트할 필요는 없다. User 클래스의 전제 조건을 단위 테스트하는 것으로 충분하다.

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

도메인 모델을 현재 상태로 만든 단계를 빼기 어려울 때가 있다. 그러나 애플리케이션에서 정확히 무슨 일이 일어나는지 외부 시스템에 알려야 하기 때문에 이러한 단계들을 아는 것이 중요할지도 모른다. 컨트롤러에 이러한 책임도 있으면 더 복잡해진다. 이를 피하려면, 도메인 모델에서 중요한 변경 사항을 추적하고 비즈니스 연산이 완료된 후 해당 변경 사항을 프로세스 외부 의존성 호출로 변환한다. 도메인 이벤트로 이러한 추적을 구현할 수 있다.

도메인 이벤트는 애플리케이션 내에서 도메인 전문가에게 중요한 이벤트를 말한다. 도메인 전문가에게는 무엇으로 도메인 이벤트와 일반 이벤트를 구별하는지가 중요하다. 도메인 이벤트는 종종 시스템에서 발생하는 중요한 변경 사항을 외부 애플리케이션에 알리는 데 사용된다.


CRM에는 추적 요구 사항도 있다. 메시지 버스에 메시지를 보내서 외부 시스템에 변경된 사용자 이메일을 알려줘야 한다. 현재 구현에는 알림 기능에 결함이 있다. 다음 예제와 같이 이메일이 변경되지 않은 경우에도 메시지를 보낸다.

public class UserController
    {
        public string ChangeEmail(int userId, string newEmail)
        {
            /* 준비 */

            user.ChangeEmail(newEmail, company);

            _database.SaveCompany(company);
            _database.SaveUser(user);
            _messageBus.SendEmailChangedMessage(userId, newEmail);

            return "OK";
        }
    }

이메일이 같은지 검사하는 부분을 컨트롤러로 옮겨서 버그를 해결할 수 있지만, 비즈니스 로직이 파편화되는 문제가 있다. 새 이메일이 이전 이메일과 동일하다면 애플리케이션이 오류를 반환해서는 안 되므로 CanChangeEmail()에 검사하는 부분을 넣을 수 없다.

 

이러한 특정 검사로 인해 비즈니스 로직을 너무 많이 파편화하지 않으므로, 검사가 포함돼도 컨트롤러가 지나치게 복잡하지 않다고 생각한다. 그러나 애플리케이션이 프로세스 외부 의존성을 도메인 모델로 넘기지 않고 해당 의존성을 불필요하게 호출해서 도메인 모델을 오히려 지나치게 복잡하게 하는 것과 같이 더 어려운 상황이 될 수 있다. 너무 복잡하지 않게 하는 방법은 도메인 이벤트를 사용하는 것뿐이다.

 

구현 관점에서 도메인 이벤트는 외부 시스템에통보하는 데 필요한 데이터가 포함된 클래스다. 그체적인 예로는 사용자의 ID와 이메일을 들 수 있다.

 

구현 관점에서 도메인 이벤트는 외부 시스템에 통보하는 데 필요한 데이터가 포함된 클래스다. 구체적인 예로는 사용자 ID와 이메일을 들 수 있다. 다음은 ChangeEmail() 메서드를 리팩터링 한 후다.

public void ChangeEmail(string newEmail, Company company)
{
    Precondition.Requires(CanChangeEmail() == null);

    if (Email == newEmail)
      return;

    UserType newType = company.IsEmailCorporate(newEmail)
                ? UserType.Employee
                : UserType.Customer;

    if (Type != newType)
    {
        int delta = newType == UserType.Employee ? 1 : -1;
        company.ChangeNumberOfEmployees(delta);
    }

    Email = newEmail;
    Type = newType;
    EmailChangedEvents.Add(new EmailChangedEvent(UserId, newEmail));
}

그런 다음 컨트롤러는 이벤트를 메시지 버스의 메시지로 변환한다.

public class UserController
{
        private readonly Database _database = new Database();
        private readonly MessageBus _messageBus = new MessageBus();

        public string ChangeEmail(int userId, string newEmail)
        {
            object[] userData = _database.GetUserById(userId);
            User user = UserFactory.Create(userData);

            string error = user.CanChangeEmail();
            if (error != null)
                return error;

            object[] companyData = _database.GetCompany();
            Company company = CompanyFactory.Create(companyData);

            user.ChangeEmail(newEmail, company);

            _database.SaveCompany(company);
            _database.SaveUser(user);
            foreach (EmailChangedEvent ev in user.EmailChangedEvents)
            {
                _messageBus.SendEmailChangedMessage(ev.UserId, ev.NewEmail);
            }

            return "OK";
        }
}

도메인 이벤트는 컨트롤러에서 의사 결정 책임을 제거하고 해당 책임을 도메인 모델에 적용함으로써 외부 시스템과의 통신에 대한 단위 테스트를 간결하게 한다. 컨트롤러를 검증하고 프로세스 외부 의존성을 목으로 대체하는 대신, 다음 예제와 같이 단위 테스트에서 직접 도메인 이벤트 생성을 테스트할 수 있다.

public class Tests
{
        [Fact]
        public void Changing_email_from_corporate_to_non_corporate()
        {
            var company = new Company("mycorp.com", 1);
            var sut = new User(1, "user@mycorp.com", UserType.Employee, false);

            sut.ChangeEmail("new@gmail.com", company);

            company.NumberOfEmployees.Should().Be(0);
            sut.Email.Should().Be("new@gmail.com");
            sut.Type.Should().Be(UserType.Customer);
            sut.EmailChangedEvents.Should().Equal(
                new EmailChangedEvent(1, "new@gmail.com"));
        }
}

물론 오케스트레이션이 올바르게 수행되는지 확인하고자 컨트롤러를 테스트해야 하지만, 그렇게 하려면 훨씬 더 작은 테스트가 필요하다. 이것은 다음 장의 주제다.

5. 결론

이 장에서 다뤘던 주제는 외부 시스템에 대한 애플리케이션 사이드 이펙트를 추상화하는 것이었다. 비즈니스 연산이 끝날 때까지 이러한 사이드 이펙트를 메모리에 둬서 추상화하고, 프로세스 외부 의존성 없이 단순한 단위 테스트로 테스트할 수 있다.

  • 도메인 이벤트는 메시지 버스에서 메시지에 기반한 추상화에 해당
  • 도메인 클래스의 변경 사항은 데이터베이스의 향후 수정 사항에 대한 추상화

추상화할 것을 테스트하기보다 추상화를 테스트하는 것이 더 쉽다. 

 

도메인 이벤트와 CanExecute/Execute 패턴을 사용해 도메인 모델에 모든 의사 결정을 잘 담을 수 있었지만, 항상 그렇게 할 수는 없다. 비즈니스 로직 파편화가 불가피한 상황들이 있다.

  • 도메인 모델에 프로세스 외부 의존성을 두지 않고서는 컨트롤러 외부에서 이메일 고유성을 검증할 방법이 없다.
  • 도메인 계층에서 프로세스 외부 의존성을 호출하지 않기 때문에 도메인 계층을 호출해 얻어낸 결과가 어디로 갈 것인지에 대한 결정은 컨틀롤러가 할 수 밖에 없다.

5장에서 살펴봤듯이, 메서드가 클래스의 식별할 수 있는 동작인지 여부는 클라이언트가 누구인지와 클라이언트의 목표가 무엇인지에 달려있다. 식별할 수 있는 동작이 되려면 메서드는 다음 두 가지 기준 중 하나를 충족해야 한다.

  • 클라이언트 목표 중 하나에 직접적인 연관이 있음
  • 외부 애플리케이션에서 볼 수 있는 프로세스 외부 의존성에서 사이드 이펙트가 발생함

예시

  • 클라이언트: 외부 클라이언트

외부 클라이언트입장에서 컨트롤러의 ChangeEmailI() 메서드는 식별할 수 있는 동작이며, 메시지 버스에 대한 호출도 마찬가지이다.

  • ChangeEmailI()은 외부 클라이언트의 진입점이므로 첫 번째 기준을 충족
  • 메시지 버스에 대한 호출은 외부 애플리케이션으로 메시지를 볼 수 있으므로 두 번째 기준을 충족

그러나 컨트롤러에서 User로 가는 후속 호출은 외부 클라이언트의 목표와 직접적인 연관이 없다. 따라서 컨트롤러의 동작을 테스트할 때 컨트롤러가 User에 수행하는 호출을 검증해서는 안 된다.

'테스트' 카테고리의 다른 글

Unit Testing - 1장  (0) 2024.08.12
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/10   »
1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30 31
글 보관함