이전에 작성했던 테스트 관련 시리즈 에서는 주로 애플리케이션의 기능 검증과 핵심 비즈니스 영역의 품질을 검증하기 위한 관점에서 여러 가지 방안들을 다뤘습니다.
최근에는 애플리케이션의 기능적인 품질을 보장하는 것도 중요하지만 동료들과 협업하면서 프로젝트별로 사전에 정의한 레이어드 아키텍처의 의존성 규칙이나 패키지 구조와 같은 제약사항 즉, 아키텍처 규칙을 일관되게 유지하는 것 또한 매우 중요하다고 느꼈는데요.
이런 고민을 해결하기 위해 여러 자료나 책을 살펴보던 중, 소프트웨어의 아키텍처 규칙을 테스트 코드로 검증하여 설계 품질을 보장할 수 있는 ArchUnit이라는 테스트 라이브러리를 알게 되었어요.
이번 글에서는 ArchUnit을 도입하여 애플리케이션의 아키텍처 테스트를 수행하는 방법을 알아보고, 실제로 적용해보는 과정을 공유하고자 합니다.
ArchUnit은 Java로 개발된 코드의 아키텍처를 검증하기 위한 무료 테스트 라이브러리입니다. ArchUnit을 통해 패키지나 클래스, 레이어와 슬라이스 간의 종속성을 검사하고 순환 종속성 여부를 확인할 수 있다고 하네요. 즉 사전에 정의된 아키텍처 규칙을 코드로 명세하여 테스트할 수 있게 도와주는 라이브러리라고 보면 되겠네요.
ArchUnit 공식 사이트에서는 아키텍처 테스트 시나리오를 총 7가지를 제공한다고 소개하고 있어요.
- 패키지 종속성 검사
- 클래스 종속성 검사
- 클래스 및 패키지 포함 검사
- 상속 검사
- 주석 검사
- 레이어 검사
- 사이클 체크
이전에 개발하고 있던 사이드 프로젝트를 대상으로 아키텍처 테스트 코드를 짜보려 하는데요. 해당 프로젝트의 아키텍처 구조를 살펴볼게요.
해당 프로젝트의 레이어드 아키텍처 구조는 프레젠테이션(Presentation) 계층, 비즈니스(Business) 계층, 도메인(Domain) 계층, 인프라스트럭처(Infrastructure) 계층으로 구분되어 있는데요. 여기서는 공통적으로 모든 영역을 아우르는 전역적인 공통 아키텍처 규칙과 프레젠테이션 계층의 Controller 규칙 및 비즈니스 계층의 Service 규칙까지 총 3가지의 아키텍처 규칙을 소개하겠습니다.
controller.port
패키지의 service 인터페이스를 의존해야 한다.ServiceImpl
구현 클래스를 직접 의존하면 안 된다.Controller
로 명명되어야 한다.controller.port
패키지의 서비스 인터페이스는 Service
로 명명되어야 하며, Interface 타입이어야 한다.@RestController
어노테이션이 선언되어야 한다.service.port
패키지의 infrastructure(외부 시스템) 인터페이스를 의존해야 한다.ServiceImpl
로 명명되어야 한다.service.port
패키지의 인프라스트럭처 인터페이스는 Interface 타입이어야 한다.controller.port
패키지의 Service 인터페이스를 구현해야 한다.@Service
어노테이션이 선언되어야 한다.위 3가지 규칙을 도식화로 자세히 살펴보면 아래와 같을 겁니다.
이제 앞에서 살펴본 3가지 규칙인 공통 규칙, 프레젠테이션 계층 규칙, 비즈니스 계층 규칙을 ArchUnit을 활용하여 테스트로 작성해볼게요.
먼저 ArchUnit을 사용하기 위한 전용 JUnit5 테스트 라이브러리 의존성을 추가합니다.
testImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0'
아키텍처 테스트시 원하는 패키지 경로를 지정하여 테스트할 수 있어요. 테스트 클래스 상단부에 @AnalyzeClasses
어노테이션을 선언해주면 됩니다. 저는 최상위 패키지의 bucket 패키지만을 대상으로 아키텍처 테스트를 진행하겠다는 설정과 아키텍처 테스트시 프로덕션 코드만을 검증해야 하기에 test 패키지의 테스트 클래스들은 제외하겠다는 설정을 추가했어요.
@AnalyzeClasses(
packages = "com.xxx.xxx.bucket",
importOptions = ImportOption.DoNotIncludeTests.class
)
프로젝트의 모든 패키지 내에서 공통적으로 지켜야 할 아키텍처 규칙을 테스트하기 위한 테스트 클래스 GlobalArchitectureTest
를 만듭니다.
@AnalyzeClasses(
packages = "com.xxx.xxx.bucket",
importOptions = ImportOption.DoNotIncludeTests.class
)
public class GlobalArchitectureTest {
private static final String CONTROLLER_LAYER = "Controller";
private static final String SERVICE_LAYER = "Service";
private static final String INFRA_LAYER = "Infrastructure";
private static final String CONTROLLER_PACKAGE = "..controller";
private static final String SERVICE_PACKAGE = "..service";
private static final String INFRA_PACKAGE = "..infrastructure";
private static final String GLOBAL_PACKAGE = "com.xxx.xxx.(*)..";
@ArchTest
static final LayeredArchitecture 레이어검사_모든_계층의_의존흐름은_순방향_이어야한다 =
layeredArchitecture()
.consideringAllDependencies()
.layer(CONTROLLER_LAYER).definedBy(CONTROLLER_PACKAGE)
.layer(SERVICE_LAYER).definedBy(SERVICE_PACKAGE)
.layer(INFRA_LAYER).definedBy(INFRA_PACKAGE)
.whereLayer(CONTROLLER_LAYER).mayNotBeAccessedByAnyLayer()
.whereLayer(SERVICE_LAYER).mayOnlyBeAccessedByLayers(CONTROLLER_LAYER)
.whereLayer(INFRA_LAYER).mayOnlyBeAccessedByLayers(SERVICE_LAYER);
@ArchTest
static final ArchRule 필드주입검사_모든_클래스는_필드주입을_사용하지_않는다 =
fields()
.that().areDeclaredInClassesThat().resideInAPackage(GLOBAL_PACKAGE)
.should().notBeAnnotatedWith(Autowired.class);
@ArchTest
static final ArchRule 순환참조검사_모든_클래스는_순환_의존성을_가지면_안_된다 =
slices()
.matching(GLOBAL_PACKAGE)
.should().beFreeOfCycles();
}
공통 아키텍처 규칙을 검사하는 GlobalArchitectureTest를 자세히 살펴볼게요.
private static final String CONTROLLER_LAYER = "Controller";
private static final String SERVICE_LAYER = "Service";
private static final String INFRA_LAYER = "Infrastructure";
private static final String CONTROLLER_PACKAGE = "..controller";
private static final String SERVICE_PACKAGE = "..service";
private static final String INFRA_PACKAGE = "..infrastructure";
@ArchTest
static final LayeredArchitecture 레이어검사_모든_계층의_의존흐름은_순방향_이어야한다 =
layeredArchitecture()
.consideringAllDependencies()
.layer(CONTROLLER_LAYER).definedBy(CONTROLLER_PACKAGE)
.layer(SERVICE_LAYER).definedBy(SERVICE_PACKAGE)
.layer(INFRA_LAYER).definedBy(INFRA_PACKAGE)
.whereLayer(CONTROLLER_LAYER).mayNotBeAccessedByAnyLayer()
.whereLayer(SERVICE_LAYER).mayOnlyBeAccessedByLayers(CONTROLLER_LAYER)
.whereLayer(INFRA_LAYER).mayOnlyBeAccessedByLayers(SERVICE_LAYER);
계층 검사 코드를 보면 컨트롤러 계층, 서비스 계층, 인프라 계층을 정의해주고 프레젠테이션 계층의 Controller는 최상위 계층이기 때문에 다른 계층으로부터 접근할 수 없도록 검증해요. 그리고 비즈니스 계층의 Service는 Controller를 통해서만 접근되어야 하는 규칙과, 인프라 계층은 Service를 통해서만 접근되어야 한다는 규칙을 검증해요.
private static final String GLOBAL_PACKAGE = "com.xxx.xxx.(*)..";
@ArchTest
static final ArchRule 필드주입검사_모든_클래스는_필드주입을_사용하지_않는다 =
fields()
.that().areDeclaredInClassesThat().resideInAPackage(GLOBAL_PACKAGE)
.should().notBeAnnotatedWith(Autowired.class);
필드 주입 검사도 살펴볼까요? 프로젝트 패키지 내 모든 클래스가 @Autowired
어노테이션을 통한 필드 주입을 사용하지 않는지를 검증합니다.
private static final String GLOBAL_PACKAGE = "com.xxx.xxx.(*)..";
@ArchTest
static final ArchRule 순환참조검사_모든_클래스는_순환_의존성을_가지면_안_된다 =
slices()
.matching(GLOBAL_PACKAGE)
.should().beFreeOfCycles();
순환 참조 검사도 간단합니다. 프로젝트 패키지 내 모든 클래스가 순환 의존성을 가지지 않는지 검증해요.
자, 이제 프레젠테이션 계층의 Controller들이 정해진 아키텍처 규칙을 잘 지키도록 검증하는 ControllerArchitectureTest
테스트 클래스를 생성해볼게요.
@AnalyzeClasses(
packages = "com.xxx.xxx.bucket",
importOptions = ImportOption.DoNotIncludeTests.class
)
public class ControllerArchitectureTest {
private static final String CONTROLLER_PACKAGE = "..controller";
private static final String CONTROLLER_PORT_PACKAGE = "..controller.port";
private static final String SERVICE_PACKAGE = "..service";
private static final String CONTROLLER_NAME = "Controller";
private static final String SERVICE_INTERFACE_NAME = "Service";
@ArchTest
static final ArchRule 패키지종속성검사_Controller는_controller_port_패키지의_서비스_인터페이스를_의존해야_한다 =
classes()
.that().resideInAPackage(CONTROLLER_PACKAGE)
.should().dependOnClassesThat().resideInAPackage(CONTROLLER_PORT_PACKAGE);
@ArchTest
static final ArchRule 패키지종속성검사_Controller는_service_패키지의_구현_클래스를_직접_의존하면_안_된다 =
classes()
.that().resideInAPackage(CONTROLLER_PACKAGE)
.should().onlyDependOnClassesThat()
.resideOutsideOfPackage(SERVICE_PACKAGE);
@ArchTest
static final ArchRule 패키지구조검사_controller_패키지_내_클래스_이름은_Controller로_명명되어야_한다 =
classes()
.that().resideInAPackage(CONTROLLER_PACKAGE)
.should().haveSimpleNameEndingWith(CONTROLLER_NAME);
@ArchTest
static final ArchRule 패키지구조검사_controller_port_패키지의_서비스_인터페이스는_Service로_명명되어야_하며_Interface_타입이어야한다 =
classes()
.that().resideInAPackage(CONTROLLER_PORT_PACKAGE)
.should().haveSimpleNameEndingWith(SERVICE_INTERFACE_NAME)
.andShould().beInterfaces()
.andShould().onlyBeAccessed().byClassesThat().resideInAPackage(CONTROLLER_PACKAGE);
@ArchTest
static final ArchRule 어노테이션검사_controller_패키지의_클래스는_반드시_RestController_어노테이션이_선언되어야_한다 =
classes()
.that().resideInAPackage(CONTROLLER_PACKAGE)
.should().beAnnotatedWith(RestController.class);
}
Controller 아키텍처 테스트는 앞에서 정의한 대로 5가지 테스트 케이스가 나왔네요.
private static final String CONTROLLER_PACKAGE = "..controller";
private static final String CONTROLLER_PORT_PACKAGE = "..controller.port";
@ArchTest
static final ArchRule 패키지종속성검사1_Controller는_controller_port_패키지의_서비스_인터페이스를_의존해야_한다 =
classes()
.that().resideInAPackage(CONTROLLER_PACKAGE)
.should().dependOnClassesThat().resideInAPackage(CONTROLLER_PORT_PACKAGE);
프레젠테이션 계층의 Controller가 port 패키지의 서비스 인터페이스를 의존하는지 검증해요.
private static final String CONTROLLER_PACKAGE = "..controller";
private static final String SERVICE_PACKAGE = "..service";
@ArchTest
static final ArchRule 패키지종속성검사2_Controller는_service_패키지의_구현_클래스를_직접_의존하면_안_된다 =
classes()
.that().resideInAPackage(CONTROLLER_PACKAGE)
.should().onlyDependOnClassesThat()
.resideOutsideOfPackage(SERVICE_PACKAGE);
그리고 비즈니스 관심사를 담당하는 실제 구현체인 service 패키지로 직접 의존하지 않는지를 검증합니다.
private static final String CONTROLLER_PACKAGE = "..controller";
private static final String CONTROLLER_NAME = "Controller";
@ArchTest
static final ArchRule 패키지구조검사1_controller_패키지_내_클래스_이름은_Controller로_명명되어야_한다 =
classes()
.that().resideInAPackage(CONTROLLER_PACKAGE)
.should().haveSimpleNameEndingWith(CONTROLLER_NAME);
Controller의 역할을 담당하는 클래스가 실제로 클래스명 끝에 Controller
라는 이름으로 끝나는지도 확인해줍니다.
private static final String CONTROLLER_PACKAGE = "..controller";
private static final String CONTROLLER_PORT_PACKAGE = "..controller.port";
private static final String SERVICE_INTERFACE_NAME = "Service";
@ArchTest
static final ArchRule 패키지구조검사2_controller_port_패키지의_서비스_인터페이스는_Service로_명명되어야_하며_Interface_타입이어야한다 =
classes()
.that().resideInAPackage(CONTROLLER_PORT_PACKAGE)
.should().haveSimpleNameEndingWith(SERVICE_INTERFACE_NAME)
.andShould().beInterfaces()
.andShould().onlyBeAccessed().byClassesThat().resideInAPackage(CONTROLLER_PACKAGE);
Controller가 의존하는 같은 패키지의 port 패키지에 존재하는 서비스 인터페이스가 정말로 Interface
타입인지도 체크합니다.
private static final String CONTROLLER_PACKAGE = "..controller";
@ArchTest
static final ArchRule 어노테이션검사_controller_패키지의_클래스는_반드시_RestController_어노테이션이_선언되어야_한다 =
classes()
.that().resideInAPackage(CONTROLLER_PACKAGE)
.should().beAnnotatedWith(RestController.class);
Controller 클래스에 @RestController
어노테이션이 선언되어 있는지 검증합니다.
비즈니스 계층의 핵심 로직을 가지는 서비스 클래스에 대한 아키텍처 규칙을 검증하는 ServiceArchitectureTest
테스트 클래스를 생성합니다.
@AnalyzeClasses(
packages = "com.xxx.xxx.bucket",
importOptions = ImportOption.DoNotIncludeTests.class
)
public class ServiceArchitectureTest {
private static final String SERVICE_PACKAGE = "..service";
private static final String SERVICE_PORT_PACKAGE = "..service.port";
private static final String CONTROLLER_PORT_PACKAGE = "..controller.port";
private static final String SERVICE_IMPLEMENT_NAME = "ServiceImpl";
@ArchTest
static final ArchRule 패키지종속성검사_Service는_service_port_패키지의_infrastructure_인터페이스를_의존해야_한다 =
classes()
.that().resideInAPackage(SERVICE_PACKAGE)
.should().dependOnClassesThat().resideInAPackage(SERVICE_PORT_PACKAGE);
@ArchTest
static final ArchRule 패키지구조검사_service_패키지_내_구현_클래스_이름은_ServiceImpl로_명명되어야_한다 =
classes()
.that().resideInAPackage(SERVICE_PACKAGE)
.should().haveSimpleNameEndingWith(SERVICE_IMPLEMENT_NAME);
@ArchTest
static final ArchRule 패키지구조검사_service_port_패키지의_인프라스트럭처_인터페이스는_Interface_타입이어야_한다 =
classes()
.that().resideInAPackage(SERVICE_PORT_PACKAGE)
.should().beInterfaces();
@ArchTest
static final ArchRule 구현검사_Service_클래스는_controller_port_패키지의_Service_인터페이스를_구현해야_한다 =
classes()
.that().resideInAPackage(SERVICE_PACKAGE)
.and().areNotInterfaces().should().implement(resideInAPackage(CONTROLLER_PORT_PACKAGE));
@ArchTest
static final ArchRule 어노테이션검사_service_패키지의_구현_클래스는_반드시_Service_어노테이션이_선언되어야_한다 =
classes()
.that().resideInAPackage(SERVICE_PACKAGE)
.should().beAnnotatedWith(Service.class);
}
의도한 건 아닌데, Service 아키텍처 테스트 케이스도 Controller 아키텍처 테스트 케이스와 마찬가지로 5가지 케이스가 나왔네요. 쭉쭉 살펴봅니다.
private static final String SERVICE_PACKAGE = "..service";
private static final String SERVICE_PORT_PACKAGE = "..service.port";
@ArchTest
static final ArchRule 패키지종속성검사_Service는_service_port_패키지의_infrastructure_인터페이스를_의존해야_한다 =
classes()
.that().resideInAPackage(SERVICE_PACKAGE)
.should().dependOnClassesThat().resideInAPackage(SERVICE_PORT_PACKAGE);
비즈니스 계층의 Service가 같은 port 패키지 내의 외부 시스템과 관련된 관심사를 담당하는 인터페이스에 대한 종속성을 가지는지 테스트합니다.
private static final String SERVICE_PACKAGE = "..service";
private static final String SERVICE_IMPLEMENT_NAME = "ServiceImpl";
@ArchTest
static final ArchRule 패키지구조검사1_service_패키지_내_구현_클래스_이름은_ServiceImpl로_명명되어야_한다 =
classes()
.that().resideInAPackage(SERVICE_PACKAGE)
.should().haveSimpleNameEndingWith(SERVICE_IMPLEMENT_NAME);
프레젠테이션 계층의 인터페이스에 대한 선언부를 구현하여 실제 비즈니스 로직을 다루는 Service 클래스가 service 패키지 안에서 ServiceImpl
이라는 이름을 가지는지 확인합니다.
private static final String SERVICE_PORT_PACKAGE = "..service.port";
@ArchTest
static final ArchRule 패키지구조검사2_service_port_패키지의_인프라스트럭처_인터페이스는_Interface_타입이어야_한다 =
classes()
.that().resideInAPackage(SERVICE_PORT_PACKAGE)
.should().beInterfaces();
Service가 참조하는 필드인 외부 시스템 관련 인프라 인터페이스가 실제로 Interface
타입인지 검증해요.
private static final String SERVICE_PACKAGE = "..service";
private static final String CONTROLLER_PORT_PACKAGE = "..controller.port";
@ArchTest
static final ArchRule 구현검사_Service_클래스는_controller_port_패키지의_Service_인터페이스를_구현해야_한다 =
classes()
.that().resideInAPackage(SERVICE_PACKAGE)
.and().areNotInterfaces().should().implement(resideInAPackage(CONTROLLER_PORT_PACKAGE));
그리고 프레젠테이션 계층의 controller.port 패키지에 선언했던 인터페이스를 본 클래스가 정말로 구현하고 있는지 확인합니다.
private static final String SERVICE_PACKAGE = "..service";
@ArchTest
static final ArchRule 어노테이션검사_service_패키지의_구현_클래스는_반드시_Service_어노테이션이_선언되어야_한다 =
classes()
.that().resideInAPackage(SERVICE_PACKAGE)
.should().beAnnotatedWith(Service.class);
마지막으로 Service 클래스에 @Service
어노테이션이 선언되어 있는지 검증합니다.
이렇게 ArchUnit을 활용한 아키텍처 테스트를 통해 여러가지를 느낄 수 있었는데요. 여러가지 이점도 많지만, 단점도 분명히 존재할 수 있겠다 싶었어요.
협업하며 시간의 흐름에 따라 실제 아키텍처 문서와 아키텍처가 반영된 코드간의 괴리가 발생할 가능성이 있는데, ArchUnit을 통해 아키텍처 규칙을 명시적으로 기록할 수 있어서 아키텍처 구조에 대한 형상과 실제 협업에서 활용되는 문서와의 일관성을 유지할 수 있을 것 같다는 생각이 들었어요. 아키텍처 준수 여부를 문서와 함께 테스트 코드로도 자동화할 수 있기 때문에 일종의 살아있는 문서의 역할을 수행할 수 있을 것 같네요.
위 코드에서 잔뜩 보셨겠지만, ArchUnit의 검증 구문은 플루언트 인터페이스(Fluent Interface) 스타일로 작성되어 있어서 기존의 AssertJ처럼 메서드 체이닝을 통해 일반적인 자연어를 읽는 정도로 가독성이 높은 것 같아요.
스프링 컨텍스트를 로드하지 않고 실행되는 ArchUnit의 경량 테스트 환경 내에서 테스트가 수행되니 간단하고 빠른 속도로 아키텍처 규칙을 테스트할 수 있었어요. 저는 평소 소형 테스트 방식을 선호하기 때문에 만족하면서 테스트 코드를 작성했어요.
프레젠테이션 계층에서는 Controller 클래스와 Service 인터페이스, 그리고 비즈니스 계층에서는 프레젠테이션 계층의 Service 인터페이스를 구현하는 ServiceImpl 클래스 구조로 기획했는데요. 이 패키지 구조 규칙은 패키지 위치 및 클래스명을 강제하게 되어요. 개인이나 팀에 따라 Controller나 Service라는 네이밍을 강요하는 것은 상황에 따라서는 좋은 규칙이 아니라고 생각하거든요. 아키텍처 규칙을 명확하게 정의해서 일관성과 품질을 유지하는 것도 중요하지만 되려 유연성이 떨어져서 확장성이나 창의성 측면에서는 제한되는 사항이 생길 수도 있을 것 같아요.
이 밖에도 헥사고날 패턴과 같은 복잡한 아키텍처 구조 형태에서도 아키텍처 테스트를 통해 아키텍처 규칙을 강제화할 수 있고, 새로운 팀원에게 빠르게 구조를 리뷰할 수 있도록 도울 수도 있지 않을까 싶어요.
애플리케이션 테스트와 관련된 기술에 대해서 관심이 많은 터라 이번 글은 작성하는데 재미를 많이 붙였어요. 뜬금없는 TMI를 덧붙이자면, 요즘 Kubernetes 관련된 DevOps, SRE 영역의 글도 작성해보려 하는데 엄두가 안나네요. 앞으로 기회가 되면 조금씩 준비해보려 합니다!
⏳ 이번 글은 3일동안 5시간 30분을 투자하여 작성했습니다.