Get Your Hands Dirty on Clean Architecture 2판 소고
Published: 2025-11-09
이 글은 Get Your Hands Dirty on Clean Architecture 2판의 내용에 대해 개인적으로 보충 의견을 작성한 것이다.
헥사고날 아키텍처의 본질: 내부와 외부의 계약
헥사고날 아키텍처가 명확히 하는 영역과 그렇지 않은 영역을 확실하게 인지하는 것이 중요하다.
헥사고날 아키텍처의 근본은 외부 요인이 내부 도메인 로직에 미치는 영향을 최소화하는 데 있다. 이를 위해 포트와 어댑터를 통해 애플리케이션의 안쪽과 바깥쪽의 상호작용에 대한 명확한 계약을 정의한다. 최종 목표는 개발자가 도메인 로직을 개발할 때 육각형 경계 밖의 기술적인 세부 사항들을 신경 쓰지 않도록 하는 것이다.
아키텍처를 바라보는 관점에 따라, 내부와 외부의 구분 및 그 사이의 계약을 강조하면 헥사고날 아키텍처가 되고, 수직적인 계층 구분을 강조하면 계층형 아키텍처가 된다. 근본적인 아이디어는 크게 다르지 않다.
이러한 계약은 인커밍/아웃고잉 포트와, 각 경계에서 통신을 위해 사용하는 데이터 모델(커맨드, DTO 등)들로 구체화된다.
또한, 헥사고날 아키텍처는 육각형 내부, 즉 도메인 영역의 구현 방식에 대해서는 어떠한 제약도 두지 않는다. 도메인 영역을 풍부한 도메인 모델로 개발하든, 트랜잭션 스크립트 패턴을 사용하든 그것은 전적으로 개발자의 선택에 달려 있다.
7장 영속성 어댑터 구현하기에 대한 보충: JPA 사용 시 고려사항
영속성 포트는 도메인 엔티티가 어떻게 영속화될지에 대한 계약으로 볼 수 있다. 이는 에릭 에반스의 도메인 주도 설계(DDD)에서 말하는 애그리거트의 역할과 일맥상통하며(애그리거트의 영속성을 다루는 관문 역할을 수행한다), 스프링 데이터의 리포지토리 개념과도 유사하다.
C++과 같이 ORM 도구가 부족한 언어를 사용하거나, 스프링 데이터 JDBC처럼 계약을 명확히 정의하기 쉬운 도구를 사용할 경우, 아웃고잉 포트를 통한 영속성 계약은 매우 자연스럽게 적용된다. 스프링 데이터 JDBC의 경우, 리포지토리 인터페이스 자체를 아웃고잉 포트로 간주해도 무방하다. 이때 인터페이스의 이름을 ...Repository 대신 ...Port로 명명하는 것은 아키텍처 규칙을 명확히 하는 데 도움이 된다.
문제는 스프링 데이터 JPA를 사용할 때 발생한다. JPA의 영속성 컨텍스트 때문에 JPA 엔티티를 도메인 엔티티로 함께 사용하면 아웃고잉 포트의 계약이 쉽게 무너질 수 있다. 영속성 컨텍스트의 목적은 엔티티 조작을 데이터베이스에 매끄럽게 반영하여 객체-관계의 불일치를 줄이는 것이지만, 이러한 ‘매끄러운’ 동작은 헥사고날 아키텍처의 아웃고잉 포트(또는 DDD의 애그리거트)가 추구하는 명확한 계약 기반의 상호작용과 상충된다.
헥사고날 아키텍처(또는 DDD의 애그리거트)의 원칙에 따라 순수한 계약 기반 아키텍처를 구축하고자 한다면 JPA 사용을 신중히 고려해야 한다. 그럼에도 JPA를 사용해야 한다면, 매핑 비용을 감수하고 도메인 모델과 영속성 모델을 분리하는 것이 바람직하다.
하지만 JPA를 사용하면서 반드시 영속성에 대한 ‘계약’을 엄격하게 강조해야만 할까? DDD의 핵심 목표 중 하나는 도메인 전문가의 정신 모델과 코드 간의 간극을 줄이는 것이다. 애그리거트나 아웃고잉 포트를 통한 명시적 계약이 아니더라도 이 목표를 달성할 수 있다면, 계약을 다소 완화하는 접근도 가능하다.
이 경우 몇 가지 주의할 점이 있다. JPA를 사용한다면, 도메인 모델을 다루는 모든 행위가 인프라스트럭처와의 암묵적인 계약을 이용하는 것임을 인지해야 한다. 복잡한 유스케이스의 경우, 도메인 엔티티에 대한 연산을 무분별하게 조합하기보다, 사용 사례에 맞춰 별도의 메서드로 캡슐화하여 엔티티의 생명주기가 꼬이지 않도록 관리하는 편이 낫다. 즉, 계약을 관리해야 하는 책임의 위치가 포트에서 서비스 계층이나 도메인 모델 자체로 이동하는 것이다.
결론적으로, JPA 엔티티를 도메인 엔티티로 사용한다면 아웃고잉 포트만으로는 계약을 강제할 수 없다. 포트를 통해 계약을 강제하려면 도메인 엔티티와 JPA 엔티티를 분리해야 한다. 포트를 두지 않기로 했다면, 도메인 엔티티(곧 JPA 엔티티)에 대한 모든 조작이 인프라 조작으로 이어질 수 있음을 항상 인지하고 신중하게 다루어야 한다.
개발 속도 향상, SQL 회피, 데이터베이스 교체의 유연성 등을 고려할 때 JPA는 분명 강력한 도구이다. 그러나 JPA를 선택할 경우, 도메인 모델과 영속성 모델 간의 매핑 비용을 감수하지 않는 이상 ‘계약’의 명확성이 희생될 수 있다는 점을 명심해야 한다.
9장 경계 간 매핑에 대한 보충: 포트 생략 시의 매핑 전략
원저 9장에서는 주로 각 경계에 모델이 존재하는 경우의 매핑 전략을 다룬다. 하지만 실무에서는 인커밍 포트나 아웃고잉 포트가 생략되는 경우가 많다. 이 절에서는 포트가 생략된 아키텍처에서의 매핑 흐름과 의존성 문제를 보충하여 설명한다.
원저는 헥사고날 아키텍처를 전제로 하므로, 매핑 전략을 논할 때도 인커밍 포트와 아웃고잉 포트가 모두 존재하는 상태를 기준으로 설명한다.
그러나 실무에서 흔히 발견되는, 포트가 생략된 컨트롤러 -DTO-> 서비스 --> 도메인 모델, 레포지토리 구조를 헥사고날 아키텍처의 관점에서 분석해 보는 것은 원저의 이해를 돕는 데 유용할 것이다.
이하의 논의에서는 ‘레포지토리’를 7장에 대한 보충에서 영속성 계약과 상충될 수 있다고 지적한 스프링 데이터 JPA의 ‘레포지토리’로 취급하며, 이를 사실상 아웃고잉 포트가 생략된 영속성 어댑터의 구현체로 간주한다.
이 구조에서 발생하는 매핑의 흐름과 의존성 문제를 보충하여 설명한다.
가장 단순한 웹 애플리케이션을 가정해보자. 컨트롤러는 웹 모델(즉, DTO)을 인자로 받아 서비스에 그대로 전달한다. 서비스는 JPA 리포지토리와 JPA 엔티티를 사용하여 웹 모델의 데이터로 엔티티를 조회하거나 생성한다.
이 구조는 원저의 매핑 전략 중 어디에 해당할까? 이 경우는 인커밍 포트와 아웃고잉 포트가 모두 생략된 상태로 볼 수 있다. 웹 모델과 도메인 모델은 존재하지만, 도메인 모델이 곧 영속성 모델의 역할을 겸한다. 이는 인커밍 포트에 대해서는 ‘양방향 매핑 전략’을, 아웃고잉 포트에 대해서는 ‘매핑 없음 전략’을 포트 없이 사용하는 것과 유사하다. 이를 그림으로 표현하면 다음과 같다.

위 그림의 의존성 관계는 다소 부자연스럽다. 컨트롤러가 서비스를 호출할 때 웹 모델을 직접 도메인 모델로 변환해야 하기 때문이다. 이러한 구조는 유효성 검증과 같은 로직이 애플리케이션 계층이 아닌 웹 어댑터로 누수되기 쉽다.
이 문제를 해결하기 위해 의존관계를 다음과 같이 수정할 수 있다.

웹 어댑터가 도메인 모델에 의존하는 대신, 서비스가 웹 어댑터의 모델(웹 모델)에 의존하도록 변경하면 컨트롤러의 책임 문제를 해결할 수 있다. 하지만 이는 웹 어댑터와 애플리케이션 계층 사이에 순환 의존성을 발생시킨다.
이에 대한 한 가지 해결책은 논리적으로 웹 모델을 컨트롤러와 분리하여 독립된 데이터 구조로 간주하는 것이다.

웹 모델은 단순한 유효성 검사 외에는 별다른 행위가 없는 불변 데이터 구조체인 경우가 많다. 따라서 웹 모델을 ‘웹 어댑터에 대한 입력 명세’로 간주하면, 아키텍처 관점에서 큰 문제 없이 이러한 구조를 허용할 수 있다(이러한 분리는 어디까지나 논리적인 것이며, 빌드 시스템 설계를 통해 물리적으로 분리할 필요는 없다. 위와 같은 구조로 개발이 진행되고 있다면, 엄격한 구조 분리가 필요하지 않은 상황일 경우일 가능성이 높기 때문이다).
만일 구조를 보다 더 명확하게 하고자 한다면, 원저의 ‘인커밍 포트 건너뛰기’와 같이, 포트가 없는 상태에서도 전용 커맨드 객체를 도입할 수 있다.
전용 커맨드 객체
컨트롤러가 서비스를 호출할 때 웹 모델 대신 서비스 전용 커맨드를 사용하게 하면, 서비스는 더 이상 웹 모델에 의존하지 않아도 된다. 그러나 인커밍 포트가 없는 상태에서 커맨드 객체만 사용하는 것은 계약의 주체인 포트가 부재한 것이므로, 헥사고날 아키텍처의 본래 의의를 상당 부분 퇴색시킬 수 있다. 커맨드는 계약의 일부일 뿐, 계약 그 자체가 아니기 때문이다.
웹 컨트롤러가 다루는 데이터는 원시 타입인 경우가 많아 서비스와 모델을 공유하는 것이 자연스러울 수 있다. 하지만 특정한 경우에는 모델 공유 자체가 부자연스러울 수 있다. 예를 들어, C++ 라이브러리를 개발할 경우에는 이식성을 위해 최종 인터페이스를 C 스타일 함수로 제공해야 할 수 있다. 이 때에는 커맨드 객체와 같은 명확한 데이터 구조를 정의하는 것이 매우 합리적이다. 커맨드는 반드시 특정 클래스나 구조체로 존재할 필요는 없다. 인커밍 어댑터의 모델이 다음 계층으로 전달되기 전에 ’다음 계층이 요구하는 형태‘로 항상 변환된다면, 이는 논리적으로 커맨드 계층이 존재하는 것과 같다. 예를 들어, char*를 std::string으로 변환하는 과정도 인커밍 어댑터 모델을 커맨드로 변환하는 행위로 볼 수 있다.
앞서 7장 영속성 어댑터 구현하기에 대한 보충에서 논한 바와 같이, JPA로 헥사고날 아키텍처를 원칙적으로 구현하고자 할 경우 도메인 모델과 영속성 모델의 분리는 필수적이다. 이 경우 원저의 ‘양방향 매핑 전략’을 따르는 것이 좋다. 그러나 매핑 비용을 감수하기 어렵다면, 아웃고잉 포트의 계약은 포기하되 포트 자체를 생략하고 도메인 모델과 영속성 모델을 일치시키는 방안을 고려해볼 수 있다. 이 경우, 아웃고잉 포트가 담당해야 하는 역할인 영속성에 대한 계약은 도메인 모델과 영속성 어댑터의 조합으로 애플리케이션 계층이 다루게 되므로, 논리적인 구조에서도 다음과 같이 영속성 어댑터를 애플리케이션 계층에 포함시킬 수 있다.

13장 여러 컨텍스트 관리하기에 대한 보충: 도메인 내부의 계층화
헥사고날 아키텍처의 주된 목적은 내부와 외부를 계약으로 분리하는 것이다. 따라서 육각형 내부의 도메인 영역을 어떻게 구성할지에 대해서는 특별히 규정하지 않는다.
하지만 외부 요소를 신경 쓰지 않아도 되므로, 도메인 내부에서는 기능 단위로 수직 분할(버티컬 슬라이스)을 적용하기가 매우 용이하다. 각 슬라이스는 특정 유스케이스와 관련된 서비스와 도메인 모델을 중심으로 명확하고 단순하게 구성할 수 있다.
내부 슬라이스 간의 연관 관계를 구성할 때도 올바른 의존성 관리는 필수적이다. 그러나 이때는 외부 구성요소를 다룰 때처럼 의존성 역전 원칙(DIP)을 강박적으로 적용할 필요는 없다. 같은 컨텍스트 내의 도메인 간 관계에서는, 핵심 도메인이 변경되면 부차 도메인이 영향을 받는 것이 자연스럽기 때문이다. 단, C++과 같이 컴파일 의존성이 중요한 언어에서는 주의가 필요하다. Pimpl 관용구 등을 적용하지 않으면, 핵심 도메인 구현부의 변경만으로도 부차 도메인 전체의 재컴파일이 필요해져 빌드 시간이 급증하는 부작용이 발생할 수 있다.
내부 도메인 간에도 순환 의존성이 발생할 수 있다. 이 경우 ‘의존성 역전’이나 ‘도메인 이벤트’과 같은 방법을 통한 분리를 고려할 수 있다. 같은 컨텍스트 내라면 ‘의존성 역전’ 정도의 대응이 권장된다. ‘도메인 이벤트’와 같은 이벤트 기반 방식은 애플리케이션의 규모가 크고, 이벤트를 발행하는 쪽과 구독하는 쪽이 서로 다른 컨텍스트에 속할 때(팀이 다른 경우 등) 사용하는 것이 효과적이다.
예를 들어, VehicleSecurityService가 암호화 기능을 제공하는 CryptoService를 사용한다고 가정해보자. 두 서비스는 거의 독립적이며, VehicleSecurityService가 CryptoService를 has-a 관계로 소유한다. 각 서비스는 자신만의 도메인 모델을 가지며, 필요한 인커밍 포트와 아웃고잉 포트를 구현하거나 사용한다. 이러한 관계는 일반적인 라이브러리 의존성 관리와 다르지 않다. 빌드 시스템(예: CMake의 target_link_libraries)을 통해 VehicleSecurityService가 CryptoService를 private 의존성으로 갖도록 설정하면 충분하다.
DI 프레임워크가 없는 환경에서 헥사고날 아키텍처를 적용할 때는, 최종적으로 최상단 설정 계층에서 주요 객체를 조립해야 한다는 점을 잊지 말아야 한다.
6장 웹 어댑터 구현하기에 대한 보충: UI 아키텍처 패턴과 어댑터
원저 6장의 웹소켓 예시에 더하여, UI 아키텍처 패턴인 MVC, MVP, MVVM이 헥사고날 아키텍처의 어댑터와 어떻게 관련되는지 설명한다.
웹소켓뿐만 아니라 전통적인 UI 아키텍처 패턴도 인커밍/아웃고잉 어댑터의 좋은 예시가 될 수 있다.
- MVC (Model-View-Controller): UI 관점에서 MVC 패턴은 서버 사이드 렌더링(SSR) 환경에서 자주 사용된다. 사용자의 요청(Request)을 받아 처리하고 완성된 뷰(HTML)를 응답(Response)으로 돌려주는 과정은 인커밍 어댑터의 역할과 정확히 일치한다.
- MVP (Model-View-Presenter) / MVVM (Model-View-ViewModel): 이 패턴들은 클라이언트 사이드 렌더링(CSR)이나 네이티브 앱 환경에서 주로 사용된다. 뷰는 사용자의 입력을 받아 프레젠터/뷰모델에 전달하고(인커밍), 프레젠터/뷰모델은 모델의 변경 사항을 다시 뷰에 전달하여 화면을 갱신한다(아웃고잉). 이 양방향 상호작용은 인커밍 어댑터와 아웃고잉 어댑터를 모두 활용하는 구조로 볼 수 있다.
즉, 클라이언트(UI)와 서버가 물리적으로 분리되어 요청-응답 방식의 통신이 주가 되는 환경에서는 인커밍 어댑터 중심의 MVC가, 지속적인 연결을 통해 양방향 통신이 용이한 환경에서는 인커밍/아웃고잉 어댑터를 모두 사용하는 MVP/MVVM과 같은 패턴이 자연스럽게 적용될 수 있다.
10장 애플리케이션 조립하기에 대한 보충: 팩토리 패턴의 역할
팩토리 패턴이 도메인 로직 내에서 목적 없이 남용되는 경우가 있다. DI를 위해 팩토리 패턴을 사용하는 것은 자연스럽지만, 도메인 내부에서는 상속을 통한 다형성보다 다른 방식으로 문제를 해결할 수도 있다(상속보다 조합을 사용하라는 범용 조언을 참고하라). 도메인 내부에서 팩토리 패턴을 사용한다면, 이는 의존성 역전보다는 복잡한 객체 생성 로직을 캡슐화하는 목적일 가능성이 높다.