본문 바로가기

웹 개발 (Spring Boot)

레이어 아키텍처에서 클린 아키텍처 + DDD로 리팩토링하기 (1)

   원래 사이드 프로젝트 내에서 레이어 아키텍처를 사용하다가 클린 아키텍처로 리팩토링을 하면서 DDD(Domain-Driven Development)를 더욱 정확히 적용하게 되었는데, 이와 관련하여 경험한 바를 정리하고자 한다. 우선 레이어 아키텍처의 특징과 장점을 일반론 차원에서 제시하면서 우리 프로젝트에서 이들을 어떻게 다루었는지를 개략적으로 덧붙여보겠다(전부 제시한다기보다는 이 포스팅에서 중점적으로 다루고자 하는 부분만 적겠다).


레이어 아키텍처(Layer Architecture)

ㅡ 특징

 

1. 서버에서는 주로 애플리케이션 / 도메인 / 인프라 계층을 다룬다.

- 프레젠테이션 계층이 있기는 하나 서버 외부를 의미하는 것으로 알아서 과감히 생략한다.

- 레이어 아키텍처에 매퍼 계층이 포함되지는 않는 것으로 알지만, 계층 간 역할의 더욱 명확한 분리를 위해 이 계층 또한 고려 대상이었다.

 

2. 각 계층은 주로 컨트롤러 / 서비스 / 리포지토리를 코어 구성 요소로 삼는다.

- 컨트롤러는 REST API로 구현했다.

 

ㅡ 장점

 

1. 계층이 단순하다.

- 애플리케이션 계층에서 모든 요청과 응답 처리를 다루고, 도메인 계층에서 모든 비즈니스 로직을 집약하며, 인프라 계층에서 DB와의 상호 작용을 전적으로 담당한다.

 

2. 친숙하고 널리 사용된다.

- 우리 팀원은 이미 이 계층에 익숙했고, 다른 아키텍처는 잘 알지 못했다. 예전에 개발 초창기에는 모두에게 익숙한 방식을 위주로 개발을 진행했기 때문에, 레이어 아키텍처가 이러한 점에서 우위에 있을 수 있었다.

 

3. 소규모 프로젝트에서 적합하다.

- 그 당시 프로젝트는 규모가 작았고, 이미 개발된 기능 또는 추후 개발이 예정된 기능이 그리 많지 않았다. 굳이 복잡한 아키텍처를 도입할 필요 없이 레이어 아키텍처를 사용하더라도 목표를 충분히 달성할 수 있을 것으로 판단했다.

 

하지만,

 

   개발을 하면서 이 아키텍처에 대한 여러 가지 단점들(특히 DDD와 접목하는 과정에서의 단점들)이 도출되었고, 이러한 점으로 말미암아 클린 아키텍처로 전환하는 계기를 마련할 수 있었다. 단점은 아래와 같다.


ㅡ 단점

 

1. 하나의 덩어리로서의 백엔드 코드베이스

 

- 예를 들어 게시글을 다루는 서비스가 있다고 하자. 여기서 특정 회원이 게시글을 변경할 수 있도록 하는 메소드를 제공한다고 하자. 그렇다면, SRP에 따라서 서비스를 기능별로 잘게 나눌 때, 아마 이 서비스는 다음의 서비스로부터 의존성을 주입받을 필요가 있을 것 같다.

 

댓글 서비스: (게시글에 딸린) 댓글을 관리하는 서비스

회원 서비스: (게시글을 작성하는) 회원을 관리하는 서비스

좋아요 서비스: (게시글에 대한) 좋아요를 관리하는 서비스

북마크 서비스: (게시글에 대한) 북마크를 관리하는 서비스

JWT 서비스: (클라이언트가 보낸) API에 특정한 토큰을 인증하고 처리하기 위한 서비스

... 그리고 필요하다면, DB로의 직접적인 접근을 위해 각각의 서비스에 대하여 딸린 리포지토리 등을 사용할 수도 있을 것이다.

 

   이런 식으로, 각각의 API 호출에 대한 워크플로우에 대해서는 서비스가 계층을 이룬다고 하더라도(예로, 위의 경우에서는 게시글 서비스가 다른 서비스를 호출하는 관계가 성립한다) 서비스 서로 간에 역으로 다른 서비스가 필요한 경우가 언제든지 생길 수 있을 테고(예로, 회원 서비스에서 지금까지 작성한 게시글의 수를 확인하기 위해 게시글 서비스의 의존성을 받을 수도 있을 것이다), 이는 서비스 간 의존성의(서비스 단위가 아닌) "메소드 단위" 계층을 형성하게 된다. 결국 이는 각 서비스를 기준으로 본다면 하나의 서비스가(꼭 전부는 아닐지라도) 그 다른 많은 서비스와 참조 연관관계(HAS-A 관계)가 생기는 상황을 낳고, 이들을 서로 독립적으로 바라보는 건 불가능하게 된다.

 

   이렇게 되면 여러 단점이 있을 수 있을 텐데, 우선 다음과 같은 가정이 필요하다: 도메인 클래스의 사용을 서비스 계층 내에서 강제하며, 서비스 내에서 로직의 캡슐화가 이루어져 있는 경우. 이때는 매퍼 계층을 도입하는 게 분명 관심사의 분리 측면에서 도움은 되겠지만, 매퍼 계층에서의 관리 문제와 성능상 오버헤드가 시간이 흐름에 따라 더욱 극심해질 것으로 예상되었다. 한 서비스에서 다른 서비스 또는 리포지토리에 접근하기 위해서는 도메인 클래스 <-> 도메인 클래스 또는 도메인 클래스 <-> 엔터티 클래스(JPA 기능을 사용할 경우) 간 변환이 필요할 것이고, 연관되는 서비스의 쌍 또는 서비스 - 리포지토리의 쌍마다 매핑을 위한 메소드를 만들어야 하므로 번거롭고 반복적인 작업을 개발 내내 이어가야 할 것이었다.

 

   게다가 서비스 간 뿐만 아니라, 매퍼 계층 내에서도 매핑에 따른 오버헤드가 반드시 포함되어야 할 수밖에 없다고 판단했다(특히 도메인 클래스를 통해 데이터의 변경을 수행하려고 할 때는). 왜냐하면 식별자를 기준으로 도메인 클래스에 해당하는 엔터티 클래스를 트랜잭션에서 한 번이라도 사용하면 영속성 컨텍스트에 해당 엔터티가 저장될 것인데, 무턱대고 같은 식별자를 갖는 엔터티 클래스를 새로 만들어서 이를 저장하는 것을 시도한다면 분명히 충돌로 인해 예외가 터질 것이기 때문이다. 매퍼는(Spring Data JPA 적용 시) 엔터티 클래스를 findBy 메소드를 통해 가져온 다음에 이 엔터티의 값을 일일이 매핑하고, 다시 도메인 클래스로 변환해야 한다. 필드 하나를 바꾸더라도 코드의 재사용성을 확보하려면 모든 필드를 매핑해야 한다는 점에서 이러한 방식의 구현은 Spring Data JPA의 더티 체킹의 이점을 전혀 발휘하지 못하고, 이래저래 불편한 상황만 자꾸 늘어나는 것으로 여겨져서 도메인 클래스의 사용을 강제하는 것은 단점이 크다는 결론에 이르렀다.

 

2. 도메인 클래스를 없앰에 따른 비즈니스 로직과 그렇지 않은 로직의 혼재

 

- 아무리 봐도 도메인 클래스는 작은 규모의 프로젝트에서는 안 쓰는 것이 타당했다. 그래서 팀 내에서 위와 같은 논의를 거쳐 해당 클래스를 버리고 Request, Response를 대표하는 클래스 또는 DTO를 통해 인프라 계층과 직접 상호 작용하는 것으로 방향성을 정했다. 이제 다음과 같은 논제가 두드러질 차례였다: 서비스 계층은 진정으로 비즈니스 로직 처리 계층으로서의 역할을 수행하고 있는가?

 

   위와 비슷한 시기에 서비스는 크게 다음과 같이 두 가지로 양분되었다: 애플리케이션 서비스, 도메인 서비스(사실 도메인 서비스는 검증 서비스만을 위한 구조에 가까웠으므로, 이후에는 이를 검증 서비스로 부르기로 한다). 구조를 간단하게 말하자면 애플리케이션 서비스는 검증 서비스와 리포지토리의 호출을 오케스트레이션했다. 이를 통해 서비스를 이루던 사실상 전부인 두 기능을 명확히 재사용할 수 있는 구성 요소들로 분리할 수 있었고, 이 점까지는 좋았다. 하지만 우리는 곧 '과연 검증이 도메인의 전부일까? 또는, 검증 그 자체만으로 도메인이라 할 수 있을까?'라는 문제에 직면했다. 검증이 비즈니스 로직의 일부라는 것까지는 분명한 사실이었다. 하지만, 우리는 어떤 클래스 또는 데이터를 검증하고 있어서, 즉 검증되는 주체가 무엇이어서 이 검증 과정 그 자체를 온전한 구성 요소로서의 도메인, 즉 "비즈니스 로직"이라고 부를 수 있는 것인가? 이 점이 애매했다.

 

   또 다른 문제도 있었다. "캡슐화된 비즈니스 로직"이라고 하면 당연히 비즈니스에 필요한 로직만 다른 부차적인 로직과 분리되어 캡슐화되어야 한다고 생각했다. 하지만 서비스 계층에서 이러한 작업을 수행했다고 자신있게 말하기에는, 딱히 비즈니스 로직으로 볼 수 없는 CRUD 작업에 관한 메소드가 서비스 계층 내부 메소드에 너무도 많이 섞여 있었다. 여기서는 유비쿼터스 언어를 도입하려고 할 때 발생하는 문제가 연관이 있었다. Spring Data JPA는 리포지토리 메소드의 네이밍에 따라서 내부적으로 SQL을 만들어주는데, 유비쿼터스 언어를 도입하려면 이 메소드의 이름이 변경되어야 했기 때문이다. IDD(Interface Driven Development)를 통해 해결하기에는 단순히 인터페이스 상속으로 해결하자니 메소드명을 바꿀 수 없어 의미가 없었고, JpaRepository를 상속하는 인터페이스에 @NoRepositoryBean을 붙이고 이 두 가지(상위 인터페이스와 JpaRepository를 상속하는 인터페이스)를 연결하는 인터페이스를 사용해서 해결하자니 그 의의에 비해 작업량이 너무 늘어났다.

 

   기타 문제로는 레이어 아키텍처에서는 전역으로 사용되는 패키지에 대한 구조를 어떻게 잡아야 하는지 알려주지 않는다는 점, 재사용성이 없는 메소드의 추가로 인한 서비스 클래스의 지나친 확장이 식별되었으며, 끝으로 프로젝트 구조 전체에 IDD를 사용할 충분한 이유를 레이어 아키텍처가 제공하지 않는다는 점도 필자는 고민거리로 지목했다. IDD는 OCP(Open-Closed Principle)를 구현하는 핵심 수단이라고 생각했는데, 다른 이유 없이 인터페이스 상속 하나만 보고 각각의 서비스 혹은 리포지토리마다 인터페이스를 만들기에는 어딘가 부족하다는 인식이 있었다. 


   이런 문제를 종합하였고, 다행히 이들을 해결할 수 있는 아키텍처가 무엇이 있을지 알아볼 시간이 좀 있어 조사를 해봤다. 그렇게 두 가지의 굵직한 아키텍처ㅡ클린 아키텍처와 헥사고날 아키텍처를 알아볼 수 있었고, 결론적으로는 클린 아키텍처에 DDD를 접목하는 방향으로 정할 수 있었다.

 

   글이 길어져서 이번 포스팅은 이쯤 마치고, 다음 포스팅에서부터는 헥사고날 아키텍처 대신 클린 아키텍처를 도입한 이유와 클린 아키텍처를 통해 위에 제시된 문제점이 어떻게 해소되었는지, 그리고 DDD는 어떤 식으로 클린 아키텍처와 조화되었는지를 설명하겠다.