'현업에선 이렇게 하더라' 시리즈의 첫 게시글로 Spring Boot 프로젝트에서 Gradle Multi Module을 구성하는 방법에 대해 이야기해보려 한다.
예제 프로젝트는 다음과 같다. (module-settings feature를 확인해주세요)
Oracle Java 공식 문서에선, 모듈을 패키지의 한 단계 위의 집합체이며 관련된 패키지와 리소스들의 재사용할 수 있는 그룹이라 정의한다.
필요한 기능별로 모듈을 생성하고, 레고를 조립하듯 필요한 모듈을 조립한다. N개의 모듈이 조립되어 있는 프로젝트를 Multi Module 프로젝트라고 한다.
예를 들면 다음과 같은 상황이 있을 수 있겠다.
이런 중복된 코드들을 모듈화시켜 사용하기 위해 Multi Module을 사용하곤 한다. 만약 독립적으로 관리해야 한다면 중복해서 관리해야 하므로 리스크가 늘어난다. 이렇기에, 공통의 기능은 의존성 주입을 통해 모듈별로 기능을 분리하여 작성한다. 그 결과 코드의 이해가 쉬워진다.
필자가 참여하는 어느 프로젝트에서도 아래와 같은 모듈로 구성하여 처리하고 있다.

멀티 모듈 구조에선 원하는 모듈을 골라 빌드/배포가 가능하다.
다음과 같은 명령어를 통해 빌드를 쉽게 진행 할수 있다.
./gradlew clean :module-api:buildNeeded --stacktrace --info --refresh-dependencies -x test
멀티 모듈 프로젝트의 경우엔 별도로 빌드를 진행하는 반면, 루트 프로젝트에서 각각의 모듈을 빌드할 수 있다.
IntelliJ에서 멀티 모듈을 생성하는 방법은 간단하다.

New > Module...을 통해 생성한다.최종적으로 생성된 프로젝트의 구조는 다음과 같다. (feature/module-settings 기준)
.
├── README.md
├── api
│ ├── build.gradle
│ ├── gradle
│ │ └── wrapper
│ │ ├── gradle-wrapper.jar
│ │ └── gradle-wrapper.properties
│ ├── gradlew
│ ├── gradlew.bat
│ ├── out
│ │ └── production
│ │ ├── classes
│ │ └── resources
│ │ └── application.yml
│ └── src
│ ├── main
│ │ ├── java
│ │ │ └── me
│ │ │ └── ramos
│ │ │ └── api
│ │ │ ├── ApiApplication.java
│ │ │ └── controller
│ │ │ └── TestApiController.java
│ │ └── resources
│ │ └── application.yml
│ └── test
│ └── java
│ └── me
│ └── ramos
│ └── api
│ └── ApiApplicationTests.java
├── build.gradle
├── commons
│ ├── build.gradle
│ ├── gradle
│ │ └── wrapper
│ │ ├── gradle-wrapper.jar
│ │ └── gradle-wrapper.properties
│ ├── gradlew
│ ├── gradlew.bat
│ ├── out
│ │ └── production
│ │ └── resources
│ │ └── application.yml
│ └── src
│ ├── main
│ │ ├── java
│ │ │ └── me
│ │ │ └── ramos
│ │ │ └── commons
│ │ │ └── service
│ │ │ └── TestCommonService.java
│ │ └── resources
│ │ └── application.yml
│ └── test
│ └── java
│ └── me
│ └── ramos
│ └── commons
│ └── CommonsApplicationTests.java
├── gradle
│ └── wrapper
├── gradlew
├── gradlew.bat
└── settings.gradle
commons 모듈과 api 모듈로 구성했다. commons 모듈은 entity, service, 공통 exception 및 처리들 등으로 구성되어 있고 api 모듈에선 이를 사용하는 구조다.

먼저, gradle과 관련된 설정들을 자세히 확인해보자.
위 구조상, root 프로젝트에선 settings.gradle이 들어있고 api, commons 모듈 하위엔 해당 설정 파일이 없다. 각 모듈에서 모두 해당 파일을 가지고 있을 순 있지만 큰 의미는 없기에 root에서만 관리하도록 하자.
하위 프로젝트(api, commons)를 settings.gradle에서 설정해준다.
rootProject.name = 'multimodule'
include 'api'
include 'commons'
commons 모듈 하위에 있는 build.gradle 파일에 다음과 같이 설정해준다.
plugins {
id 'java'
id 'org.springframework.boot' version '2.7.16'
id 'io.spring.dependency-management' version '1.0.15.RELEASE'
}
group = 'me.ramos'
version = '0.0.1-SNAPSHOT'
java {
sourceCompatibility = '17'
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
tasks.named('test') {
useJUnitPlatform()
}
tasks.register("prepareKotlinBuildScriptModel") {}
아직은 DB, Spring Data JPA 등의 필요한 의존성을 명시하진 않았지만 추 후 root 프로젝트 하위의 build.gradle의 내용도 바뀌게 될 것이다.
마찬가지로 api 모듈 하위의 build.gradle에 다음과 같이 설정해준다.
plugins {
id 'java'
id 'org.springframework.boot' version '2.7.16'
id 'io.spring.dependency-management' version '1.0.15.RELEASE'
}
group = 'me.ramos'
version = '0.0.1-SNAPSHOT'
java {
sourceCompatibility = '17'
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
implementation project(":commons")
}
tasks.named('test') {
useJUnitPlatform()
}
tasks.register("prepareKotlinBuildScriptModel") {}
중요한 부분은 api 모듈이 commons 모듈을 의존하여 사용하기 때문에 반드시 아래와 같이 추가해야한다.
implementation project(":commons")
참고로 예제 프로젝트에선 commons 모듈내에 @SpringBootApplication이 붙어있는 entry point 클래스가 없다. 이는 해당 모듈에 대해 따로 서버를 띄울 필요가 없기 때문에 해당 클래스를 지웠다.
만약, 모듈마다 독립적인 서버 배포를 해야 한다면 각 모듈 하위에 해당 클래스들은 필요하다.
Spring Bean에 대해 모듈간 의존성을 정확하게 의존하고 있는지 테스트를 해보자.
이에 앞서, api 모듈에 들어있는 entry point 클래스에 걸려있는 @SpringBootApplication에 컴포넌트 스캔 대상의 base package들을 정확하게 명시해주어야 한다.
package me.ramos.api;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication(
scanBasePackages = { "me.ramos.api", "me.ramos.commons" }
)
public class ApiApplication {
public static void main(String[] args) {
SpringApplication.run(ApiApplication.class, args);
}
}
만약 해당 설정을 추가하지 않는다면, 다음과 같은 에러가 발생하고 서버 실행 시 정상적으로 작동하지 않고 종료하게 된다.

api 모듈에 들어있는 Controller는 다음과 같다.
package me.ramos.api.controller;
import lombok.RequiredArgsConstructor;
import me.ramos.commons.service.TestCommonService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/test")
@RequiredArgsConstructor
public class TestApiController {
private final TestCommonService testCommonService;
@GetMapping
public String apiTest() {
return testCommonService.test();
}
}
commons 모듈에 들어있는 Service는 다음과 같다.
package me.ramos.commons.service;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class TestCommonService {
public String test() {
return "Hello Common Service";
}
}
최종적으로 curl을 통해 해당 API를 호출하면 다음과 같이 정상적으로 결과가 반환된다.
