7. 가치있는 단위 테스트를 위한 리팩토링
원제 : Refactoring toward valuable unit tests
본 포스팅의 내용에 들어가기전에 중요한 정의들을 다시 한 번 리마인드해보자.
좋은 단위 테스트 스위트란?
- 개발 주기에 통합되어 있다.
- 코드베이스 중 가장 중요한 부분만을 대상으로 한다.
- 최소한의 유지비로 최대의 가치를 이루어낸다.
- 가치 있는 테스트 식별하기
- 가치 있는 테스트 작성하기
가치 있는 테스트의 4대 요소
- 회귀 방지
- 리팩토링 내성
- 빠른 피드백
- 유지보수성
정리해보자면 가치 있는 테스트가 무엇인지 알고 이를 작성할 수 있어야함을 알 수 있다.
본 포스팅에서는 예제를 통해 리팩토링할 코드를 식별하고 이를 개선하는 방법에 대해서 알아보도록 하자.
7.1. 리팩토링할 코드 식별하기
원제 : Identifying the code to refactor
기반 코드를 리팩토링하지 않고는 테스트 스위트를 유의미하게 개선할 수가 없다.
먼저 기반 코드를 네 가지 유형으로 분류하여, 리팩토링의 방향성을 잡아보도록 하자.
7.1.1. 코드의 네 가지 유형들
원제 : The four types of code
모든 제품 코드는 다음과같이 크게 2개의 차원으로 분류할 수 있다.
- 복잡도 또는 도메인 유의성(Complexity or domain significance)
- 협력자 수(The number of collaborators)
먼저 복잡도와 도메인 유의성에 대해 살펴보자.
코드 복잡도(code complexity) 는 코드 내 의사 결정(=분기) 지점 수로 정의할 수 있으며, 이 숫자가 클수록 복잡도도 높아진다.
도메인 유의성(domain significance) 은 코드가 프로젝트의 문제 도메인에 대해 얼마나 의미있는 지를 나타낸다.
일반적으로 도메인 레이어의 모든 코드는 최종 사용자의 목표와 직접적으로 연관되므로 도메인 유의성이 높다고 볼 수 있다.
반면에 유틸성 코드는 연관성이 없으므로 유의성이 낮다고 볼 수 있다.
일반적으로 복잡한 코드와 높은 도메인 유의성을 가진 코드가 회귀 방지에 강하기 때문에 단위 테스트에서 가장 이로운 코드라고 볼 수 있다.
다만 도메인 코드는 굳이 복잡할 필요가 없고 유의성이 나타나지 않더라도 테스트하는 것이 좋다.
두 번째로 클래스 또는 메서드가 가진 협력자의 수에 대해 살펴보자.
협력자는 가변 의존성이거나 프로세스 외부 의존성이거나 둘 다에 해당한다.
협력자가 많을 수록 코드는 길어질 것이며, 테스트 코드도 덩달아 길어지게 될 것이다.
이는 유지보수성 지표를 낮추는 역할을 하므로 결국 해당 코드는 더 많은 테스트 비용을 지불하게 된다.
협력자의 유형도 중요하다.
도메인 모델이라면 프로세스 외부 의존성을 제거하여 Mock에 따른 유지비를 최소화해야하며,
애플리케이션의 경계를 넘는 상호 작용에 대해서만 Mock을 사용하여 리팩토링 내성을 지켜야 한다.
따라서 프로세스 외부 의존성을 가진 모든 통신은 도메인 계층의 외부 클래스에 위임하여 도메인 클래스가 프로세스 내부에서만 동작하도록 하는 것이 좋다.
위에서 알아본 코드 복잡도와 도메인 유의성 그리고 협력자 수의 조합으로 아래와 같이 네 가지 코드 유형을 식별할 수 있다.
1. 도메인 모델과 알고리즘(Domain model and algorithms)
복잡한 코드는 통상 도메인 모델이다.
물론 문제 도메인과 직접적으로 관련이 없는 복잡한 알고리즘이 존재할 수 있다.
2. 간단한 코드(Trivial code)
협력자가 있는 경우가 거의 없는 코드이다.
따라서 복잡도나 도메인 유의성도 거의 없다.
3. 컨트롤러(Controllers)
도메인 클래스와 외부 애플리케이션과 같은 다른 구성 요소의 작업을 조정하는 역할을 한다.
4. 지나치게 복잡한 코드(Overcomplicated code)
협력자가 많으며 복잡하고 중요한 비즈니스 로직을 많이 가지고 있는 코드이다.
이를 표로 나타내면 아래와 같다.
A 유형에 해당하는 도메인 모델과 알고리즘이 단위 테스트에 가장 좋은 유형이다.
해당 유형에 대한 단위 테스트는 비용도 적게 들고 가치있는 테스트가 될 확률이 높다.
코드가 다소 복잡하거나 중요한 로직을 담당하기때문에 테스트를 통한 회귀 방지 향상에도 유리하며, 협력자의 수가 적기때문에 테스트 비용도 절약된다.
C 유형에 해당하는 간단한 코드는 테스트할 가치가 없다고 볼 수 있다.
D 유형에 해당하는 컨트롤러는 통합 테스트의 일부로서 테스트를 수행하는 한다.
문제가 되는 것은 마지막으로 남은 B 유형이다.
B 유형의 코드는 단위 테스트도 어렵지만, 그렇다고 테스트 커버리지의 확보 없이 방치하기엔 위험하기 때문이다.
본 포스팅에서는 B 유형에 대한 딜레마를 우회하는 방법에 대해 알아보는 것이 주요 내용이다.
참고 코드가 중요해지거나 복잡해질수록 협력자는 더 적도록 관리해야한다.
우리의 목표는 각각의 테스트가 프로젝트의 가치를 높이는 테스트 스위트를 구축하는 것이므로 B 유형의 코드를 A 유형이나 D 유형으로 리팩토링하는 것이 방법이다.
아래 그림을 참고하자.
이제부터 B유형의 코드를 A 혹은 D 유형으로 분류하기 위한 기법에 대해서 알아보자.
7.1.2. 험블 객체 패턴을 이용한 복잡한 코드 분할
원제 : Using the Humble Object pattern to split overcomplicated code
지나치게 복잡한 코드를 분리하려면 험블 객체 패턴(Hunble Object Pattern) 을 사용해야한다.
코드가 프레임워크의 의존성과 강하게 결합되어있는 경우 테스트가 어려울 수도 있다.
예를 들어 비동기나 멀티스레드의 실행, 사용자 인터페이스 및 프로세스 외부 의존성과의 통신이 존재하는 경우이다.
위처럼 어려운 의존성과 결합된 코드를 테스트하려면 비용이 만만치 않을 것이다.
이 경우 테스트가 가능한 부분만을 추출하여 격리하여, 결과적으로 테스트 가능한 부분을 둘러싼 험블 래퍼(Humble Wrapper) 를 구축해야 한다.
험블 래퍼에서 테스트하기 어려운 의존성과 새로 추출된 구성 요소를 붙이지만 자체적인 로직은 거의 없을 것이므로 테스트할 필요가 없게 된다.
위처럼 험블 객체 패턴을 사용하면 지나치게 복잡한 코드에서 로직을 추출해 코드를 테스트할 필요가 없도록 격리할 수 있게 된다.
이러한 방식을 우리는 함수형 아키텍쳐에서 이미 경험해보았다.
함수형 아키텍쳐는 여기서 더 나아가 프로세스 외부 의존성뿐만 아니라 모든 협력자와의 커뮤니케이션에서 비즈니스로직을 분리해버린다.
함수형 아키텍쳐의 구성 요소를 표에 대입해보면 아래와 같다.
험블 객체 패턴을 보는 또 다른 방법은 단일 책임 원칙(Single Responsibility principle) 을 지키는 것이다.
이는 각 클래스가 하나의 책임만 가져야 한다는 원칙이다.
이 책임 중 하나로 늘 비즈니스 로직이 있는데, 험블 객체 패턴을 적용하면 비즈니스 로직을 거의 모든 것과 분리할 수 있다.
또 다른 예시로 도메인 주도 설계(Domain-Driven Design) 의 집계 패턴(Aggregate Pattern) 이 있다.
이는 클래스를 크럴스터로 묶어서 클래스 간 연결을 줄이는 것으로, 클래스는 클래스터 내부에 강결합되어있되 클러스터 자체는 느슨하게 결합시키는 것이다.
이처럼 비즈니스 로직을 분리하는 것은 단순히 테스트를 편하게 하는 것만을 목적으로 하진 않는다.
비즈니스 로직의 분리는 코드 복잡도를 낮추고 프로젝트의 장기적인 성장에도 영향을 주기 때문이다.
7.2. 가치있는 단위 테스트를 위한 리팩토링
원제 : Refactoring toward valuable unit tests
이제부터 예제를 통해 B유형을 A와 D유형으로 리팩토링해보도록 하자.
예제는 고객 관리 시스템이며, 험블 객체 패턴을 사용해 리팩토링을 수행한다.
7.2.1. 고객 관리 시스템 소개
원제 : Introducing a customer management system
고객 관리 시스템은 사용자의 등록을 처리하며, 모든 사용자가 데이터베이스에 저장된다.
현재 시스템은 사용자 이메일 변경이라는 단 하나의 유스케이스만 지원하며, 이 유스케이스에는 세 가지 비즈니스 규칙이 있다.
- 사용자 이메일이 회사 도메인에 속한 경우 해당 사용자는 직원으로 표시된다. 그렇지 않으면 고객으로 간주한다.
- 시스템은 회사의 직원 수를 추적한다. 사용자의 유형이 직원에서 고객으로 혹은 고객에서 지원으로 변경되면 직원 수도 변경해야 한다.
- 이 메일이 변경되면 시스템은 메시지 버스를 통해 외부 시스템에 알린다.
아래 코드는 고객 관리 시스템의 초기 구현이다.
1 | public class User { |
User
클래스는 사용자의 이메일을 변경한다.
이 구현을 코드 유형 도표 관점에 분석해보자.
ChangeEmail()
메서드는 사용자를 직원 혹은 고객으로 식별할지, 회사의 직원 수를 어떻게 업데이트할지의 두 가지 명시적인 의사 결정만을 포함하고 있으며 코드 복잡도는 그리 높지 않다고 볼 수 있다.
이 두 가지의 의사 결정이 애플리케이션의 핵심 비즈니스 로직이므로, 복잡도와 유의성이 높다고 볼 수 있다.
그 다음으로는 의존성이다.
User
클래스는 두 개의 명시적인 의존성과 두 개의 암시적인 의존성까지 총 네 개의 의존성을 가지고 있다.
명시적 의존성은 userId
와 newEmail
인수이지만, 값이므로 협력자라고 볼 수는 없다.
암시적 의존성은 Database
와 MessageBus
로 프로세스 외부 협력자이다.
따라서 User
클래스의 위치는 아래와 같다.
User
클래스처럼 도메인 로직을 가진 클래스가 스스로 데이터를 검색하고 저장하는 방식을 활성 레코드 패턴(Active Record Pattern) 이라고 한다.
단순한 프로젝트라면 잘 동작하지만, 프로젝트의 규모가 커지면 확장성이 떨어지는 문제가 있다.
7.2.2. 1단계 : 암시적 의존성을 명시적 의존성으로 변환
원제 : Take 1: Making implicit dependencies explicit
테스트를 쉽게하는 일반적인 방법은 암시적 의존성을 명시적 의존성으로 변환하는 것이다.
즉 데이터베이스와 메시지 버스에 대한 인터페이스를 두고, 이 인터페이스를 User
클래스에 주입하는 방식으로 리팩토링한 뒤
테스트에서는 해당 인터페이스를 Mock으로 처리하는 방식이다.
결론적으로 도메인 모델을 프로세스 외부 협력자의 의존성을 끊어내는 것이 좋다.
7.2.3. 2단계 : 애플리케이션 서비스 계층 도입
원제 : Take 2: Introducing an application services layer
도메인 모델이 외부 시스템과 직접 통신하는 문제를 극복하려면 험블 컨트롤러(Humble Controller) 를 구현해 책임을 이전해야 한다.
일반적으로 도메인 클래스는 다른 도메인 클래스나 단순한 값과 같은 내부 의존성에만 의존하도록 해야 한다.
아래는 UserController
클래스 코드이다.
1 | public class UserController { |
User
클래스로부터 프로세스 외부 의존성을 분리한 코드임을 알 수 있다.
좋은 시도이지만 여기에도 몇 가지 문제가 있다.
- 프로세스 외부 의존성인
Database
와MessageBus
가 주입되지않고 직접 인스턴스화되므로 통합 테스트에서 문제를 야기할 수 있다. UserController
는 데이터베이스로부터 받은 원시 데이터를 이용해User
객체를 재구성한다. 이는 복잡한 로직에 해당되므로 애플리케이션 서비스에 속해선 안된다.- Company의 책임도 이전해야 한다.
UserController
는 새로운 이메일이 전의 이메일과 다른지 여부과 관계없이 무조건 데이터를 수정하고 메시지 버스에 알림을 보낸다.
이제 분리하고 남은 User
클래스를 다시 살펴보자.
1 | public class User { |
User
클래스는 더 이상 프로세스 외부 의존성도 협력자도 없으므로 테스트하기 쉬운 코드가 되었다.
다시 도표에서 User
와 UserController
의 위치를 표현해보면 아래와 같다.
7.2.4. 3단계 : 애플리케이션 서비스의 복잡도 제거
원제 : Take 3: Removing complexity from the application service
UserController
가 D 유형에 확실시 속하려면 재구성 로직을 추출해서 분리해야한다.
따라서 User
객체를 재구성하는 별도의 팩토리 메서드를 아래와 같이 추가할 수 있다.
1 | public class UserFactory { |
이제 User
를 생성하는 것은 완전히 격리되어있으므로 테스트가 용이해졌다.
이 메서드는 data
배열에 최소 3개의 요소가 있어야한다는 안전장치로 Precondition
을 사용하고 있다.
결론적으로 UserFactory
는 도메인 유의성이 없어졌으므로, 유틸리티라고 볼 수 있게되었다.
7.2.5. 4단계 : 새로운 Company
클래스 소개
원제 : Take 4: Introducing a new Company class
컨트롤러 코드를 다시 가져와서 살펴보자.
1 | public class UserController { |
User
에서 업데이트된 직원 수를 반환하는 부분이 조금 어색해보인다.
이는 잘못된 책임을 가지고 있다는 신호이자 추상화가 없다는 신호이다.
이를 해결하기 위해 회사 관련 로직과 데이터를 관리하는 Company
클래스를 작성해보자.
1 | public class Company { |
Company
클래스는 두 개의 메서드를 가지고 있으며, 원시 데이터를 묻지않고 바로 동작을 수행하도록 작성되었다.
이 Company
클래스 또한 객체의 재구성을 위해 CompanyFactory
를 만들어주는 것이 좋다.
이제 개선된 UserController
클래스는 아래와 같다.
1 | public class UserController { |
이제 User
클래스의 변경점도 다시 살펴보자.
1 | public class User { |
잘못 가지고 있던 책임을 이관하고 나니 User
클래스가 좀 더 깔끔해졌음을 느낄 수 있다.
다시 도표내 위치를 도식해보자.
이제 모든 복잡도가 팩토리로 이동했기 때문에 UserController
는 정말 D 유형에 알맞는 형태가 되었다.
UserController
를 통해 모든 협력자도 한 곳으로 모을 수 있게 되었다.
7.3. 최적의 단위 테스트 커버리지 분석
원제 : Analysis of optimal unit test coverage
7.2 섹션에서 리팩토링을 완료하였다.
이제 프로젝트에 어느 부분이 코드 범주에 속하는지 그리고 해당 범주를 어떻게 테스트해야하는지 분석해보자.
먼저 아래 표를 살펴보자.
코드복잡도와 도메인 유의성 | 협력자가 거의 없음 | 협력자가 많음 |
---|---|---|
높음 | User.ChangeEmail() Company.ChangeNumberOfEmployees() Company.IsEmailCorporate() CompnayFactory.create() |
|
낮음 | User 와 Compnay 의 생성자 |
UserController.ChangeEmail() |
비즈니스 로직을 추출해서 분리해내고 나면, 코드 베이스의 어떤 부분을 테스트 단위로 할지 결정하기 용이하다.
7.3.1. 도메인 계층과 유틸리티 코드 테스트
원제 : Testing the domain layer and utility code
위의 표에서 적시된 메서드들을 가져와보자.
User.ChangeEmail()
Company.ChangeNumberOfEmployees()
Company.IsEmailCorporate()
CompnayFactory.create()
좌상단에 위치한 위 메서드들은 코드 복잡도와 도메인 유의성이 높기에, 테스트 비용 측면에서 최상의 결과를 가져올 수 있다.
이는 위 메서드들이 회귀 방지가 뛰어나고 협력자도 거의 없기에 유지비용도 적게 들기때문이다.
이중 User
를 어떻게 테스트하는 지 알아보자.
1 | [ ] |
전체 커버리지를 달성하기 위해선 아래의 테스트 메서드들도 필요할 것이다.
1 | public void Changing_email_from_corporate_to_non_corporate() |
다른 세 가지 클래스에 대한 테스트는 훨씬 짧을 것이고, 매개변수화된 테스트로 아래와 같이 여러 테스트를 묶어서 처리할 수도 있다.
1 | [ ] |
7.3.2. 협력자가 많거나, 복잡도와 도메인 유의성이 낮은 경우의 테스트
원제 : Testing the code from the other three quadrants
복잡도가 낮고 협력자가 거의 없는 코드는 User
와 Company
의 생성자를 예로 들 수 있다.
1 | public User(int userId, string email, UserType type) { |
이런 생성자는 매우 단순하기 때문에 회귀 방지도 낮기마련으로 굳이 테스트를 위한 노력을 들일 필요는 없다.
복잡도가 높고 협력자가 많은 경우는 리팩토링으로 제거되었으므로 테스트 대상인 코드가 없다.
7.3.3. 전제 조건을 테스트하려면?
원제 : Should you test preconditions?
특별한 종류의 분기점(=전제 조건)을 살펴보고 테스트 여부를 결정해보도록 하자.
예시로 Company.ChangeNumberOfEmployees()
메서드를 가져와보았다.
1 | public void ChangeNumberOfEmployees(int delta) { |
회사의 직원 수가 음수일 수는 없다는 전제 조건이 존재하고 있다.
이 전제 조건은 예외 상황에서만 활성화되는 일종의 보호장치라고 볼 수 있다.
현실 세계에서 직원 수는 절대 음수가 될 수 없기에 코드에 오류가 있는 경우를 제외하면 이 전제조건에 걸리지않을 것이다.
다만 일반적으로 도메인 유의성이 있는 모든 전제 조건을 테스트하는 것이 권장된다.
즉 전제 조건을 테스트함으로써, Company
클래스의 불변성을 확인할 수 있다고 볼 볼 수 있다.
이번엔 User.Create()
메서드를 보자.
1 | public static User Create(object[] data) { |
이 전제 조건은 도메인 유의성이 없으므로 테스트할 이유가 없다고 볼 수 있다.
7.4. 컨트롤러에서의 조건부 로직 처리
원제 : Handling conditional logic in controllers
조건부 로직을 처리하면서 동시에 프로세스 외부 협력자 없이 도메인 레이어를 유지보수하는 것은 생각보다 까다롭고 절충해야하는 문제이다.
이번엔 이 절충이 무엇인지 살펴보도록 하자.
비즈니스 로직의 분리는 아래와 같이 비즈니스의 동작이 3단계로 이루어졌을 때 가장 효과적이다.
- 저장소에서 데이터 검색(Retrieving data from storage)
- 비즈니스 로직 실행(Executing business logic)
- 데이터를 다시 저장소에 저장(Persisting data back to the storage)
그림으로 보면 아래와 같다.
다만 이렇게 3단계아 아닌 중간 결과에 따라 또 조회가 발생하는 경우도 있다.
이것도 그림으로 나타내면 아래와 같다.
이런 상황에서는 아래와 같은 세 가지 방법에 따라 처리하면 된다.
- 외부에 대한 모든 읽기와 쓰기를 외곽으로 밀어낸다.
- read-decide-act 구조로 만드는 것인데, 이는 성능의 저하를 유발한다.
- 또한 필요없는 경우에도 컨트롤러가 프로세스 외부 의존성을 호출할 수 있다.
- 도메인 모델에 프로세스 외부 의존성을 주입한다.
- 이후 비즈니스 로직이 해당 의존성을 호출할 시점을 직접 결정할 수 있게 한다.
- 의사 결정 단계를 세분화하고, 각 단계별로 컨트롤러를 실행하도록 한다.
문제는 다음 세 가지 특성의 균형을 맞추는 것이다.
1. 도메인 모델 테스트 유의성(Domain model testability)
도메인 클래스의 협력자 수와 유형에 따른 함수
2. 컨트롤러의 단순성(Controller simplicity)
의사 결정 지점이 있느냐 없느냐에 따라 달라질 수 있다.
3. 성능(Performance)
프로세스 외부 의존성에 대한 호출 횟수로 정의할 수 있다.
위에서 언급한 세 방법들은 세 가지 특성 중 두 가지 특성만 가질 수 있다.
- 외부에 대한 모든 읽기와 쓰기를 외곽으로 밀어낸다.
- 컨트롤러를 단순하게 하고, 프로세스 외부 의존성과 도메인 모델을 분리하여 테스트를 용이하게 하지만 성능이 저하된다.
- 도메인 모델에 프로세스 외부 의존성을 주입한다.
- 컨트롤러를 단순하게 하고, 성능을 유지해주지만 도메인 모델의 테스트 유의성이 떨어진다.
- 의사 결정 단계를 세분화하고, 각 단계별로 컨트롤러를 실행하도록 한다.
- 성능을 유지하고, 도메인 모델 테스트 유의성에 도움을 주지만 컨트롤러가 복잡해진다.
대부분의 소프트웨어는 성능을 최우선으로 하므로 두 번째 혹은 세 번째 방법을 주로 선택한다.
다만 두 번째 방법은 지나치게 복잡한 코드를 생산할 수 있어 세 번째 방법이 권장된다.