ArchUnit은 “도메인 계층은 웹 계층을 참조하지 않는다” 같은 약속을 JUnit 테스트로 만들어 주는 라이브러리입니다. 문서에 적어두는 대신 빌드에서 강제하는 셈이죠.
써보기 전부터 궁금했던 게 하나 있었습니다. ArchUnit은 소스가 아니라 컴파일된 .class 파일을 읽습니다. 소스를 파싱하는 것보다 정확하다는 얘기를 여러 곳에서 봤는데, 정말 그런가 싶었습니다.
직접 돌려보니 답이 단순하지 않았습니다. 리플렉션보다는 확실히 낫습니다. 그런데 소스에 import가 멀쩡히 있는데도 의존을 0건으로 보는 경우가 있더군요. 그 지점을 javap으로 열어보기까지 했습니다.
ArchUnit이 하는 일
규칙은 이렇게 생깁니다.
ArchRule rule = noClasses().that().resideInAPackage("demo.domain..")
.should().dependOnClassesThat().resideInAPackage("demo.web..");
rule.check(new ClassFileImporter().importPackages("demo"));
읽으면 그대로 문장입니다. “demo.domain 패키지의 클래스는 demo.web 패키지에 의존해서는 안 된다.”
이 규칙을 위반한 코드를 넣고 돌리면 이렇게 나옵니다.
Field <demo.domain.BadOrder.controller> has type <demo.web.OrderController> in (BadOrder.java:0)
Method <demo.domain.BadOrder.set(demo.web.OrderController)> has parameter of type
<demo.web.OrderController> in (BadOrder.java:0)
처음 돌렸을 때 이 메시지가 마음에 들었습니다. “어딘가 잘못됐다”가 아니라 어느 필드, 어느 메서드의 파라미터인지 따로따로 짚어 줍니다. 고칠 자리를 찾느라 헤맬 일이 없습니다.
왜 이런 검사를 해야 하나
이게 실제로 중요한 질문입니다. 규칙을 문서에 적어두면 안 되는 걸까요.
문서는 썩고 코드는 자란다
아키텍처 결정은 보통 위키나 노션에 적힙니다. 그 문서가 코드와 아무 연결이 없다는 게 문제입니다. 6개월 뒤에 코드는 바뀌었고 문서는 그대로인데, 어느 쪽이 진실인지 아무도 모릅니다.
테스트로 만들면 규칙을 어길 때 빌드가 깨집니다. 규칙 자체를 바꾸고 싶으면 테스트를 고쳐야 하고, 그 변경은 리뷰에 올라옵니다. 결정과 그 이유가 커밋 이력에 남는다는 게 문서와의 가장 큰 차이입니다.
컴파일러는 계층 위반을 잡아주지 않는다
demo.domain에서 demo.web을 import하는 건 문법적으로 아무 문제가 없습니다. 컴파일러가 볼 때 잘못된 게 없으니까요. 패키지 구조로 표현한 계층은 자바 언어가 지켜주지 않습니다.
모듈로 쪼개면 빌드 도구가 막아주긴 합니다. 대신 멀티모듈 빌드의 복잡성을 떠안게 되죠. 단일 모듈로 가면서 경계만 강제하고 싶을 때 쓸 만합니다.
한 번 뚫리면 다음은 쉬워진다
계층 위반이 무서운 건 한 건 자체가 아니라 선례가 된다는 점입니다.
급해서 도메인에서 컨트롤러를 하나 참조합니다. 다음 사람이 그 코드를 보고 “여기서는 이렇게 하는구나” 하고 따라 합니다. 6개월 뒤에는 어디가 경계였는지 아무도 모르는 상태가 되고, 그때 되돌리려면 처음 막는 것보다 수십 배 비쌉니다.
코드 리뷰가 이걸 잡아주길 기대하기는 어렵습니다. 리뷰어는 바뀐 로직을 보느라 import 한 줄을 그냥 지나칩니다. 저도 놓친 적 있고요. 사람이 매번 안 틀려야 하는 일이면 기계에 맡기는 편이 낫습니다.
신규 입사자에게 규칙을 알려준다
새로 온 사람이 계층을 잘못 쓰면 CI가 알려줍니다. 그 실패 메시지가 온보딩 문서보다 명확한 경우가 많습니다. 무엇을 어겼고 어디서 어겼는지가 그대로 나오니까요. 아무도 안 읽는 위키 문서보다는 낫다고 봅니다.
주요 기능
아래 예제는 전부 ArchUnit 1.4.1과 JDK 21로 돌려보고 결과를 확인한 것입니다.
클래스 단위 규칙
가장 기본입니다. 조건과 대상을 조합합니다.
// 서비스 클래스는 Service 로 끝나야 한다
classes().that().resideInAPackage("..service..")
.should().haveSimpleNameEndingWith("Service");
// 도메인은 스프링에 의존하지 않는다
noClasses().that().resideInAPackage("..domain..")
.should().dependOnClassesThat().resideInAPackage("org.springframework..");
// 필드 주입을 금지한다
noFields().should().beAnnotatedWith("org.springframework.beans.factory.annotation.Autowired");
계층 아키텍처 — layeredArchitecture
계층을 정의하고 접근 방향을 선언합니다.
ArchRule rule = layeredArchitecture().consideringAllDependencies()
.layer("Web").definedBy("demo.web..")
.layer("Service").definedBy("demo.service..")
.layer("Domain").definedBy("demo.domain..")
.whereLayer("Web").mayNotBeAccessedByAnyLayer()
.whereLayer("Service").mayOnlyBeAccessedByLayers("Web");
consideringAllDependencies()를 빼면 기본적으로 일부 의존만 보므로, 처음에는 붙여서 시작하는 편이 안전합니다.
패키지 순환 의존 — slices
순환 의존은 손으로 찾기가 제일 까다롭습니다. 이건 경로를 그려서 보여주더군요.
SlicesRuleDefinition.slices()
.matching("demo.(*)..")
.should().beFreeOfCycles();
실행 결과입니다.
Cycle detected: Slice a ->
Slice b ->
Slice a
1. Dependencies of Slice a
- Field <demo.a.A.b> has type <demo.b.B> in (A.java:0)
2. Dependencies of Slice b
- Field <demo.b.B.a> has type <demo.a.A> in (B.java:0)
고리를 이루는 의존이 각각 무엇인지까지 나옵니다. 어디를 끊어야 할지 바로 보여서 이 부분이 제일 유용했습니다.
테스트 통합 — @AnalyzeClasses
JUnit 5에서는 애너테이션으로 붙입니다. 클래스 임포트 결과를 캐시하므로 규칙이 늘어도 매번 다시 읽지 않습니다.
@AnalyzeClasses(packages = "com.example.app",
importOptions = ImportOption.DoNotIncludeTests.class)
class ArchitectureTest {
@ArchTest
static final ArchRule 도메인은_웹을_모른다 =
noClasses().that().resideInAPackage("..domain..")
.should().dependOnClassesThat().resideInAPackage("..web..");
}
레거시 대응 — FreezingArchRule
개인적으로는 이게 제일 쓸모 있어 보였습니다. 이미 위반이 수백 건 쌓인 프로젝트에 규칙을 넣으면 빌드가 빨개져서 결국 아무도 안 쓰게 되는데, 그 문제를 정면으로 다룹니다.
FreezingArchRule은 현재 위반을 기록해 두고 그 이후에 새로 생긴 것만 실패로 처리합니다.
@ArchTest
static final ArchRule 규칙 = FreezingArchRule.freeze(
noClasses().that().resideInAPackage("..domain..")
.should().dependOnClassesThat().resideInAPackage("..web..")
);
기존 부채는 그대로 두고 더 나빠지는 것만 막는 방식입니다. 이러면 규칙을 넣는 데 아무 부담이 없습니다. 기록된 위반을 하나씩 고쳐 나가면 저장소가 줄어들고요.
바이트코드 분석은 더 정밀한가
처음에 궁금했던 게 이겁니다.
ArchUnit은 ClassFileImporter로 컴파일된 .class 파일을 읽습니다. 소스를 파싱하지도 않고 클래스를 JVM에 로딩하지도 않습니다. 리플렉션을 안 쓴다는 얘기죠.
얻는 게 있으면 잃는 것도 있을 텐데, 그게 뭔지 확인해 봤습니다.
리플렉션보다 정밀하다 — 실측
애너테이션 유지 정책을 세 가지로 만들어 두고 같은 클래스를 ArchUnit과 리플렉션으로 각각 읽어봤습니다.
SOURCE 는 양쪽 다 못 봤고 RUNTIME 은 양쪽 다 봤습니다. 갈린 건 CLASS 였습니다. ArchUnit 은 보는데 리플렉션은 못 봅니다.
>>> ArchUnit(바이트코드)이 보는 것
classOnly() -> [ClassRetained]
invisible() -> []
visible() -> [KeptAtRuntime]
>>> 리플렉션이 보는 것
visible() -> [KeptAtRuntime]
invisible() -> []
classOnly() -> []
CLASS 유지 애너테이션은 클래스 파일에는 남아 있지만 런타임에는 노출되지 않습니다. 리플렉션으로는 못 보고 바이트코드로는 봅니다. 이 차이는 명확했습니다.
실무에서는 다른 이유가 더 클 것 같습니다. 리플렉션은 클래스를 로딩해야 하니 static 초기화자가 실행되고, 의존 클래스가 클래스패스에 없으면 NoClassDefFoundError가 연쇄로 납니다. ArchUnit은 파일만 읽으니 그런 부작용이 없습니다. 검사할 때 스프링 컨텍스트를 안 띄워도 되는 이유가 이겁니다.
소스보다 정밀하다 — 제네릭 타입 인자까지 본다
List<Order> 같은 제네릭은 컴파일하면 필드 디스크립터가 Ljava/util/List;로 소거됩니다. 그러면 Order에 대한 의존도 같이 사라지지 않을까 싶었습니다. 확인해 보니 아니더군요.
public class GenericHolder {
private List<Order> orders;
private Map<String, Order> index;
public List<Order> getOrders() { return orders; }
}
>>> GenericHolder 의 demo.domain 의존:
Field <GenericHolder.orders> has generic type <List<demo.domain.Order>>
with type argument depending on <demo.domain.Order>
Field <GenericHolder.index> has generic type <Map<String, demo.domain.Order>>
with type argument depending on <demo.domain.Order>
Method <GenericHolder.getOrders()> has generic return type <List<demo.domain.Order>>
with type argument depending on <demo.domain.Order>
>>> 총 3건
세 건 모두 잡아냅니다. 클래스 파일의 Signature 속성에 제네릭 정보가 남아 있고 ArchUnit이 그걸 읽습니다. 필드인지 반환 타입인지까지 구분해서 알려주고요.
한 가지 더. 쓰지 않는 import는 바이트코드에 아예 없습니다. 소스를 파싱하는 도구라면 미사용 import를 의존으로 잡을 수 있는데 여기서는 그럴 일이 없습니다. 반대로 컴파일러가 만들어낸 의존(브릿지 메서드 같은 것)은 그대로 보입니다.
그런데 덜 정밀한 지점이 있다 — 상수 인라이닝
여기서 예상이 빗나갔습니다. 아래 두 클래스를 만들어 봤는데, 둘 다 import demo.consts.Limits;가 있습니다.
public class ConstantUser {
public int limit() { return Limits.MAX_ITEMS; } // static final 상수
public String prefix() { return Limits.PREFIX; } // static final 상수
}
public class MethodUser {
public int limit() { return Limits.computeMax(); } // 메서드 호출
}
ArchUnit이 본 결과입니다.
>>> demo.service.ConstantUser -> demo.consts 의존 0건
>>> demo.service.MethodUser -> demo.consts 의존 1건
Method <MethodUser.limit()> calls method <Limits.computeMax()> in (MethodUser.java:6)
ConstantUser가 0건입니다. 소스에는 import도 있고 Limits.MAX_ITEMS도 쓰는데요. 처음엔 규칙을 잘못 쓴 줄 알고 몇 번 다시 돌려봤습니다.
javap으로 열어보고서야 알았습니다.
public int limit();
Code:
0: bipush 100 // Limits.MAX_ITEMS 가 아니라 상수 100 그 자체
2: ireturn
public java.lang.String prefix();
Code:
0: ldc #9 // String ORD-
2: areturn
static final 상수는 컴파일 시점에 값이 그대로 박혀 버립니다. 호출 지점에 Limits를 거치는 흔적이 아예 안 남습니다.
그런데 상수 풀을 보면 클래스 이름은 남아 있습니다. 이게 좀 헷갈렸습니다.
Constant pool:
#7 = Class #8 // demo/consts/Limits
#8 = Utf8 demo/consts/Limits
Class 엔트리는 있는데 어떤 명령어도 이걸 참조하지 않습니다. 그래서 ArchUnit이 의존으로 세지 않은 거였습니다. 뒤집어 보면 클래스 파일에서 문자열을 긁어모으는 방식이 아니라 실제 사용 지점을 따라간다는 뜻이기도 합니다. 이 점은 오히려 신뢰가 갔습니다.
SOURCE 애너테이션도 못 본다
같은 이유로 RetentionPolicy.SOURCE 애너테이션도 검사할 수 없습니다. 컴파일하면 사라지니까요. Lombok의 상당 부분이나 정적 분석용 마커 애너테이션이 여기 해당합니다.
정리하면
| 무엇을 알고 싶은가 | 바이트코드 분석 |
|---|---|
| 이 코드가 실행 시 실제로 무엇에 의존하는가 | 더 정밀하다 |
리플렉션이 볼 수 없는 CLASS 애너테이션 |
볼 수 있다 |
| 제네릭 타입 인자 의존 | 본다 |
| 클래스 로딩 부작용 없이 검사 | 가능하다 |
| 소스에 적힌 의도(import, 상수 참조) | 덜 정밀하다 |
SOURCE 유지 애너테이션 |
볼 수 없다 |
정리하자면 “바이트코드가 더 정밀하다”는 말은 실행되는 의존성을 기준으로 할 때만 맞습니다. 소스에 적힌 의도를 기준으로 삼으면 오히려 빠지는 정보가 생깁니다.
규칙을 설계할 때의 함정
위에서 확인한 게 실무에 바로 영향을 줍니다.
상수 전용 클래스를 계층 경계로 삼으면 안 됩니다. Constants나 ErrorCodes 같은 static final 모음 클래스를 다른 계층에 두고 “참조 금지” 규칙을 걸면, 실제로는 참조하고 있는데도 규칙이 조용히 통과합니다. 이게 제일 위험한 형태입니다. 규칙이 실패하면 알아채기라도 하는데, 통과해 버리면 지켜지고 있다고 착각하게 되니까요.
대응할 방법은 몇 가지 있습니다.
- 상수를
enum이나 메서드로 노출하면 바이트코드에 참조가 남습니다. 규칙이 정상 작동합니다. - 상수 클래스는 애초에 계층 경계 규칙의 대상에서 제외하고, 별도로 관리합니다.
- 경계를 정말 강제해야 한다면 패키지 규칙 말고 모듈 분리로 가는 편이 확실합니다.
도입 순서도 중요해 보입니다. 처음부터 완벽한 규칙 세트를 만들려 들면 위반이 수백 건 쏟아지고 결국 아무도 손을 못 댑니다. FreezingArchRule로 현재 상태를 얼려두고 새 위반만 막는 것부터 하는 편이 실제로 남습니다.
시작하기
Maven 의존성은 하나입니다.
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit5</artifactId>
<version>1.4.1</version>
<scope>test</scope>
</dependency>
규칙은 하나로 시작하는 게 좋습니다. 팀에서 제일 자주 어기는 것 하나를 고르면 됩니다.
@AnalyzeClasses(packages = "com.example.app",
importOptions = ImportOption.DoNotIncludeTests.class)
class ArchitectureTest {
@ArchTest
static final ArchRule 도메인은_웹을_모른다 =
noClasses().that().resideInAPackage("..domain..")
.should().dependOnClassesThat().resideInAPackage("..web..");
}
ImportOption.DoNotIncludeTests를 빼먹으면 테스트 코드가 규칙을 어긴다고 나옵니다. 저도 처음에 여기서 한 번 당황했습니다.
이 규칙 하나만 CI에 넣어도 그날부터 경계가 지켜집니다. 규칙을 늘리는 건 그다음 일이고요. 테스트 전략 전반은 Spring Boot 테스트 완벽 가이드에 정리해 두었습니다.
자주 묻는 질문
Q. 규칙 실행에 시간이 얼마나 걸리나요?
A. 제 예제는 클래스가 10여 개뿐이라 규칙당 0.3~0.4초였습니다. 대부분 클래스 임포트에 쓰이는 시간이고, @AnalyzeClasses가 결과를 캐시하니 규칙을 늘려도 그만큼 비례해서 늘지는 않습니다. 실무 규모라면 임포트 범위를 좁히는 게 가장 효과적일 겁니다.
Q. 위반이 너무 많이 나와서 못 켜겠습니다.
A. 그게 정상입니다. FreezingArchRule.freeze(...)로 감싸면 현재 위반을 기록해 두고 새로 생긴 것만 실패시킵니다. 부채를 한 번에 갚을 필요 없이 더 쌓이지 않게만 막는 것부터 시작하세요.
이 글의 코드와 실행 결과는 ArchUnit 1.4.1 / JDK 21 환경에서 직접 실행해 확인한 것입니다(2026년 9월 기준). 상수 인라이닝과 애너테이션 유지 정책 실험은
javap으로 바이트코드를 함께 확인했습니다. ArchUnit 버전에 따라 제네릭 의존 추적 등 일부 동작이 다를 수 있으니, 도입 전 사용 중인 버전에서 확인하시기 바랍니다.
“ArchUnit 써보고 알게 된 것: 바이트코드 분석이 놓치는 의존성”에 대한 1개의 생각