‘만들면서 배우는 클린 아키텍처’ 기술 서적을 읽고 학습 내용을 정리하기 위한 목적의 TIL 포스팅입니다🙆♂️ 예제코드는 깃허브 레포지토리를 참고해주세요.
10장 - 아키텍처 경계 강제하기
경계와 의존성
출처: https://jandari91.tistory.com/60
- 아키텍처 경계를 강제한다는것은 의존성이 올바른 방향을 향하도록 강제하는것을 의미한다. 점선 화살표는 아키텍처에서 허용되지 않은 의존성이다.
접근제한자
- package-private 제한자가 중요한 이유는 자바 패키지를 통해 클래스들을
응집적인 모듈
로 만들어주기 때문이다.- 모듈의 진입점으로 활용된 클래스들만 public 으로 열어주면 된다.
- 이렇게 하면 의존성이 잘못된 방향을 가리켜서 의존성 규칙을 위반할 위험이 줄어든다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
buckpal
└─────── account
├──── adapter
│ ├──── in
│ │ └──── web
│ │ └──── o AccountController
│ │
│ └──── out
│ └──── persistence
│ ├──── o AccountPersistenceAdapter
│ └──── o SpringDataAccountRepository
│
├──── domain
│ ├──── + Account
│ └──── + Activity
│
└──── application
├──── o SendMoneyService
└──── port
├──── in
│ └──── + SendMoneyUseCase
└──── out
├──── + LoadAccountPort
└──── + UpdateAccountStatePort
- persistence 패키진와 application 패키지내의 SendMoneyService는 자신이 구현할 포트를 통해서만 접근되기에 package-private 으로 만들수있다. (‘o 표시’)
- 의존성 주입 메커니즘은 일반적으로 리플렉션을 이용해 클래스를 인스턴스화하기에 package-private 이더라도 상관없다.
이 방법을 스프링에서 사용하려면 클래스패스 스캐닝을 이용해야만 한다. 다른 방법에서는 객체의 인스턴스들을 우리가 직접 생성해야 하기에 public 제한자를 이용해야 한다.
- 예제의 나머지 클래스들은 아키텍처 정의에 의해 public(‘+’ 표시)이어야 한다. domain 패키지는 다른 계층에서 접근 가능해야 하고, application 계층은 web 어댑터와 persistence 어댑터에서 접근 가능해야 한다.
- package-private 제한자는 몇 개 정도의 클래스로만 이뤄지 작은 모듈에서 가장 효과적이다. 그러나 패키지 내의 클래스가 특정 개수를 넘어가기 시작하면 하나의 패키지에 너무 많은 클래스를 포함하는 것이 혼란스러워지게 되고 이렇게 되면 코드를 찾는게 쉽지 않아진다. 그러면 하위패키지를 만드는 방법으로 보통 해결하곤 하는데 자바는 하위 패키지를 다른 패키지로 취급하기에 하위 패키지는 public 으로 열어줘야만 한다. 그러면 아키텍처에서 의존성 규칙이 깨질수 있는 환경이 만들어지게 된다.
컴파일 후 체크
- public 제한자는 아키텍처 상의 의존성 방향이 잘못되더라도 컴파일러단에서 이를 잡을수 없게 된다.
- 대안은 ‘컴파일 후 체크(post-compile check)’를 도입하는것이다. 컴파일된 후에 런타임에 체크한다는 뜻이다. 런타임 체크는 지속적인 통합 빌드 환경에서 자동화된 테스트 과정에서 가장 잘 동작한다.
ArchUnit
- 위와 같은 체크를 도와주는 자바용 도구로
ArchUnit
이 있다. 의존성 방향이 기대한 대로 잘 설정돼 있는지 체크할 수 있는 API를 제공하고 의존성 규칙 위반을 발견하면 예외를 던진다. - Junit 과 같은 단위 테스트 프레임워크 기반에서 가장 잘 동작하며 의존성 규칙을 위반할 경우 테스트를 실패시킨다.
- 각 계층이 전용 패키지를 가진다하면 ArchUnit으로 계층간 의존성을 체크할 수 있다. 예를 들어, 아래 예제 코드처럼 도메인 계층에서 바깥쪽의 애플리케이션 계층으로 향하는 의존성이 없다는것을 체크할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
class DependencyRuleTests {
@Test
void domainLayerDoesNotDependOnApplicationLayer() {
noClasses()
.that()
.resideInAPackage("buckpal.domain..")
.should()
.dependOnClassesThat()
.resideInAnyPackage("buckpal.application..")
.check(new ClassFileImporter()
.importPackages("buckpal.."));
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class DependencyRuleTests {
@Test
void validateRegistrationContextArchitecture() {
HexagonalArchitecture.boundedContext("account")
.withDomainLayer("domain")
.withAdaptersLayer("adapter")
.incoming("web")
.outgoing("persistence")
.and()
.withApplicationLayer("application")
.service("service")
.incomingPorts("port.in")
.outgoingPorts("port.out")
.and()
.withConfiguration("configuration")
.check(new ClassFileImporter()
.importPackages("buckpal.."));
}
}
- 단점은 실패에 안전(fail-safe)하진 않다. 패키지 이름에 오타를 내면 테스트가 어떤 클래스도 찾지 못하므로 의존성 규칙 위반 사례를 발견하지 못하게 된다.
- 오타가 하나라도 나거나 패키지명을 하나만 리팩터링해도 테스트 전체가 무의미해질 수 있다.
- 이런 상황을 방지하려면 클래스를 하나도 찾지 못했을때 실패하는 테스트를 추가해야 한다.
- 그럼에도 불구하고 여전히 리팩터링에 취약한 것은 사실이다. 컴파일 후 체크는 언제나 코드와 함께 유지보수해야 한다.
빌드 아티팩트
- maven 과 gradle 과 같은 빌드 도구의 가장 중요한 기능 중 하나는 의존성 해결(dependency resolution)이다. 어떤 코드 베이스를 빌드 아티팩트로 변환하려면 빌드 도구가 가장 먼저 할 일은 의존하는 모든 아티팩트가 사용 가능한지이다. 만약 불가능한 것이 있다면 아티팩트 리포지토리로부터 가져오려고 시도하고 이마저도 실패하면 코드를 컴파일 하기전에 에러와 함께 빌드를 실패시킨다.
- 이를 활용해서 모듈과 아키텍처 계층 간의 의존성을 강제할 수 있다. (따라서 경계를 강제하는 효과가 생김)
- 각 모듈 혹은 계층에 대해 전용 코드 베이스와 빌드 아티팩트로 분리된 빌드 모듈(JAR 파일)을 만들 수 있다.
- 각 모듈의 빌드 스크립트에서는 아키텍처에서 허용하는 의존성만 지정한다. 클래스들이 클래스패스에 존재하지 않아 컴파일 에러가 발생하기에 개발자들은 더 이상 실수로 잘못된 의존성을 만들 수 없게 된다.
- 여러 개의 빌드 아티팩트로 만드는 방법은 아래 이미지와 같다.
출처: https://jandari91.tistory.com/60
- maven 의 gradle 의 멀티 모듈을 활용해서 계층들을 별도 모듈로 분리하는 방식이라 이해하면 된다.
- 왼쪽에서 오른쪽으로 갈수록 더 엄격하게 경계를 강제하는 방법이라 이해하면 된다.
- 위 이미지의 세번째 열의 케이스는 도메인 엔티티가 포트에서 전송 객체(DTO)로 사용되지 않는 경우라면(‘매핑하지 않기’ 전략을 허용하지 않는 경우) 의존성 역전 원칙을 적용해서 포트 인터페이스만 포함하는 API 모듈을 분리 가능하다.
- 맨 오른쪽 열의 케이스를 보면 도메인 빌드 아티팩트에 대한 의존성을 간단하게 선언하는 것만으로도 다른 애플리케이션(다른 유스케이스, 다른 서비스를 가진)이 같은 도메인 엔티티를 사용할 수 있게 된다.
- 위 이미지 방법 말고도 실제론 빌드 아티팩트를 나누는 더 다양한 방법이 존재한다. 하지만 핵심은 모두 같다. ‘모듈을 더 세분화할수록, 모듈간 의존성을 더 잘 제어할 수 있게 된다는것’이다. 하지만 더 작게 분리할수록 (매핑 전략을 채택해서) 모듈 간 매핑이 더 많아지게 된다.
빌드 모듈로 아키텍처 경계를 구분하는것은 패키지로 구분하는것과 비교했을때의 장점
1. 빌드 도구가 순환 의존성을 허용하지 않는다.
- 순환 읮존성은 한 모듈에서의 변경이 잠재적으로 순환고리에 포함된 다른 모든 모듈을 변경하게 만들며, 단일 책임 원칙을 위반한다.
- 자바 컴파일러는 두 개 혹은 그 이상 패키지에서 순환 의존성이 있든 말든 신경 쓰지 않는다.
2. 특정 모듈의 코드를 격리한채로 변경 가능하다.
- 특정 모듈에서 발생하는 컴파일에러와 무관하게 격리된 다른 계층 모듈의 테스트를 실행할 수 있다.
- 메이븐이나 그레이들로 빌드 프로세르르 실행하는 것 역시 마찬가지다. 만약 두 계층이 같은 빌드 모듈에 있다면 어느 한쪽 계층의 컴파일 에러로 빌드가 실패할것이다.
3. 빌드 스크립트를 통해 의존성을 관리하므로 추가하려할 경우 정말 필요한 의존성인지 다시 한 번 생각해볼 수 있게 해준다.
- 모듈 간 의존성이 빌드 스크립트에 분명하게 선언돼 있기 때문에 새로 의존성을 추가하는 일은 우연이 아닌 의식적인 행동이 되기 때문이다.
- 하지만 이런 장점에는 빌드 스크립트를 유지보수하는 비용을 수반하기에 아키텍처를 여러 개의 빌드 모듈로 나누기 전에 아키텍처가 어느 정도 안정된 상태여야 한다.
유지보수 가능한 소프트웨어를 만드는데 어떻게 도움이 될까?
- 소프트웨어 아키텍처는 아키텍처 요소 간의 의존성을 관리하는게 전부다.
- 새로운 코드를 추가하거나 리팩토링시 패키지 구조를 항상 염두에 둬야하고, 가능하다면 package-private 가시성을 이용해 패키지 바깥에서 접근하면 안되는 클래스에 대한 의존성을 피해야 한다.
- 하나의 빌드 모듈 안에서 아키텍처 경계를 강제해야 하고, 패키지 구조가 허용하지 않아 package-private 제한자를 사용할 수 없다면 ArchUnit 같은 컴파일 후 체크 도구를 이용해야 한다.
- 그리고 아키텍처가 충분히 안정적이라 느껴지면 아키텍처 요소를 독립적인 빌드 모듈로 추출해야 한다. 그래야 의존성을 분명하게 제어할 수 있기 때문이다.
- 아키텍처 경계를 강제하고 시간이 지나도 유지보수하기 좋은 코드를 만들기 위해 세 가지 접근 방식 모두를 함께 조합해서 사용할 수 있다.