2carrot84
by 2carrot84
9 min read

Categories

  • book

Tags

  • clean architecture
  • 만들면서 배우는 클린 아키텍처

오늘은 지난 포스팅에 이어서 만들면서 배우는 클린 아키텍처 에 대해 정리해둔 내용을 계속 포스팅 하려고 합니다.

이번 포스팅에서는 5,6,7,8장 내용을 다루고자 합니다.

05장 웹 어댑터 구하기
06장 영속성 어댑터 구하기
07장 아키텍처 요소 테스트하기
08장 경계간 매핑하기

05장 웹 어댑터 구하기

우리가 목표하는 아키텍처에서 외부 세계와의 모든 커뮤니케이션은 어댑터를 통해 이루어진다.

의존성 역전

  • 웹어댑터는 주도하는 혹은 인커밍 어댑터다.
    • 외부로부터 요청을 받아 애플리케이션 코어를 호출하고 무슨일을 해야할지 알려준다.
  • 애플리케이션 계층은 웹 어댑터가 통신할 수 있는 특정 포트를 제공한다.
    • 서비스는 이 포트를 구현하고, 웹 어댑터는 이 포트를 호출할 수 있다.

웹 어댑터의 책임

  1. HTTP 요청을 자바 객체로 매핑
  2. 권한 검사
  3. 입력 유효성 검증
  4. 입력을 유스케이스의 입력 모델로 매핑
  5. 유스케이스 호출
  6. 유스케이스 출력을 HTTP 로 매핑
  7. HTTP 응답을 반환
  • 보통은 웹 어댑터가 인증과 권한 부여를 수행, 실패할 경우 에러를 반환
  • 웹 어댑터의 입력 모델을 유스케이스의 입력 모델로 변환할 수 있다는 것을 집중해야 한다.
  • 웹 계층에서부터 개발을 시작하는 대신 도메인과 애플리케이션 계층부터 개발하기 시작
    • 웹 어댑터와 애플리케이션 계층 간의 경계 생성

컨트롤러 나누기

  • 각 컨트롤러가 가능한 한 좁고 다른 컨트롤러와 가능한 한 적게 공유하는 웹 어댑터 조각을 구현해야 한다.
  • 자주 사용하는 방식은 AccountController 를 하나 만들어서 계좌와 관련된 모든 요청을 받는 것
    • 단점
      1. 클래스 마다 코드는 적을수록 좋다 (+ 테스트 코드 포함)
      2. 데이터 구조의 재활용을 촉진
        • 각 연산에 대해 가급적이면 별도의 패키지 안에 별도의 컨트롤러를 만드는 방식을 선호
        • 가급적 메서드와 클래스명은 유스케이스를 최대한 반영
        • 각 컨트롤러가 컨트롤러 자체의 모델을 가지고 있거나, 원시값을 받아도 된다.
        • 서로다른 연산에 대한 동시 작업이 쉬워진다.

유지보수 가능한 소프트웨어를 만드는데 어떻게 도움이 될까?

  • 웹 어댑터를 구현할 때는 HTTP 요청을 애플리케이션의 유스케이스에 대한 메서드 호출로 변환하고 결과를 다시 HTTP 로 변환하고 어떤 도메인 로직도 수행하지 않는다.
  • 애플리케이션 계층은 HTTP 에 대한 상세정보를 노출 시키지 않도록 HTTP 와 관련된 작업을 해서는 안된다.
    • 다른 어댑터로 쉽게 교체할 수 있다.

06장 영속성 어댑터 구현하기

영속성 계층을 애플리케이션 계층의 플러그인으로 만드는 방법을 살펴보겠다.

의존성 역전

영속성 계층 대신 애플리케이션 서비스에 영속성 기능을 제공하는 영속성 어댑터

  • 애플리케이션 서비스에서는 영속성 기능을 사용하기 위해 포트 인터페이스를 호출
    • 이 포트는 실제로 영속성 작업을 수행, 데이터베이스와 통신할 책임을 가진 영속성 어댑터 클래스에 의해 구현
  • 포트는 사실상 애플리케이션 서비스와 영속성 코드 사이의 간접적인 계층

영속성 어댑터의 책임

  1. 입력을 받는다.
  2. 입력을 데이터베이스 포맷으로 향한다.
  3. 입력을 데이터베이스로 보낸다.
  4. 데이터베이스 출력을 애플리케이션 포맷으로 매핑
  5. 출력을 반환한다.
  • 영속성 어댑터는 포트 인터페이스를 통해 입력을 받는다.
    • 데이터베이스를 쿼리하거나 변경하는데 사용할 수 있는 포맷으로 입력모델을 매핑
    • 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 -> 양방향 매핑

유지보수 가능한 소프트웨어를 만드는데 어떻게 도움이 될까?

  • 각 유스케이스에 대해 좁은 포트를 사용하면 유스케이스마다 다른 매핑 전략을 사용, 다른 유스케이스에 영향을 미치지 않으면서 코드를 개선

마무리

새로 일하게 된 회사에 우연히도 헥사고날 아키텍처가 적용된 프로젝트들이 몇 개 있습니다.

그동안 실무적으로 사용해본 경험이 없었는데, 이번 기회에 실무에서 사용을 하면서 클린아키텍처에 대해 실무적인 고민을 조금 더 해볼 수 있는 좋은 기회가 될 것 같습니다.

관련하여 포스팅할 내용이 있으면 추후 포스팅으로 찾아오겠습니다.

그럼 이만. 🥕👋🏼🖐🏼

참고자료🤣

만들면서 배우는 클린 아키텍처