오늘은 지난 포스팅에 이어서 만들면서 배우는 클린 아키텍처 에 대해 정리해둔 내용을 계속 포스팅 하려고 합니다.
이번 포스팅에서는 5,6,7,8장 내용을 다루고자 합니다.
05장 웹 어댑터 구하기
06장 영속성 어댑터 구하기
07장 아키텍처 요소 테스트하기
08장 경계간 매핑하기
05장 웹 어댑터 구하기
우리가 목표하는 아키텍처에서 외부 세계와의 모든 커뮤니케이션은 어댑터를 통해 이루어진다.
의존성 역전
- 웹어댑터는
주도하는
혹은인커밍
어댑터다.- 외부로부터 요청을 받아 애플리케이션 코어를 호출하고 무슨일을 해야할지 알려준다.
- 애플리케이션 계층은
웹 어댑터가 통신할 수 있는 특정 포트를 제공
한다.- 서비스는 이 포트를 구현하고, 웹 어댑터는 이 포트를 호출할 수 있다.
웹 어댑터의 책임
- HTTP 요청을 자바 객체로 매핑
- 권한 검사
- 입력 유효성 검증
- 입력을 유스케이스의 입력 모델로 매핑
- 유스케이스 호출
- 유스케이스 출력을 HTTP 로 매핑
- HTTP 응답을 반환
- 보통은 웹 어댑터가 인증과 권한 부여를 수행, 실패할 경우 에러를 반환
- 웹 어댑터의 입력 모델을 유스케이스의 입력 모델로 변환할 수 있다는 것을 집중해야 한다.
- 웹 계층에서부터 개발을 시작하는 대신
도메인과 애플리케이션 계층부터 개발하기 시작
- 웹 어댑터와 애플리케이션 계층 간의 경계 생성
컨트롤러 나누기
- 각 컨트롤러가
가능한 한 좁고 다른 컨트롤러와 가능한 한 적게 공유하는 웹 어댑터 조각을 구현
해야 한다. - 자주 사용하는 방식은 AccountController 를 하나 만들어서 계좌와 관련된 모든 요청을 받는 것
- 단점
- 클래스 마다 코드는 적을수록 좋다 (+ 테스트 코드 포함)
- 데이터 구조의 재활용을 촉진
각 연산에 대해 가급적이면 별도의 패키지 안에 별도의 컨트롤러를 만드는 방식을 선호
- 가급적 메서드와 클래스명은 유스케이스를 최대한 반영
- 각 컨트롤러가 컨트롤러 자체의 모델을 가지고 있거나, 원시값을 받아도 된다.
- 서로다른 연산에 대한 동시 작업이 쉬워진다.
- 단점
유지보수 가능한 소프트웨어를 만드는데 어떻게 도움이 될까?
- 웹 어댑터를 구현할 때는 HTTP 요청을 애플리케이션의 유스케이스에 대한 메서드 호출로 변환하고 결과를 다시 HTTP 로 변환하고
어떤 도메인 로직도 수행하지 않는다.
- 애플리케이션 계층은 HTTP 에 대한 상세정보를 노출 시키지 않도록 HTTP 와 관련된 작업을 해서는 안된다.
- 다른 어댑터로 쉽게 교체할 수 있다.
06장 영속성 어댑터 구현하기
영속성 계층을 애플리케이션 계층의 플러그인으로 만드는 방법을 살펴보겠다.
의존성 역전
영속성 계층 대신 애플리케이션 서비스에 영속성 기능을 제공하는 영속성 어댑터
- 애플리케이션 서비스에서는
영속성 기능을 사용하기 위해 포트 인터페이스를 호출
- 이 포트는 실제로 영속성 작업을 수행, 데이터베이스와 통신할 책임을 가진 영속성 어댑터 클래스에 의해 구현
- 포트는 사실상 애플리케이션 서비스와 영속성 코드 사이의
간접적인 계층
영속성 어댑터의 책임
- 입력을 받는다.
- 입력을 데이터베이스 포맷으로 향한다.
- 입력을 데이터베이스로 보낸다.
- 데이터베이스 출력을 애플리케이션 포맷으로 매핑
- 출력을 반환한다.
- 영속성 어댑터는 포트 인터페이스를 통해 입력을 받는다.
- 데이터베이스를 쿼리하거나 변경하는데 사용할 수 있는
포맷으로 입력모델을 매핑
- ex. 데이터베이스 테이블 구조를 반영한
JPA 엔티티 객체
로 매핑
- 데이터베이스를 쿼리하거나 변경하는데 사용할 수 있는
- 영속성 어댑터의
입력 모델이
영속성 어댑터 내부에 있는 것이 아니라애플리케이션 코어에 위치
- 영속성 어댑터 내부를 변경하는 것이 코어에 영향을 미치지 않는다.
- 데이터베이스에 쿼리를 날리고 쿼리 결과를 받아온다.
- 데이터베이스 응답을 포트에 정의된 출력 모델로 매핑해서 반환
포트 인터페이스 나누기
특정 엔티티가 필요로 하는 모든 데이터베이스 연산을 하나의 리포지토리 인터페이스
에 넣어 두는게 일반적인 방법- 데이터베이스 연산에 의존하는 각 서비스는 단 하나의 메서드만 사용하더라도 하나의 ‘넓은’ 포트 인터페이스에 의존성을 갖게 된다.
매우 좁은 포트를 만드는 것
은 코딩을 플러그 앤드 플레이 경험으로 만든다.
영속성 어댑터 나누기
영속성 연산이 필요한 도메인 클래스 하나당 하나의 영속성 어댑터를 구현
할 수 있다.- 영속성 어댑터들은 영속성 기능을 이용한 도메인 경계를 따라 자동으로 나뉜다.
- 도메인 코드는 영속성 포트에 의해 정의된 명세를 어떤 클래스가 충족 시키는지에 관심 없다는 사실을 기억하자
- 모든 포트가 구현돼 있기만 한다면 영속성 계층에서 하고 싶은 어떤 작업이든 해도 된다.
- ‘애그리거트당 하나의 영속성 어댑터’ 접근 방식 또한 나중에 여러 개의 바운디드 컨텍스트의 영속성 요구사항을 분리하기 위한 좋은 토대가 된다.
- ‘바운디드 컨텍스트’ = 경계
- 바운디드 컨텍스트 간의 경계를 명확하게 구분하고 싶다면
각 바운디드 컨텍스트가 영속성 어댑터를 하나씩 가지고 있어야 한다.
스프링 데이터 JPA 예제
- 영속성 측면과의 타협없이 풍부한 도메인 모델을 생성하고 싶다면 도메인 모델과 영속성 모델을 매핑하는 것이 좋다.
데이터베이스 트랜잭션은 어떻게 해야 할까?
- 트랜잭션은 하나의 특정한 유스케이스에 대해서 일어나는 모든 쓰기작업에 걸쳐 있어야 한다.
- 그 중 하나라도 실패할 경우 다 같이 롤백
트랜잭션을 열고 닫을지 결정하는건 영속성 어댑터 호출을 관장하는 서비스에 위임
유지보수 가능한 소프트웨어를 만드는데 어떻게 도움이 될까?
- 도메인 코드에 플러그인처럼 동작하는 영속성 어댑터를 만들면 도메인 코드가 영속성과 과련된 것들로부터 분리되어 풍부한 도메인 모델을 만들 수 있다.
- 좁은 포트 인터페이스를 사용하면 포트마다 다른 방식으로 구현할 수 있는 유연함이 생긴다.
07장 아키텍처 요소 테스트하기
테스트 피라미드
- 기본 전제는 만드는
비용이 적고, 유지보수하기 쉽고, 빨리 실행되고, 안정적인 크기의 테스트들에 대해 높은 커버리지를 유지
(단위 테스트) - 여러개의 단위와 단위를 넣는 경계, 아키텍처 경계, 시스템 경계를
결합하는 테스트는 만드는 비용이 더 비싸지고, 실행이 더 느려져 깨지기 더 쉬워진다.
- 테스트 피라미드는
테스트가 비싸질 수록 커버리지 목표는 낮게
잡아야 한다.
- 테스트 피라미드는
- 일반적으로
하나의 클래스를 인스턴스화하고 해당 클래스의 인터페이스를 통해 기능들을 테스트
- 테스트 중의 클래스가 다른 클래스에 의존한다면 목으로 대체한다. (mock)
- 통합 테스트는
연결된 여러 유닛을 인스턴스화하고 유닛들의 네트워크가 기대한 대로 잘 동작
하는지 검증 - 시스템 테스트는
애플리케이션을 구성하는 모든 객체 네트워크를 가동시켜 특정 유스케이스가 전계층에서 잘 동작
하는지 검증한다.
단위 테스트로 도메인 엔티티 테스트하기
- 특정 상태의 Account를 인스턴스화하고 withdraw() 메서드를 호출해서 출금을 성공했는지 검증하고, Account 의 상태에 대해 기대되는 부수효과들이 잘 일어났는지 확인
- 테스트는 만들고 이해하는 것도 쉬운 편, 아주 빠르게 실행, 테스트가 이보다 간단할 수는 없다
- 비즈니스 규칙을 검증하기에 가장 적절한 방법
단위테스트로 유스케이스 테스트하기
- 테스트의 가독성을 높이기 위해 행동-주도 개발에서 일반적으로 사용되는 방식대로
given/when/then 섹션으로 나눴다.
- 상태가 없는 (stateless) 유스케이스 서비스는
모킹된 의존 대상의 특정 메서드와 상호작용 했는지 여부
를 검증한다.- 테스트가 코드의 행동 변경뿐만 아니라 코드의 구조 변경에도 취약 (코드 리팩토링 -> 테스트도 리팩토링)
- 테스트에서
어떤 상호작용을 검증하고 싶은지 신중하게 생각
- 모든 동작을 검증하는 대신
중요한 핵심만 골라 집중해서 테스트하는 것이 좋다.
- 모든 동작을 검증하려면 클래스가 조금이라도 바뀔때마다 테스트를 변경 (테스트 가치하락)
- 모든 동작을 검증하는 대신
통합 테스트로 웹 어댑터 테스트하기
- 웹 어댑터는 JSON 문자열 등의 형태로 HTTP 를 통해 입력을 받고, 입력에 대한 유효성 검증을 하고, 유스케이스에서 사용할 수 있는 포맷으로 매핑하고, 유스케이스에 전달한다.
- 입력 객체를 만들고 Mock HTTP 요청을 웹 컨트롤러에 보낸다. isOk() 메서드로
HTTP 응답의 상태가 200 임을 검증, Mocking 한 유스케이스가 잘 호출됐는지 검증
한다. - 웹 컨트롤러를 평범한 단위테스트로 테스트하면 모든 매핑, 유효성 검증, HTTP 항목에 대한 커버리지가 낮아지고, 프로덕션 환경에서 정상적으로 작동할 지 확신할 수 없게 된다.
통합 테스트로 영속성 어댑터 테스트하기
- 단순히 어댑터의 로직만 검증하고 싶은게 아니라 데이터베이스 매핑도 검증
- @DataJpaTest 애너테이션으로 스프링 데이터 리포지토리들을 포함해서 DB 접근에 필요한 객체를 인스턴스화
- @Import 애너테이션을 추가해서 특정 객체가 추가됐다는 것을 명확하게 표현
- 이 테스트에서는 데이터베이스를 모킹하지 않았다는 점이 중요
- 실제 데이터베이스와 연동했을 때
SQL 구문의 오류나 데이터베이스 테이블과 자바 객체간의 매핑 에러 등으로 문제가 생길 확률이 높아진다.
- 실제 데이터베이스와 연동했을 때
- 스프링에서는 기본적으로 in-memory db를 test 에서 사용 (실용적)
- DB 마다 고유한 SQL 문법이 있어서 문제가 될 수 있다.
- Testcontainers 같은 라이브러리를 이요하여 실제 DB를 docker container 에 띄울 수 있다.
시스템 테스트로 주요 경로 테스트하기
- 시스템 테스트는
전체 어플리케이션을 띄우고 API 를 통해 요청을 보내고 모든 계층이 잘 동작하는지 검증
한다. - @SpringBootTest 애너테이션은 스프링이 application 을 구성하는 모든 객체 네트워크를 띄우게 한다.
- 랜덤 포트로 이 application 을 띄우도록 설정
- test 메서드에서는 요청을 생성해서 application 에 보내고 응답 상태와 계좌의 새로운 잔고를 검증한다.
- MockMvc 가 아니라 TestRestTemplate 을 이용해서 실제 HTTP 통신을 하는 것이다.
- 시스템 테스트라고 하더라도
언제나 서드파티 시스템을 실행해서 테스트할 수 있는 것은 아니다.
- 결국 Mocking 을 해야 할 때도 있다.
- 육각형 아키텍처는 이러한 경우 몇 개의 출력 포트 인터페이스만 모킹 하면 된다.
- 테스트 가독성을 높이기 위해 지저분한 로직들을 헬퍼 메서드 안으로 감췄다.
- 헬퍼 메서드들은 여러 가지 검증할 때 사용할 수 있는 DSL(domain-specific language)를 형성
- JGiven 같은 행동 주도 개발을 위한 라이브러리는 테스트용 어휘를 만드는데 도움을 준다.
- 시스템 테스트는 단위 테스트와 통합 테스트가 발견하는 버그와 또 다른 종류의 버그를 발견
- 계층간 매핑 버그 같은 것.
- 시스템 테스트는 여러 개의 유스케이스를 결합해서 시나리오를 만들때 더 빛이 난다.
얼마만큼의 테스트가 충분할까?
- 라인 커버리지 보다는
얼마나 마음 편하게 S/W 를 배포할 수 있느냐를 테스트 성공의 기준
으로 삼으면 된다. - 버그를 수정하고 이로부터 배우는 것을 우선순위로 삼으면 제대로 가고 있는 것
- 각각의 production 버그에 대해서 ‘테스트가 이 버그를 왜 잡지 못했을까?’를 생각하고, 이 케이스를 커버할 수 있는 테스트를 추가해야 한다.
테스트를 정의하는 전략
- 도메인 엔티티를 구현할 때는 단위 테스트로 커버하자.
- 유스케이스를 구현할 때는 단위 테스트로 커버하자.
- 어댑터를 구현할 때는 통합 테스트로 커버하자.
- 사용자가 취할 수 있는 중요 애플리케이션 경로는 시스템 테스트로 커버하자.
- 테스트가 기능 개발 후가 아닌 개발 중에 이뤄진다면 개발도구로 느껴질 것이다.
- 리팩터링할 때마다 테스트 코드를 변경해야 한다면 테스트는 테스트로서의 가치를 잃는다.
유지보수 가능한 소프트웨어를 만드는데 어떻게 도움이 될까?
- 육각형 아키텍처는 도메인 로직과 바깥으로 향한 어댑터를 깔끔하게 분리
도메인 로직은 단위 테스트, 어댑터는 통합 테스트로 처리
하는 명확한 테스트 전략 정의
입출력 포트는 테스트에서 아주 뚜렷한 모킹 지점
이 된다.- 모킹하는 것이 너무 버거워지거나 코드의 특정 부분을 커버하기 위해 어떤 종류의 테스트를 써야 할지 모르겠다면 경고 신호이다.
08장 경계간 매핑하기
두 계층에서 같은 모델을 사용하는 것에 대해
- 매핑을 찬성하는 개발자 : 양 계층간 같은 모델을 사용할 경우 두 계층이 강하게 결합
- 매핑에 반대하는 개발자 : 두 계층간에 매핑을 하게 되면 보일러 플레이트 코드를 너무 많이 만들게 된다.
‘매핑하지 않기’ 전략
- 모든 계층이 같은 모델을 사용하니 계층간 매핑을 전혀 할 필요가 없다.
웹계층과 영속성 계층은 모델에 대해 특별한 요구사항이 있을 수 있다.
- 웹, 애플리케이션, 영속성 계층과 관련된 이유로 인해 변경돼야 하기 때문에
단일 책임 원칙을 위반
오로지 한 계층에서만 필요한 필드들을 포함하는 파편화된 도메인 모델
로 이어질 수 있다.
모든 계층이 정확히 같은 구조의 정확히 같은 정보를 필요로 할 때 완벽한 선택지
다.
‘양방향’ 매핑 전략
- 각 계층은 도메인 모델과는 완전히 다른 구조의 전용 모델을 가지고 있다.
- 웹 계층에서는 웹 모델을 인커밍 포트에서 필요한 도메인 모델로 매핑, 인커밍 포트에 의해 반환된 도메인 객체를 다시 웹 모델로 매핑
- 영속성 계층은 아웃고잉 포트가 사용하는 도메인 모델과 영속성 모델 간의 매핑과 유사한 매핑을 담당
- 각 계층이 전용 모델을 가지고 있는 덕분에 각 계층 모델을 변경하더라도 다른 계층에는 영향이 없다.
- ‘매핑하지 않기’ 전략 다음으로 간단한 전략이다.
- 매핑 책임이 명확하다.
- 단점
- 너무 많은 보일러플레이트 코드가 생긴다.
- 도메인 모델이 계층 경계를 넘어서 통신하는데 사용 (바깥쪽 계층의 요구에 따른 변경에 취약)
- 각 유스케이스마다 적절한 전략을 택할 수 있어야 한다.
‘완전’ 매핑 전략
- 각 연산마다 별도의 입출력 모델을 사용한다.
- 계층 경계를 넘어 통신할 때 도메인 모델 대신 각 작업에 특화된 모델을 사용 (command, request)
각 유스케이스는 전용 필드와 유효성 검증 로직을 가진 전용 커멘드를 가진다.
- 한 계층을 다른 여러 개의 커맨드로 매핑하는 데는 하나의 웹 모델과 도메인 모델 간의 매핑보다 더 많은 코드가 필요하다.
- 여러 유스케이스의 요구사항을 함께 다뤄야 하는 매핑에 비해 구현하고 유지보수하기가 훨씬 쉽다.
- 이 전략은 웹 계층과 애플리케이션 계층 사이에서 상태변경 유스케이스의 경계를 명확하게 할 때 가장 적합
- 어플리케이션 계층과 영속성 계층 사이에서는 매핑 오버헤드 때문에 사용하지 않는 것이 좋다.
- 매핑 전략은 여러 가지를 섞어 쓸 수 있고, 섞어 써야만 한다.
‘단반향’ 매핑 전략
- 모든 계층의 모델들이 같은 인터페이스 구현한다.
- 관련 있는 특성에 대한 getter 메서드를 제공, 도메인 모델의 상태를 캡슐화
- 도메인 모델 자체는 풍부한 행동을 구현, 어플리케이션 계층 내의 서비스에서 이러한 행동에 접근
- 도메인 객체가 인커밍/아웃커밍 포트가 기대하는 대로 상태 인터페이스를 구현하고 있어 매핑없이 도메인 객체를 바깥 계층으로 전달 가능
- 바깥 계층에서는 상태 인터페이스를 이용할지, 전용 모델로 매핑해야 할지 결정할 수 있다.
- 행동을 변경하는 것이 상태 인터페이스에 의해 노출돼 있지 않아야한다.
- 애플리케이션 계층에서는 바깥 계층에서 전달된 객체를 실제 도메인 모델로 매핑 (도메인 모델의 행동에 접근)
- DDD 개념의 팩토리와 잘 어울린다. (어떤 특정한 상태로부터 도메인 객체를 재구성할 책임)
- 한 계층이 다른 계층으로부터 객체를 받으면 이용할 수 있도록 다른 무언가로 매핑
언제 어떤 매핑 전략을 사용할 것인가?
- 한 전략을 전체 코드에 대한 전역 규칙으로 정의하면 안된다.
- 소프트웨어는 시간이 지나며 변화를 거듭
- 간단한 전략으로 시작, 계층 간 결합을 떼어내는 데 도움이 되는 복잡한 전략
- 팀 내에서 합의할 수 있는 가이드라인 필요
- 어떤 상황에 어떤 매핑 전략을 가장 먼저 택해야 하는가에 대한 답과 근거가 필요
- 변경 유스케이스
- 웹 - 어플리케이션 : 완전 매핑 (유스케이스별 유효성 검증 규칙이 명확, 필요한 필드만 다룬다)
- 어플리케이션 - 영속성 : 매핑 X -> 양방향 매핑
- 쿼리 유스케이스
- 웹 - 어플리케이션 - 영속성 : 매핑 X -> 양방향 매핑
유지보수 가능한 소프트웨어를 만드는데 어떻게 도움이 될까?
- 각 유스케이스에 대해 좁은 포트를 사용하면 유스케이스마다 다른 매핑 전략을 사용, 다른 유스케이스에 영향을 미치지 않으면서 코드를 개선
마무리
새로 일하게 된 회사에 우연히도 헥사고날 아키텍처가 적용된 프로젝트들이 몇 개 있습니다.
그동안 실무적으로 사용해본 경험이 없었는데, 이번 기회에 실무에서 사용을 하면서 클린아키텍처에 대해 실무적인 고민을 조금 더 해볼 수 있는 좋은 기회가 될 것 같습니다.
관련하여 포스팅할 내용이 있으면 추후 포스팅으로 찾아오겠습니다.
그럼 이만. 🥕👋🏼🖐🏼