1~9장까지는 아키텍처에 대해서 많은 이야기를 나누는 부분이었다.
(코드를 어떻게 작성하고 어디에 위치시킬지 결정하는 데 있어 표준이 되는 아키텍처에 대해 알게 되었다.)
하지만 일정 규모 이상의 모든 프로젝트에서는 시간이 지나면서 아키텍처가 서서히 무너지게 된다. 계층 간의 경계가 악화되고 코드는 점점 더 테스트하기 어려워진다. 새로운 기능을 구현하는데 점점 더 많은 시간이 들게 될 것이다.
이번 10장에서는 아키텍처 내의 경계를 강제하는 방법과 함께 아키텍처 붕괴에 맞서 싸우기 위해 취할 수 있는 몇가지 조치를 살펴본다 !
경계와 의존성(들어가기 전)
아키텍처 경계를 강제하는 여러 가지 방법에 대해 이야기하기에 앞서 아키텍처의 어디에 경계가 있고, “경게를 강제한다”는 것일까?

아키텍처 경계를 강제한다는 것은 의존성이 올바른 방향을 향하도록 강제하는 것을 의미한다. 아키텍처에서 허용되지 않은 의존성을 점선 화살표로 표시하고있다.
위의 그림 육각형 아키텍처의 요소들이 2장에서 소개한 포괄적인 클린 아키텍처 방식과 유사한 4개의 계층이 어떻게 흩어져 있는지 보여주고 있다.
그림을 살펴보면 가장 안쪽의 계층에는 도메인 엔티티가 있다. 애플리케이션 계층은 애플리케이션 서비스 안에 유스케이스를 구현하기 위해 도메인 엔티티에 접근한다. 어댑터는 인커밍 포트를 통해 서비스에 접근하고, 반대로 서비스는 아웃고잉 포트를 통해 어댑터에 접근한다.
마지막으로 설정 계층은 어댑터와 서비스 객체를 생성할 팩터리를 포함하고 있고, 의존성 주입 메커니즘을 제공한다.
위의 그림은 아키텍처의 경계가 꽤 명확하다고 할 수 있다. 각 계층 사이, 안쪽 인접 계층과 바깥족 인접 계층 사이에 경계가 있다. 의존성 규칙에 따르면 계층 경계를 넘는 의존성은 항상 안쪽 방향으로 향해야 한다.
이번 10장에서는 이러한 의존성 규칙을 강제하는 방법들을 알아보고, (점선 화살표처럼) 잘못된 방향을 가리키는 의존성을 없게 만드는 것이 목표이다.
접근 제한자
경계를 강제하기 위해 자바에서 제공하는 가장 기본적인 도구인 접근 제한자부터 시작한다.
접근 제한자는 저자가 신입 개발자 면접에서 단골로 출제했던 주제인 만큼 중요하다고 할 수 있다.
- 자바에 어떤 접근 제한자가 있고, 차이점이 무엇일까?
대부분의 면접자는 public, protected, private 제한자만 알고 있었고 대부분이 package-private(default) 제한자를 모르고 있었다. 이것이 저자에게는 왜 접근 제한자가 이런 식으로 구성돼 있는지 질문을 해나가면서 면접자가 직접 답을 추론해나갈 수 있는지 확인용도가 되었다고 한다.
package-private(default) 제한자는 왜 중요할까?
자바 패키지를 통해 클래스들을 응집적인 “모듈”로 만들어주기 때문이다.
이러한 모듈 내에 있는 클래스들은 서로 접근 가능하지만, 패키지 바깥에서는 접근할 수 없다 그럼 모듈의 진입점으로 활용될 클래스들만 골라서 public으로 만들면 된다. 이렇게 하면 의존성이 잘못된 방향을 가리켜서 의존성 규칙을 위반할 위험이 줄어든다.

접근 제한자를 염두에 두고 3장에서 본 패키지 구조를 다시한번 살펴보자
out.persistence 패키지에 잇는 클래스들은 외부에서 접근할 필요가 없기 때문에 package-private(위의 패키지 구조에 ‘o’로 표시되어 있음)으로 만들 수 있다.
영속성 어댑터는 자신이 구현하는 출력 포트를 통해 접근된다. 같은 이유로 SendMoneyService를 package-private로 만들 수 있다. 의존성 주입 매커니즘은 일반적으로 리플렉션을 이용해 클래스를 인스턴스로 만들기 때문에 package-private이더라도 여전히 인스턴스를 만들 수 있다.
이 방법을 스프링에서 사용하려면 9장에서 설명한 클래스패스 스캐닝을 이용해야만 한다. 다른 방법에서는 객체의 인스턴스들을 우리가 직접 생성해야 하기 때문에 public 제한자를 이용해야 한다.
예제의 나머지 클래스들은 아키텍처의 정의에 의해 public(’+’로 표시되어 있음)이어야 한다.
도메인 패키지는 다른 계층에서 접근할 수 있어야 하고 application 계층은 web 어댑터와 persistence 어댑터에서 접근 가능해야 한다.
package-private 제한자는 몇 개 정도의 클래스로만 이뤄진 작은 모듈에서 가장 효과적이다. 그러나 패키지 내의 클래스가 특정 개수를 넘어가기 시작하면 하나의 패키지에 너무 많은 클래스를 포함하는 것이 혼란스러워지게 된다.
이렇게 되면 코드를 쉽게 찾을 수 있도록 하위 패키지를 만드는 방법을 선호한다. 하지만 이렇게 하면 자바는 하위 패키지를 다른 패키지로 취급하기 때문에 하위 패키지의 package-private 멤버의 접근할 수 없게 된다. 그래서 하위 패키지의 멤버는 public으로 만들어서 바깥 세계에 노출시켜야 하기 때문에 우리의 아키텍처에서 의존성 규칙을 깨질 수 있는 환경이 만들어지게 되버린다..
컴파일 후 체크
클래스에 public 제한자를 쓰면 아키텍처 상의 의존성 방향이 잘못되더라도 컴파일러는 다른 클래스들이 이 클래스를 사용하도록 허용한다. 이런 경우에는 컴파일러가 전혀 도움이 되지 않기 때문에 의존성 규칙을 위반했는지 확인할 수 있는 다른 수단을 찾아야 한다.
한 가지 방법은 컴파일 후 체크(post-compile check)를 도입하는 것이다. 다시 말해, 코드가 컴파일된 후에 런타임에 체크한다는 뜻이다. 이러한 런타임 체크는 지속적인 통합 빌드 환경에서 자동화된 테스트 과정에서 가장 잘 동작한다.
이러한 체크를 도와주는 자바용 도구로 ArchUnit이 있다. 다른 무엇보다 ArchUnit은 의존성 방향이 기대한 대로 잘 설정돼 있는지 체크할 수 있는 API를 제공한다. 의존성 규칙 위반을 발견하면 예외를 던진다. 이 도구는 JUnit과 같은 단위 테스트 프레임워크 기반에서 가장 잘 동작하며 의존성 규칙을 위반할 경우 테스트를 실패시킨다.
이전 절에서 정의한 패키지 구조대로 각 계층이 전용 패키지를 가지고 있다고 가정하면 ArchUnit으로 계층 간의 의존성을 체크할 수 있다. 예를 들어, 도메인 계층에서 바깥쪽의 애플리케이션 계층으로 향하는 의존성이 없다는 것을 체크할 수 있다.
class DepecdencyRuleTests { @Test void domainLayerDoesNotDependencyOnApplicationLayer() { noClasses() .that() .resideInAPackage("bukpal.domain..") .should() .dependOnClassesThat() .resideInAnyPackage("bukpal.application..") .check(new ClassFileImpoter() .importPackages("bukpal..")); } }
ArchUnit API를 이용하면 작은 자작업만으로도 육각형 아키텍처 내에서 관련된 모든 패키지를 명시할 수 있는 일종의 도메인 특화 언어(DSL)를 만들 수 있고, 패키지 사이의 의존성 방향이 올바른지 자동으로 체크할 수 있다.
class DepecdencyRuleTests { @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 ClassFileImpoter() .importPackages("bukpal..")); } }
앞의 예제에서는 먼저 바운디드 컨텍스트의 부모 패키지를 지정한다.(단일 바운디드 컨텍스트라면 애플리케이션 전체에 해당한다.) 그런 다음 도메인, 어댑터, 애플리케이션, 설정 계층에 해당하는 하위 패키지들을 지정한다. 마지막에 호출하는 check()는 몇 가지 체크를 실행하고 패키지 의존성이 의존성 규칙을 따라 유효하게 설정됐는지 검증한다.
육각형 아키텍처 DSL에 대한 코드는 예제 프로젝트의 HexagonalArchitecture 클래스에서 확인할 수 있다.
잘못된 의존성을 바로잡는 데 컴파일 후 체크가 큰 도움이 되긴 하지만, 실패에 안전(fail-safe)하지는 않다. 패키지 이름인 bukpal에 오타를 내면 테스트가 어떤 클래스도 찾지 못하기 때문에 의존성 규칙 위반 사례를 발견하지 못할 것이다.
오타가 하나라도 나거나 패키지명을 하나만 리팩터링해도 테스트 전체가 무의미해질 수 있다. 이런 상황을 방지하려면 클래스를 하나도 찾지 못했을 때 실패하는 테스트를 추가해야 한다. 그럼에도 불구하고 여전히 리팩터링에 취약한 것은 사실이다. 컴파일 후 체크는 언제나 코드와 함께 유지보수해야 한다.
빌드 아티펙트
지금까지 코드 상에서 아키텍처 경계를 구분하는 유일한 도구는 패키지였다. 모든 코드가 같은 모놀리식 빌드 아티팩트의 일부였던 셈이다.
빌드 아티펙트는 빌드 프로세스의 결과물이다. 자바 사계에서 요즘 가장 인기 있는 빌드 도구는 메이븐과 그레이들이 있다. 그러므로 지금까지 단일 메이븐 혹은 그레이들 빌드 스크립트가 있고 메이븐이나 그레이들을 호출해서 코드를 컴파일하고, 테스트하고, 하나의 JAR 파일로 패키징 할 수 있었다고 상상하자.
빌드 도구의 중요한 기능 중 하나는 의존성 해결이다. 어떤 코드베이스를 빌드 아티팩트로 변환하기 위해 빌드 도구가 가장 먼저 할 일은 코드베이스가 의존하고 있는 모든 아티팩트가 사용 가능한지 확인하는 것이다. 만약 사용 불가능한 것이 있다면 아티팩트 리포지토리로부터 가져오려고 시도한다. 이마저도 실패한다면 코드를 컴파일도 하기 전에 에러와 함께 빌드가 실패한다.
이를 활용해서 모듈과 아키텍처의 계층 간의 의존성을 강제할 수 있다.(따라서 경계를 강제하는 효과가 생긴다)
각 모듈 혹은 계층에 대해 전용 코드베이스와 빌드 아티팩트로 분리된 빌드 모듈(JAR 파일)을 만들 수 있다. 각 모듈의 빌드 스크립트에서는 아키텍처에서 허용하는 의존성만 지정한다. 클래스들이 클래스패스에 존재하지도 않아 컴파일 에러가 발생하기 때문에 개발자들은 더이상 실수로 잘못된 의존성을 만들 수 없다.

잘못된 의존성을 막기 위해 아키텍처를 여러 개의 빌드 아티팩트로 만드는 여러 가지 방법
위의 그림은 아키텍처를 여러 개의 분리된 빌드 아티팩트로 나누는 몇 가지 선택지를 보여준다.
맨 왼쪽에는 설정, 어댑터, 애플리케이션 계층의 빌드 아티팩트로 이뤄진 기본적인 3개의 모듈 빌드 방식이 있다.
설정 모듈은 어댑터 모듈에 접근할 수 있고, 어댑터 모듈은 애플리케이션 모듈에 접근할 수 있다. 설정 모듈은 암시적이고 전이적인 의존성 때문에 애플리케이션 모듈에도 접근할 수 있다.
어댑터 모듈은 영속성 어댑터 뿐만 아니라 웹 어댑터도 포함하고 있다. 즉 빌드 도구가 두 어댑터 간의 의존성을 막지 않을 것이라는 뜻이다. 두 어댑터 간의 의존성이 의존성 규칙에서 엄격하게 금지된 것은 아니지만(두 어댑터 모두 같은 바깥 계층에 있으므로) 대부분의 경우 어댑터를 서로 격리시켜 유지하는 것이 좋다.
어쨌든 영속성 계층의 변경이 웹 계층에 영향을 미치거나 웹 계층의 변경이 영속성 계층에 영향을 미치는 것을 바라지 않을 것이다.(단일 책임 원칙을 기억하자)
애플리케이션을 다른 서드파티 API에 연결하는 다른 종류의 어댑터에서도 마찬가지다.
실수로 어댑터 간에 의존성이 추가되는 바람에 API와 관련된 세부사항이 다른 어댑터로 세어나가는 것을 바라지 않을 것이다.
그렇기 때문에 하나의 어댑터 모듈을 여러 개의 빌드 모듈로 쪼개서 어댑터당 하나의 모듈이 되게 할 수도 있다. 위의 그림 두번째 열이 여기에 해당한다.
다음으로 애플리케이션 모듈도 쪼갤 수 있다. 두 번째 열에서는 애플리케이션 모듈이 애플리케이션에 대한 인커밍/아웃고잉 포트, 그리고 이러한 포트를 구현하거나 사용하는 서비스, 도메인 로직을 담은 도메인 엔티티를 모두 포함하고 있다.
도메인 엔티티가 포트에서 전송 객체로 사용되지 않는 경우라면(8장에서 이야기한 매핑하지 않기 전략을 허용하지 않는 경우) 의존성 역전 원칙을 적용해서 포트 인터페이스만 포함하는 API 모듈을 분리해서 빼낼 수 있다. 이는 그림 10.3의 세번째 열에 해당한다.
어댑터 모듈과 애플리케이션 모듈은 API 모듈에 접근할 수 있지만, 그 반대는 불가능하다. API 모듈은 도메인 엔티티에 접근할 수도 없고 포트 인터페이스 안에서 도메인 엔티티를 사용할 수도 없다. 또한 어댑터는 더이상 엔티티와 서비스에 직접 접근할 수 없고 포트를 통해서 접근해야 한다.
한걸음 더 나아가 API 모듈을 인커밍 포트와 아웃고잉 포트 각각만 가지고 있는 두 개의 모듈로 쪼갤 수 있다. (위 그림의 네번째 열) 이런 식으로 인커밍 포트나 아웃고잉 포트에 대해서만 의존성을 선언함으로써 특정 어댑터가 인커밍 어댑터인지 아웃고잉 어댑터인지를 매우 명확하게 정의할 수 있다.
또 애플리케이션 모듈을 더 쪼갤 수 있다. 서비스만 가지고 있는 모듈과 도메인 엔티티만 가지고 있는 모듈로 쪼개는 것이다. 이렇게 하면 엔티티가 서비스에 접근할 수 없어지고, 도메인 빌드 아티팩트에 대한 의존성을 간단하게 선언하는 것만으로도(다른 유스케이스, 다른 서비스를 가진) 다른 애플리케이션이 같은 도메인 엔티티를 사용할 수 있게 된다.
위의 그림은 애플리케이션을 빌드 모듈로 쪼개는 다양한 방법을 묘사하고 있다. 그림에는 4개만 표현했지만 실제로는 더 다양한 방법이 있다. 핵심은 모듈을 더 세분화할수록, 모듈 간 의존성을 더 잘 제어할 수 있게 된다는 것이다. 하지만 더 작게 분리할수록 모듈간에 매핑을 더 많이 수행해야 한다. 이를 위해 8장에서 소개한 매핑 전략들 중 하나를 적용해야 한다.
이 밖에도 빌드 모듈로 아키텍처 경계를 구분하는 것은 패키지로 구분하는 방식과 비교했을 때 몇 가지 장점이 있다. 첫 번째로, 빌드 도구가 순환 의존성을 극도로 싫어한다는 것이다. 순환 의존성은 하나의 모듈에서 일어나는 변경이 잠재적으로 순환 고리에 포함된 다른 모듈을 변경하게 만들며, 단일 책임 원칙을 위배하기 때문에 좋지 않다.
빌드 도구는 이러한 순환 의존성을 허용하지 않는다. 의존성을 해결하는 과정에서 무한 로프에 빠지기 때문이다. 그러므로 빌드 도구를 이용하면 빌드 모듈 간 순환 의존성이 없음을 확신할 수 있다.
반면 자바 컴파일러는 두개 혹은 그 이상의 패키지에서 순환 의존성이 있든지 말든지 신경쓰지 않는다.
두 번째로, 빌드 모듈 방식에서는 다른 모듈을 고려하지 않고 특정 모듈의 코드를 격리한 채로 변경할 수 있다. 일시적으로 특정 어댑터에서 컴파일 에러가 생기는 애플리케이션 계층을 리팩터링하고 있다고 상상해보자. 만약 어댑터와 애플리케이션 계층이 같은 빌드 모듈에 있다면 어댑터가 컴파일되지 않더라도 애플리케이션 계층의 테스트를 실행할 수 있는 경우에도 대부분의 IDE는 테스트를 실행하려면 어댑터의 컴파일 에러를 모두 고쳐야 한다고 고집할 것이다. 만약 애플리케이션 계층이 독립된 빌드 모듈이라면 IDE가 어댑터에 신경쓰지 않을 것이기 때문에 애플리케이션 계층의 테스트를 마음대로 실행할 수 있다. 메이븐이나 그레이들로 빌드 프로세스를 실행하는 것 역시 마찬가지다. 만약 두 계층이 같은 빌드 모듈에 있다면 어느 한쪽 계층의 컴파일 에러 때문에 빌드가 실패할 것이다.
그러므로 여러 개의 빌드 모듈은 각 모듈을 격리한 채로 변경할 수 있게 해준다. 심지어 각 모듈을 자체 코드 리포지토리에 넣어 서로 달른 팀이 서로 다른 모듈을 유지보수하게 할 수도 있다.
마지막으로 모듈 간 의존성이 빌드 스크립트에 분명하게 선언돼 있기 때문에 새로 의존성을 추가하는 일은 우연이 아닌 의식적인 행동이 된다. 어떤 개발자가 당장은 접근할 수 없는 특정 클래스에 접근해야 할 일이 생기면 빌드 스크립트에 이 의존성을 추가하기에 앞서 정말로 이 의존성이 필요한 것인지 생각할 여지가 생긴다.
하지만 이런 장점에는 빌드 스크립트를 유지보수하는 비용을 수반하기 때문에 아키텍처를 여러 개의 빌드 모듈로 나누기 전에 아키텍처가 어느 정도는 안정된 상태여야 한다.
유지보수 가능한 소프트웨어를 만드는 데 어떻게 도움이 될까?
기본적으로 소프트웨어 아키텍처는 아키텍처 요소 간의 의존성을 관리하는 게 전부다.
만약 의존성이 거대한 진흙 덩어리가 된다면 아키텍처 역시 거대한 진흙 덩어리가 된다.
그렇기 때문에 아키텍처를 잘 유지해 나가고 싶다면 의존성이 올바른 방향을 가리키고 있는지 지속적으로 확인해야 한다.
새로운 코드를 추가하거나 리팩터링할 때 패키지 구조를 항상 염두에 둬야 하고, 가능하다면 package-private 가시성을 이용해 패키지 바깥에서 접근하면 안되는 클래스에 대한 의존성을 피해야 한다.
하나의 빌드 모듈 안에서 아키텍처 경계를 강제해야 하고, 패키지 구조가 허용하지 않아 package-private 제한자를 사용할 수 없다면 ArchUnit 같은 컴파일 후 체크 도구를 이용해야 한다.
그리고 아키텍처가 충분히 안정적이라고 느껴지면 아키텍처 요소를 독립적인 빌드 모듈로 수출해야 한다. 그래야 의존성을 분명하게 제어할 수 있기 때문이다.
아키텍처 경계를 강제하고 시간이 지나도 유지보수 하기 좋은 코드를 만들기 위해 세가지 접근 방식 모두를 함께 조합해서 사용할 수 있다.