Gradle Multi Module(with Spring) 이야기

라모스·2023년 10월 9일

Intro

'현업에선 이렇게 하더라' 시리즈의 첫 게시글로 Spring Boot 프로젝트에서 Gradle Multi Module을 구성하는 방법에 대해 이야기해보려 한다.

예제 프로젝트는 다음과 같다. (module-settings feature를 확인해주세요)

Multi Module이란?

Oracle Java 공식 문서에선, 모듈을 패키지의 한 단계 위의 집합체이며 관련된 패키지와 리소스들의 재사용할 수 있는 그룹이라 정의한다.

필요한 기능별로 모듈을 생성하고, 레고를 조립하듯 필요한 모듈을 조립한다. N개의 모듈이 조립되어 있는 프로젝트를 Multi Module 프로젝트라고 한다.

Multi Module을 사용하는 이유?

예를 들면 다음과 같은 상황이 있을 수 있겠다.

  • Member API 서버와 Order API 서버에서 공통으로 사용해야하는 Exception 클래스와 해당 예외 핸들러가 필요하다.
  • API 서버에 DB Entity가 필요하고 Batch 서버에서도 동일한 DB Entity가 필요하다.

이런 중복된 코드들을 모듈화시켜 사용하기 위해 Multi Module을 사용하곤 한다. 만약 독립적으로 관리해야 한다면 중복해서 관리해야 하므로 리스크가 늘어난다. 이렇기에, 공통의 기능은 의존성 주입을 통해 모듈별로 기능을 분리하여 작성한다. 그 결과 코드의 이해가 쉬워진다.

필자가 참여하는 어느 프로젝트에서도 아래와 같은 모듈로 구성하여 처리하고 있다.

멀티 모듈 구조에선 원하는 모듈을 골라 빌드/배포가 가능하다.

다음과 같은 명령어를 통해 빌드를 쉽게 진행 할수 있다.

./gradlew clean :module-api:buildNeeded --stacktrace --info --refresh-dependencies -x test

멀티 모듈 프로젝트의 경우엔 별도로 빌드를 진행하는 반면, 루트 프로젝트에서 각각의 모듈을 빌드할 수 있다.

Spring Boot 프로젝트에서 Multi Module 구성하기

IntelliJ에서 멀티 모듈을 생성하는 방법은 간단하다.

  1. Gradle 프로젝트를 생성한다.
  2. root 프로젝트 내에서 New > Module...을 통해 생성한다.
  3. root 프로젝트의 src과 하위 디렉토리는 정리한다.

최종적으로 생성된 프로젝트의 구조는 다음과 같다. (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에서만 관리하도록 하자.

Root 프로젝트의 settings.gradle

하위 프로젝트(api, commons)를 settings.gradle에서 설정해준다.

rootProject.name = 'multimodule'

include 'api'
include 'commons'

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 모듈

마찬가지로 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")

main class?

참고로 예제 프로젝트에선 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 테스트

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를 호출하면 다음과 같이 정상적으로 결과가 반환된다.

References

profile
블로그 이전 → https://ramos-log.tistory.com/

0개의 댓글