만들면서 배우는 클린 아키텍처
들어가며
BEAT 프로젝트를 진행하면서, 코드가 점점 처음 설계한 아키텍처에서 멀어지는 걸 실감했다. 😭
특히, 기술 부채가 쌓이면서 계층 간 경계가 흐려지고, 의존성이 복잡하게 얽히는 문제를 자주 마주했다.
앱잼 당시 빠르게 기능을 구현해야 했던 만큼, 하나의 서비스 레이어가 9개의 레포지토리에 의존하는 상황까지 발생했는데, 별다른 조치 없이 개발을 이어가다 보니 코드는 점점 복잡해졌고, 유지보수도 어려워졌다.
결국 처음 설계했던 계층 구조의 원칙은 무너졌고, 수정할수록 의존성이 계속해서 얽히는 악순환이 반복되었다.
그 결과, 특정 비즈니스 로직을 변경할 때마다 불필요한 의존성까지 수정해야 하는 상황이 반복되었고, 이를 통해 모듈 간 경계를 명확히 유지하는 것이 얼마나 중요한지 절실히 깨달았다.
이러한 문제를 해결하기 위해 BEAT 프로젝트에 헥사고날 아키텍처 도입을 고민했지만, 장단점이 분명한 아키텍처이기에 무작정 적용하기에는 신중할 필요가 있었다.
과연, 헥사고날 아키텍처가 BEAT 프로젝트에 적합한 선택일까?
이를 판단하기 위해, 아키텍처의 개념과 적용 방식을 깊이 이해하고자 책을 구매하게 되었다.
이제, 본격적으로 헥사고날 아키텍처가 코드의 구조를 어떻게 개선할 수 있을지 탐구해보자 🚀
1장. 계층형 아키텍처의 문제는 무엇일까?
책에서는 Layered Architecture의 문제점으로 “지름길을 택하기 쉬워진다, 테스트하기 어렵다, 동시 작업이 어렵다” 등을 언급하고 있다. 하지만 코드 예시가 없어서인지, 이러한 문제들이 실제로 어떻게 발생하는지 명확하게 와닿지는 않았다.
개념적으로는 이해되지만, 구체적인 사례가 없어 공감하기 어려웠다.
또한, 이러한 문제들은 팀원 간 레이어 정책을 명확히 정하고, 원활한 커뮤니케이션을 통해 충분히 해결할 수 있는 부분이라고 생각되어 더욱 와닿지 않았던 것 같기도 하다.
하지만, 단 한 가지는 문장만으로도 확실히 와닿았다. 바로 “데이터베이스 주도 설계를 유도한다”는 점이었다.
Layered Architecture는 데이터베이스 주도 설계를 유도한다
Layered Architecture에서는 서비스 계층(Service)이 Repository를 직접 호출하여 DB 엔티티를 조작하는 방식이 일반적이다.
나 역시 개발을 진행하면서 DB에 작은 칼럼 하나 추가하는 일조차 Service, Entity 등 여러 클래스를 수정해야 했고, 이는 유지보수를 어렵게 만들었다.
이런 경험을 떠올리면서, DB 변경이 애플리케이션 전반에 미치는 구조적인 문제를 더 깊이 실감할 수 있었다.
2장. 의존성 역전하기
개인적으로 이 부분에서 클린 아키텍처(Clean Architecture)와 헥사고날 아키텍처(Hexagonal Architecture)가 DDD와 함께 연관되어 언급되는 이유를 이해할 수 있었다.
이들은 DB 중심 설계에서 도메인 중심 설계로 전환할 수 있도록 계층을 분리하고, 비즈니스 로직이 데이터 저장 방식에 종속되지 않도록 설계하는 방법을 제공한다. 이를 통해 도메인 모델을 애플리케이션의 핵심으로 삼고, 영속성 계층과의 결합도를 낮춰 유지보수성과 확장성을 높일 수 있다.
Spring을 기준으로 하는 전통적인 레이어드 아키텍처(Layered Architecture)에서는 하나의 엔티티가 JPA 엔티티이면서 동시에 도메인 엔티티 역할까지 수행한다. 즉, 데이터 저장과 비즈니스 로직을 한곳에서 함께 관리하고 있다는 뜻이다.
이러한 구조에서는 비즈니스 로직이 데이터베이스에 쉽게 종속되어, 결국 레이어드 아키텍처가 “데이터베이스 주도 설계”로 치우치기 쉽다고 느꼈다.
그런데, 앞서 언급한 클린 아키텍처(Clean Architecture)와 헥사고날 아키텍처(Hexagonal Architecture)는 JPA 엔티티와 도메인 엔티티를 분리함으로써, 기존의 DB 중심 설계 패러다임에서 벗어나는 길을 열었다.
이 방식에서는 JPA 엔티티를 영속성 계층(Persistence Layer)에 두고, 비즈니스 로직을 담당하는 도메인 엔티티를 도메인 계층으로 올림으로써, 데이터 저장 방식과 비즈니스 로직을 분리한다.
또한, Repository 인터페이스를 도메인 또는 애플리케이션 계층에 두고, 실제 구현체는 영속성 계층에서 맡도록 설계함으로써, 도메인 계층이 JPA, MyBatis 등 특정 영속성 기술에 직접 의존하지 않도록 한다.
이를 통해 도메인 계층과 영속성 계층 간의 순환 의존성을 방지하고, 상위 모듈(도메인)이 하위 모듈(영속성)에 직접 의존하지 않도록 하는 DIP(Dependency Inversion Principle)를 실현할 수 있다.
예를 들어, 만약 DB에 변동 사항이 발생한다고 가정해보자. 비즈니스 로직과 직접 관련되지 않은 변경이라면, JPA 엔티티와 레포지토리 구현체만 수정하면 되므로, 도메인 계층에는 영향을 미치지 않는다. 즉, DB 스키마 변경이 애플리케이션의 핵심 비즈니스 로직에 미치는 영향을 최소화 할 수 있다.
※ 단, 비즈니스 로직과 직접 연관된 변경(예: 주문 상태 필드 추가 등)이 발생한다면, 도메인 모델도 함께 수정해야 한다고 한다. 이러한 경우는 도메인 모델 자체를 올바르게 반영하기 위한 자연스러운 변화로 볼 수 있다.
3장. 코드 구성하기
buckpal
|-- account
|-- adapter
| |-- in
| | |-- web
| | |-- AccountController
| |-- out
| | |-- persistence
| | |-- AccountPersistenceAdapter
| | |-- SpringDataAccountRepository
|-- domain
| |-- Account
| |-- Activity
|-- application
|-- SendMoneyService
|-- port
|-- in
| |-- SendMoneyUseCase
|-- out
| |-- LoadAccountPort
| |-- UpdateAccountStatePort
해당 파트에서는 헥사고날 아키텍처를 효과적으로 구현할 수 있는 패키지 구조에 대해 다룬다.
책을 보기 전, 혼자서 헥사고날 아키텍처를 구현하기에 가장 적합한 패키지 구조를 여러개 조사하고 나름 결론을 내렸었는데, 그 구조와 100% 동일한 구조로 책에서 설명해주고 있어서 뿌듯했다 😊
육각형 아키텍처(Hexagonal Architecture)는 엔티티, 유스케이스, 인커밍/아웃고잉 포트, 인커밍/아웃고잉 어댑터를 핵심 구성 요소로 하며, 이를 반영한 패키지 구조를 갖는다.
예시 기준, 최상위에는 Account 관련 유스케이스를 구현한 모듈임을 나타내는 account 패키지가 있으며, 도메인 모델은 domain 패키지에 위치하고, 서비스 계층(인커밍 포트 구현체)은 application 패키지에 속하며, 인커밍/아웃고잉 포트 인터페이스도 application 패키지에서 관리된다. 또한 인커밍 어댑터(포트 호출)와 아웃고잉 어댑터(포트 구현)는 adapter 패키지에 위치한다.
이러한 패키지 구조는 다음과 같은 장점을 제공한다고 한다.
모델-코드 갭(Model-Code Gap) 해소
아키텍처 모델은 추상적인 개념과 기술적 설계가 혼합되어 있으며, 항상 코드에 직접적으로 매핑되지 않는다. 그 결과, 시간이 지나면서 코드가 점점 목표하는 아키텍처와 멀어지는 문제가 발생할 수 있다.
그러나 헥사고날 아키텍처 기반의 패키지 구조는 이러한 문제를 해결하는 데 효과적이다. 패키지 구조가 아키텍처를 직접 반영하므로, 설계와 코드가 일관성을 유지할 수 있으며, 각 구성 요소가 명확하게 분리됨으로써 아키텍처와 코드의 괴리를 최소화할 수 있다.
“코드가 점점 목표하는 아키텍처와 멀어지는 문제”라는 부분이 특히 공감되었다.
프로젝트를 진행하면서 기술 부채가 쌓일수록, 점점 처음 설계했던 계층 구조에서 벗어나게 되는 것을 여러 번 경험했다. 하지만, 패키지 레벨에서 이를 어느 정도 예방할 수 있다면, 아키텍처를 유지하면서도 안심하고 개발할 수 있는 환경을 만들 수 있다. 그런 점에서 이 전략은 매우 유효한 접근 방식이라고 생각된다.
패키지 간 접근 제어
이 패키지 구조에서는 application 패키지 내부의 포트 인터페이스를 통하지 않고는 바깥에서 호출되지 않는다.
즉, 애플리케이션 계층에서 어댑터 계층으로 향하는 우발적인 의존성이 원천적으로 차단된다.
이를 표로 정리하면 다음과 같다.
# | 구분 | 패키지 | 설명 |
1 | Public으로 선언해야 하는 클래스 | application | 포트 인터페이스 (SendMoneyUseCase, LoadAccountPort, UpdateAccountStatePort) |
2 | Public으로 선언해야 하는 클래스 | domain | 도메인 클래스 (Account, Activity) |
3 | Package-private로 감출 수 있는 클래스 | application | 서비스 클래스 (SendMoneyServcie) 인커밍 포트 인터페이스를 통해 호출되므로, 굳이 public일 필요 없음 |
4 | Package-private로 감출 수 있는 클래스 | adapter | 어댑터 클래스 (application 패키지 내 포트 인터페이스를 통해 호출되므로, package-private 가능) |
사실 이 부분에서 만약 프로젝트에 헥사고날 아키텍처를 적용한다면, package-private를 써야할 지 고민을 많이 했다.
내릴 결론은 "헥사고날 아키텍처에서는 쓰는게 좋을 것 같다"였다.
일단 고민했던 이유는 "현업에서 package-private이 잘 사용되지 않는다"는 글을 봤기 때문이다. (참고)
하지만, 헥사고날 아키텍처에서는 패키지 간 의존성을 명확히 제한할 필요가 있기 때문에, package-private을 적극적으로 활용하는 것이 더 나은 선택이라고 판단했다.
우선, 헥사고날 아키텍처의 핵심 원칙 중 하나는 애플리케이션 계층이 어댑터 계층에 의존하지 않아야 한다는 것이다. 이를 지키려면 어댑터 클래스가 애플리케이션 계층으로 노출되지 않도록 제한해야 하며, package-private이 효과적인 방법이 될 수 있다.
실제로, 어댑터 클래스는 오직 애플리케이션 계층의 포트를 통해 호출되므로, 외부에서 직접 접근할 필요가 없다. 따라서, package-private을 사용하면 어댑터 계층의 클래스를 불필요하게 공개하지 않고, 애플리케이션 계층을 통해서만 접근하도록 강제할 수 있다.
물론, package-private이 현업에서 잘 사용되지 않는 이유도 이해된다. 하지만, 헥사고날 아키텍처에서는 특정 계층을 보호하고 의도하지 않은 의존성을 차단하는 것이 중요하기 때문에, package-private을 통해 접근을 제어하는 것이 아키텍처의 원칙을 유지하는 데 유리하다고 결론을 내렸다.
어댑터의 독립성 유지 및 교체 용이성
어댑터를 독립적인 패키지로 분리하면, 특정 어댑터를 쉽게 교체할 수 있는 구조가 된다.
예를 들어, JpaAccountAdapter를 사용하다가 NoSQL 기반의 MongoAccountAdapter로 교체해야 한다면, 아웃고잉 포트 인터페이스(예: LoadAccountPort)의 구현체만 바꾸면 된다.
이를 통해 인프라 변경이 애플리케이션 로직에 미치는 영향을 최소화하고, 기술적인 유연성을 확보할 수 있다.
이 부분을 보면서, 어댑터의 독립성이 유지되면 얼마나 유연한 구조를 만들 수 있는지 다시 한번 실감했다.
프로젝트를 진행하다 보면, 데이터베이스나 외부 시스템이 변경되는 상황은 생각보다 자주 발생한다. 예를 들어, 초기에 관계형 데이터베이스(RDB)를 사용하다가, 확장성을 고려해 NoSQL로 전환하는 경우도 많다. 그런데, 만약 애플리케이션이 특정 DB 기술(JPA, MyBatis 등)에 강하게 결합되어 있다면, 이를 교체하는 과정에서 비즈니스 로직까지 수정해야 하는 문제가 발생할 수 있다.
하지만, 헥사고날 아키텍처에서 포트 인터페이스를 기반으로 어댑터를 분리해두면, 이러한 변경이 상대적으로 용이하다. JpaAccountAdapter를 MongoAccountAdapter로 바꾸는 것처럼, 아웃고잉 포트(LoadAccountPort)의 구현체만 변경하면 되므로, 애플리케이션 계층에는 영향을 주지 않고 인프라 변경을 수행할 수 있다.
결국, 어댑터를 독립적인 패키지로 관리하는 것은 단순히 코드를 정리하는 수준이 아니라, 애플리케이션의 확장성과 유지보수성을 높이는 중요한 전략이라고 느꼈다.
DDD(도메인 주도 설계)와의 직접적인 대응
이 패키지 구조는 DDD(도메인 주도 설계)의 바운디드 컨텍스트(Bounded Context) 개념과도 일맥상통한다.
바운디드 컨텍스트는 특정 도메인을 하나의 독립적인 단위로 정의하며, 이를 포트(incoming/outgoing)를 통해 외부와 통신하도록 구성한다.
즉, account 패키지가 하나의 바운디드 컨텍스트가 되며, 외부와의 상호작용을 담당하는 진입점과 출구(포트 인터페이스)가 명확하게 정의됨으로써, DDD의 원칙을 효과적으로 적용할 수 있다.
이 부분을 읽으면서, 헥사고날 아키텍처와 DDD의 개념이 얼마나 자연스럽게 연결되는지 다시 한번 깨닫게 되었다.
실제 프로젝트에서 여러 모듈을 다루다 보면, 서비스 간 경계를 어떻게 나눠야 할지 고민하는 순간이 많다. 그런데, DDD에서 정의하는 바운디드 컨텍스트 개념과 헥사고날 아키텍처의 패키지 구조를 연계하면, 자연스럽게 올바른 경계를 설정할 수 있다는 점이 굉장히 유용하다고 느꼈다.
의존성 주입의 역할과 클린 아키텍처에서의 중요성
위에서는 헥사고날 아키텍처를 해당 패키지구조로 설계를 했을 때 장점을 살펴보았다.
이제는 클린 아키텍처의 가장 본질적인 요건을 살펴보자.
클린 아키텍처의 핵심 요건 중 하나는 애플리케이션이 인커밍/아웃고잉 어댑터에 직접 의존하지 않는 것이다. 이를 실현하기 위해 의존성 역전 원칙(DIP, Dependency Inversion Principle)을 적용한다.
의존성을 역전시키기 위해 애플리케이션 계층에 인터페이스(port)를 정의하고, 어댑터 계층에서 해당 인터페이스를 구현하는 방식을 사용한다.
즉, 애플리케이션 계층은 구체적인 어댑터(예: DB, 메시지 브로커, 외부 API 등)와 직접적으로 결합되지 않고, 어댑터 계층이 애플리케이션 계층의 인터페이스를 구현함으로써, 구체적인 의존성을 감출 수 있다.
참고
Java package-private 은 안쓰나요?
hyeon9mak.github.io
만들면서 배우는 클린 아키텍처 - 예스24
우리 모두는 낮은 개발 비용으로 유연하고 적응이 쉬운 소프트웨어 아키텍처를 구축하고자 한다. 그러나 불합리한 기한과 쉬워보이는 지름길은 이러한 아키텍처를 구축하는 것을 매우 어렵게
m.yes24.com