본문 바로가기

iOS/Architecture

만들면서 배우는 클린아키텍처 를 읽으며 (1~4장)

'클린 코드'를 읽고 그 이후에 유명한 여러 아키텍처들 (MVC, VIPER, MVVM 등)을 보고 따라 프로젝트도 만들어 보며 아키텍처의 필요성, 각각의 장단점을 파악하고 있던 중에 Clean Architecture라는 아키텍처 또한 공부를 하게 되었다. 사실 듣기도 많이 들어봤고 '클린 아키텍처'도 읽었지만 조금 막연한 느낌이었다. 개인 프로젝트에 최대한 클린 아키텍처를 따라 만들어 보면서 많은 생각도 하고 주변이나 유명한 채팅방에 물어보며 최근에야 조금씩 정리가 되어가는 느낌이다. 그러던 중 곰 튀김님이 이 책을 추천해주셔서 읽어보게 되었는데 읽어보며 더욱 아키텍처의 의미가 정리가 되는 것 같다. 이럴 때 바로 블로그에 정리를 해보려 한다. 

http://www.yes24.com/Product/Goods/105138479

Part 1 계층형 아키텍처의 문제점

 

계층형 아키텍처는 데이터베이스 주도 설계를 유도한다

전통적인 계층형 아키텍처의 토대는 데이터베이스(영속성 계층)이다. 그러나 우리가 만드는 대부분의 애플리케이션의 목적은 보통 비즈니스를 관장하는 규칙이나 정책을 반영한 모델을 만들어서 사용자가 이러한 규칙과 정책을 더욱 편리하게 활용할 수 있게 하는것. 따라서 우리는 상태(state)가 아닌 행동(behavior)을 중심으로 모델링한다. 모든 애플리케이션들은 상태가 중요한 요소이긴 하지만 행동이 상태를 바꾸는 주체이기 때문에 행동이 비즈니스를 이끈다.

 

지름길을 택하기 쉬워진다

전통적인 계층형 아키텍처에서 전체적으로 적용되는 유일한 규칙: 특정한 계층에서는 같은 계층의 컴포넌트나 아래에 있는 계층만 접근 가능하다. 

위 규칙외의 다른 규칙을 강제하진 않는다. 이로 인해 상위 계층에 위치한 컴포넌트에 접근하려면 간단히 계층 아래로 내려버린다 -> 지름길이 발생한다… (깨진 창문 이론)

점점 영속성 계층 (최하단 계층)이 비대해진다.

 

테스트하기 어려워진다

만약 엔티티의 필드를 단 하나만 조작하면 되는 경우에 웹 계층에서 바로 영속성 계층에 접근하면 도메인의 계층을 건드릴 필요가 없지 않을까?

첫 번째 문제점, 단 하나의 필드를 조작하는 것에 불과하더라도 도메인 로직을 웹 계층에 구현하게 된다. -> 잠재적인 유스케이스 확장성을 망친다

두 번째 문제점, 웹 계층 테스트 시 도메인 계층뿐 아니라 영속성 계층도 모킹 해야 한다. -> 테스트의 복잡도 상승

 

유스케이스를 숨긴다

계층형 아키텍처에서는 도메인 로직이 여러 계층에 걸쳐 흩어지기 쉽다. 유스케이스가 ‘간단’해서 도메인 계층을 생략하여 웹 계층에 존재할 수도 있고, 도메인 계층과 영속성 계층 모두에서 접근 가능하도록 영속성 계층에 존재할 수도 있다.

또는 계층형 아키텍처에서는 도메인 서비스의 ‘너비’에 관한 규칙을 강제하지 않으므로 결국 여러 개의 유스케이스를 담당하는 아주 넓은 서비스가 생성되어 버린다 -> 넓은 서비스는 영속성 계층에 많은 의존성을 갖게 되고, 많은 상위 웹 레이어들이 이 서비스에 의존하게 된다.

결국 고도로 특화된 좁은 도메인 서비스가 유스케이스 하나씩만 담당하게 한다면 엄청 수월해질 것이다.

 

동시 작업이 어려워진다

모든 상황에서 50명 정도 되는 큰 규모의 개발팀이 10명 정도 되는 작은 규모의 개발팀보다 5배 빠를 거라고 기대할 수는 없다. 계층형 아키텍처라면 더더욱.

 

Part 2 의존성 역전하기

 

단일 책임 원칙 (Single Responsibility Principle, SRP)

“하나의 컴포넌트는 오로지 한 가지 일만 해야 하고, 그것을 올바르게 수행해야 한다.” -> “컴포넌트를 변경하는 이유는 오직 하나뿐이어야 한다.”

‘책임’은 사실 ‘오로지 한 가지 일만 하는 것’ 보다는 ‘변경할 이유’로 해석해야 한다. 만약 컴포넌트를 변경할 이유가 오로지 한 가지라면 컴포넌트는 딱 한 가지 일만 하게 된다. 하지만 이보다 더 중요한 것은 변경할 이유가 오직 한 가지라는 그 자체다.

많은 코드는 단일 책임 원칙을 위반하기 때문에 시간이 갈수록 변경하기가 더 어려워지고 그로 인해 변경 비용도 증가한다. 시간이 갈수록 컴포넌트를 변경할 더 많은 이유가 쌓여간다. 변경할 이유가 많이 쌓인 후에는 한 컴포넌트를 바꾸는 것이 다른 컴포넌트가 실패하는 원인으로 작용할 수 있다.

 

의존성 역전 원칙 (Dependency Inversion Principle, DIP)

계층형 아키텍처에서 계층 간 의존성은 항상 다음 계층인 아래 방향을 가리킨다. 즉 상위 계층들(웹, 도메인)이 하위 계층(영속성)들에 비해 변경할 이유가 더 많다. -> 영속성 계층에 대한 도메인 계층의 의존성 때문에 영속성 계층을 변경할 때마다 잠재적으로 도메인 계층도 변경해야 한다. But, 도메인 코드는 애플리케이션에서 가장 중요한 코드이다. 이러한 의존성을 제거할 방법은 의존성 역전 원칙이다. 이는 의존성의 양쪽 코드를 모두 제어할 수 있을 때만 의존성을 역전시킬 수 있다.

만약 도메인 계층에서 영속성 계층의 엔티티와 리포지토리와 상호작용하는 서비스가 하나 있다고 할때

  1. 엔티티는 도메인 객체를 표현하고 도메인 코드는 이 엔티티들의 상태를 변경하는 일을 중심으로 하기 때문에 먼저 엔티티를 도메인 계층으로 올린다.
  2. 이렇게 되면 영속성의 리포지토리가 도메인 계층에 있는 엔티티에 의존하기 때문에 두 계층 사이에 순환 의존성이 생긴다.
  3. 도메인 계층에 리포지토리에 대한 인터페이스를 만들고, 실제 리포지토리는 영속성에서 구현함으로써 DIP를 적용한다.

이것이 클린 아키텍처의 핵심 기능이다.

 

클린 아키텍처

클린 아키텍처에서의 설계는 비즈니스 규칙의 테스트를 용이하게 하고, 비즈니스 규칙은 프레임워크, 데이터베이스, UI기술, 그 밖의 외부 애플리케이션이나 인터페이스로부터 독립적일 수 있게 하는 것. -> 도메인 코드가 바깥으로 향하는 어떤 의존성도 없어야 한다. 대신 의존성 역전 원칙의 도움으로 모든 의존성이 도메인 코드를 향하고 있다.

이 아키텍처의 코어에는 주변 유스케이스에서 접근하는 도메인 엔티티들이 있다. 도메인 코드에서는 어떤 영속성 프레임워크나 UI 프레임워크가 사용되는지 알 수 없기 때문에 특정 프레임워크에 특화된 코드를 가질 수 없고 비즈니스 규칙에 집중할 수 있다. 그러나 클린 아키텍처에는 대가가 따른다. 도메인 계층이 영속성이나 UI같은 외부 계층과 철저히 분리돼야 하므로 애플리케이션의 엔티티에 대한 모델을 각 계층에서 유지보수 해야한다. 즉, 도메인 계층과 영속성 계층이 데이터를 주고 받을 때, 두 엔티티를 서로 변환해야 한다는 뜻이다. 하지만 이것은 바람직한 일이다. 이것이 바로 도메인 코드를 프레임워크에 특화된 문제로부터 해방시키고자 했던 결합이 제거된 상태다.

 

육각형 아키텍처 ( 헥사고날 아키텍처 )

모든 의존성은 코어(도메인 엔티티)를 향한다. 육각형 바깥에는 애플리케이션과 상호작용하는 다양한 어댑터들이 있다. 

왼쪽에 있는 어댑터들은 (애플리케이션 코어를 호출하기 때문에) 애플리케이션을 주도하는 어댑터들이다. 반면 오른쪽에 있는 어댑터들은 (애플리케이션 코어에 의해 호출되기 때문에) 애플리케이션에 의해 주도되는 어댑터들이다.

애플리케이션 코어와 어댑터들 간에 통신이 가능하려면 애플리케이션 코어가 각각의 포트를 제공해야 한다. 주도하는 어댑터에게는 그러한 포트가 코어에 있는 유스케이스 클래스들에 의해 구현되고 호출되는 인터페이스가 될 것이고, 주도되는 어댑터에게는 그러한 포트가 어댑터에 의해 구현되고 코어에 의해 호출되는 인터페이스가 될 것이다. 

 

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

클린 아키텍처, 육각형 아키텍처, 혹은 포트와 어댑터 아키텍처 중 무엇으로 불리든 의존성을 역전시켜 도메인 코드가 다른 바깥쪽 코드에 의존하지 않게 함으로써 영속성과 UI에 특화된 모든 문제로부터 도메인 로직의 결합을 제거하고 코드를 변경할 이유의 수를 줄일 수 있다. 그리고 변경할 이유가 적을수록 유지보수성은 좋아진다.

 

Part 3 코드 구성하기

 

의존성 주입의 역할

인커밍 어댑터에 대해서는 제어 흐름의 방향이 어댑터와 도메인 코드 간의 의존성 방향과 같은 방향이기 때문에 어댑터는 그저 애플리케이션 계층에 위치한 서비스를 호출할 뿐이다. (그럼에도 불구하고 애플리케이션 계층으로의 진입점을 구분 짓기 위해 실제 서비스를 포트 인터페이스들 사이에 숨겨두고 싶을 수 있다.)

아웃고잉 어댑터에 대해서는 제어 흐름의 반대 방향으로 의존성을 돌리기 위해 의존성 역전 원칙을 이용해야 한다. 그런데 포트 인터페이스를 구현한 실제 객체를 누가 애플리케이션 계층에 제공해야 할까? 포트를 애플리케이션 계층 안에서 수동으로 초기화하고 싶지는 않다. 애플리케이션 계층에 어댑터에 대한 의존성을 추가하고 싶지는 않기 때문이다. 

이 부분에서 의존성 주입을 활용할 수 있다. 모든 계층에 의존성을 가진 중립적인 컴포넌트를 하나 도입하는 것이다. 이 컴포넌트는 아키텍처를 구성하는 대부분의 클래스를 초기화하는 역할을 한다.

 

Part 4 유스케이스 구현하기

 

  1. 육각형 아키텍처는 도메인 중심의 아키텍처에 적합하기 때문에 도메인 엔티티를 만드는 것으로 시작한 후 해당 도메인 엔티티를 중심으로 유스케이스를 구현한다. -> 도메인 모델 구현
  2. 유스케이스 둘러보기. 일반적으로 유스케이스는 다음과 같은 단계를 따른다.
  • 1. 입력을 받는다
  • 2. 비즈니스 규칙을 검증한다
  • 3. 모델 상태를 조작한다
  • 4. 출력을 반환한다 

 

입력 유효성 검증 ( 송금하기 usecase 중)

입력 유효성 검증은 유스케이스 클래스의 책임이 아니다. 하지만 여전히 이 작업은 애플리케이션 계층의 책임에 해당하기 때문에 지금 논의하는 게 적절하다. 이는 입력 모델(input model)이 이문제를 다룬다. 더 정확히 말하자면 생성자 내에서 입력 유효성을 검증하는 것이 적절하다.

 

유스케이스마다 다른 입력 모델

각 유스케이스 전용 입력 모델은 유스케이스를 훨씬 명확하게 만들고 다른 유스케이스와의 결합도 제거해서 불필요한 부수효과가 발생하지 않게 한다. 물론 비용이 안 드는 것은 아니다. 들어오는 데이터를 각 유스케이스에 해당하는 입력 모델에 매핑해야 되기 때문이다.

 

비즈니스 규칙 검증하기

입력 유효성 검증은 유스케이스 로직의 일부가 아닌 반면, 비즈니스 규칙 검증은 분명히 유스케이스 로직의 일부다. 이는 애플리케이션의 핵심이기에 적절하게 잘 다뤄야 한다. 그렇다면 각각 언제 해야 하는가?

둘 사이의 실용적인 구분점은 비즈니스 규칙을 검증하는 것은 도메인 모델의 현재 상태에 접근해야 하는 반면, 입력 유효성 검증은 그럴 필요가 없다는 것이다. 즉 “출금 계좌는 초과 출금되어서는 안 된다”는 현재 상태에 접근하므로 비즈니스 규칙, 반대로 “송금되는 금액은 0보다 커야 한다” 는 모델에 접근하지 않고도 검증 가능하므로 입력 유효성 검증으로 구현할 수 있다. 그러면 비즈니스 규칙 검증은 어떻게 구현하는 게 좋을까?

가장 좋은 방법은 비즈니스 규칙을 도메인 엔티티 안에 넣는 것이다. 만약 도메인 엔티티에서 비즈니스 규칙을 검증하기가 여의치 않다면 유스케이스 코드에서 도메인 엔티티를 사용하기 전에 해도 된다.

 

풍부한 도메인 모델 vs 빈약한 도메인 모델

‘풍부한’ 도메인 모델에서는 애플리케이션의 코어에 있는 엔티티에서 가능한 많은 도메인 로직이 구현된다. 엔티티들은 상태를 변경하는 메서드를 제공하고, 비즈니스 규칙에 맞는 유효한 변경만 허용한다.

‘빈약한’ 도메인 모델에서는 엔티티 자체가 굉장히 얇다. 일반적으로 엔티티는 상태를 표현하는 필드와 이 값을 읽고 바꾸기 위한 getter, setter 메서드만 포함하고 어떤 도메인 로직도 갖고 있지 않다.

-> 즉 도메인 로직이 유스케이스 클래스에 구현돼 있다는 것이다. 비즈니스 규칙을 검증하고, 엔티티의 상태를 바꾸고, 데이터베이스 저장을 담당하는 아웃고잉 포트에 엔티티를 전달할 책임 역시 유스케이스 클래스에 있다. ‘풍부함’이 유스케이스에 존재한다. 

—> swift에서 내가 썼던 대부분의 엔티티는 빈약했다

 

유스케이스 마다 다른 출력 모델

유스케이스를 가능한 구체적으로 유지하기 위해서는 계속 질문해야 한다. 만약 의심스럽다면 가능한 적게 반환하자. 유스케이스들 간에 같은 출력 모델을 공유하게 되면 유스케이스들도 강하게 결합된다. 한 유스케이스에서 출력 모델에 새로운 필드가 필요해지면 이 값과 관련이 없는 다른 유스 케이스에서도 이 필드를 처리해야 한다. 공유 모델은 장기적으로 봤을 때 갖가지 이유로 점점 커지게 돼 있다. 단일 책임 원칙을 적용하고 모델을 분리해서 유지하는 것은 유스케이스의 결합을 제거하는 데 도움이 된다.

 

읽기 전용 유스케이스는 어떨까?

계좌 잔액을 표시해야 한다고 가정하면, 이에 대한 읽기 전용 유스케이스를 구현해야 할까?

만약 전체 프로젝트의 맥락에서 이러한 작업이 유스케이스로 분류된다면 어떻게든 다른 유스케이스와 비슷한 방식으로 구현해야 한다. 하지만 이 경우에는 애플리케이션 코어의 관점에서 이 작업은 간단한 데이터 쿼리다. 그렇기 때문에 프로젝트 맥락에서 유스케이스로 가눚되지 않는다면 실제 유스케이스와 구분하기 위해 쿼리로 구현할 수 있다. 쿼리 서비스는 유스케이스 서비스와 동일한 방식으로 동작한다. getAccountBalnceQuery라는 인커밍 포트를 구현하고, 데이터베이스로부터 실제로 데이터를 로드하기 위해 loadAccountPort라는 아웃고잉 포트를 호출한다.

 

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

입출력 모델을 독립적으로 모델링한다면 원치 않는 부수효과를 피할 수 있다. 물론 유스케이스 간에 모델을 공유하는 것보다는 더 많은 작업이 필요하다. 각 유스케이스마다 별도의 모델을 만들어야 하고, 이 모델과 엔티티를 매핑해야 한다. 그러나 유스케이스별로 모델을 만들면 유스케이스를 명확하게 이해할 수 잇고, 장기적으로 유지보수하기도 더 쉽다. 또한 여러 명의 개발자가 다른 사람이 작업 중인 유스케이스를 건드리지 않은 채로 여러 개의 유스케이스를 동시에 작업할 수 있다. 꼼꼼한 입력 유효성 검증, 유스케이스별 입출력 모델은 지속 가능한 코드를 만드는 데 큰 도움이 된다.

 

 

 

 

느낀점

사실 이 책은 java를 기반으로 한 책이라 직접적인 코드에 연관이 있진 않았다. 그래서 swift에 대입했을 때 과연 같은 상황일까?라고 생각을 해본다면 완전히 다 맞다곤 할 수 없다. 하지만 이 아키텍처의 본질과 특징을 파악하기엔 별 지장이 없었다. 내가 다른 아키텍처들과 달리 클린 아키텍처를 어려워했던 이유도 계층형 아키텍처에 익숙해져 있다가 도메인 중심의 아키텍처를 처음 접하게 되어 그랬던 것 같다. 아키텍처는 100% 정답이 없다곤 하지만 도메인을 중심으로 둔 부분에 대해선 흥미로웠고 결국 대부분의 사람들이 원하는 아키텍처에 이상향이 이런 형태이지 않을까? 란 생각이 들었다.