ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 계층형 아키텍처는 데이터베이스 주도 설계를 유도한다.
    카테고리 없음 2022. 9. 24. 19:03

    계층형 아키텍처는 계층을 잘 이해하고 구성한다면 웹 계층이나 영속성 계층에 독립적으로 도메인 로직을 작성할 수 있다.

    원한다면 도메인 로직에 영향을 주지 않고 웹계층과 영속성 계층의 사용 기술을 바꿀 수도 있다.

    잘 만든다면 선택의 폭을 넓히고, 변하는 요구사항에 빠르게 대응할 수 있지만, 나쁜 습관이 스며들기 쉽고, 시간이 지날수록 소프트웨어를 점점 더 변경하기 어렵게 만드는 허점이 있다.

     

    아래 구조는 영속성계층과 도메인 계층의 강한 결합이 생긴다.

    서비스는 영속성 모델을 비지니스 모델처럼 사용하고,

    서비스가 도메인 로직뿐만 아니라, 즉시/지연 로딩, 트랜잭션, 캐시 플로시 등 영속성 계층과 관련된 일을 하게 된다.

    영속성 코드가 사실상 도메인 코드에 녹아들어가, 둘 중 하나만 바꾸는 것이 어려워진다. 

     

    엔티티는 도메인 객체를 표현하고 , 도메인 코드는 엔티티 상태를 변경하는 일을 하기 때문에 엔티티를 도메인 계층으로 올린다.

    도메인코드와 영속성 코드간의 의존성을 역전시켜 영속성 코드가 도메인 코드에 의존하고, 도메인 코드 변경이유를 줄인다. 하지만 이미 도메인에서 영속성을 의존하고 있기 때문에 영속성도 도메인을 의존하면  순환 의존성이 생긴다.

    이 때, DIP 를 적용해 도메인 계층에 리포지토리에 대한 인터페이스를 만들어 실제 작업은 영속성에서 구현함으로 해결할 수 있다.

     

    1, 위의 구조를 구현한 패키지 구조이다.

    의존성이 domain 패키지에 있는 도메인 코드만을 향하게 한다.

    문제점:

    1. 애플리케이션 기능조각이나 특성을 구분 짓는 패키지 경계가 없다.

    이 구조에서 다른 사용자 관련 기능을 추가해야한다면, domain 에 User,UserService, UserRepository 춫 추가하게 되는데, 연관없는 기능끼리 예상하지 못한 부수효과를 일으킬수 있다. 

     2. AccountService, AccountController 는 어떤 유스케이스를 제공하는지, 책임이 무엇인지 들어가서 보기 전까지는 파악이 어렵다. 인커밍, 아웃고잉 포트가 숨겨져 있다.

     

     

    2. 이렇게 기능별로 구성을 할 수 도 있다.

    같은 레벨에 있기 때문에 package-private 으로 패키지간 경계를 강화할 수 있다.

    또 AccountService 대신 SendMoneyService 라는 이름으로 코드의 의도를 파악하기 쉽다.

    하지만 이 방식은 가시성이 떨어진다. 어댑터 패키지명이 없고, 포트를 확인할 수 없다.

    SendMoneyService 가 AccountRepositoryImpl 에 접근을 막을 수 있는 방법이 없다.

     

    3.

    아래 구조에서는 서드파티 api 나, db 접근 방법에 변경이 있으면 adapter 패키지를 수정해야 함을 바로 파악할 수 있고, 수정 방법도 간편하다.

    DDD 개념에도 직접적인 대응이 가능하다. account 같은 상위 레벨 패키지는 다른 바운디드 컨텍스트와 통신할 

    전용 진입점출구(포트)를 포함하는 바운디드 컨텍스트에 해당한다.

    domain 은 DDD가 제공하는 도구를 이용해 어떤 도메인 모델이든 만들 수 있다.

    adapter 패키지에 있는 클래스들은 application 에 port 인터페이스를 통하지 않고 바깥에서 호출이 불가하기 때문에 package-private 수준으로 둬도 된다.

    domain 은 서비스, 어댑터에서도 접근 가능하도록 일부 public 이여야한다.

    application 도 어댑터에서 접근 가능해야하는 부분을 public 으로 둔다.


    유스케이스

    입력값을 받고 , 비지니스 규칙 검증, 모델 상태 조작, 출력값 반환을 한다.

    입력값 검증과 비지니스 규칙 검증의 차이 

    입력값 검증은 caller 의 책임인 것 같지만 모든 caller 의 입력값을 신뢰하기 어렵고, 모든 adapter 에서 각각 유효성 검증을 구현하는 것이 비효율적이고 실수의 위험이 높다.

    그래서 애플리케이션에서 입력값 유효 검증을 해, 모델 상태를 보호한다. 

    그럼 이건 application 에 port > in 구조안에 들어갈 수 있다. 유스케이스의 일부지만 유스케이스 코드를 오염시키지 않는다.

     

    유스케이스마다 save, update 가 각기 다른 입력 값을 허용한다면, 서로 다른 검증 로직이 필요하다.

    각 유스케이스에 해당하는 입력 모델을 매핑해야한다.

     

    입력 유효성을 검증하는 것은 구문상의(syntactical) 유효성을 검증하는 것이라고 할 수 있다.

    비지니스 규칙은 유스케이스의 맥락 속에서 의미적인(semantical) 유효성을 검증하는 일이다.

    출금계좌는 초과 출금이 되어서는 안된다.

    입력 유효성 : 송금 금액은 0보다 커야한다.

    비지니스 규칙 : 출금계좌와 입금계좌가 존재해야한다. 모델의 현재 상태에 접근해야 한다.

    비지니스 규칙은 도메인 모델의 현재 상태에 접근해야한다는 점에서 입력값 유효검사와 다르다.

     

    비지니스 규칙은 도메인 엔티티 안에서 처리하거나 유스케이스 코드에서 엔티티 사용전에 직접 할 수도 있다.

     

    유스케이스 간 같은 출력 모델을 공유하게 되면 유스케이스들도 강하게 결합된다. 출력 모델에 값이 하나 추가되면 모든 유스케이스들에게 다른 결과가 전달되기 때문이다. 공유 모델은 장기적으로 점점 더 커진다. 

    단일 책임 원칙을 적용하는 것이 유스케이스 결합을 제거하는데 도움이 된다.

     

    읽기 전용 유스케이스

    UI 단에서는 리스트 보여주기 처럼 유스케이스라고 할 수 있지만 애플리케이션 입장에서는 간단한 데이터 쿼리다. 

    쿼리를 위한 인커밍 전용 포트를 만들어 이를 쿼리 서비스에 구현한다.

    이런 방식은 CQS(Command-Query Separation), CQRS (Command-Query Responsibility Segregation) 같은 개념과 잘맞는다. 


    웹 어댑터의 인커밍 포트

    웹 어댑터에서 유스케이스를 바로 호출하지 않고, 중간에 간접 계층 포트를 넣었을 때 장점은, 

    포트가 애플리케이션 코어가 외부와 통신할 수 있는 곳에 대한 명세 역할을 하기 때문에 파악이 쉬워진다는데 있다.

     

    웹어댑터의 책임 

    HTTP 요청을 자바 객체로 매핑

    권한 검사, 유효값 검증, 유스케이스의 입력 모델로 매핑, 유스케이스 호출,

    유스케이스 출력을 HTTP 로 매핑, HTTP 응답반환

     

    컨트롤러를 작게 쪼개 여러개의 컨트롤러로 생성하면, 파악이 쉽고, 특히 테스트 코드를 작성하고 파악하는데 훨씬 수월하다. 동일한 모델을 공유하는 컨트롤러를 여러개로 나누는 것을 두려워하지 말자.

     

     

    웹 어댑터는 '주도하는' 혹은 '인커밍' 어댑터다. 외부로부터 요청을 받아 애플리케이션 코어를 호출하고 무엇을 할 지 알려준다. 

    영속성 어댑터는 '주도되는' 혹은 '아웃고잉' 어댑터다. 애플리케이션에 의해 호출될 뿐, 애플리케이션을 호출하지는 않는다.

    영속성 어댑터의 입력모델이 영속성 어댑터 내부에 있는 것이 아니라 애플리케이션 코어에 있기 때문에 

    영속성 어댑터의 내부를 변경하는 것이 코어에 영향을 미치지 않는다.

    데이터베이스 응답은 포트에 정의된 출력 모델로 매핑해서 반환한다. 

     

    포트 인터페이스는 어떻게 나눌까? 보통 하나의 인터페이스에 모든 데이터베이스 연산을 둔다.

    하지만 이는 각 서비스에서 모든 연산에 의존하고, 단위 테스트를 작성할 때 어떤 메서드를 mocking 해야하는지 혼란스럽다. 필요없는 것에 의존하지 않도록 ISP, 인터페이스 분리원칙을 적용한다.

    매우 좁은 포트를 만드는 것은 코딩을 플러그 앤 플레이 (plug and play) 경험으로 만든다.

     

    영속성 어댑터 또한 여러개로 나눌 수 있다. 

    각 영속성 기능을 이용하는 도메인 경계를 따라 자동으로 나눠진다.

    혹은 JPA 가 아닌 다른 SQL 매퍼를 사용할 때도 어댑터를 따로 만들어 사용할 수 있다. 

    '애그리어트당 하나의 영속성 어댑터' 접근 방식 또한 나중에 여러 개의 바운디드 컨텍스트의 영속성 요구사항을 분리하기 위한 좋은 토대가 된다. 바운디드 컨텍스트간 경계를 명확히 하기 위해 컨텍스트마다 영속성 어댑터를 갖는 것이다.

     

    여기서 도메인과 영속성 엔티티가 분리되었고, Persistence Adapter 에서 JPA 레포지토리  접근과 이를 매핑하는 역할도 한다.

    public class Account{..}
    
    @Entity
    public class AccountJpaEntity{}
    
    interface AccountRepository extends JpaRepository<AccountJpaEntity,Long> {..}
    
    @RequiredArgsConstructor
    @Component
    class AccountPersistenceAdapter implements LoadAccountPort, UpdateAccountPort{
        private final AccountRepository accountRepository;
        private final AccountMapper accountMapper;
        
        @Override
        public Account loadAccount(Long id){
            AccountJpaEntity jpaEntity = accountRepository.findById(id);
            return accountMapper.mapToDomainEntity(jpaEntity);
        }
    }

    육각형 아키텍처는 도메인 로직과 바깥으로 향한 어댑터가 잘 분리되어 있다.

    핵심 도메인은 단위 테스트, 어댑터는 통합 테스트로 처리하는 명확한 테스트 전략을 정의할 수 있다.

    입/출력 포트는 테스트에서 명확한 모킹 지점이 된다. 

    포트에 대해 모킹할지, 실제 구현을 테스트 할 지 선택할 수 있다.

    포트 인터페이스가 적을 수록 어떤 메서드를 모킹할지 덜 헷갈린다.

    모킹하는 것이 버거워지거나, 코드의 특정 부분을 커버하기 위해 어떤 종류의 테스트를 해야할지 모른다면 이는 경고 신호이다. 


    매핑 전략 

    1, 매핑하지 않기 

    웹계층에서 사용하는 Rest 모델과 데이터베이스 매핑 모델이 같기 때문에, 도메인의 단일 책임 원칙이 위반된다.

    하지만 모든 계층이 정확히 같은 구조의 같은 정보를 요구한다면 좋은 선택지이다. 

    그러나 애플리케이션 계층이나 도메인 계층에서 웹과 영속성 문제를 다루게 되면 다른 전략을 취해야한다,

     

    2. 양방향 매핑

    웹이나 영속성 관심사로 오염되지 않은 도메인 모델을 가진다.

    매핑 책임이 명확하다. 어댑터는 안쪽 계층의 모델로 매핑하고, 다시 반대로 매핑한다.

    안쪽 계층은 해당 계층의 모델만 알면 되고, 도메인에 집중할 수 있다.

    하지만 많은 보일러플레이트가 생긴다. 특히 매핑 프레임워크가 내부 동작 방식을 제네릭 코드와 리플렉션 뒤로 숨기면 매핑 로직을 디버깅하기 어려워진다.

    또 도메인 모델이 계층 경계를 넘어 사용된다. 인커밍 포트와 아웃고잉 포트는 도메인 객체를 입력 파라미터와 반환값으로 쓴다. 도메인 모델은 도메인의 필요에 의해서만 변경되는 것이 이상적이지만 바깥쪽 계층의 요구에 따른 변경에 취략해진다.

     

    3. 완전 매핑 

    각 연산마다 별도의 입출력 모델을 사용한다.

    계층 경계를 넘어 통신할 때 도메인 모델을 사용하지만, 각 작업에 특화된 모델을 사용한다.

    이런 모델을 커멘드, 요청 같은 단어로 사용한다.

    웹 계층은 입력을 애플리케이션의 커멘드 객체로 매핑할 책임이 있다. 

    이 매핑은  많은 코드가 필요하지만, 여러 유스케이스의 요구사항을 함께 다뤄야 하는 매핑보다 구현과 유지보수가 쉽다.

    이 전략은 웹과 애플리케이션 계층 사이에서 상태 변경 유스케이스의 경계를 명확하게 할 때 가장 빛을 발한다.

     

    4. 단방향 매핑 

    모든 계층의 모델들이 같은 인터페이스를 구현한다. 이 인터페이스는 관련 있는 특성에 대한 getter 메서드를 제공해 

    도메인 모델의 상태를 캡슐화한다. 도메인 모델 자체는 풍부한 행동을 구현할 수 있고, 애플리케이션 계층 내의 서비스에서 이러한 행동에 접근할 수 있다. 

    도메인 객체를 바깥쪽으로 전달하고 싶으면 매핑없이 할 수 있다. 왜냐면 도메인 객체가 인커밍/아웃고잉 포트가 기대하는 대로 상태 인터페이스를 구현하고 있기 때문이다.

    바깥 계층에서는 상태 인터페이스를 사용할지, 전용 모델로 매핑할지 결정할 수 있다. 행동을 변경하는 것이 상태 인터페이스에 노출되어 있지 않아 실수로 도메인 상태를 변경하지 않는다.

    바깥 계층에서 애플리케이션으로 전달하는 객체도 이 상태 인터페이스를 구현할 수 있다. 이 매핑은 DDD의 팩토리라는 개념과 잘 어울린다. 팩토리는 어떤 특정한 상태로부터 도메인 객체를 재구성할 책임을 가지고 있다.

     

     

    가이드라인 

    1. 변경 유스케이스를 작업하고 있다면

    웹계층과 애플리케이션 계층 사이에서는 유스케이스 간 결합을 제거하기 위해 

    완전 매핑 전략을 첫번째로 선택한다. 유스케이스별 유효성 검증 규칙이 명확하고 특정 유스케이스에서 필요 없는 필드를 다루지 않는다.

     

    애플리케이션과 영속성계층 간에는 매핑하지 않기 전략을 사용한다.

    매핑 오버헤드를 줄이고 빠르게 코드를 짤 수 있다. 하지만 애플리케이션에서 영속성을 다뤄야한다면 양방향 매핑 전략으로 전환한다. 

     

    2. 쿼리 작업을 하면 매핑하지 않기를 선택한다.

    웹 계층과 애플리케이션 계층 사이, 애플리케이션과 영속성 계층 사이에 모두 적용한다.

    하지만 애플리케이션에서 영속성 문제, 웹 문제를 다뤄야한다면 양방향 매핑 전략으로 전환한다. 

     


    계층 간 경계를 강제하는 방법 

    경계를 강제하는 것은 의존성이 올바른 방향을 향하도록 강제하는 것을 말한다.

     

    1. package-privagte 접근 제한자의 사용 

    + 는 public , o 는 package-private 이다

     

    2, 컴파일 후 확인 자바용 도구 ArchUnit

     

    3. 빌드 아티팩트 

    아키텍처를 여러 개의 빌드 아티펙트로 만들 수 있다. 

    맨 왼쪽에 설정, 어댑터, 애플리케이션 계층의 3개 모듈 빌드 방식

    설정모듈은 어댑터에 접근가능하고, 어댑터는 애플리케이션에 접근가능하다.

    설정은 암시적이고 전이적인 의존성으로 애플리케이션에도 접근가능하다.

     

    어댑터 모듈에서 웹과 영속성이 함께 있는데, 두 어댑터 간 의존성이 금지되지 않았지만 격리시켜 유지하는 것이 좋다.

     

    또 도메인 엔티티가 전송 객체로 사용되지 않는 경우 의존성 역전 원칙을 이용해 세번째처럼 포트만 따로 할 경우 모듈화할 수도 있다.

     

    모듈을 더 작게 분리할 수록, 모듈 간 매핑을 더 많이 수행해야한다.

     

    패키지보다 빌드 모듈이 가진 장점

    1. 빌드 도구가 순환 의존성을 허용하지 않는다. 

    2. 다른 모듈을 고려하지 않고 특정 모듈의 코드를 격리한 채로 변경할 수 있다.

    3. 모듈 간 의존성이 빌드 스크립트에 분명하게 선언되어 있어, 새로 의존성을 추가하는 것은 무의식적이 아닌 의식적인 행동이 된다.

    반응형
Designed by Tistory.