육각형 아키텍처와 시스템 통신 - 5장 목과 테스트 취약성 (3)
단위테스트 (블라디미르 코리코프)
5장 목과 테스트 취약성
3 목과 테스트 취약성과의 관계
이번 절에서 알아 볼 것
- 육각형 아키텍처 (hexagonal architecture)
- 내부 통신과 외부 통신의 차이점
- 목과 테스트 취약성 관의 관계
3.1 육각형 아키텍처 정의
육각형 아키텍처는 앨리스터 코오번(Alistair Cockburn) 에 의해 처음 소개되었다.
전형적인 애플리케이션은 도메인(비지니스 로직)과 애플리케이션 서비스라는 두 계층으로 구성된다.
도메인 계층은 애플리케이션의 중심부이기 때문에 도표의 중앙에 위치한다.
도메인 계층은 필수 기능으로 비즈니스 로직이 포함되어 있다.
애플리케이션 서비스 계층은 도메인 계층 위에 있으며 외부 환경과의 통신을 조정한다.
애플리케이션의 API에 대한 모든 요청이 먼저 애플리케이션 서비스 계층에 도달한다.
애플리케이션 서비스 계층은 도메인 클래스와 프로세스 외부 의존성 간의 작업을 조정한다.
예시는 다음과 같다.
- 데이터베이스를 조회하고 해당 데이터로 도메인 클래스 인스턴스 구체화
- 해당 인스턴스에 연산 호출
- 결과를 데이터베이스에 다시 저장
애플리케이션 서비스 계층과 도메인 계층의 조합을 육각형으로 나타내며 이 육각형은 애플리케이션을 의미한다.
애플리케이션들은 다른 애플리케이션과 소통할 수 있고, 다른 애플리케이션도 육각형으로 나타낸다.
여기서 말하는 다른 애플리케이션은 SMTP, 서드파티, 메시지 버스 등이 될 수 있다.
여러 육각형이 서로 소통하면서 육각형 아키텍처를 구성하게 된다.
육각형 아키텍처의 목적은 세 가지 중요한 지침을 강조하는 것이다.
1. 도메인 계층과 애플리케이션 서비스 계층간의 관심사 분리
비즈니스 로직은 애플리케이션의 가장 중요한 부분이다. 따라서 해당 비지니스 로직에 대해서만 책임을 져야 하며, 다른 모든 책임에서는 제외되야 한다.
외부 애플리케이션과 소통하는 것은 애플리케이션 서비스에 귀속이 되어야 한다.
반대로 애플리케이션 서비스에는 어떠한 로직도 있으면 안 된다.
요청이 들어오면 도메인 클래스의 연산으로 변환한 다음 결과를 저장하거나 호출자에게 다시 반환해서 도메인 계층으로 변환하는 책임이 있다.
도메인 계층을 애플리케이션의 도메인 지식(사용 방법) 모음으로,
애플리케이션 서비스 계층을 일련의 비즈니스 유스케이스(사용 대상)로 볼 수 있다.
2. 애플리케이션 내부 통신
육각형 아키텍처는 애플리케이션 서비스 계층에서 도메인 계층으로 흐르는 단방향 의존성 흐름을 규정한다.
도메인 계층 내부 클래스는 도메인 계층 내부 클래스끼리 서로 의존하고 애플리케이션 서비스 계층의 클래스에 의존하지 않는다.
(이 지침은 이전 지침에서 나온 것이다.)
애플리케이션 서비스 계층과 도메인 계층 간에 관심사를 분리하는 것은
애플리케이션 서비스 계층이 도메인 계층에 대해 아는 것을 의미하지만, 반대는 아니다.
도메인 계층은 외부 환경에서 완전히 격리돼야 한다.
3. 애플리케이션 간의 통신
외부 애플리케이션은 애플리케이션 서비스 계층에 있는 공통 인터페이스를 통해 해당 애플리케이션에 연결된다. 아무도 도메인 계층에 직접 접근할 수 없다.
육각형의 각 면은 애플리케이션 내외부 연결을 나타낸다.
육각형에는 여섯 면이 있지만, 애플리케이션이 다른 애플리케이션을 여섯 개만 연결할 수 있는 것은 아니다. 연결 수는 임의로 정할 수 있으며 연결이 많을 수도 있다.
애플리케이션의 각 계층은 식별할 수 있는 동작을 나타낸다.
잘 설계된 API의 원칙에는 프랙탈(기하학) 특성이 있다.
API를 잘 설계하면(즉, 구현 세부 사항을 숨기면) 테스트도 프랙탈 구조를 갖게 된다.
달성하는 목표는 같지만 서로 다른 수준에서 동작을 검증한다.
애플리케이션을 다루는 테스트는 해당 서비스가 외부 클라이언트에게 매우 중요하고 큰 목표를 어떻게 이루는지 확인한다.
도메인 클래스 테스트는 그 큰 목표의 하위 목표를 검증한다.
어떤 테스트든 비즈니스 요구 사항으로 거슬러 올라갈 수 있어야 한다.
각 테스트는 도메인 전문가에게 의미 있는 이야기를 전달해야 하며, 그렇지 않으면 테스트가 구현 세부 사항과 결합돼 있으므로 불안정하다는 강한 암시이다.
식별할 수 있는 동작은 바깥 계층에서 안으로 흐른다.
외부 클라이언트에게 중요한 목표는 개별 도메인 클래스에서 달성한 하위 목표로 변환된다.
따라서 도메인 계층에서 식별할 수 있는 동작은 각각 구체적인 비즈니스 유스케이스와 연관성이 있다.
이 연관성을 가장 안쪽(도메인) 계층에서 애플리케이션 서비스 계층 바깥쪽으로, 그리고 외부 클라이언트의 요구 사항까지 재귀적으로 추적할 수 있다.
이 추적성은 식별할 수 있는 동작의 정의에 다른다.
코드 조각이 식별할 수 있는 동작이 되려면 클라이언트가 목표를 달성할 수 있도록 도울 필요가 있다.
도메인 클래스의 경우 클라이언트는 애플리케이션 서비스에 해당하고, 애플리케이션 서비스이면 외부 클라이언트에 해당한다.
잘 설계된 API로 코드베이스를 검증하는 테스트는 식별할 수 있는 동작만 결합돼 있기 때문에 비즈니스 요구 사항과 관계가 있다.
* 코드베이스의 공개 API를 항상 비즈니스 요구 사항에 따라 추적하라는 지침은 대부분의 도메인 클래스와 애플리케이션 서비스에 적용되지만 유틸리티나 인프라 코드에는 적용되지 않는다. 해당 코드로 해결하는 문제는 종종 너무 낮은 수준이고 세밀해서 구체적인 비즈니스 유스케이스로 추적할 수 없다.
3.2 시스템 내부 통신과 시스템 간 통신
일반적인 애플리케이션에는 시스템 내부(inter-system) 통신과 시스템 간(intra-system) 통신이 있다.
시스템 내부 통신은 애플리케이션 내 클래스 간의 통신이다.
시스템 간 통신은 애플리케이션이 다른 애플리케이션과 통신하는 것을 말한다.
[Note] 시스템 내부 통신은 구현 세부 사항이고, 시스템 간 통신은 그렇지 않다. 도메인 클래스 간의 협력은 식별할 수 있는 동작이 아니므로 시스템 내부 통신은 구현 세부 사항이다.
따라서 이러한 협력과 결합하면 테스트가 취약해진다.
시스템 간 통신의 특성은 별도 애플리케이션과 함께 성장하는 방식에서 비롯된다.
성장의 주요 원칙 중 하나는 하위 호환성을 지키는 것이다.
시스템 내부 리팩터링과 다르게, 외부 애플리케이션과 통신할 때 사용하는 통신 패턴은 항상 외부 애플리케이션이 이해할 수 있도록 유지해야 한다.
목을 사용하면 시스템과 외부 애플리케이션 간의 통신 패턴을 확인할 때 좋다.
목을 시스템 내 클래스 간의 통신을 검증하는 데 사용하면 테스트가 구현 세부 사항과 결합되므로 그에 따라 리팩터링 내성 지표가 미흡해진다.
3.3 시스템 내부 통신과 시스템 간 통신의 예
다음 비지니스 유스케이스를 고려해보자.
- 고객이 상점에서 제품을 구매하려고 한다.
- 매장 내 제품 수량이 충분하면
- 재고가 상점에서 줄어든다.
- 고객에게 이메일로 영수증을 발송한다.
- 확인 내역을 반환한다.
// 취약한 테스트로 이어지지 않는 목 사용
public void Successful_purchase()
{
var mock = new Mock<IEmailGateway>();
var sut = new CustomerController(mock.Object);
bool isSuccess = sut.Purchase(customerId: 1, productId: 2, quantity: 5);
Assert.Ture(isSuccess);
mock.Verify(
x => x.SendRequest(
“customer@email.com”, “Shampoo”, 5),
Times.Once); // 시스템이 구매에 대한 영수증을 보내는지 검증
}
// 취약한 테스트로 이어지는 목 사용
public void Purchase_succeeds_when_enough_inventory()
{
var storeMock = new Mock<IStore>();
storeMock
.Setup(x => x.HasEnoughInventory(Product.Shampoo, 5))
.Returns(true);
var customer = new Customer();
bool success = customer.Purchase(storeMock.Object, Product.Shampoo, 5);
Assert.Ture(success);
storeMock.Verify(
x => x.RemoveInventory(Product.Shampoo, 5),
Times.Once);
}
SMTP 서비스에 대한 호출을 목으로 하는 이유는 타당하다. 리팩터링 후에도 이러한 통신 유형이 그대로 유지되도록 하기 때문에 테스트 취약성을 야기하지 않는다.
CustomerController 와 SMTP 서비스 간의 통신과 달리, Customer 클래스에서 Store 클래스로의 메소드 호출은 애플리케이션 경계를 넘지 않는다. 호출자와 수신자 모두 애플리케이션 내에 있다.
4. 단위 테스트의 고전파와 런던파 재고
격리 주석 | 단위의 크기 | 테스트 대역 사용 대상 | |
---|---|---|---|
런던파 | 단위 | 단일 클래스 | 불변 의존성 외 모든 의존성 |
고전파 | 단위 테스트 | 단일 클래스 또는 클래스 세트 | 공유 의존성 |
런던파는 불변 의존성을 제외한 모든 의존성에 목 사용을 권장하며 시스템 내 통신과 시스템 간 통신을 구분하지 않는다.
런던파를 따라 목을 무분별하게 사용하면 종종 구현 세부 사항에 결합돼 테스트에 리팩터링 내성이 없게 된다. 리팩터링 내성이 저하되면 테스트는 가치가 없어진다.
고전파는 테스트 간에 공유하는 의존성만 교체하자고 하므로 이 문제에 훨씬 유리하다.
4.1 모든 프로세스 외부 의존성을 목으로 해야 하는 것은 아니다.
의존성 유형
- 공유 의존성 : 테스트 간에 공유하는 의존성 (제품 코드가 아님)
- 프로세스 외부 의존성 : 프로그램의 실행 프로세스 외에 다른 프로세스를 점유하는 의존성 (예를 들면 데이터베이스, 메시지 버스, SMTP 서비스 등)
- 비공개 의존성 : 공유하지 않는 모든 의존성
고전파에서는 공유 의존성을 피할 것을 권고한다. 테스트가 실행 컨텍스트를 서로 방행하고, 결국 병렬 처리를 할 수 없기 때문이다. 테스트를 병렬적, 순차적 또는 임의의 순서로 실행할 수 있는 것을 테스트 격리라고 부른다.
공유 의존성이 프로세스 외부에 있는 것이 아니면 각 테스트 실행 시 해당 의존성을 새 인스턴스로 써서 재사용을 피하기 쉽다.
공유 의존성이 프로세스 외부에 있으면 테스트가 더 복잡해진다. 각 테스트 실행 전에 데이터베이스를 인스턴스화하거나 메시지 버스를 새로 준비할 수 없다. (이렇게 하면 테스트 스위트가 현저히 느려질 것이다.) 일반적인 접근법은 이러한 의존성을 테스트 대역, 즉 목과 스텁으로 교체하는 것이다.
그러나 모든 프로세스 외부 의존성을 목으로 해야하는 것은 아니다. 프로세스 외부 의존성이 애플리케이션을 통해서만 접근할 수 있으면, 이러한 의존성과의 통신은 시스템에서 식별할 수 있는 동작이 아니다.
애플리케이션과 외부 시스템 간의 통신 패턴을 항상 지켜야 하는 요구 사항은 하위 호환성을 지켜야 한다는 점에서 비롯된다.
그러나 애플리케이션이 외부 시스템에 대한 프록시 같은 역할을 하고 클라이언트가 직접 접근할 수 없으면, 하위호환성 요구 사항은 사라진다. 이제 이 외부 시스템과 애플리케이션을 같이 배포할 수 있으면 클라이언트에 영향을 미치지 않을 것이다. 이러한 시스템의 통신 패턴은 구현 세부 사항이 된다.
좋은 예로는 애플리케이션 데이터베이스가 있다. 애플리케이션에서만 사용되는 데이터 베이스다. 어떤 외부 시스템도 이 데이터베이스에 접근할 수 없다. 따라서 기존 기능을 손상시키지 않는 한 시스템과 애플리케이션 데이터베이스 간의 통신 패턴을 원하는 대로 수정할 수 있다. 해당 데이터베이스는 클라이언트의 시야에서 완전히 숨어 있기 때문에 전혀 다른 저장 방식으로 대체할 수 있고, 그렇게 해도 아무도 모를 것이다.
완전히 통제권을 가진 프로세스 외부 의존성에 목을 사용하면 깨지기 쉬운 테스트로 이어진다. 데이터베이스와 애플리케이션은 하나의 시스템으로 취급해야 한다.
이것은 분명히 문제가 될 수 있다. 피드백 속도를 저하시키지 않고 어떻게 이러한 의존성을 테스트 할 수 있을지 6장과 7장에서 다뤄본다.
4.2 목을 사용한 동작 검증
종종 목이 동작을 검증한다고 한다. 하지만 대부분의 경우 그렇지 않다.
목은 애플리케이션의 경계를 넘나드는 상호 작용을 검증할 때와 이러한 상호 작용의 사이드 이펙트가 외부 환경에서 보일 때만 동작과 관련이 있다.
* 대부분의 경우 실제 동작이 아닌 상호 작용을 검증하는데 사용된다는 것을 이야기 하고 싶은 것으로 보임.
fin.