계층형 아키텍처의 문제는 무엇일까?

  • 계층형 아키텍처는 데이터베이스 주도 설계를 유도한다.
  • 웹 계층은 도메인 계층에 의존하고, 도메인 계층은 영속성 계층에 의존하기 때문에 자연스레 데이터베이스에 의존하게 된다.
  • 전통적인 계층형 아키텍처에서는 합리적인 방법이다. 의존성의 방향에 따라 자연스럽게 구현한 것이기 때문이다. 하지만 비즈니스 관점에서는 전혀 맞지 않는 방법이다. 다른 무엇보다도 도메인 로직을 먼저 만들어야 한다.
  • 영속성 계층과 도메인 계층 사이에 강한 결합이 생긴다.
  • 과거에도 이와 똑같은 전력이 있다면 재차 그렇게 하는 것에 대한 심리적 부담감은 훨씬 낮아지기 마련이다. 이를 두고 ‘깨진 창문 이론’이라고 부른다.
  • 영속성 계층에서는 모든 것에 접근 가능하기 때문에 시간이 지나면서 점점 비대해진다.
  • 적어도 추가적인 아키텍처 규칙을 강제하지 않는 한 계층은 최선의 선택이 아니다. 여기서 ‘강제한다’는 해당 규칙이 깨졌을 때 빌드가 실패하도록 만드는 규칙을 의미한다.
  • 도메인 계층을 건너뛰는 것은 도메인 로직을 코드 여기저기에 흩어지게 만든다.
  • 단 하나의 필드를 조작하는 것에 불과하더라도 도메인 로직을 웹 계층에 구현하게 된다는 것이다.
  • 웹 계층 테스트에서 도메인 계층뿐만 아니라 영속성 계층도 모킹(mocking)해야 한다는 것이다.
  • 실제로 테스트 코드를 작성하는 것보다 종속성을 이해하고 목(mock)을 만드는 데 더 많은 시간이 걸리게 된다.
  • 실제로는 새로운 코드를 짜는 데 시간을 쓰기보다는 기존 코드를 바꾸는 데 더 많은 시간을 쓴다.
  • 넓은 서비스는 영속성 계층에 많은 의존성을 갖게 되고, 다시 웹 레이어의 많은 컴포넌트가 이 서비스에 의존하게 된다. 그럼 서비스를 테스트하기도 어려워지고 작업해야 할 유스케이스를 책임지는 서비스를 찾기도 어려워진다.
  • 고도로 특화된 좁은 도메인 서비스가 유스케이스 하나씩만 담당하게 한다면 이런 작업들이 얼마나 수월해질까?
  • 코드에 넓은 서비스가 있다면 서로 다른 기능을 동시에 작업하기가 더욱 어렵다.

의존성 역전하기

  • 컴포넌트를 변경하는 이유는 오직 하나 뿐이어야 한다.
  • 아마도 단일 책임 원칙을 ‘단일 변경 이유 원칙(Single Reason to Change Principle)’으로 바꿔야 할지도 모르겠다.
  • 컴포넌트를 변경할 이유가 한 가지라면 우리가 어떤 다른 이유로 소프트웨어를 변경하더라도 이 컴포넌트에 대해서는 전혀 신경 쓸 필요가 없다. 소프트웨어가 변경되더라도 여전히 우리가 기대한 대로 동작할 것이기 때문이다.
  • 안타깝게도 변경할 이유라는 것은 컴포넌트 간의 의존성을 통해 너무도 쉽게 전파된다.
  • 많은 코드는 단일 책임 원칙을 위반하기 때문에 시간이 갈수록 변경하기가 더 어려워지고 그로 인해 변경 비용도 증가한다. 시간이 갈수록 컴포넌트를 변경할 더 많은 이유가 쌓여간다. 변경할 이유가 많이 쌓인 후에는 한 컴포넌트를 바꾸는 것이 다른 컴포넌트가 실패하는 원인으로 작용할 수 있다.
  • 도메인 코드는 애플리케이션에서 가장 중요한 코드다. 영속성 코드가 바뀐다고 해서 도메인 코드까지 바꾸고 싶지는 않다.
  • 코드상의 어떤 의존성이든 그 방향을 바꿀 수(역전시킬 수) 있다.
  • 도메인 코드가 바깥으로 향하는 어떤 의존성도 없어야 함을 의미한다. 대신 의존성 역전 원칙의 도움으로 모든 의존성이 도메인 코드를 향하고 있다.
  • 계층 간의 모든 의존성이 안쪽으로 향해야 한다는 것이다.
  • 도메인 코드에서는 어떤 영속성 프레임워크나 UI 프레임워크가 사용되는지 알 수 없기 때문에 특정 프레임워크에 특화된 코드를 가질 수 없고 비즈니스 규칙에 집중할 수 있다.
  • 영속성이나 UI에 특화된 문제를 신경 쓰지 않아도 된다면 이렇게 하기가 굉장히 수월해진다.
  • 모든 의존성은 코어를 향한다.
  • 애플리케이션 코어와 어댑터들 간의 통신이 가능하려면 애플리케이션 코어가 각각의 포트를 제공해야 한다. 주도하는 어댑터(driving adapter)에게는 그러한 포트가 코어에 있는 유스케이스 클래스들에 의해 구현되고 호출되는 인터페이스가 될 것이고, 주도되는 어댑터(driven adapter)에게는 그러한 포트가 어댑터에 의해 구현되고 코어에 의해 호출되는 인터페이스가 될 것이다.
  • 의존성을 역전시켜 도메인 코드가 다른 바깥쪽 코드에 의존하지 않게 함으로써 영속성과 UI에 특화된 모든 문제로부터 도메인 로직의 결합을 제거하고 코드를 변경할 이유의 수를 줄일 수 있다. 그리고 변경할 이유가 적을수록 유지보수성은 더 좋아진다.

코드 구성하기

  • 계층으로 구성하면 애플리케이션의 기능 조각(functional slice)이나 특성(feature)을 구분 짓는 패키지 경계가 없다. 추가적인 구조가 없다면, 아주 빠르게 서로 연관되지 않은 기능들끼리 예상하지 못한 부수효과를 일으킬 수 있는 클래스들의 엉망진창 묶음으로 변모할 가능성이 크다.
  • 계층으로 구성하면 애플리케이션이 어떤 유스케이스들을 제공하는지 파악할 수 없다. 어떤 기능이 웹 어댑터에서 호출되는지, 영속성 어댑터가 도메인 계층에 어떤 기능을 제공하는지 한눈에 알아볼 수 없다.
  • 기능에 의한 패키징 방식은 사실 계층에 의한 패키징 방식보다 아키텍처의 가시성을 훨씬 더 떨어뜨린다.
  • 대부분의 소프트웨어 개발 프로젝트에서 아키텍처가 코드에 직접적으로 매핑될 수 없는 추상적인 개념이라는 사실을 보여준다. 만약 패키지 구조가 아키텍처를 반영할 수 없다면 시간이 지남에 따라 코드는 점점 목표하던 아키텍처로부터 아키텍처로부터 멀어지게 될 것이다.
  • 표현력 있는 패키지 구조는 아키텍처에 대한 적극적인 사고를 촉진한다. 많은 패키지가 생기고, 현재 작업 중인 코드를 어떤 패키지에 넣어야 할지 계속 생각해야 하기 때문이다.
  • 표현력 있는 패키지 구조는 적어도 코드와 아키텍처 간의 갭을 줄일 수 있게 해준다.

유스케이스 구현하기

  • 유스케이스 코드가 도메인 로직에만 신경 써야 하고 입력 유효성 검증으로 오염되면 안 된다고 생각한다. 그러나 유스케이스는 비즈니스 규칙(business rule)을 검증할 책임이 있다.
  • 비즈니스 규칙을 충족하면 유스케이스는 입력을 기반으로 어떤 방법으로든 모델의 상태를 변경한다. 일반적으로 도메인 객체의 상태를 바꾸고 영속성 어댑터를 통해 구현된 포트로 이 상태를 전달해서 저장될 수 있게 한다.
  • 애플리케이션 계층에서 입력 유효성을 검증해야 하는 이유는, 그렇게 하지 않을 경우 애플리케이션 코어의 바깥쪽으로부터 유효하지 않은 입력값을 받게 되고, 모델의 상태를 해칠 수 있기 때문이다.
  • 입력 모델에 있는 유효성 검증 코드를 통해 유스케이스 구현체 주위에 사실상 오류 방지 계층(anti corruption layer)을 만들었다.
  • 비즈니스 규칙을 검증하는 것은 도메인 모델의 현재 상태에 접근해야 하는 반면, 입력 유효성 검증은 그럴 필요가 없다는 것이다.
  • 입력 유효성을 검증하는 것은 구문상의(syntactical) 유효성을 검증하는 것이라고도 할 수 있다. 반면 비즈니스 규칙은 유스케이스의 맥락 속에서 의미적인(semantical) 유효성을 검증하는 일이라고 할 수 있다.
  • 가장 좋은 방법은 비즈니스 규칙을 도메인 엔티티 안에 넣는 것이다.
  • 만약 도메인 엔티티에서 비즈니스 규칙을 검증하기가 여의치 않다면 유스케이스 코드에서 도메인 엔티티를 사용하기 전에 해도 된다.
  • 풍부한 도메인 모델(rich domain model)에서는 유스케이스가 도메인 모델의 진입점으로 동작한다. 이어서 유스케이스는 사용자의 의도만을 표현하면서 이 의도를 실제 작접을 수행하는 체계화된 도메인 엔티티 메서드 호출로 변환한다. 많은 비즈니스 규칙이 유스케이스 구현체 대신 엔티이에 위치하게 된다.
  • 빈약한 도메인 모델(anemic domain model)에서는 도메인 로직이 유스케이스 클래스에 구현돼 있다. 비즈니스 규칙을 검증하고, 엔티이의 상태를 바꾸고, 데이터베이스 저장을 담당하는 아웃고잉 포트에 엔티티를 전달한 책임 역시 유스케이스 클래스에 있다. ‘풍부함’이 엔티티 대신 유스케이스에 존재하는 것이다.
  • 유스케이스들 간에 같은 출력 모델을 공유하게 되면 유스케이스들도 가하게 결합된다. 한 유스케이스엣 ㅓ출력 모델에 새로운 필드가 필요해지면 이 값과 관련이 없는 다른 유스케이스에서도 이 필드를 처리해야 한다. 공유 모델은 장기적으로 봤을 때 갖가지 이유로 점점 커지게 돼 있다. 단일 책임 원칙을 적용하고 모델을 분리해서 유지하는 것은 유스케이스의 결합을 제거하는 데 도움이 된다.
  • 읽기 전용 쿼리는 쓰기가 가능한 유스케이스(또는 ‘커맨드’)와 코드 상에서 명확하게 구분된다. 이런 방식은 CQS(Command-Query Separation)나 CQRS(Command-Query Responsibility Segregation) 같은 개념과 아주 잘 맞는다.
  • 유스케이스별로 모델을 만들면 유스케이스를 명확하게 이해할 수 있고, 장기적으로 유지보수하기도 더 쉽다. 또한 여러 명의 개발자가 다른 사람이 작업 중인 유스케이스를 건드리지 않은 채로 여러 개의 유스케이스를 동시에 작업할 수 있다.
  • 꼼꼼한 입력 유효성 검증, 유스케이스별 입출력 모델은 지속 가능한 코드를 만드는 데 큰 도움이 된다.

웹 어댑터 구현하기

  • 애플리케이션 코어가 외부 세계와 통신할 수 있는 곳에 대한 명세가 포트이기 때문이다. 포트를 적절한 곳에 위치시키면 외부와 어떤 통신이 일어나고 있는지 정확히 할 수 있다.
  • 유스케이스 입력 모델에서 했던 유효성 검증을 똑같이 웹 어댑터에서도 구현해야 하는 것은 아니다. 대신, 웹 어댑터의 입력 모델을 유스케이스의 입력 모델로 변환할 수 있다는 것을 검증해야 한다. 이 변환을 방해하는 모든 것이 유효성 검증 에러다.
  • 웹 어댑터와 애플리케이션 계층 간의 이 같은 경계는 웹 계층으로부터 개발을 시작하는 대신 도메인과 애플리케이션 계층부터 개발하기 시작하면 자연스럽게 생긴다. 특정 인커밍 어댑터를 생각할 필요 없이 유스케이스를 먼저 구현하면 경계를 흐리게 만들 유혹에 빠지지 않을 수 있다.
  • 웹 컨트롤러를 나눌 때는 모델을 공유하지 않는 여러 작은 클래스들을 만드는 것을 두려워해서는 안 된다. 작은 클래스들은 더 파악하기 쉽고, 더 테스트하기 쉬우며, 동시 작업을 지원한다. 이렇게 세분화된 컨트롤러를 만드는 것은 처음에는 조금 더 공수가 들겠지만 유지보수하는 동안에는 분명이 빛을 발할 것이다.

영속성 어댑터 구현하기

  • 영속성 어댑터의 입력 모델이 영속성 어댑터 내부에 있는 것이 아니라 애플리케이션 코어에 있기 때문에 영속성 어댑터 내부를 변경하는 것이 코어에 영향을 미치지 않는다는 것이다.
  • 출력 모델이 영속성 어댑터가 아니라 애플리케잇녀 코어에 위치하는 것이 중요하다.
  • 인터페이스 분리 원칙(Interface Segregation Principle, ISP)은 이 문제의 답을 제시한다. 이 원칙은 클라이언트가 오로지 자신이 필요로 하는 메서드만 알면 되도록 넓은 인터페이스를 특화된 인터페이스로 분리해야 한다고 설명한다.
  • ‘애그리거트당 하나의 영속성 어댑트’ 접근 방식 또한 나중에 여러 개의 바운디드 컨텍스트(bounded context)의 영속성 요구사항을 분리하기 좋은 토대가 된다.
  • 바운디드 컨텍스트 간의 경계를 명확하게 구분하고 싶다면 각 바운디드 컨텍스트가 영속성 어댑트들을 하나씩 가지고 있어야 한다.
  • 어떤 맥락이 다른 맥락에 있는 무엇인가를 필요로 한다면 전용 인커밍 포트를 통해 접근해야 한다.
  • 트랜잭션은 하나의 특정한 유스케이스에 대해서는 일어나는 모든 쓰기 작업에 걸쳐 있어야 한다. 그래야 그중 하나라도 실패할 경우 다 같이 롤백될 수 있기 때문이다.
  • 영속성 어댑터는 어떤 데이터베이스 연산이 같은 유스케이스에 포함되는지 알지 못하기 때문에 언제 트랜잭션을 열고 닫을지 결정할 수 없다. 이 책임은 영속성 어댑터 호출을 관장하는 서비스에 위임해야 한다.
  • 좁은 포트 인터페이스를 사용하면 포트마다 다른 방식으로 구현할 수 있는 유연함이 생긴다. 심지어 포트 뒤에서 애플리케이션이 모르게 다른 영속성 기술을 사용할 수도 있다. 포트의 명세만 지켜진다면 영속성 계층 전체를 교체할 수도 있다.

아키텍처 요소 테스트하기

  • 테스트가 코드의 행동 변경뿐만 아니라 코드의 구조 변경에도 취약해진다는 의미가 된다.
  • 모든 동작을 검증하는 대신 중요한 핵심만 골라 집중해서 테스트하는 것이 좋다.
  • 도메인 엔티티를 구현할 때는 단위 테스트로 커버하자
  • 유스케이스를 구현할 때는 단위 테스트로 커버하자
  • 어댑터를 구현할 때는 통합 테스트로 커버하자
  • 사용자가 취할 수 있는 중요 애플리케이션 경로는 시스템 테스트로 커버하자
  • 입출력 포트는 테스트에서 아주 뚜렷한 모킹 지점이 된다. 각 포트에 대해 모킹할지, 실제 구현을 이용할지 선택할 수 있다. 만약 포트가 아주 작고 핵심만 담고 있다면 모킹하는 것이 아주 쉬울 것이다. 포트 인터페이스가 더 적은 메서드를 제공할수록 어떤 메서드를 모킹해야 할지 덜 헷갈린다.

경계 간 매핑하기

  • 도메인과 애플리케이션 계층은 웹이나 영속성과 관련된 특수한 요구사항에 관심이 없음에도 불구하고 Account 도메인 모델 클래스는 이런 모든 요구사항을 다뤄야 한다. Account 클래스는 웹, 애플리케이션, 영속성 계층과 관련된 이유로 인해 변경돼야 하기 때문에 단일 책임 원칙을 위반한다.
  • 왜 해당 전략을 최우선으로 택해야 하는지도 설명할 수 있어야 한다.
  • 변경 유스케이스를 작업하고 있다면 웹 계층과 애플리케이션 계층 사이에는 유스케이스간의 결합을 제거하기 위해 ‘완전 매핑’ 전략을 첫 번째 선택지로 택해야 한다. 이렇게 하면 유스케이스별 유효성 검증 규칙이 명확해지고 특정 유스케이스에서 필요하지 않은 필드를 다루지 않아도 된다.
  • 각 유스케이스에 대해 좁은 포트를 사용하면 유스케이스마다 다른 매핑 전략을 사용할 수 있고, 다른 유스케이스에 영향을 미치지 않으면서 코드를 개선할 수 있기 때문에 특정 상황, 특정 시점에 최선의 전략을 선택할 수 있다.

애플리케이션 조립하기

  • 모든 의존성은 안쪽으로, 애플리케이션의 도메인 코드 방향으로 향해야 도메인 코드가 바깥 계층의 변경으로부터 안전하다는 점을 기억하자.
  • 설정 컴포넌트는 우리가 제공한 조각들로 애플리케이션을 조립하는 것을 책임진다. 웹 어댑터 인스턴스 생성, HTTP 요청이 실제로 웹 어댑터로 전달되도록 보장, 유스케이스 인스턴스 생성, 웹 어댑터에 유스케이스 인스턴스 제공, 영속성 어댑터 인스턴스 생성, 유스케이스에 영속성 어댑트 인스턴스 제공, 영속성 어댑터가 실제로 데이터베이스에 접근할 수 있도록 보장
  • 설정 컴포넌트는 설정 파일이나 커맨드라인 파라미터 등과 같은 설정 파라미터의 소스에도 접근할 수 있어야 한다. 애플리케이션이 조립되는 동안 설정 컴포넌트는 이러한 파라미터를 애플리케이션 컴포넌트에 제공해서 어떤 데이터베이스에 접근하고 어떤 서버를 메일 전송에 사용할지 등의 행동 양식을 제어한다.
  • 클래스패스 스캐닝은 클래스에 프레임워크에 특화된 애너테이션을 붙여야 한다는 점에서 침투적이다. 또 마법 같은 일이 일어날 수 있는데, 스프링 전문가가 아니라면 원인을 찾는 데 수일이 걸릴 수 있는 숨겨진 부수효과를 야기할 수도 있다.
  • Configuration 애너테이션은 모든 빈을 가져오는 대신 설정 클래스만 선택하기 때문에 해로운 마법이 일어날 확률이 줄어든다.
  • 설정 클래스가 생성하는 빈(이 경우에는 영속성 어댑터 클래스들)이 설정 클래스와 같은 패키지에 존재하지 않는다면 이 빈들을 public으로 만들어야 한다. 가시성을 제한하기 위해 패키지를 모듈 경계로 사용하고 각 패키지 안에 전용 설정 클래스를 만들 수는 있다.

아키텍처 경계 강제하기

  • 가장 안쪽의 계층에는 도메인 엔티티가 있다. 애플리케이션 계층은 애플리케이션 서비스 안에 유스케이스를 구현하기 위해 도메인 엔티티에 접근한다. 어댑터는 인커밍 포트를 통해 서비스에 접근하고, 반대로 서비스는 아웃고잉 포트를 통해 어댑터에 접근한다. 마지막으로 설정 계층은 어댑터와 서비스 객체를 생성할 팩터리를 포함하고 있고, 의존성 주입 메커니즘을 제공한다.
  • package-private 제한자는 자바 패키지를 통해 클래스들을 응집적인 ‘모듈’로 만들어 주기 때문에 중요하다. 이러한 모듈 내에 있는 클래스들은 서로 접근 가능하지만, 패키지 바깥에서는 접근할 수 없다. 그럼 모듈의 진입점으로 활용될 클래스들만 골라서 public으로 만들면 된다. 이렇게 하면 의존성이 잘못된 방향을 가리켜서 의존성 규칙을 위반할 위험이 줄어든다.

의식적으로 지름길 사용하기

  • 유스케이스 간 입출력 모델을 공유하는 것은 유스케이스들이 기능적으로 묶여 있을 때 유효하다. 즉, 특정 요구사항을 공유할 때 괜찮다는 의미다. 이 경우 특정 세부사항을 변경할 경우 실제로 두 유스케이스 모두에 영향을 주고 싶은 것이다.
  • 유스케이스가 단순히 데이터베이스의 필드 몇 개를 업데이트하는 수준이 아니라 더 복잡한 도메인 로직을 구현해야 한다면, 유스케이스 인터페이스에 대한 전용 입출력 모델을 만들어야 한다. 왜냐하면 유스케이스의 변경이 도메인 엔티티까지 전파되길 바라진 않을 것이기 때문이다.
  • 인커밍 포트는 애플리케이션 중심에 접근하는 진입점을 정의한다. 이를 제거하면 특정 유스케이스를 구현하기 위해 어떤 서비스 메서드를 호출해야 할지 알아내기 위해 애플리케이션의 내부 동작에 대해 더 잘 알아야 한다. 전용 인커밍 포트를 유지하면 한눈에 진입점을 식별할 수 있다.

아키텍처 스타일 결정하기

  • 외부의 영향을 받지 않고 도메인 코드를 자유롭게 발전시킬 수 있다는 것은 육각형 아키텍처 스타일이 내세우는 가장 중요한 가치다.
  • 육각형 스타일과 같은 도메인 중심의 아키텍처 스타일은 DDD의 조력자라고까지 말할 수 있다. 도메인을 중심에 두는 아키텍처 없이는, 또 도메인 코드를 향한 의존성을 역전시키지 않고서는, DDD를 제대로 할 가능성이 없다. 즉, 설계가 항상 다른 요소들에 의해 주도되고 말 것이다.

참고