‘만들면서 배우는 클린 아키텍처’ 기술 서적을 읽고 학습 내용을 정리하기 위한 목적의 TIL 포스팅입니다🙆♂️ 예제코드는 깃허브 레포지토리를 참고해주세요.
시작하기전 추천사 (객체지향의 사실과 오해 저자 조영호)
- 클린아키텍처의 핵심은 의존성 방향으로 비즈니스 로직이 외부 요소에 존재하지 않고 프레젠테이션 계층과 데이터 소스 계층이 도메인 계층에 의존하도록 만들어야 한다는 것이다.
- 애플리케이션은 비즈니스 관심사를 다루는 내부(inside)와 기술적인 관심사를 다루는 외부(outside)로 분해된다. 여기서 외부에 포함된 기술적인 컴포넌트를 어댑터(adapter)라 부르고, 어댑터가 내부와 상호작용하는 접점을 포트(port)라 부른다.
- 에릭 에반스는 «도메인 주도 설계»에서 “도메인 주도 설계의 전제 조건은 도메인 구현을 격리시키는 것이다.” 라는 말로 내부와 외부의 분리를 강조했다.도메인을 기반으로 애플리케이션을 구축하기 위해선 육각형 아키텍처처럼 경계와 의존성을 강제할 수 있는 아키텍처를 채택하는 것이 중요하다는 사실을 깨달았다.
- 즉, 육각형 아키텍처는 도메인 중심의 개발을 위해 필요한 아키텍처라고도 볼 수 있을것이다.
1장 - 계층형 아키텍처의 문제는 무엇일까?
- 저자의 경험에 의하면 계층형 아키텍처의 문제점은 코드에 나쁜 습관들이 스며들기 쉽게하고 시간이 지날수록 소프트웨어를 점점 더 변경하기 어렵게 만드는 수많은 허점들을 노출한다는 것이다.
계층형 아키텍처는 데이터베이스 주도 설계를 유도한다.
- 웹 -> 도메인 -> 영속성 으로 흘러가는 의존성 방향은 자연스럽게 데이터베이스에 의존하게 만든다. 즉 모든 것이 영속성 계층을 토대로 만들어진다.
- ORM 프레임워크(JPA)의 사용은 비즈니스 규칙을 영속성 관점과 섞고 싶은 유혹을 쉽게 받는다.
출처: https://jandari91.tistory.com/50
위 이미지에서처럼 도메인 계층에선 영속성 계층의 엔티티에 접근하여 사용하기 마련이게 된다. 그렇게 되면 두 계층 간의 강한 결합이 생기게되고 서비스는 영속성 모델을 비즈니스 모델로처럼 사용하게 되고 이로 인해 도메인 로직뿐만이 아닌 즉시로딩, DB트랜잭션 등 영속성 계층과 관련된 작업들을 해야만 한다.
- 우리는 상태(state)가 아닌 행동(behavior)을 중심으로 모델링한다. 어떤 애플리케이션이든 상태가 중요한 요소긴하지만 행동이 상태를 바꾸는 주체이기 떄문에 행동이 비즈니스를 이끌어간다.
- 비즈니스 관점에선 영속성계층보단 도메인 로직을 먼저 만들어야 한다. 그래야만 우리가 로직을 제대로 이해했는지 확인할 수 있고 이를 기반으로 영속성 계층과 웹 계층을 만들어야 한다.
지름길을 택하기 쉬워진다.
출처: https://jandari91.tistory.com/50
- 레이어드 아키텍처에서 만약 상위 계층에 위치한 컴포넌트에 접근해야 한다면 간단하게 해당 컴포넌트를 계층 아래로 내려버리면 된다.
- 그럼 위 이미지처럼 영속성 계층이 비대해지게 될 것이다.
- 아키텍처 관점에서 강제하지 않으면 (코드리뷰 수준이 아닌 빌드가 안되도록 하는 수준으로) 여러가지 핑계로(마감이 얼마 안남았다는 등) 아키텍처는 유지보수하기 어렵게되버릴것이다.
- 모두 그렇다. 한 번은 괜찮을것이다. 근데 한 번하게 되면 누군가 쉽게 또 하게될것이다..
테스트하기 어려워진다
출처: https://jandari91.tistory.com/50
- 계층형 아키텍처에서 일반적으로 계층을 건너뛰는 변화가 나타난다.
- 엔티티의 필드를 단 하나만 조작하면 되는 경우 웹 계층에서 바로 영속성 계층에 접근하는 것이다. (도메인 계층을 건너띄고)
- 두 가지 문제점이 있다.
- 1)도메인 로직을 웹 계층에 구현하게 되는것. 유스케이스가 확장되면 웹 계층에 더 많아질것이고 애플리케이션 전반에 걸쳐 책임이 섞이고 도메인 로직들이 퍼져나가 유지보수성이 떨어지게될것
- 2)웹 계층 테스트에서 도메인 계층뿐만 아닌 영속성 계층도 모킹하게 되어 단위 테스트 복잡도가 올라가고 테스트를 작성하지 않게되는것. 웹 컴포넌트의 규모가 커지면 다양한 영속성 컴포넌트에 의존성이 많이 쌓이며 테스트 복잡도는 더 올라갈것이다..
유스케이스를 숨긴다
출처: https://jandari91.tistory.com/50
- 계층형 아키텍처는 도메인 서비스의 ‘너비’ 에 관한 규칙을 강제하지 않다보니 위 이미지처럼 여러 개의 유스케이스를 담당하는 아주 넓은 서비스가 만들어지기도 한다.
- 넓은 서비스는 영속성 계층에 많은 의존성을 갖게 되고, 다시 웹 레이어의 많은 컴포넌트가 이 서비스에 의존하게 된다. 그럼 서비스를 테스트하기도 어려워지고 작업해야할 유스케이스를 책임지는 서비스를 찾기도 어려워진다.
- 고돌 특화된 좁은 도메인 서비스가 유스케이스 하나씩만 담당하게 한다면 위와 같은 작업들은 수월해질것이다. ex. UserService 에서 사용자 등록 유스케이스를 찾는 대신 RegisterUserService 를 바로 열어서 작업을 시작하는 것처럼 말이다.
동시 작업이 어려워진다.
- 개발 인원이 늘어난다했을때 생산성이 그만큼 늘어나려면 아키텍처가 동시 작업을 지원해야 한다. 하지만 이는 쉽지 않다..
- 그리고 계층형 아키텍처는 그다지 위의 관점에서 도움되지 않는다. 모든 것이 영속성 계층 위에 만들어지기에
영속성 계층 -> 도메인 계층 -> 웹 계층
의 순으로 이뤄져야하고 새로운 유스케이스를 추가해야한다면 동시에 한 명의 개발자만 작업할 수 있게 된다. - DB 주도 설계는 영속성 로직이 도메인 로직과 너무 뒤섞여서 각 측면을 개별적으로 작업할수 없기 때문이다.
- 또한 코드에 넓은 서비스가 있다면 같은 서비스를 동시 편집하는 상황이 발생하는 일이 잦게될것이고 병합 충돌(merge conflict)과 잠재적으로 이전 코드로 되돌려야 하는 문제를 야기하게 된다.
유지보수 가능한 소프트웨어를 만드는데 어떻게 도움이 될까?
- 물론 계층형 아키텍처를 올바르게 구축후 몇가지 추가적인 규칙들을 적용하면 유지보수하기 매우 쉬워지며 코드를 쉽게 변경 or 추가할 수 있게 된다.
- 그러나 앞에서 살펴봤듯이 계층형 아키텍처는 많은것들이 잘못된 방향으로 흘러가도록 용인한다. 아주 엄격한 자기훈련 없이는 시간이 지날수록 품질이 저하되고 유지보수하기 어려워지기 쉽다. 그리고 마감일이 새로 픽스될때마다 이러한 자기훈련은 느슨해질것이다.
- 계층형 아키텍처로 만들든 다른 아키텍처 스타일로 만들든, 계층형 아키텍처의 함정을 염두에 두면 지름길을 택하지 않고 유지보수하기 더 쉬운 솔루션을 만드는데 도움이될 것이다.
Reference
2장 - 의존성 역전하기
이번장에선 계층형 아키텍처 문제점에 대한 대안을 이야기한다.
단일 책임 원칙
- 하나의 컴포넌트는 오로지 한 가지 일만 해야 하고, 그것을 올바르게 수행해야 한다.(X)
- 좋은 조언이지만 SRP 의 실제의도는 아니다
- 컴포넌트를 변경하는 이유는 오직 하나뿐이어야 한다.(O)
- 책임은 1가지 일만 하는 것 보다 변경할 이유로 해석해야 한다.
- 단일변경 이유 원칙(Single Reson to Change Principle) 이 적절할 수도 있다.
- 변경 이유가 1개일때 1가지 일만 하는 것은 자동으로 따라온다.
note: 컴포넌트를 변경할 이유가 1가지라면 어떤 다른 이유로 소프트웨어를 변경하더라도 이 컴포넌트에 대해서는 전혀 신경 쓸 필요가 없다. 소프트웨어가 변경되더라도 여전히 우리가 기대한 대로 동작할 것이다.
- 하지만 변경할 이유는 컴포넌트 의존성을 통해 너무 쉽게 전파된다.
출처: https://jandari91.tistory.com/51
- 컴포넌트 E 의 경우 의존하는 것이 없으므로 변경할 유일한 이유가 E의 기능이 바뀔때 뿐이다
- 컴포넌트 A 의 경우 모든 컴포넌트를 의존하므로 어떤 컴포넌트가 바뀌어도 같이 바뀌어야 한다.
- SRP 위반시 변경하기가 더 어려워진다. 다른 컴포넌트의 실패요인으로 작용될 수 있다.
부수효과에 관한 이야기
- 저자는 클라이언트로 하여금 잘못 구조화된 소프트웨어를 변경하는데 더 많은 비용을 지불하도록 만드는 경우가 있었다.
의존성 역전 원칙
note: 코드상의 어떤 의존성이든 그 방향을 바꿀 수(역전시킬 수) 있다.
- 사실 의존성 양쪽 코들르 모두 제어할 수 있을때만 의존성을 역전시킬 수 있따. 만약 서드파티 라이브러리에 의존성이 있다면 의존성을 역전시킬 수 없다.
출처: https://jandari91.tistory.com/51
- 계층형 아키텍처에서 계층간 의존성은 항상 다음 계층인 아래 방향을 가리킨다. 단일 책임 원칙을 고수준에서 적용할때 상취 계층들이 하위 계층들에 비해 변경할 이유가 더 많다는것을 알 수 있다.
- 그러므로 영속성 계층에 대한 도메인 계층의 의존성 때문에 영속성 계층을 변경할때마다 잠재적으로 도메인 계층도 변경해야 한다. 그러나 도메인 코드는 애플리케이션에서 가장 중요한 영역이므로 영속성 코드가 바뀐다해서 도메인 코드까지 바꾸고 싶진 않다.
- 이 의존성을 어떻게 제거할 수 있을까? DIP를 적용(도메인 코드와 영속성 코드 간의 의존성을 역전시켜 도메인 코드를 ‘변경할 이유’의 개수를 줄임으로써)함으로써 가능하다.
- 엔티티를 도메인 계층으로 올리고 도메인 계층에 리포지토리 인터페이스를 만들고, 실제 리포지토리 구현체를 영속성 계층에서 구현(DIP 실제 적용)하게 하면 된다.
출처: https://jandari91.tistory.com/51
- 이 묘수로 영속성 코드에 있는 숨막히는 의존성으로부터 도메인 로직을 해방시켰다.
클린 아키텍처
- 로버트 마틴은 ‘클린 아키텍처’라는 용어를 같은 이름의 책에서 정립했다.
- 그는 클린 아키텍처에서는 설계가 비즈니스 규칙의 테스트를 용이하게 하고, 비즈니스 규칙은 프레임워크, DB, UI기술, 그 밖의 외부 애플맄네이션이나 인터페이스로부터 독립적일수있다고 이야기했다.
- 이는 도메인 코드가 바깥으로 향하는 어떤 의존성도 없어야함을 의미한다. 대신 의존성 역전 원칙의 도움으로 모든 의존성이 안쪽(도메인 코드)을 향하고 있다.
출처: https://jandari91.tistory.com/51
- 이 아키텍처에서 코어에는 주변 유스케이스에서 접근하는 도메인 엔티티들이 있다. 유스케이스는 앞에서 서비스라 불렀던것들인데, 단일 책임(즉, 변경할 단 한 가지의 이유)을 갖기 위해 조금 더 세분화돼 있다. 이를 통해 이전에 이야기했던
넓은 서비스 문제
를 피할 수 있다. - 도메인 코드에서는 어떤 영속성 프레임워크나 UI 프레임워크가 사용되는지 알 수 없기 떄문에 특정 프레임워크에 특화된 코드를 가질 수 없고 비즈니스 규칙에 집중할 수 있다.
- 그래서 도메인 코드를 자유롭게 모델링할 수 있다.
- 예를 들어, 도메인 주도 설계(DDD)를 가장 순수한 형태로 적용해볼수도 있다.
- 영속성이나 UI에 특화된 문제를 신경쓰지 않아도 된다면 이렇게 하기 굉장히 수월해진다.
- 하지만 클린아키텍처에선 대가가 따른다. 도메인 계층이 영속성이나 UI 같은 외부 계층과 철저히 분리돼야 하므로 애플리케이션의 엔티티에 대한 모델을 각 계층에서 유지보수해야 한다.
- 가령 영속성 계층에서 ORM프레임워크를 사용한다 했을떄 도메인 계층은 영속성 계층을 모르기 때문에 도메인 계층에서 사용한 엔티티 클래스를 영속성 계층에서 함께 사용할 수 없고 두 계층에서 각각 엔티티를 만들어 관리해야 한다. 즉, 도메인 계층과 영속성 계층이 데이터를 주고 받을때, 두 엔티티를 서로 변환해야 한다는 뜻이다.
- 이는 도메인 계층과 다른 계층들 사이에서도 마찬가지다.
- 실제 개인적인 경험으로 매번 컨버팅 작업을 해줘야하는 번거로운 작업임에도 불구하고 이는 바람직한 일이다. 이것이 바로 도메인 코드를 프레임워크에 특화된 문제로부터 해방시키고자 했던, 결합이 제거된 상태다.
- 가령 JPA 에선 ORM이 관리하는 엔티티에 인자가 없는 기본 생성자를 추가하도록 강제하는데 이것이 도메인 모델에는 포함해서는 안될 프레임워크에 특화된 결합의 예이다.
- 8장에선 도메인 계층과 영속성 계층의 결합을 그대로 수용하는 ‘매핑하지 않기’ 전략을 비롯한 여러 매핑 전략에 대해 살펴보겠다.
- 로버트 마틴의 클린 아키텍처는 다소 추상적이기에 조금 더 깊게 들어가서 클린 아키텍처의 원칙들을 조금 더 구체적으로 만들어주는 ‘육각형 아키텍처’에 대해 살펴보자.
육각형 아키텍처(헥사고날 아키텍처)
출처: https://jandari91.tistory.com/51
- 알리스테어 콕번이 만든 용어로 애플리케이션 코어가 육각형으로 표현되다보니 붙여진 이름이다.
- 육각형에서 외부로 향하는 의존성이 없기 때문에 마틴이 클린 아키텍처에서 제시한 의존성 규칙이 그대로 적용된다는 점을 주목하자. 대신 모든 의존성은 코어를 향한다.
- 왼쪽에 있는 어댑터들은 (애플리케이션 코어를 호출하기 때문에) 애플리케이션을 주도하는 어댑터들이고, 반면 오른쪽 어댑터들은 (애플리케이션 코어에 의해 호출되기 때문에) 애플리케이션에 의해 주도되는 어댑터들이다.
- 애플리케이션 코어와 어댑터들간의 통신이 가능하려면 애플리케이션 코어가 각각의 포트를 제공해야 한다. 주도하는 어댑터(driving adapter)에게는 그러한 포트가 코어에 있는 유스케이스 클래스 중 하나에 의해 구현되고 어댑터에 의해 호출되는 인터페이스가 될것이고, 주도되는 어댑터(driven adapter)에게는 그러한 포트가 어댑터에 의해 구현되고 코어에 의해 호출되는 인터페이스가 될 것이다.
- 이러한 핵심 개념으로 ‘포트와 어댑터’ 아키텍처로도 알려져 있다.
유지보수 가능한 소프트웨어를 만드는데 어떻게 도움이 될까?
- 의존성을 역전시켜 도메인 코드가 다른 바깥쪽 코드에 의존하지 않게 함으로써 영속성과 UI에 특화된 모든 문제로부터 도메인 로직의 결합을 제거하고 코드를 변경할 이유의 수를 줄일 수 있다. 그리고 변경할 이유가 적을수록 유지보수성은 좋아진다.
- 또한 도메인 코드는 비즈니스 문제 딱 맞도록 자유롭게 모델링 가능해지고, 영속성 코드와 UI코드도 영속성 문제와 UI문제에 맞게 자유롭게 모델링될 수 있다.
Reference
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 인지 모른다.
유지보수 가능한 소프트웨어를 만드는데 어떻게 도움이 될까?
- 코드에서 아키텍처의 특정 요소를 찾으려면 이제 아키텍처 다이어그램의 박스 이름을 따라 패키지 구조를 탐색하면 된다. 이로써 의사소통, 개발, 유지보수 모두가 조금 더 수월해진다.
Reference
4장 - 유스케이스 구현하기
- 위에서 설명한 내용에 따르면 애플리케이션, 웹, 영속성 계층이 현재 아키텍처에서 아주 느슨하게 결합돼 있기 때문에 필요한 대로 도메인 코드를 자유롭게 모델링할 수 있다.
- 육각형 아키텍처는 도메인 중심의 아키텍처에 적합하기에 도메인 엔티티를 만드는것으로 시작후 해당 도메인 엔티티를 중심으로 유스케이스를 구현한다.
도메인 모델 구현하기
- 한 계좌에서 다른 계좌로 송금하는 유스케이스를 구현한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
package buckpal.domain;
@AllArgsConstructor
@Getter
public class Account {
private AccountId id;
private Money baselineBalance;
private ActivityWindow activityWindow;
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()
).isPositive();
}
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;
}
}
- Account(계좌) 엔티티는 실제 계좌의 현재 스냅숏을 제공한다.
- 계좌에 대한 모든 입출금은 Activity 엔티티에 포착한다.
- 한 계좌에 대한 모든 활동(activity)들은 항상 메모리에 한꺼번에 올리는건 현명한 방법이 아니기에 Account 엔티티는 ActivityWindow 값 객체(value object)에서 포착한 지난 며칠 혹은 몇 주간의 범위에 해당하는 활동만 보유한다.
유스케이스 둘러보기
- 일반적으로 유스케이스는 아래와 같은 단계를 따른다.
- 1)입력을 받는다
- 2)비즈니스 규칙을 검증한다
- 3)모델 상태를 조작한다
- 4)출력을 반환한다
- 비즈니스 규칙을 충족하면 유스케이스는 입력을 기반으로 어떤 방법으로든 모델 상태를 변경한다. 일반적으로 도메인 객체의 상태를 바꾸고 영속성 어댑터를 통해 구현된 포트로 이 상태를 전달해서 저장될 수 있게 한다. 유스케이스는 또 다른 아웃고잉 어댑터를 호출할 수도 있다.
- 마지막 단계는 아웃고잉 어댑터에서 온 출력값을, 유스케이스를 호출한 어댑터를 반환할 출력 객체로 변환하는 것이다.
- 1장에서 이야기한 넓은 서비스 문제를 피하기 위해 모든 유스케이스를 각 분리된 서비스로 만든다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package buckpal.application.service;
import buckpal.application.port.in.SendMoneyCommand;
import buckpal.application.port.in.SendMoneyUseCase;
import buckpal.application.port.out.AccountLock;
import buckpal.application.port.out.LoadAccountPort;
import buckpal.application.port.out.UpdateAccountStatePort;
import lombok.RequiredArgsConstructor;
import org.springframework.transaction.annotation.Transactional;
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class SendMoneyService implements SendMoneyUseCase {
private final LoadAccountPort loadAccountPort;
private final AccountLock accountLock;
private final UpdateAccountStatePort updateAccountStatePort;
@Transactional
@Override
public boolean sendMoney(SendMoneyCommand command) {
//TODO: 비즈니스 규칙 검증
//TODO: 모델 상태 조작
//TODO: 출력 값 반환
return false;
}
}
- 서비스는 인커밍 포트 인터페이스인 SendMoneyUseCase를 구현하고, 계좌를 불러오기 위해 아웃고잉 포트 인터페이스인 LoadAccountPort 를 호출한다. 그리고 DB의 계좌상태 업데이트를 위해 UpdateAccountStatePort 를 호출한다.
출처: https://jandari91.tistory.com/54
입력 유효성 검증
- 저자는 유스케이스 코드가 도메인 로직에만 신경써야 하고 입력 유효성 검증으로 오염되면 안된다 생각한다
- 그러나 유스케이스는 비즈니스 규칙(business rule)을 검증할 책임이 있다.
- 과연 유스케이스에서 필요로 하는것을 호출자가 모두 검증했다고 믿을수있을까? 또 해당 유스케이스를 호출하는 모든 각 어댑터에서 유효성검증을 해야할텐데 실수할수도 있고 잊을수도 있다.
- 애플리케이션 계층에서 입력 유효성을 검증하는 이유는, 그렇게 하지 않을 경우 애플리케이션 코어의 바깥쪽으로부터 유효하지 않은 입력값을 받게 되고, 모델 상태를 해칠수 있기 때문이다.
- 그러면 어디서 입력유효성 검증을 해야할까? 입력 모델(input model)이 이 문제를 다루도록 해보자. ‘송금하기’ 유스케이스에선 SendMoneyCommand 클래스다. 더 정확히는 생성자내에서다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package buckpal.application.port.in;
import buckpal.common.SelfValidating;
import buckpal.domain.Account.AccountId;
import buckpal.domain.Money;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.Value;
import javax.validation.constraints.NotNull;
@Value
@Getter
@EqualsAndHashCode(callSuper = false)
public class SendMoneyCommand extends SelfValidating<SendMoneyCommand> {
@NotNull
private final AccountId sourceAccountId;
@NotNull
private final AccountId targetAccountId;
@NotNull
private final Money money;
public SendMoneyCommand(
AccountId sourceAccountId,
AccountId targetAccountId,
Money money) {
this.sourceAccountId = sourceAccountId;
this.targetAccountId = targetAccountId;
this.money = money;
this.validateSelf();
}
}
- 객체 생성시 예외를 던져서 객체 생성을 막으면 된다.
- SendMoneyCommand 필드에 final 을 지정해 불변 필드로 만들면 안정적으로 유효한 불변 객체를 유지할 수 있다.
- SendMoneyCommand는 유스케이스 API의 일부이기에 인커밍 포트 패키지에 위치한다. 그러므로 유효성 검증이 애플리케이션 코어(육각형 아키텍처 내부)에 남아있지만 신성한 유스케이스 코드를 오염시키지 않게된다.
- 자바에선 Bean Validation API(spring-boot-starter-validation)가 필요한 유효성 규칙들을 필드 애너테이션으로 표현 가능하다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
class SendMoneyCommand extends SelfValidating<SendMoneyCommand> {
@NotNull
private final AccountId sourceAccountId;
@NotNull
private final AccountId targetAccountId;
@NotNull
private final Money money;
public SendMoneyCommand(
AccountId sourceAccountId,
AccountId targetAccountId,
Money money) {
this.sourceAccountId = sourceAccountId;
this.targetAccountId = targetAccountId;
this.money = money;
this.validateSelf();
}
}
public abstract class SelfValidating<T> {
private Validator validator;
public SelfValidating() {
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
validator = factory.getValidator();
}
/**
* Evaluates all Bean Validations on the attributes of this
* instance.
*/
protected void validateSelf() {
Set<ConstraintViolation<T>> violations = validator.validate((T) this);
if (!violations.isEmpty()) {
throw new ConstraintViolationException(violations);
}
}
}
- 커맨트 모델은 SelfValidating.validateSelf() 함수를 호출함으로써, 유효성 검증을 수행하고 예외를 던지게 된다.
- 유스케이스 로직 내부에 구현하지 않고 커맨드 모델 생성자를 통해 입력 유효성 검증을 수행함으로써 오류 방지 계층을 만든것이다.
생성자의 힘
- 빌더 패턴 활용하여 커맨드 모델을 생성하는 경우 입력 유효성 검증에 대한 누락이 발생할수 있게 된다.
- 요즘 IDE 는 생성자 파라미터 힌트를 제공해주기도 한다.
유스케이스마다 다른 입력 모델
- 두 유스케이스에 동일한 입력 모델을 사용하고 싶은 생각이 들때가 있다. ‘계좌 등록하기’와 ‘계좌 정보 업데이트하기’ 유스케이스처럼 말이다.
- ‘계좌 등록하기’는 소유자 ID가 필요하고, ‘계좌 정보 업데이트’ 유스케이스는 업데이트칠 계좌 ID가 필요하다.
- 그러다보면 ‘계좌 등록하기’에선 계좌 ID가 null을 허용해야 하고, ‘계좌 정보 업데이트’ 에선 소유자 ID에 null을 허용해야 한다.
- 불변 커맨드 객체 필드에 null을 유효한 상태로 받아들이는 것은 그 자체로 코드 냄새다.
- 하지만 더 문제되는 부분은 이제 입력 유효성을 어떻게 검증하느냐다. 등록 유스케이스와 업데이트 유스케이스는 서로 다른 유효성 검증 로직이 필요할텐데 아마 유효성 검증 로직을 분기처리하여 관리되고 유지보수에 좋지 못할것이다.
- 각 유스케이스 전용 입력 모델은 유스케이스를 훨씬 명확하게 만들고 다른 유스케이스와의 결합도 제거해서 불필요한 부수효과가 발생하지 않게 한다. 들어오는 데이터를 각 유스케이스에 해당하는 입력모델로 매핑해야 하기 때문에 물론 비용이 안 드는 것은 아니다.
비즈니스 규칙 검증하기
- 입력 유효성 검증은 구문상의 유효성을 검증하는 것이고, 비즈니스 규칙은 유스케이스 맥락에서 의미적인(semantical) 유효성 검증이라 할 수 있다.
- 좀 더 쉽게 설명하면 입력 유효성 검증은 논리적인 수준의 검증 없이 단순한 필드에 대한 검증이고 비즈니스 규칙 검증은 모델의 현재 상태를 기반으로 하는 논리적인 수준의 검증이라 할 수 있다.
- “송금되는 금액은 0보다 커야 한다”라는 규칙은 모델에 접근하지 않고도 검증될 수 있기에 입력 유효성 검증으로 구현할 수 있다. 하지만 논란의 여지는 있다. 송금액은 매우 중요하므로 비즈니스 규칙으로도 다룰수 있다는 것이다.
- 하지만 맨 처음 정의대로 구현하게 되면 장점이 있다. 코드 상의 어느 위치에 둘지 결정하고 나중에 더 쉽게 찾을 수 있다. 현재 모델 상태에 접근해야 하는지 여부만 확인하면 되기 떄문이다. 그러면 유지보수하기 쉬워진다.
- 비즈니스 규칙 검증은 도메인 모델 안에 유효성 검증 로직을 넣는것이 베스트하다. 그러면 위치를 정하기도 쉽고 추론하기도 쉽다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Account {
// ...
public boolean withDraw(Money money, AccountId targetAccountId) {
if (!mayWithDraw(money)) {
return false;
}
...
}
...
}
- 만약 도메인 엔티티에서 비즈니스 규칙을 검증하기 여의치 않다면 유스케이스 코드에서 도메인 엔티티를 사용하기 전에 해도 된다. (ex. 단순 ID 값에 해당하는 데이터가 DB에 있는지 확인이 필요할 경우)
풍부한 도메인 모델 vs 빈약한 도메인 모델
- 풍부한 도메인 모델은 애플리케이션 코어 있는 엔티티에서 가능한 많은 도메인 로직이 구현된다. 엔티티들은 상태를 변경하는 메서드를 제공하고, 비즈니스 규칙에 맞는 유효한 변경만을 허용하면 된다.
- 빈약한 도메인 모델은 상태를 표현하는 필드와 getter, setter 메서드만 포함하며 어떤 도메인 로직도 가지고 있지 않다. 즉, 도메인 로직이 유스케이스 클래스에 구현되있다는것이다.
- 위 두 가지중 각자 스타일에 맞게 선택해서 사용하면 된다.
유스케이스마다 다른 출력 모델
- 입력과 마찬가지로 출력도 가능하면 각 유스케이스에 맞게 구체적일수록 좋고, 출력은 호출자에게 꼭 필요한 데이터만 들고 이썽야 한다.
- 유스케이스들 간에 출력 모델을 공유하면 강한 결합이 생기게되고 유지보수하기 어려워진다. (한 유스케이스에만 필요한 필드들이 계속 늘어나게되는 등..)
읽기 전용 유스케이스
- 읽기 전용 작업을 유스케이스라 언급하는것은 조금 이상하다.
- 예를 들어 UI에 계좌 잔액을 표시한다고 할때 애플리케이션 코어 관점에선 간단한 데이터 쿼리다.
- 이를 구현하는 한 가지 방법은 쿼리를 위한 인커밍 전용 포트를 만들어 이를 ‘쿼리 서비스’로 구현하는 것이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
package buckpal.application.service;
@RequiredArgsConstructor
class GetAccountBalanceService implements GetAccountBalanceQuery {
private final LoadAccountPort loadAccountPort;
@Override
public Money getAccountBalance(AccountId accountId) {
return loadAccountPort.loadAccount(accountId, LocalDateTime.now())
.calculateBalance();
}
}
- 여러 계층에 걸쳐 같은 모델을 사용한다면 지름길을 써서 클라이언트가 아웃고잉 포트를 직접 호출하게 할 수도 있다.
유지보수 가능한 소프트웨어를 만드는데 어떻게 도움이 될까?
- 입출력 모델을 독립적으로 모델링한다면 원치 않는 부수효과를 피할수 있다.
- 유스케이스별 모델을 명확히 이해할수 있고, 여러 개발자가 협엽시 다른 사람이 작업중인 유스케이스를 건들지 않는채로 동시 작업을 할 수 있게 된다.
Reference
- https://rudaks.tistory.com/entry/만들면서-배우는-클린-아키텍처-4-유스케이스-구현하기
- https://jandari91.tistory.com/54
- https://kimdonghyungsoo.tistory.com/13
5장 - 웹 어댑터 구현하기
출처: https://jandari91.tistory.com/55
- 애플리케이션 계층은 웹 어댑터가 통신할 수 있는 특정 포트를 제공하고 웹 어댑터는 이 포트를 호출하고 서비스는 이 포트를 구현한다. 의존성 역전 원칙이 적용되었다.
출처: https://jandari91.tistory.com/55
- 왜 어댑터와 유스케이스 사이에 포트라는 간접 계층을 넣어야 할까? 애플리케이션 코어가 외부 세계와 통신할수 있는 곳에 대한 명세가 포트이고 외부와 어떤 통신이 일어나는지를 정확히 알 수 있기 떄문이다. (또한 유스케이스 로직을 순수하게 보호하기 위해서)
- 웹 소켓으로 실시간 데이터를 사용자의 브라우저로 보낸다할땐 반드시 포트가 필요하다. 아래 이미지와 같이 웹 어댑터에서 구현하고 애플리케이션 코어에서 호출해야 한다.
- 이 포트는 아웃고잉 포트이기에 이제 웹 어댑터는 인커밍 어댑터인 동시에 아웃고잉 어댑터가 된다. 한 어댑터가 두 가지 역할을 하지 못할 이유는 없다.
출처: https://jandari91.tistory.com/55
웹 어댑터의 책임
- HTTP 요청을 자바 객체로 매핑
- 권한검사
- 입력유효성 검증
- 입력을 유스케이스의 입력 모델로 매핑
- 유스케이스호출
- 유스케이스의 출력을 HTTP로 매핑
- HTTP 응답을 반환
- 입력유효성 검증은 유스케이스의 입력 모델과는 구조나 의미가 완전히 다를 수 있으므로 또 다른 유효성 검증을 수행해야 한다.
- 유스케이스 입력 모델에서 했던것과 똑같이 구현해야하는 것이 아니라 웹 어댑터의 입력 모델을 유스케이스 입력 모델로 변환할 수 있다는 것을 검증해야 한다. 이 변환을 방해하는 모든것이 유효성 검증에러다.
- 위 1부터 8까지의 과정중 하나라도 문제가 생기면 예외를 던지고, 웹 어댑터는 에러를 호출자에게 보여줄 메시지로 변환해야 한다.
- 하지만 이책임들은 애플리케이션 계층에서 신경쓰면 안되는 것들이기도 하다. HTTP와 관련된것은 애플리케이션 계층으로 침투해선 안된다. 우리가 바깥 계층에서 HTTP를 다루고 있다는 것을 애플리케이션 계층이 알게되면 HTTP를 사용하지 않는 또 다른 인커밍 어댑터는 애플리케이션 계층을 사용하지 못하게 된다. 좋은 아키텍처는 선택의 여지를 남겨둔다.
- 웹 어댑터와 애플리케이션 계층 간의 이 같은 경계는 도메인과 애플리케이션 계층부터 개발하기 시작하면 자연스레 생긴다.
- 특정 인커밍 어댑터를 생각할 필요 없이 유스케이스 먼저 구현하면 경계를 흐리게 만들 유혹에 빠지지 않을 수 있다.
컨트롤러 나누기
- 클래스마다 코드는 적을수록 좋다. 특정 프로덕션 코드에 해당하는 테스트 코드를 찾기도 쉽다.
- 저자는 각 연산에 대해 가급적이면 별도 패키지 안에 별도 컨트롤러를 만드는 방식을 선호한다.(ex.
SendMoneyController
) - 또한 컨트롤러명과 서비스명에 대해서도 잘 고려하는게 중요하다. 책 예제에선 계좌를 생성하는 유일한 방법은 사용자가 계좌를 등록하는 방법뿐이므로 CreateAccount 보단 RegisterAccount 라는 네이밍이 더 명확하다.
- 위처럼 나누게 되면 서로 다른 연산에 대한 동시작업이 쉬워진다. 여러 개발자가 병렬적으로 작업해도 병합 충돌이 일어나지 않을것이다.
유지보수 가능한 소프트웨어를 만드는데 어떻게 도움이 될까?
- 애플리케이션 계층은 HTTP 에 대한 상세 정보를 노출시키지 않도록하면 웹 어댑터를 다른 어댑터로 쉽게 교체 가능하게 된다.
- 웹 컨트롤러는 단위가 더 작을수록 파악하기 수월해지고 테스트하기도 쉬워지며 동시작업을 효율적으로 만든다. 세분화된 컨트롤러는 초기엔 공수가 더 들지라도 유지보수 관점에선 훨씬 더 좋다.
Reference
6장 - 영속성 어댑터 구현하기
의존성 역전
출처: https://jandari91.tistory.com/56
- 영속성 어댑터는 ‘아웃고잉’ 어댑터다. 애플리케이션에 의해 호출될뿐, 애플리케이션을 호출하진 않는다.
- 영속성 계층에 대한 코드 의존성을 없애기 위해 포트라는 간접 계층을 추가한것이다. 이렇게 되면 영속성 코드를 리팩터링하더라도 코어 코드를 변경하는 결과로 이어지지 않을것이다.
영속성 어댑터의 책임
- 입력을 받는다.
- 입력을 데이터베이스 포멧으로 매핑한다.
- 입력을 데이터베이스로 보낸다.
- 데이터베이스 출력을 애플리케이션 포맷으로 매핑한다.
- 출력을 반환한다.
- 핵심은 영속성 어댑터의 입출력 모델이 영속성 어댑터 내부에 있는 것이 아니라 애플리케이션 코어에 있기 때문에 영속성 어댑터 내부를 변경하는것이 코어에 영향을 미치지 않는다는것이다.
포트 인터페이스 나누기
- 아래 이미지처럼 모든 DB 연산을 하나의 리포지토리 인터페이스에 넣는게 일반적인 방법이다.
출처: https://jandari91.tistory.com/56
- 위 방법은 각 서비스들이 인터페이스에서 단 하나의 메서드만 사용하더라도 넓은 포트 인터페이스에 대한 의존성을 갖게 된다. 코드에 불필요한 의존성이 생겼다는 뜻이다.
- 필요치 않은 메서드에 생긴 의존성은 코드를 이해하고 테스트하기 어렵게 만든다.
- RegisterAccountService 의 단위 테스트를 작성하려할때 AccountRepository 인터페이스의 어떤 메서드를 호출하는지 찾아 모킹해야 한다.
- 일부만 모킹하는것은 다음 작업자가 인터페이스 전체가 모킹됐다고 기대하며 에러를 마주하게 될 수 있다. 그래서 또 다시 확인해야하는 상황이 생긴다.
- 이 문제에 대한 해결책은 ISP(인터페이스 분리 원칙)다.
- 클라이언트가 오로지 자신이 필요로 하는 메서드만 알면 되도록 넓은 인터페이스를 특화된 인터페이스로 분리해야 한다고 설명한다.
출처: https://jandari91.tistory.com/56
- 이제 각 서비스는 실제 필요한 메서드에만 의존한다. 포트 이름이 역할을 명확하게 잘 표현한다.
- 테스트에서는 어떤 메서드를 모킹할지 고민할 필요가 없다. 왜냐하면 대부분 포트당 하나의 메서드만 있을것이기 때문이다.
- 매우 좁은 포트를 만드는것은 코딩을 플러그 앤드 플레이(plug and play) 경험으로 만든다. 서비스 코드 작성시 필요한 포트에 그저 ‘꽂기만’하면 된다.
물론 모든 상황에 ‘포트 하나당 하나의 메서드’를 적용하진 못할것이다. 응집성이 높고 함께 사용될떄가 많기 때문에 하나의 인터페이스에 묶고 싶은 DB 연산들이 있을수 있다.
영속성 어댑터 나누기
- 이전 이미지에선 모든 영속성 포트를 구현한 단 하나의 영속성 어댑터 클래스가 있었다.
- 하지만 아래 이미지와 같이 영속성 연산이 필요한 도메인 클래스(또는 DDD 에서의 ‘애그리거트’) 하나당 하나의 영속성 어댑터를 구현하는 방식을 택할수도 있다.
출처: https://jandari91.tistory.com/56
- 이렇게 하면 영속성 어댑터들은 각 영속성 기능을 이용하는 도메인 경계를 따라 자동으로 나눠진다.
- 영속성 어댑터를 훨씬 더 많은 클래스로 나눌수도 있다. 예를 들어 JPA, 매퍼, 성능을 개선하기 위한 평범한 SQL을 이용하는 다른 종류의 포트도 함께 구현하게 될수 있다. 그후에 JPA 어댑터 하나와 평이한 SQL 어댑터 하나를 만들고 각각이 영속성 포트의 일부분을 구현하면 된다.
- 도메인 코드는 영속성 포트에 의해 정의된 명세를 어떤 클래스가 충족시키지는지에 관심없다는 사실이 중요하다.
- ‘애그리거트당 하나의 영속성 어댑터’ 접근 방식 또한 나중에 여러 개의 바운디드 컨텍스트(bounded context)의 영속성 요구사항을 분리하기 위한 좋은 토대가 된다.
- 청구(billing) 유스케이스를 책임지는 바운디드 컨텍스트가 정의되면 아래와 같이 될것이다.
출처: https://jandari91.tistory.com/56
- 만약 하나의 바운디드 컨텍스트 맥락에서 다른 맥락에 있는 무언가를 필요로 한다면 전용 인커밍 포트를 통해 접근해야 한다.
스프링 데이터 JPA 예제
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
package buckpal.domain;
@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 Money calculateBalance() {
// ...
}
public boolean withDraw(Money money, AccoutId targetAccountId) {
// ...
}
public boolean deposit(Money money, AccoutId sourceAccountId) {
// ...
}
}
1
2
3
4
5
6
7
8
9
10
11
12
package buckpal.adapter.persistence;
@Entity
@Table(name = "account")
@Data
@AllArgsConstructor
@NoArgsConstructor
class AccountJpaEntity {
@Id
@GeneratedValue
private Long id;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package buckpal.adapter.persistence;
@Entity
@Table(name = "activity")
@Data
@AllArgsConstructor
@NoArgsConstructor
class ActivityJpaEntity {
@Id
@GeneratedValue
private Long id;
@Column private LocalDateTime timestamp;
@Column private Long ownerAccountId;
@Column private Long sourceAccountId;
@Column private Long targetAccountId;
@Column private Long amount;
}
1
2
3
4
package buckpal.adapter.persistence;
interface AccountRepository extends JpaRepository<AccountJpaEntity, Long> {
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package buckpal.adapter.persistence;
interface ActivityRepository extends JpaRepository<ActivityJpaEntity, Long> {
@Query("select a from ActivityJpaEntity a " +
"where a.ownerAccountId = :ownerAccountId " +
"and a.timestamp >= :since")
List<ActivityJpaEntity> findByOwnerSince(
@Param("ownerAccountId") Long ownerAccountId,
@Param("since") LocalDateTime since);
@Query("select sum(a.amount) from ActivityJpaEntity a " +
"where a.targetAccountId = :accountId " +
"and a.ownerAccountId = :accountId " +
"and a.timestamp < :until")
Long getDepositBalanceUntil(
@Param("accountId") Long accountId,
@Param("until") LocalDateTime until);
@Query("select sum(a.amount) from ActivityJpaEntity a " +
"where a.sourceAccountId = :accountId " +
"and a.ownerAccountId = :accountId " +
"and a.timestamp < :until")
Long getWithdrawalBalanceUntil(
@Param("accountId") Long accountId,
@Param("until") LocalDateTime until);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
package buckpal.adapter.persistence;
@RequiredArgsConstructor
@Component
class AccountPersistenceAdapter implements
LoadAccountPort,
UpdateAccountStatePort {
private final AccountRepository accountRepository;
private final ActivityRepository activityRepository;
private final AccountMapper accountMapper;
@Override
public Account loadAccount(
Account.AccountId accountId,
LocalDateTime baselineDate) {
AccountJpaEntity account =
accountRepository.findById(accountId.getValue())
.orElseThrow(EntityNotFoundException::new);
List<ActivityJpaEntity> activities =
activityRepository.findByOwnerSince(
accountId.getValue(),
baselineDate);
Long withdrawalBalance = orZero(activityRepository
.getWithdrawalBalanceUntil(
accountId.getValue(),
baselineDate));
Long depositBalance = orZero(activityRepository
.getDepositBalanceUntil(
accountId.getValue(),
baselineDate));
return accountMapper.mapToDomainEntity(
account,
activities,
withdrawalBalance,
depositBalance);
}
private Long orZero(Long value){
return value == null ? 0L : value;
}
@Override
public void updateActivities(Account account) {
for (Activity activity : account.getActivityWindow().getActivities()) {
if (activity.getId() == null) {
activityRepository.save(accountMapper.mapToJpaEntity(activity));
}
}
}
}
- JPA의 @ManyToOne 이나 @OneToMany 애너테이션을 이용해 ActivityJpaEntity와 AccountJpaEntity 를 연결해서 관계를 표현할수도 있었겠지만 DB쿼리에 부수효과가 생길수 있기에 일단 이부분은 제외하였다.
- 도메인 엔티티와 JPA 엔티티를 같이 사용하는것이 유효한 전략일수도 있다. 그렇게되면 JPA로 인해 도메인 모델을 타협할수밖에 없다.
- 예를 들어, JPA 에 맞춰 기본 생성자를 무조건 생성해줘야만 한다.
- 또한, 영속성 계층에선 성능을 고려하여 @ManyToOne 관계를 설정하는것이 적절할수있겠지만, 예제에선 항상 데이터 일부만 가져오기를 바라기 때문에 도메인 모델에선 이 관계가 반대가 되기를 원한다.
- 그러므로 영속성 측면과의 타협 없이 풍부한 도메인 모델을 생성하고자 한다면 도메인 모델과 영속성 모델을 분리하는것이 좋다.
데이터베이스 트랜잭션은 어떻게 해야할까?
- 트랜잭션은 하나의 유스케이스에 대해서 일어나는 모든 쓰기 작업에 걸쳐있어햐 하기에 영속성 어댑터를 호출하는 서비스에 위임해야 한다. 영속성 어댑터는 어떻게 묶일지를 모른다.
- AOP 로 서비스에 자동적으로 트랜잭션을 걸어주는 방법도 있다.
유지보수 가능한 소프트웨어를 만드는데 어떻게 도움이 될까?
- 도메인 코드가 영속성과 관련된것들로부터 분리되어 풍부한 도메인 모델을 만들 수 있다.
- 좁은 포트 인터페이스를 사용하면 포트마다 다른 방식으로 구현할 수 있는 유연함이 생긴다. 심지어 포트 뒤에서 애플리케이션이 모르게 다른 영속성 기술을 사용할수도 있다.(JPA, MyBatis, QueryDSL 등) 포트 명세만 지켜진다면 영속성 계층의 전체를 다른 기술로 교체할 수도 있다.
Reference
7장 - 아키텍처 요소 테스트하기
테스트 피라미드
출처: https://jandari91.tistory.com/57
- 기본 전제는 만드는 비용이 적고, 유지보수하기 쉽고, 빨리 실행되고, 안정적인 작은 크기의 테스트들에 대해 높은 커버리지를 유지하는 것이다. (단위 테스트)
- 여러 개의 단위와 단위를 넘는 경계, 아키텍처 경계, 시스템 경계를 결합하는 테스트는 만드는 비용이 더 비싸지고, 실행이 더 느려지며 깨지기 더 쉬워진다.(피라미드에서 위로 갈수록)
- 단위 테스트는 하나의 클래스를 인스턴스화하고 해당 클래스의 인터페이스를 통해 기능들을 테스트한다. 만약 테스트 중인 클래스가 다른 클래스에 의존되는 클래스들은 인스턴스화하지 않고 테스트하는 동안 필요한 작업들을 흉내 내는 목(mock)으로 대체한다.
- 통합 테스트는 연결된 여러 유닛을 인스턴스화하고 시작점이 되는 클래스의 인터페이스로 데이터를 보낸 후 유닛들의 네트워크가 기대한대로 잘 동작되는지 검증한다. 두 계층 간의 경계를 걸쳐서 테스트할 수 있기 때문에 객체 네트워크가 완전하지 않거나 어떤 시점에는 목을 대상으로 수행해야 한다.
- 시스템 테스트는 애플리케이션을 구성하는 모든 객체 네트워크를 가동시켜 특정 유스케이스가 전 계층에서 잘 동작하는지 검증한다. (ex. UI를 포함한 엔드투엔드 테스트층)
단위 테스트로 도메인 엔티티 테스트하기
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class AccountTest {
@Test
void withdrawalSucceeds () {
AccountId accountId = new AccountId(1L); Account account = defaultAccount
.withAccountId(accountId)
.withBaselineBalance (Money.of (555L))
•withActivityWindow(new ActivityWindow(
defaultActivity()
.withTargetAccount(accountId)
.withMoney (Money.of (999L))
.build(),
defaultActivity()
.withTargetAccount(accountId)
.withMoney(Money.of(IL))
.build()))
.build();
boolean success = account.withdraw(Money.of(555L), new AccountId (99L));
assertThat(success).isTrue ();
assertThat (account.getActivityWindow().getActivities()).hasSize(3);
assertThat(account.calculateBalance()).isEqualTo(Money.of (1000L));
}
}
- 특정 상태의 Account 를 인스턴스화하여 withdraw 함수를 호출하여 출금이 성했는지 검증하고, Account 객체의 상태에 대해 기대되는 부수효과들이 잘 일어났는지 확인하는 단순 단위 테스트다.
- 이해하는것도 쉬운편이고 아주 빠르게 실행되고 간단하다.
단위 테스트로 유스케이스 테스트하기
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
class SendMoneyServiceTest {
private final LoadAccountPort loadAccountPort =
Mockito.mock(LoadAccountPort.class);
private final AccountLock accountLock =
Mockito.mock(AccountLock.class);
private final UpdateAccountStatePort updateAccountStatePort =
Mockito.mock(UpdateAccountStatePort.class);
private final SendMoneyService sendMoneyService =
new SendMoneyService(loadAccountPort, accountLock, updateAccountStatePort, moneyTransferProperties());
@Test
void transactionSucceeds() {
Account sourceAccount = givenSourceAccount();
Account targetAccount = givenTargetAccount();
givenWithdrawalWillSucceed(sourceAccount);
givenDepositWillSucceed(targetAccount);
Money money = Money.of(500L);
SendMoneyCommand command = new SendMoneyCommand(
sourceAccount.getId().get(),
targetAccount.getId().get(),
money);
boolean success = sendMoneyService.sendMoney(command);
assertThat(success).isTrue();
AccountId sourceAccountId = sourceAccount.getId().get();
AccountId targetAccountId = targetAccount.getId().get();
then(accountLock).should().lockAccount(eq(sourceAccountId));
then(sourceAccount).should().withdraw(eq(money), eq(targetAccountId));
then(accountLock).should().releaseAccount(eq(sourceAccountId));
then(accountLock).should().lockAccount(eq(targetAccountId));
then(targetAccount).should().deposit(eq(money), eq(sourceAccountId));
then(accountLock).should().releaseAccount(eq(targetAccountId));
thenAccountsHaveBeenUpdated(sourceAccountId, targetAccountId);
}
private void thenAccountsHaveBeenUpdated(AccountId... accountIds){
ArgumentCaptor<Account> accountCaptor = ArgumentCaptor.forClass(Account.class);
then(updateAccountStatePort).should(times(accountIds.length))
.updateActivities(accountCaptor.capture());
List<AccountId> updatedAccountIds = accountCaptor.getAllValues()
.stream()
.map(Account::getId)
.map(Optional::get)
.collect(Collectors.toList());
for(AccountId accountId : accountIds){
assertThat(updatedAccountIds).contains(accountId);
}
}
private void givenDepositWillSucceed(Account account) {
given(account.deposit(any(Money.class), any(AccountId.class)))
.willReturn(true);
}
private void givenWithdrawalWillSucceed(Account account) {
given(account.withdraw(any(Money.class), any(AccountId.class)))
.willReturn(true);
}
private Account givenTargetAccount(){
return givenAnAccountWithId(new AccountId(42L));
}
private Account givenSourceAccount(){
return givenAnAccountWithId(new AccountId(41L));
}
private Account givenAnAccountWithId(AccountId id) {
Account account = Mockito.mock(Account.class);
given(account.getId())
.willReturn(Optional.of(id));
given(loadAccountPort.loadAccount(eq(account.getId().get()), any(LocalDateTime.class)))
.willReturn(account);
return account;
}
private MoneyTransferProperties moneyTransferProperties(){
return new MoneyTransferProperties(Money.of(Long.MAX_VALUE));
}
}
- 의존성이 있는 포트들을 모킹해서 해당 유스케이스가 정상적으로 실행되었는지 그리고 트랜잭션이 성공적이었는지, 출금 및 입금 Account, 그리고 계좌에 락을 걸고 해제하는 책임을 가진 AccountLock 에 대해 특정 메서드가 호출됐는지 검증한다.
- 테스트는 서비스가 (모킹된) 의존 대상의 특정 메서드와 상호작용했는지 여부를 검증한다. 이는 테스트가 코드의 행동 변경 뿐만 아니라 구조 변경에도 취약해지게되고 리팩터링되면 테스트도 변경될 확률이 높아진다.
- 그렇기 때문에, 테스트에서 어떤 상호작용을 검증하고 싶은지 신중하게 생각해야하고 위 테스트코드처럼 모든 동작을 검증하는 대신 중요한 핵심만 골라 집중해서 테스트하는것이 필요하다.
통합 테스트로 웹 어댑터 테스트하기
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class SendMoneyControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private SendMoneyUseCase sendMoneyUseCase;
@Test
void testSendMoney() throws Exception {
mockMvc.perform(post("/accounts/send/{sourceAccountId}/{targetAccountId}/{amount}",
41L, 42L, 500)
.header("Content-Type", "application/json"))
.andExpect(status().isOk());
then(sendMoneyUseCase).should()
.sendMoney(eq(new SendMoneyCommand(
new AccountId(41L),
new AccountId(42L),
Money.of(500L))));
}
}
- HTTP 요청 결과가 200임을 검증하고, 모킹한 유스케이스가 잘 호출됐는지를 검증한다. 웹 어댑터 책임 대부분은 이 테스트로 커버된다.
- MockMvc 객체를 이용해 모킹했기 떄문에 실제 HTTP 프로토콜을 통해 테스트한것은 아니다. 프레임워크가 HTTP 프로토콜에 맞게 모든것을 적절히 잘 변환한다고 믿는것이다.
- 입력을 JSON 에서 SendMonyCommand 객체 매핑하는 전 과정은 다루고 있다. 만약 SendMoneyCommand 객체에 validation 로직이 있다면 해당 검증 로직도 확인이 필요할것이다.
- 이 테스트가 단위 테스트가 아닌 통합 테스트인 이유는 단순하게 컨트롤러 클래스만 테스트한것이 아니라 @WebMvcTest 애너테이션은 스프링이 스프링이 특정 요청 경로, 자바와 JSON 간의 매핑, HTTP 입력 검증 등에 필요한 전체 객체 네트워크를 인스턴스화하도록 만든다. 그리고 테스트에서는 웹 컨트롤러가 이 네트워크의 일부로서 잘 동작하는지 검증한다.
- 웹 컨트롤러가 스프링 프레임워크에 강하게 묶여있으므로 격리된 상태로 테스트하기보단 프레임워크와 통합된 상태로 테스트하는것이 합리적이다.
통합 테스트로 영속성 어댑터 테스트하기
- 영속성 어댑터도 스프링 프레임워크에 강하게 묶여있으므로 단위 테스트보단 통합 테스트를 적용하는것이 합리적이다.
- 단순 어댑터 로직만 검증하고 싶은게 아니라 데이터베이스 매핑도 검증하고 싶기 때문이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@DataJpaTest
@Import({AccountPersistenceAdapter.class, AccountMapper.class})
class AccountPersistenceAdapterTest {
@Autowired
private AccountPersistenceAdapter adapterUnderTest;
@Autowired
private ActivityRepository activityRepository;
@Test
@Sql("AccountPersistenceAdapterTest.sql")
void loadsAccount() {
Account account = adapterUnderTest.loadAccount(new AccountId(1L), LocalDateTime.of(2018, 8, 10, 0, 0));
assertThat(account.getActivityWindow().getActivities()).hasSize(2);
assertThat(account.calculateBalance()).isEqualTo(Money.of(500));
}
@Test
void updatesActivities() {
Account account = defaultAccount()
.withBaselineBalance(Money.of(555L))
.withActivityWindow(new ActivityWindow(
defaultActivity()
.withId(null)
.withMoney(Money.of(1L)).build()))
.build();
adapterUnderTest.updateActivities(account);
assertThat(activityRepository.count()).isEqualTo(1);
ActivityJpaEntity savedActivity = activityRepository.findAll().get(0);
assertThat(savedActivity.getAmount()).isEqualTo(1L);
}
}
@DataJpaTest 애너테이션
은 스프링 데이터 리포지토리들을 포함해서 데이터베이스 접근에 필요한 객체 네트워크를 인스턴스화해야 한다고 스프링에 전달해준다.@Import 애너테이션
은 특정 객체가 이 네트워크에 추가됐다는것을 명확히 표현할수 있게해준다.- 여기서 중요한점은 DB를 모킹하지 않고 실제 DB로 테스트했다는 점이다.
- 실제 DB와 연동했을때 SQL 구문 오류나 DB 테이블과 자바 객체 간의 매핑 에러 등으로 문제가 생길확률이 높아지기 때문이다.
참고로 스프링에선 기본적으로 인메모리 데이터베이스(h2)를 적용해준다.
- 프로덕션 환경에선 인메모리 데이터베이스를 사용하지 않는 경우가 많으므로 인메모리 데이터베이스에서 테스트가 완벽하게 통과했더라도 실제 데이터베이스에는 문제가 생길 가능성이 높다. 에를 들면 DB마다 고유한 SQL 문법이 있어서 이 부분이 문제가 되는 식으로 말이다.
- 이러한 이유로 영속성 어댑터 테스트는 싲레 데이터베이스를 대상으로 진행해야 한다. Testcontainers 같은 라이브러리는 필요한 데이터베이스를 도커 컨테이너에 띄울 수 있기 때문에 아주 유용하다.
- 두 개의 DB 시스템을 신경 쓸 필요가 없다는 장점도 생긴다. 쉽게 생각하면 마이그레이션을 양쪽 따로 신경쓸 걱정도 사라지게 된다.
시스템 테스트로 주요 경로 테스트하기
- 시스템 테스트는 전체 애플리케이션을 띄워 API 를 통해 요청을 보내고, 모든 계층이 조화롭게 잘 동작하는지 검증한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class SendMoneySystemTest {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private LoadAccountPort loadAccountPort;
@Test
@Sql("SendMoneySystemTest.sql")
void sendMoney() {
Money initialSourceBalance = sourceAccount().calculateBalance();
Money initialTargetBalance = targetAccount().calculateBalance();
ResponseEntity response = whenSendMoney(
sourceAccountId(),
targetAccountId(),
transferredAmount());
then(response.getStatusCode())
.isEqualTo(HttpStatus.OK);
then(sourceAccount().calculateBalance())
.isEqualTo(initialSourceBalance.minus(transferredAmount()));
then(targetAccount().calculateBalance())
.isEqualTo(initialTargetBalance.plus(transferredAmount()));
}
private Account sourceAccount() {
return loadAccount(sourceAccountId());
}
private Account targetAccount() {
return loadAccount(targetAccountId());
}
private Account loadAccount(AccountId accountId) {
return loadAccountPort.loadAccount(
accountId,
LocalDateTime.now());
}
private ResponseEntity whenSendMoney(
AccountId sourceAccountId,
AccountId targetAccountId,
Money amount) {
HttpHeaders headers = new HttpHeaders();
headers.add("Content-Type", "application/json");
HttpEntity<Void> request = new HttpEntity<>(null, headers);
return restTemplate.exchange(
"/accounts/send/{sourceAccountId}/{targetAccountId}/{amount}",
HttpMethod.POST,
request,
Object.class,
sourceAccountId.getValue(),
targetAccountId.getValue(),
amount.getAmount());
}
private Money transferredAmount() {
return Money.of(500L);
}
private Money balanceOf(AccountId accountId) {
Account account = loadAccountPort.loadAccount(accountId, LocalDateTime.now());
return account.calculateBalance();
}
private AccountId sourceAccountId() {
return new AccountId(1L);
}
private AccountId targetAccountId() {
return new AccountId(2L);
}
}
@SpringBootTest
애너테이션은 모든 스프링빈을 빈컨테이너에 등록하고 애플리케이션을 기동시킨다.- 웹 어댑터에서처럼 MockMvc를 이용해 요청을 보내는것이 아니라 TestRestTemplate 을 이용해 요청을 보낸다. 테스트를 프로덕션 환경에 조금 더 가깝게 만들기 위해 실제 HTTP 통신을 하는것이다.
- 실제 HTTP 통신을 하는것처럼 출력 어댑터도 이용한다. 예제에서 출력 어댑터는 영속성 어댑터뿐이다. 다른 시스템과 통신하는 (ex. 마이크로서비스간 내부 통신) 애플리케이션의 경우 다른 출력 어댑터들도 잇을수 있다. 시스템 테스트라고 하더라도 언제나 서드파티 시스템을 실행해서 테스트할 수 있는 것은 아니기 때문에 결국 모킹을 해야할때도 있다. 육각형 아키텍처는 이러한 경우 몇 개의 출력 포트 인터페이스만 모킹하면 되기에 아주 쉽게 해결 가능하다.
- 참고로 테스트 가독성을 높이기 위해 지저분한 로직들을 헬퍼 메서드안으로 감췄다. 이제 이 헬퍼 메서드들은 여러 가지 상태를 검증할때 사용할 수 있는 도메인 특화 언어(DSL)를 형성한다.
- 단위 테스트와 통합테스트를 만들었다면 시스템 테스트와 겹치는 부분이 많을것이다. 그럼에도 추가적인 다른 장점도 있다. 예를 들면 또 다른 종류의 버그를 발견해서 수정할 수 있게 해준다. 단위 테스트나 통합 테스트만으론 알아차리지 못했을 계층간 매핑 버그 같은 것들 말이다.
- 여러 개의 유스케이스를 결합하여 시나리오를 만들때 더 빛이 난다. 각 시나리오는 사용자가 애플리케이션을 사용하면서 거쳐갈 특정 API 모음들을 의미한다. 시스템 테스틀르 통해 중요한 시나리오들이 커버된다면 최신 변경사항들이 기존 형상에 영향이 가지 않았음을 확신할수 있어 제품 안정성을 지킬수 있다.
얼마만큼의 테스트가 충분할까?
- 라인 커버리지는 테스트 성공을 측정하는데 잘못된 지표다. 코드의 중요한 부분이 전혀 커버되지 않을수 있기에 100%를 제외한 어떤 목표도 완전 무의미하다. 심지어 100%라도 버그가 잘 잡혔는지 확신할 수 없다.
- 저자는 얼마나 마음 편하게 소프트웨어를 배포할 수 있느냐를 테스트의 성공 기준으로 삼으면 된다고 생각한다. 테스트를 실행후 배포해도될만큼 테스트를 신뢰한다면 말이다.
- 처음 몇번의 배포에는 믿음의 도약이 필요하다. 그렇지만 프로덕션 버그를 수정하고 이로부터 배우는것을 우선순위로 삼으면 제대로 가고 있는것이다.
- 각각의 프로덕션 버그에 대해 “테스트가 왜 이버그를 잡지 못했지?”를 생각하고 답변을 기록하며, 이 케이스를 커버할 수 있는 테스트를 추가해야 한다. 시간이 지나면 이 작업들이 배포할때 마음을 편안하게 해줄것이고, 남겨둔 기록은 시간이 지날수록 상황이 개선되고 있음을 증명해줄것이다.
- 육각형 아키텍처에서 사용하는 테스트 전략은 다음과 같다.
- 1)도메인 엔티티를 구현할 때는 단위 테스트로 커버하자
- 2)유스케이스를 구현할 때는 단위 테스트로 커버하자
- 3)어댑터를 구현할 때는 통합 테스트로 커버하자
- 4)사용자가 취할 수 있는 중요 애플리케이션 경로는 시스템 테스트로 커버하자
- ‘구현할 때는’ 이라는 문구에 주목하자. 테스트가 기능 개발후가 아닌 개발 중에 이뤄진다면 하기 싫은 귀찮은 작업이 아니라 개발 도구로 느껴질것이다.
- 하지만 새로운 필드를 추가할때마다 테스트를 고치는데 한 시간을 써야 한다면 뭔가 잘못된것이다. 아마도 테스트가 구조적 변경에 너무 취약할것이므로 어떻게 개선할지 살펴봐야한다. 리팩터링 할때마다 테스트 코드도 변경해야 한다면 테스트는 테스트로서의 가치를 잃는다. (각 계층별 테스트에 대한 목적을 뚜렷하게 하지 않으면 이런 상황이 발생하지 않을까 싶다)
유지보수 가능한 소프트웨어를 만드는데 어떻게 도움이 될까?
- 핵심 도메인 로직은 단위 테스트로, 어댑터는 통합 테스트로 처리하는 명확한 테스트 전략을 정의할 수 있다.
- 입출력 포트는 테스트에서 아주 뚜렷한 모킹 지점이 된다.
- 모킹하는 것이 너무 버거워지거나 코드의 특정 부분을 커버하기 위해 어떤 종류의 테스트를 써야 할지 모르겠다면 이는 경고 신호다. 이런 측면에서 테스트는 아키텍처의 문제점을 경고해주고 좋은 구조로 이끌도록 도와준다.
Reference
8장 - 경계 간 매핑하기
- 각 계층의 매핑을 모델을 매핑하는것에 대한 찬성파와 반대파가 있다.
- 찬성파: 매핑 안할 경우 두 계층에서 같은 모델을 사용할텐데 두 계층이 강하게 결합된다.
- 반대파: 보일러 플레이트코드가 너무 많아진다.
‘매핑하지 않기’ 전략
출처: https://jandari91.tistory.com/58
- 웹 계층과 애플리케이션 계층 모두 Account 클래스에 접근해야 한다는것(두 계층이 같은 모델을 사용)을 의미한다.
장점
- 간단한 CRUD 유스케이스에는 실용적일수 있다.
- 같은 필드를 가진 웹 모델을 도메인 모델로, 혹은 도메인 모델을 영속성 모델로 매핑할 필요는 없다.
- 도메인 모델에 추가한 JSON 이나 ORM 애너테이션 한두개를 바꿔야하더라도 큰 상관없다.
단점
- 도메인 모델에 웹 계층, 영속성 계층과 관련된 애너테이션이 필요할수있고(JSON, JPA) 웹, 애플리케이션, 영속성 계층과 관련된 이유로 변경돼야 하므로
단일 책임 원칙
을 위반하게 된다. - 오로지 한 계층에서만 필요로 하는 필드들을 포함하는 파편화된 도메인 모델로 이어질 수 있다.
정리
- 모든 계층이 정확히 같은 구조의 정확히 같은 정보를 필요로 한다면 완벽한 선택지다.
- 그러나 애플리케이션 계층이나 도메인 계층에서 웹과 영속성 문제를 다루게되면(애너테이션을 제외하더라도) 곧바로 다른 전략을 취해야한다.
- 하지만 나중에 언제든 바꿀수 있다. 저자의 경험에 의하면 많은 유스케이스들이 간단한 CRUD 수준으로 시작했다가 시간이 지남에 따라 값비싼 매핑 전략이 필요한, 풍부한 행동과 유효성 검증을 가진 제대로된 비즈니스 유스케이스로 바뀌어갔기 때문이다. 또는 영원히 CRUD 유스케이스로 남을수도 있겠지만, 이 경우엔 다른 매핑 전략에 시간을 들이지 않았기에 이것 역시 반가운 일이다.
‘양방향’ 매핑 전략
출처: https://jandari91.tistory.com/58
- 각 계층은 도메인 모델과는 완전히 다른 구조의 전용 모델을 가진다.(웹 모델, 영속성 모델)
- 웹 계층에선 웹 모델을 인커밍 포트에서 필요한 도메인 모델로 매핑하고, 인커밍 포트에 의해 반환된 도메인 객체를 다시 웹 모델로 매핑한다.
- 영속성 계층은 아웃고잉 포트가 사용되는 도메인 모델과 영속성 모델 간의 매핑과 유사한 매핑을 담당한다.
- 두 계층 모두 양 방향으로 매핑하기에 양방향 매핑이라 부른다.
- ex. 웹 계층에서 전용호 request 모델을 도메인 모델로 변환해서 유스케이스를 호출하고 도메인 모델을 반환값으로 리턴받아 별도 response 모델을 반환하는 구조
장점
- 각 계층이 전용 모델을 변경하더라도 다른 계층에는 영향이 없다. 그래서 웹 모델은 데이터 최적으로 표현할 수 있는 구조를 가질수 있고, 도메인 모델은 유스케이스를 제일 잘 구현할 수 있는 구조를 가질 수 있다. 그리고 영속성 모델은 데이터베이스에 객체를 저장하기 위해 ORM에서 필요로 하는 구조를 가질 수 있다.
- 이 매핑 전략은 웹이나 영속성 관심사로 오염되지 않은 깨끗한 도메인 모델로 이어지게 되고(JSON 이나 ORM 매핑 애너테이션 없이) 단일 책임 원칙을 만족하게 된다.
- ‘양방향’ 매핑의 또 다른 장점은 ‘매핑하지 않기’ 전략 다음으로 간단한 전략이고 매핑 책임이 명확하다.
- 즉, 바깥쪽 계층/어댑터는 안쪽 계층 모델로 매핑하고, 다시 반대 방향으로 매핑한다. 안쪽 계층은 해당 계층의 모델만 알면되고 매핑 대신 도메인 로직에 집중할 수 있다.
단점
- 두 모델간 매핑구현으로 인해 꽤 시간이 들고, 너무 많은 보일러플레이트 코드가 생긴다.
- 도메인 모델은 도메인 모델의 필요에 의해서만 변경되는것이 이상적이지만 바깥쪽 계층의 요구에 취약해진다.
- 도메인 모델이 계층 경계를 넘어 통신하는데 사용되고 있기 때문이다. 인커핑 포트와 아웃고잉 포트는 도메인 객체를 입력 파라미터와 반환값으로 사용한다.
정리
- 이 전략도 은총알은 아니다. 철칙처럼 여겨선 안된다.
‘완전’ 매핑 전략
출처: https://jandari91.tistory.com/58
- 각 연산마다 별도 입출력 모델을 사용한다.
- 계층 경계를 넘어 통신할때 도메인 모델을 사용하는 대신 별도 유스케이스 작업에 특화된 모델(SendMoneyCommand)을 사용한다.
- 커맨드(command), 요청(request) 혹은 이와 비슷한 단어로 표현한다.
- 웹 계층은 입력을 애플리케이션 계층의 커맨드 객체로 매핑할 책임을 가지게 된다.
- 각 유스케이스는 전용 필드와 유효성 검증 로직을 가진 전용 커맨드를 가진다.
- 값을 비워둘수있는 필드를 허용해선 안된다. 허용할 경우 현재 유스케이스에서 필요없는 유효성 검증이 수행될수도 있기 때문이다.
- 애플리케이션 계층은 커맨드 객체를 유스케이스에 따라 도메인 모델을 변경하기 위해 필요한 무엇인가로 매핑할 책임을 가진다.
장점
- 여러 유스케이스의 요구사항을 함께 다뤄야 하는 매핑에 비해 구현하고 유지보수하기 훨씬 쉽다.
정리
- 전역 패턴으로 추천하진 않는다.
- 웹 계층(인커밍 어댑터)과 애플리케이션 계층 사이에서 상태 변경 유스케이스의 경계를 명확하게 할 때 가장 효과적이다.
- 애플리케이션 계층과 영속성 계층 사이에선 매핑 오버헤드 때문에 사용하지 않는 것이 좋다.
- 연산의 입력 모델에 대해서만 이 매핑을 사용하고, 도메인 객체를 그대로 출력 모델로 사용하는것도 좋다. SendMonyUseCase 가 업데이트된 잔고를 가진채로 Account 객체를 그대로 반환하는것처럼 말이다.
- 이처럼 매핑 전략은 여러가지를 섞어쓸수 있고, 섞어써야만 한다. 어떤 매핑 전략도 모든 계층에 걸쳐 전역 규칙일 필요가 없다.
‘단방향’ 매핑 전략
출처: https://jandari91.tistory.com/58
- 모든 계층의 모델들이 같은 인터페이스를 구현한다.
- 이 인터페이스는 관련 있는 특성(attribute)에 대한 getter 메서드를 제공해서 도메인 모델의 상태를 캡슐화한다.
- 도메인 객체를 바같 계층으로 전달하고 싶으면 매핑 없이 할 수 있다. 왜냐하면 도메인 객체가 인커밍/아웃고잉 포트가 기대하는대로 상태 인터페이스를 구현하기 때문이다. 그러면 바깥쪽 계층에선 상태 인터페이스를 이용할지, 전용 모델로 매핑해야할지 결정할 수 있다.
- 행동을 변경하는 것이 상태 인터페이스에 의해 노출돼 있지 않기 때문에 실수로 도메인 객체의 상태를 변경하는 일은 발생하지 않는다.
- 애플리케이션 계층에선 상태 인터페이스를 실제 도메인 모델로 매핑해서 도메인 모델의 행동에 접근할 수 있게 된다.
- DDD의 팩터리(factory)라는 개념과 잘 어울린다.
- 팩터리(factory) : 어떤 특정한 상태로부터 도메인 객체를 재구성할 책임을 가지고 있다.
- 매핑 책임은 명확하다. 만약 한 계층이 다른 계층으로부터 객체를 받으면 해당 계층에서 이용할 수 있도록 다른 무언가를 매핑하는것이다. 그러므로 각 계층은 한 방향으로만 매핑한다.
- 다른 전략에 비해 개념적으로 어렵다…
- 계층 간의 모델이 비슷할 때 가장 효과적이다. 예를 들어, 읽기 전용 연산의 경우 상태 인터페이스가 필요한 모든 정보를 제공하기 때문에 웹 계층에서 전용 모델로 매핑할 필요가 전혀 없다.
언제 어떤 매핑 전략을 사용할 것인가?
- 상황에 맞게 최선의 전략을 사용하면 된다.
- 하나의 매핑 전략을 전역 규칙으로 삼지마라. 특정 작업에 대해 최선이 아님에도 불구하고 깔끔하게 느껴진다고 택하는것은 무책임하다.
- 소프트웨어는 시간이 지나면서 변화하기 떄문에 언제든 매핑전략이 변경될 수 있다.
- 팀 내에서 상황에 따른 매핑 전략 가이드를 마련하는게 좋다. 이를 성공적으로 적용시키려면 구성원들의 인지와 공감이 필요하다. 더 나아가 팀 차원에서 지속적으로 논의하고 수정이 필요하다.
예시 변경 유스케이스
- 예를 들어, 변경 유스케이스와 쿼리 유스케이스에 서로 다른 매핑 가이드라인을 정해둘수있다. 또 웹 계층과 애플리케이션 계층 사이에서 사용할 매핑 전략과 애플리케이션 계층에서 영속성 계층 사이에서 사용할 매핑 전략을 다르게 세울 수 있다.
변경 유스케이스
웹 <-> 애플리케이션 계층
사이에선 유스케이스간 결합을 제거하기 위해 ‘완전 매핑’ 전략을 첫번째 선택지로 택한다.- 이렇게 하면 유스케이스별 유효성 검증 규칙이 명확해지고 특정 유스케이스에서 필요하지 않은 필드를 다루지 않아도 된다
애플리케이션 <-> 영속성
계층 사이에선 매핑 오버헤드를 줄이고 빠르게 코드를 짜기 위해 ‘매핑하지 않기’ 전략을 첫 번째 선택지로 둔다.- 하지만 애플리케이션 게층에서 영속성 문제를 다뤄야 하게 되면 ‘양방향’ 매핑 전략으로 바꿔 영속성 문제를 영속성 계층에 가둘수있다.
쿼리 유스케이스
웹 <-> 애플리케이션
,애플리케이션 <-> 영속성
계층 모두 매핑 오버헤드를 줄이고 빠르게 코드를 짜기 위해 ‘매핑하지 않기’ 전략을 첫번째 선택지로 둔다.- 하지만 애플리케이션 계층에서 영속성 문제나 웹 문제를 다뤄야 하게 되면
웹 <-> 애플리케이션
,애플리케이션 <-> 영속성 계층
사이에서 각각 ‘양방향’ 매핑 전략으로 바꾼다.
유지보수 가능한 소프트웨어를 만드는데 어떻게 도움이 될까?
- 좁은 포트를 사용하면 유스케이스마다 다른 매핑 전략을 사용할 수 있고, 다른 유스케이스에 영향을 미치지 않으면서 코드를 개선할 수 있기 때문에 특정 상황, 특정 시점에 최선의 전략을 선택할 수 있다.
- 상황별로 매핑 전략을 선택하는 것은 모든 상황에 같은 매핑 전략을 사용하는것보다 분명 더 어렵고 더 많은 커뮤니케이션이 필요하다. 하지만 매핑 가이드라인이 있는 한 코드가 정확히 해야 하는 일만 수행하면서도 더 유지보수하기 쉬운 코드로 팀에 보상이 되어 돌아올 것이다.
Reference
9장 - 애플리케이션 조립하기
구현된 유스케이스, 웹 어댑터, 영속성 어댑터를 애플리케이션단에서 조립해야지만 하나의 애플리케이션으로 동작하게 된다.
왜 조립까지 신경 써야 할까?
- 코드 의존성이 안쪽으로(올바로)가게 하기 위해서다. 그래야 도메인 코드를 바깥 계층의 변경으로부터 지킬수 있다.
- 유스케이스가 포트가 아닌 영속성 어댑터를 직접 참조하게 되면 안된다. 유스케이스는 포트만 알아야하고, 런타임에 구현체가 주입되도록 해야한다.
- 이렇게함으로써 코드를 더 테스트하기 쉽게 만드는 부수효과를 얻을 수 있다.
- 한 클래스가 필요로 하는 모든 객체를 생성자로 전달할 수 있다면 실제 객체 대신 목으로 전달할 수 있고, 이렇게 되면 격리된 단위 테스트를 생성하기 쉬워진다.
- 객체 인스턴스를 생성하고 의존성을 주입시켜주는 책임은 ‘설정 컴포넌트’에 있다.
- 설정 컴포넌트는 아래 이미지처럼 아키텍처에 대해 중립적이고 인스턴스 생성을 위해 모든 클래스에 대한 의존성을 가진다.
- 설정 컴포넌트는 모든 내부 계층에 접근할 수 있는 가장 바깥쪽에 위치한다.
출처: https://jandari91.tistory.com/59
- 설정 컴포넌트의 역할은 다음과 같다.
- 웹 어댑터 인스턴스 생성
- HTTP 요청이 실제로 웹 어댑터로 전달되도록 보장
- 유스케이스 인스턴스 생성
- 웹 어댑터에 유스케이스 인스턴스 제공
- 영속성 어댑터 인스턴스 생성
- 유스케이스 영속성 어댑터 인스턴스 제공
- 영속성 어댑터가 실제로 데이터베이스에 접근할 수 있도록 보장
- 설정 파일이나 커맨드라인 파라미터 등과 같은 설정 파라미터의 소스에도 접근할 수 있어야 한다.
- 이러한 파라미터를 애플리케이션 컴포넌트에 제공해서 어떤 DB에 접근하고 어떤 서버를 메일 전송에 사용할지 등의 행동 양식을 제어한다.
- 근데 위 내용처럼 책임(변경할 이유)이 굉장히 많다. SRP를 위반한다. 그러나 애플리케이션 나머지 부분을 깔끔하게 유지하고 싶다면 이처럼 구성요소들을 연결하는 바깥쪽 컴포넌트가 필요하다.
평범한 코드로 조립하기
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package copyeditor.configuration;
class Application {
public static void main(String[] args) {
AccountRepository accountRepository = new AccountRepository();
ActivityRepository activityRepository = new ActivityRepository();
AccountPersistenceAdapter accountPersistenceAdapter = new AccountPersistenceAdapter(accountRepository, activityRepository);
SendMoneyUseCase sendMoneyUseCase = new SendMoneyUseService(
accountPersistenceAdapter,
accountPersistenceAdapter
);
SendMoneyController sendMoneyController = new SendMoneyController(sendMoneyUseCase);
startProcessingWebRequests(sendMoneyController);
}
}
- 메인 함수에서 필요한 모든 클래스의 인스턴스를 생성후 함께 연결한다. 그리고 startProcessingWebRequests 를 호출하여 웹 컨트롤러를 HTTP로 노출시킨다. (해당 함수는 웹 어댑터를 HTTP로 노출시키는데 필요한 애플리케이션 부트스트랩핑 로직이 들어간다)
평범한 코드로 조립의 단점
1) 조립하는데 필요한 코드양이 많아진다.
- 위 예제는 단순한 예제지만 엔터프라이즈 애플리케이션라면 엄청 생성 및 조립 코드가 많아질것이다.
2) 모든 조립되는 클래스는 public 접근제한자를 가져야한다.
- 각 클래스가 속한 패키지 외부에서 인스턴스를 생성하기 때문이다.
- 이렇게 되면 가령 유스케이스에서 영속성 어댑터에 직접 접근하는것을 막지 못한다.
- package-private 접근 제한자로 원치 않는 의존성을 피할 수 있었다면 더 좋았을것이다.
다행히도 package-private 의존성을 유지하면서 이처럼 지저분한 작업을 대신해줄 수 있는 의존성 주입 프레임워크들이 있다.
스프링의 클래스패스 스캐닝으로 조립하기
- 스프링 프레임워크를 이용해서 애플리케이션을 조립한 결과물을 애플리케이션 컨텍스트(application context)라 한다.
- 애플리케이션 컨텍스트는 애플리케이션 모든 객체(자바 용어로는 빈)를 포함한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@RequiredArgsConstructor
@Component
class AccountPersistenceAdapter implements
LoadAccountPort,
UpdateAccountStatePort {
private final AccountRepository accountRepository;
private final ActivityRepository activityRepository;
private final AccountMapper accountMapper;
@Override
public Account loadAccount(Account.AccountId accountId,
LocalDateTime baselineDate,
LocalDateTime baselineDate) {
// ...
}
@Override
public void updateActivities(Account account) {
// ...
}
}
- 스프링은 클래스패스 스캐닝으로 @Component 애너테이션이 붙은 클래스들을 객체로 생성하여 애플리케이션 컨텍스트에 등록한다. 의존성 주입도 잘 알아서 해준다.
1
2
3
4
5
6
7
8
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Component
public @interface PersistenceAdapter {
@AliasFor(annotation = Component.class)
String value() default "";
}
- 위와 같은 메타-애너테이션으로 영속성 어댑터 클래스들이 애플리케이션의 일부임을 표시할수 있다. 이는 코드를 읽는 사람들이 아키텍처를 더 쉽게 파악하도록 한다.
스프링의 클래스패스 스캐닝 방식의 단점
1) 클래스에 프레임워크에 특화된 애너테이션이 붙는다.
- 강경한 클린 아키텍처파는 이런 방식이 특정 프레임워크와 결합되어 사용하지 말아야 한다고 한다..
- 근데 뭐 일반적인 애플리케이션 개발에선 필요하다면 한 클래스에 애너테이션 하나 정도는 용인할 수 있고, 리팩터링도 그리 어렵지 않게 할 수 있다.
- 하지만 다른 개발자들이 사용할 라이브러리나 프레임워크를 만드는 입장에선 사용하지 말아야할 방법이다.
- 라이브러리나 프레임워크 사용자들이 스프링 프레임워크의 의존성에 엮이게 되기 때문이다.
2) 원인 찾기 어려운 숨겨진 부수효과를 야기할 수도 있다.
- 클래스패스 스캐닝이 애플리케이션에 조립에 사용하기엔 너무 둔한 도구이기 때문에 그런다.
스프링의 자바 컨피그로 조립하기
- 스캐닝보다 덜 지저분하고 프레임워크와 함꼐 제공되므로 모든것을 직접 코딩할 필요가 없는 방식이다.
- 이 방식은 애플리케이션 컨텍스트에 올릴 빈을 생성하는 설정 클래스를 만드는 방식이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Configuration
@EnableJpaRepositories
class PersistenceAdapterConfiguration {
@Bean
AccountPersistenceAdapter accountPersistenceAdapter(
AccountRepository accountRepository,
ActivityRepository activityRepository,
AccountMapper accountMapper
) {
return new AccountPersistenceAdapter(
accountRepository,
activityRepository,
accountMapper
);
}
@Bean
AccountMapper accountMapper() {
return new AccountMapper();
}
}
@EnabledJpaRepositories
애너테이션을 메인 애플리케이션에도 붙일수는 있지만 별도 설정 묘듈로 옮겨야 한다.- 메인 애플리케이션(@SpringBootApplication 애너테이션이 붙은 시작 클래스)에 붙이면 기동할때마다 JPA를 활성화해서 영속성이 필요 없는 테스트에서 애플리케이션을 실행할때도 JPA레포지토리들을 활성화할것이다.
- 그러므로 이러한 ‘기능 애너테이션’을 별도 설정 모듈로 옮기는게 애플리케이션을 더 유연하게 만들고, 항상 모든 것을 한꺼번에 시작할 필요 없게 해준다.
장점
1. 모든 빈을 가져오는 대신 설정 클래스만 선택하기 때문에 원인 모를 부수효과가 일어날 확률이 줄어든다.
2. 애플리케이션 컨텍스트에 등록되는 빈들을 제어하기 쉽게 만들어준다.
- 웹 어댑터, 애플리케이션 계층의 특정 모듈을 위한 설정 클래스를 만들수도 있다. 그러면 특정 모듈만 포함하고, 그외 다른 모듈의 빈은 모킹해서 애플리케이션 컨텍스트를 만들수 있다. 이렇게 하면 테스트에 큰 유연성이 생기고 리팩터링을 많이 하지 않고 각 모듈의 코드를 자체 코드베이스, 자체 패키지, 자체 JAR 파일로 밀어넣을수 있다.
- @Component 애너테이션을 코드 여기 저기에 붙이도록 강제하지 않아서 그래서 애플리케이션 계층을 스프링과의 의존성없이 깔끔하게 유지 가능하다.
단점
1. 설정 클래스가 생성하는 빈이 설정 클래스와 같은 패키지에 존재해야만 한다.
- 같은 패키지가 아니라면 public 접근제한자로 만들어야 한다.
- 가시성을 제한하기 위해 패키지를 모듈 경계로 사용하고 각 패키지 안에 전용 설정 클래스를 만들수는 있다. 하지만 이렇게하면 하위 패키지를 사용 못하게 된다.
유지보수 가능한 소프트웨어를 만드는데 어떻게 도움이 될까?
- 스프링 스캐닝 방식은 코드 규모가 커지면 금방 투명성이 낮아진다. 어떤 빈이 애플리케이션 컨택스트에 올라가는지 파악하기 어려워진다. 또 테스트에서 애플리케이션 컨텍스트의 일부만 독립적으로 띄우기가 어려워진다.
- 반면 전용 설정 컴포넌트 방식은 애플리케이션이 이러한 책임(변경할 이유)으로부터 자유로워지고 서로 다른 모듈로부터 독립되어 코드 상에서 손쉽게 옮겨 다닐수 있는 응집도가 매우 높은 모듈을 만들수 있다. 하지만 늘 그렇듯이 설정 컴포넌트를 유지보수하는데 약간의 시간을 추가로 들여야 한다.
Reference
10장 - 아키텍처 경계 강제하기
경계와 의존성
출처: https://jandari91.tistory.com/60
- 아키텍처 경계를 강제한다는것은 의존성이 올바른 방향을 향하도록 강제하는것을 의미한다. 점선 화살표는 아키텍처에서 허용되지 않은 의존성이다.
접근제한자
- package-private 제한자가 중요한 이유는 자바 패키지를 통해 클래스들을
응집적인 모듈
로 만들어주기 때문이다.- 모듈의 진입점으로 활용된 클래스들만 public 으로 열어주면 된다.
- 이렇게 하면 의존성이 잘못된 방향을 가리켜서 의존성 규칙을 위반할 위험이 줄어든다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
buckpal
└─────── account
├──── adapter
│ ├──── in
│ │ └──── web
│ │ └──── o AccountController
│ │
│ └──── out
│ └──── persistence
│ ├──── o AccountPersistenceAdapter
│ └──── o SpringDataAccountRepository
│
├──── domain
│ ├──── + Account
│ └──── + Activity
│
└──── application
├──── o SendMoneyService
└──── port
├──── in
│ └──── + SendMoneyUseCase
└──── out
├──── + LoadAccountPort
└──── + UpdateAccountStatePort
- persistence 패키진와 application 패키지내의 SendMoneyService는 자신이 구현할 포트를 통해서만 접근되기에 package-private 으로 만들수있다. (‘o 표시’)
- 의존성 주입 메커니즘은 일반적으로 리플렉션을 이용해 클래스를 인스턴스화하기에 package-private 이더라도 상관없다.
이 방법을 스프링에서 사용하려면 클래스패스 스캐닝을 이용해야만 한다. 다른 방법에서는 객체의 인스턴스들을 우리가 직접 생성해야 하기에 public 제한자를 이용해야 한다.
- 예제의 나머지 클래스들은 아키텍처 정의에 의해 public(‘+’ 표시)이어야 한다. domain 패키지는 다른 계층에서 접근 가능해야 하고, application 계층은 web 어댑터와 persistence 어댑터에서 접근 가능해야 한다.
- package-private 제한자는 몇 개 정도의 클래스로만 이뤄지 작은 모듈에서 가장 효과적이다. 그러나 패키지 내의 클래스가 특정 개수를 넘어가기 시작하면 하나의 패키지에 너무 많은 클래스를 포함하는 것이 혼란스러워지게 되고 이렇게 되면 코드를 찾는게 쉽지 않아진다. 그러면 하위패키지를 만드는 방법으로 보통 해결하곤 하는데 자바는 하위 패키지를 다른 패키지로 취급하기에 하위 패키지는 public 으로 열어줘야만 한다. 그러면 아키텍처에서 의존성 규칙이 깨질수 있는 환경이 만들어지게 된다.
컴파일 후 체크
- public 제한자는 아키텍처 상의 의존성 방향이 잘못되더라도 컴파일러단에서 이를 잡을수 없게 된다.
- 대안은 ‘컴파일 후 체크(post-compile check)’를 도입하는것이다. 컴파일된 후에 런타임에 체크한다는 뜻이다. 런타임 체크는 지속적인 통합 빌드 환경에서 자동화된 테스트 과정에서 가장 잘 동작한다.
ArchUnit
- 위와 같은 체크를 도와주는 자바용 도구로
ArchUnit
이 있다. 의존성 방향이 기대한 대로 잘 설정돼 있는지 체크할 수 있는 API를 제공하고 의존성 규칙 위반을 발견하면 예외를 던진다. - Junit 과 같은 단위 테스트 프레임워크 기반에서 가장 잘 동작하며 의존성 규칙을 위반할 경우 테스트를 실패시킨다.
- 각 계층이 전용 패키지를 가진다하면 ArchUnit으로 계층간 의존성을 체크할 수 있다. 예를 들어, 아래 예제 코드처럼 도메인 계층에서 바깥쪽의 애플리케이션 계층으로 향하는 의존성이 없다는것을 체크할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
class DependencyRuleTests {
@Test
void domainLayerDoesNotDependOnApplicationLayer() {
noClasses()
.that()
.resideInAPackage("buckpal.domain..")
.should()
.dependOnClassesThat()
.resideInAnyPackage("buckpal.application..")
.check(new ClassFileImporter()
.importPackages("buckpal.."));
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class DependencyRuleTests {
@Test
void validateRegistrationContextArchitecture() {
HexagonalArchitecture.boundedContext("account")
.withDomainLayer("domain")
.withAdaptersLayer("adapter")
.incoming("web")
.outgoing("persistence")
.and()
.withApplicationLayer("application")
.service("service")
.incomingPorts("port.in")
.outgoingPorts("port.out")
.and()
.withConfiguration("configuration")
.check(new ClassFileImporter()
.importPackages("buckpal.."));
}
}
- 단점은 실패에 안전(fail-safe)하진 않다. 패키지 이름에 오타를 내면 테스트가 어떤 클래스도 찾지 못하므로 의존성 규칙 위반 사례를 발견하지 못하게 된다.
- 오타가 하나라도 나거나 패키지명을 하나만 리팩터링해도 테스트 전체가 무의미해질 수 있다.
- 이런 상황을 방지하려면 클래스를 하나도 찾지 못했을때 실패하는 테스트를 추가해야 한다.
- 그럼에도 불구하고 여전히 리팩터링에 취약한 것은 사실이다. 컴파일 후 체크는 언제나 코드와 함께 유지보수해야 한다.
빌드 아티팩트
- maven 과 gradle 과 같은 빌드 도구의 가장 중요한 기능 중 하나는 의존성 해결(dependency resolution)이다. 어떤 코드 베이스를 빌드 아티팩트로 변환하려면 빌드 도구가 가장 먼저 할 일은 의존하는 모든 아티팩트가 사용 가능한지이다. 만약 불가능한 것이 있다면 아티팩트 리포지토리로부터 가져오려고 시도하고 이마저도 실패하면 코드를 컴파일 하기전에 에러와 함께 빌드를 실패시킨다.
- 이를 활용해서 모듈과 아키텍처 계층 간의 의존성을 강제할 수 있다. (따라서 경계를 강제하는 효과가 생김)
- 각 모듈 혹은 계층에 대해 전용 코드 베이스와 빌드 아티팩트로 분리된 빌드 모듈(JAR 파일)을 만들 수 있다.
- 각 모듈의 빌드 스크립트에서는 아키텍처에서 허용하는 의존성만 지정한다. 클래스들이 클래스패스에 존재하지 않아 컴파일 에러가 발생하기에 개발자들은 더 이상 실수로 잘못된 의존성을 만들 수 없게 된다.
- 여러 개의 빌드 아티팩트로 만드는 방법은 아래 이미지와 같다.
출처: https://jandari91.tistory.com/60
- maven 의 gradle 의 멀티 모듈을 활용해서 계층들을 별도 모듈로 분리하는 방식이라 이해하면 된다.
- 왼쪽에서 오른쪽으로 갈수록 더 엄격하게 경계를 강제하는 방법이라 이해하면 된다.
- 위 이미지의 세번째 열의 케이스는 도메인 엔티티가 포트에서 전송 객체(DTO)로 사용되지 않는 경우라면(‘매핑하지 않기’ 전략을 허용하지 않는 경우) 의존성 역전 원칙을 적용해서 포트 인터페이스만 포함하는 API 모듈을 분리 가능하다.
- 맨 오른쪽 열의 케이스를 보면 도메인 빌드 아티팩트에 대한 의존성을 간단하게 선언하는 것만으로도 다른 애플리케이션(다른 유스케이스, 다른 서비스를 가진)이 같은 도메인 엔티티를 사용할 수 있게 된다.
- 위 이미지 방법 말고도 실제론 빌드 아티팩트를 나누는 더 다양한 방법이 존재한다. 하지만 핵심은 모두 같다. ‘모듈을 더 세분화할수록, 모듈간 의존성을 더 잘 제어할 수 있게 된다는것’이다. 하지만 더 작게 분리할수록 (매핑 전략을 채택해서) 모듈 간 매핑이 더 많아지게 된다.
빌드 모듈로 아키텍처 경계를 구분하는것은 패키지로 구분하는것과 비교했을때의 장점
1. 빌드 도구가 순환 의존성을 허용하지 않는다.
- 순환 읮존성은 한 모듈에서의 변경이 잠재적으로 순환고리에 포함된 다른 모든 모듈을 변경하게 만들며, 단일 책임 원칙을 위반한다.
- 자바 컴파일러는 두 개 혹은 그 이상 패키지에서 순환 의존성이 있든 말든 신경 쓰지 않는다.
2. 특정 모듈의 코드를 격리한채로 변경 가능하다.
- 특정 모듈에서 발생하는 컴파일에러와 무관하게 격리된 다른 계층 모듈의 테스트를 실행할 수 있다.
- 메이븐이나 그레이들로 빌드 프로세르르 실행하는 것 역시 마찬가지다. 만약 두 계층이 같은 빌드 모듈에 있다면 어느 한쪽 계층의 컴파일 에러로 빌드가 실패할것이다.
3. 빌드 스크립트를 통해 의존성을 관리하므로 추가하려할 경우 정말 필요한 의존성인지 다시 한 번 생각해볼 수 있게 해준다.
- 모듈 간 의존성이 빌드 스크립트에 분명하게 선언돼 있기 때문에 새로 의존성을 추가하는 일은 우연이 아닌 의식적인 행동이 되기 때문이다.
- 하지만 이런 장점에는 빌드 스크립트를 유지보수하는 비용을 수반하기에 아키텍처를 여러 개의 빌드 모듈로 나누기 전에 아키텍처가 어느 정도 안정된 상태여야 한다.
유지보수 가능한 소프트웨어를 만드는데 어떻게 도움이 될까?
- 소프트웨어 아키텍처는 아키텍처 요소 간의 의존성을 관리하는게 전부다.
- 새로운 코드를 추가하거나 리팩토링시 패키지 구조를 항상 염두에 둬야하고, 가능하다면 package-private 가시성을 이용해 패키지 바깥에서 접근하면 안되는 클래스에 대한 의존성을 피해야 한다.
- 하나의 빌드 모듈 안에서 아키텍처 경계를 강제해야 하고, 패키지 구조가 허용하지 않아 package-private 제한자를 사용할 수 없다면 ArchUnit 같은 컴파일 후 체크 도구를 이용해야 한다.
- 그리고 아키텍처가 충분히 안정적이라 느껴지면 아키텍처 요소를 독립적인 빌드 모듈로 추출해야 한다. 그래야 의존성을 분명하게 제어할 수 있기 때문이다.
- 아키텍처 경계를 강제하고 시간이 지나도 유지보수하기 좋은 코드를 만들기 위해 세 가지 접근 방식 모두를 함께 조합해서 사용할 수 있다.
Reference
11장 - 의식적으로 지름길 사용하기
- 지름길 자체를 파악하면 우발적으로 사용되는 지름길을 인식하고 수정할 수 있다. 또는 정당한 지름길이라면 지름길의 효과를 의식적으로 택할 수도 있다.
왜 지름길은 깨진 창문 같을까?
- 1969년 심리학자 필립 짐바르도의 ‘깨진 창문 이론’이라는 실험의 내용과 유사하다.
- ‘깨진 창문 이론’은 어떤 것이 멈춘 것처럼 보이고, 망가져 보이거나 관리되지 않는다고 여겨지면 인간의 뇌는 이를 더 멈추고, 망가뜨려도 된다고 생각하게 된다는것과 비슷하다.
- 위 이론이 코드 작업에 적용될땐 다음의 의미를 갖는다.
- 품질이 떨어진 코드에서 작업할때 더 낮은 품질의 코드를 추가하기 쉽다.
- 코딩 규칙을 많이 어긴 코드에서 작업할때 또 다른 규칙을 어기기도 쉽다.
- 지름길을 많이 사용한 코드에서 작업할때 또 다른 지름길을 추가하기도 쉽다.
깨끗한 상태로 시작할 책임
- 가능한 지름길을 쓰지 않고 기술 부채를 지지 않은채 프로젝트를 깨끗하게 시작하는것이 중요하다. 지름길이 몰래 스며드는 순간 깨진 창문과 같아져 버려서 더 많은 지름길을 끌어들이기 때문이다.
- 새로운 프로젝트를 인계받는 입장에서는 이 코드가 연관성 전혀 없는 레거시이기에 깨진 창문을 만들어 내기가 더 쉽다.
- 때론 지름길이 더 실용적일때도 있다. 작업 중인 부분이 프로젝트 전체로 봤을땐 그리 중요치 않거나, 프로토타이핑 작업중이거나, 경제적인 이유로..
- 이러한 의도적인 지름길에 대해서는 세심하게 잘 기록해둬야 한다. 마이클 나이가드가 제안한 아키텍처 결정 기록의 형태도 괜찮다.
- 만약 팀원 모두가 이 문서에 대해 인지하고 있따면 지름길이 합리적인 이유에 의해 추가됐다는 사실을 알기에 깨진 창문 이론의 영향을더 줄일수 있을 것이다.
유스케이스간 모델 공유하기
출처: https://jandari91.tistory.com/61
- 유스케이스들이 기능적으로 묶여 있을때 유효하다. 즉, 특정 세부사항을 변경할때 실제로 두 유스케이스 모두에 영향을 주고 싶은 것이다.
- 만약 두 유스케이스가 독립적으로 진화해야 한다면 입출력 모델 공유는 지름길이 된다. 만약 독립적으로 진화해야 한다면 처음엔 똑같은 입출력 모델을 복붙하더라도 일단 분리해서 시작해야 한다.
- 그러므로 유스케이스가 독립적으로 진화할 필요가 있는지 주기적으로 질문해야 한다. 대답이 “예”가 되는 그때가 바로 입출력 모델을 분리할 시점이다.
도메인 엔티티를 입출력 모델로 사용하기
출처: https://jandari91.tistory.com/61
- 인커밍 포트는 도메인 엔티티에 의존성을 가지고 있다. 그 결과 Account 엔티티는 변경할 또 다른 이유가 생긴것이다.
- 간단한 생성이나 업데이트 유스케이스에서는 유스케이스 인터페이스에 도메인 엔티티가 있는것이 괜찮을지도 모른다. (DB에 저장해야 하는 바로 그상태 정보가 엔티티에 있으므로)
- 하지만 유스케이스가 단순히 데이터베이스 필드 몇 개를 업데이트하는 수준이 아니라 더 복잡한 도메인 로직을 구현해야 한다면(도메인 로직 일부를 풍분한 도메인 엔티티로 위임할 수도 있으니) 유스케이스 인터페이스에 대한 전용 입출력 모델을 만들어야 한다. 왜냐하면 유스케이스 변경이 도메인 엔티티까지 전파되길 바라진 않을것이기 때문이다.
- 이 지름길이 위험한 이유는 많은 유스케이스가 간단한 생성 또는 업데이트 유스케이스로 시작해서 시간이 지난면서 복잡한 도메인 로직 괴물이 되어간다는 사실 때문이다.
- 이는 최소 기능 제품(MVP)으로 시작해서 점점 복잡도를 높여가는 애자일 환경에서 특히 그렇다.
- 그러므로 처음에는 도메인 엔티티를 입력 모델로 사용했더라도 도메인 모델로부터 독립적인 전용 입력 모델로 교체해야 하는 시점을 잘 파악해야 한다.
인커밍 포트 건너뛰기
- 아웃고잉 포트는 애플리케이션 계층과 아웃고잉 어댑터 사이의 의존성을 역전시키기 위한 (의존성을 안쪽으로 가게하기 위한) 필수 요소인 반면 인커밍 포트는 의존성 역전에 필수적인 요소는 아니다.
- 인커밍 어댑터가 인커밍 포트 없이 애플리케이션 서비스에 직접 접근하도록 할 수 있다.
출처: https://jandari91.tistory.com/61
- 위 이미지를 보면 인커밍 어댑터와 애플리케이션 계층 사이의 추상화 계층을 줄였다.
- 하지만 인커밍 포트는 애플리케이션 중심에 접근하는 진입점을 정의한다. 이를 제거하면 특정 유스케이스를 구현하기 위해 어떤 서비스 메서드를 호출해야 할지 알아내기 위해 애플리케이션 내부 동작에 대해 더 잘 알아야 한다. 전용 인커밍 포트를 유지하면 한눈에 식별 가능하다. 이는 새로운 개발자가 코드를 파악시 특히 더 도움된다.
- 또 다른 이유는 아키텍처를 쉽게 가능할 수 있기 때문이다. 이전 장에서 소개한 아키텍처를 강제하는 옵션들을 이용하면 인커밍 어댑터가 애플리케이션 서비스가 아닌 인커밍 포트만 호출하게 할 수 있다. 그럼 애플리케이션 계층에 대한 모든 진입점을 정의하는것이 아주 의식적인 결정이 된다. 인커밍 어댑터에서 호출할 의도가 없던 서비스 메서드를 실수로 호출하는 일이 절대 발생할 수 없다.
- 애플리케이션 규모가 작거나 인커이 어댑터가 하나뿐이라 모든 제어 흐름을 인커밍 포트의 도움 없이 단순에 파악할 수 있다면 인커밍 포트가 없는것이 편하다.
- 그러나 애플리케이션 규모가 이후로도 계속 작게 유지되거나 인커밍 어댑터가 계속 하나밖에 없을것이라고는 확신할 수 없다.
애플리케이션 건너뛰기
출처: https://jandari91.tistory.com/61
- 아웃고잉 어댑터에 있는 AccountPersistenceAdapter 클래스는 직접 인커밍 포트를 구현해서 일반적으로 인커밍 포트를 구현하는 애플리케이션 서비스를 대체한다.
- 간단한 CRUD 유스케이스에선 보통 애플리케이션 서비스가 도메인 로직없이 생성, 업데이트, 삭제 요청을 그대로 영속성 어댑터에 전달하므로 구미가 당기는 방식이다. 그대로 전달하는 대신 영속성 어댑터가 직접 유스케이스를 구현하게 할 수 있다.
- 하지만 이 방법의 단점은 인커밍 어댑터와 아웃고잉 어댑터 사이에 모델을 공유해야 한다. 여기에선 도메인 모델을 입력 모델로 사용하는 케이스가 되버리게 되는것이다.
- 나아가 애플리케이션 코어에 유스케이스라고 할만한것이 없어진다. 만약 시간이 지남에 따라 CRUD 윳케이스가 점점 복잡해지면 도메인 로직을 그대로 아웃고잉 어댑터에 추가하고 싶은 생각이 들 수 있고, 그러면 도메인 로직이 흩어져서 추후 유지보수하기 어려워진다.
- 결국 단순히 전달만 하는 보일러 플레이트 코드가 가득한 서비스가 많아지는 것을 방지하기 위해 간단한 CRUD 유스케이스에선 애플리케이션 서비스를 건너뛰기로 결정할수도 있다. 하지만 유스케이스가 엔티티를 단순히 생성, 업데이트, 삭제하는 것보다 더 많은 일을 하게되면 애플리케이션 서비스를 만든다는 명확한 가이드라인을 팀에 정해둬야 한다.
유지보수 가능한 소프트웨어를 만드는데 어떻게 도움이 될까?
- 경제적인 관점에서 지름길이 합리적일때도 있다.
- 모든 애플리케이션은 처음엔 작게 시작하기에, 유스케이스가 단순한 CRUD 상태에서 벗어나는 시점이 언제인지에 대해 팀이 합의하는 것이 매우 중요하다. 합의를 이루고난 후에야 팀은 지름길을 장기적으로 더 유지보수하기 좋은 아키텍처로 대체할 수 있다.
- 단순 CRUD 상태에서 더이상 벗어나지 않은 유스케이스도 있다. 이러한 유스케이스는 유지보수 비용을 증가시키지 않기 때문에 지름길을 계속 그대로 유지하는게 더 경제적이다.
- 어떤 경우든 아키텍처에 대해, 그리고 왜 특정 지름길을 선택했는가에 대한 기록을 남겨서 나중에 우리 자신 또는 프로젝트를 인계받는 이들이 이 결정에 대해 다시 평가할 수 있게 하자.
Reference
12장 - 아키텍처 스타일 결정하기
- 언제 실제로 육각형 아키텍처 스타일을 사용해야 할까? 그리고 언제 전통적인 계층형 아키텍처 스타일(혹은 그외의 어떤 스타일이든)을 고수해야 할까? 에 대해 알아보자.
도메인이 왕이다
- 영속성 관심사나 외부 시스템에 대한 의존성 등의 변화로부터 자유롭게 도메인 코드를 개발할 수 잇는 것이 육각형 아키텍처 스타일의 주요 특징이라는 점이 명확해졌을것이다.
- “외부의 영향을 받지 않고 도메인 코드를 자유롭게 발전시킬수 있다는것은 육각형 아키텍처 스타일이 내세우는 가장 중요한 가치다” 이것이 도메인 주도 설계 방식과 정말 잘 어울리는 이유다.
- DDD에선 도메인이 개발을 주도한다. 그리고 영속성 문제나 다른 기술적인 측면에 대해서 함께 생각할 필요가 없게되면 도메인에 대해 가장 잘 고려할 수 있게 된다.
- 육각형 스타일과 같은 도메인 중심의 아키텍처 스타일은 DDD의 조력자라고까지 말할수 있다.
- 도메인을 중심에 두는 아키텍처 없이는, 또 도메인 코드를 향한 의존성을 역전시키지 않고서는, DDD를 제대로 할 가능성이 없다. 즉, 설계가 항상 다른 요소들에 의해 주도되고 말것이다.
- 만약 도메인 코드가 애플리케이션에서 가장 중요한것이 아니라면 이 아키텍처 스타일은 필요하지 않을것이다.
경험이 여왕이다
- 인간은 습관의 동물이다. 습관이 저절로 결정을 내리기에 우리는 무언가를 결정할때 시간을 들일 필요가 없다.
- 만약 새로운 웹 애플리케이션을 만든다면 습관처럼 계층형 아키텍처 스타일을 이용할수도 있다. 이는 나쁜 결정이라 말하는것은 아니다.
- 아키텍처 스타일에 대해서 괜찮은 결정을 내리는 유일한 방법은 다른 아키텍처 스타일을 경험해보는것이다.
- 육각형 아키텍처에 대한 확신이 없다면 지금 만들고 있는 애플리케이션의 작은 모듈부터 먼저 시도해보자. 개념에 익숙해지고 스타일에 익숙해져보자.
- 이 책에 있는 아이디어들을 적용하고, 수정하고, 자신만의 아이디어를 추가해서 편하게 느껴지는 스타일을 개발해보자.
- 그러면 이 경험이 다음 아키텍처 결정을 할때 도움을 줄것이다.
그때그때 다르다
- 어떤 아키텍처 스타일을 선택해야 하는지는 그때그때 다르다.
- 어떤 소프트웨어를 만드느냐에 따라서
- 도메인 코드의 역할에 따라서
- 팀의 경험에 따라서
- 최종적으로 내린 결정이 마음에 드느냐에 따라서