Search

8장 통합 테스트를 하는 이유

작성자
진행일
2022/12/04
8장에서 다루는 내용 - 통합 테스트의 역할 이해 - 테스트 피라미드의 개념 자세히 살펴보기 - 가치 있는 통합 테스트 작성

8.1 통합 테스트는 무엇인가?

8.1.1 통합 테스트의 역할

2장에서 살펴봤듯이, 단위 테스트는 다음 세 가지 요구사항을 충족하는 테스트다.
단일 동작 단위를 검증하고
빠르게 수행하고
다른 테스트와 별도로 처리한다.
위 세 가지 요구 사항 중 하나라도 충족하지 못하는 테스트는 통합 테스트 범주에 속한다. → 단위 테스트가 아닌 모든 테스트가 통합 테스트에 해당한다.
그림 8.1 통합 테스트는 컨트롤러를 다루는 반면, 단위 테스트는 도메인 모델과 알고리즘을 다룬다.
통합 테스트는 대 부분 시스템이 프로세스 외부 의존성과 통합해 어떻게 작동하는지 검증한다.
7장에서 살펴봤듯이, 그림 8.1에서 간단한 코드와 지나치게 복잡한 코드는 테스트를 해서는 안된다.

8.1.2 다시 보는 테스트 피라미드

단위 테스트와 통합 테스트 간의 균형을 유지하는 것이 중요하다.
통합 테스트가 프로세스 외부 의존성에 직접 작동하면 느려지며, 유지비가 많이 든다.
유지비 증가 이유
프로세스 외부 의존성 운영이 필요함
관련된 협력자가 많아서 테스트가 비대해짐
통합 테스트는 코드를 많이 거치므로 회귀 방지가 단위 테스트보다 우수하다. 또한 제품 코드와의 결합도가 낮아 리팩터링 내성도 우수하다.
그림 8.2 테스트 피라미드는 대부분의 애플리케이션에 가장 적합한 절충안을 나태난다. (2장에서 설명했듯이 엔드 투 엔드 테스트는 통합 테스트의 하위 집합이다.)
단위 테스트와 통합 테스트의 비율은 프로젝트 특성에 따라 다를 수 있지만, 단위 테스트로 가능한 한 많이 비즈니스 시나리오의 예외 상황을 확인하고, 통합 테스트는 주요 흐름(happy path)과 단위 테스트가 다루지 못하는 기타 예외 상황(edge case)을 다룬다.
주요 흐름은 시나리오의 성공적인 실행이다. 예외 상황은 비즈니스 시나리오 수행 중 오류가 발생하는 경우다.
그림 8.3 간단한 프로젝트의 테스트 피라미드
테스트 피라미드는 프로젝트의 복잡도에 따라 피라미드 대신 직사각형 모양이 될 수 있다. 또한 아주 단순한 경우라면 어떠한 단위 테스트도 없을 것이다.

8.1.3 통합 테스트와 빠른 실패

통합 테스트를 사용해 비즈니스 시나리오당 하나의 주요 흐름과 단위 테스트로 처리할 수 없는 모든 예외 상황을 다루는 지침을 자세히 살펴본다.
통합 테스트에서 프로세스 외부 의존성과의 상호 작용을 모두 확인하려면 가장 긴 주요 흐름을 선택하라.
단위 테스트에 다룰 수 없는 예외 상황이 있듯이 이 부분도 예외가 있다.
7장에서 알아본 예제 코드에서 User가 canChangeEmail 메서드를 어떻게 구현했고 성공적인 실행을 changeEmail()의 전제 조건으로 삼았는지 알아봤다.
// User public void changeEmail(String newEmail, Company company) { Precondition.requires(canChangeEmail() == null); /* 메서드 나머지 부분 */ } // UserController public String changeEmail(int userId, String newEmail) { Object[] userDate = _dataase.getUserById(userId); User user = UserFactory.create(userDate); String error = user.canChangeEmail(); if(error != null) { return error; // 예외 상황 } /* 메서드 나머지 부분 */ }
Java
복사
컨트롤러는 canChangeEmail()을 호출하고 해당 메서드가 오류를 반환하면 연산을 중단한다.
위 예제는 이론적으로 통합 테스트로 다룰 수 있는 예외 상황을 보여주나 이 테스트는 충분한 가치가 없다.
컨트롤러가 먼저 canChangeEmail()을 참조 하지 않고 이메일을 변경하려고 하면 애플리케이션이 충돌한다.
컨트롤러에서 canChangeEmail()을 호출하는 것과 달리 User에 사전 조건이 있는지를 테스트해야 한다. → 통합 테스트 보다는 단위 테스트로 하는 것이 더 낫다.
좋지 않은 테스트를 작성하는 것보다는 테스트를 작성하지 않는 것이 좋다. 가치가 별로 없는 테스트는 좋지 않은 테스트다.

8.2 어떤 프로세스 외부 의존성을 직접 테스트해야 하는가?

통합 테스트는 시스템이 프로세스 외부 의존성과 어떻게 통합되는지를 검증한다.
실제 프로세스 외부 의존성을 사용
해당 의존성을 목으로 대체
이번 절에서는 각각을 언제 적용해야 하는지 알아본다.

8.2.1 프로세스 외부 의존성의 두 가지 유형

관리 의존성(전체를 제어할 수 있는 프로세스 외부 의존성)
이러한 의존성은 애플리케이션을 통해서만 접근할 수 있으며, 해당 의존성과의 상호 작용은 외부 환경에서 볼수 없다.
예를 들어 데이터베이스가 있다.
비관리 의존성(전체를 제어할 수 없는 프로세스 외부 의존성)
해당 의존성과의 상호 작용을 외부에서 볼 수 있다.
예를 들어 SMTP 서버와 메시지 버스 등이 있다.
5장에서 관리 의존성과의 통신은 구현 세부 사항이라고 했다. 반대로 비관리 의존성과의 통신은 시스템의 식별할 수 있는 동작이다.
관리 의존성은 실제 인스턴스를 사용하고, 비관리 의존성은 목으로 대체하라
그림 8.4 관리 의존성 통신은 구현 세부 사항이다. 통합 테스트에서 해당 의존성을 그대로 사용하라. 비관리 의존성 통신은 시스템의 식별할 수 있는 동작이다. 해당 의존성은 목으로 대체해야 한다.
비관리 의존성에 대한 통신 패턴을 유지해야 하는 것은 하위 호환성을 지켜야 하기 때문이다.
목을 사용하면 모든 가능한 리팩터링을 고려해서 통신 패턴 영속성을 보장할 수 있다.
통합 테스트에서 관리 의존성의 실제 인스턴스를 사용하면 외부 클라이언트 관점에서 최종 상태를 확인할 수 있다. 또한 컬럼 이름을 변경하거나 데이터베이스를 이관하는 등 데이터베이스 리팩터링에도 도움이 된다.

8.2.2 관리 의존성이면서 비관리 의존성인 프로세스 외부 의존성 다루기

때로는 두 가지 모두의 속성을 나타내는 프로세스 외부 의존성이 있을 수 있다.
좋은 예로, 다른 애플리케이션이 접근할 수 있는 데이터베이스가 있다.
시스템은 전용 데이터 베이스로 시작한다. → 다른 시스템이 같은 데이터베이스의 데이터를 요구하기 시작한다. → 따라서 그 팀은 단지 다른 시스템과 쉽게 통합할 수 있도록 일부 테이블만 접근 권한을 공유하기로 결정한다. → 데이터베이스는 관리 의존성이면서 비관리 의존성이다.
여전히 애플리케이션에서만 볼 수 있는 부분이 있다. 그러나 이러한 부분 외에도 다른 애플리케이션에서 접근할 수 있는 테이블이 많이 있다.
시스템 간의 통합을 구현하는 데 데이터베이스를 사용하면 시스템이 서로 결합되고 추가 개발을 복잡하게 만들기 때문에 좋지 않다. 그러므로 다른 방법이 없을 경우에만 이 방법을 사용하고 API(동기식 통신)나 메시지 버스(비동기식 통신)를 사용하는 것이 더 낫다.
그림 8.5 외부 애플리케이션에서 볼 수 있는 데이터베이스 부분을 비관리 의존성으로 처리하라. 통합 테스트에서 목으로 대체한다. 나머지 데이터베이스는 관리 의존성으로 취급하라. 상호 작용이 아닌 최종 상태를 검증하라.
공유 테이블은 외부에서 볼 수 있고 애플리케이션과 테이블 간의 통신 방식을 주의해야 하기 때문에 꼭 필요한 경우가 아니라면 시스템이 해당 테이블과 상호 작용하는 방식을 변경하지 말라!

8.2.3 통합 테스트에서 실제 데이터베이스를 사용할 수 없으면 어떻게 할까?

때로는 통합 테스트에서 관리 의존성을 실제 버전으로 사용할 수 없는 경우도 있다. 테스트 자동화 환경에 배포할 수없는 레거시 데이터베이스를 예로 들 수 있다.
IT 보안 정책 때문이거나 테스트 데이터베이스 인스턴스를 설정하고 유지하는 비용이 크기 때문
이러한 경우에 관리 의존성임에도 불구하고 데이터베이스를 목으로 처리해야 할까?
그렇지 않다. 관리 의존성을 목으로 대체하면 통합 테스트의 리팩터링 내성이 저하되기 때문이다.
회귀 방지도 떨어진다. 그리고 데이터베이스가 프로젝트에서 유일한 프로세스 외부 의존성이면, 통합 테스트는 회귀 방지에 있어 기존 단위 테스트 세트와 다를 바 없다.
데이터베이스를 그대로 테스트할 수 없으면 통합 테스트를 아예 작성하지 말고 도메인 모델의 단위 테스트에만 집중하라. → 가치가 충분하지 않은 테스트는 테스트 스위트에 있어서는 안 된다.

8.3 통합 테스트: 예제

7장의 샘플 CRM 시스템에서 통합 테스트를 어떻게 하는지 살펴본다. 이 시스템은 사용자 이메일 변경 기능만 구현돼 있다.
데이터베이스에서 사용자와 회사를 검색하고 의사 결정을 도메인 모델에 위임한 다음, 결과를 데이터베이스에 다시 저장하고 필요한 경우 메시지 버스에 메시지를 싣는다.
그림 8.6 사용자 이메일을 변경하는 유스케이스. 컨트롤러는 데이터베이스, 메시지 버스, 도메인 모델 간의 작업을 조정한다.
// 컨트롤러의 현재 모습 public class UserController { private final Database _database; private final IMessageBus _messageBus; public UserController(Database database, IMessageBus messageBus) { _database = database; _messageBus = 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: user.emailChangedEvents) { _messageBus.sendEmailChangedMessage(ev.userId, ev.newEmail); } return "OK"; } }
Java
복사

8.3.1 어떤 시나리오를 테스트 할까?

통합 테스트에 대한 일반적인 지침은 가장 긴 주요 흐름과 단위 테스트로는 수행할 수 없는 모든 예외 상황을 다루는 것이다.
CRM 프로젝트에서 가장 긴 주요 흐름은 기업 이메일에서 일반 이메일로 변경하는 것이다.
데이터베이스에서 사용자와 회사 모두 업데이트된다. 즉 사용자는 유형을 변경하고 이메일도 변경하며, 회사는 직원 수를 변경한다.
메시지 버스로 메시지를 보낸다.
단위 테스트로 테스트하지 않는 한 가지 예외 상황이 있는데, 바로 이메일으 변경할 수 없는 시나리오다. → 이 시나리오를 테스트할 필요는 없다. 컨트롤러에 이러한 확인이 없으면 애플리케이션이 빨리 실패하기 때문이다.
통합 테스트는 하나만 남는다.
public void changingEmailFromCorporateToNonCorporate()

8.3.2 데이터베이스와 메시지 버스 분류하기

통합 테스트를 작성하기 전에 프로세스 외부 의존성을 두 가지로 분류해서 직접 테스트할 대상과 목으로 대체할 대상을 결정해야 한다.
애플리케이션 데이터베이스는 어떤 시스템도 접그날 수 없으므로 관리 의존성이다. 따라서 실제 인스턴스를 사용해야 한다.
통합 테스트는
데이터베이스에 사용자와 회사를 삽입하고
해당 데이터베이스에서 이메일 변경 시나리오를 실행하며
데이터베이스 상태를 검증하게 된다.
메시지 버스는 비관리 의존성이다. 메시지 버스의 목적은 다른 시스템과의 통신을 가능하게 하는 것뿐이다. 메시지 버스를 목으로 대체하고 컨트롤러와 목 간의 상호 작용을 검증하게 된다.

8.3.3 엔드 투 엔드 테스트는 어떤가?

샘플 프로젝트에는 엔드 투 엔드 테스트가 없을 것이다.
API로 시나리오를 엔드 투 엔드 테스트하면 배포해서 모두 작동하는 버전의 API로 테스트하게 되고, 이는 어떤 외부 의존성도 목으로 대체하지 않는 것을 의미한다.
통합 테스트는 동일한 프로세스 내에서 애플리케이션을 호스팅하고 비관리 의존성을 목으로 대체한다.
그림 8.7 엔드 투 엔드 테스트는 외부 클라이언트를 모방하므로, 테스트 범위에 포함된 모든 프로세스 외부 의존성을 참조하는 배포된 버전의 애플리케이션을 테스트 한다.
그힘 8.8 통합 테스트는 동일한 프로세스 내에서 애플리케이션을 호스팅한다. 엔드 투 엔드 테스트와 달리 비관리 의존성을 목으로 대체한다.

8.3.4 통합 테스트: 첫 번째 시도

@Test @DisplayName("통합 테스트") public void changingEmailFromCorporateToNonCorporate() { // Arrange Database db = new Database(ConnectionString); User user = createUser( "user@mycorp.com", UserType.Employee, db); createCompany("mycorp.com", 1, db); IMessageBus messageBusMock = mock(MessageBus.class); UserController sut = new UserController(db, messageBusMock); // Act String result = sut.changeEmail(user.UserId, "new@gmail.com"); // Assert assertEquals("OK", result); Object[] userData = db.getUserById(user.UserId); User userFromDb = UserFactory.create(userData); assertEquals("new@gmail.com", userFromDb.Email); assertEquals(UserType.Customer, userFromDb.Type); Object[] companyData = db.getCompany(); Company companyFromDb = CompanyFactory.create(companyData); assertEquals(0, companyFromDb.NumberOfEmployees); verify(messageBusMock, times(1)) .sendEmailChangedMessage(user.getUserId(), "new@gmail.com"); } private Company createCompany(String domainName, int numberOfEmployees, Database database) { Company company = new Company(domainName, numberOfEmployees); database.saveCompany(company); return company; } private User createUser(String email, UserType type, Database database) { User user = new User(0, email, type, false); database.saveUser(user); return user; }
Java
복사
테스트는 준비 구절에서 사용자와 회사를 데이터베이스에 삽입하지 않고, createUser()와 createCompany() 헬퍼 메서드를 호출한다. 이러한 메서드는 여러 통합 테스트에서 재사용할 수 있다.
입력 매개변수로 사용한 데이터와 별개로 데이터베이스 상태를 확인하는 것이 중요하다.
검증 구절에서 사용자와 회사 데이터를 각각 조회하고, 새로운 userFromDb와 companyFromDb 인스턴스를 생성한 후에 해당 상태를 검증만 한다.
테스트가 데이터베이스에 대해 읽기와 쓰기를 모두 수행하므로 회귀 방지를 최대로 얻을 수 있다.
읽기는 컨트롤러에서 내부적으로 사용하는 동일한 코드를 써서 구현해야 한다.
통합 테스트는 할 일을 다 했지만, 아직 더 개선할 수 있다.
헬퍼 메서드를 사용하면 검증 구절도 크기를 줄일 수 있다.
messageBusMock은 회귀 방지가 그다지 좋지 않다.
9장과 10장에서는 목과 데이터베이스 테스트 모범 사례에 관한 개선 사항을 설명한다.

8.4 의존성 추상화를 위한 인터페이스 사용

단위 테스트 영역에서 가장 많이 오해하는 주제 중 하나는 인터페이스 사용이다.
인테페이스를 둔 이유를 잘못 설명하고, 그 결과 남용하는 경향이 있다.
이 절에서는 잘못된 이유를 살펴보고, 어떤 환경에서 바람직하거나 바람직하지 않은지를 알아본다.

8.4.1 인터페이스와 느슨한 결합

많은 개발자가 데이터베이스나 메시지 버스와 같은 프로세스 외부 의존성을 위해 인터페이스를 도입한다.
인터페이스에 구현이 하나만 있는 경우에도 그렇다.
이 관습은 널리 퍼져 있어서 아무도 의문을 제기하지 않는다.
// 자주 볼 수 있는 클래스와 인터페이스 쌍 public interface IMessageBus public class MessageBus implements IMessageBus public interface IUserRepository public class UserRepository implements IUserRepository
Java
복사
이렇게 인터페이스를 사용하는 일반적인 이유는
인터페이스가 프로세스 외부 의존성을 추상화해 느슨한 결합을 달성하고,
기존 코드를 변경하지 않고 새로운 기능을 추가해 공개 폐쇄 원칙을 잘 지키기 때문이다.
위 두 가지 이유는 모두 오해다.
단일 구현을 위한 인터페이스는 추상화가 아니며, 해당 인터페이스를 구현하는 구체 클래스보다 결합도가 낮지 않다.
진정한 추상화는 발견하는 것이지, 발명하는 것이 아니다.
두 번째 이유는 더 기본적인 원칙인 YAGNI를 위반하기 때문에 잘못된 생각이다.
향후 기능이 어떤지 설명하려고 기능을 개발해서도, 기존 코드를 수정해서도 안 된다.
YAGNI(You Aren’t Gonna Need It) - 현재 필요하지 않은 기능에 시간을 들이지 말라
현재 비즈니스 담당자들에게 필요하지 않은 기능에 시간을 보낸다면, 지금 당장 필요한 기능을 제치고 시간을 허비하는 것이다. → 기회 비용
프로젝트 코드가 적을수록 좋다.
코드를 작성하는 것은 문제를 해결하는 값비싼 방법이다. 해결책에 필요한 코드가 적고 간단할수록 더 좋다.

8.4.2 프로세스 외부 의존성에 인터페이스를 사용하는 이유는 무엇인가?

각 인테퍼에스에 구현이 하나만 있다고 가정할 때 프로세스 외부 의존성에 인터페이스를 사용하는 이유는?
간단히 말하자면, 목을 사용하기 위함이다.
인터페이스가 없으면 테스트 대역을 만들 수 없으므로 SUT과 프로세스 외부 의존성 간의 상호 작용을 확인할 수 없다.
따라서 이러한 의존성을 목으로 처리할 필요가 없는 한, 프로세스 외부 의존성에 대한 인터페이스를 두지 말라.
비관리 의존성만 목으로 처리하므로, 결국 비관리 의존성에 대해서만 인터페이스를 쓰라는 지침이 된다.
관리 의존성을 컨트롤러에 명시적으로 주입하고, 해당 의존성을 구체 클래스로 사용하라.
진정한 추상화는 목과 상관없이 인터페이스로 나타낼 수 있다.
그러나 목 대체 이외의 이유로 단일 구현을 위해 인터페이스를 도입하는 것인 YAGNI에 위배된다.
예제 8.2에서 UserController는 생성자를 통해 메시지 버스와 데이터베이스를 모두 받지만, 메시지 버스만 인터페이스로 돼 있다는 것을 알 수 있다.
private final Database _database; private final IMessageBus _messageBus; public UserController(Database database, IMessageBus messageBus) { _database = database; _messageBus = messageBus; } public String changeEmail(int userId, String newEmail) { /* _database와 _messageBus를 사용하는 메서드 */ }
Java
복사

8.4.3 프로세스 내부 의존성을 위한 인터페이스 사용

때로는 프로세스 외부 의존성뿐만 아니라 프로세스 내부 의존성도 인터페이스 기반인 코드를 볼 수 있다.
public interface IUser { int userId; String email; String canChangeEmail; void changeEmail(String newEmail, Company company); } public class User implements IUser { /* ... */ }
Java
복사
IUser에 구현이 하나만 있다고(특정 인테페이스가 항상 구현이 하나만 있다고) 가정하면, 이는 좋지 않은 신호다.
프로세스 외부 의존성과 마찬가지로 도메인 클래스에 대해 단일 구현으로 인터페이스를 도입하는 이유는 목으로 처리하기 위한 것뿐이다.
그러나 프로세스 외부 의존성과 달리 도메인 클래스 간의 상호 작용을 확인해서는 안 된다.
→ 깨지기 쉬운 테스트로 이어지고, 결국 리팩터링 내성이 떨어지게 된다.

8.5 통합 테스트 모범 사례

통합 테스트를 최대한 활용하는 데 도움이 되는 몇 가지 일반적인 지침이 있다.
도메인 모델 경계 명시하기
애플리케이션 내 계층 줄이기
순환 의존성 제거하기
보통 테스트에 유익한 모범 사례가 코드 베이스의 상태를 개선하는 편이다.

8.5.1 도메인 모델 경계 명시하기

항상 도메인 모델을 코드베이스에서 명식적이고 잘 알려진 위치에 두도록 하라.
도메인 모델은 프로젝트가 해결하고자 하는 문제에 대한 도메인 지식의 모음이다.
도메인 모델에 명시적 경계를 지정하면 코드의 해당 부분을 더 잘 보여주고 잘 설명할 수 있다.
단위 테스트는 도메인 모델과 알고리즘을 대상으로 하고, 통합 테스트는 컨트롤러를 대상으로 한다. 도메인 클래스와 컨트롤러 사이의 명확한 경계로 단위 테스트와 통합 테스트의 차이점을 쉽게 구별할 수 있다.
이러한 경계는 별도의 어셈블러 또는 패키지 형태를 취할 수 있다.

8.5.2 계층 수 줄이기

대부분의 개발자는 간접 계층을 추가해서 코드를 추상화하고 일반화하려고 한다.
그림 8.9 다양한 애플리케이션 문제는 때때로 별도의 간접 계층으로 해결된다. 일반적인 기능은 각 계층의 작은 부분을 차지한다.
극단적인 경우로, 애플리케이션에 추상 계층이 너무 많으면 코드베이스를 탐색하기 어렵고 아주 간단한 연산이라 해도 숨은 로직을 이해하기가 너무 어려워진다.
컴퓨터 과학의 모든 문제는 또 다른 간접 계층으로 해결할 수 있다. 간접 계층이 너무 많아서 문제가 생기지 않는다면 말이다. - 데이빗 휠러
간접 계층은 코드를 추론하는 데 부정적인 영향을 미친다.
추상화가 지나치게 많으면 단위 테스트와 통합 테스트에도 도움이 되지 않는다. 간접 계층이 많은 코드베이스는 컨트롤러와 도메인 모델 사이에 명확한 경계가 없는 편이다.
7장에서 살펴봤듯이 효과적인 테스트를 위한 전제 조건이다.
각 계층을 따로 검증하는 경향이 훨씬 강하다.
이러한 경향으로 통합 테스트는 가치가 떨어지며, 각 테스트는 특정 계층의 코드만 실행하고 하위 계층은 목으로 처리한다.
그림 8.10 도메인 계층, 애플리케이션 서비스 계층, 인프라 계층. 이 세 가지 계층만 활용하면 된다.
가능한 한 간접 계층을 적게 사용하라.
대부분의 백엔드 시스템에서는 도메인 모델, 애플리케이션 서비스 계층(컨트롤러), 인프라 계층, 이 세 가지만 활용하면 된다.
인프라 계층은 보통 도메인 모델에 속하지 않는 알고리즘과 프로세스 외부 의존성에 접근할 수 있는 코드로 구성된다.

8.5.3 순환 의존성 제거하기

코드베이스의 유지 보수성을 대폭 개선하고 테스트를 더 쉽게 할 수 있는 또 다른 방법으로 순환 의존성을 제거하는 것이 있다.
순환 의존성(circular dependency 또는 cyclic dependency)은 둘 이상의 클래스가 제대로 작동하고자 직간접적으로 서로 의존하는 것을 말한다.
순환 의존성의 예는 콜백이다.
여기서 CheckOutServiceReportGenerationService 인스턴스를 생성하고 해당 인스턴스에 자신을 인수로 전달한다.
ReportGenerationServiceCheckOutService를 다시 호출해 보고서 생성 결과를 알려준다.
public class CheckOutService { public void checkOut(int orderId) { ReportGenerationSerivce service = new ReportGenerationService(); service.generateReport(orderId, this); /* 기타 코드 */ } } public class ReportGenerationService { public void generateReport(int orderId, CheckoutService checkoutService) { /* 생성이 완료되면 checkoutService 호출 */ } }
Java
복사
추상 계층이 너무 많은 것과 마찬가지로 순환 의존성은 코드를 읽고 이해하려고 할 때 알아야 할 것이 많아서 큰 부담이 된다.
순환 의존성이 있으면 해결책을 찾기 위한 출발점이 명확하지 않기 때문
하나의 클래스를 이해하려면 주변 클래스 그래프 전체를 한 번에 읽고 이해해야 하며, 심지어 소규모의 독립된 클래스 조차도 파악하기 어려워질 수 있다.
순환 의존성은 테스트를 방해한다.
클래스 그래프를 나눠서 동작 단위를 하나 분리 하려면 인터페이스에 의존해 목으로 처리해야 하는 경우가 많으며, 이는 도메인 모델을 테스트할 때 해서는 안된다.
인터페이스 사용은 순환 의존성의 문제만 가린다.
CheckOutService에 대한 인터페이스를 두고 ReportGenerationService를 구체 클래스 대신 인터페이스에 의존하게 하는 경우 컴파일 타임에 순환 참조를 제거할 수 있지만, 런타임에는 순환이 있다.
그림 8.11 인터페이스를 사용하면 컴파일 타임에 순환 의존성을 제거하지만 런타임에는 제거하지 않으며, 코드를 이해하는 데 알아야 하는 부담이 더 줄어들지 않는다.
순환 의존성을 처리하는 방법은 → 순환 의존성을 제거하는 것이다.
ReportGenerationService를 리팩터링
public class CheckOutService { public void checkOut(int orderId) { ReportGenerationService service = new ReportGenerationService(); Report report = service.generateReport(orderId); /* 기타 작업 */ } } public class ReportGenerationService { public Report generateReport(int orderId) { /* ... */ } }
Java
복사
코드베이스에서 순환 의존성을 모두 제거하는 것은 거의 불가능하다. 그렇더라도 서로 의존적인 클래스의 그래프를 가능한 한 작게 만들면 손상을 최소화할 수 있다.

8.5.4 테스트에서 다중 실행 구절 사용

3장에서 살펴봤듯이, 테스트에서 두 개 이상의 준비나 실행 또는 검증 구절을 두는 것은 ‘코드 악취’에 해당한다.
예를 들어, 사용자 등록과 사용자 삭제와 같이 두 가지 관련 유스케이스가 있으면 하나의 통합 테스트에서 두 유스케이스를 모두 확인하려고 할 수 있다.
준비 : 사용자 등록에 필요한 데이터 준비
실행 : UserController.registerUser() 호출
검증 : 등록이 성공적으로 완료됐는지 확인하기 위해 데이터베이스 조회
실행 : UserController.deleteUser() 호출
검증 : 사용자가 삭제됐는지 확인하기 위해 데이터베이스 조회
이런 방식은 사용자의 상태가 자연스럽게 흐르기 때문에 설득력 있고, 첫 번째 실행은 두 번째 실행의 준비 단계 역할을 할 수 있다.
각 실행을 고유의 테스트로 추출해 테스트를 나누는 것이 좋다. 불필요한 작업처럼 보일 수 있지만 이 작업은 장기적으로 유리하다.
각 테스트가 단일 동작 단위에 초점을 맞추게 하면, 테스트를 더 쉽게 이해하고 필요할 때 수정할 수 있다.
이 지침의 예외로, 원하는 상태로 만들기 어려운 프로세스 외부 의존성으로 작동하는 테스트가 있다.
사용자를 등록하면 외부 은행 시슽메에서 계좌가 만들어진다. → 은행에서 샌드박스를 제공하기에 엔드 투 엔드 테스트에서 이 샌드박스를 사용한다. → 문제는 샌드박스가 느리거나 은행에서 호출 수를 제한한다는 것이다. → 이러한 시나리오에서는 여러 동작을 하나의 테스트로 묶어서 문제가 있는 프로세스 외부 의존성에 대한 상호 작용 횟수를 줄이는 것이 유리한다.
둘 이상의 실행 구절로 테스트를 작성하는 것이 타당한 이유는 프로세스 외부 의존성을 관리하기 어려운 경우뿐이다. 따라서 단위 테스트는 프로세스 외부 의존성으로 작동하지 않기 때문에 절대로 실행 구절이 여러 개 있어서는 안된다.

8.6 로깅 기능을 테스트하는 방법

로깅은 회색 지대로, 테스트에 관해서는 어떻게 해야 할지 분명하지 않다.
로깅을 조금이라도 테스트해야 하는가?
만약 그렇다면 어떻게 테스트해야 하는가?
로깅이 얼마나 많으면 충분한가?
로거 인스턴스를 어떻게 전달할까?

8.6.1 로깅을 테스트해야 하는가?

로깅은 횡단 기능(cross cutting functionality)으로, 코드베이스 어느 부분에서나 필요로 할 수 있다.
public void changeEmail(String newEmail, Company company) { _logger.info("Changing email for user {userId} to {newEmail}"); // 메서드 시작 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); // 사용자 유형 변경 _logger.info("User {userId} changed type from {Type} to {newType}"); } Email = newEmail; Type = newType; emailChangedEvents.add(new EmailChangedEvent(UserId, newEmail)); _logger.info("email is changed for user {userId}"); // 메서드 끝 }
Java
복사
메스드의 시작과 끝에서, 그리고 사용자 유형이 변경될 때마다 로그 파일에 기록한다. 이 기능을 테스트해야 하는가?
로깅은 애플리케이션의 동작에 대해 중요한 정보를 생성하나 너무 보편적이므로, 테스트 노력을 더 들일 가치가 있는지 분명하지 않다.
로깅이 애플리케이션의 식별할 수 있는 동작인가, 아니면 구현 세부 사항인가?
결국 로깅은 텍스트 파일이나 데이터베이스와 같은 프로세스 외부 의존성에 부작용을 초래한다.
이러한 부작용을 고객이나 애플리케이션의 클라이언트 또는 개발자 이외의 다른 사람이 보는 경우라면, 로깅은 식별할 수 있는 동작이므로 반드시 테스트해야 한다.
하지만 보는 이가 개발자뿐이라면, 아무도 모르게 자유로이 수정할 수 있는 구현 세부 사항이므로 테스트해서는 안 된다.
스티브 프리먼과 냇 프라이스의 ‘Growing Object-Oriented Software, Guided by Tests’에서는 다음과 같이 두 가지 유형의 로깅으로 나눈다.
지원 로깅은 지원 담당자나 시스템 관리자가 추적할 수 있는 메시지를 생성한다.
진단 로깅은 개발자가 애플리케이션 내부 상황을 파악할 수 있도록 돕는다.

8.6.2 로깅을 어떻게 테스트해야 하는가?

로깅에는 프로세스 외부 의존성이 있기 때문에 테스트에 관한 한 프로세스 외부 의존성에 영향을 주는 다른 기능과 동일한 규칙이 적용된다. → 애플리케이션과 로그 저장소 간의 상호 작용 검증 위해 목 사용
ILogger 인터페이스를 목으로 처리하지 말라.
지원 로깅은 비즈니스 요구 사항이므로 코드베이스에 명시적으로 반영하라.
지원 로깅에는 ILogger 대신 DomainLogger 클래스를 만들고 사용하라.
메서드의 시작과 끝은 진단 로깅, 사용자 유형의 모든 변경 사항을 기록하는건 지원 로깅
// User public void changeEmail(String newEmail, Company company) { _logger.info("Changing email for user {userId} to {newEmail}"); // 메서드 시작 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); // 사용자 유형 변경 _domainLogger.info("User {userId} changed type from {Type} to {newType}"); } Email = newEmail; Type = newType; emailChangedEvents.add(new EmailChangedEvent(UserId, newEmail)); _logger.info("email is changed for user {userId}"); // 메서드 끝 } // DomainLogger public class DomainLogger implements IDomainLogger { private final ILogger _logger; public DomainLogger(ILogger logger) { _logger = logger; } public void UserTypeChanged(int userId, UserType oldType, UserType newType) { _logger.info("User {userId} changed type from {oldType} to {newType}"); } }
Java
복사
DomainLogger는 ILogger 위에서 작동한다. 도메인 언어를 사용해 비즈니스에 필요한 특정 로그 항목을 선언하므로 지원 로깅을 더 쉽게 이해하고 유지보수 할 수 있다.
구조화된 로깅(structured logging)은 로그 데이터 캡처와 렌더링을 분리하는 로깅 기술이다.
전통적인 로깅은 간단한 텍스트로 작동한다.
문제점) 특정 유형의 메시지가 몇 개인지, 특정 사용자 ID와 관련된 메시지가 몇 개인지 알기가 쉽지 않다. → 전문도구 또는 직접 작성한 도구가 필요하다.
반면에 구조화된 로깅은 로그 저장소에 구조가 있다. → 표면적으로 전통적과 비슷해보인다.
기저 동작은 크게 다르다.(그림 참조)
그림 8.12 구조화된 로깅은 로그 데이터와 해당 데이터의 렌더링을 분리한다.
DomainLogger에는 프로세스 외부 의존성(로그 저장소)이 있다. 여기에 문제가 있다.
User와 해당 의존성과 상호 작용하므로, 비즈니스 로직과 프로세스 외부 의존성을 분리해야하는 원칙을 위반한다. → DomainLogger 사용하면 User가 지나치게 복잡한 코드 범주로 들어가 유지보수가 어렵다
사용자 이메일 변경에 대해 외부 시스템의 알림을 구현한 것과 같은 방식으로 해결 가능
// User public void changeEmail(String newEmail, Company company) { _logger.info("Changing email for user {userId} to {newEmail}"); // 메서드 시작 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); /** * DomainLogger 대신 도메인 이벤트 사용 * UserTypeChangedEvent와 EmailChangedEvent라는 두 가지 도메인 이벤트가 있다. * 둘 다 같은 인터페이스를 구현하므로 같은 컬렉션에 저장할 수 있음 */ addDomainEvent(new UserTypeChangedEvent(userId, type, newType); } Email = newEmail; Type = newType; emailChangedEvents.add(new EmailChangedEvent(UserId, newEmail)); _logger.info("email is changed for user {userId}"); // 메서드 끝 } // UserController public String changeEmain(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); // 사용자 도메인 이벤트 전달 _eventDispatcher.dispatcher(user.domainEvents); return "OK"; }
Java
복사
EventDispatcher는 도메인 이벤트를 프로세스 외부 의존성에 대한 호출로 변환하는 새로운 클래스
UserTypeChangedEvent를 사용하면 두 가지 책임(프로세스 외부 의존성 통신과 도메인 로직)을 분리할 수 있다. → 로깅을 테스트 하는 것은 비관리 의존성을 테스트하는 것과 다르지 않다.
단위 테스트 : User에서 UserTypeChangedEvent 인스턴스를 확인해야 한다.
단일 통합 테스트 : 목을 써서 DomainLogger와의 상호 작용이 올바른지 확인해야 한다.
도메인 클래스가 아닌 컨트롤러에 지원 로깅이 필요한 경우 컨트롤러는 도메인 모델과 프로세스 외부 의존성 간의 협업을 조정하므로 UserController는 해당 로거를 직접 사용할 수 있다.
User 클래스가 진단 로깅을 사용하는 방식은 변경하지 않음 → 진단 로깅은 개발자만을 위한 것이므로 테스트할 필요가 없다.

8.6.3 로깅이 얼마나 많으면 충분한가?

지원 로깅은 비즈니스 요구 사항이므로, 질문의 여지가 없다. 그러나 진단 로깅은 조절할 수 있다.
다음 두 가지 이유로 로깅을 과도하게 사용하지 않는 것이 중요하다.
과도한 로깅은 코드를 혼란스럽게 한다. 특히 도메인 모델에 해당한다.
핵심은 로그의 신호 대비 잡음 비율이다. 로그가 많을수록 관련 정보를 찾기가 어려워진다.
도메인 모델에서는 진단 로깅을 절대 사용하지 않도록 하라.
이러한 로깅은 도메인 클래스에서 컨트롤러로 옮길 수 있다.
무언가를 디버깅해야 할 때만 일시적으로 진단 로깅을 사용하라. → 디버깅이 끝나면 제거하라

8.6.4 로거 인스턴스를 어떻게 전달하는가?

정적 메서드 사용
public class User { private static final ILogger _logger = LogManager.getLogger(User.class); public void changEmail(String newEmail, Company company) { _logger.info("changing email for user {userId} to {newEmail}"); /* ... */ _logger.info("Email is changed for user {userId}"); }
Java
복사
이러한 유형의 의존성 획득을 앰비언트 컨텍스트라고 부른다. 이는 안티 패턴이며 두 가지 단점이 있다.
의존성이 숨어있고 변경하기가 어렵다
테스트가 더 어려워진다.
어떠한 경우에도 앰비언트 컨텍스트는 해결책이 아니다.
명시적인 로거 주입(메서드 인수)
public void changeEmail(String newEmail, Company company, ILogger logger) { _logger.info("changing email for user {userId} to {newEmail}"); /* ... */ _logger.info("Email is changed for user {userId}"); }
Java
복사

결론

식별할 수 있는 동작인지, 아니면 구현 세부 사항인지 여부에 대한 관점으로 프로세스 외부 의존성과의 통신을 살펴보자.
개발자가 아닌 사람이 로그를 볼 수 있으면 로깅 기능을 목으로 처리하고, 그렇지 않으면 테스트하지 말라.