'클린 아키텍처' 프로젝트 설계하기

개요

 작년에 로버트 마틴의 '클린 아키텍처'를 읽은 적이 있다. SOLID부터 시작해서 여러 디자인 패턴이나 방법론에 대한 소개를 통해 객체지향적인 사고를 할 수 있도록 도왔고, OOP에 대한 새로운 시각을 제시해 주기도 했던 책이었다.

 

하지만 그 책을 읽고 나서도, 프로젝트를 진행하면서 책에 존재하는 수많은 선택지들 중에 '그렇다면 내가 앞으로 어떤 방법을 택해야 하는가?'라는 질문에 대한 확답을 내리지는 못했다. 예를 들어 추상화를 통해 클래스간의 결합도를 줄이고 외부 인터페이스에 의존하지 않는 코드를 만들고자 했던 목표를 두고 작성한 코드가, 간단한 프로젝트임에도 기존의 몇 배에 달하는 클래스들이 복잡한 연관관계를 가지고 있어 가독성이 떨어지고 간단한 유지보수조차 어렵게 하는 trade-off를 낳는 것을 목격하기도 했다. 

 

물론 상황에 따라서 정답에 가까운 선택지를 고르는 것이 최선의 방법이겠지만, 아직 나는 어떤 것이 정답에 가까울지에 대한 질문에 확신을 가지고 대답할 수 없었다. 그래서 이번에는 작년에 읽었던 '클린 아키텍처'에 더해 '만들면서 배우는 클린 아키텍처'라는 책을 읽고, 사이드 프로젝트에 적용해가면서 나만의 결론을 내려보려고 한다.

 

 

계층형 아키텍처의 문제

Controller-Service-Entity(ORM)

 

 아마 웹 개발을 시작한 많은 사람들이 맨 처음 이러한 계층형 아키텍처를 접했으리라고 생각한다. 이러한 계층형 아키텍처는 가장 많이 사용하는 아키텍처로서, 요청을 처리하고 응답하는 프로세스를 계층화한 가장 기초적이고 견고한 구조이기도 하다.

 

하지만 이 단순한 3계층 아키텍처는 소프트웨어 크기가 커지고 유지보수 기간이 길어질수록 클래스간 결합도가 높아지고, 그 결과 변경에 취약한 코드가 된다는 문제를 낳는다. 

 

이러한 문제점의 가장 큰 원인은 3계층 구조가 영속성 계층, 즉 데이터베이스에 의존적이라는 것이다. 위 그림에서 알 수 있듯이, 웹 계층과 도메인 계층은 모두 영속성 계층에 의존성을 가지고 있다. 결국 비즈니스 로직을 갖는 도메인 계층이 영속계층과 강하게 결합하게 되고, 유지보수를 계속할수록 비즈니스 로직을 수행하는 다양한 퍼사드 클래스나 헬퍼 클래스까지 영속성 계층에 의존성을 갖게 될 수도 있다. 또한 한쪽 방향으로 의존성을 갖는 아키텍처 특성상 웹 계층에서 영속성 계층에 직접 접근하는 일이 생길 수도 있다. 이는 분명 계층간의 역할을 분리하는 기존 의도와 어긋남에도, 이러한 개발자의 일탈을 막는 장치는 어디에도 존재하지 않는다.

 

추가로 기본적인 3계층 아키텍처는 다음과 같은 문제점들도 가지고 있다.

- 테스트를 어렵게 만든다. 간단한 단위 테스트조차 웹, 도메인, 영속성 계층에 대한 검증을 요구하게 되고, 웹 계층 테스트에서 도메인뿐 아니라 영속성 계층에 대한 테스트까지 mocking하게 만든다.

- 동시 작업이 어려워진다. 모든 계층이 영속성 계층에 의존적이므로, 엔티티 설계가 끝난 후에야 비즈니스 로직에 대한 작업이 들어갈 수 있고, 그 후에야 컨트롤러를 구현할 수 있다.

 

 

계층형 아키텍처 자체가 잘못된 것이 아니다

 일부 문제들은 계층형 아키텍처에 대한 높은 이해를 가지고 설계한다면 생기지 않을 문제점들일 수도 있다. 하지만 모든 개발자가 아키텍처에 대한 높은 이해를 가지고 있는 것은 아니며, 계층형 아키텍처는 개발자의 잘못된 구현을 막지 못한다는 것이 서비스 크기가 커질수록 문제점으로 다가올 확률이 높다.

 

 

의존성 역전으로 도메인 중심 구조 만들기

 앞서 보았듯이, 계층형 아키텍쳐는 웹 -> 도메인 -> 영속성이라는 단방향 의존성을 가지고 있다. 결국 하위 계층이 변경될 직접적인 이유는 클라이언트의 새로운 요구사항 등과 같은 필수적인 요소여야 함에도, 상위 계층의 경우 하위 계층에 의해 불필요하게 변경될 가능성이 존재한다.

 

계층화 구조와 상관 없이 "프로그램의 핵심은 비즈니스 로직을 갖는 도메인 계층"이라는 사실을 고려했을 때, 도메인 계층이 영속성 계층에 강한 의존성을 가지고 있다는 사실은 충분히 불편하게 느껴질 것이다. 그래서 책에서는 계층간 의존성을 역전시켜 도메인 중심의 개발이 이루어질 수 있도록 하고 있다.

 

https://eckrin.tistory.com/171

 

 개발자는 다형성을 이용하여 DIP를 구현할 수 있으며, 이를 통해 간접적으로 제어의 흐름을 역전시킬 수 있다. 우리는 이를 3계층 아키텍처에도 동일하게 적용할 수 있다.

 

 

프로젝트 설계

단방향 의존성을 가지는 3계층 구조
인터페이스를 통해 영속계층이 도메인에 의존하도록 변경

 

위와 같은 원리를 프로젝트에 적용한 예시이다. 기존 계층형 아키텍처에서 비즈니스(서비스) 로직은 도메인 계층에, 엔티티와 리포지토리는 영속성 계층에 존재했다면, 위에서는 리포지토리 인터페이스를 만들고 이를 도메인 계층에 위치시켰다. 여기서 웹과 도메인 계층의 결합도를 더 낮추고 싶다고 하면, BannerService에 대한 인터페이스를 추가하고 웹 계층에서는 해당 인터페이스에 대한 의존성만 갖도록 하면 된다.

 

이제 엔티티의 상태를 변경하는 도메인 로직과 엔티티는 같은 계층에 위치하고, 실제 구현은 영속성 계층에서 리포지토리 인터페이스를 구현하는 방식으로 맡게 된다. 이러한 방법을 통해 영속성 계층이 도메인 계층에 의존하도록 의존성을 역전시킬 수 있다. 이제 영속성 계층에 변경사항이 생겼다면, 리포지토리 구현체만 수정하면 된다. 

 

특이한 점은 엔티티가 도메인 계층에서 관리하는 'Banner' 클래스와 영속계층에서 관리하는 'BannerEntity' 클래스로 나누어졌다는 것이다. 도메인 계층이 영속성 계층에 대해서 알 수 없도록 분리했기 때문에 각 계층에서 엔티티를 만들게 된 것이다(물론 도메인 계층의 엔티티는 의미상으로만 엔티티이다). 도메인 코드를 특정 외부 프레임워크에 종속적이지 않도록 하기 위해서 필연적으로 나타나게 되는 결과이다.

 

 

패키지 구성하기

그렇다면 위와 같은 아키텍처에 대한 패키지를 어떻게 구성하면 좋을까?  책에서는 도메인 중심적인 해결책을 제시하였다.

 

 

  • 최상위 패키지로 domain과 application, adapter를 두고, domain에는 엔티티, application에는 서비스 클래스를 위치시킨다.
  • adapter 패키지에는 웹 계층과 영속계층에 관련된 클래스가 존재한다. 도메인 관점에서 해석하는 것이 목표므로, adapter.in에는 도메인으로 제어가 들어오는 web계층에 해당하는 Controller 클래스가, adapter.out에는 도메인으로부터 제어가 나가는 persistence계층에 해당하는 Repository가 위치한다.
  • application은 도메인 계층의 핵심 비즈니스 로직을 담은 서비스 클래스와 함께 결합도를 낮추고 의존성을 역전시켜줄 포트 인터페이스가 존재한다. port.in에는 웹 계층 인터페이스가, port.out에는 영속계층의 인터페이스가 존재하여 웹 계층과 영속 계층이 모두 도메인 계층에 의존하도록 하여 도메인 중심적인 구조를 만든다.
  • 도메인 계층에 웹 계층 또는 영속 계층에서 접근 가능해야하는 인터페이스(port 하위 등)를 제외한 클래스들은 접근지정자를 조절하여 불필요한 의존성이 생기는 것을 방지할 수 있다.

 

 

유효성 검증은 어느 계층의 책임인가

 위 예시에서는 서비스 코드에 대한 인터페이스를 유스케이스라고 명명하고 있다. 유스케이스란 시스템에서 수행되는 일련의 활동에 대한 시나리오를 의미하는데, 일반적으로 입력->비즈니스 규칙 검증->상태 변경->출력의 과정을 거친다. 이 때 중요한 점은 입력값에 대한 검증과, 비즈니스 규칙을 검증하는 로직을 구분해야 한다는 것이다.

 

 

입력값 검증

 입력값에 대한 유효성을 검증하는 것은 사실 유스케이스의 책임은 아니라고 할 수 있다. 하지만 이는 여전히 application 계층의 책임인데, web이나 persistence 계층에서 입력 유효성을 검증하려고 하면 어댑터가 유스케이스 각각에 대한 흐름을 분기시켜 입력 유효성을 검증해야 할 것이기 때문이다. 하나의 유스케이스가 여러개의 어댑터를 통해 호출될 수도 있다는 점을 고려하면 이는 adapter 계층의 복잡도를 증가시키고 도메인 계층의 결합도를 증가시키는 원인이 될 것이다. 

 

비즈니스 규칙 검증

 그렇다면 비즈니스 규칙을 검증하는 것은 어떨까? 비즈니스 규칙은 분명 유스케이스 로직의 일부이며, 애플리케이션의 핵심이기에 application 계층에서 검증하는 것이 당연하다.

 

두 검증의 차이

입력값 검증과 비즈니스 규칙 검증의 가장 큰 차이는 도메인 모델의 '현재' 상태에 의존적인가의 여부라고 할 수 있다. 따라서 검증 과정에 현재 도메인 모델의 상태를 고려해야 하는지 여부를 확인해서 입력값 검증과 비즈니스 규칙 검증을 구분할 수 있다.

 

 

마지막으로 이와 같은 검증 코드를 application 계층의 어디에 위치시킬 것인가에 대해서는 각자의 코드 스타일에 달려있다. 책에서는 '풍부한 도메인 모델'과 '빈약한 도메인 모델'이라는 두 케이스를 소개하고 있는데, 전자는 도메인 엔티티(영속 엔티티와 다르다 - 도메인 계층에 위치함) 안에 비즈니스 규칙을 검증하는 로직을 포함하지만, 후자는 엔티티에 필드와 getter, setter를 제외한 도메인 로직을 두지 않으며 이를 유스케이스, 즉 서비스 클래스에서 해결한다. 무엇이 옳은가에 대한 정답은 존재하지 않으며, 개발자가 적당한 스타일을 선택해서 사용하면 된다.

 

 책에서는 이러한 두 가지 케이스를 소개만 할 뿐, 어떤 방법이 나은지에 대해서는 설명하지 않고 있다. 그래서 내 경험을 통해 얻은 (아마 많은 개발자들이 공감할 것이다) 점만 적어보자면, '빈약한' 도메인 모델만 사용하면 엔티티에 대한 검증 로직이 모조리 서비스 클래스에 추가되며, 결국 코드의 응집도가 낮아지고 가독성이 저해된다라는 것이다. 따라서 엔티티의 상태를 업데이트하는 등의 단순한 작업들은 엔티티 내의 도메인 로직을 사용하고 적절한 명명규칙을 통해서 가독성을 올리고 코드의 불필요한 반복을 최소화하는 것이 좋아 보인다.

 

그렇다고 모든 비즈니스 로직을 도메인 엔티티에 넣을 생각은 하지 않는 것이 좋아 보인다. 도메인 엔티티도 결국 '엔티티'이지, 퍼사드나 유틸리티 클래스가 아니기 때문이다. 도메인 엔티티에 대한 변경이 주된 기능을 하는 부분만 도메인 엔티티에 별도 로직으로 분리하는 것이 좋지 않을까?

 

 

 

의존성 역전으로 도메인 중심 구조 만들기 (1) - 웹 어댑터

 앞서 LoadApplicationPort 인터페이스에서 확인할 수 있듯이, 도메인 계층과 영속 계층의 의존성을 역전시키기 위해서 도메인 계층에 영속성 계층에 대한 인터페이스를 위치시키는 방법을 사용하였다. 그런데 웹 계층(컨트롤러)와 도메인(서비스) 계층은 제어의 흐름을 역전시킬 필요가 없는데도 인터페이스를 두는 이유가 무엇일까? 

 

incoming, outgoing 포트 인터페이스 추가

 

1. 포트 인터페이스는 애플리케이션 코어가 외부 세계와 통신할 수 있는 명세이기 때문이다. 도메인 계층만 보고도 포트 인터페이스를 통해서 웹 컨트롤러와 어떤 통신이 이루어지고 있는지 알 수 있으며, 이는 도메인 계층에 대한 유지보수를 보다 용이하게 만든다.

 

2.서비스 계층에서 웹 컨트롤러를 호출해야 하는 케이스가 존재한다. 애플리케이션 코어에서 실시간 데이터를 사용자의 브라우저로 보내기 위해서는 애플리케이션 계층에서 능동적으로 웹 컨트롤러에 알림을 보낼 수 있어야 한다. 그러한 과정을 위해서는 위와 같은 아웃고잉 포트 인터페이스가 필요하다. 

 

 

 

 

의존성 역전으로 도메인 중심 구조 만들기 (2) - 영속성 어댑터

 

 

 앞서 이야기했듯이 포트 인터페이스를 사용하여 도메인 계층과 영속계층의 의존성을 역전시킬 수 있다. 이것은 도메인 계층의 영속 계층에 대한 코드 의존성을 없애기 위한 방법이며, (런타임 의존성은 여전히 애플리케이션 서비스에서 영속성 어댑터로 향한다) 포트 인터페이스를 통해 영속성 계층 내의 변경이 서비스에까지 변경으로 이어지는 것을 방지한다는데 의미를 가진다.

 

 

영속성 계층에 대한 결합도를 낮춘다

 이렇게 분리된 영속성 어댑터는, 포트 인터페이스를 구현하는 어떠한 방식으로든 자유롭게 교체될 수 있다. 이는 사용하던 JPA가 아닌 다른 인터페이스로 교체하더라도 애플리케이션 클래스의 코드로 이어지지는 않는다는 것을 말한다. 영속 계층의 변화가 도메인 계층으로 전파되는 것을 최소화하는 것이다. 

 

 

포트 인터페이스 설계하기

 

이제 포트 인터페이스를 어떻게 구성해야 될 지에 대해서 생각해보자. 일반적으로 위 그림과 같이 'AccountRepository'와 같은 포트의 형태를 사용하여 Account에 관련된 기능들을 한 개의 클래스에 모아두는 경우가 많다. 이는 영속성 어댑터가 사용하지도 않을 메서드들에 대한 의존성을 가지게 만들며, 테스트를 어렵게 만든다.

 

예를 들어 계좌정보가 정상적으로 불러와지는지에 대한 테스트를 한다고 하자. 우리는 애플리케이션 서비스가 호출할 메서드를 모킹해주어야 한다. 하지만 AccountRepository의 어떤 메서드가 사용되는지를 확인하고 모킹하기 위해서는 서비스 클래스에 대한 분석이 필요하고, 이는 다른 테스트시에 AccountRepository의 어떤 메서드가 모킹되었는지 구분하기 힘들게 만든다.

 

 

 

ISP(인터페이스 분리 원칙)는 하나의 일반적인 인터페이스보다 여러개의 구체적인 인터페이스가 낫다고 말한다. 이를 위해 각 서비스가 필요한 메서드를 가진 클래스에만 의존하도록 아웃고잉 포트 인터페이스를 분리했다. 뿐만 아니라 영속성 어댑터들을 도메인 단위로 나누어 영속성 어댑터가 도메인 경계를 따라서 자연스럽게 분리되도록 하고, 필요한 포트로의 의존성만을 갖도록 하였다.

 

 

테스트 설계하기

 설계한 시스템과 마찬가지로, 아키텍처의 경계를 넘나드는 테스트 구조는 테스트 비용과 속도에 부정적인 영향을 줄 수 있다. 따라서 테스트에 들어가는 자원을 줄이기 위해서는 커버리지 목표를 작게 잡는 것이 좋다.

 

 

- 단위 테스트: 클래스를 인스턴스화하고, 다른 클래스에 의존하는 부분은 모킹(mocking)함으로써 개별 메서드의 동작을 확인하는 등 소프트웨어의 가장 작은 단위에 대한 동작을 테스트한다.
- 통합 테스트: 모듈간 연결을 담당하는 여러 클래스를 인스턴스화하고, 여러 모듈이나 컴포넌트가 서로 잘 작동하는지 모듈간의 상호작용을 테스트한다.
- 시스템 테스트: 전체 시스템이 사용자 관점에서 특정 유스케이스가 제대로 동작하는지 확인한다.

 

 

아키텍처 경계 강화하기

 클린 아키텍처를 지키면서 프로젝트를 설계했다고 하더라도, 시간이 지나며 프로젝트의 규모가 커짐에 따라서 아키텍처의 경계가 무너질 수 있다. 

 

도메인 중심 개발(DDD)을 위해서는 도메인 중심적인 프로젝트 설계가 필요하다.

 

- 도메인 엔티티가 맨 안쪽에 위치하고, 애플리케이션 계층은 서비스 안의 유스케이스 구현체를 통해서 도메인에 접근한다.
- 어댑터는 입력 포트(유스케이스) 인터페이스를 통해서 그 구현체인 서비스 코드에 접근한다.
- 서비스는 출력 포트를 통해서 영속성 어댑터에 접근하고, 해당 어댑터를 통해서 영속 계층(레포지토리)에 접근할 수 있다.

 

모든 계층 간 의존성은 바깥쪽에서 안쪽을 가리키며, 최종적으로 모든 의존성은 도메인 계층으로 이어진다. 이러한 구조 설계를 위해서는 어떤 방법을 사용해야 할까?

 

자바는 패키지, 즉 모듈간에 상호 접근 가능하게 설정하는 접근 제한자인 'package-private' 즉, default 제한자가 존재한다. 이 default 제한자가 달려있으면 모듈 내의 클래스는 상호 접근하지만, 외부에서는 접근할 수 없다. 따라서 해당 모듈의 진입점으로 활용될 클래스들을 골라서 public으로 설정해주면 된다.

 

 

 

 

참고: 만들면서 배우는 클린 아키텍처 - Tom Hombergs

실습 github: https://github.com/eckrin/clean-architecture