모놀리식 아키텍처 프로젝트에서 멀티모듈 아키텍처로 리팩터링 할 때 초기 구성에 대한 부분을 2편에 걸쳐 살펴보겠습니다.👏
제가 다니고 있는 회사의 솔루션 프로젝트 특성상 한번 작성된 코드를 대부분 재사용하고, 일부 기능에 대한 고객사 커스터마이징이 이루어집니다. 이 과정에서 모놀리식 아키텍처를 사용하는 경우 기능을 담당하는 코드 간의 의존성과 변경 사항에 대한 추적이 어려우며, 코드 버전 관리 부분에서 잦은 충돌이 발생하거나 일치화하기 힘든 현상들이 발생합니다.
이런 상황이 지속되면 프로젝트 수행 시 반복되는 코드 작성 및 작업 효율 저하가 발생하고, 결론적으로 코드 품질 및 생산성이 저하되어 솔루션 퀄리티에 영향을 미치게 됩니다. 따라서 공통으로 사용하는 코드는 통합되어 버전을 관리하고, 고객사의 성격에 따라 커스터마이징 되는 부분을 별도의 모듈로 관리하는 방식의 멀티모듈 아키텍처 구성이 필요합니다.
공통 모듈(핵심 기능, 유틸 등)의 모든 기능을 고객사별로 재사용 가능하도록 구성하고, 새로운 고객사 모듈 추가가 간단하도록 아키텍처를 구성합니다.
최종적으로 데모 또는 고객사 프로젝트 시 개발 시간을 단축하고, 기본 백오피스 기능이 구현된 공통 모듈을 기반으로 고객이 요구한 기능 개발에 집중하여 퀄리티 높은 솔루션을 제공합니다.
-project-root
|-build
|-docs
|-src
|-main
|-java
|-resource
|-test
|-gradle
|-build.gradle
|-...생략
모듈이 구분되어 있지 않기 때문에 루트 경로에 하나의 build.gradle을 이용하여 의존성, 태스크 등을 정의하여 관리를 중이었으며, 다음과 같은 불편함이 존재했습니다.
이 외에도 코드 버전 관리의 어려움 등에 대한 여러가지 불편함이 존재했습니다.
-project-root
|-build
|-docs
|-module-app # 복수 개의 모듈을 조합하여 통합 실행
|-build.gradle
|-module-common # 공통 사용 클래스, config 등에 대한 공통 모듈
|-build.gradle
|-module-system # 백오피스 또는 개발 과정에서 사용되는 부분에 대한 모듈
|-build.gradle
|-module-customer # 고객사 모듈 (system과 수평 관계)
|-build.gradle
|-gradle
|-build.gradle # 프로젝트 메인 build.gradle
|-settings.gradle # 모듈 정보를 관리
위와 같이 멀티모듈을 적용하여 프로젝트를 구성하면 다음과 같은 이점이 존재합니다.
이제부터 어떻게 프로젝트의 build.gradle을 설정했는지 알아보겠습니다.
rootProject
plugins {
java
id("org.springframework.boot") version "3.5.3"
id("io.spring.dependency-management") version "1.1.7"
}
// 프로젝트 전체에 적용
allprojects {
group = "com.aibiz"
version = "0.0.1-SNAPSHOT"
repositories {
mavenCentral()
gradlePluginPortal()
}
}
// Java 버전 설정
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
// 루트 프로젝트에서는 bootJar 태스크 비활성화
tasks.getByName("bootJar") {
enabled = false
}
tasks.getByName("jar") {
enabled = true
}
// 서브모듈 공통 설정
subprojects {
apply(plugin = "java")
apply(plugin = "org.springframework.boot")
apply(plugin = "io.spring.dependency-management")
// 모든 모듈에서 기본으로 필요한 의존성만 추가
dependencies {
implementation("org.springframework.boot:spring-boot-starter")
// 테스트
testImplementation("org.springframework.boot:spring-boot-starter-test")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
tasks.withType<Test> {
useJUnitPlatform()
}
}
프로젝트 전체에 적용해야 하는 내용이나 서브 모듈에 대한 공통 의존성 추가 등에 대해서 작성해줍니다. 여기서 중요한 부분인 bootJar와 jar는 모든 build.gradle을 설명한 뒤 추가 설명하겠습니다.
plugins {
`java-library`
}
// common : 실행 가능한 jar가 아니므로 bootJar 비활성화
tasks.getByName("bootJar") {
enabled = false
}
tasks.getByName("jar") {
enabled = true
}
dependencies {
// 기본 공통 의존성들만
api("org.springframework.boot:spring-boot-starter")
api("org.springframework.boot:spring-boot-starter-web")
api("org.springframework.boot:spring-boot-starter-validation")
api("org.springframework.boot:spring-boot-starter-actuator")
api("org.springframework.boot:spring-boot-starter-data-redis")
// Lombok
api("org.projectlombok:lombok")
annotationProcessor("org.projectlombok:lombok")
// Database 드라이버
api("org.postgresql:postgresql")
// JSON 처리
api("com.fasterxml.jackson.core:jackson-databind")
api("com.fasterxml.jackson.datatype:jackson-datatype-jsr310")
// API Documentation - restdocs, Swagger, mockMvc
api("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0")
api("com.epages:restdocs-api-spec-mockmvc:0.17.1")
api("org.springframework.restdocs:spring-restdocs-mockmvc")
// Utility
api("org.apache.commons:commons-lang3")
api("commons-codec:commons-codec")
}
module-common의 경우 모든 모듈에서 의존하는 모듈이기 때문에 복수 개의 모듈에서 사용하는 의존성이 있다면 해당 모듈에 의존성을 설정하고, 기타 모듈에서는 module-common자체를 의존하도록 설정하여 라이브러리로서 역할을 수행합니다.
해당 부분에서는 api와 implementation에 대한 부분이 중요하니 뒤에서 추가 설명을 하겠습니다.
plugins {
# restdocs-api-spec 플러그인 추가
id("com.epages.restdocs-api-spec") version "0.17.1"
}
tasks.getByName("jar") {
enabled = false
}
val snippetsDir by extra { file("build/generated-snippets") }
dependencies {
// common 모듈
implementation(project(":module-common"))
// JPA
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
// MyBatis
implementation("org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3")
// MyBatis 테스트
testImplementation("org.mybatis.spring.boot:mybatis-spring-boot-starter-test:3.0.3")
}
// 테스트 태스크 설정
tasks.test {
useJUnitPlatform()
outputs.dir(snippetsDir)
}
// OpenAPI 3.0 스펙 생성 설정
openapi3 {
title = "Project System API"
description = "사용자 관리, 부서 관리, 메뉴 관리, 권한 관리, 시스템 이력 관리를 위한 백오피스 API"
version = "1.0.0"
format = "yml"
outputDirectory = "src/main/resources"
outputFileNamePrefix = "project-api"
}
module-common을 의존하는 module-system의 build.gradle입니다. module-system에서 사용하는 의존성은 차후 고객사 모듈이 추가되는 경우 공통되는 부분이 많기 때문에 module-common에 의존성을 추가하면 공통되는 의존성을 사용할 수 있습니다.
위 설정 내용에서 볼 수 있듯이 예를 들어 API 문서화 스펙을 모듈별로 다르게 가져간다면 멀티모듈 구조에서는 해당 모듈의 build.gradle 설정만 추가하면 손쉽게 적용할 수 있습니다.
그럼 이제 위에서 중요하다고 언급한 부분들에 대해 추가로 알아보겠습니다.
tasks.getByName("bootJar") {
enabled = false
}
tasks.getByName("jar") {
enabled = true
}
멀티 모듈을 적용하면서 위 코드에서 보이는 것처럼 두가지 설정을 진행했습니다. 위 설정이 의미하는게 정확히 어떤건지 확인해보겠습니다.
bootJar를 실행하게 되면 project-name.jar와 같은 형식의 .jar파일이 생성되며, 실행될 모듈을 대상으로 true설정을 해줍니다.
-build
|-libs
|-OOO.jar
의존의 대상, 즉 특정 모듈에서 의존성을 가지는 모듈에는 jar 설정만 true로 하여 project-name-plain.jar가 생성되도록 합니다.
-build
|-libs
|-000-plain.jar
.jar 구조
-jar
|-BOOT-INF # 개발한 소스 코드가 위치한 영역
|-META-INF # 스프링부트와 개발한 소스 코드를 연결하는 영역
|-org # 스프링 부트 영역
plain.jar 구조
-plain-jar
|-META-INF # 스프링부트와 소스 코드 연결 영역
|-application.yml # 환경 변수 설정 파일
|-com # 작성된 소스 코드
|-templates
plain.jar는 Manifest의 버전 정보만 담고 있으며, 이런 이유로 단독으로 실행될 수 없습니다. 반면에 jar는 classes, lib등 실행에 필요한 모든 것을 가지고 있기 때문에 단독으로 실행이 가능한 것 입니다.
jar를 실행하면 spring-boot-loader가 우리의 소스 코드를 실행합니다. spring-boot-loader는 빌드가 되는 시점에 gradle이 디폴트로 넣어주기 때문에 프로젝트에서 찾으려면 찾을 수 없습니다.
즉, bootJar는 실제로 실행되는 모듈인 경우 true로 설정하고, 라이브러리의 역할만 하는 모듈의 경우 jar를 true로 설정하면 됩니다.
// module-common/build.gradle.kts
dependencies {
api("org.springframework.boot:spring-boot-starter-web")
api("org.springframework.boot:spring-boot-starter-validation")
// ...생략
}
// module-system/build.gradle.kts
dependencies {
implementation(project(":module-common"))
// 이렇게 하면 Spring Boot, JPA 등을 자동으로 사용할 수 있음
}
// module-system/build.gradle.kts에서 다시 선언해야 함
dependencies {
implementation(project(":module-common"))
implementation("org.springframework.boot:spring-boot-starter-web") // 중복!
implementation("org.springframework.boot:spring-boot-starter-jpa") // 중복!
// ...생략
}
위와 같이 중복된 의존성을 추가하는 경우가 발생 할 수 있고, 의존성 버전들이 모두 상이한 결과를 초래할 수 있기 때문에 의존성이 공통으로 사용되는 부분이 식별된다면 공통 모듈에 구성하는 방법을 고려해봐야합니다.
module-system이 module-common을 의존하는 경우 build.gradle이 모두 세팅되면 아래와 같이 명시적으로 Config 클래스를 정의하여 컴포넌트 스캔 범위를 지정하고, 개발을 시작하면 됩니다.
@Configuration
@ComponentScan(basePackages = {
"com.example.project.common",
"com.example.project.system"
})
public class SystemConfig {}
내용이 긴 관계로 환경 변수 파일(application.yml) 설정에 대한 부분은 다음 포스팅을 참고해주세요!🫡