티스토리 뷰

이 포스팅은 만들면서 배우는 클린 아키텍처를 읽고 작성하였습니다.

Overview

코드를 구성하는 몇 가지 방법과 헥사고날 아키텍처를 표현하는 패키지 구조를 소개합니다.

프로젝트를 처음 생성할 때 패키지 구조를 먼저 설계하게 되는데, 진행될수록 점점 바빠져서 처음의 원칙을 지키지 않고 서로 규칙 없이 참조하게 되는 경우를 많이 겪어보셨을 것입니다.

지금부터 송금하기 유스케이스에 대해 여러 가지 패키지 구조 예시를 살펴보겠습니다.

계층 구조

첫 번째 접근 방법은 계층 구조를 이용하는 것입니다.

app
├── domain
│   ├── Account
│   ├── AccountRepository
│   ├── AccountService
│   └── Activity
├── persistence
│   └── AccountRepositoryImpl
└── web
    └── AccountController

웹, 도메인, 영속성 계층을 이용해 설계한 구조로 가장 간단하면서도 적합한 구조입니다. 의존성 역전 원칙을 적용해 domain 패키지에 있는 도메인 코드만 향하도록 설계하였습니다. domain 패키지에 AccountRepository 인터페이스를 persistence 패키지의 AccountRepositoryImpl이 구현함으로써 의존성을 역전시켰습니다.

하지만 이 구조는 여전히 문제가 있습니다.

먼저 애플리케이션의 기능이나 특징을 구분짓는 패키지의 경계가 없습니다. 사용자를 관리하는 기능을 추가해야 할 경우 web 패키지에 UserController가, domain 패키지에 UserService, UserRepository, User가, persistence 패키지에 UserRepositoryImpl이 추가되어야 합니다. 이런식으로 기능이 추가되어 간다면 서로 연관되지 않은 기능끼리 같은 패키지 내에 묶여 엉망이 될 가능성이 높습니다.

그리고 애플리케이션이 어떤 유스케이스를 제공하는지 알 수 없습니다. AccountService, AccountController가 어떤 유스케이스를 구현했는지 알 수 있을까요? 그냥 계좌에 관련된 CRUD 작업이 있지 않을까 하고 짐작할 뿐입니다. 이 상태에서 어떤 기능을 찾기 위해선 서비스가 어떤 내용을 구현하고있는지 추측하여 해당 메서드를 찾아 제대로 수행했는지 확인해야 합니다.

마찬가지로 패키지 구조를 통해서는 우리가 목표로 하는 아키텍처를 파악할 수 없습니다. 헥사고날 아키텍처 스타일을 따랐다고 추측할 수 있고, 해당 아키텍처의 컨벤션을 안다면 web, persistence 패키지를 조사해보면서 어댑터를 찾게 될 것입니다. 하지만 어떤 기능이 웹 어댑터에서 호출되는지, 영속성 어댑터가 도메인 계층에 어떤 기능을 제공하는지 한눈에 알아볼 수 없습니다. in, out 포트가 코드속에 숨겨져있기 때문입니다.

기능으로 구성한 구조

app
└── account
    ├── Account
    ├── AccountController
    ├── AccountRepository
    ├── AccountRepositoryImpl
    └── SendMonyService

계층 구조와 가장 본질적인 차이점은 계좌와 관련된 모든 기능을 account 패키지 안에 넣었다는 것과 계층을 나타내던 패키지들을 제거했다는 것입니다.

각 기능을 묶은 패키지를 account와 같은 레벨로 추가하고 외부에서 접근할 수 없게 package-private 레벨로 지정하여 각 기능사이의 불필요한 의존성이 생기는 것을 방지할 수 있습니다.

또 유스케이스를 바로 알 수 있게 AccountService를 SendMoneyService로 변경하였습니다. 이제 송금하기 기능을 찾기 위해선 클래스명만으로 찾을 수 있게 되었습니다.

하지만 이런 패키징 방식은 계층 구조보다 가시성을 훨씬 더 떨어트립니다. 어댑터를 나타내는 패키지명이 없고, in, out 포트 역시 확인할 수 없습니다. 그리고 내부적으로 아무리 의존성을 역전시키기 위해 AccountRepository 인터페이스를 사용하더라도, package-private 레벨로 접근 가능하기 떄문에 도메인 코드가 영속성 코드에 의존하는 것을 강제로 막을 방법이 없습니다.

표현력 있는 패키지 구조

핵사고날 아키텍처에서 구조적으로 핵심적인 요소는 엔터티, 유스케이스, in/out 포트, in/out 어댑터 입니다. 이를 생각하면서 헥사고날 아키텍처를 표현할 수 있는 패키지 구조를 구성하면 아래와 같습니다.

app
└── account
    ├── adapter
    │   ├── in
    │   │   └── web
    │   │       └── AccountController
    │   └── out
    │       └── persistence
    │           ├── AccountPersistenceAdapter
    │           └── SpringDataAccountRepository
    ├── application
    │   ├── SendMoneyService
    │   └── port
    │       ├── in
    │       │   └── SendMoneyUseCase
    │       └── out
    │           ├── LoadAccountPort
    │           └── UpdateAccountStatePort
    └── domain
        ├── Account
        └── Activity

앞서 언급한 각 요소들은 패키지 하나씩에 직접 매핑됩니다. 최상위에는 계좌와 관련된 유스케이스를 구현한 모듈임을 나타내는 account 패키지가 있습니다.

그 다음 레벨에는 도메인 모델이 속한 domain 패키지와 도메인 모델을 둘러싼 서비스 계층인 application 패키지가 있습니다. SendMoneyService는 in 포트 인터페이스인 SendMoneyUseCase를 구현하고, out 포트 인터페이스인 LoadAccountPort와 UpdateAccountStatePort는 영속성 어댑터에서 구현합니다. adapter 패키지는 애플리케이션 계층으로 향하는 in 포트와, 나오는 out 포트를 구현합니다.

아주 기술적으로 보이는 패키지 구조이지만 아직은 헷갈릴 수 있습니다.

저 같은 경우도 이론적으로 읽고 볼 때는 엄청 헷갈렸는데 막상 직접 개발해보니 알아서 패키지를 딱딱 찾아가면서 인터페이스를 먼저 생성하고 구현체를 적절한 위치에 구현하게 되더군요. (그래도 아직까진 헷갈리긴 합니다😅)

이 패키지 구조는 아키텍처와 코드 사이의 갭, 또는 모델과 코드 사이의 갭을 효과적으로 다룰 수 있는 강력한 요소입니다. 소프트웨어 개발을 함에 있어 아키텍처와 코드를 매핑하는 것이 애초에 어려운 일이기 때문에 현재 구조가 와닿지 않고 어려울 수 있습니다.

하지만 이런 표현력있는 패키지 구조는 아키텍처에 대한 적극적인 사고를 촉진합니다. 많은 패키지가 생기고 많은 클래스가 생길 때마다 어떤 위치에 작성해야할지 계속 고민하게 만들어주기 때문입니다.

이렇게 패키지가 많은 경우 모두 public으로 만들어 패키지간의 접근을 허용해줘야 할 것 같은데 adapter 패키지만큼은 그렇지 않습니다. adapter 패키지에 구현된 모든 클래스들은 application 패키지 내의 포트 인터페이스를 통하지 않고는 호출되지 않으므로 package-private 레벨로 두어도 됩니다. 따라서 application 패키지에서 adapter 패키지의 클래스로 직접적인 의존성을 가질 수는 없습니다.

다른 종류의 어댑터가 필요할 경우 adapter 패키지 내에서 application 패키지 내의 포트 인터페이스를 추가로 구현하면 언제든지 교체할 수 있습니다.

이 패키지 구조의 또 다른 장점 중 하나는 DDD 개념에 직접적으로 대응시킬 수 있다는 점입니다. account 같은 패키지는 다른 기능을 가진 패키지와 통신할 수 있어야 하는데 domain 패키지 내에서 DDD가 제공하는 모든 도구들을 이용해 원하는 도메인 모델을 만들어 낼 수 있습니다.

패키지 구조를 개발하는 내내 유지하기 위해서는 규칙이 필요합니다. 그리고 패키지 구조가 적합하지 않아 코드와의 갭을 넓히고 아키텍처에 맞지 않는 패키지를 만들어야 하는 경우도 생길 수 있습니다.

이처럼 완벽한 방법은 존재하지 않지만, 표현력이 있는 패키지구조를 사용하면 코드와 갭을 줄일 수 있게 해줍니다.

의존성 주입의 역할

클린 아키텍처의 가장 본질적인 요건은 애플리케이션 계층이 in/out 어댑터에 의존성을 갖지 않는 것입니다.

예제로 주어진 패키지 구조에서는 애플리케이션 계층이 어댑터 계층으로 의존성을 가지지 않습니다. 어댑터는 그저 애플리케이션 계층에 위치한 서비스를 호출하면 됩니다. 애플리케이션 계층으로 진입하는 진입점을 구분하기 위해서 서비스를 포트 인터페이스에 숨겨둘 수도 있습니다.

이렇게 포트 인터페이스를 이용해 호출할 경우 실제 개게는 의존성을 주입을 이용해야 합니다. 모든 계층에 의존성을 가진 중립적인 컴포넌트를 도입하는 것인데, 스프링으로 개발하는 경우 스프링이 이에 해당합니다. 만약 스프링을 사용하지 않는 경우 인터페이스의 구현체를 주입해 줄 별도의 컴포넌트가 필요합니다.

유지보수에 어떻게 도움이 되는가?

이름을 따라 패키지 구조를 탐색할 수 있으므로 코드에서 아키텍처의 특정 요소를 찾기가 쉬워집니다. 이렇게 함으로써 의사소통이나, 개발, 유지보수가 모두 수월해지게 됩니다.


다음은 유스케이스를 직접 구현하면서 패키지 구조와 의존성 주입에 대해 자세히 살펴보겠습니다.

 

2022.07.28 - [Architecture] - 클린 아키텍처: 의존성 역전하기

 

클린 아키텍처: 의존성 역전하기

이 포스팅은 만들면서 배우는 클린 아키텍처를 읽고 작성하였습니다. 단일 책임 원칙 이 원칙의 일반적인 해석은 "하나의 컴포넌트는 오로지 한 가지 일만 해야 하고, 그것을 올바르게 수행해야

jaime-note.tistory.com

2022.07.27 - [Architecture] - 클린 아키텍처: 계층형 아키텍처의 문제점

 

클린 아키텍처: 계층형 아키텍처의 문제점

이 포스팅은 만들면서 배우는 클린 아키텍처를 읽고 작성하였습니다. 개요 계층으로 구성된 전통적인 웹 애플리케이션 구조는 보통 아래 그림과 같습니다. 웹 계층에서는 요청을 받아 도메인(

jaime-note.tistory.com

 

댓글