본문 바로가기

웹 개발 (Spring Boot)

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

   이전 포스팅(https://akdnjs0308.tistory.com/18)에서 내용을 이어가 보자.

 

   이 과정에서 집중적으로 고려하던 아키텍처는 총 두 가지였다: 헥사고날 아키텍처와 클린 아키텍처. 이번에도 각각에 대한 일반적인 특징부터 제시해볼까 한다. 필자가 알아보고 이해한 바를 정리한 것이므로 부족하거나 잘못된 부분이 있을 수 있음을 양해 바라며, 일부러 DDD에 관한 내용은 제거하였다(이에 관해서는 후술하겠다):


헥사고날 아키텍처(Hexagonal Architecture)

 

1. 주로 다음과 같은 구성 요소로 이루어진다(가장 안쪽 계층에 위치한 구성 요소 순): 코어, 포트, 어댑터 + 애플리케이션 서비스.

- 코어는 도메인으로 볼 수 있고, 포트는 인터페이스, 어댑터는 구현체이다.

- 애플리케이션 서비스는 인바운드 흐름에서 코어 및 포트 계층을 오케스트레이션한다.

 

2. 이를 바탕으로 개발할 때 아키텍처로부터 얻는 주요한 이점은 다음과 같다.

- 코어 계층(필수적인 비즈니스 로직)을 다른 계층으로부터 분리하여 캡슐화 및 모듈러성을 확보한다.

- 포트를 통한 IDD 방식의 구현을 통해 OCP와 DIP 원칙을 준수한다.

- 오케스트레이션을 위한 전용 구성 요소를 생성함으로써 중앙집중화된 접근 제어를 가능하게 한다.


클린 아키텍처(Clean Architecture)

 

1. 주로 다음과 같은 구성 요소로 이루어진다(가장 안쪽의 계층 순): 엔터티, 유즈 케이스, 인터페이스 어댑터, 프레임워크.

- 여기서의 엔터티 또한 도메인으로 볼 수 있고, 유즈 케이스는 인터페이스, 인터페이스 어댑터는 오케스트레이션 계층, 프레임워크는 구현체로 볼 수 있다.

 

이를 개발 측면에서 헥사고날과 비교하자면:

- 포트 대신 유즈 케이스라는 명칭을 쓰지만 내부적인 프로젝트 구조까지 고려하면 결국 이 두 구성 요소 모두 IDD를 위해 사용된다는 점에서 크게 다른 점이 없다.

- 프레임워크 계층도 기본적으로는 헥사고날의 어댑터와 크게 다르지 않지만, 프레임워크 간 독립을 강조한다는 면에서 새로운 의미가 더욱 분명하게 부여되었다고 할 수 있다.

- 애플리케이션 "서비스"는 이제 인터페이스 어댑터 "계층"으로 자리 잡아서, 계층 간 기능 구분이 더욱 확실하게 강조된다고 볼 수 있다.

 

2. 이를 바탕으로 개발을 할 때 헥사고날을 적용할 때 얻는 이점에 더하여 추가적으로 기대할 수 있는 주요한 이점은 다음과 같다.

- 각 프레임워크마다 독립적으로 관리함으로써 서로 다른 의존성이 체계 없이 혼재되는 상황을 예방할 수 있고, 이는 의존성 관리 및 대체에 좀 더 유리한 환경을 조성한다.

- REST API가 인바운드 포트 대신 애플리케이션 서비스(헥사고날에서는 이렇게 부르지만 클린에서는 컨트롤러라고 부른다. Spring MVC의 그 컨트롤러를 생각하면 안 된다!)와 연관 관계를 맺는 것을 허용한다.


   필자는 처음부터 헥사고날 대신 클린을 눈여겨봤다. 왜냐하면 클린 아키텍처는 헥사고날(2005) 외에도 어니언 아키텍처(2008)에 영감을 받아 이들의 개념을 통합하며 확립(2012)되었기 때문이다. 어니언 아키텍처는 이 포스팅에서 자세히 다루지는 않으려 하지만, 핵심만 말하자면 헥사고날 아키텍처에 DDD 개념과 계층 간 의존성 방향에 대한 강조를 추가한 아키텍처로 볼 수 있다. 우리는 DDD를 적용하려는 생각이 일찍이 있었기 때문에 자연히 클린에 더 눈이 갈 수밖에 없었다.

 

   이제 클린 아키텍처가 이전 포스팅에서 제시했던 문제를 어떻게 해결했는지로 나아가기 전 단 하나의 논제만을 남겨 두고 있다. DDD를 왜 쓰려고 했는지다. DDD는 요즘 들어서는 사용해야 하냐 말아야 하냐로 설왕설래하는 개발 방법론인 듯하기는 하지만, 필자는 팀 내에서 판단해서 필요하면 쓰면 그만이라는 주의라서 크게 신경을 쓰지는 않았다. 그리고 개발을 하면서 느끼게 된 건, 토론을 통해 장단점을 아무리 파악해 보려 해 봐야 직접 적용을 해보는 것만큼 와닿지는 않는다는 것이다. 충분히 토론했다면 그 결과를 바탕으로 결정한 사항을 적용해 보면 그만이다. 이게 현 사이드 프로젝트의 백엔드 팀장으로서 필자가 갖는 사고방식이다.

 

   어쨌든 다시 DDD로 돌아와 보자. DDD는 세부적으로 들어가면 복잡하고 깊은 고민을 안겨 주지만, 우리 사이드 프로젝트에서는 DDD의 핵심 개념에 대한 논의 끝에 대략 다음과 같은 공통 이해를 확립할 수 있었다.


도메인 주도 개발(Domain-Driven Development, 약칭  DDD)

 

1. 종합체를 바탕으로 하는 도메인 객체의 완전한 캡슐화와 속박된 맥락을 통한 이들 간 구분.

 

2. 유비쿼터스 언어를 통한 비즈니스 로직의 의미론적으로 더욱 올바른 표현.

 

3. 코드적으로 담당자를 확실하게 구분함으로써 특정 도메인에 대한 높은 이해를 확립하도록 도모.


   필자는 백엔드 팀장 이전에 팀장으로서, 우리 사이드 프로젝트가 추구하는 바를 고려할 때, 코드에서도 우리 사이드 프로젝트가 어떤 대상을 다루는지, 그리고 이 대상들이 어떤 작업을 하고 있는지를 쉽게 확인할 수 있어야 한다고 믿었다. 백엔드 팀이라고 해서 개발에만 몰두하는 게 아니라 자신의 코드가 우리가 하는 일에서 어떤 의미를 갖는지, 그리고 반대로 우리가 하는 일을 정확히 대표하기 위해 우리는 코드적으로 어떤 대상을 다뤄야 하고, 이들 간에 관계는 어떻게 설정할지, 누가 이들을 담당할지 등 말 그대로 DDD적인 측면에서 개발을 하기를 원했다.

 

   이를 위해서 필자는 여러 "재료"를 활용할 필요가 있었고, 그중 하나가 "단순히 데이터를 보관하기 위한 멍청한 데이터 홀더"가 아닌 비즈니스적으로 의미가 있는 도메인 객체였다. DDD가 제시하는 종합체와 속박된 맥락이라는 개념은 이러한 배경에 안성맞춤이었고, 이 종합체를 적절히 다룸으로써 백엔드 팀원이 자연스럽게 의미 그대로의 "비즈니스 로직"을 다루기를 희망하였다.

 

   유비쿼터스 언어도 위와 같은 맥락에서 대단히 의미 있게 본 DDD 하위 개념이었다. 누군가에게는 단순히 필드나 메소드의 네이밍을 다르게 하는 데 불과하다고 여길지 모르겠지만, 필자에게 registerMember()와 saveMember()는 분명히 다르고, updateProfile()과 overrideProfile()은 분명히 다르다. 물론 유비쿼터스 언어는 비개발자도 쉽게 이해할 수 있는 언어라는 점에서 팀의 모든 구성원이 백엔드적으로 어떤 작업을 수행하는지 더욱 분명히 이해할 수 있도록 돕는다는 데 기본적인 의의를 찾을 수 있지만, 백엔드 팀원 스스로도 기획적으로 요구한 사항을 빠짐없이, 순서에 맞게 실행하고 있는지 가독성 있게 볼 수 있다는 점에서 또 다른 의미를 발견할 수 있다.

 

   끝으로, 필자는 담당자라는 개념을 상당히 중요하게 여긴다. 만약 담당자가 분명히 정해지지 않은 채로 어떤 기능을 구현한다면 나중에 이를 리팩토링할 필요가 있을 때 매번, 일일이 누가 하는 게 좋을지 논의하고 정해야 할 것이다. 또한 이 기능에 문제가 생겨도 누가 이를 처리해야 할지 애매해질 것이다. 담당자를 기능의 일정한 범주로 정확히 구분해야 무언가 일이 생겼을 때 그 일이 누구의 소관인지 굳이 말하지 않아도 알 수 있게 되고, 자신이 그러한 범주를 책임진다는 책임감도 더욱 강하게 심어진다. 속박된 맥락은 그 자체로 이 "범주"를 구분하기에 적합하며, 도메인에 걸친 전역 사항만 논의를 거쳐 알맞게 팀원에게 분배하면 백로그 관리도 훨씬 편해진다는 이점을 제공한다.


   이러한 생각을 바탕으로 팀원을 설득하는 데 성공하여, 우리는 클린 아키텍처와 DDD를 동시에 사용하기로 합의했다. 이제 클린 아키텍처가 어떻게 우리의 문제를 해결할 수 있었는지 말할 차례다.

 

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

 

   이 문제는 속박된 맥락으로 덩어리를 잘게 쪼개고 의존성 방향성을 확립함으로써 어느 정도 해결할 수 있었다. 일부 예외적인 경우를 제외한다면 속박된 맥락에서는 다른 속박된 맥락과 교류할 수 없다는 원칙이 있기 때문에 레이어 아키텍처에서와 같은 대량의 서비스 의존성 주입은 발생하지 않는다. 전역으로 사용하는 서비스 의존성을 받을 수는 있지만, 그러한 서비스는 속박된 맥락에서 무엇이 관리되고 있는지 전혀 알지 못하기 때문에 계층 간 의존성 또한 항상 단방향으로 구성할 수 있다. 

 

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

 

   이제 각각의 비즈니스 로직은 도메인 계층(원래 클린 아키텍처에서는 엔터티 계층으로 부르지만, Spring Data JPA의 엔터티 클래스와 이름이 겹치기도 하고 DDD의 엔터티 클래스와도 이름이 충돌하여 도메인 계층으로 부르기로 했다. 사실 유즈 케이스도 도메인에 속한다고 볼 수 있지만, 그렇다고 엔터티 계층과 유즈 케이스 계층을 합치기도 애매하고 달리 더 좋은 네이밍이 있는지도 잘 모르겠다)과 유즈 케이스 계층을 통해 분명히 표현되고, 또 관리된다. 아무리 CRUD 작업이라고 할지라도 우리는 유비쿼터스 언어를 적용하여 이러한 작업이 비즈니스적으로도 의미 있게 표현되고 의미에 따라 독립적으로 사용될 수 있도록 관리할 수 있으며, 컨트롤러는 단순히 도메인 계층과 유즈 케이스를 오케스트레이션하는 역할을 넘어, 특정한 프로세스에 비즈니스적으로 어떤 과정이 수반되는지를 좀 더 명료하게 보여줄 수 있다.

 

3. 기타 문제

 

   우리는 클린 아키텍처를 전역적으로 사용되는 기능에도 나름대로 적용할 수 있었다. 속박된 맥락에 걸쳐 공유될 수 있는 데이터를 흔히 공유되는 커널(Shared Kernel)이라고 부르는데, 우리는 이 점에 착안하여 전역으로 사용되는 POJO를 담는 패키지명으로서의 shared의 의미를 더욱 분명히 할 수 있었다(사실 shared라는 네이밍을 사용하자고 결정할 때까지만 해도 이러한 생각까지 하지는 못했지만, 나중에 공유되는 커널을 어디에 둘지 고민하다 shared라는 네이밍에 이러한 의미를 새로 부여할 수 있었다). 또한 전역으로 사용되면서 특정 프레임워크에 의존하는 기능의 집합으로서의 패키지명은 자연히 framework로 설정할 수 있었고, 여기서 공통의 관심사를 추출하여 따로 infrastructure라는 패키지에 담는 과정까지 부드럽게 수행할 수 있었다. 재사용성이 없는 메소드는 이제 특정한 속박된 맥락 안에서만 존재하므로 특정 서비스가 불필요하고 지나치게 커지는 문제 또한 막을 수 있었다.


   이렇게만 말하면 해피 엔딩이겠지만, 여러 우여곡절이 당연히 뒤따랐다. 결정된 구체적인 사항은 전적으로 우리 프로젝트에 국한되는 내용이라 길게 적을 필요는 없을 것 같다만, 그럼에도 몇 가지만 정리하여 밑에 남긴다.

 

1. 과연 의존성을 완벽히 분리해 낼 수 있을까

   원래 필자가 배우기로, 프레임워크 계층을 제외한 모든 계층에는 외부 의존성이 있으면 안 된다. 하지만 그렇다고 @Service 혹은 @Transactional과 같은 어노테이션을 포기할 수는 없었고, Swagger 관련 어노테이션도 포기할 수 없었다. 그 외에도 Spring Boot의 이점을 활용하면 좋은 경우와 기타 예외적인 경우를 포함하여 우리는 프로젝트에서 각 계층마다 정해진 특수한 외부 의존성을 사용해도 된다는 규칙을 확립하기로 했다.

 

2. 과연 중복 코드를 계속해서 만들어내는 것을 용인해야 할까

   원칙상으로는 속박된 맥락은 서로에 대해 완전히 독립적이어야 한다. 하지만 그렇다고 Spring Data JPA 기능을 사용하는 엔터티 클래스 및 이를 활용하면서 JpaRepository를 상속하는 리포지토리 인터페이스를 각각의 속박된 맥락에 대해서 하나씩 새로 만드는 게 옳은 일일까? 우리 팀은 그건 아닌 것 같다는 데 의견을 모았다. 그래서 결론적으로, 이들 엔터티 클래스와 리포지토리 인터페이스는 전역 패키지에서 가져다 쓰는 것으로 정했다.

 

3. 과연 속박된 맥락의 구조를 완벽하게 통일할 수 있을까

   우리 팀은 속박된 맥락의 구조에 관하여 큰 틀에서 합의를 보았지만, 시간이 지남에 따라 구현 세부 사항까지 통일하는 데 더욱더 큰 어려움을 느꼈다. 예를 들어, REST API에서 @RequestParam으로 매개변수를 하나 받았다고 하자. 그렇다면 우리는 어떻게 이 데이터를 컨트롤러로 보내는 것이 좋을까? 다른 요청 데이터와 묶어서 Record로 보내야 할까, 아니면 Request와 각각의 매개변수를 따로 보내내도 될까? 따로 보낸다면, 이 데이터들은 Java에서 제공하는 타입으로 보내면 그만일까, 아니면 반드시 도메인 객체로 래핑(Wrapping)하여 보내야 할까?

 

   이러한 점들까지 논의하여 구조를 진정으로 하나로 통일한다면 다른 팀원의 도메인을 참고할 때 도움은 되겠지만, 우리가 해야 하는 수많은 일에 비해 이 과정은 중요성도 낮을뿐더러 그 자체로 너무 지엽적인 문제로 인식될 수 있다고 여겼다. 그래서 각자의 구현 방식을 존중하되 시간이 된다면 자신이 어떠한 관점으로 로직을 구성했는지를 공유하고, 피드백 사항이 있다면 그때마다 논의를 거치는 것으로 결론을 내렸다.


   클린 아키텍처로 리팩토링하는 과정은 순탄치 않았고, 개발의 편의성을 위해 원칙 몇 가지는 포기해야만 했으며, 구현의 세부 사항과 관련해서는 지금도 팀원과 논의를 거치고 있다. 그럼에도 이 아키텍처를 적용하면서 해결된 문제도 많고 위에 제시된 것들 외에도 많은 장점을 누리고 있다고 여긴다.

 

   이러한 과정이 우리 프로젝트를 한 단계 발전된 모습으로 이끌었을 것이리라 믿는다.