Architecture/DesignPatterns

[만들면서 배우는 클린 아키텍처] 03 코드 구성하기

누구세연 2024. 12. 22. 13:00

'만들면서 배우는 클린 아키텍처' 책을 정리한 내용입니다.

 

 

코드를 보는 것만으로도 어떤 아키텍처인지 파악할 수 있다면 좋지 않을까?

 

이번 글에서는 코드를 구성하는 몇 가지 방법을 살펴보고, 육각형 아키텍처를 직접적으로 반영하는 표현력 있는 패키지 구조를 소개하겠다.

 

새 프로젝트에서 가장 먼저 제대로 만들려고 하는 것은 패키지 구조다. 프로젝트에서 계속 사용할 괜찮아 보이는 구조를 잡는다. 그러고 나서 프로젝트가 진행될수록 점점 바빠지고 패키지 구조는 짜임새 없는 엉망진창 코드를 그럴싸하게 보이게 만드는 껍데기일 뿐이라는 점을 깨닫게 된다. 한 패키지에 있는 클래스들이 불러오지(import) 말아야 할 다른 패키지에 있는 클래스들을 불러오게 된다.

 

지금부터 예제 코드를 구조화하기 위한 여러 가지 방법들을 살펴보겠다. 조금 더 구체적으로 설명하자면 사용자가 본인의 계좌에서 다른 계좌로 돈을 송금할 수 있는 '송금하기' 유스케이스를 살펴보겠다.

 

계층으로 구성하기

코드를 구조화하는 첫 번째 접근법은 계층을 이용하는 것으로서, 다음과 같이 코드를 구성할 수 있다.

buckpal
|--- domain
|	 |-- Account
|	 |-- Activity
|	 |-- AccountRepository
|	 |-- AccountService
|--- persistence
|	 |__ AccountRepositoryImpl
|
|___ web
	 |__ AccountController

계층으로 코드를 구성하면 기능적인 측면들이 섞이기 쉽다.

 

웹 계층, 도메인 계층, 영속성 계층 각각에 대해 전용 패키지인 web, domain, persistence를 뒀다. 여러 가지 이유로 간단한 구조의 계층은 가장 적합한 구조가 아닐 수 있다. 그래서 이번에는 먼저 의존성 역전 원칙을 적용해서 의존성이 domain 패키지에 있는 도메인 코드만을 향하도록 해뒀다. 여기서는 domain 패키지에 AccountRepository 인터페이스를 추가하고, persistence 패키지에 AccountRepositoryImpl 구현체를 둠으로써 의존성을 역전시켰다.

 

그러나 적어도 세 가지 이유로 이 패키지 구조는 최적의 구조가 아니다.

 

첫 번째로, 애플리케이션의 기능 조각(functional slice)이나 특성(feature)을 구분 짓는 패키지 경계가 없다. 이 구조에서 사용자를 관리하는 기능을 추가해야 한다면 web 패키지에 UserController를 추가하고, domain 패키지에 UserService, UserRepository, User를 추가하고 persistence 패키지에 UserRepositoryImpl를 추가하게 될 것이다. 추가적인 구조가 없다면, 아주 빠르게 서로 연관되지 않은 기능들끼리 예상하지 못한 부수효과를 일으킬 수 있는 클래스들의 엉망진창 묶음으로 변모할 가능성이 크다.

 

두 번째로, 애플리케이션이 어떤 유스케이스들을 제공하는지 파악할 수 없다. AccountService와 AccountController가 어떤 유스케이스를 구현했는지 파악할 수 있겠는가? 특정 기능을 찾기 위해서는 어떤 서비스가 이를 구현했는지 추측해야 하고, 해당 서비스 내의 어떤 메서드가 그에 대한 책임을 수행하는지 찾아야 한다.

 

비슷하게, 패키지 구조를 통해서는 우리가 목표로 하는 아키텍처를 파악할 수 없다. 육각형 아키텍처 스타일을 따랐다고 추측할 수는 있고, 그렇기 때문에 웹 어댑터에서 호출되는지, 영속성 어댑터를 찾기 위해 web, persistence 패키지의 클래스들을 조사해 볼 수 있다. 하지만 어떤 기능이 웹 어댑터에서 호출되는지, 영속성 어댑터가 도메인 계층에 어떤 기능을 제공하는지 알아볼 수 없다. 인커밍(Incoming) 포트와 아웃고잉(outgoing) 포크가 코드 속에 숨겨져 있다.

 

기능으로 구성하기

'계층으로 구성하기' 방법의 몇 가지 문제를 해결해보자.

 

다음 접근법은 예제 코드를 기능으로 구성한 것이다.

buckpal
|___ account
    	|--- Account
        |--- AccountController
        |--- AccountRepository
        |--- AccountRepositoryImp
        |___ SendMoneyService

기능을 기준으로 코드를 구성하면 기반 아키텍처가 명확하게 보이지 않는다.

 

가장 본질적인 변경은 계좌와 관련된 모든 코드를 최상위의 account 패키지에 넣었다는 점이다. 계층 패키지들도 없었다.

 

각 기능을 묶은 새로운 그룹은 account와 같은 레벨의 새로운 패키지로 들어가고, 패키지 외부에서 접근되면 안 되는 클래스들에 대해 package-private 접근 수준을 이용해 패키지 간의 경계를 강화할 수 있다.

 

패키지 경계를 package-private 접근 수준과 결합하면 각 기능 사이의 불필요한 의존성을 방지할 수 있다.

 

또한 AccountService의 책임을 좁히기 위해 SendMoneyService로 클래스명을 바꿨다(이는 계층에 의한 패키지 구조 방식에서도 할 수 있긴 했다). 이제 '송금하기' 유스케이스를 구현한 코드는 클래스명만으로도 찾을 수 있게 됐다. 애플리케이션의 기능을 코드를 통해 볼 수 있게 만드는 것을 가리켜 로버트 마틴이 '소리치는 아키텍처'(screaming architecture)라고 명명한 바 있다. 왜냐하면 코드가 그 의도를 우리에게 소리치고 있기 때문이다.

 

그러나 기능에 의한 패키징 방식은 사실 계층에 의한 패키징 방식보다 아키텍처의 가시성을 훨씬 더 떨어뜨린다. 어댑터를 나타내는 패키지명이 없고, 인커밍 포크, 아웃고잉 포트를 확인할 수 없다. 심지어 도메인 코드와 영속성 코드 간의 의존성을 역전시켜서 SendMoneyService가 AccountRepository 인터페이스만 알고 있고 구현체는 알 수 없도록 했음에도 불구하고, package-private 접근 수준을 이용해 도메인 코드가 실수로 영속성 코드에 의존하는 것을 막을 수 없다.

 

아키텍처적으로 표현력 있는 패키지 구조

육각형 아키텍처에서 구조적으로 핵심적인 요소는 엔티티, 유스케이스, 인커밍/아웃고잉 포트, 인커밍/아웃고잉(혹은 주도하거나 주도되는) 어댑터다. 이 요소들을 예제 애플리케이션의 아키텍처를 표현하는 패키지 구조로 구성해 보자.

buckpal
|___ account
        |--- adapter
        |       |--- in
        |       |     |___ web
        |       |           |__ AccountController
        |       |
        |       |--- out
        |       |     |___ persistence
        |       |              |--- AccountPersistenceAdapter
        |       |              |___ SpringDataAccountRepository
        |--- domain
        |       |--- Account
        |       |___ Activity
        |___ application
                |--- SendMoneyService
                |___ port
                      |--- in
                      |     |___ SendMoneyUseCase
                      |
                      |___ out
                            |--- LoadAccountPort
                            |___ UpdateAccountStatePort

아키텍처로 표현력 있는 패키지 구조에서는 각 아키텍처 요소들에 정해진 위치가 있다.

 

구조의 각 요소들은 패키지 하나씩에 직접 매핑된다. 최상위에는 Account와 관련된 유스케이스를 구현한 모듈임을 나타내는 account 패키지가 있다.

 

그다음 레벨에는 도메인 모델이 속한 domain 패키지가 있다. application 패키지는 도메인 모델을 둘러싼 서비스 계층을 포함한다. SendMoneyService는 인커밍 포트 인터페이스인 SendMoneyUseCase를 구현하고, 아웃고잉 포트 인터페이스이자 영속성 어댑터에 의해 구현된 LoadAccountPort와 UpdateAccountStatePort를 사용한다.

 

adapter 패키지는 애플리케이션 계층의 인커밍 포트를 호출하는 인커밍 어댑터와 애플리케이션 계층의 아웃고잉 포트에 대한 구현을 제공하는 아웃고잉 어댑터를 포함한다. BuckPal 예제의 경우 각각의 하위 패키지를 가진 web 어댑터와 persistence 어댑터로 이뤄진 간단한 웹 애플리케이션이 된다.

 

휴, 정리하고 보니 아주 기술적으로 들리는 패키지 구조다. 조금 헷갈리지 않는가?

 

이러한 육각형 아키텍처를 고수준으로 표현한 그림이 사무실 벽에 걸려 있고, 이 그림을 보면서 현재 사용 중인 서드 파티 API에 대한 클라이언트를 변경하는 작업에 대해 동료와 이야기를 나눈다고 상상해 보자. 그 과정에서 서로 간의 이해를 돕기 위해 그 부분에 해당하는 아웃고잉 어댑터를 그림에서 가리킬 수 있다. 그러고 나서 이야기를 끝냈을 때, IDE 앞에 앉아서 클라이언트를 변경하는 작업을 바로 시작할 수 있다. 해당 API 클라이언트의 코드는 adapter/out/<어댑터 이름> 패키지에서 곧바로 찾을 수 있기 때문이다.

 

헷갈리기보다는 도움이 되지 않는가?

 

이 패키지 구조는 이른바 '아키텍처-코드 갭'(architecture-code gap) 혹은 ' 모델-코드 갭'(model-code gap)을 효과적으로 다룰 수 있는 강력한 요소다. 이러한 용어는 대부분의 소프트웨어 개발 프로젝트에서 아키텍처가 코드에 직접적으로 매핑될 수 없는 추상적 개념이라는 사실을 보여준다. 만약 패키지 구조가 아키텍처를 반영할 수 없다면 시간이 지남에 따라 코드는 점점 목표하던 아키텍처로부터 멀어지게 될 것이다.

 

또한 이처럼 표현력 있는 패키지 구조는 아키텍처에 대한 적극적인 사고를 촉진한다. 많은 패키지가 생기고, 현재 작업 중인 코드를 어떤 패키지에 넣어야 할지 계속 생각해야 하기 때문이다.

 

그런데 패키지가 아주 많다는 것은 모든 것을 public으로 만들어서 패키지 간의 접근을 허용해야 한다는 것을 의미하는 게 아닐까?

 

적어도 어댑터 패키지에 대해서는 그렇지 않다. 이 패키지에 들어 있는 모든 클래스들은 application 패키지 내에 있는 포트 인터페이스를 통하지 않고는 바깥에서 호출되지 않기 때문에 package-private 접근 수준으로 둬도 된다. 그러므로 애플리케이션 계층에서 어댑터 클래스로 향하는 우발적인 의존성은 있을 수 없다.

 

하지만 application 패키지와 domain 패키지 내의 일부 클래스들은 public으로 지정해야 한다. 의도적으로 어댑터에서 접근 가능해야 하는 포트들은 public이어야 한다. 도메인 클래스들은 서비스, 그리고 잠재적으로는 어댑터에서도 접근 가능하도록 public이어야 한다. 서비스는 인커밍 포트 인터페이스 뒤에 숨겨질 수 있기 때문에 public일 필요가 없다.

 

어댑터 코드를 자체 패키지로 이동시키면 필요할 경우 하나의 어댑터를 다른 구현으로 쉽게 교체할 수 있다는 장점도 있다. 예를 들어, 최종적으로 어떤 데이터베이스가 적절할지 확실하기 않아서 간단한 키-밸류 데이터베이스로 개발을 시작했는데, SQL 데이터베이스로 교체해야 한다고 가정해 보자. 간단하게 관련 아웃고잉 포트들만 새로운 어댑터 패키지에 구현하고 기존 패키지를 지우면 된다.

 

이 패키지 구조의 또 다른 매력적인 장점은 DDD개념에 직접적으로 대응시킬 수 있다는 점이다. 예제 코드에서 account 같은 상위 레벨 패키지는 다른 바운디드 컨텍스트(bounded context)와 통신할 전용 진입점과 출구(포트)를 포함하는 바운디드 컨텍스트에 해당한다. domain 패키지 내에서는 DDD가 제공하는 모든 도구를 이용해 우리가 원하는 어떤 도메인 모델이든 만들 수 있다.

 

모든 구조와 마찬가지로 패키지 구조를 소프트웨어 프로젝트 내내 유지하기 위해서는 지켜야 할 규칙이 있다. 또한 패키지 구조가 적합하지 않아서 어쩔 수 없이 아키텍처-코드 갭을 넓히고 아키텍처를 반영하지 않는 패키지를 만들어야 하는 경우도 생길 수 있다.

 

완벽한 방법은 없다. 그러나 표현력 있는 패키지 구조는 적어도 코드와 아키텍처 간의 갭을 줄일 수 있게 해 준다.

 

의존성 주입의 역할

앞에서 설명한 패키지 구조가 클린 아키텍처에 도움이 되기는 하지만, 클린 아키텍처의 가장 본질적인 요건은 2장에서 배웠듯이 애플리케이션 계층이 인커밍/아웃고잉 어댑터에 의존성을 갖지 않는 것이다.

 

예제 코드의 웹 어댑터와 같이 인커밍 어댑터에 대해서는 그렇게 하기가 쉽다. 제어 흐름의 방향이 어댑터와 도메인 코드 간의 의존성 방향과 같은 방향이기 때문이다. 어댑터는 그저 애플리케이션 계층에 위치한 서비스를 호출할 뿐이다. 그럼에도 불구하고 애플리케이션 계층으로의 진입점을 구분 짓기 위해 실제 서비스를 포트 인터페이스들 사이에 숨겨두고 싶을 수 있다.

 

예제 코드의 영속성 어댑터와 같이 아웃고잉 어댑터에 대해서는 제어 흐름의 반대 방향으로 의존성을 돌리기 위해 의존성 역전 원칙을 이용해야 한다.

 

이것이 어떤 식으로 동작하는지는 이미 앞에서 살펴봤다. 애플리케이션 계층에 인터페이스를 만들고 어댑터에 해당 인터페이스를 구현한 클래스를 두면 된다. 육각형 아키텍처에서는 이 인터페이스가 포트다. 아래의 그림과 같이 애플리케이션 계층은 어댑터의 기능을 실행하기 위해 포트 인터페이스를 호출한다.

 

그런데 포트 인터페이스를 구현한 실제 객체를 누가 애플리케이션 계층에 제공해야 할까? 포트를 애플리케이션 계층 안에서 수동으로 초기화하고 싶지는 않다. 애플리케이션 계층에 어댑터에 대한 의존성을 추가하고 싶지는 않기 때문이다.

 

이 부분에서 의존성 주입을 활용할 수 있다. 모든 계층에 의존성을 가진 중립적인 컴포넌트를 하나 도입하는 것이다. 이 컴포넌트는 아키텍처를 구성하는 대부분의 클래스를 초기화하는 역할을 한다.

웹 컨트롤러가 서비스에 의해 구현된 인커밍 포트를 호출한다. 서비스는 어댑터에 의해 구현된 아웃고잉 포트를 호출한다.

앞의 그림에서 중립적인 의존성 주입 컴포넌트는 AccountController, SendMoneyService, AccountPersistenceAdapter 클래스의 인스턴스를 만들 것이다. AccountController가 SendMoneyUseCase 인터페이스를 필요로 하기 때문에 의존성 주입을 통해 SendMoneyService 클래스의 인스턴스를 주입한다. 컨트롤러는 인터페이스만 알면 되기 때문에 자신이 SendMoneyService 인스턴스를 실제로 가지고 있는지도 모른다.

 

이와 비슷하게 SendMoneyService 인스턴스를 만들 때도 의존성 주입 매커니즘이 LoadAccountPort 인터페이스로 가장한 AccountPersistenceAdapter 클래스의 인스턴스를 주입할 것이다.

 

유지보수 가능한 소프트웨어를 만드는 데 어떻게 도움이 될까?

이번 글에서는 실제 코드 구조를 최대한 우리가 목표로 하는 아키텍처에 가깝게 만들어주는 육각형 아키텍처의 패키지 구조를 살펴봤다. 코드에서 아키텍처의 특정 요소를 찾으려면 이제 아키텍처 다이어그램의 박스 이름을 따라 패키지 구조를 탐색하면 된다. 이로써 의사소통, 개발, 유지보수 모두가 조금 더 수월해진다.

 

다음 장에서는 애플리케이션 계층, 웹 어댑터, 영속성 어댑터를 이용해서 유스케이스를 하나 구현하면서 패키지 구조와 의존성 주입에 대해 조금 더 자세히 살펴보겠다.