본문 바로가기
Design pattern

멀티모듈로 헥사고날 구현[1] - 모듈 구성

by 코딩공장공장장 2024. 3. 10.


헥사고날 아키텍쳐 패턴을 멀티모듈 방식으로 구성한 방식에 대해 공유해보겠다.

아키텍쳐에 기반한 모듈 구성과 그역할에 대해 1편에서 소개하고,

2편에서는 application layer를 pojo 방식으로 구현한 방법에 대해 소개하겠다.

 

모듈 구성


 

 

헥사고날 아키텍쳐를 간단하게 설명하면

비즈니스 로직을 갖는 application core와 클라이언트와 소통하는 user interface, 그리고 application이 필요로하는 툴(DB, 3rd-party-api)을 사용할 수 있게 하는 infrastructure 영역으로 구분된다.

비즈니스 로직은 순수한 프로그래밍 언어로 표현되며 비즈니스 규칙을 담고 있다.

따라서 어떠한 기술에도 종속되지 않고 순수한 비즈니스 규칙을 표현할 수 있어야한다.

기술에 변경에도 영향도를 없기 하기 위함이다.

 

반면 user interface와 infrastructure는 기술에 종속되어있다.

web server와 소통하는 Controller는 통신 규약을 지켜야하며

DB와 소통하는 ORM Adapter는 SQL이라는 DB 종속적인 언어를 사용한다.

 

헥사고날 아키텍쳐는 기술적 영역과 비즈니스 영역을 구분하여 서로 영향 없는 프로그래밍 구조를 갖추도록 한다.

그리고 이 영역을 확실하게 구분지을 수 있는 방법이 패키지 단위로 독립적인 모듈을 구성해줄 수 있는 멀티모듈이다.

 

나의 헥사고날 모듈 구조는 아래와 같다.

  • app-domain : 비즈니스 모델인 DTO, VO로 구성 
  • app-service : 비즈니스 로직인 service 클래스와 adpater와 소통하는 Port로 구성
  • bootstrap : 프로젝트를 구동 목적 모듈
  • infrastructure : DB, Email, Storage 등 외부 서버와 통신하기 위한 adapter 모듈들이 존재
  • user-inerface  : Web Server와 통신하는 Controller로 구성

 

app-domain 모듈


app-domain 모듈은 비즈니스 모델을 표현한 dto와 vo로 구성되어있다.

비즈니스 계층에서 사용할 Exception 타입과 Enum 타입 또한 정의하였다.

 

비즈니스 로직을 표현하는 app-domain과 app-service는 pojo로 구현하기에 그 어떠한 프레임워크나 라이브러리의 기능도 지원받을 수 없다.

pojo를 통해 기술의 변경에 영향을 받지 않는 재사용성 높은 코드를 작성할 수 있지만, 프로그램 개발에 생산성을 향상시켜주는 기능을 사용할 수 없다면 이는 매우 큰 단점이다.

 

DI와 트랜잭션 같은 기능은 생산성 향상에 반드시 필요했기에 나는 아래와 같이 커스텀 어노테이션을 작성하였다.

위 이미지의 system.construction 패키지에 존재하는 클래스 파일은 비즈니스 규칙을 담은 모델은 아니다.

트랜잭션이나 DI와 같은 기능을 사용하기 위한 목적이다.

이 커스텀 어노테이션을 사용하면 트랜잭션과 DI 기능을 지원 받을 수 있다.

외부 모듈에서 위 어노테이션을 사용한 클래스에 DI와 트랜잭션 기능을 적용시켜주었다.

 

application layer의 pojo를 그대로 가져가며 완전히 독립된 외부 모듈에서 적용시켜주었다.
따라서 구조적으로  application layer의 pojo를 지켰고, 만약 기술이 달라지더라도 해당 외부 모듈만 수정할 수 있는 구조를 갖추게 하였다.

 

이에 대한 설명은 2편에서 설명하도록 하겠다.

 

app-domain의 build.gradle.kts의 의존설정에는 아래와 같이 아무것도 존재하지 않는다.

dependencies {
}

 

app-service 모듈


application layer에 해당하는 모듈이다.

비즈니스 로직을 담은 Service 클래스들이 존재하고

Driving Adapter와 Driven Adapter를 구분하고자 

컨트롤러 처럼 application layer를 호출하는 모듈이 사용하는 port는 UseCase라고 네이밍하였고,

리포지토리 처럼 application layer에 의해 호출당하는 모듈이 사용하는 port는 port라는 네이밍을 그대로 사용하였다.

<app-service 패키지 구조>

 

다만, Stroage, Orm과 같이 그 목적이 다른 경우 구분을 위하여 StoragePort, OrmPort와 같은 postFix 네이밍 규칙을 적용하였다.

<Driven Adapter의 Port 네이밍 규칙>

 


infrastructure


application layer에서 호출하는 adapter이다.

사용하는 툴이 많다면 하위에 여러 모듈이 존재할 수 있다. 

나 또한 email 서버, db, s3와 연결하기 위하여 여러개의 모듈로 구성하였다.

 

 

DB와 연동되는 orm-jpa-adapter를 예시로 들겠다.

아래와 같이 app-service에서 정의한 Port의 구현체가 존재한다.

orm adapter이기에 sql 동작에 대한 구체적인 로직 수행이 주 역할이다.

@Repository
class CsErrorReportWriteRepository : CsErrorReportWriteOrmPort, BaseRepository() {
    override fun create(createDto: CsErrorReportCreateDto): Long {
        val saveEntity = CsErrorReportFactory.getSaveEntity(createDto)
        em.persist(saveEntity)
        return saveEntity.id
    }
}

 

 

모듈의 의존성은 아래와 같이 application layer를 의존한다.

필요로하는 의존성이 있다면 추가해도 좋다. 

툴과 연동되는 기술 종속적인 모듈이기에 다른 의존성이 존재할 수 있다.

implementation(project(":app-domain"))
implementation(project(":app-service"))

implementation("org.springframework.boot:spring-boot-starter-data-jpa")

 

 

참고로 독립적으로 빌드되고 실행할 수 있는 모듈은 아니기에 bootJar, bootRun 작업을 막았다.

빌드와 실행은 부트스트랩 모듈에서 이뤄지게 할 것이다.

import org.springframework.boot.gradle.tasks.bundling.BootJar
import org.springframework.boot.gradle.tasks.run.BootRun

tasks.named<BootJar>("bootJar") {
    enabled = false
}

tasks.named<BootRun>("bootRun") {
    enabled = false
}

 

user-interface


user-interface 하위에도 여러 adapter들이 존재할 수 있다. 

나의 경우 rest-api 통신 기반으로  동작하는 controller만 존재하기에 하나의 adapter 모듈만 존재하지만,

통신 방식이 달라지거나 사용하는 툴이 달라지면 해당 케이스마다 adapter가 존재할 수 있다.

 

user-interface의 adapter인 Controller는 아래와 같이 application layer의 port를 합성하는 구조를 갖추고 있다.

두 레이어의 의존성을 낮추기 위한 목적이다.

객체간의 협력의 방식은 모두 application layer의 port를 통해 정의되므로 port에서 정의하는 input dto 구조로 변환되어 호출해야한다.

@PreAuthorize("hasRole('MANAGER')")
@RestController
@RequestMapping("/manger/math/content")
class ManagerContentsWriteController(
    private val mathContentsWriteCase: MathContentsWriteCase,
    private val mathContentsMapper: MathContentsMapper
) {
    // 자체제작 문제 등록
    @PostMapping("/in-house")
    fun createInHouseContents(
        @UserId memberId: UUID,
        @RequestBody
        @Valid createReq: MathConSimilarSrcCreateRequest
    ): ResponseEntity<ResponseData<Any>> {
        // request to dto 변환
        val contents = mathContentsMapper.toContents(memberId, createReq.contents)

        // 수학문제 생성
        val contentsId = mathContentsWriteCase.createInHouseContents(contents, createReq.similarSrc)

        return ResponseUtil.ok(mapOf("contentsId" to contentsId))
    }
}

 

 

의존성은 app-domain과 app-service를 필요로 한다.

웹서버와 소통하기 위한 목적의 모듈이므로 spring-boot-starter-web 의존성 또한 추가하였다.

    implementation(project(":app-domain"))
    implementation(project(":app-service"))

    implementation("org.springframework.boot:spring-boot-starter-web")

 

infrastructure 모듈과 마찬가지로 독립적인 빌드와 실행을 막기 위해 bootJar, bootRun 활성화를 막았다.

import org.springframework.boot.gradle.tasks.bundling.BootJar
import org.springframework.boot.gradle.tasks.run.BootRun

tasks.named<BootJar>("bootJar") {
    enabled = false
}

tasks.named<BootRun>("bootRun") {
    enabled = false
}

 

 

bootstrap 모듈


bootstrap은 프로젝트를 구동하기 위한 모듈이다.

모든 의존성을 bootstrap 모듈에서 정의하고 bootstrap 모듈을 통해 각 모듈이 연동되고 빌드되어 실행될 수 있다.

따라서 아래와 같이 모든 의존성을 설정하였고

implementation(project(":project:app-domain"))

implementation(project(":project:app-service"))

implementation(project(":project:infrastructure:email-adapter"))
implementation(project(":project:infrastructure:orm-jpa-adapter"))
implementation(project(":project:infrastructure:storage-adapter"))
implementation(project(":project:infrastructure:hwp-client-adapter"))

implementation(project(":project:user-interface:rest-api"))

 

빌드와 실행이 이뤄지도록 bootJar과 bootRun의 enabled 설정을 true로 설정하였다.

 

스프링 프로젝트가 구동하기 위해 이 모듈에는 아래와 같이 Bootstrap 클래스 하나만 존재한다.

 

 

반응형