[만들면서 배우는 클린 아키텍처] 10. 아키텍처 경계 강제하기
경계와 의존성
경계를 강제한다는 것은 의존성이 올바른 방향을 향하도록 강제하는 것을 의미한다.
점선 화살표: 잘못된 방향을 가리키는 의존성
접근 제한자
public, protected, prifate, package-private(default) 제한자가 있다.
package-private 제한자는 자바 패키지를 통해 클래스들을 모듈로 만들어준다. 패키지 내에 있는 클래스들은 서로 접근 가능하지만 바깥에서는 접근할 수 없다. 모듈 진입점으로 활용될 클래스들만 public으로 만들면 의존성이 잘못된 방향을 가리킬 위험이 줄어든다.
O 표시: private-package Class
+ 표시: public Class
영속성 어댑터는 자신이 구현하는 아웃고잉 포트를 통해 접근되므로 private-package 로 만들 수 있다.
유스케이스가 구현되어있는 service 또한 인커밍 포트를 통해 접근되므로 private-package로 만들 수 있다.
이 방법을 사용하려면 클래스패스 스캐닝을 이용해야만 한다. 다른 방법에서는 객체의 인스턴스를 직접 생성해야하므로 public 제한자를 이용해야 한다.
도메인 엔티티와 애플리케이션 계층은 다른 계층에서 접근할 수 있어야 하므로 public 이어야 한다.
private-package 제한자는 몇 개 정도의 클래스로만 이루어진 작은 모듈에서 효과적이다 너무. 많은 클래스를 포함하게 되면 코드를 쉽게 찾을 수 있도록 하위 패키지를 만들게 되는데 자바는 하위 패키지의 멤버도 다른 패키지로 취급하기 때문에 하위 패키지 클래스들은 public으로 만들게 되어 의존성 규칙이 깨질 수 있는 환경이 된다.
컴파일 후 체크(post-compile check)
코드가 컴파일 된 후에 런타임에 체크한다는 뜻이다.
아키텍처 상의 의존성 방향이 잘못되더라도 컴파일러는 의존성 규칙을 위반했는지 확인해주지 않기 때문에 다른 방법을 찾아야 한다.
런타임 체크는 지속적인 통합 빌드 환경에서 자동화된 테스트 과정에서 가장 잘 동작한다.
ArchUnit
의존성 방향이 기대한 대로 잘 설정돼 있는지 체크할 수 있는 API를 제공한다. 단위 테스트 프레임워크 기반에서 가장 잘 동작하며 의존성 규칙을 위반할 경우 테스트를 실패시킨다.
각 계층이 전용 패키지를 가지고 있다면 계층 간의 의존성을 체크할 수 있다.
class DependencyRuleTests {
@Test
void domainLayerDoesNotDependOnApplicationLayer) {
noClasses()
.that()
.resideInAnyPackage("buckpal.domain..")
.should()
.dependOnClassesThat()
.resideInAnyPackage("buckpal.application..")
.check(new ClassFileImporter().importPackages("buckpal.."));
}
}
육각형 아키텍처 내에서 관련된 모든 패키지를 명시할 수 있는 일종의 도메인 특화 언어를 만들 수 있고, 패키지 사이의 의존성 방향이 올바른지 자동으로 체크할 수 있다.
class DependencyRuleTests {
@Test
void validateRegistrationContextArchitecture() {
HexagonalArchitecture.boundedContext("account") // 바운디드 컨텍스트의 부모 패키지 지정
.withDomainLayer("domain") // 도메인
.withAdaptersLayer("adapter") // 어댑터
.incoming("web")
.outgoing("persistence")
.and()
.withApplicationLayer("application") // 애플리케이션
.services("service")
.incomingPorts("port.in")
.outgoingPorts("port.out")
.and()
.withConfiguration("configuration") // 설정
.check(new ClassFileImporter().importPackages("buckpal.."));
}
}
단점
- 패키지 이름에 오타를 내면 어떤 클래스도 찾지 못하기 때문에 의존성 규칙 위반 사례를 발견하지 못할 수도 있다. 이를 방지하려면 클래스를 하나도 찾지 못했을 때 실패하는 테스트를 추가해야 한다.
- 언제나 코드와 함께 유지보수해야 한다.
빌드 아티팩트(monolithic build artifact)
빌드 프로세스의 결과물이다.
빌드 도구인 메이븐(Maven), 그레이들(Gradle)을 호출해서 컴파일, 테스트, JAR파일로 패키징 하는 경우
빌드 도구의 주요한 기능 중 하나는 의존성 해결이다. 코드베이스를 빌드 아티팩트로 변환하기 위해 코드베이스가 의존하고 있는 모든 아티팩트가 사용 가능한지 확인하는 것이다. 만약 사용 불가능한 것이 있다면 아티팩트 리포지토리로부터 가져오려 시도하고, 이마저도 실패하면 코드를 컴파일하기 전에 에러와 함께 빌드가 실패한다.
이를 통해 모듈과 아키텍처 계층 간의 의존성을 강제할 수 있다.
각 모듈 혹은 계층에 대해 분리된 빌드 모듈(JAR파일)을 만들 수 있다. 각 모듈의 빌드 스크립트에서는 아키텍처에서 허용하는 의존성만 지정하기 때문에 클래스들이 클래스패스에 존재하지도 안아 컴파일 에러가 발생하게 된다.
1. 설정, 어댑터, 애플리케이션 계층의 빌드 아티팩트로 이루어진 기본적인 모듈 빌드 방식.
어댑터 모듈은 웹 어댑터와 영속성 어댑터를 모두 포함하고 있으므로 빌드 도구가 두 어댑터 간의 의존성을 막을 수 없다.
2. 어댑터당 하나의 모듈로 분리.
3. 애플리케이션 모듈을 포트와 포트를 구현하거나 사용하는 서비스, 도메인 엔티티 분리.
도메인 엔티티가 포트에서 전송 객체로 사용되지 않는 경우라면 포트 인터페이스만 분리할 수 있다.
4. 포트당 하나의 모듈로 분리 & 서비스만 가지고 있는 모듈과 도메인 엔티티만 가지고 있는 모듈로 분리.
도메인 엔티티가 서비스에 접근할 수 없어지고, 다른 유스케이스, 서비스를 가진 애플리케이션이 같은 도메인 엔티티를 사용할 수 있게 된다.
장점
- 모듈을 세분화할수록 모듈 간 의존성을 더 잘 제어할 수 있게 된다. 하지만 모듈 간에 매핑을 더 많이 수행해야 한다.
- 빌드 도구는 의존성을 해결하는 과정에서 무한 루프에 빠지기 때문에 순환 의존성을 허용하지 않는다. 그러므로 빌드 도구를 이용하면 빌드 모듈 간 순환 의존성이 없음을 확신할 수 있다. (자바 컴파일러는 순환 의존성에 대해 신경 쓰지 않는다)
- 다른 모듈을 고려하지 않고 특정 모듈의 코드를 격리한 채로 변경할 수 있다. 특정 어댑터에서 컴파일 에러가 생기더라도 애플리케이션 계층의 테스트는 실행할 수 있다. 같은 빌드 모듈에 있다면 어느 한쪽 계층의 컴파일 에러 때문에 빌드가 실패할 것이다.
- 모듈 간 의존성이 빌드 스크립트에 선언돼 있기 때문에 새로 의존성을 추가하는 일은 의식적인 행동이 된다.
하지만 빌드 스크립트를 유지보수하는 비용을 수반하기 때문에 아키텍처가 안정된 상태여야 한다.
유지보수 가능한 소프트웨어를 만드는 데 어떻게 도움이 될까?
기본적으로 소프트웨어 아키텍처는 아키텍처 요소 간의 의존성을 관리하는 게 전부다. 그렇기 때문에 의존성이 올바른 방향을 가리키고 있는지 지속적으로 확인해야 한다.
패키지 구조를 항상 염두하고, 접근 제어자를 통해 바깥에서 접근하면 안 되는 클래스에 대한 의존성을 피해야 한다.
패키지 구조가 허용하지 않아 접근 제어자를 사용할 수 없다면 컴파일 후 체크 도구를 이용해야 한다.
아키텍처가 충분히 안정적이라고 느껴지면 독립적인 빌드 모듈로 추출해야 한다.
세 가지 접근 방식 모두를 함께 조합해서 사용할 수 있다.