티스토리 뷰

앞선 글에서 package 구조, Java access modifier 그리고 ArchUnit을 통해 Hexagonal Architecture를 구현해보았다.

마지막에 " [1] 개발 중엔 아직 실수할 수 있다, [2] 테스트를 수행하지 않는다면기존의 문제점은 계속해서 발생한다." 문제점들을 이야기했는데

이번 글에서는 멀티모듈, JPMS, Kotlin을 이용해서 이 문제들을 해결해보고자 한다.

 

 


3. Multi module (멀티모듈)로 구현하기

 

Chapter 02: Multi module by KimDoubleB · Pull Request #2 · KimDoubleB/clean-architecture-example

 

github.com

 

결국 ArchUnit으로는 잘못된 의존을 막을 수 있는데 큰 도움이 되지만

개발 중에는 막지 못하며 이를 우회할 수 있는 여러 방법이 존재한다.

 

이 문제의 원인을 생각해보면 “의존 가능여부”에 있다.

불필요한, 해서는 안되는 의존 관계에 있어서 아예 불가능하게 만든다면 이 문제들을 해결할 수 있을 것이다.

 

이를 위해 Gradle 기반의 Multi module (멀티모듈)을 구성해 필요한 의존성만 가지도록 설계해보자.

 

어떻게 모듈을 나눠야할까?

다양한 방법이 존재하지만, 간단한 구성이라면 아래와 같이 해볼 수 있다.

  • 개인적인 의견이며, 구현하는 사람/비즈니스 상황에 따라 모듈 구조는 달라질 수 있다.
.
├── adapter.in (http)
├── adapter.out (persistent)
├── application (port, domain)
├── bootstrap (bootstrap/spring)
└── common

 

Gradle 의존관계를 그려보면 아래와 같다.

// settings.gradle
include 'bootstrap'
include 'adapter.in'
include 'adapter.out'
include 'application'
include 'common'

// all modules (project) - build.gradle
dependencies {
    // for spring
    implementation("org.springframework.boot:spring-boot-autoconfigure")
    annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    ...
}

// bootstrap - build.gradle
dependencies {
    implementation project(':adapter.in')
    implementation project(':adapter.out')
    implementation project(':application')
}

// adapter.in - build.gradle
dependencies {
    implementation project(':application')
    implementation 'org.springframework.boot:spring-boot-starter-web'
}

// adapter.out - build.gradle
dependencies {
    implementation project(':application')
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
}

 

 

처음 구조를 보면 bootstrap 모듈에 대해 궁금증이 생길 수 있는데, 단순 SpringApplication을 가진 모듈이라고 보면 된다.

@SpringBootApplication
public class BuckPalApplication {
	public static void main(String[] args) {
		SpringApplication.run(BuckPalApplication.class, args);
	}
}

bootstrap 모듈이 Spring의 시작점이여서 adapter, application의 모듈을 가져가 bean으로 등록하게 된다.

즉, 이름 그대로 서비스 애플리케이션의 시작점이다.

 

 

위의 구조에서는 adapter가 in/out 각각 1개씩이므로

adapter마다 1개의 모듈로 구성하는 것이 당연하다 싶을 수 있다.

이런 선입견을 깨보고자 조금 더 복잡한 구조를 살펴보면, 다음과 같은 구조로도 구현이 가능하다.

  • adapter는 module in module로 구성했다.
.
├── application (port, domain)
├── bootstrap (bootstrap/spring)
├── common
├── adapter.in
│   ├── http
│   └── grpc
├── adapter.out.cache
│   ├── caffeine
│   └── redis
├── adapter.out.eventbroker
│   ├── rabbitmq
│   └── kafka
└── adapter.out.persistence
    ├── mysql
    └── postgresql

 

 

필요에 따라 같은 목적임에 다른 구현체를 사용하는 경우를 가정해본 것인건데

bootstrap 모듈에서 원하는 구현체만을 꽂아 사용하면 된다.

  • adapter들은 port만을 바라보고 활용하고 있기에 구현체가 다르더라도 문제가 없이 동작할 수 있다.
// grpc, redis, kafka, mysql을 사용하고 싶은 경우
// bootstrap - build.gradle
dependencies {
    implementation project(':adapter.in:grpc')
    implementation project(':adapter.out.cache:redis')
    implementation project(':adapter.out.eventbroker:kafka')
    implementation project(':adapter.out.persistence:mysql')
    implementation project(':application')
}

// http, caffeine, kafka, postgresql을 사용하고 싶은 경우
// bootstrap - build.gradle
dependencies {
    implementation project(':adapter.in:http')
    implementation project(':adapter.out.cache:caffeine')
    implementation project(':adapter.out.eventbroker:kafka')
    implementation project(':adapter.out.persistence:postgresql')
    implementation project(':application')
}

 

 

Multi module의 예제를 살펴보았는데, 기존에 이야기했던 문제들은 해결되었을까?

필요없는 외부 의존성을 완벽히 차단할 수 있다.

domain 모듈에서는 Spring web이라던지, Spring Data JPA 같은 필요 없는 의존성을 가지지 않고 필요한 외부 의존성 만을 가지게 구성이 가능하다.

 

adapter.in에서는 adapter.out을 모르고, adapter.out 에서는 adapter.in 을 모른다.

즉, 서로 몰라도 되는 관계에서는 정말 모르게, 참조할 수 없게 격벽을 세울 수 있다.

 

이를 통해 개발 중에도 필요없는 의존성을 추가할 수 있는 실수를 막을 수 있고

구현체가 아닌 추상화에만 의존할 수 있게 만들 수 있다.

 

또 앞서 이야기했듯 bootstrap 모듈에서 어떤 의존성을 추가하느냐에 따라 서비스 애플리케이션이 구성된다.

다시 말하면, 도메인 로직의 변경 없이 외부 adpater만 원하는 구현에 맞춰 갈이 끼움으로서 애플리케이션 구성 변경이 가능하다는 것이다.

 

 

그럼에도 이 구조에서도 domain.service 패키지 내 class들의 java access modifier는 package-private 해야한다는 점을 유의해야한다.

domain 모듈 내 port, domian.model, domain.service를 다 가지고 있는 상황이다보니 adapter에서는 domain.service 구현 로직에 의존할 가능성이 있다. 고로 domain.service 패키지 내 구현체들은 package-private 해야한다.

 

 

프로젝트가 커지면 domain.service package 내부에 여러 package가 생길 수 있다

프로젝트가 커지는 경우, domain.service 내 여러 Service 구현체들이 생겨나고, Service 간에도 의존/호출관계가 생길 수 있다.

보통 하나의 패키지에 클래스가 많아지면 각 목적에 맞게 패키지를 구분하도록 리팩터링을 진행하게 되는데, domain.service 내 클래스들간의 호출이 불가능해지는 문제가 발생한다 (같은 패키지가 아니여서 package-private 특성 상 호출이 불가능해짐).

 

port.in 인터페이스를 통해 접근은 가능하지만, 같은 도메인 로직인데 외부 통로를 통해 접근한다는 점이 조금 어색하게 느껴진다.

또한, port가 존재하지 않는 내부를 위한 domain service 클래스라고 한다면 아예 접근이 불가능해지는 문제가 발생할 수 있다.

 

 

물론 모듈을 더 쪼개, domain.service를 위한 모듈을 따로 구성한다면 이러한 문제를 해결할 수 있다.

 

Chapter 02-2: Multi module by KimDoubleB · Pull Request #3 · KimDoubleB/clean-architecture-example

 

github.com

.
├── adapter.in (http)
├── adapter.out (persistent)
├── domain.model
├── domain.service
├── port
├── bootstrap (bootstrap/spring)
└── common

하지만 개인적으로 이렇게 모듈을 많이 쪼개는 것은 모듈의 불편함을 키우는 것 같다 (trade-off).

 

하나의 로직 수정 시에 port, domain.model, domain.service는 함께 수정될 일이 많다고 생각한다.

이러한 구조에서는 수정 해야하는 모듈들이 너무 많고, 개발에 피로함이 쌓일 수 있을 것 같다.

(개인적으로 하나의 기능 추가/수정하는데 모듈 4개 이상 건드려야한다면 좀 많이 불편할 것 같다)

 

 

그럼, 다시 문제로 돌아와

domain.service가 복잡해져서 패키지로 분리하고 싶은데, 서로 간에 호출/의존이 불가능하다는 문제가 있다.

 

모듈 추가 없이 이러한 문제를 해결할 방법은 없을까?

문제의 본질을 보자. 문제는 Java 언어의 불완전한 패키지 가시성 제어로 발생한다. 이를 해결할 수는 없을까?

 

 

 


4. Multi module + JPMS(Java Platform Module System)로 구현하기

 

Chapter 03: Multi module with JPMS by KimDoubleB · Pull Request #4 · KimDoubleB/clean-architecture-example

 

github.com

사실 Java 언어의 패키지 가시성 문제는 과거부터 있었고

이 문제를 해결하기 위해 Java 9에서부터 Java Platform Module System (JPMS)이 지원되기 시작했다.

 

JPMS에 대해서 설명하기엔 너무 양이 많아질 것 같기도 하고, 워낙 잘 설명된 글이 많으니 검색해 살펴보자.

짧게 설명하면 모듈 디스크럽터(module-info.java)를 통해 “모듈의 어떤 패키지를 노출을 허용할 것인지?”, “어떤 패키지 의존을 허용할 것인지?”를 결정할 수 있다. (이외에도 여러 이점들이 있으나 이 글에서는 생략한다)

이를 활용한다면 port, domain을 하나의 모듈로 가져가면서, domain.service에 대해서 외부 노출을 하지 않고, 각 service 로직은 타 패키지여도 참조가 가능한 구조를 만들 수 있다.

 

모듈 디스크럽터(module-info.java)를 정의하기 전, 몇 가지 주의점이 있다.

  • 모듈 디스크럽터는 모듈의 root package에 정의해야한다.
  • 모듈 디스크럽터를 정의하는 순간, 해당 모듈이 참조하는 패키지는 모듈 디스크럽터에 정의된 패키지만 가능해진다.
  • 모듈 디스크럽터가 완벽히 동작하기 위해서는 모든 모듈에 모듈 디스크럽터를 정의해야만 한다.
    • 쉽게 말하면 adapter 모듈에서 application/domain 모듈을 의존할 때, applciation/domain 모듈의 모듈 디스크럽터에 정의한대로 동작하게 만들려면 application/domain 모듈 또한 모듈 디스크럽터를 사용하고 있어야 한다.

 

자, 이제 module-info.java 를 정의해보자.

// common - module-info.java
module buckpal.common.main {
	requires jakarta.validation;
	requires spring.context;
	requires spring.core;

	exports io.reflectoring.buckpal.common;
	exports io.reflectoring.buckpal.common.validation;
}

// application - module-info.java
module buckpal.application.main {
	requires static lombok;
	requires spring.context;
	requires spring.tx;
	requires jakarta.validation;
	requires buckpal.common.main;

	exports io.reflectoring.buckpal.application.domain.model;
	exports io.reflectoring.buckpal.application.port.in;
	exports io.reflectoring.buckpal.application.port.out;
}

// adapter.in - module-info.java
module buckpal.adapter.in.main {
	requires static lombok;
	requires spring.web;
	
	requires buckpal.application.main;
	requires buckpal.common.main;
}

// adapter.out - module-info.java
module buckpal.adapter.out.main {
	requires static lombok;
	requires jakarta.persistence;
	requires spring.context;
	requires spring.data.commons;
	requires spring.data.jpa;

	requires buckpal.application.main;
	requires buckpal.common.main;
}

 

application과 adapter.in을 집중해서 살펴보자.

  • application module-info.java 에서 exports 한 것을 보면 domain.service 패키지는 포함되지 않을 것을 볼 수 있다. 즉, domain.service 는 외부에 노출되지 않는다.
  • adapter.in module-info.java 에서 requires 을 통해 application module을 추가했다. 이에 application 모듈의 module-info.java 에 따라 접근할 수 있는 패키지들을 adapter.in 모듈에서 이용할 수 있다.

 

한번 직접 수정해서 확인해보자.

먼저 SendMoneyService 를 public으로 변경해주자

public class SendMoneyService implements SendMoneyUseCase
  • 이를 통해 application 모듈 내에서는 어디서나 접근 가능한 클래스가 되었다.

 

그리고 adatper.in 모듈의 SendMoneyController 에서 Import해서 직접 접근하려고 하면, 다음과 같은 오류가 발생한다.

Package 'io.reflectoring.buckpal.application.domain.service' is declared in module 'buckpal.application.main', which does not export it to module 'buckpal.adapter.in.main'

module에서 exports 되지 않아 접근이 불가능하다는 오류 메시지를 띄워주며 오류가 발생하며, 컴파일 타임에서도 오류가 발생하는 것을 볼 수 있다.

 

이로서 앞선 문제들을 해결할 수 있었다. 하지만 여기서 또 끝이 아니다.

Java의 여러 고질적인 문제점들을 해결하고 있는 Kotlin에서는 어떨까?

 

 

 


5. Multi module + Kotlin Visibility modifiers로 구현하기

 

Chapter 04: Multi module with Kotlin by KimDoubleB · Pull Request #5 · KimDoubleB/clean-architecture-example

 

github.com

 

Kotlin에서도 4번에서 소개한 JPMS는 동작한다

  • Java 모듈 시스템이지만 정상적으로 동작한다. module-info.java 파일을 kotlin 디렉토리 하에 넣어도 제대로 동작한다.

 

하지만 코틀린에서는 module-info.java 를 이용하지 않아도 앞에서 이야기 한 같은 모듈/다른 패키지에선 접근을 허용하되, 다른 모듈에서는 접근을 하지 않게 막는 방법을 지원할 방법이 존재한다.

 

바로 internal visibility modifiers를 이용하면 된다.

 

Visibility modifiers | Kotlin

 

kotlinlang.org

visibility modifiers라는 어색한 단어가 나오지만, 사실 Java의 access modifies/접근 제한자와 개념이 같다.

코틀린에서 부르는 이름만 다를 뿐이다.

 

Java와 다른 점이라고 하면

  • Java default 접근 제한자는 package-private (no-modifier) 인 반면, Kotlin default 접근 제한자는 public 이다.
  • Kotlin에는 package-private 접근 제한자가 존재하지 않는다. 대신 internal 접근 제한자가 존재한다. internal 은 접근제한을 모듈 범위로 한정한다.

 

internal 은 “모듈 범위”로 접근이 제한된다.

즉, 같은 모듈 내에서는 접근이 가능하나, 타모듈에서는 접근이 불가능하다는 이야기가 된다.

 

이는 우리가 JPMS, 모듈 디스크럽터(module-info.java)로 정의했던 내용과 동일하다.

그럼 이를 이용해 똑같이 구현해보고, 동작하는지 확인해보자.

 

앞 예제와 같은 구조에서 module-info.java 를 제거함과 동시에

domain.service 구현체 클래스들을 코틀린으로 변경하고 internal 접근제한자를 이용해보았다.

@RequiredArgsConstructor
@UseCase
@Transactional
internal class SendMoneyService(
    private val loadAccountPort: LoadAccountPort,
    private val accountLock: AccountLock,
    private val updateAccountStatePort: UpdateAccountStatePort,
    private val moneyTransferProperties: MoneyTransferProperties
) : SendMoneyUseCase {

    override fun sendMoney(command: SendMoneyCommand): Boolean {
        checkThreshold(command)

        ...
    }
}

이 클래스를 adapter.in에서 직접 참조해보자.

 

그럼 다음과 같은 오류로 접근이 불가능한 것을 볼 수 있다.

 

이렇게 앞선 4번 JPMS로 구현한 결과를 똑같이 만들어볼 수 있었다.

 

마지막, 결론

이렇게 다양한 방법을 통해 Hexagonal Architecture를 구현해보았다.

"개발함에 있어 실수를 저지르지 않고 Hexagonal Architecture를 어떻게 구현할 수 있을까?" 단순한 의문을 갖고 찾아본 것인데

여러 기능들을 활용해 이를 지원할 수 있다는 것을 알게 되었다. 이 과정에서 모르는 개념들도 많이 알게 되었고...

 

근데 뭐... 지금 생각이 들면 "개발자의 입장에서 사용성(개발 생산성/편의성)이 어떤 것이 제일 좋은가?"가 핵심이지 않나 싶다.

Clean/Hexagonal Architecture를 사용하는데 있어 "불편한게 많아", "Trade off가 너무 커"라는 입장을 많이 봐서 그런지, 이런 구현방법에 있어서도 개발생산성/편의성과 Trade off를 제일 줄이는 방법이 무엇일까 하는 생각이 든다.

 

이건 뭐 직접 개발하고 서비스를 운영해봐야 알겠지 싶다. 고로 개발하면서 경험한 것들을 기반으로 계속해서 이 글은 수정될 수 있다.

Hexagonal 진짜 하지 마세요 라고 말하게 될 수도 있다

구현안해보고 Hexagonal 욕하는 건 혼나야한다 (과거의 나)

320x100
반응형
댓글
반응형
250x250
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/10   »
1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30 31
글 보관함