만들면서 배우는 클린 아키텍처 책을 읽고 정리하며 소감을 적는 포스트입니다.
서론
지금까지 아키텍처에 대해서 많은 이야기를 나누었다. 하지만 일정 규모 이상의 모든 프로젝트에서는 시간이 지나면서 아키텍처가 서서히 무너지게 된다. 계층 간의 경계가 약화되고, 코드는 점점 더 테스트하기 어려워지고, 새로운 기능을 구현하는 데 점점 더 많은 시간이 든다.
이번에는 아키텍처 내의 경계를 강제하는 방법과 함께 아키텍처 붕괴에 맞서 싸우기 위해 취할 수 있는 몇 가지 방법을 살펴보자.
경계와 의존성
아키텍처 경계를 강제하는 여러 가지 방법에 대해 이야기하기에 앞서 아키텍처의 어디에 경계가 있고, ‘경계를 강제한다’는 것이 어떤 의미인지 먼저 살펴보자.
- 아키텍처 경계를 강제한다는 것은 의존성이 올바른 방향을 향하도록 강제하는 것을 의미한다.
- 아키텍처에서 허용되지 않은 의존성을 점선 화살표로 표시했다.
가장 안쪽의 계층에는 도메인 엔티티가 있다. 애플리케이션 계층은 애플리케이션 서비스 안에 유스케이스를 구현하기 위해 도메인 엔티티에 접근한다. 어댑터는 인커밍 포트를 통해 서비스에 접근하고, 반대로 서비스는 아웃고잉 포트를 통해 어댑터에 접근한다. 마지막으로 설정 계층은 어댑터와 서비스 객체를 생성할 팩토리를 포함하고 있고, 의존성 주입 메커니즘을 제공한다.
위의 그림을 보면 아키텍처의 경계는 꽤 명확하다. 각 계층 사이, 안쪽 인접 계층과 바깥쪽 인접 계층 사이에 경계가 있다. 의존성 규칙에 따르면 계층 경계를 넘는 의존성은 항상 안쪽 방향으로 향해야 한다. 이제 이러한 의존성 규칙을 강제하는 방법들을 살펴보자.
접근 제한자
경계를 강제하기 위해 자바에서 제공하는 가장 기본적인 도구인 접근 제한자부터 시작해 보자. 보통 접근 제한자 중 package-private(default) 제한자에 대해서는 잘 모르는 경우가 있다.
package-private 제한자
package-private 제한자는 왜 중요할까? 자바 패키지를 통해 클래스들은 응집적인 ‘모듈’로 만들어 주기 때문이다. 이러한 모듈 내에 있는 클래스들은 서로 접근 가능하지만, 패키지 바깥에서는 접근할 수 없다. 그럼 모듈의 진입점으로 활용된 클래스들만 골라서 public으로 만들면 된다. 이렇게 하면 의존성이 잘못된 방향을 가리켜서 의존성 규칙을 위반할 위험이 줄어든다.
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
- 접근 제한자를 염두에 두고 3장에서 본 패키지 구조를 다시 한번 살펴보자
persistence 패키지에 있는 클래스들은 외부에서 접근할 필요가 없기 때문에 package-private(o 표시)으로 만들 수 있다. 영속성 어댑터는 자신이 구현하는 출력 포트를 통해 접근된다. 같은 이유로 SendMoneyService를 package-private으로 만들 수 있다.
이 방법을 스프링에서 사용하려면 클래스패스 스캐닝을 이용해야만 한다. 다른 방법에서는 객체의 인스턴스들을 우리가 직접 생성해야 하기 때문에 public 제한자를 이용해야 한다.
나머지 클래스들은 아키텍처의 정의에 의해 public(+ 표시)이어야 한다. 도메인 패키지는 다른 계층에서 접근할 수 있어야 하고, application 계층은 web 어댑터와 persistence 어댑터에서 접근 가능해야 한다.
package-private 제한자는 몇 개 정도의 클래스로만 이뤄진 작은 모듈에서 가장 효과적이다. 그러나 패키지 내의 클래스가 특정 개수를 넘어가기 시작하면 하나의 패키지에 너무 많은 클래스를 포함하는 것이 혼란스러워지게 된다. 이렇게 되면 코드를 쉽게 찾을 수 있도록 하위 패키지를 만드는 방법을 선호한다.
하지만 이렇게 하면 자바는 하위 패키지를 다른 패키지로 취급하기 때문에 하위 패키지의 package-private 멤버에 접근할 수 없게 된다. 그래서 하위 패키지의 멤버는 public으로 만들어서 바깥 세계에 노출시켜야 하기 때문에 우리의 아키텍처에서 의존성 규칙이 깨질 수 있는 환경이 만들어진다.
컴파일 후 체크
클래스에 public 제한자를 쓰면 아키텍처 상의 의존성 방향이 잘못되더라도 컴파일러는 다른 클래스들이 이 클래스를 사용하도록 허용한다. 이런 경우에는 컴파일러가 전혀 도움이 되지 않기 때문에 의존성 규칙을 위반했는지 확인할 다른 수단을 찾아야 한다.
한 가지 방법은 컴파일 후 체크(post-compile check)를 도입하는 것이다. 다시 말해, 코드가 컴파일된 후에 런타임에 체크한다는 뜻이다. 이러한 런타임 체크는 지속적인 통합 빌드 환경에서 자동화된 테스트 과정에서 가장 잘 동작한다.
이러한 체크를 도와주는 자바용 도구로 ArchUnit이 있다. 다른 무엇보다 ArchUnit은 의존성 방향이 기대한 대로 잘 설정돼 있는지 체크할 수 있는 API를 제공한다. 의존성 규칙 위반을 발견하면 예외를 던지다. 이 도구는 JUnit과 같은 단위 테스트 프레임워크 기반에서 가장 잘 동작하며 의존성 규칙을 위반할 경우 테스트를 실패시킨다.
이전 절에서 정의한 패키지 구조대로 각 계층이 전용 패키지를 가지고 있다고 가정하면 ArchUnit으로 계층 간의 의존성을 체크할 수 있다. 예를 들어, 도메인 계층에서 바깥쪽의 애플리케이션 계층으로 향하는 의존성이 없다는 것을 체크할 수 있다.
class DependencyRuleTests {
@Test
void domainLayerDoesNotDependOnApplicationLayer() {
noClasses()
.that()
.resideInPackage("buckpal.domain..")
.should()
.dependOnClassesThat()
.resideInAnyPackage("buckpal.application..")
.check(new ClassFileImporter()
.importPackages("buckpal.."));
}
}
ArchUnit API를 이용하면 적은 작업만으로도 육각형 아키텍처 내에서 관련된 모든 패키지를 명시할 수 있는 일종의 도메인 특화 언어(DSL)를 만들 수 있고, 패키지 사이의 의존성 방향이 올바른지 자동으로 체크할 수 있다.
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.."));
}
}
- 바운디드 컨텍스트의 부모 패키지를 지정한다.
- 단일 바운디드 컨텍스트라면 애플리케이션 전체에 해당
- 도메인, 어댑터, 애플리케이션, 설정 계층에 해당하는 하위 패키지들을 지정한다.
- 마지막에 호출하는 check()는 몇 가지 체크를 실행하고 패키지 의존성이 의존성 규칙을 따라 유효하게 설정됐는지 검증한다.
잘못된 의존성을 바로잡는 데 컴파일 후 체크가 큰 도움이 되긴 하지만, 실패에 안전하지는 않다. 패키지 이름에 오타를 내면 테스트가 어떤 클래스도 찾기 못하기 때문에 의존성 규칙 위반 사례를 발견하지 못할 것이다. 오타가 하나라도 나거나 패키지명을 하나만 리팩터링해도 테스트 전체가 무의미해질 수 있다.
이런 상황을 방지하려면 클래스를 하나도 찾지 못했을 때 실패하는 테스트를 추가해야 한다. 그럼에도 불구하고 여전히 리팩터링에 취약한 것은 사실이다. 컴파일 후 체크는 언제나 코드와 함께 유지보수해야 한다.
빌드 아티팩트
지금까지 코드 상에서 아키텍처 경계를 구분하는 유일한 도구는 패키지였다. 모든 코드가 같은 모놀리식 빌드 아티팩트의 일부였던 셈이다.
빌드 아티팩트는 빌드 프로세스의 결과물이다. 자바 세계에서 인기 있는 빌드 도구는 메이븐과 그레이들이다. 그러므로 지금까지 단일 메이븐 혹은 그레이들 빌드 스크립트가 있고, 메이븐이나 그레이들을 호출해서 코드를 컴파일하고, 테스트하고, 하나의 JAR 파일로 패키징 할 수 있었다고 상상하자.
빌드 도구의 주요한 기능 중 하나는 의존성 해결이다. 어떤 코드베이스를 빌드 아티팩트로 변환하기 위해 빌드 도구가 가장 먼저 할 일은 코드베이스가 의존하고 있는 모든 아티팩트가 사용 가능한지 확인하는 것이다. 만약 사용 불가능한 것이 있다면 아티팩트 리포지토리부터 가져오려고 시도한다. 이마저도 실패한다면 코드를 컴파일하기 전에 에러와 함께 빌드가 실패한다.
이를 활용해서 모듈과 아키텍처의 계층 간의 의존성을 강제할 수 있다. 각 모듈 혹은 계층에 대해 전용 코드베이스와 빌드 아티팩트로 분리된 빌드 모듈(JAR 파일)을 만들 수 있다. 각 모듈의 빌드 스크립트에서는 아키텍처에서 허용하는 의존성만 지정한다. 클래스들이 클래스패스에 존재하지도 않아 컴파일 에러가 발생하기 때문에 개발자들은 더 이상 실수로 잘못된 의존성을 만들 수 없다.
맨 왼쪽 첫 번째 열의 구조에서는 설정, 어댑터, 애플리케이션 계층의 빌드 아티팩트로 이뤄진 기본적인 3개의 모듈 빌드 방식이 있다. 설정 모듈은 어댑터 모듈에 접근할 수 있고, 어댑터 모듈은 애플리케이션 모듈에 접근할 수 있다. 설정 모듈은 암시적으로 전이적인 의존성 때문에 애플리케이션 모듈에도 접근할 수 있다.
어댑터 모듈은 영속성 어댑터뿐만 아니라 웹 어댑터도 포함하고 있다. 즉, 빌드 도구가 두 어댑터 간의 의존성을 막지 않을 것이라는 뜻이다. 두 어댑터 간의 의존성 규칙에서 엄격하게 금지된 것은 아니지만 대부분의 경우 어댑터를 서로 격리시켜 유지하는 것이 좋다.
영속성 계층의 변경이 웹 계층에 영향을 미치거나 웹 계층의 변경이 영속성 계층에 영향을 미치는 것을 바라지 않을 것이다. 단일 책임 원칙을 기억하자. 애플리케이션을 다른 서드파티 API에 연결하는 다른 종류의 어댑터에서도 마찬가지다. 실수로 어댑터 간에 의존성이 추가되는 바람에 API와 관련된 세부사항이 다른 어댑터로 새어나가는 것을 바라지 않을 것이다.
그렇기 때문에 하나의 어댑터 모듈을 여러 개의 빌드 모듈로 쪼개서 어댑터당 하나의 모듈이 되게 할 수도 있다. 두 번째 열의 구조가 여기에 해당한다.
다음으로 애플리케이션 모듈도 쪼갤 수 있다. 두 번째 열에서는 애플리케이션 모듈이 애플리케이션에 대한 인커밍/아웃고잉 포트, 그리고 이러한 포트를 구현하거나 사용하는 서비스, 도메인 로직을 담은 도메인 엔티티를 모두 포함하고 있다. 도메인 엔티티가 포트에서 전송 객체(transfer object)로 사용되지 않는 경우라면(’no mapping’ 전략을 허용하지 않는 경우) 의존성 역전 원칙을 적용해서 포트 인터페이스에만 포함하는 API 모듈을 분리해서 빼낼 수 있다. 이는 세 번째 열의 구조가 여기에 해당한다.
한걸음 더 나아가 API 모듈을 인커밍 포트와 아웃고잉 포트 각각만 가지고 있는 두 개의 모듈로 쪼갤 수 있다. 이는 네 번째 열의 구조가 여기에 해당한다. 이런 식으로 인커밍 포트나 아웃고잉 포트에 대해서만 의존성을 선언함으로써 특정 어댑터가 인커밍 어댑터인지 아웃고잉 어댑터인지 매우 명확하게 정의할 수 있다 또, 애플리케이션 모듈을 더 쪼갤 수도 있다. 서비스만 가지고 있는 모듈과 도메인 엔티티만 가지고 있는 모듈로 쪼개는 것이다. 이렇게 하면 엔티티가 서비스에 접근할 수 없어지고, 도메인 빌드 아티팩트에 대한 의존성을 간단하게 선언하는 것만으로도 다른 애플리케이션이 같은 도메인 엔티티를 사용할 수 있게 된다.
위의 그림에서는 4가지만 표현했지만 실제로는 더 다양한 방법이 있다. 핵심은 모듈을 더 세분화할수록, 모듈 간 의존성을 더 잘 제어할 수 있게 된다는 것이다. 하지만 더 작게 분리할수록 모듈 간에 매핑을 더 많이 수행해야 한다.
빌드 모듈로 경계 구분하는 것의 장점
이 밖에도 빌드 모듈로 아키텍처 경계를 구분하는 것은 패키지로 구분하는 방식과 비교했을 때 몇 가지 장점이 있다.
- 빌드 도구는 순환 의존성을 허용하지 않기 때문에 순환 의존성이 없음을 확신할 수 있다.
- 빌드 모듈 방식에서는 다른 모듈을 고려하지 않고 특정 모듈의 코드를 격리한 채로 변경할 수 있다.
- 모듈 간 의존성이 빌드 스크립트에 분명하게 선언돼 있기 때문에 새로 의존성을 추가하는 일은 우연이 아닌 의식적인 행동이 된다.
순환 의존성은 하나의 모듈에서 일어나는 변경이 잠재적으로 순환 고리에 포함된 다른 모든 모듈을 변경하게 만들며, 단일 책임 원칙을 위배한다.
하지만 이런 장점에는 빌드 스크립트를 유지보수하는 비용을 수반하기 때문에 아키텍처를 여러 개의 빌드 모듈로 나누기 전에 아키텍처가 어느 정도는 안정된 상태여야 한다.
요약
기본적으로 소프트웨어 아키텍처는 아키텍처 요소 간의 의존성을 관리하는 게 전부다. 만약 의존성이 거대한 진흙 덩어리가 된다면 아키텍처 역시 거대한 진흙 덩어리가 된다. 그렇기 때문에 아키텍처를 잘 유지해나가고 싶다면 의존성이 올바른 방향을 가리키고 있는지 지속적으로 확인해야 한다.
새로운 코드를 추가하거나 리팩터링 할 때 패키지 구조를 항상 염두에 둬야 하고, 가능하다면 package-private 가시성을 이용해 패키지 바깥에서 접근하면 안 되는 클래스에 대한 의존성을 피해야 한다.
하나의 빌드 모듈 안에서 아키텍처 경계를 강제해야 하고, 패키지 구조가 허용되지 않아 package-private 제한자를 사용할 수 없다면 ArchUnit 같은 컴파일 후 체크 도구를 이용해야 한다.
그리고 아키텍처가 충분히 안정적이라고 느껴지면 아키텍처 요소를 독립적인 빌드 모듈로 추출해야 한다. 그래야 의존성을 분명하게 제어할 수 있기 때문이다.
아키텍처 경계를 강제하고 시간이 지나도 유지보수하기 좋은 코드를 만들기 위해 세 가지 접근 방식 모두를 함께 조합해서 사용할 수 있다.
이번에는 아키텍처 내의 경계를 강제하는 방법과 함께 아키텍처 붕괴에 맞서 싸우기 위해 취할 수 있는 몇 가지 방법을 살펴보자.
'design & development' 카테고리의 다른 글
[만들면서 배우는 클린 아키텍처] 12. 아키텍처 스타일 결정하기 (0) | 2024.11.26 |
---|---|
[만들면서 배우는 클린 아키텍처] 11. 의식적으로 지름길 사용하기 (1) | 2024.11.25 |
[만들면서 배우는 클린 아키텍처] 9. 애플리케이션 조립하기 (0) | 2024.11.23 |
[만들면서 배우는 클린 아키텍처] 8. 경계 간 매핑하기 (1) | 2024.11.22 |
[만들면서 배우는 클린 아키텍처] 7. 아키텍처 요소 테스트하기 (0) | 2024.11.21 |