테스트 스위트는 calculateTotal이 올바른 입력을 받았을 때 42를 반환하는지 검증한다. 하지만 src/domain/Invoice.ts가 src/infrastructure/Database.ts를 import해도 되는지는 검증하지 않는다. 컴파일러는 둘 다 문제없다고 판단한다. 단위 테스트도 둘 다 통과한다. 그러나 둘 중 하나는 아키텍처 위반이며, 6개월 후 당신에게 일주일간의 리팩토링을 안겨줄 것이다.
이것이 바로 사각지대다. 우리는 로직을 테스트하고, 구조는 저절로 지켜질 거라고 가정한다. 그렇지 않다.
아키텍처 테스트가 실제로 무엇인가
아키텍처 테스트는 코드의 동작이 아닌 구조에 대한 assertion이다. 의존성 그래프가 팀이 합의한 설계와 일치하는지 확인한다. 개발자가 잘못된 레이어를 import하거나, 순환 의존성을 만들거나, 팀이 정한 접미사 없이 repository 클래스를 명명하면 빌드를 실패시킨다.
이것은 lint 규칙이 아니다. Linting은 스타일 위반을 잡아낸다. 아키텍처 테스트는 구조적 위반을 잡아낸다. 구조에는 의미가 있기 때문에 이 차이는 중요하다. 부모 모듈을 import하는 모듈은 스타일 문제가 아니다. 설계 문제다.
대부분의 팀은 이런 규칙을 위키나 README, 또는 tech lead가 고정한 Slack 메시지에 문서화한다. 문서는 온볼딩에는 유용하다. 강제력에는 무용하다. 아키텍처 테스트는 규칙을 사람의 기억에서 빌드 파이프라인으로 옮긴다. 잊어버리는 대가가 프로덕션 장애가 아닌 빌드 실패가 되는 곳으로 말이다.
테스트로 강제할 수 있는 것들
가장 명백한 사용 사례는 의존성 방향이다. Domain은 infrastructure를 import해서는 안 된다. UI는 data access를 직접 import해서는 안 된다. 이런 규칙은 패키지나 디렉터리 경계에 깔끔하게 매핑된다.
하지만 아키텍처 테스트는 import 이상의 것을 확인할 수 있다. 프로덕션 코드베이스에서 실제로 중요한 패턴들을 살펴자.
순환 의존성. 다른 세 개의 패키지를 거쳐 자기 자신을 import하는 패키지는 여전히 순환 의존성이다. 코드 리뷰에서 눈으로는 놓치기 쉽다. import 그래프를 순회하는 테스트는 놓치지 않는다.
네이밍 컨벤션. 팀이 모든 repository 구현체의 이름이 Repository로 끝나야 한다고 정했다면, 테스트로 이를 강제할 수 있다. 페달틱하게 들리겠지만, 누군가 같은 코드베이스에 UserDao와 UserRepo를 만들고 신입 개발자가 둘 중 어떤 것을 써야 할지 모르게 되면 그때서야 문제를 느낀다.
특정 라이브러리에 대한 금지된 의존성. 어쩌면 domain 레이어는 axios, pg, fs에 의존해서는 안 될 수도 있다. 어쩌면 프론트엔드는 네이티브 메서드를 표준화하기 위해 lodash를 import해서는 안 될 수도 있다. 아키텍처 테스트는 특정 패키지가 특정 모듈의 의존성 트리에 절대 등장하지 않음을 assertion할 수 있다.
어노테이션과 상속 규칙. Java에서는 ..domain..에 있는 어떤 클래스도 @Autowired를 사용하지 않음을 테스트할 수 있다. C#에서는 Infrastructure의 어떤 클래스도 Application의 인터페이스를 구현하지 않음을 테스트할 수 있다. 이것은 도메인 지식 없이는 정적 분석만으로 표현할 수 없는 구조적 제약이다.
작성 방법
최고의 아키텍처 테스트는 평범한 단위 테스트처럼 보인다. 기존 테스트 러너에서 실행된다. 다른 테스트와 같은 CI 잡에 포함된다. 유일한 차이는 무엇을 assertion하는가이다.
Java + ArchUnit:
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
@AnalyzeClasses(packages = "com.mycompany")
class ArchitectureTest {
@ArchTest
static final ArchRule no_cycles =
slices()
.matching("com.mycompany.(*)..")
.should().beFreeOfCycles();
@ArchTest
static final ArchRule domain_is_independent =
noClasses()
.that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAPackage("..infrastructure..");
}
이것은 JUnit 테스트다. mvn test로 실행된다. 빌드를 실패시킨다. 별도의 CI 잡이 필요 없다.
C# + NetArchTest:
using NetArchTest.Rules;
using Xunit;
public class ArchitectureTests
{
[Fact]
public void Domain_Should_Not_Depend_On_Infrastructure()
{
var result = Types.InCurrentDomain()
.That()
.ResideInNamespace("MyApp.Domain")
.ShouldNot()
.DependOnAny(Types.InNamespace("MyApp.Infrastructure"))
.GetResult();
Assert.True(result.IsSuccessful);
}
}
Python + import-linter:
Python에는 성숙한 ArchUnit 대안이 없지만, import-linter는 테스트처럼 작동하는 선언적 설정을 제공한다:
# .importlinter
[importlinter:contract:domain-independent]
name = Domain does not import infrastructure
type = forbidden
source_modules =
myapp.domain
forbidden_modules =
myapp.infrastructure
CI에서 lint-imports로 실행한다. 위반 시 non-zero로 종료된다.
Go: 직접 작성하기
Go에는 주류 아키텍처 테스트 라이브러리가 없다. 대부분의 팀은 AST를 순회하는 작은 테스트를 직접 작성한다:
import (
"strings"
"testing"
"golang.org/x/tools/go/packages"
)
func TestDomainDoesNotImportInfrastructure(t *testing.T) {
cfg := &packages.Config{
Mode: packages.NeedImports | packages.NeedFiles,
}
pkgs, err := packages.Load(cfg, "./domain/...")
if err != nil {
t.Fatal(err)
}
for _, pkg := range pkgs {
for path := range pkg.Imports {
if strings.Contains(path, "/infrastructure/") {
t.Errorf("domain package %s imports infrastructure: %s", pkg.PkgPath, path)
}
}
}
}
20줄짜리 코드다. 테스트 스위트에 포함된다. 모든 PR에서 실행된다. 이것이 핵심이다. 프레임워크가 필요한 것이 아니다. assertion이 필요한 것이다.
아무도 말하지 않는 트레이드오프
아키텍처 테스트는 공짜가 아니다. 새로운 범주의 빌드 실패를 도입하고, 새로운 빌드 실패는 항상 마찰을 만든다.
단위 테스트보다 느리다. 50만 라인 코드베이스의 모든 import를 스캔하는 아키텍처 테스트는 시간이 걸린다. 시간 단위는 아니다. 초 단위, 때로는 수십 초 단위다. 하지만 이것은 일반적인 단위 테스트보다 한 자릿수 느린 것이다. 빠른 단위 테스트와 같은 잡에서 실행하면 단위 테스트의 가치를 만드는 빠른 피드백 루프를 잃게 된다.
실용적인 분리는 아키텍처 테스트를 전용 CI 잡에서 실행하거나, 통합 테스트로 태그를 달고 빠른 스위트가 통과한 후에 실행하는 것이다. 규칙은 여전히 merge를 막는다. 다만 로컬 npm test와 같은 명령을 느리게 만들지는 않는다.
규칙이 너무 광범위하면 false positive이 발생한다. domain 레이어에서 node_modules의 모든 import를 금지하면 date-fns나 zod의 정당한 사용도 막게 된다. 규칙에는 예외가 필요하고, 예외는 유지보수가 필요하다. 30개의 pathNot 항목을 가진 규칙은 아키텍처를 강제하는 것이 아니다. 현재의 엉망인 상태를 인코딩하는 것이다.
거짓된 자신감을 줄 수 있다. 아키텍처 테스트를 통과했다고 해서 설계가 좋다는 뜻은 아니다. 당신이 작성한 규칙과 설계가 일치한다는 뜻이다. 규칙이 틀렸다면, 테스트는 단지 자동화된 cargo culting에 불과하다.
CI를 망가뜨리지 않고 추가하는 방법
10개의 규칙으로 시작하지 말라. 하나로 시작하라. 가장 큰 고통을 준 의존성 방향을 고른다. 어쩌면 domain이 infrastructure를 import하는 것일 수도 있다. 어쩌면 프론트엔드가 백엔드 코드를 직접 import하는 것일 수도 있다.
테스트를 작성하라. 로컬에서 실행하라. 실패 개수를 세라. 0개라면, 당신은 아주 규율이 엄격하거나, 규칙이 생각한 것을 잡아내지 못하고 있는 것이다. 의도적인 위반으로 검증하라.
50개라면 선택의 기로에 선다. 모든 50개를 하나의 영웅적인 PR에서 고치거나, 기존 위반에 대해서는 예외를 두고 새로운 위반을 금지할 수 있다. 두 번째 옵션은 덜 통쾌하지만 더 지속 가능하다.
테스트가 CI를 실패하게 만들어라. 경고가 아닌 실패. 경고는 엔지니어가 무시하는 법을 배우는 규칙이다.
FAQ
아키텍처 테스트가 코드 리뷰를 대체하는가?
아니다. 코드 리뷰에서 인간이 약한 부분, 예를 들어 20개 파일에 걸친 전이적 import를 찾아내는 것을 자동화한다. 새로운 의존성이 의미가 있는지 판단하는 것은 여전히 인간이 더 뛰어나다.
마이크로서비스는 어떤가?
아키텍처 테스트는 단일 배포 단위 내에서 가장 잘 작동한다. 서비스 간에는 import 그래프가 아닌 API 계약과 배포 격리로 경계를 강제한다.
네이밍 컨벤션도 테스트해야 하는가?
불일치가 실제로 혼란을 일으킬 때만 그렇게 하라. Repository 접미사를 강제하는 테스트는 10명 개발자 팀에서는 유용하다. 1인 프로젝트에서는 아마도 노이즈일 것이다.
모노레포에서도 사용할 수 있는가?
그렇다. Nx, Bazel, Turborepo 모두 모듈 경계 강제 기능이 있다. 이미 사용 중이라면 내장 규칙을 사용하라. 더 빠르게 실행되고 의존성 그래프와 통합된다. 사용하지 않는다면 독립형 아키텍처 테스트가 가벼운 진입점이다.
하나의 규칙으로 시작하라
당신의 코드베이스에는 이미 암묵적인 아키텍처 규칙이 존재한다. 시니어 엔지니어의 머릿속에 살아 있다. 그 엔지니어가 휴가를 가지 않았을 때 코드 리뷰에서 집행된다. 데드라인 직전 밤 11시에 어겨진다.
그중 하나를 테스트로 문서화하라. 빨갛게 만들어라. 위반 사항을 고쳐라. CI를 막게 만들어라.
다음에 누군가 “domain 레이어에서 infrastructure를 import해도 되나요?”라고 묻는다면, 답은 위키에 있지 않을 것이다. 실패한 빌드에 있을 것이다.