11. 의식적으로 지름길 사용하기

경제적인 관점에서 지름길을 사용하는게 더 효과적일 수 있다. 지름길을 사용하려면 일단 지름길 자체를 파악해야 한다.

  1. 유스케이스 간 모델 공유하기

기본적으로 유스케이스마다 다른 입출력 모델을 가져야 한다. 입력 파라미터의 타입과 반환값의 타입이 달라야 한다는 말이다.

만약 인커밍 포트 인터페이스의 입출력 모델이 같은 모델을 공유할 경우 공유한 모델이 변경될 경우 두 유스케이스 모두 영향을 받는다. 단일 책임 원칙에서 중요하게 생각하는 “변경할 이유”를 공유하게 되는 것이다.

만약 실제로 특정 세부사항을 변경할 경우 실제 두 유스케이스 모두에 영향을 주고 싶은 것이라면 괜찮다.

시작은 공유하더라도 어느 시점에서 유스케이스가 독립적으로 분리가 필요한 시점이라면 분리해야 한다.

  1. 도메인 엔티티를 입출력 모델로 사용하기

도메인 엔티티를 유스케이스의 입출력 모델로 사용하면 결합이 발생한다.

유스케이스가 단순히 데이터베이스 필드 몇개를 업데이트 하는 수준으로 간단하다면 괜찮을지도 모르지만 더 복잡한 도메인 로직을 구현해야 한다면 유스케이스 인터페이스에 대한 전용 입출력 모델을 만들어야 한다.

유스케이스의 변경이 도메인 엔티티까지 전파되는걸 바라지 않는다면 말이다.

  1. 인커밍 포트 건너뛰기

아웃고잉 포트는 의존성 역전(안쪽으로 흐르게 하기)에 필수 요소이지만 인커밍 포트는 그렇지 않다.

인커밍 어댑터에서 인커밍 포트 없이 애플리케이션 서비스에 직접 접근하도록 할 수 있다.

이 경우 두 계층 사이의 추상화 계층을 줄이면서 괜찮게 느껴질 수 있다.

하지만 인커밍 포트를 통해 애플리케이션 중심에 접근하는 진입점을 정의하지 않으면 특정 유스케이스를 구현하기 위해 어떤 서비스 메서드를 호출해야 하는지 알기 위해 애플리케이션 내부 동작에 대해 더 알아야 한다.

  1. 애플리케이션 서비스 건너뛰기

만약 간단한 CRUD 유스케이스에서 애플리케이션 서비스가 도메인 로직 없이 생성, 업데이ㅌ, 삭제 요청을 그대로 영속성 어댑터에 전달하기 때문에 건너뛰고 싶을 수도 있다.

하지만 이렇게 하려면 인커밍 어댑터와 아웃고잉 어댑터 사이에 모델을 공유해야 하는데 공유해야 하는 모델이 도메인 엔티티가 되면서 앞서 이야기한 도메인 엔티티를 입출력 모델로 사용하는 경우가 될 것이다.

또한 시간이 지나서 유스케이스가 점점 복잡해지면 도메인 로직을 그대로 아웃고잉 어댑터에 추가하고 싶은 생각이 들면서 도메인 로직이 흩어져서 찾고 유지보수 하는것이 어려워 진다.

10. 아키텍처 경계 강제하기

일정 규모 이상의 프로젝트라면 계층 간의 경계가 약화되고, 코드는 점점 테스트하기 어려워질 것이다.

아키텍처의 경계를 강제한다는 것은 각 계층간의 의존성이 올바른 방향을 향하도록 강제하는 것을 말한다. 바깥쪽에서 안쪽으로, 어댑터 → 애플리케이션 → 도메인 으로 의존성이 흘러가야 한다.


의존성 규칙을 강제하는 가장 기본적인 방법은 접근 제한자를 사용하는 것이다.

public, private, protected는 많이 알고 있지만 default에 대해서 깊게 생각하는 신입 개발자는 많지 않다.

자바 패키지를 통해 클래스들을 응집적인 모듈로 만들어주기 때문에 default 제한자는 중요하다.

모듈 내의 클래스들은 서로 접근이 가능하지만 패키지 바깥에선 접근할 수 없다.

그럼 모듈의 진입점으로 활용할 클래스들만 골라서 public으로 만들어준다면 의존성이 잘못된 방향을 가리키게 될 확률이 줄어든다.

각 계층의 어댑터, 서비스 클래스는 외부에서 접근할 필요가 없으니 default 제한자로 생성하고 다른 계층에서 접근해야 하는 domain 패키지는 public으로 만들 수 있다.

마찬가지로 웹어댑터와 영속성 어댑터에서 접근 가능해야 하는 애플리케이션 계층의 인커밍,아웃고잉 포트 역시 public으로 구현해야 한다.

default 제한자는 몇 개 정도의 클래스로만 이뤄진 작은 모듈에서 가장 효과적이다.

패키지 내 클래스가 일정 수준을 넘어서게 되면 보통 하위 패키지를 만드는 방법을 선호하는데 이렇게 하면 자바는 하위 패키지를 다른 패키지로 취급하여 하위 패키지의 default 멤버에 접근할 수 없게 된다.

이렇게 되면 하위 패키지의 멤버는 public으로 만들어서 바깥으로 노출시켜야 하는데 의존성 규칙이 깨질 수 있는 환경이 될 수 있다.


클래스에 public 제한자를 사용하면 아키텍처의 의존성 방향이 잘못되더라도 컴파일러에서 이를 확인할 수 없다.

이때 컴파일 후 런타임에서 체크하는 방법을 도입할 수 있는데 ArchUnit은 Junit과 같은 단위 테스트 프레임워크 기반에서 아키텍처의 의존성 규칙 위반을 발견하면 예외를 던질 수 있는 API를 제공한다.

ArchUnit API를 이용하면 헥사고날 아키텍처 내에서 관련된 모든 패키지를 명시할 수 있는 일종의 도메인 특화 언어(DSL)을 만들 수 있고 패키지 사이의 의존성 방향이 올바른지 자동으로 체크할 수 있다.

네이버에서 유닛테스트에 해당 라이브러리를 도입한 사례는 나중에 참고해보자 https://d2.naver.com/helloworld/9222129


Maven이나 Gradle은 자바에서 많이 사용하는 빌드 도구인데 주요한 기능 중 하나는 의존성 해결이다.

잘못된 의존성을 막기 위해 아키텍처를 여러 개의 빌드 아티팩트로 만들 수 있다.

각 모듈 혹은 계층에 대해 전용 코드베이스와 빌드 아티팩트로 분리된 빌드 모듈(JAR 파일)을 만들고 각 모듈의 빌트 스크립트에서는 아키텍처에서 허용하는 의존성만 지정하게 되면 클래스들이 클래스패스에 존재하지도 않아 컴파일 에러가 발생하여 잘못된 의존성을 만들수조차 없게 될 것이다.

또한 빌드 모듈로 아키텍처 경계를 구분하는 것을 패키지로 구분하는 방식에 비해 몇가지 장점이 더 있는데

  1. 빌드 도구는 순환 의존성(circular dependency)를 허용하지 않는다. 의존성을 해결하는 과정에서 무한 루프에 빠지기 때문에 빌드 도구를 사용하면 순환의존성이 없음을 확신할 수 있다.
  2. 빌드 모듈 방식은 다른 모듈을 고려하지 않고 특정 모듈의 코드를 격리한 채로 변경할 수 있다.
    특정 어댑터에서 컴파일 에러가 발생하는 애플리케이션 계층을 리팩토링 할 경우 같은 빌드 모듈에 있다면 컴파일 에러를 고쳐야 애플리케이션 계층의 테스트가 실행되겠지만 서로 다른 빌드 모듈에 독립적으로 존재할 경우 어댑터의 컴파일 에러와 상관없이 애플리케이션 계층을 테스트할 수 있을 것이다.
  3. 모듈 간 의존성이 빌드 스크립트에 분명하게 선언돼 있기 때문에 새로운 의존성을 추가할 때 의식적으로 한번 더 생각해볼 수 있다.

9. 애플리케이션 조립하기

유스케이스, 웹 어댑터, 영속성 어댑터를 구현했으니 이것들이 동작하는 애플리케이션으로 조립해보자.

각 클래스를 그냥 필요할때 인스턴스화 하지 않는 이유는 코드의 의존성이 올바른 방향을 가리키게 하기 위해서다. 모든 의존성은 안쪽, 애플리케이션의 도메인 코드 방향으로 향해야 도메인 코드가 바깥 계층의 변경으로부터 안전하다.

헥사고날 아키텍처 스타일은 하나의 코드를 훨씬 더 테스트하기 쉽다. 한 클래스가 필요로 하는 모든 객체를 생성자로 전달할 수 있다면 실제 객체 대신 목으로 전달할 수 있고, 이렇게 되면 격리된 단위 테스트를 생성하기 쉬워진다.

가장 추천하는 건 객체 인스턴스를 생성할 책임을 모든 클래스에 대한 의존성을 가지는 설정 컴포넌트를 만드는 것이다.

  • 각 계층의 어댑터 인스턴스 생성
  • HTTP요청, 데이터베이스 접근 등 외부 접근에 대한 전달을 보장
  • 각 어댑터에 유스케이스 인스턴스 제공

그 외 설정 파일이나 설정 파라미터의 소스에도 접근해야 한다.

단일 책임 원칙을 위반하게 되지만 나머지 부분을 깔끔하게 유지하기 위해서 구성요소들을 연결하는 바깥쪽 컴포넌트가 필요하다.

스프링 프레임워크를 이용해서 애플리케이션을 조립한 결과물을 애플리케이션 컨텍스트(application context)라고 한다. 애플리케이션 컨텍스트는 애플리케이션을 구성하는 모든 객체(bean)을 포함한다.

일반적으로는 스프링의 클래스패스 스캐닝으로 조립하는데 클래스패스에서 접근 가능한 모든 클래스를 확인해서 @Component 어노테이션이 붙은 클래스를 찾아서 각 클래스의 객체를 생성한다.

이때 필요한 모든 필드를 인자로 받는 생성자가 필요한데 Lombok 라이브러리의 @RequiredArgsConstructor 어노테이션을 사용하면 모든 final 필드를 인자로 받는 생성자를 자동으로 만들수도 있다.

각 클래스들의 인스턴스를 만들어 애플리케이션 컨텍스트에 추가하게 되는데 어노테이션 기반이라 필요한 곳에 작성만 잘하면 손쉽게 애플리케이션을 조립할 수 있지만 몇가지 단점도 존재한다.

  1. 클래스에 프레임워크에 특화된 어노테이션을 붙여야 한다는 점에서 침투적이다.
    일반적인 애플리케이션 개발에선 필요한 경우 한두개 정도는 용인되더라도 다른 개발자들이 사용할 라이브러리나 프레임워크를 만드는 입장이라면 사용자가 스프링 프레임워크의 의존성에 엮이게 되서 쓰지 말아야 한다.
  2. 스프링 전문가가 아니라면 문제가 발생했을 때 원인을 찾는데 많은 시간이 소요될 수 있다.
    이는 클래스패스 스캐닝이 단순히 스프링에게 부모 패키지를 알려 준 후 이 패키지 안에서 @Compoment가 붙은 모든 클래스를 찾는 방법이기 때문이다.
    애플리케이션 내 모든 클래스를 하나하나 다 알기 어렵다.

좀 더 제어하기 쉬운 대안으로 스프링의 Java Config로 조립하는 방법이 있다.

이 방식은 애플리케이션 컨텍스트에 추가할 빈을 생성하는 설정 클래스를 만든다.

@Configuration 어노테이션을 사용하여 설정 클래스임을 표시하여 클래스패스 스캐닝을 사용하는 방식은 모든 빈을 가져오는 대신 설정 클래스만 선택하기 때문에 제어가 한결 쉽다.

빈 자체는 설정 클래스 내의 @Bean 어노테이션이 붙은 팩토리 메서드를 통해 생성한다.

@EnabledJpaRepositories 어노테이션을 사용하여 스프링 부트가 정의된 모든 스프링 데이터 리포지토리 인터페이스의 구현체를 제공할 것이다.

이 어노테이션은 설정 클래스가 아니라 메인 애플리케이션에도 붙일 수 있지만 그럴 경우 애플리케이션이 시작할 때마다 JPA를 활성화해서 영속성이 실질적으로 필요없는 테스트에서 애플리케이션을 실행할 때도 JPA 리포지토리들을 활성화 할 것이다.
따라서 이러한 기능 어노테이션은 별도의 설정 모듈에 있는 것이 한꺼번에 모든 것이 시작할 필요가 없어져서 애플리케이션을 더 유연하게 만들어 준다.

이러한 방식은 일반적인 클래스패스 스캐닝 방식에 비해 @Component 어노테이션을 코드 여기저기에 붙이도록 강제하지 않아서 애플리케이션 계층을 스프링 프레임워크에 대한 의존성 없이 깔끔하게 유지할 수 있다.

다만, 설정 클래스가 생성하는 빈이 설정 클래스와 같은 패키지에 존재하지 않는다면 이 빈들을 public 으로 만들어야 한다.

8. 경계 간 매핑하기

앞서 웹, 애플리케이션, 도메인, 영속성 계층의 역할에 대해선 이야기 했으니 각 계층의 모델을 매핑하는 것에 대해서 이야기해보자.

매핑을 하지 않으면 양 계층에서 같은 모델을 사용하게 되면서 두 계층이 강하게 결합된다.

하지만 보일러플레이트 코드를 너무 많이 만들게 되는 단점이 발생하기도 한다.

매핑하기는 크게 4가지 전략이 있다.

  1. 매핑하지 않기 전략

계층들을 연결하는 포트 인터페이스가 도메인 모델을 입출력 모델로 사용하여 모든 계층이 같은 모델을 사용한다면 계층간 매핑이 전혀 필요없다.

하지만 이러한 전략은 특정 계층에 특별한 요구사항이 발생할 경우 그와 상관없는 모든 계층의 모델에 새로운 필드나 어노테이션이 추가될 수 있다.

도메인 모델이 다른 계층들과 관련된 이유로 변경되야 하므로 단일 책임 원칙을 위반한다.

하지만 간단한 CRUD 유스케이스같은 경우는 같은 필드를 가진 웹 모델을 도메인 모델로, 혹은 도메인 모델을 영속성 모델로 매핑할 필요가 없을 것이다.

모든 계층이 정확히 같은 구조의, 정확히 같은 정보를 필요로 한다면 매핑하지 않기 전략은 좋은 선택지가 될 수 있다.

  1. 양방향 매핑 전략

각 계층이 전용 모델을 가진 매핑 전략을 말한다.

각 어댑터가 전용 모델을 가지고 있어서 해당 모델을 도메인 모델로, 도메인 모델을 해당 모델로 매핑할 책임을 가지고 있다.

웹 계층의 컨트롤러는 웹 모델을 서비스와 연결되는 포트 인터페이스에서 도메인 모델로 매핑하고 반환된 도메인 객체를 다시 웹 모델로 매핑한다. 영속성 계층도 이와 유사하게 구현한다.

각 계층은 전용 모델을 가지고 있으므로 각 계층이 전용 모델을 변경하더라도 다른 계층에는 영향이 없다.

각 모델은 계층에 필요한 데이터, 유스케이스를 제일 잘 구현할 수 있고 도메인 모델은 웹이나 영속성의 관심사로 오염되지 않는 깨끗한 도메인 모델로 이어져서 단일 책임 원칙을 만족한다.

개념적으로 매핑하지 않기 다음으로 간단하여 매핑 책임이 명확한데 매핑보다 도메인 로직에 집중할 수 있다.

다만, 너무 많은 보일러플레이트 코드가 생기는데 두 모델 간 매핑을 구현하는 데 꽤 시간이 들고 매핑 프레임워크를 사용하여 내부 동작을 제네릭과 리플렉션 뒤로 숨길 경우 디버깅하기도 쉽지 않다.

또다른 단점으로 도메인 모델이 계층 경계를 넘어서 통신하는데 사용된다는 것이다.

인커밍, 아웃고잉 포트는 도메인 객체를 입력 파라미터와 반환값으로 사용하면서 바깥쪽 계층의 요구에 따라 변경에 취약해질 수 있다.

  1. 완전 매핑 전략

각 연산마다 모두 별도의 입출력 모델을 사용할 수 있다. 계층 경계를 넘어 통신할 때 도메인 모델을 사용하는게 아니라 각 작업에 특화된 전용 모델을 만드는 것이다.

당연히 한 계층을 다른 여러 개의 커맨드로 연결하게 되면 하나의 웹 모델과 도메인 모델 간의 매핑보다 더 많은 코드가 필요하지만 여러 유스케이스의 요구사항을 함께 다뤄야 하는 매핑에 비해 구현하고 유지보수 하기가 훨씬 쉽다.

완전 매핑 전략은 전역적으로 적용하기 보다는 웹 계층과 애플리케이션 계층 사이에 상태 변경 유스케이스의 경계를 명확하게 할 때 가장 좋다.

애플리케이션 계층과 영속성 계층 사이에는 매핑 오버헤드 때문에 추천하지 않는다.

  1. 단방향 매핑 전략

모든 계층의 모델들이 같은 인터페이스를 구현한다. 이 인터페이스는 관련있는 모든 필드에 대한 getter 메서드를 제공하여 도메인 모델의 상태를 캡슐화 한다.

웹, 영속성 계층으로 도메인 객체를 전달하고 싶으면 별도의 매핑 없이 할수 있다. 모든 계층이 바라보는 상태 인터페이스를 사용할지, 전용 모델로 매핑할지는 바깥 계층에서 정할 수 있다.

행동을 변경하는 것이 상태 인터페이스에 의해 노출돼 있지 않기 때문에 실수로 도메인 객체의 상태를 변경하는 일은 발생하지 않는다.

각 매핑 전략은 저마다의 장단점이 있때문에 한 전략을 전역적으로 사용하기 보다는 그때그때 상황에 맞는 전략을 구사해야 한다.

7. 아키텍처 요소 테스트하기

기본 전제

  • 만드는 비용이 적고
  • 유지보수 하기 쉬워야 하며
  • 빨리 실행되고 안정적인 작은 크기의 테스트들에 대해 높은 커버리지를 유지해야 한다.

테스트 피라미드

  • 시스템 테스트 : 애플리케이션을 구성하는 모든 객체 네트워크를 가동시켜 특정 유스케이스가 전 계층에서 잘 동작하는지 검증
  • 통합테스트: 연결된 여러 유닛을 인스턴스화하고 시작점이 되는 클래스의 인터페이스로 데이터를 보낸 후 유닛들의 네트워크가 기대한대로 잘 동작하는지 검증
  • 단위테스트 : 하나의 클래스를 인스턴스화하고 해당 클래스의 인터페이스를 통해 기능들을 테스트

클린 아키텍처 기준

  • 단위 테스트 : 특정 코드의 중요한 로직들이 의도한대로 별도의 외부 의존성 없이 제대로 돌아가는지 검증이 필요한 경우, 도메인 엔티티 테스트, 유스케이스 테스트
  • 통합 테스트 : 웹 어댑터, 영속성 어댑터와 같이 외부 클라이언트와 상호작용을 통해 검증이 필요한 경우
  • 시스템 테스트 : 전체 애플리케이션을 띄우고 API를 통해 요청을 보내고 모든 계층이 조화롭게 잘 동작하는지 검증

앞서 단위, 통합 테스트를 잘 구현했다면 시스템 테스트는 일부 겹치는 로직도 있지만 단위,통합만으로 알아차리지 못한 계층 간 매핑 버그 같은건 시스템 테스트를 통해서 알게되는 경우도 있다.

시스템 테스트를 통해 중요한 시나리오들을 모두 커버하면 배포할 준비가 된것이다.

단순히 라인 커버리지를 100%로 만드는 것을 목표로 테스트하는건 잘못된 지표이다.

처음 몇번의 배포는 믿음의 도약을 하고 이후 버그를 수정하고 이로부터 배우는 것을 목표로 삼는 다면 제대로 가는 것이다.

“테스트가 이 버그를 왜 잡지 못했을까?” 를 생각하고 이에 대한 답변을 기록하고, 이 케이스를 커버할 수 있는 테스트를 추가해야 한다.

새로운 필드를 추가할때마다 테스트를 고치는데 한 시간을 써야 한다면 뭔가 잘못된것이다.

테스트가 구조적 변경에 너무 취약하여 리팩토링할 때마다 테스트 코드도 변경해야 한다면 테스트로서의 가치를 잃는다.

헥사고날 아키텍처는 도메인 로직과 바깥으로 향한 어댑터를 깔끔하게 분리하여 핵심 도메인 로직은 단위 테스트로, 어댑터는 통합 테스트로 처리하는 명확한 테스트 전략을 정의할 수 있다.