계층형 아키텍처는 웹 계층, 도메인 계층, 영속성 계층으로 구성된 전통적인 웹 애플리케이션 구조로, 아마 가장 많이 경험해 본 프로젝트 구조일 것입니다.

맨 위의 웹 계층에서는 요청을 받아 도메인 혹은 비즈니스 계층에 있는 서비스로 요청을 보내고, 서비스에서는 필요한 비즈니스 로직을 수행하고, 도메인 엔티티의 현재 상태를 조회하거나 변경하기 위해 영속성 계층의 컴포넌트를 호출합니다.

계층형 아키텍처는 사실 견고한 아키텍처 패턴으로, 계층을 잘 이해하고 구성한다면 웹 계층이나 영속성 계층에 독립적으로 도메인 로직을 작성할 수 있습니다. 하지만, 계층형 아키텍처는 코드에 나쁜 습관들이 스며들기 쉽게 만들고 시간이 지날수록 소프트웨어를 점점 더 변경하기 어렵게 만드는 수많은 허점을 노출합니다.

계층형 아키텍처의 문제는 다음과 같은 문제들을 야기합니다.

데이터 베이스 주도 설계를 유도한다

우리는 보통 비즈니스를 관장하는 규칙이나 정책을 반영한 모델을 만들 때, 상태가 아닌 행동을 중심으로 모델링을 합니다. 행동이 비즈니스를 이끌어가기 때문이죠. 따라서 비즈니스 관점에서 도메인 로직을 먼저 만든 후 해당 로직이 맞는지 검증한 후 이를 기반으로 다른 계층을 만들어야 합니다. 하지만 전통적인 계층형 아키텍처에서는 도메인 로직이 아닌 데이터베이스를 토대로 아키텍처를 만들게 됩니다. 이렇게 되면 서비스가 도메인 로직뿐만 아니라 영속성 계층 관련 작업들까지 맡게 되며, 이는 영속성 계층과 도메인 계층 간 강한 결합을 유발합니다. 즉 계층형 아키텍처의 목표인 유연함과는 거리가 멀어지겠죠.

영속성 계층이 비대해질 수 있다

계층형 아키텍처의 유일한 규칙은, 특정한 규칙에서는 같은 계층에 있는 컴포넌트나 아래에 있는 계층에만 접근 가능하다는 것입니다. 그러다 보니 하위 계층에 위치한 컴포넌트가 상위 컴포넌트에 접근해야 한다면, 쉽게 상위 컴포넌트를 하위로 내려서 문제를 해결할 수 있습니다. 그런데 이렇게 되면 하위 계층이 점점 비대해지게 됩니다. 특히 헬퍼 컴포넌트나 유틸리티 컴포넌트들이 아래 계층으로 내려갈 가능성이 높습니다. 이렇게 비대해진 계층은 이해하기 어렵고 유지 보수하기 어렵게 만듭니다.

테스트하기 어렵게 만든다

계층형 아키텍처에서는 계층을 건너뛰기 쉽습니다. 계층을 건너뛰어서 하위 계층에 의존할 경우 여러 가지 문제가 발생할 수 있습니다. 먼저 도메인 로직이 퍼져나갈 확률이 높습니다. 만약 도메인 로직이 웹 계층에서 구현된다면 애플리케이션 전반에 걸쳐 책임이 섞이고 도메인 로직이 퍼져나갈 것입니다. 두 번째로 단위 테스트의 복잡도가 증가합니다. 왜냐하면 의존하는 하위 계층에 대해서도 Mocking 하거나 테스트를 준비하기 위한 코드들이 더 필요해지기 때문입니다.

유즈 케이스를 숨긴다

항상 새로운 유스케이스만 만들 수 있다면 좋겠지만, 결국 기존의 유스케이스를 변경하고 유지 보수하는 일이 훨씬 많습니다. 기능을 추가하거나 변경할 적절한 위치를 찾아야 할 일이 많다는 뜻이죠. 아키텍처는 코드를 빠르게 탐색하는 데 도움이 돼야 하지만, 계층형 아키텍처에서는 쉽지 않습니다.

앞서 언급한 것처럼 계층형 아키텍처에서는 도메인 로직이 여러 계층에 걸쳐 흩어져있을 수 있습니다. 이럴 경우 새로운 기능을 추가해야 할 때 적절한 위치를 찾기 어려워집니다. 심지어 많은 영속성 계층에 의존성을 갖는 넓은 서비스가 존재할 수도 있고, 이 넓은 서비스를 웹 레이어의 많은 컴포넌트에서 의존하게 됩니다.

고도로 특화된 좁은 도메인 서비스가 유스케이스 하나씩만 담당하게 한다면 이런 작업들이 수월해집니다. 예를 들어 UserService에서 사용자 등록, 수정, 탈퇴 등 사용자 관련 모든 유스케이스를 담당하는 경우와 UserRegisterService, UserUpdateService 같은 특정 케이스만 담당하는 경우가 있다면 후자가 확실히 작업하기 수월할 것입니다.

동시작업을 어렵게 만든다

코드에 넓은 서비스가 있다면, 서로 다른 기능을 동시에 작업하는 것이 어려워집니다. UserService가 사용자 등록, 수정, 탈퇴 등 사용자 관련 모든 유스케이스를 담당하고 있는 경우에 각 기능을 서로 다른 개발자들이 맡고 있다면, 모두 UserService에서 작업을 해야 할 것입니다. 작업이 끝나면 ‘병합 충돌’이 필연적으로 발생할 것이며, 이전 코드로 되돌려야 하는 문제가 발생할 수도 있습니다. 그래서 위에서 언급한 것처럼 UserRegisterService, UserUpdateService 같은 특정 케이스만 담당하는 경우로 나누어서 작업하기 쉽도록 할 수 있습니다.