들어가며
프로젝트를 진행할 때 가장 먼저 하는 일 중 하나는 코딩 컨벤션을 정하는 일입니다. 작게는 코드 포맷부터 변수명, 메서드명, 그리고 더 나아가 전체적인 폴더구조와 아키텍처까지 다양한 컨벤션을 정하고 프로젝트를 시작합니다. 이러한 컨벤션들은 대개 문서화로 남겨두게 되죠.
팀원들이 모두 이러한 컨벤션에 잘 맞춰서 개발한다면 좋겠지만, 실제로는 이해도의 차이가 있기 마련입니다. 특히 신규 팀원이 들어온 경우에는 이런 문서를 온전히 이해하고 적용하기가 쉽지 않습니다. 그래서 보통 코드 리뷰라는 과정을 거치면서 이를 맞춰나가게 되는데, 이 과정에서 의사소통 비용이 발생하게 됩니다.
코드 리뷰에서는 비즈니스 로직이 잘 설계되었는지를 중점적으로 봐야 한다고 생각합니다. 자잘한 컨벤션까지 일일이 지적하는 것은 여간 까다로운 일이 아닙니다. 사실 IDE 포매터나 Checkstyle을 통해 기본적인 코딩 컨벤션은 어느 정도 검증하고 자동으로 보정할 수 있습니다. 심지어 husky와 함께 커밋 메시지 컨벤션까지도 검증할 수 있죠.
그런데 네이밍 컨벤션이나 아키텍처, 레이어 컨벤션은 어떻게 보장할 수 있을까요? 이런 것들을 자동으로 검증할 방법은 없을까요? 아키텍처 전반의 품질을 지속적으로 보장하면서도 개발자와 리뷰어의 부담을 줄일 수 있는 방법이 필요했어요.
이 글에서는 Java 프로젝트에서 아키텍처 규칙을 코드로 검증할 수 있게 해주는 ArchUnit 라이브러리를 소개하고, 실제 프로젝트에 적용한 사례를 공유하려고 합니다.
컨벤션의 중요성과 현실적인 한계
컨벤션의 중요성
코딩 컨벤션은 단순히 코드를 예쁘게 작성하기 위한 것이 아닙니다. 컨벤션은 팀원 간의 소통을 원활하게 하고, 코드 품질과 일관성을 일정 수준 이상으로 유지하며, 신규 팀원의 적응을 돕는 중요한 역할을 합니다. 특히 아키텍처 관련 컨벤션은 프로젝트의 유지보수성과 확장성에 직접적인 영향을 미치기 때문에 더욱 중요합니다.
기존 방식의 한계
보통 컨벤션은 다음과 같은 방식으로 관리됩니다.
- 문서화: README나 Wiki에 컨벤션을 상세히 기록
- 코드 리뷰: PR을 통해 컨벤션 준수 여부 확인
- 포맷팅 도구: 코드 스타일 자동 포맷팅
이 중 코드 리뷰는 비즈니스 로직 검토, 알고리즘 효율성 체크 등 더 중요한 사항에 집중해야 하는데, 자잘한 컨벤션 위반까지 확인하는 것은 리뷰어에게 큰 부담이 됩니다. 또한 코드 리뷰는 사람이 하는 일이다 보니 컨벤션 위반을 놓칠 가능성도 있어요.
IDE의 포매터나 CheckStyle 같은 도구는 들여쓰기, 줄 바꿈 등의 스타일 관련 컨벤션은 잘 검사하지만, 아키텍처나 레이어 간 의존성 같은 고수준의 규칙은 검사하기 어렵습니다. 그렇다면 이런 높은 수준의 컨벤션은 어떻게 자동화할 수 있을까요? 이럴 때 필요한 것이 바로 ArchUnit입니다.
ArchUnit이란?
ArchUnit은 Java 코드의 아키텍처를 테스트할 수 있는 오픈소스 라이브러리입니다. 일반적인 단위 테스트처럼 작성할 수 있으며, JUnit과 같은 표준 테스트 프레임워크와 함께 사용할 수 있습니다.
ArchUnit의 주요 기능
ArchUnit은 다음과 같은 다양한 아키텍처 규칙을 검증할 수 있습니다.
- 패키지와 클래스의 의존성 검사: 특정 패키지나 클래스가 다른 패키지나 클래스에 의존하는지 검사할 수 있습니다.
- 레이어 아키텍처 검사: 애플리케이션 레이어(예: Controller, Service, Repository)가 올바르게 의존하고 있는지 검사할 수 있습니다.
- 클래스 네이밍 규칙 검사: 클래스 이름이 특정 패턴을 따르는지 검사할 수 있습니다.
- 애노테이션 사용 검사: 특정 클래스나 메서드에 필요한 애노테이션이 있는지 검사할 수 있습니다.
- 순환 참조 검사: 패키지나 클래스 간의 순환 참조가 있는지 검사할 수 있습니다.
- 코딩 컨벤션 검사: 팀에서 정한 코딩 컨벤션을 준수하는지 검사할 수 있습니다.
ArchUnit의 동작 원리
ArchUnit은 JVM 바이트코드(.class 파일)를 직접 읽어서 분석합니다. 이 과정에서 Java 바이트코드를 조작하고 분석할 수 있는 ASM 라이브러리를 사용합니다. 이를 활용해 클래스, 메서드, 필드 간의 의존 관계와 애노테이션 등의 메타데이터를 추출하게 됩니다.
예를 들어, ArchUnit이 다음과 같은 검증을 수행할 때,
noClasses().that().resideInPackage("..service..")
.should().dependOnClassesThat().resideInPackage("..controller..")
내부적으로는 이런 일이 벌어집니다.
- 지정된 패키지에서 모든 클래스 파일을 로드
- 각 클래스의 바이트코드를 파싱해 의존성 그래프 구축
- service 패키지의 클래스들이 controller 패키지의 클래스를 참조하는지 확인
- 위반 사항이 있으면 상세한 위치 정보와 함께 보고
특히 좋은 점은 클래스 파일을 직접 읽어들이기 때문에 실행 중인 애플리케이션이나 런타임 환경에 의존하지 않고도 정적 분석이 가능하다는 겁니다. 즉, 애플리케이션을 실행하지 않고도 아키텍처를 검증할 수 있게 되고, 덕분에 테스트는 매우 빠른 속도로 이루어집니다.
ArchUnit 사용법
ArchUnit을 사용하기 위해 먼저 의존성을 추가해야 합니다. Gradle을 사용한다면 다음과 같이 의존성을 추가할 수 있습니다.
testImplementation 'com.tngtech.archunit:archunit-junit5:1.0.1'
기본적인 사용 방법
ArchUnit을 사용하는 방법은 크게 세 가지가 있습니다.
1. 선언형 검사 (Declarative Style)
선언형 검사는 규칙을 먼저 선언하고, 그 규칙을 한 번에 검사하는 방식입니다. 이 방식은 코드의 가독성이 높고, 규칙을 명확하게 표현할 수 있다는 장점이 있습니다.
@Test
public void 서비스_레이어는_컨트롤러_레이어에_의존하지_않아야_한다() {
JavaClasses importedClasses = new ClassFileImporter().importPackages("com.myapp");
ArchRule rule = noClasses()
.that().resideInAPackage("..service..")
.should().dependOnClassesThat().resideInAPackage("..controller..");
rule.check(importedClasses);
}
2. 명령형 검사 (Imperative Style)
클래스와 메서드를 직접 순회하면서 원하는 조건을 세밀하게 검사하는 방식입니다. 복잡한 규칙이나 맞춤형 검증에 적합합니다.
@Test
public void 애노테이션_규칙_검증() {
JavaClasses classes = new ClassFileImporter().importPackages("com.myapp");
for (JavaClass clazz : classes) {
if (clazz.getPackageName().contains("controller")) {
assertThat(clazz.isAnnotatedWith(RestController.class))
.as("컨트롤러는 @RestController 애노테이션을 가져야 합니다")
.isTrue();
}
}
}
3. AssertJ 연동 검사
AssertJ의 SoftAssertion을 활용해 여러 위반 사항을 한 번에 수집해서 보여주는 방식입니다. 특히 초기 개발 단계에서 모든 문제를 한눈에 파악하는 데 유용합니다.
@Test
public void 컨트롤러_규칙_검증() {
SoftAssertions softly = new SoftAssertions();
JavaClasses classes = new ClassFileImporter().importPackages("com.myapp.controller");
for (JavaClass clazz : classes) {
softly.assertThat(clazz.getSimpleName().endsWith("Controller"))
.describedAs("컨트롤러 클래스명은 Controller로 끝나야 합니다: %s", clazz.getName())
.isTrue();
}
softly.assertAll();
}
실제 프로젝트 적용 사례
GitHub - toduck-App/toduck-backend: Service for adult ADHD
Service for adult ADHD. Contribute to toduck-App/toduck-backend development by creating an account on GitHub.
github.com
이제 실제 프로젝트에서 ArchUnit을 어떻게 활용했는지 살펴보겠습니다. 저희 프로젝트에서는 다음과 같은 규칙을 ArchUnit으로 검증하고 있습니다.
레이어 의존성 규칙
기존 3티어 아키텍처를 사용하면서 서비스 계층 간 의존성이 높고 도메인 로직과 레포지토리 호출 코드가 뒤섞여 유지보수가 어려워지는 문제를 겪었는데요. 이를 해결하기 위해 이번 프로젝트에서는 UseCase 레이어를 추가해 고수준 비즈니스 로직을 분리했습니다. 기존과 다른 아키텍처 방식이다 보니 팀원들에게 명확한 규칙 설명과 이해가 필요했고, 레이어가 늘어남에 따라 복잡해진 의존성 관리를 체계적으로 검증할 방법이 필요했습니다.
현재 프로젝트에서는
다음과 같은 레이어 구조를 가지고 있습니다.
- Presentation Layer: Controller, DTO, API 인터페이스
- Domain Layer: Service, UseCase
- Persistence Layer: Repository, Entity
- Common Layer: Mapper 등 공통 유틸리티
이러한 레이어 간의 의존성 규칙을 다음과 같이 정의하고 검증하고 있습니다.
@ArchTest
static final ArchRule 레이어_의존성_규칙을_준수한다 = layeredArchitecture()
.consideringAllDependencies()
.layer(CONTROLLER.name()).definedBy(CONTROLLER.getFullPackageName())
.layer(DTO.name()).definedBy(DTO.getFullPackageName())
.layer(SERVICE.name()).definedBy(SERVICE.getFullPackageName())
.layer(USECASE.name()).definedBy(USECASE.getFullPackageName())
.layer(REPOSITORY.name()).definedBy(REPOSITORY.getFullPackageName())
.layer(ENTITY.name()).definedBy(ENTITY.getFullPackageName())
.layer(MAPPER.name()).definedBy(MAPPER.getFullPackageName())
.whereLayer(CONTROLLER.name()).mayNotBeAccessedByAnyLayer()
.whereLayer(SERVICE.name()).mayOnlyBeAccessedByLayers(USECASE.name())
.whereLayer(USECASE.name()).mayOnlyBeAccessedByLayers(CONTROLLER.name())
.whereLayer(REPOSITORY.name()).mayOnlyBeAccessedByLayers(SERVICE.name())
.whereLayer(ENTITY.name())
.mayOnlyBeAccessedByLayers(
SERVICE.name(), USECASE.name(), REPOSITORY.name(), MAPPER.name(), ENTITY.name(), DTO.name()
)
.whereLayer(MAPPER.name()).mayOnlyBeAccessedByLayers(SERVICE.name(), USECASE.name());
이 규칙은 각 레이어가 어떤 레이어에 접근할 수 있는지를 명확하게 정의하고 있습니다. Controller는 UseCase만 호출할 수 있고, UseCase는 Service만, Service는 Repository만 호출할 수 있도록 단방향 의존성을 강제합니다. 이를 통해 레이어 간 의존성 방향이 일관되게 유지되어 코드의 구조와 흐름이 명확해 집니다.
클래스 네이밍 규칙
각 레이어의 클래스는 일관된 네이밍 규칙을 따라야 합니다. 이를 검증하는 테스트는 다음과 같습니다.
@ArchTest
static final ArchRule 컨트롤러_클래스_네이밍_규칙을_준수한다 = classes()
.that().resideInAPackage(CONTROLLER.getFullPackageName())
.should().haveSimpleNameEndingWith("Controller");
@ArchTest
static final ArchRule 유스케이스_클래스_네이밍_규칙을_준수한다 = classes()
.that().resideInAPackage(USECASE.getFullPackageName())
.should().haveSimpleNameEndingWith("UseCase");
@ArchTest
static final ArchRule 서비스_클래스_네이밍_규칙을_준수한다 = classes()
.that().resideInAPackage(SERVICE.getFullPackageName())
.should().haveSimpleNameEndingWith("Service");
애노테이션 사용 규칙
각 레이어의 클래스는 특정 애노테이션을 가지고 있어야 합니다. 이를 검증하는 테스트는 다음과 같습니다.
@ArchTest
static final ArchRule Service_클래스는_Service_어노테이션을_가진다 =
classes()
.that().resideInAPackage(SERVICE.getFullPackageName())
.should().beAnnotatedWith(Service.class);
@ArchTest
static final ArchRule UseCase_클래스는_UseCase_어노테이션을_가진다 =
classes()
.that().resideInAPackage(USECASE.getFullPackageName())
.should().beAnnotatedWith(UseCase.class);
@ArchTest
static final ArchRule Controller_클래스는_RestController_어노테이션을_가진다 =
classes()
.that().resideInAPackage(CONTROLLER.getFullPackageName())
.should().beAnnotatedWith(RestController.class);
@ArchTest
static final ArchRule Controller_메서드는_인증된_메서드만_포함한다 =
methods()
.that().areDeclaredInClassesThat().resideInAPackage(CONTROLLER.getFullPackageName())
.and().arePublic()
.should().beAnnotatedWith(PreAuthorize.class);
현재 프로젝트에서는 Swagger를 사용하여 API 문서를 자동으로 생성합니다. 공통 응답 형식과 도메인별 에러 코드 체계를 도입했는데, 이 구조가 API 문서에도 정확히 반영되어야 했습니다.
기존 Swagger 어노테이션으로는 현재의 공통 응답 구조를 제대로 표현할 수 없어 커스텀 어노테이션을 개발했습니다. 기본 Swagger 어노테이션을 사용하면 정확한 응답 구조가 문서화되지 않기 때문에, 반드시 커스텀 @ApiResponseExplanations를 사용하도록 강제하는 규칙이 필요했습니다.
@ArchTest
static final ArchRule API_메서드는_Operation_어노테이션을_가진다 =
methods()
.that().areDeclaredInClassesThat().resideInAPackage(API.getFullPackageName())
.should().beAnnotatedWith(Operation.class);
@ArchTest
static final ArchRule API_메서드는_ApiResponseExplanations_어노테이션을_가진다 =
methods()
.that().areDeclaredInClassesThat().resideInAPackage(API.getFullPackageName())
.should().beAnnotatedWith(ApiResponseExplanations.class);
모든 API 메서드에는 @Operation과 @ApiResponseExplanations 이라는 커스텀 Swagger 애노테이션이 있어야 함을 다음과 같이 검증할 수 있었습니다.
코딩 스타일 규칙
일부 코딩 스타일 규칙도 ArchUnit으로 검증할 수 있습니다. 현재 저희는 System.out.println()과 같은 표준 스트림 접근을 금지하고 있습니다.
@ArchTest
private final ArchRule 표준스트림에_접근하지_말아야한다 = NO_CLASSES_SHOULD_ACCESS_STANDARD_STREAMS;
위는 ArchUnit에서 기본 제공되는 규칙을 활용한 것입니다.
이 외에도 다음과 같은 유용한 기본 규칙들이 제공됩니다.
- NO_CLASSES_SHOULD_USE_JAVA_UTIL_LOGGING: Java 기본 로깅 API 사용 금지
- NO_CLASSES_SHOULD_USE_JUNIT_ASSERTIONS: JUnit 대신 다른 테스트 라이브러리 사용 권장
- NO_CLASSES_SHOULD_USE_FIELD_INJECTION: 스프링의 필드 주입 대신 생성자 주입 권장
- NO_CLASSES_SHOULD_THROW_GENERIC_EXCEPTIONS: 일반적인 예외 대신 구체적인 예외 사용 권장
- NO_CLASSES_SHOULD_USE_JODATIME: Java 8 이후에는 내장 시간 API 사용 권장
저희는 더 나은 테스트 코드 가독성을 위해 JUnit 대신 AssertJ를 사용하도록 강제하고 있습니다. AssertJ는 플루언트 인터페이스를 제공하여 테스트 코드를 마치 일반 영어 문장처럼 읽을 수 있게 해주며, 더 직관적인 에러 메시지와 다양한 매처(matcher)를 제공합니다. 이를 위해 다음과 같은 ArchUnit 규칙을 적용하고 있습니다.
@ArchTest
public static final ArchRule 가독성을_위해_Junit을_사용하지_않는다 =
noClasses()
.should().accessClassesThat()
.haveFullyQualifiedName(org.junit.Assert.class.getName())
.because("Junit 대신 AssertJ를 사용하세요.");
ArchUnit 도입의 장단점
ArchUnit을 활용한 아키텍처 테스트를 통해 여러 가지 이점을 경험할 수 있었습니다. 물론 모든 도구가 그렇듯 장점만 있는 건 아니기에, 단점도 함께 살펴보겠습니다.
장점
- 자동화된 아키텍처 검증
수동 코드 리뷰에만 의존하지 않고 빌드 단계에서 자동으로 아키텍처 규칙을 검증할 수 있습니다. 이를 통해 코드 리뷰에서는 비즈니스 로직에 더 집중할 수 있게 됩니다. - 빠른 피드백 사이클
스프링 컨텍스트를 로드하지 않고 바이트코드만 분석하기 때문에 테스트가 매우 빠르게 실행됩니다. 덕분에 개발자는 빠른 피드백을 받고 즉시 수정할 수 있습니다. - 가독성 높은 테스트 구문
ArchUnit은 플루언트 인터페이스 스타일로 작성되어 있어 마치 일반 문장을 읽는 것처럼 이해하기 쉽습니다. 이는 테스트 코드 자체의 가독성과 유지보수성을 높여줍니다. - 점진적 적용 가능
기존 코드베이스에도 점진적으로 적용할 수 있습니다. 예를 들어 freeze()라는 메서드를 사용하면 현재의 위반 사항을 허용하면서 새로운 위반만 잡아낼 수 있습니다.
단점
- 초기 설정 비용
규칙을 정의하고 테스트를 작성하는 데 초기 투자가 필요합니다. 팀 전체가 ArchUnit에 익숙해지는 데도 시간이 걸릴 수 있어요. - 유지보수 부담
아키텍처가 진화함에 따라 테스트 코드도 계속 업데이트해야 합니다. 특히 대규모 리팩토링 시에는 많은 테스트 코드를 수정해야 할 수도 있습니다. - 규칙의 엄격함
때로는 너무 엄격한 규칙이 창의성이나 유연성을 제한할 수 있습니다. 예를 들어 "모든 컨트롤러 클래스명은 Controller로 끝나야 한다"와 같은 규칙은 상황에 따라 불필요한 제약이 될 수도 있죠. - 예외 처리의 복잡성
모든 규칙에는 예외가 있기 마련인데, 이런 예외 케이스를 ArchUnit에서 처리하는 것이 복잡할 수 있습니다. 다행히 ArchUnit은 ignoreDependency()나 because() 같은 메서드로 예외를 정의할 수 있지만, 예외가 많아지면 테스트 코드가 복잡해질 수 있습니다.
ArchRule rule = noClasses()
.that().resideInPackage("..service..")
.should().dependOnClassesThat().resideInPackage("..controller..")
.ignoreDependency(ServiceA.class, ControllerB.class)
.because("특별한 이유로 예외 처리");
테스트 코드의 또 다른 의미: 살아있는 문서
우리가 작성하는 테스트 코드는 단순히 기능 검증을 넘어 프로젝트의 지식을 담는 문서 역할을 하는 경우가 많습니다. 단위 테스트나 통합 테스트를 보면 비즈니스 로직의 흐름과 입출력 예시를 이해할 수 있죠.
ArchUnit 테스트도 마찬가지입니다. 이는 단순한 규칙 검증이 아니라 프로젝트의 아키텍처를 명확히 보여주는 '살아있는 문서'가 됩니다.
예를 들어, 다음과 같은 테스트 코드를 보면,
@ArchTest
static final ArchRule 레이어_의존성_검증 = 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");
이 짧은 코드만으로도 프로젝트가 3개의 레이어로 구성되어 있고, 각 레이어 간의 관계가 어떻게 되는지 한눈에 파악할 수 있습니다. 문서를 읽는 것보다 훨씬 명확하고 간결하게 아키텍처를 이해할 수 있습니다.
또한 ArchUnit 테스트는 일종의 가이드라인 역할도 해요. 규칙을 위반하는 코드를 작성하면 테스트가 즉시 실패하기 때문에, 개발자가 아키텍처 규칙을 미처 알지 못했더라도 올바른 방향으로 유도될 수 있어요. 이는 특히 큰 팀이나 복잡한 프로젝트에서 매우 유용합니다.
제가 특히 인상 깊었던 점은 코드 리뷰 과정이 크게 개선되었다는 겁니다. 리뷰어가 "이 컨트롤러는 이름 규칙을 따라야 해요" 또는 "유즈케이스 레이어에서 직접 리포지토리에 접근하면 안 돼요"와 같은 기계적인 피드백을 줄 필요가 없어졌죠. 대신 비즈니스 로직이나 코드의 효율성 같은 더 중요한 측면에 집중할 수 있게 되었어요.
결론
ArchUnit은 Java 프로젝트의 아키텍처 일관성을 유지하는 데 매우 강력한 도구입니다. 기존의 단위 테스트 프레임워크와 통합되어 사용할 수 있기 때문에 도입 장벽도 낮습니다.
프로젝트를 진행하다 보면 문서로만 존재하는 아키텍처 규칙들이 점점 지켜지지 않는 경우가 많아요. 코드 리뷰만으로는 이런 부분을 모두 잡아내기 어렵고, 자칫하면 기술 부채로 쌓이게 됩니다. 실제로 ArchUnit을 도입한 후 아키텍처 관련 이슈가 크게 줄었고, 실제 두 명의 신규 팀원이 참가했을 때도 아키텍처를 빠르게 이해하는 데 도움이 되었다는 피드백을 받을 수 있었습니다.
ArchUnit은 단순히 규칙을 강제하는 도구가 아니라, 팀의 아키텍처 지식을 코드화하고 공유하는 방법입니다. 이를 통해 팀은 더 일관되고 유지보수하기 쉬운 코드를 작성할 수 있으며, 결과적으로 개발 생산성과 코드 품질 향상에 기여할 수 있습니다.
'Java' 카테고리의 다른 글
Java 21: 새로운 LTS 버전의 주요 기능 톺아보기 (3) | 2024.10.16 |
---|