ArchUnit은 Java 코드의 아키텍처를 검사하기 위한 오픈소스이다. 패키지와 클래스, 레이어와 슬라이스 간의 종속성, 순환 참조 등을 확인할 수 있다.
자바 바이트 코드를 분석해서 모든 클래스를 자바 코드 구조로 가져오는 방식으로 검증을 수행하는데, JUnit을 사용해서 아키텍처와 코딩 규칙을 테스트하는 것이 주요 목적이다.
ArchUnit으로 검증할 수 있는 것은 다음과 같다.
1. 패키지 의존성 검사
2. 클래스 의존성 검사
3. 패키지 및 클래스 포함 검사
4. 상속 검사
5. 어노테이션 검사
6. 레이어 검사
7. 사이클 검사
자세한 사용법은 공식 문서를 참조하도록 하고, 간단하게 어떤 방식으로 아키텍처를 검사할 수 있는지 확인해보자. 참고로 깃허브를 통해 예시도 제공해주고 있다.
testImplementation 'com.tngtech.archunit:archunit-junit5:1.0.0'
ArchUnit은 archunit-junit5-api, archunit-junit5-engine, archunit-junit5-engine-api 총 3가지의 모듈을 제공해주고 있다.
archunit-junit5-api에는 ArchUnit의 JUnit5 지원으로 테스트를 작성하는 사용자 API가 포함되어 있으며, archunit-junit5-engine에는 이러한 테스트를 실행하기 위한 런타임 엔진이 포함되어 있다. archunit-junit5-engine-api는 ArchUnit JUnit5 테스트 실행을 보다 상세하게 제어하고자 하는 도구, 특히 특정 규칙 필드를 실행하도록 ArchUnit Test Engine에 지시하는 데 사용할 수 있는 필드 선택기를 위한 API 코드를 포함한다.
ClassFileImporter 클래스를 사용해서 검증할 클래스들을 패키지 단위로 가져올 수 있다.
JavaClasses classes = new ClassFileImporter().importPackages("com.mycompany.myapp");
ArchRuleDefinition 클래스를 사용하여 제약 조건을 정의하고, 검증할 수 있다.
ArchRule myRule = classes()
.that().resideInAPackage("..service..")
.should().onlyBeAccessed().byAnyPackage("..controller..", "..service..");
myRule.check(importedClasses);
@AnalyzeClasses(packages = "com.mycompany.myapp")
public class MyArchitectureTest {
@ArchTest
public static final ArchRule myRule = classes()
.that().resideInAPackage("..service..")
.should().onlyBeAccessed().byAnyPackage("..controller..", "..service..");
}
검증할 클래스들을 가져오고, 제약 조건을 정의하고, 검증을 실행하는 단계를 어노테이션 기반으로 위와 같이 줄일 수 있다.
하지만 이는 DisplayName을 줄 수 없다는 점이 단점이다. 따라서 테스트의 이름을 부여해야 한다면 사용할 수 없다.
지금부터는 간단하게 패키지 의존성, 레이어 검사를 예시로 들어 작성해보고, ArchUnit이 제공하는 나머지 기능들은 필요한 공식 문서를 참조해서 작성하도록 하자.
현재 예제 프로젝트의 패키지 구조를 살펴보면 아래와 같이 되어있다.
기본적으로 controller -> service -> repository의 구조로 계층형 아키텍처를 구성하고 있고, domain은 모든 레이어에서 참조가 가능하며, domain이 각 레이어를 참조하지 않도록 설계가 되어있다.
따라서 이러한 룰을 적용시켜 패키지 의존성 검사를 수행해 볼 것이다.
@Test
@DisplayName("service 패키지는 controller 패키지에 의존하지 않는다.")
void serviceDependencyTest() {
JavaClasses classes = new ClassFileImporter().importPackages("com.example.applicationtest");
ArchRule serviceDependencyRule = noClasses().that().resideInAPackage("..service..")
.should().dependOnClassesThat().resideInAPackage("..controller..");
serviceDependencyRule.check(classes);
}
우선 검증할 클래스들을 가져와야 한다. 이는 ClassFileImporter를 통해 가져올 수 있다. 애플리케이션 최상단의 패키지 경로를 입력하자.
문법 자체는 정말 간단하다. 영어를 그대로 제약조건으로 만들 수 있다. 너무 많은 메서드를 제공하고 있기 때문에, 각각의 클래스에서 메서드가 어떤 것을 제약하는지를 확인해보는 것이 좋다.
있는 그대로 해석하면 "service라는 이름을 가진 패키지에 있는 어떠한 클래스도 controller라는 이름을 가진 패키지에 있는 클래스와 의존해선 안된다." 라는 뜻이 되겠다.
ArchRule의 check()를 통해 테스트를 돌려보면, 바로 제약 조건이 위배되었다고 뜬다.
java.lang.AssertionError: Architecture Violation [Priority: MEDIUM] - Rule 'no classes that reside in a package '..service..' should depend on classes that reside in a package '..controller..'' was violated (3 times):
Method <com.example.applicationtest.service.MemberService.save(com.example.applicationtest.controller.MemberSaveRequest)> calls method <com.example.applicationtest.controller.MemberSaveRequest.getAge()> in (MemberService.java:42)
Method <com.example.applicationtest.service.MemberService.save(com.example.applicationtest.controller.MemberSaveRequest)> calls method <com.example.applicationtest.controller.MemberSaveRequest.getName()> in (MemberService.java:41)
Method <com.example.applicationtest.service.MemberService.save(com.example.applicationtest.controller.MemberSaveRequest)> has parameter of type <com.example.applicationtest.controller.MemberSaveRequest> in (MemberService.java:0)
MemberService의 save() 메서드가 총 3번 해당 제약조건을 위배하고 있다고 한다. 한번 가서 확인해보자.
public Long save(MemberSaveRequest memberSaveRequest) {
Member member = Member.builder()
.name(memberSaveRequest.getName())
.age(memberSaveRequest.getAge())
.build();
memberRepository.save(member);
return member.getId();
}
save() 에서는 MemberSaveRequest에 의존하고 있었다. 따라서 메서드 파라미터 의존성, MemberSaveRequest의 메서드들 의존성 까지 해서 총 3번이 위배되었다고 표시해준 것이다. 이를 Service 전용 DTO로 분리해서 해결해보자.
@Getter
@Setter
@Builder
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class MemberSaveDto {
private String name;
private Integer age;
}
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class MemberSaveRequest {
private String name;
private Integer age;
public MemberSaveDto toDto() {
return MemberSaveDto.builder()
.name(name)
.age(age)
.build();
}
}
우선 위와 같이 MemberSaveRequest와 이를 Service로 전달하기 위한 MemberSaveDto를 만들었다.
public Long save(MemberSaveDto memberSaveDto) {
Member member = Member.builder()
.name(memberSaveDto.getName())
.age(memberSaveDto.getAge())
.build();
memberRepository.save(member);
return member.getId();
}
그리고 save()에서는 이제 더 이상 MemberSaveRequest에 의존하는 것이 아니라, MemberSaveDto에 의존하도록 수정했다.
@PostMapping("/members")
public Long memberSave(@RequestBody MemberSaveRequest memberSaveRequest) {
return memberService.save(memberSaveRequest.toDto());
}
컨트롤러에서 앞으로 MemberSaveRequest를 MemberSaveDto로 변환한 다음에 save() 메서드를 호출하게 된다.
이제 테스트가 정상적으로 통과되는 것을 확인할 수 있다.
위에 ArchRuleDefinition을 통해 제약 조건을 정의할 때, resideInAPackage()에는 String 타입의 packageIdentifier 매개변수가 선언되어 있다. 이는 PackageMatcher 클래스를 통해 특정한 패키지를 식별해주는 역할을 수행하는데, AspectJ 문법과 유사하게 적용할 수 있다.
PackageMatcher에 설명되어 있는 예시를 보면 쉽게 이해가 가능하다.
1. ..pack..
- 매칭: a.pack, a.pack.b, a.b.pack.c.d
- 매칭X: a.packa.b
2. *.pack.*
- 매칭: a.pack.b
- 매칭X: a.pack.b.c
3. ..*pack*..
- 매칭: a.apackb.b
4. *.*.pack*..
- 매칭: a.b.packfix.c.d
- 매칭X: a.packfix.b, a.b.prepack.c
.
은 여러 패키지를 의미한다. 따라서 ..pack.. 으로 작성한다면 앞 뒤에 몇 개의 패키지가 오든 정확히 pack이라는 이름을 가진 패키지를 식별하게 된다.
*
은 패키지 경로로 따지면 하나의 패키지를 의미하고, 이름에 사용한다면 와일드카드처럼 여러 문자를 의미한다. 따라서 *.pack*.. 으로 작성한다면, 앞에는 단 하나의 상위 패키지만, 패키지 명은 pack으로 시작, 하위에는 여러 패키지가 와도 상관 없는 조건으로 패키지를 식별하게 된다.
어떠한 패키지가 클래스와 의존 관계를 가지고 있느냐를 확인할 때에는 dependOnClassesThat() 메서드를 사용하는 것이 좋다. 이 메서드가 보다 넓은 범위의 제약을 설정하기 때문이다.
dependOnClassesThat()은 필드로 가지고 있거나, 메서드 파라미터로 가지고 있거나, 상속받는 경우에도 제약 위반으로 판단한다.
반면 onlyBeAccessed() 필드에 접근하거나, 메서드를 호출하거나 등의 접근 자체만을 제약 위반으로 판단한다. 따라서 제약 조건을 설정할 패키지 내 클래스들의 실제 메서드를 호출을 하지 않거나, 필드에 접근하지 않으면서 해당 클래스들를 필드로 가지고만 있다면, 즉 연관 관계만을 맺고 있다면 이는 검증이 되지 않는다.
예를 들어, Service 패키지의 어떠한 DTO가 Controller 패키지의 어떠한 Request 객체를 자신의 필드로 가지고 있다고 해보자. 단, 이 때 DTO는 Request의 어떠한 필드에도 접근하지 않고, 어떠한 메서드도 호출하지 않는다고 해보자.
이럴 경우 객체 간 연관 관계가 맺어져 있는 것은 사실이니 dependOnClassesThat() 메서드는 이를 위반으로 판단한다. 반면, onlyBeAccessed() 메서드는 실제 접근은 하지 않았으니 이를 위반으로 판단하지 않는다.
따라서 완벽하게 의존성을 검증하기 위해서는 dependOnClassesThat()이 보다 더 넓은 범위를 검증하기 때문에 이를 사용하는 것이 좋아보인다.
이 두 메서드는 ClassesShould 클래스에 정의되어 있는 메서드인데, 굉장히 많은 메서드를 제공하고 있으니 직접 클래스 파일을 디컴파일해서 확인해보는 것을 권장한다.
@Test
@DisplayName("repository 패키지는 service, controller 패키지에 의존하지 않는다.")
void repositoryDependencyTest() {
JavaClasses classes = new ClassFileImporter().importPackages("com.example.applicationtest");
ArchRule repositoryDependencyRule = noClasses().that().resideInAPackage("..repository..")
.should().dependOnClassesThat().resideInAnyPackage("..controller..", "..service");
repositoryDependencyRule.check(classes);
}
만약 여러 패키지를 조건으로 추가해야 한다면 resideInAnyPackage()를 사용하면 된다.
@Test
@DisplayName("4. service 패키지는 controller, service 패키지에서만 의존이 가능하다.")
void serviceAccessTest() {
JavaClasses classes = new ClassFileImporter().importPackages("com.example.applicationtest");
ArchRule serviceAccessRule = classes().that().resideInAnyPackage("..service..")
.should().onlyHaveDependentClassesThat().resideInAnyPackage("..controller..", "..service..");
serviceAccessRule.check(classes);
}
영어 문장을 그대로 해석하면 된다. "service 패키지에 있는 클래스들은 오직 controller, service 패키지에 있는 클래스들에서만 의존을 가진다." 즉, controller, service에 있는 클래스들만 service에 의존할 수 있다는 소리가 되겠다.
이 역시 이전에 accessClassesThat(), dependOnClassesThat() 처럼 의존과 접근을 나누어서 메서드를 제공한다. 만약 조금 더 좁은 범위인 접근을 기준으로 하려면 아래와 같이 작성하면 된다.
@Test
@DisplayName("4. service 패키지는 controller, service 패키지에서만 의존이 가능하다.")
void serviceAccessTest() {
JavaClasses classes = new ClassFileImporter().importPackages("com.example.applicationtest");
ArchRule serviceAccessRule = classes().that().resideInAnyPackage("..service..")
.should().onlyBeAccessed().byClassesThat().resideInAnyPackage("..controller..", "..service..");
serviceAccessRule.check(classes);
}
onlyBeAccessed() 메서드를 제공하고, 이는 영어 문법 상 뒤에 어떠한 클래스로부터 접근이 가능한지를 명시하는 byClassesThat() 이라는 메서드가 필요하다.
사실 웬만한 스프링 애플리케이션은 Layered Architecture를 사용해서 Controller -> Service -> Repository의 구조로 설계를 한다. 만약 위에 패키지 의존성 검사로 이러한 구조를 검증한다면, 고려해야할 테스트 케이스가 많아진다.
따라서 ArchUnit에서는 이러한 레이어도 검사를 할 수 있도록 API를 제공해준다.
@Test
@DisplayName("메인 레이어는 Controller -> Service -> Repository 로 구성되어 있다.")
void layerTest() {
JavaClasses classes = new ClassFileImporter().importPackages("com.example.applicationtest");
layeredArchitecture()
.consideringAllDependencies()
.layer("Controller").definedBy("..controller..")
.layer("Service").definedBy("..service..")
.layer("Repository").definedBy("..repository")
.whereLayer("Controller").mayNotBeAccessedByAnyLayer()
.whereLayer("Service").mayOnlyBeAccessedByLayers("Controller")
.whereLayer("Repository").mayOnlyBeAccessedByLayers("Service")
.check(classes);
}
각 레이어를 정의하고, 해당 레이어에 대해 조건을 걸어주면 된다.
레이어를 정의할 때 의존성 체크를 할 범위를 지정하는 설정할 수 있는데, 총 3가지 메서드를 제공해주고 있다. 자세한 설명은 Architectures 클래스를 직접 찾아보도록 하자.
모든 객체와의 종속성을 고려한다. 심지어 Object 클래스와의 종속성도 고려하기 때문에 추후에 설명할 mayOnlyAccessLayers() 메서드를 사용한다면 Object, Integer, String 등등의 객체와도 종속성을 검사하게 된다. 따라서 mayOnlyAccessLayers()와는 현실적으로 사용할 수 없는 옵션이다.
하지만 모든 종속성을 고려하기 때문에 corner case에 대해 높은 보안 수준을 제공한다. 예를 들어 mayOnlyBeAccessedByLayers()를 통해 오직 정의된 레이어들 만에서 접근할 수 있도록 설정할 경우, 여러 계층으로 나누어진 구조라고 하더라도 어떠한 종속성이는 모두 잡아낸다.
특정 패키지와의 종속성만 고려하는 종속성 설정을 정의할 수 있다. 지정한 패키지 외부의 모든 종속성은 무시한다. 따라서 Object, String과 같은 Java.lang 패키지들과 종속성도 확인하지 않으며, 설정만 잘 한다면 corner case로 방지할 수 있는, 균형을 잡을 수 있는 메서드이다.
계층 간의 종속성만 고려하는 종속성 설정을 정의할 수 있습니다. 정의된 계층 외부에 있는 모든 종속성은 무시됩니다. 따라서 Object, String과 같은 Java.lang 패키지들과 종속성을 확인하지 않는다. 다만 설정한 계층 간의 종속성만을 파악하기 때문에 corner case에는 취약할 수 있다.
위의 코드의 whereLayer() 메서드를 통해 조건을 걸 계층을 명시하고, 어떠한 조건을 줄 것인지 메서드를 제공해주고 있다.
이는 총 4가지 메서드가 정의되어 있으며, 역시 자세한 내용은 Architectures 클래스를 직접 확인해보자. 사실 영어 문장 그대로 해석하면 돼서 딱히 어렵진 않다.
조건을 설정한 레이어가 모든 레이어로부터 접근되지 않아야 할 경우 사용한다.
조건을 설정한 레이어가 어떠한 레이어에도 접근하지 않을 경우 사용한다.
인자로 넘겨준 레이어만이 해당 레이어에 접근할 수 있음을 설정할 경우 사용한다.
조건을 설정한 레이어가 오직 인자로 넘겨준 레이어에만 접근할 수 있음을 설정할 경우 사용한다.