‘만들면서 배우는 클린 아키텍처’ 기술 서적을 읽고 학습 내용을 정리하기 위한 목적의 TIL 포스팅입니다🙆♂️ 예제코드는 깃허브 레포지토리를 참고해주세요.
3장 - 코드 구성하기
- 코드를 구성하는 몇 가지 방법을 살펴보고, 육각형 아키텍처를 직접적으로 반영하는 표현력있는 패키지 구조를 소개한다.
- BuckPal 예제 코드를 구조화히기 위한 여러 가지 방법들을 살펴본다. 사용자가 본인 계좌에서 다른 계좌로 돈을 송금할 수 있는 ‘송금하기’ 유스케이스를 살펴본다.
계층으로 구성하기
1
2
3
4
5
6
7
8
9
10
buckapl
|--- domain
| |----- Account
| |----- Activity
| |----- AccountRepository
| |----- AccountService
|--- persistence
| |----- AccountRepositoryImpl
|--- web
| |----- AccountController
- 계층으로 코드를 구성하면, 기능적인 측면들이 섞이기 쉽다.
문제점
문제1 : 애플리케이션의 기능 조각(functional slice) 이나 특징(feature) 을 구분 짓는 패키지 경계가 없다
- 사용자를 관리하는 기능을 추가한다면 web패키지 domain 패키지, persistence 패키지에 관련 클래스가 추가될것이다.
- 추가적인 구조가 없다면, 아주 빠르게 서로 연관되지 않은 기능들끼리 예상하지 못한 부수효과를 일으킬 수 있는 클래스들의 묶음으로 변모할 수 있다.
문제2 : 애플리케이션이 어떤 유스케이스들을 제공하는지 파악할 수 없다.
- 특정 기능을 찾기 위해 어떤 서비스가 어떤 함수에서 이에 대한 책임을 구현했는지 추측해야 한다.
문제3 : 패키지 구조를 통해서는 우리의 목표로 하는 아키텍처를 파악할 수 없다.
- 어떤 기능이 웹 어댑터에서 호출되는지, 영속성 어댑터가 도메인 계층에 어떤 기능을 제공하는지 한 눈에 알 수 없다.
- 인커밍(incoming) 포트와 아웃고인(outgoing) 포트가 코드 속에 숨겨져 있다.
기능으로 구성하기
1
2
3
4
5
6
7
buckpal
|-- account
|-- Account
|-- AccountController
|-- AccountRepository
|-- AccountRepositoryImpl
|-- SendMoneyService
- acouunt 패키지로 묶고 계층 패키지를 없앴다.
- 기능 기준으로 구조화시엔 기반 아키텍처가 명확히 보이지 않는다.
장점
- 패키지 외부에서 접근하면 안되는 클래스들에 대해 package-private 접근 수준을 이용해 패키지 간의 경계를 강화할 수 있다.
- 패키지 경계를 package-private 접근 수준과 결합하면 각 기능사이의 불필요한 의존성을 방지할 수 있다.
- SendMoneyService 와 같이 송금하기 기능을 구현한 클래스를 클래스명으로 바로 찾을 수 있다.(소리치는 아키텍처)
단점
- 아키텍처의 가시성을 계층방식보다 훨씬 더 떨어뜨린다.
- 어댑터를 나타내는 패키지명도 없다. 인커밍 포트, 아웃고인 포트도 없다.
- 인터페이스만 알고 구현체를 알수 없게 했지만 패키지 내부 package-private 접근 수준으로 구현체를 자유롭게 접근할 수 있다.
아키텍처적으로 표현력 있는 패키지 구조
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
buckpal
|-- account
|-- adapter
| |-- in
| | |-- web
| | |-- AccountController
| |-- out
| | |-- persistence
| | |-- AccountPersistenceAdapter
| | |-- SpringDataAccountRepository
|-- domain
| |-- Account
| |-- Activity
|-- application
|-- SendMoneyService
|-- port
|-- in
| |-- SendMoneyUseCase
|-- out
| |-- LoadAccountPort
| |-- UpdateAccountStatePort
- 육각형 아키텍처의 핵심적인요소 : 엔티티, 유스케이스, 인커밍/아웃고잉 포트, 인커밍/아웃고잉 어댑터
- 최상위에 Account 관련 유스케이스를 구현한 모듈임을 나타내는 account 패키지
- 도메인 모델에 속한 domain 패키지
- 서비스 계층(인커밍 포트 구현체)이 속한 application 패키지
- 인커밍 포트 인터페이스, 아웃고잉 포트 인터페이스가 속한 application 패키지
- 인커핑 포트를 호출하는 인커밍 어댑터와 아웃고인 포트에 대한 구현을 제공하는 아웃고잉 어댑터가 속한 adapter 패키지
- 만약 서드파티 API에 대한 클라이언트를 변경하는 작업이 추가된다면 adapter/out/{어댑터 이름} 패키지에서 바로 구현하면 되고 수정이 필요할때 바로 찾을 수 있다.
장점
장점1: 모델-코드 갭(아키텍처-코드 갭)을 효과적으로 다룰 수 있다.
- 아키텍처를 직접적으로 매핑후 반영할 수 있다는 점이다.
- 만약 패키지 구조가 아키텍처를 반영할 수 없다면 시간이 지남에 따라 코드는 점점 목표하던 아키텍처로부터 멀어지게 된다.
- 또한, 이처럼 표현력 있는 패키지 구조는 아키텍처에 대한 적극적인 사고를 촉진한다.
- 많은 패키지가 생기고, 현재 작업 중인 코드를 어떤 패키지에 넣어야 할지 계쏙 생각하기 때문이다.
장점2: 패키지간 접근을 제어할 수 있다.
- 패키지가 아주 많다는 것은 모든것을 public 으로 만들어 패키지간 접근을 허용해야 한다는것을 의미하진 않는다.
- 적어도 adapter 패키지에 대해선 그렇지 않다. 이 패키지에 들어 있는 모든 클래스들은 application 패키지 내에 있는 포트 인터페이스를 통하지 않고는 바깥에서 호출되지 않기 때문에 package-private 접근 수준으로 둬도 된다. 그러므로 애플리케이션 계층에서 adapter 클래스로 향하는 우발적인 의존성은 있을 수 없다.
- 하지만 application 패키지와 domain 패키지 내의 일부 클래스들은 public 이어야 한다. 도메인 클래스들은 서비스, 그리고 잠재적으로 어댑터에서도 접근 가능하도록 public 이어야 한다. 서비스는 인커밍 포트 인터페이스 뒤에 숨겨질 수 있기 때문에 public 일 필요가 없다.
장점3: 어댑터 교체가 용이하다
- 하나의 어댑터를 다른 구현으로 쉽게 교체할 수 있다.
- 예를 들어, 최종적으로 어떤 DB 쓸지 결정되지 않은 상태에서 간단히 키-밸류 DB로 개발을 시작했는데, RDB로 교체해야 한다면 간단히 관련 아웃고잉 포트들만 새로운 adapter 패키지에 구현하고 기존 패키지를 지우면 된다.
장점4: DDD 개념에 직접적으로 대응시킬 수 있다.
- 예제 코드에서 account 같은 상위 레벨 패키지는 다른 바운디드 컨텍스트(bounded context)와 통신할 전용 진입점과 출구(포트)를 포함하는 바운디드 컨텍스트에 해당한다.
- domain 패키지 내에서는 DDD가 제공하는 모든 도구를 이용해 우리가 원하는 어떤 도메인 모델이든 만들 수 있다.
모든 구조와 마찬가지로 패키지 구조를 소프트웨어 프로젝트 내내 유지하기 위해서는 지켜야할 규칙이 있다. 또한 패키지 구조가 적합하지 않아서 어쩔 수 없이 아키텍처-코드 갭을 넓히고 아키텍처를 반영하지 않는 패키지를 만들어야 하는 경우도 생길 수 있다. 완벽한 방법은 없다. 그러나 표현력 있는 패키지 구조는 적어도 코드와 아키텍처 간의 갭을 줄일 수 있게 해준다.
의존성 주입의 역할
- 클린 아키텍처의 가장 본질적인 요건은 2장에서 나왔다시피 애플리케이션 계층이 인커밍/아웃고잉 어댑터에 의존성을 갖지 않는 것이다.
- 예제 코드의 웹 어댑터와 같이 인커밍 어댑터에 대해서는 그렇게 하기가 쉽다. 제어 흐름의 방향이 어댑터와 도메인 코드 간의 의존성 방향과 같은 방향이기 때문이다.
- 반면 영속성 어댑터와 같이 아웃고잉 adapter 에 대해서는 제어 흐름의 반대 방향으로 의존성을 돌리기 위해 의존성 역전 원칙을 이용해야 한다.
- 애플리케이션 계층에 인터페이스를 만들고 어댑터에 해당 인터페이스를 구현한 클래스를 두면 된다. 육각형 아키텍처에서는 이 인터페이스가 포트다. 아래 이미지와 같이 애플리케이션 계층은 어댑터의 기능을 실행하기 위해 이 포트 인터페이스를 호출한다.
- 그런데 포트 인터페이스를 구현한 실제 객체를 누가 애플리케이션 계층에 제공해야 할까? => 이 부분에서 의존성 주입을 활용할 수도 있다. 모든 계층에 의존성을 가진 중림적인 컴포넌트를 하나 도입하는 것이다. 이 컴포넌트는 아키텍처를 구성하는 대부분의 클래스를 초기화하는 역할을 한다.
출처: https://jandari91.tistory.com/52
AccountController
- SendMoneyUseCase 인터페이스가 필요하므로 의존성 주입을 통해 SendMoneyService 클래스의 인스턴스를 주입
- AccountController 는 SendMoneyUseCase 인터페이스의 실제 구현체가 SendMoneyService 인지 모른다.
SendMoneyService
- LoadAccount 인터페이스로 가장한 AccountPersistenceAdapter 클래스의 인스턴스 주입
- SendMoneyService 는 SendMony LoadAccount 인터페이스의 실제 구현체가 AccountPersistenceAdapter 인지 모른다.
유지보수 가능한 소프트웨어를 만드는데 어떻게 도움이 될까?
- 코드에서 아키텍처의 특정 요소를 찾으려면 이제 아키텍처 다이어그램의 박스 이름을 따라 패키지 구조를 탐색하면 된다. 이로써 의사소통, 개발, 유지보수 모두가 조금 더 수월해진다.