계층형 아키텍처의 문제는 무엇일까?

- 웹 계층 : 요청을 받아 도메인 혹은 비즈니스 계층에 있는 서비스로 요청을 보낸다.
- 도메인 계층 : 필요한 비즈니스 로직을 수행하고, 도메인 엔티티의 현재 상태를 조회하거나 변경하기 위해 영속성 계층의 컴포넌트를 호출한다.
- 영속성 계층 : 엔티티를 조회 또는 변경한다.
계층형 아키텍처는 견고한 아키텍처 패턴이다. 계층을 잘 이해하고 구성한다면 웹 계층이나 영속성 계층에 독립적으로 도메인 로직을 작성할 수 있다. 원한다면 도메인 로직에 영향을 주지 않고 웹 계층과 영속성 계층에 사용된 기술을 변경할 수 있다. 기존 기능에 영향을 주지 않고 새로운 기능을 추가할 수도 있다.
잘 만들어진 계층형 아키텍처는 선택의 폭을 넓히고, 변화하는 요구사항과 외부 요인에 빠르게 적응할 수 있게 해주는데 문제점은 무엇일까? → 나쁜 습관이 스며들기 쉽고 시간이 지날수록 소프트웨어를 점점 더 변경하기 어렵게 만드는 허점을 노출한다.
계층형 아키텍처의 문제점
계층형 아키텍처는 데이터베이스 주도 설계를 유도한다.
우리가 만드는 대부분의 애플리케이션의 목적은 상태가 아니라 행동을 중심으로 모델링한다. 상태도 중요하지만 행동이 상태를 바꾸는 주체이기 때문에 행동이 비즈니스를 이끌어간다. 하지만, 전통적인 계층형 아키텍처에서는 영속성 계층을 먼저 구현한 경우가 대부분이다. 의존성의 방향에 따라 자연스럽게 구현한 것이기 때문이다.

지름길을 택하기 쉬워진다.
전통적인 계층형 아키텍처에서 전체적으로 적용되는 유일한 규칙은, 특정한 계층에서는 동일 계층이거나 아래에 있는 계층에만 접근 가능하다는 것이다.
만약 상위 계층에 위치한 컴포넌트에 접근해야 한다면 간단하게 컴포넌트를 계층 아래로 내려버리면 된다. 그러면 접근이 가능하고, 깔끔하게 문제가 해결된다. (이렇게 한다면 문제 해결은 간단하지만 아래 그림처럼 점점 비대하질 것이다.)

테스트 하기 어렵고, 유스케이스를 숨긴다.
이 밖에도 단순 도메인 로직을 웹 계층에서 진행하는 경우도 생길 수 있다. 만약 유스케이스가 확장된다면 더 많은 도메인 로직이 웹 계층에 추가될 수 있고 이는 수정과 테스트에 어려움을 증가시킨다.
기존에는 findById() 처럼 단순 조회는 웹 계층에서 진행했는데 이러한 단순 조회도 도메인 계층을 통하는 것이 좋다.
마지막으로 도메인 계층의 서비스의 ‘너비’에 관한 규칙을 강제하지 않는다. 그렇기 때문에 시간이 지나면 여러 개의 유스케이스를 담당하는 아주 넓은 서비스가 만들어 질 수 있다. 이 또한 영속성 계층에 많은 의존성을 갖게 되고, 다시 웹 계층의 많은 컴포넌트가 이 서비스에 의존하게 된다. 그러면 테스트하기가 어렵고 수정에도 어려워진다.
동시 작업이 어려워진다.
하나의 서비스에 많은 기능을 포함하고 있는 경우에는 다른 유스케이스에 대한 작업을 하더라도 병합 충돌 문제를 야기할 수 있다.
의존성 역전하기
단일 책임 원칙
하나의 컴포넌트는 오로지 한 가지 일만 해야 하고, 그것을 올바르게 수행해야 한다. → 컴포넌트를 변경하는 이유는 오직 하나뿐이어 한다.
만약 컴포넌트를 변경할 이유가 한 가지라면 우리가 어떤 다른 이유로 소프트웨어를 변경하더라도 이 컴포넌트에 대해서는 전혀 신경 쓸 필요가 없다. 왜? 여전히 우리가 기대한 대로 동작할 것이 명백하니까. (변경할 이유는 컴포넌트의 의존성을 통해 쉽게 전파된다!)
부수효과 : 컴포넌트를 변경했을 때 어느 컴포넌트가 망가지는 상황
의존성 역전 원칙
계층형 아키텍처에서 계층 간 의존성 때문에 영속성 계층을 변경할 때마다 잠재적으로 도메인 계층도 변경해야 한다. 그러나 도메인 코드는 애플리케이션에서 가장 중요한 코드이다. 이를 방지하기 위한 방법이 있을까? → 의존성 역전 원칙
의존성 역전 원칙은 양쪽 코드를 모두 제어할 수 있을 때 가능!!(당연히 서드파티 라이브러리는 불가능)
의존성 역전은 어떻게 동작할까? 그림 2에서는 도메인 계층의 서비스가 영속성 계층의 엔티티와 레포지토리를 의존하고 있다.
가장 먼저, 엔티티를 도메인 계층으로 올린다. 그러면 두 계층 사이의 순환 의존성이 생기는데, 이 부분에 DIP를 적용하여 레포지토리 인터페이스를 도메인 계층에 만들고 실제는 영속성 계층에 구현하게 하는 것이다.

클린 아키텍처
도메인 코드가 바깥으로 향하는 어떤 의존성도 없어야 하며 대신 의존성 역전 원칙의 도움으로 모든 의존성이 도메인 코드를 향하게 한다.
구조
엔티티 ← 유스케이스 ← 컨트롤러, 게이트웨이, 프레젠터 ← 웹, UI, 외부 인터페이스, 데이터베이스, 장치
(유스케이스 : 단일 책임을 갖기 위해 서비스를 세분화 → 넓은 서비스 문제를 피할 수 있다.)
대가
애플리케이션의 엔티티에 대한 모델을 각 계층에서 유지보수해야 한다. 왜? 도메인 계층은 영속적 계층을 모르기 때문에 도메인 계층에서 사용하는 엔티티 클래스를 영속성 계층에서 사용할 수 없고 두 계층에서 각각 엔티티를 만들어야한다. 즉, 두 계층 사이에서 데이터를 주고 받을 때 서로 변환해서 주고받아야 한다. (다른 계층도 동일)
육각형 아키텍처(헥사고날 아키텍처)

육각형 안에는 도메인 엔티티와 이와 상호작용하는 유스케이스가 있다. 육각형에서 외부로 향하는 의존성이 없기 때문에 마틴이 클린 아키텍처에서 제시한 의존성 규칙이 그대로 적용. 대신 모든 의존성을 코어를 향한다.
애플리케이션 코어와 어댑터들 간의 통신이 가능하려면 애플리케이션 코어가 각각의 포트를 제공해야 한다. 주도하는 어댑터에게는 그러한 포트가 코어에 있는 유스케이스 클래스들에 의해 구현되고 호출되는 인터페이스가 될 것이고, 주도되는 어댑터에게는 그러한 포트가 어댑터에 의해 구현되고 코어에 의해 호출되는 인터페이스 될 것이다.
코드 구성하기
계층으로 구성하기
- buckpal
- domain
- Account
- Activity
- AccountRepository
- AccountService
- persistence
- AccountRepositoryImpl
- web
- AccountController
- domain
(의존성 역전 원칙을 적용한 패키지 구조)
위 패키지 구조가 최적의 구조가 아닌 이유
- 애플리케이션의 기능 조각이나 특성을 구분 짓는 패키지 경계가 없다.
- 애플리케이션이 어떤 유스케이스들을 제공하는지 파악할 수 없다.
- 패키지 구조를 통해서는 우리가 목표로 하는 아키텍처를 파악할 수 없다.
기능으로 구성하기
- buckpal
- account
- Account
- AccountController
- AccountRepository
- AccountRepositoryImpl
- SendMoneyService
- account
가장 본질적인 변경은 계좌와 관련된 모든 코드를 최상위에 account 패키지에 넣었다는 점이다. 계층 패키지들도 없앴다.
각 기능을 묶은 새로운 그룹은 account와 같은 레벨의 새로운 패키지로 들어가고, 패키지 외부에서 접근되면 안 되는 클래스들에 대해 package-private 접근 수준을 이용하여 패키지 간의 경계를 강화할 수 있다.
또한, Service의 클래스명을 수정하였다. 그러나 기능에 의한 패키징 방식은 사실 계층에 의한 패키징 방식보다 아키텍처의 가시성을 훨씬 더 떨어뜨린다. 어댑터를 나타내는 패키지명이 없고, 인커밍 포트, 아웃고잉 포트를 확인할 수 없다.
아키텍처적으로 표현력 있는 패키지 구조
- buckpal
- account
- adapter
- in
- web
- AccountController
- web
- out
- persistence
- AccountPersistenceAdapter
- SpringDataAccountRepository
- persistence
- in
- domain
- Account
- Activity
- application
- SendMoneyService
- port
- in
- SendMoneyUseCase
- out
- LoadAccountPort
- UpdateAccountStatePort
- in
- adapter
- account
육각형 아키텍처에서 구조적으로 핵심적인 요소는 엔티티, 유스케이스, 인커밍/아웃고잉 포트, 인커밍/아웃고잉 어댑터이다.
application 패키지는 도메인 모델을 둘러싼 서비스 계층을 표현한다.
SendMoneyService는 인커밍 포트 인터페이스인 SendMoneyUseCase를 구현하고, 아웃고잉 포트 인터페이스이자 영속성 어댑터에 의해 구현된 LoadAccountPort와 UpdateAccountStatePort를 사용한다.
adapter 패키지는 애플리케이션 계층의 인커밍 포트를 호출하는 인커밍 어댑터와 애플리케이션 계층의 아웃고잉 포트에 대한 구현을 제공하는 아웃고잉 어댑터를 포함한다.
패키지 간의 접근 제한 설정
위 패키지 구조에서는 다양한 패키지가 존재한다. 그렇다면 모든 것을 public으로 만들어서 패키지 간의 접근을 허용해야하는가에 대한 의문이 생긴다.
적어도, adapter 패키지에 대해서는 그렇지 않다. 해당 패키지에 들어 있는 모든 클래스들은 application 패키지 내에 있는 포트 인터페이스를 통하지 않고는 바깥에서 호출되지 않기 때문에 package-private 접근 수준으로 설정해도 된다.(DI를 통해 해결)
하지만 application 패키지와 domain 패키지 내의 일부 클래스들은 public으로 지정해야 한다. 의도적으로 어댑터에서 접근 가능해야 하는 포트들은 public이어야 한다. 도메인 클래스들은 서비스, 그리고 잠재적으로는 어댑터에서도 접근 가능하도록 public이어야 한다. 서비스는 인커밍 포트 인터페이스 뒤에 숨겨질 수 있기 때문에 public일 필요가 없다.
유스케이스 구현하기
유스케이스 둘러보기
일반적으로 유스케이스는 다음과 같은 단계를 따른다.
- 입력을 받는다.
- 비즈니스 규칙을 검증한다.
- 모델 상태를 조작한다.
- 출력을 반환한다.
유스케이스는 인커밍 어댑터로부터 입력을 받는데 해당 검증은 다른 곳에서 진행을 하며 비즈니스 규칙 검증에 책임을 가져야한다.
입력 유효성 검증
호출하는 어댑터가 입력을 전달하기 전에 입력 유효성에 대해서 검증한다면 과연 신뢰할 수 있을까? 또한 하나 이상의 어댑터에서 호출될 텐데, 각 어댑터마다 전부 구현을 해야하는가? 라는 의문이생긴다.
결국 입력 유효성은 어플리케이션 계층에서 해야하는데 그 내부 어디에서 해야할까? 해당 문제는 입력 모델 내부에서 해결하도록 하자.
유스케이스마다 다른 입력 모델
각기 다른 유스케이스에 동일한 입력 모델을 사용하고 싶을 때가 생기지만 지양하는 부분. 왜? 세부적인 유효성 검증 로직을 넣어야 한다.
비즈니스 규칙 검증하기
입력 유효성을 검증하는 것은 구문상의 유효성을 검증하는 것이라고도 할 수 있다. 반면 비즈니스 규칙은 유스케이스의 맥락 속에서 의미적인 유효성을 검증하는 일이라고 할 수 있다.
현재 모델의 상태에 접근해야 하는지 여부를 확인하자!
ex)
출금 계좌는 초과 출금되어서는 안 된다. (접근 O)
송금되는 금액은 0 이상이어야 한다. (접근 X)
비즈니스 규칙 검증은 도메인 엔티티 또는 유스케이스 코드에서 구현 (대부분 도메인 엔티티에 구현하는 것이 위치를 정하기도 쉽고 추론하기 쉽다.)
유스케이스마다 다른 출력 모델
입력도 비슷하게 출력도 가능하면 각 유스케이스에 맞게 구체적일수록 좋다. 출력은 호출자에게 꼭 필요한 데이터만 들고 있어야 한다. 위 이유와 비슷하게 웬만하면 분리하자.
웹 어댑터 구현하기
의존성 역전


그림 7과 같이 웹 어댑터가 유스케이스를 직접 호출할 수 있다. 하지만 포트를 적절한 곳에 위치시키면 외부와 어떤 통신이 일어나고 있는지 정확히 알 수 있고, 이는 레거시 코드를 다루는 유지보수 엔지니어에게는 무척 소중한 정보이다.

위 웹소켓 컨트롤러는 인커밍 어댑터인 동시에 아웃고잉 어댑터가 된다.
웹 어댑터의 책임
- HTTP 요청을 자바 객체로 매핑
- 권한 검사
- 입력 유효성 검증
- 입력을 유스케이스의 입력 모델로 매핑
- 유스케이스 호출
- 유스케이스의 출력을 HTTP로 매핑
- HTTP 응답을 반환
앞에 내용에서 입력 유효성 검증은 유스케이스 입력 모델의 책임이라고 얘기했었는데, 유스케이스 입력 모델은 유스케이스의 맥락에서 유효한 입력만 허용해야 한다. 그러나 여기서는 웹 어댑터의 입력 모델에 대해 이야기하고 있는 것이다. 유스케이스의 입력 모델과는 구조나 의미가 완전히 다를 수 있으므로 또 다른 유효성 검증을 수행해야 한다.
유스케이스 입력 모델에서 했던 유효성 검증을 똑같이 웹 어댑터에서도 구현해야 하는 것은 아니다. 대신, 웹 어댑터의 입력 모델을 유스케이스이 입력 모델로 변환할 수 있다는 것을 검증해야 한다. (이 변환을 방해하는 모든 것이 유효성 검증 에러다.)
컨트롤러 나누기
너무 적은 것보다는 너무 많은 게 낫다. 각 컨트롤러가 가능한 한 좁고 다른 컨트롤러와 가능한 한 적게 공유하는 웹 어댑터 조각을 구현해야 한다.
하나의 클래스에 관련된 모든 것이 모여 있으면 괜찮아 보인다. 하지만 다음과 같은 단점이 존재한다.
- 클래스마다 코드는 적을수록 좋다. (파악하기 어렵고, 테스트 코드 작성에도 어렵다.)
- 데이터 구조의 재활용을 촉진한다.
영속성 어댑터 구현하기
의존성 역전

애플리케이션 서비스에서는 영속성 기능을 사용하기 위해 포트 인터페이스를 호출한다. 이 포트는 실제로 영속성 작업을 수행하고 데이터베이스와 통신할 책임을 가진 영속성 어댑터 클래스에 의해 구현된다.
포트는 사실상 영속성 계층에 대한 코드 의존성을 없애기 위해 이러한 간접 계층을 추가하고 있다. 영속성 코드를 리팩터링하더라도 코어 코드를 변경하는 결과로 이어지지 않을 것이다.
영속성 어댑터의 책임
- 입력을 받는다.
- 입력을 데이터베이스 포맷으로 매핑한다
- 입력을 데이터베이스로 보낸다
- 데이터베이스 출력을 애플리케이션 포맷으로 매핑한다
- 출력을 반환한다
영속성 어댑터는 포트 인터페이스를 통해 입력을 받는다. 입력 모델은 인터페이스가 지정한 도메인 엔티티나 특정 데이터베이스 연산 전용 객체가 될 것이다.
핵심은 영속성 어댑터의 입력 모델이 영속성 어댑터 내부에 있는 것이 아니라 애플리케이션 코어에 있기 때문에 영속성 어댑터 내부를 변경하는 것이 코어에 영향을 미치지 않는다는 것이다.
포트 인터페이스 나누기

위 구조처럼 작성을 한다면 인터페이스에서 단 하나의 메서드만 사용하더라도 하나의 ‘넓은’ 포트 인터페이스에 의존성을 갖게 된다. 코드에 불필요한 의존성이 생겼다는 뜻이다. → 맥락 안에서 필요하지 않은 메서드에 생긴 의존성은 코드를 이해하고 테스트하기 어렵게 만든다. → 인터페이스의 일부만 모킹하는 것은 다음에 이 테스트에서 작업하는 사람은 인터페이스 전체가 모킹됐다고 기대하는 바람에 에러를 보게 될 수 있기 때문이다.
이 문제의 답은 ‘인터페이스 분리 원칙’

각 서비스를 실제로 필요한 메서드에만 의존하게 한다면, 나아가 포트의 이름이 포트의 역할을 명확하게 잘 표현하고 있다. 테스트에서는 어떤 메서드를 모킹할지 고민할 필요가 없다.
왜냐하면 대부분의 경우 포트당 하나의 메서드만 있을 것이기 때문이다. 물론 모든 상황에 ‘포트 하나당 하나의 메서드’를 적용하지는 못할 것이다. 응집성이 높고 함께 사용될 때가 많기 때문에 하나의 인터페이스에 묶고 싶은 데이터베이스 연산들이 있을 수 있다.
영속성 어댑터 나누기
이전 그림에서는 모든 영속성 포트를 구현한 단 하나의 영속성 어댑터 클래스가 있었다. 그러나 모든 영속성 포트를 구현하는 한, 하나 이상의 클래스 생성을 금지하는 규칙은 없다. 아래와 같이 영속성 연산이 필요한 도메인 클래스 하나당 하나의 영속성 어댑터를 구현하는 방식을 선택할 수 있다.

경계 간 매핑하기
‘매핑하지 않기’ 전략

동일한 모델로 여러 계층에서 사용하게 된다면 단일 책임 원칙을 위반할 확률이 크다… 그런데, 그렇다고 절대로 쓰면 안 된다는 건가? 만약 모든 계층이 정확히 같은 구조의, 같은 정보를 필요로 하다면 ‘매핑하지 않기’ 전략은 완벽한 선택지이다.
‘양방향’ 매핑 전략

이 매핑 전략은 웹이나 영속성 관심사로 오염되지 않은 깨끗한 도메인 모델로 이어진다. JSON이나 ORM 매핑 애너테이션도 없어도 된다. 단일 책임 원칙을 만족하는 것이다. 또 다른 장점은 개념적으로는 ‘매핑하지 않기’ 전략 다음으로 간단한 전략이라는 것이다. 매핑 책임이 명확하다.
단점으로는 너무 많은 보일러플레이트 코드가 생깁니다. 코드의 양을 줄이기 위해 매핑 프레임워크를 사용하더라도 두모델 간 매핑을 구현하는 데는 꽤 시간이 든다.
또 다른 단점은 도메인 모델이 계층 경계를 넘어서 통신하는 데 사용되고 있다는 것이다. 인커밍 포트와 아웃고잉 포트는 도메인 객체를 입력 파라미터와 반환값으로 사용한다.
‘완전’ 매핑 전략

이 매핑 전략에서는 각 연산마다 별도의 입출력 모델을 사용한다. 해당 전략은 웹 계층과 애플리케이션 계층 사이에서 상태 변경 유스케이스의 경계를 명확하게 할 때 가장 빛을 발한다. 애플리케이션 계층과 영속성 계층 사이에서는 매핑 오버헤드 때문에 사용하지 않는 것이 좋다.
또한 어떤 경우에는 연산의 입력 모델에 대해서만 이 매핑을 사용하고, 도메인 객체를 그대로 출력 모델로 사용하는 것도 좋다. SendMoneyUseCase가 업데이트된 잔고를 가진 채로 Account 객체를 그대로 반환하는 것처럼 말이다.
‘단방향’ 매핑 전략

이 전략에서는 모든 계층의 모델들이 같은 인터페이스를 구현한다. 이 인터페이스는 관련 있는 특성에 대한 getter 메서드를 제공해서 도메인 모델의 상태를 캡슐화한다.
도메인 모델 자체는 풍부한 행동을 구현할 수 있고, 애플리케이션 계층 내의 서비스에서 이러한 행동에 접근할 수 있다. 도메인 객체를 바깥 계층으로 전달하고 싶으면 매핑 없이 할 수 있다. 왜냐하면 도메인 객체가 인커밍/아웃커밍 포트가 기대하는 대로 상태 인터페이스를 구현하고 있기 때문이다.
이 전략에서 매핑 책임은 명확하다. 만약 한 계층이 다른 계층으로부터 객체를 받으면 해당 계층에서 이용할 수 있도록 다른 무언가로 매핑하는 것이다. 그러므로 각 계층은 한 방향으로만 매핑한다. 그래서 이 전략의 이름이 ‘단방향’ 매핑 전략인 것이다. 그래서 계층 간의 모델이 비슷할 때 가장 효과적이다.
그래서 언제 어떤 매핑 전략을 사용할 것인가?
사실 정답은 없다. 그때그때마다 다르다. 추천하는 방법으로는 다음과 같다.
- 하나의 전략을 전체 코드에 적용하는 것은 피하자.
- 빠르게 코드를 짤 수 있는 간단한 전략으로 시작해서 계층 간 결합을 떼어내는 데 도움이 되는 복잡한 전략으로 갈아타자.
- 팀 내에서 합의할 수 있는 가이드라인을 정하고 내용으로는 어떤 상황에서 어떤 매핑 전략을 가장 먼저 택해야 하는가 + 왜 해당 전략을 최우선으로 택해야 하는가
가이드 라인 예시
변경 유스케이스를 작업하고 있다면 웹 계층과 애플리케이션 계층 사이에서는 유스케이스 간의 결합을 제거하기 위해 ‘완전 매핑’ 전략을 첫 번째 선택지로 택해야 한다. 이렇게 하면 유스케이스별 유효성 검증 규칙이 명확해지고 특정 유스케이스에서 필요하지 않은 필드를 다루지 않아도 된다.
변경 유스케이스를 작업하고 있다면 애플리케이션과 영속성 계층 사이에서는 매핑 오버헤드를 줄이고 빠르게 코드를 짜기 위해서 ‘매핑하지 않기’ 전략을 첫 번째 선택지로 둔다. 하지만 애플리케이션 계층에서 영속성 문제를 다뤄야 하게 되면 ‘양방향’ 매핑 전략으로 바꿔서 영속성 문제를 영속성 계층에 가둘 수 있게 한다.
쿼리 작업을 한다면 매핑 오버헤드를 줄이고 빠르게 코드를 짜기 위해 ‘매핑하지 않기’ 전략이 웹 계층과 애플리케이션 계층 사이, 애플리케이션 계층과 영속성 계층 사이에서 첫 번째 선택지가 돼야 한다. 하지만 애플리케이션 계층에서 영속성 문제나 웹 문제를 다뤄야 하게 되면 웹 계층과 애플리케이션 계층, 애플리케이션 계층과 영속성 계층 사이에서 각각 ‘양방향’ 매핑 전략으로 바꿔야 한다.