박종훈 기술블로그

7장 가치 있는 단위 테스트를 위한 리팩터링

단위테스트 (블라디미르 코리코프)


7장 가치 있는 단위 테스트를 위한 리팩터링 - 리팩터링할 코드 식별하기

에서 이어지는 글입니다.


복잡한 코드를 알고리즘과 컨트롤러로 나눠보자. 험블 객체 패턴을 사용해 일반화 하는 방법에 대해서 알아보자.

2.1 고객 관리 시스템 소개

사용자 등록을 처리하는 고객 관리 시스템을 샘플로 한다.
모든 사용자는 데이터베이스에 저장된다.

현재 시스템은 사용자 이메일 변경이라는 단 하나의 유스케이스만 지원한다.

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

초기 구현은 다음과 같다.



User 클래스는 사용자 이메일을 변경한다. 간결성을 위해 이메일 정확성이나 데이터베이스에서 사용자의 존재 여부를 확인하는 것과 같이 간단한 유효성 검사는 생략했다. 이 구현을 코드 유형 도표 관점에서 분석해보자.

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

반면에 User 클래스에는 네 개의 의존성이 있으며, 그중 두 개는 명시적으로 나머지 두 개는 암시적이다. 명시적 의존성은 userId와 newEmail 인수다. 그러나 이 둘은 값이므로 클래스의 협력자 수에는 포함되지 않는다. 암시적인 것은 Database와 MessageBus이다. 이 둘은 프로세스 외부 협력자다. 앞에서 언급했듯이 도메인 유의성이 높은 코드에서 프로세스 외부 협력자는 사용하면 안된다. 따라서 User 클래스는 협력자 측면에서도 점수가 높으므로 이 클래스는 지나치게 복잡한 코드로 분류된다.

도메인 클래스가 스스로 데이터베이스를 검색하고 다시 저장하는 이러한 방식을 활성 레코드(Active Record) 패턴이라고 한다. 단순한 프로젝트나 단기 프로젝트에서는 잘 작동하지만 코드베이스가 커지면 확장하지 못하는 경우가 많다. 그 이유는 정확히 두 가지 책임, 즉 비즈니스 로직과 프로세스 외부 의존성과의 통신 사이에 분리가 없기 때문이다.

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

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

의존성이 프로세스 외부에 있는 클래스를 테스트하려면 복잡한 목 체계가 필요하고, 테스트 유지비가 증가하게 된다. 그리고 목을 데이터베이스 의존성에 사용하면 테스트 취약성을 야기할 수 있다.

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

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

도메인 모델이 외부 시스템과 직접 통신하는 문제를 극복하려면 다른 클래스인 험블 컨트롤러(humble controller, 육각형 아키텍처 분류상 애플리케이션 서비스)로 책임을 옮겨야 한다.

일반적으로 도메인 클래스는 다른 도메인 클래스나 단순 값과 같은 프로세스 내부 의존성에만 의존하도록 해야 한다.

이에 따른 애플리케이션 서비스 첫번째 버전은 다음과 같다.



애플리케이션 서비스를 이용하여 외부 의존성과의 작업을 줄일 수 있게 되었다.

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

User 클래스는 더 이상 프로세스 외부 의존성과 통신할 필요가 없으므로 테스트하기가 매우 쉬워졌다.

User 클래스의 ChangeEmail 메서드의 새로운 버전은 다음과 같다.



User는 더 이상 협력자를 처리할 필요가 없기 때문에 도메인 모델 사분면으로 수직축에 가깝게 이동했다.

하지만 UserController가 문제다. 컨트롤러 사분면에 들어갔지만, 아직 로직이 꽤 복잡하므로 지나치게 복잡한 코드의 경계에 걸쳐 있다.

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

UserController가 컨트롤러 사분면에 확실히 있으려면 재구성 로직을 추출해야 한다.
ORM(Object-Relational Mapping, 관계 객체 매핑) 라이브러리를 사용해 데이터베이스를 도메인 모델에 매핑하면, 재구성 로직을 옮기기에 적절한 위치가 될 수 있다.
ORM을 사용하지 않거나 사용할 수 없으면, 도메인 모델에 원시 데이터베이스 데이터로 도메인 클래스를 인스턴스화하는 팩토리 클래스를 작성하라.



이렇게 되면 이제 모든 협력자와 완전히 격리돼 있으므로 테스트가 쉬워진다.

Precondition은 간단한 사용자 정의 클래스이다. 이 재구성 로직은 도메인 유의성이 없다. 다시 말하면 사용자 이메일을 변경하려는 클라이언트의 목표와 직접적인 관련이 없다. 이런 코드가 유틸리티 코드의 예이다.

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

아까 이야기 했던 문제점 중 업데이트된 직원 수를 반환하는 부분을 개선해보자.

이 부분이 어색한 이유는 책임을 잘못 두었기 때문이다.

개선을 위해 새로 만든 Company 클래스는 다음과 같다.



이 클래스에는 ChangeNumberOfEmployees()와 IsEmailCorporate() 라는 두 가지 메서드가 있다.

이를 통해 묻지 말고 말하라"tell, don’t ask”라는 원칙을 준수하는데 도움이 된다.

UserFactory와 유사하게 Company 객체의 재구성을 담당하는 CompanyFactory 클래스도 만들어주자.

이제 컨트롤러와 유저 클래스는 다음과 같이 개선할 수 있다.





이렇게 되면 훨씬 깔끔해진다.
회사 데이터를 처리하는 것을 Company 인스턴스에 위임하게 되었다.

복잡도 사분면은 다음과 같이 바뀌게 된다.

User는 Factory 라는 협력자가 생겼기 때문에 살짝 오른쪽으로 이동하였다.
UserContoller는 기존에 있던 복잡도가 팩토리로 이동했기 때문에 아래쪽으로 이동하였다.

이렇게 변경함으로
모든 사이드 이펙트는 변경된 사용자 이메일과 직원 수의 형태로 도메인 모델 내부에 남아있게 된다.
따라서 컨트롤러가 User 객체와 Company 객체를 데이터베이스에 저장할 때만 사이드 이펙트가 도메인 모델의 경계를 넘게 된다.

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

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

험블 객체 패턴을 사용해 리팩터링을 마쳤다. 프로젝트의 어느 부분이 어떤 코드 범주에 속하는지와 해당 부분을 어떻게 테스트 해야하는지 분석해보자.

협력자가 거의 없음협력자가 많음
복잡도와 도메인 유의성이 높음User의 ChangeEmail(new Email, company)Company의 ChangeNumberOfEmployees(delta)
복잡도와 도메인 유의성이 낮음User와 Company의 생성자UserController의 Change Email(userId, newMail)

위 표는 샘플 프로젝트의 모든 코드를 코드 유형 도표의 위치별로 보여준 것이다.

(샘플 프로젝트는 이전 포스트를 참고한다.)

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

좌측 상단 테스트 메서드는 코드의 복잡도나 도메인 유의성이 높아 회귀 방지가 뛰어나고 협력자가 거의 없어 유지비도 가장 낮다.

User 클래스 테스트의 예는 다음과 같다.
(일반 메일에서 회사 메일로 변경)



전체 테스트 커버리지를 달성하려면, 다음과 같은 테스트 3 개가 더 필요하다.

세가지 테스트를 따로 작성하는 것이 아니라 입력을 매개변수화 하여 다음과 같이 테스트를 묶을 수도 있다.



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

복잡도가 낮고 협력자가 거의 없는 코드(좌측 하단)의 경우 의 예로는 생성자가 있다

일반적으로 생성자는 단순해서 노력을 들일 필요가 없고, 테스트는 회귀 방지가 떨어진다.

본 예시에서 복잡도가 높고 협력자가 많은 코드(좌측 상단)는 리팩터링을 통해 제거하였다. 따라서 테스트 할 것이 없다.

복잡도가 낮고 협력자가 많은 코드(우측 하단)에 대해서는 어떻게 테스트 해야하는지 다음 장에서 살펴본다.

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

Company에서 사용되었던 아래 코드를 보자

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

이 코드는 회사의 직원 수가 음수가 돼서는 안 된다는 전체 조건을 보호기기 위한 조건이 들어가있다. 직원수가 0 미만으로 내려가는 경우는 코드에 오류가 있는 경우뿐이다. 일종의 보호장치이다.

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

그러나 도메인 유의성이 없는 전제 조건을 테스트하는 데 시간을 들이지 말라.

예를들어 UserFactory의 Create 메서드에는 다음과 같은 보호 장치가 있다.

Precondition.Requires(data.Length >= 3);

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

fin.