만들면서 배우는 클린 아키텍처 1~3장 정리

한 줄 요약: 좀 급진론이긴 하지만 예제 코드를 보면 재밌다

Sigrid Jin
12 min readDec 25, 2021
http://www.yes24.com/Product/Goods/105138479

1장: 계층형 아키텍처의 문제는 무엇인가?

  • 우리가 일반적으로 만드는 계층형 아키텍쳐의 토대는 데이터베이스다.
  • Reminder: 웹 계층은 요청을 받아 도메인(비즈니스) 계층에 있는 서비스로 요청을 보낸다. 도메인(비즈니스) 계층은 필요한 비즈니스 로직을 수행한다. 그리고, 도메인 엔티티의 현재 상태를 조회하거나 변경하기 위해 영속성 계층 컴포넌트를 호출한다
  • 하지만, 계층형 아키텍처는 DB 주도설계를 유도한다.
  • 우리는 상태가 아니라 행동을 기반으로 모델링한다.
  • DB Schema를 먼저 작성하고, 이를 토대로 도메인 로직을 구현한다. 기존 계층형 아키텍처에 따르면, 의존성의 방향에 따라 자연스러운 방식이다.
  • DB 중심적 아키텍처가 만들어지는 원인은 ORM 프레임워크를 사용하기 때문이다.
  • 하지만 비즈니스 관점에서는 전혀 맞지 않다. 도메인 로직을 먼저 만들고, 이를 제대로 이해해야 영속성과 웹 계층을 만들 수 있다.
  • 즉, 도메인 계층에서 데이터베이스 엔티티를 사용하는 것은 영속성 계층과의 강한 결합을 유발한다.
  • 서비스는 영속성 모델을 비즈니스 모델로 사용하기 때문에, 즉시로딩이니 지연로딩이니 cache flush 등 영속성 계층 작업을 해야 한다.
  • 전통적 계층형 아키텍처에서는, 같은 계층이나 아래에 있는 계층 및 컴포넌트에 접근 가능하다.
  • 그래서 상위 계층에 위치한 컴포넌트에 접근해야 한다면, 간단하게 컴포넌트를 자꾸 계층 아래에 내려버리려고만 한다.
  • 결국, 최종적인 영속성 계층에서는 모든 것이 접근 가능해지기 때문에 시간이 지나면서 점점 비대해진다.
  • 그렇다고 계층을 건너뛰게 되면, 도메인 로직이 App 전체에 걸쳐 책임이 섞이기 때문에 구현이 어렵다.
  • 또한, mocking의 강도가 증가하므로 단위 테스트의 복잡도가 올라간다.
  • 도메인 서비스의 너비(width)가 넓은 서비스는, 영속성 계층에 더 많은 의존성을 갖기 마련이다.

2장: 의존성 역전하기

  • 단일 책임원칙(SRP)는 컴포넌트를 변경하는 이유가 오직 하나 뿐이어야 한다 라는 실제 의미를 갖는다.
  • 하지만, 변경할 이유가 많이 쌓인 후에는 한 컴포넌트를 바꾸는 것이 다른 컴포넌트가 실패하는 원인으로 작용할 수 있다.
  • 단일 책임원칙이 고수준에서 적용되면, 영속성 계층에 대한 도메인 계층의 의존성 때문에, 영속성 계층을 변경할 때마다 잠재적으로 도메인 계층도 변경해야 한다. 하지만, 영속성 코드가 바뀐다고 해서 App 수준에서 가장 중요한 단위인 도메인 코드를 위협하고 싶지는 않다.
  • 코드 상의 어떤 의존성이든 그 방향을 바꿀 수, 즉 역전할 수 있다.
  • 엔티티는 도메인 객체를 표현하고, 도메인 코드는 엔티티들의 상태를 변경하는 일을 중심으로 한다.
  • 엔티티를 도메인 계층으로 올린다.
  • 영속성 계층의 레포지토리가 도메인 계층의 엔티티에 의존하기 때문에, 순환 의존이 발생한다.
  • 도메인 계층에 레포지토리에 대한 인터페이스를 만들고, 실제 레포지토리는 영속성 계층에서 구현하도록 한다.
  • 의존성 역전 원칙을 통해, 도메인이 나가는 외부 의존성은 없고, 모든 의존성이 도메인 코드를 향하도록 한다.
  • 계층 간의 모든 의존성이 도메인 내부로 향하도록 한다.
  • 이 방식을 통해, 도메인 코드에서는 어느 ORM이나 UI framework가 사용되는 지 알 수 없기 때문에, 특정 프레임워크에 의존적인 코드를 가질 수 없다. 그래서 도메인 코드를 자유롭게 모델링할 수 있다.
  • 하지만 클린 아키텍처에는 대가가 따른다.
  • 도메인 계층이 영속성이나 UI 등 외부 계층과 철저하게 분리되어야 하므로, Application의 엔티티 모델을 각 계층에서 유지보수 해야한다.
  • 가령, ORM을 사용할 때 도메인 계층은 영속성 계층을 모르기 때문에 도메인 계층의 엔티티 클래스와 영속성 계층의 엔티티 클래스를 별도로 만들어야 한다.
  • 따라서, 계층 간의 데이터 교환 시 두 엔티티를 서로 변환해야 한다. 이는 바람직한 일로, 도메인 코드를 프레임워크와의 결합을 제거한 상태다.
  • 가령, JPA는 ORM이 관리하는 Entity에 인자가 없는 기본 생성자를 추가하도록 강제한다. 이게 프레임워크와의 결합이 강화된 상태다.
  • 육각형 아키텍처를 사용하자. ports-and-adapters라고도 불리는 아키텍쳐다.
  • 육각형 안에는 도메인 엔티티, 그리고 이와 상호작용하는 유스케이스가 있다.
  • A use case in this sense is a class that handles everything around, well, a certain use case. As an example let’s consider the use case “Send money from one account to another” in a banking application.
  • We’d create a class SendMoneyUseCase with a distinct API that allows a user to transfer money. The code contains all the business rule validations and logic that are specific to the use case and thus cannot be implemented within the domain objects. Everything else is delegated to the domain objects (there might be a domain object Account, for instance).
  • Similar to the domain objects, a use case class has no dependency on outward components. When it needs something from outside of the hexagon, we create an output port.
  • 육각형 외부로 향하는 의존성이 없으므로, 오직 도메인으로 향하는 의존성만 갖는다.
  • 육각형 밖에는 App과 상호작용하는 다양한 어댑터들이 있는데, 웹 브라우저와 상호작용하는 웹 어댑터도 있고, 일부 어댑터는 외부 시스템과 상호작용하기도 한다.
  • App 코어와 어댑터 간의 통신이 가능하려면 App 코어가 각각의 포트를 제공해야 한다.
  • driving adapter에는 유스케이스 클래스들이 이를 구현하고 호출하는 인터페이스가 되고, driven adapter는 그러한 포트가 어댑터에 의해 구현되고 코어에 의해 호출되는 인터페이스가 된다.
  • An input port is a simple interface that can be called by outward components and that is implemented by a use case. The component calling such an input port is called an input adapter or “driving” adapter.
  • An output port is again a simple interface that can be called by our use cases if they need something from the outside (database access, for instance).
  • This interface is designed to fit the needs of the use cases, but it’s implemented by an outside component called an output or “driven” adapter.
  • If you’re familiar with the SOLID principles, this is an application of the Dependency Inversion Principle (the “D” in SOLID), because we’re inverting the dependency from the use cases to the output adapter using an interface.

3장: 코드 구성하기

계층 이용

  • 코드 구조화의 접근법 중 하나는 계층을 이용하는 것이다.
buckpal
| - domain
- Account
- Activity
- AccountRepository
- AccountService
- persistence
- AccountRepositoryImpl
- web
- AccountController
buckpal
| - account
- Account
- AccountController
- AccountRepository
- AccountRepositoryImpl
- SendMoneyService
  • 의존성 주입의 역할이란: 애플리케이션 계층이 인커밍/아웃고잉 어댑터에 의존성을 갖지 않는 것이 중요하다. Outgoing 어댑터의 경우에는 제어 흐름의 반대 방향으로 의존성을 돌리기 위해 의존성 역전 원칙을 이용해야 한다.
  • 계층적 아키텍처에서는, 애플리케이션 계층에 인터페이스를 만들고, 어댑터에 해당 인터페이스를 구현하는 클래스를 두는 방식이 있다.
  • 육각형 아키텍처에서는, 모든 계층에 의존성을 가진 중립적인 컴포넌트를 도입해서 해당 컴포넌트가 아키텍처를 구성하는 대부분의 클래스를 초기화한다.
  • 중립적 컴포넌트는 AccountController, SendMoneyService, AccountPersistenceAdapter 클래스의 인스턴스를 만들 것이다.
  • AccountController가 SendMoneyUseCase 인터페이스를 필요로 하기 때문에, 의존성 주입을 통해 SendMoneyService 클래스의 인스턴스를 주입한다. 컨트롤러는 인터페이스만 알면 되기 때문에 자신이 SendMoneyService를 가진지 당연히 모른다.
  • SendMoneyService 인스턴스를 만들 때에도 DI가 LoadAccountPort 인터페이스를 위시한 AccountPersistenceAdapter 인스턴스를 주입할 것이다.
  • 왜 사용해야 하는가: 아키텍처-코드 갭을 효과적으로 다룰 수 있는 강력한 요소다. 만약 패키지 구조가 아키텍처를 반영할 수 없다면, 목표하던 아키텍처로부터 멀어지게 될 것이다.
  • 어댑터 코드를 자체 패키지로 이동시키면 필요한 경우 하나의 어댑터를 다른 구현으로 쉽게 교체할 수도 있다. 예를 들어, Key-value 데이터베이스에서 SQL 데이터베이스로 바꾼다고 하면 관련 아웃고잉 포트만 바꾸면 된다.
  • DDD 개념의 직접적 대응도 가능하다. 위 코드에서는 account와 같은 상위 레벨 패키지는 다른 bounded context 통신하는 기점이 된다.
  • 구조의 각 요소들은 패키지 하나씩에 직접 매핑된다.
  • domain 패키지에는 도메인 모델이 속해있는데, application 패키지에는 도메인 모델을 둘러싼 서비스 계층을 포함한다.
  • SendMoneyService는 incoming port 인터페이스인 SendMoneyUseCase를 구현하고, outgoing port interface이자 영속성 어댑터에 의해 구현된 LoadAccountPort와 UpdateAccountStatePort를 사용한다.
  • 육각형 아키텍처에서 구조적으로 핵심이 되는 요소는 엔티티, 유스케이스, incoming, out coming adapter가 있다.

아키텍처적으로 표현력이 있는 패키지 구조

  • 하지만 기능에 의한 패키징 방식은 가시성을 떨어뜨린다.
  • 어댑터를 나타내는 패키지명이 없다.
  • 인커밍 포트, 아웃커밍 포트는 여전히 확인할 수 없다.
  • 도메인 코드와 영속성 코드를 역전시켜 SendMoneyService가 AccountRepository만 알고 있고 그의 Impl은 알 수 없도록 했음에도 불구하고, package-private 접근 수준을 이용했기 때문에 패키지 내부에서 도메인 코드가 실수로 영속성 코드에 의존하는 것을 막을 수 없다.
  • 기능을 기준으로 코드를 구성하면 기반 아키텍처가 명확하게 보이지 않는다.
  • 각 기능을 묶은 새로운 그룹은 account와 같은 레벨의 새로운 패키지로 들어가고, 패키지 외부에서 접근되면 안되는 클래스에 대해서는 package-private 접근수준을 할당한다. 이를 통해 각 기능 사이의 불필요한 의존성 생성을 방지할 수 있다.
  • 또한, AccountService의 책임을 좁히기 위해 SendMoneyService로 클래스명을 바꿨다.
  • 애플리케이션의 기능을 코드를 통해서 볼 수 있게 만드는 것은 screaming architecture라고 부른다.

기능 구성

  • 비슷하게, 패키지 구조를 통해서는 우리가 목표로 하는 아키텍처를 파악할 수 없다.
  • 일일이 패키지들의 클래스를 조사해야 한다.
  • 문제 1. 애플리케이션의 기능 조각이나 특성을 구분짓는 패키지 경계가 없다.
  • web package에 UserController를 추가하고, domain package에 UserService, UserRepository, User를 추가하고, persistence package에 UserRepositoryImpl을 추가한다.
  • 아주 빠르게 서로 연관되지 않은 기능들까지 예상하지 못한 side effect를 일으킬 수 있는 클래스들의 묶음으로 변모할 것이다.
  • 문제 2. 애플리케이션이 어떤 유스케이스들을 제공하는 지 파악할 수 없다.
  • AccountService와 AccountController가 어떤 유스케이스를 구현했는지 파악할 수 있겠는가?
  • 특정 기능을 찾기 위해서는 어떤 서비스가 이를 구현했는지 추측해야 한다.
  • 해당 서비스 내의 어떤 메소드가 그에 대한 책임을 수행하는 지 찾아야 한다.
  • 의존성 역전 원칙을 적용해서 의존성이 domain 패키지에 있는 도메인 코드만을 향하도록 한다.
  • 여기서는 domain 패키지에 AccountRepository 인터페이스를 추가하고, Persistence 패키지에 AccountRepositoryImpl 구현체를 두어 의존성을 역전시켰다.

--

--

No responses yet