1장 계층형 아키텍처의 문제는 무엇일까?
계층형 아키텍처는 크게 3가지 레이어로 나뉜다. 웹, 도메인, 영속성 레이어다. 계층을 잘 이해하고 구성한다면 웹 계층이나 영속성 계층에 독립적으로 도메인 로직을 작성할 수도있고, 도메인 로직에 영향을 주지 않고 웹 계층과 영속성 계층에 사용된 기술을 변경할 수 있다. 그리고 기존 기능에 영향을 주지 않고 새로운 기능을 추가할 수도 있다.
잘 만들어진 계층형 아키텍처는 선택의 폭을 넓히고, 변화하는 요구사항과 외부 요인에 빠르게 적응할 수 있게 해준다. 그렇다면 계층형 아키텍처의 문제점은 무엇일까
계층형 아키텍처는 코드에 나쁜 습관들이 스며들기 쉽게 만들고 시간이 지날수록 소프트웨어를 점점 더 변경하기 어렵게 만드는 많은 허점들이 있다.
계층형 아키텍처는 데이터베이스 주도 설계를 유도한다.
계층형 아키텍처는 데이터베이스가 토대이다. 웹 계층은 도메인 계층에 의존하고, 도메인 계층은 영속성 계층에 의존하기 때문에 자연스레 데이터 베이스에 의존하게 된다.
우리는 상태(state)가 아니라 행동(behavior)을 중심으로 모델링한다. 상태가 중요한 요소이긴 하짐나 행동이 상태를 바꾸는 주체이기 때문에 행동이 비즈니스를 이끌어간다. 애플리케이션을 만들려고 할 때 도메인 로직보다 영속성 계층을 먼저 생각하게 된다. 계층형 아키텍처에서는 합리적인 방법이다. 비즈니스 관점에서는 맞지 않는 방법이다. 도메인 로직을 먼저 만들어야한다.
이런 아키텍처가 만들어지는 원인은 ORM 프레임워크를 사용하기 때문이다. ORM에 의해 관리되는 엔티티들은 일반적으로 영속성 계층에 두고, 아래 방향으로만 접근 가능 하기 때문에 도메인 계층에서는 이러한 엔티티에 접근할 수 있다. 하지만 이렇게 되면 영속성 계층과 도메인 계층 사이에 강한 결합이 생긴다.
지름길을 택하기 쉬워진다.
전통적인 계층형 아키텍처에서 전체적으로 적용되는 유일한 규칙은, 특정한 계층에서는 같은 계층에 있는 컴포넌트나 아래에 있는 계층에만 접근 가능하다.
테스트하기 어려워진다.
계층형 아키텍처를 사용할 때 일반적으로 나타나는 변화의 형태는 계층을 건너뛰는 것이다. 엔티티의 필드를 단 하나만 조작하면 되는 경우에 웹 계층에서 바로 영속성 계층에 접근하면 도메인 계층을 건드릴 필요가 없지 않을까? 라는 생각에서 출발한다.
이런 일이 자주 일어난다면 단 하나의 필드를 조작하는 것에 불과하더라도 도메인 로직을 웹 계층에 구현하게된다. 그리고 웹 계층 테스트에서 도메인 계층뿐만 아니라 영속성 계층도 모킹해야 한다는 것이다.
유스케이스를 숨긴다.
개발자들은 새로운 유스케이스를 구현하는 새로운 코드를 짜는 것을 선호한다. 그러나 기존 코드를 바꾸는 데 더 많은 시간을 쓰게 된다. 이는 신규 프로젝트에서도 마찬가지이다.
아키텍처는 코드를 빠르게 탐색하는 데 도움이 되어야 한다. 계층형 아키텍처는 도메인 로직이 여러 계층에 걸쳐 흩어지기 쉽다.
동시 작업이 어려워진다.
새로운 유스케이스를 추가 한다고 가정했을때, 개발자는 3명이 있다. 한명은 웹 계층에 필요한 기능을 추가하고, 다른 한 명은 도메인 계층, 나머지 한 명은 영속성 계층에 기능을 추가할 수 있다. 그러나 계층형 아키텍처에서는 이렇게 작업할 수 없다.
모든 것이 영속성 계층 위에 만들어지기 때문에 영속성 계층을 먼저 개발해야 하고, 그다음 도메인 계층, 그리고 마지막으로 웹 계층을 만들어야 한다. 그렇기 때문에 특정 기능은 동시에 한 명의 개발자만 작업할 수 있다. 코드에 넓은 서비스가 있다면 서로 다른 기능을 동시에 작업하기 더욱 어려우며, 서로 다른 유스케이스에 대한 작업을 하게 되면 같은 서비스를 동시에 편집하는 상황이 발생하고 이는 병합 충돌과 잠재적으로 이전 코드로 되돌려야 하는 문제를 야기한다.
2장 의존성 역전하기
1장에서의 계층형 아키텍처에 대한 대안이다.
단일 책임 원칙 (Single Responsibility Principle, SRP)과 의존성 역전 원칙(Dependency Inverision Principle, DIP)에 대해 이야기 한다.
단일 책임 원칙
단일 책임 원칙은 흔히 '하나의 컴포넌트는 오로지 한 가지 일만 해야하고, 그것을 올바르게 수행해야 한다.'라고 해석하지만 실제 정의는 '컴포넌트를 변경하는 이유는 오직 하나뿐이어야 한다.'로 해석된다.
아키텍처에서는 만약 컴포넌트를 변경할 이유가 한 가지라면 우리가 어떤 다른 이유로 소프트웨어를 변경하더라도 이 컴포넌트에 대해서는 전혀 신경 쓸 필요가 없다.
의존성 역전 원칙
계층형 아키텍처에서 계층 간 의존성은 항상 다음 계층인 아래 방향을 가리킨다. 단일 책임 원칙을 고수준에서 적용할 때 상위 꼐층들이 하위 계층들에 비해 변경할 이유가 더 많다.
영속성 계층에 대한 도메인 계층의 의존성 떄문에 영속성 계층을 변경할 때마다 잠재적으로 도메인 계층도 변경해야 한다. 의존성을 제거해야 영속성 계층을 변경해도 도메인은 변경하지 않을 수 있다.
'코드상의 어떤 의존성이든 그 방향을 바꿀 수(역전시킬 수) 있다'
의존성의 양쪽 코드를 모두 제어할 수 있을 때만 의존성을 역전시킬 수 있다. 엔티티는 도메인 객체를 표현하고 도메인 코드는 엔티티들의 상태를 변경하는 일을 중심으로 하기 때문에 먼저 엔티티를 도메인 계층으로 올리면 영속성 계층의 레포지토리가 도메인 계층에 있는 엔티티에 의존하기 때문에 두 계층 사이에 순환 의존성이 생기고, 이 부분이 DIP를 적용하는 부분이다.
클린 아키텍처
도메인 코드가 바깥으로 향하는 어떤 의존성도 없어야 함을 의미하고, 의존성 역전 원칙의 도움으로 모든 의존성이 도메인 코드를 향하고 있다.
클린 아키텍처의 계층들은 동심원으로 둘러싸여 있고, 아키텍처에서 가장 주요한 규칙은 의존성 규칙으로, 계층 간의 모든 의존성이 안쪽으로 향해야 한다.
아키텍처의 core 에는 주변 유스케이스에서 접근하는 도메인 엔티티들이 있고, 단일 책임(변경할 단 한 가지의 이유)을 갖기 위해 조금 더 세분화 되어 있다.
클린 아키텍처의 대가는 영속성 계층에서 ORM 프레임워크를 사용할 때 엔티티 클래스를 필요로 한다. 도메인 계층은 영속성 계층에서 함께 사용할 수 없고 두 계층에서 각각 엔티티를 만들어야 하며, 도메인 계층과 영속성 계층이 데이터를 주고받을 때, 두 엔티티를 서로 변환해야 한다.
번거로워 보이지만, 이는 바람직한 일이며 결합이 제거된 상태다.
육각형 아키텍처 (헥사고날 아키텍처)
'육각형 아키텍처' 라는 용어는 알리스테어 콕번이 만든 용어이다. 이 아키텍처는 로버트 C.마틴이 클린 아키텍처에서 좀 더 일반적인 용어로 설명한 것과 동일한 원칙을 적용한다.
육각형 안에는 도메인 엔티티와 이와 상호작용하는 유스케이스가 있다. 육각형에서 외부로 향하는 의존성이 없다. 대신 모든 의존성은 코어를 향한다.
육각형 바깥에는 다양한 어댑터들이 있다. 왼쪽에 있는 어댑터들은 (애플리케이션 코어를 호출하기 때문에) 애플리케이션을 주도하는 어댑터들이다. 반면 오른쪽에 있는 어댑터들은 (애플리케이션 코어에 의해 호출되기 때문에) 애플리케이션에 의해 주도되는 어댑터다.
애플리케이션 코어와 어댑터들 간의 통신이 가능하려면 애플리케잇녀 코어가 각각의 포트를 제공해야 하며, 주도하는 어댑터(driving adapter)에게는 그러한 포트가 코어에 있는 유스케이스 클래스 중 하나에 의해 구현되고 어댑터에 의해 호출되는 인터페이스가 될 것이고, 주도되는 어댑터(driven adapter)에게는 그러한 포트가 어댑터에 의해 구현되고 코어에 의해 호출되는 인터페이스가 될 것이다.
이런 핵심 개념으로 인해 ports-and-adapters 아키텍처로도 알려져 있다.
3장 코드 구성하기
사용자가 본인의 계좌에서 다른 계좌로 돈을 송금할 수 있는 송금하기 유스케이스를 기준으로 설명한다.
계층으로 구성하기
웹, 도메인, 영속성 각각에 대해 전용 패키지를 두고 domain 패키지에 AccountRepository 인터페이스를 추가하고, persisstence 패키지에 AccountRepositoryImpl 구현체를 둠으로 의존성을 역전 시켰다.
- 이 패키지 구조는 최적의 패키지 구조가 아니다.
- 애플리케이션의 기능 조각 이나 특성을 구분 짓는 패키지 경계가 없다
- 애플리케이션이 어떤 유스케이스들을 제공하는지 파악할 수 없다
- 패키지 구조를 통해서 우리가 목표로 하는 아키텍처를 파악할 수 없다.
기능으로 구성하기
기능으로 패키지를 나눈 구성이다. 기능에 의한 패키징 방식은 사실 계층에 의한 패키징 방식보다 아키텍처의 가시성을 훨씬 더 떨어뜨린다.
아키텍처적으로 표현력 있는 패키지 구조
육각형 앜키텍처에서 구조적으로 핵심적인 요소는 엔티티, 유스케이스, 인커밍/아웃고잉 포트, 인커밍/아웃고잉(혹은 주도하거나 주도되는) 어댑터다.
최상위 account 패키지 아래로 도메인 모델이 속한 domain 패키지가 있다. application 패키지는 도메인 모델을 둘러싼 서비스 계층을 포함한다. SendMoneyService는 인커밍 포트 인터페이스인 SendMoneyUseCase를 구현하고, 아웃고잉 포트 인터페이스이자 영속성 어댑터에 의해 구현된 LoadAccountPort와 UpdateAccountStatePort를 사용한다.
adapter 패키지는 애플리케이션 계층의 인커밍 포트를 호출하는 인커밍 어댑터, 애플리케이션 계층의 아웃고잉 포트에 대한 구현을 제공하는 아웃고잉 어댑터를 포함한다.
이러한 패키지 구조는 architecture-code gap 혹은 model-code gap 을 효과적으로 다룰 수 있는 요소다.
의존성 주입의 역할
클린 아키텍처의 가장 본질적인 요건은 애플리케이션 계층이 인커밍/아웃고잉 어댑터에 의존성을 갖지 않는 것이다.
웹 어댑터와 같이 인커밍 어댑터에 대해서는 그렇게 하기가 쉽다. 제어 흐름의 방향이 어댑터와 도메인 코드 간의 의존성 방향과 같은 방향이기 때문이다. 어댑터는 애플리케이션 계층에 위치한 서비스를 호출할 뿐이다. 그럼에도 불구하고 애플리케이션 계층으로의 진입점을 구분 짓기 위해 실제 서비스를 포트 인터페이스들 사이에 숨겨두고 싶을 수 있다.
영속성 어댑터와 같이 아웃고잉 어댑터에 대해서는 제어 흐름의 반대 방향으로 의존성을 돌리기 위해 의존성 역전 원칙을 이용해야 한다. 애플리케이션 계층에 인터페이스를 만들고 어댑터에 해당 인터페이스를 구현한 클래스를 두면 된다.
포트 인터페이스를 구현한 실제 객체를 누가 애플리케이션 계층에 제공해야 하는가? 의존성 주입을 활용해서 모든 계층에 의존성을 가진 중립적인 컴포넌트를 하나 도입하면 된다.
4장 유스케이스_구현하기
육각형 아키텍처는 도메인 중심의 아키텍처에 적합하기 때문에 도메인 엔티티를 만드는 것으로 시작한 후 해당 도메인 엔티티를 중심으로 유스케이스를 구현한다.
도메인 모델 구현
한 계좌에서 다른 계좌로 송금하는 유스케이스
객체지향적인 방식으로 모델링하는 한 가지 방법은 입금과 출금을 할 수 있는 Account 엔티티를 만들고 출금 계좌에서 돈을 출금해서 입금 계좌로 돈을 입금하는 것이다.
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class Account {
@Getter
private final AccountId id;
@Getter private final Money baselineBalance;
@Getter private final ActivityWindow activityWindow;
public static Account withoutId(
Money baselineBalance,
ActivityWindow activityWindow) {
return new Account(null, baselineBalance, activityWindow);
}
public static Account withId(
AccountId accountId,
Money baselineBalance,
ActivityWindow activityWindow) {
return new Account(accountId, baselineBalance, activityWindow);
}
public Optional<AccountId> getId(){
return Optional.ofNullable(this.id);
}
public Money calculateBalance() {
return Money.add(
this.baselineBalance,
this.activityWindow.calculateBalance(this.id));
}
public boolean withdraw(Money money, AccountId targetAccountId) {
if (!mayWithdraw(money)) {
return false;
}
Activity withdrawal = new Activity(
this.id,
this.id,
targetAccountId,
LocalDateTime.now(),
money);
this.activityWindow.addActivity(withdrawal);
return true;
}
private boolean mayWithdraw(Money money) {
return Money.add(
this.calculateBalance(),
money.negate())
.isPositiveOrZero();
}
public boolean deposit(Money money, AccountId sourceAccountId) {
Activity deposit = new Activity(
this.id,
sourceAccountId,
this.id,
LocalDateTime.now(),
money);
this.activityWindow.addActivity(deposit);
return true;
}
@Value
public static class AccountId {
private Long value;
}
}
Account 엔티티는 실제 계좌의 현재 스냅샷을 제공하며, 모든 입금과 출금은 Activity 엔티티에 포착된다. 한 계좌에 대한 모든 활등(activity) 들을 항상 메모리에 한꺼번에 올리는 것은 현명한 방법이 아니기 때문에 Account 엔티티는 ActivityWindow값 객체(value object) 에서 포착한 지난 며칠 혹은 몇 주간의 범위에 해당하는 활동만 보유한다.
Account 엔티티는 활동창(Activity window) 의 첫번째 활동 바로 전의 잔고를 표현하는 baselineBalance 속성을 가지고 있으며, 현재 총 잔고는 기준 잔고(baselineBalance) 에 활동창의 모든 활동들의 잔고를 합한 값이다.
이 모델 덕분에 계좌에서 일어나는 입금과 출근은 각각 withdraw(), deposit() 메서드에서처럼 새로운 활동을 활동창에 추가하는 것에 불과하고 출금하기 전에는 잔고를 초과하는 금액은 출금할 수 없도록 하는 비즈니스 규칙을 검사한다. 입금과 출금을 할 수 있는 Account 엔티티가 있으므로 이를 중심으로 유스케이스를 구현하기 위해 바깥 방향으로 뻗어 나간다.
유스케이스 둘러보기
- 유스케이스가 하는 일은 다음과 같다
- 입력을 받는다.
- 비즈니스 규칙을 검증한다.
- 모델 상태를 조작한다.
- 출력을 반환한다.
인커밍 어댑터로부터 입력을 받고, 비즈니스 규칙을 검증한다. 그리고 도메인 엔티티와 이 책임을 공유한다. 비즈니스 규칙을 충족하면 입력을 기반으로 어떤 방법으로든 모델의 상태를 변경한다. 도메인 객체의 상태를 바꾸고 영속성 어댑터를 통해 구현된 포트로 이 상태를 전달해서 저장될 수 있게 한다. 아웃고잉 어댑터에서 온 출력값을, 유스케이스를 호출한 어댑터로 반환할 출력 객체로 변환한다.
서비스는 인커밍 포트 인터페이스를 구현하고, 아웃고잉 포트 인터페이스를 호출한다. 데이터베이스의 상태를 업데이트한다.
입력 유효성 검증
유효성 검증은 유스케이스 클래스의 책임은 아니지만, 이 작업은 애플리케이션 계층의 책임에 해당한다.
유스케이스에서 필요로 하는 것을 caller가 모두 검증했다고 믿을 수 있는가, 유스케이스는 하나 이상의 어댑터에서 호출되는데 그러면 유효성 검증을 각 어댑터에서 전부 구현해야 한다. 애플리케이션 계층에서 입력 유효성을 검증해야 하는 이유는, 그렇게 하지 않을 경우 애플리케이션 코어의 바깥쪽으로부터 유효하지 않은 입력값을 받게 되고, 모델의 상태를 해칠 수 있다.
특정 유스케이스의 입력 모델 (input model)에서 다룬다. 생성자 내에서 입력 유효성을 검증한다. 입력 모델에서 검증하면 유효성 검증이 애플리케이션의 코어(육각형 아키텍처의 육각형 내부)에 남아있지만 유스케이스 코드를 오염시키지는 않는다.
유효성 검증은 직접할 필요는 없고 Bean Validation API를 사용해서 검증한다. 따로 Validation factory를 사용해서 오류 방지 계층을 만들어서 유스케이스를 보호할수도 있다.
유스케이스마다 다른 입력 모델
각기 다른 유스케이스에 동일한 입력 모델을 사용하고 싶은 경우가 있다. 계좌 정보 업데이트, 계좌 등록하기 두 가지 유스케이스라면 업데이트는 계좌 ID를 필요로 하고 등록하기는 계좌를 귀속시킬 소유자의 ID 정보를 필요로 한다. 각기 다른 검증 로직이 필요해지고 비즈니스 코드를 입력 유효성 검증과 관련 관심사로 오염시킨다.
각 유스케이스 전용 입력 모델은 유스케이스를 훨씬 명확하게 만들고 다른 유스케이스와의 결합도를 제거해서 불필요한 부수효과가 발생하지 않게 해야한다. 들어오는 데이터를 각 유스케이스에 해당하는 입력 모델에 매핑해야 하기 때문에 비용은 발생하지만 여러가지 전략을 통해 사용해야 한다.
비즈니스 규칙 검증하기
입력 유효성 검증은 유스케이스 로직의 일부가 아닌 반면, 비즈니스 규칙 검증은 분명히 유스케이스 로직의 일부이므로 적절하게 다뤄야 하는데 입력 유효성과 비즈니스 규칙 검증은 언제 해야 하는가?
두 가지의 구분점은 비즈니스 규칙을 검증하는 것은 도메인 모델의 현재 상태에 접근해야 하는 반면, 입력 유효성 검증은 그럴 필요가 없다. 입력 유효성은 구문상의 유효성을 검증하는 것이라고도 할 수 있고, 비즈니스 규칙은 유스케이스의 맥락 속에서 의미적인 유효성을 검증하는 일이라고 할 수 있다.
- 비즈니스 규칙의 예 : 출금 계좌는 초과 출금되면 안된다.
- 유효성 검증의 예 : 송금되는 금액은 0보다 커야 한다.
명확한 구분은 특정 유효성 검증 로직을 코드 상의 어느 위치에 둘지 검증하고 유지보수할 때 도움된다.
비즈니스 규칙은 도메인 엔티티 안에 위치 시키는게 위치를 지정하는 것도, 추론하기도 쉽다. 만약 도메인 엔티티에서 검증하기가 제한되는 상황이라면 유스케이스 코드에서 도메인 엔티티를 사용하기 전에 검증한다.
풍부한 도메인 모델 vs 빈약한 도메인 모델
풍부한 도메인 모델에서는 애플리케이션의 코어에 있는 엔티티에서 가능한 한 많은 도메인 로직이 구현된다. 엔티티들은 상태를 변경하는 메서드를 제공하고, 비즈니스 규칙에 맞는 유효한 변경만을 허용한다.
빈약한 도메인 모델에서는 엔티티 자체가 얇다. 일반적으로 엔티티는 상태를 표현하는 필드와 이 값을 읽고 바꾸기 위한 getter, setter 메서드만 포함하고 어떤 도메인 로직도 가지고 있지 않다. 도메인 로직이 유스케이스 클래스에 구현되어 있다는 것이다. 풍부함이 엔티티 대신 유스케이스에 존재한다는 것이다.
유스케이스마다 다른 출력 모델
입력과 비슷하게 출력도 가능하면 각 유스케이스에 맞게 구체적일수록 좋다. 출력은 호출자에게 꼭 필요한 데이터만 들고 있어야 한다.
특정 유스케이스에서 이러한 데이터가 필요한지, 호출자가 정말로 이 값을 필요로 할지, 만약 그렇다면 다른 호출자도 사용할 수 있도록 해당 데이터에 접근할 전용 유스케이스를 만들어야 하는지? 이런 질문에 답은 없지만 유스케이스를 가능한 한 구체적으로 유지하기 위해서는 계속 질문 해야하며 만약 의심스럽다면 가능한 한 적게 반환한다.
유스케이스들 간에 같은 출력 모델을 공유하게 되면 유스케이스들도 강하게 결합된다.
읽기 전용 유스케이스는?
UI에 특정 데이터를 보여주는 것은 결국 DB에 있는 값을 보여주는 것이다. 애플리케이션 관점에서 간단한 데이터 쿼리이기 때문에 이를 구현하는 방법 중 하나는 인커밍 전용 포트를 만들고 쿼리 서비스(query service)에 구현한다.
읽기 전용 쿼리는 쓰기가 가능한 유스케이스와 코드 상에서 명확하게 구분된다. 이런 방식은 CQS(Command-Query Separation)나 CQRS(Command-Qeury Responseibility Segregation) 과 같은 개념과 잘 맞는다.
유지보수 가능한 소프트웨어를 만드는 데 어떻게 도움이 되는가?
입출력 모델을 독립적으로 모델링하면 원치 않는 부수효과를 피할 수 있다. 유스케이스 간에 모델을 공유하는 것보다는 더 많은 작업이 필요하다. 각 유스케이스마다 별도의 모델을 만들어야 하고, 이 모델과 엔티티를 매핑해야 한다.
유스케이스별로 모델을 만들면 유스케이스를 명확하게 이해할 수 있고, 장기적으로 유지보수하기도 더 쉽다. 또한 여러 명의 개발자가 다른 사람이 작업 중인 유스케이스를 건드리지 않은 채로 여러 개의 유스케이스를 동시에 작업할 수 있다.
'etc' 카테고리의 다른 글
[react] openlayers - react - vworld (3) | 2024.10.22 |
---|---|
[Network] react sprignboot CORS (9) | 2024.10.09 |
[만들면서 배우는 클린 아키텍처] 8장, 9장, 10장 정리 (0) | 2024.09.14 |
[만들면서 배우는 클린 아키텍처] 7장 정리 (1) | 2024.09.14 |
[만들면서 배우는 클린 아키텍처] 5장, 6장 정리 (0) | 2024.09.14 |