Gradle 기반 멀티 모듈 프로젝트를 관리하는 방법
kindof
·2023. 4. 29. 19:10
0. Gradle 멀티 모듈 프로젝트
이번 글에서는 Gradle 기반 멀티 모듈 프로젝트 관리에 대해 소개하려고 합니다.
많은 회사들에서는 아래와 같은 구조의 멀티 모듈 프로젝트를 사용합니다. 멀티 모듈로 프로젝트를 구성하지 않으면 여러 가지 기능이 한 데 묶인 거대한 프로젝트 하나를 관리하는 데 너무 많은 리소스가 들어가기 때문입니다.
- root
- core
- ...
- api
- ...
- async
- ...
- batch
- ...
- admin
- ...
...
특히 core 모듈 안에는 api, async, batch, admin 등에서 끌어다쓰는 공용 클래스가 다수 존재하도록 설계되는 경우가 많습니다.
한편, Gradle은 이런 상황에서 개별 모듈에 대한 의존성을 관리하는 동시에 모듈 간의 의존성과 루트 프로젝트에서 서브 모듈을 관리할 수 있는 방법을 제공합니다.
먼저, 서두에서 Gradle 스크립트의 실행 순서에 대해 이해하고 이후 내용을 따라가면 좋을 것 같습니다.
Gradle 스크립트 실행 순서는 아래와 같습니다.
- settings.gradle에서 모듈 리스트를 등록
- root의 build.gradle 실행
- 각 모듈의 build.gradle이 존재하면 실행
이제 프로젝트에서 코드를 짜보면서 설명하겠습니다.
1. 테스트 환경 구축
해당 프로젝트는 JDK 17 + Gradle 7.6.1 + Groovy + SpringBoot 3.0.6 환경에서 구축되었습니다. 프로젝트의 의존성은 루트 프로젝트를 생성하며 Lombok 하나만 추가했습니다.
먼저 아래와 같은 구조로 멀티 모듈 프로젝트를 구성합니다. 모듈 추가는 [Project Structures] > [Modules] 탭에서 가능합니다.
multi-module-management 라는 루트 프로젝트 하위에 module-core, module-api 모듈(프로젝트)가 존재합니다.
루트 프로젝트는 하위 모듈을 관리하기 위한 프로젝트로써 실행할 대상이 아니기 때문에 src 디렉토리는 지워줍니다.
그리고 아무것도 하지 않은 상태에서 루트 프로젝트 하위의 build.gradle 파일과 settings.gradle 파일을 보겠습니다.
[build.gradle]
plugins {
id 'java'
id 'org.springframework.boot' version '3.0.6'
id 'io.spring.dependency-management' version '1.1.0'
}
group = 'sh.practice'
version = '0.0.1-SNAPSHOT'
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()
}
프로젝트 생성 시 추가한 lombok 관련 의존성만 추가되어 있으며 특이한 내용은 없습니다.
[settings.gradle]
rootProject.name = 'multi-module-management'
settings.gradle 파일은 Gradle 멀티 모듈 프로젝트 빌드에서 중요한 역할을 합니다. 이 파일을 통해 Gradle이 여러 하위 프로젝트를 동시에 빌드하고 각각의 의존성을 해결할 수 있습니다.
이제 module-core 아래에 Book 엔티티를 정의합니다.
[Book.java]
맨 처음 프로젝트 루트의 build.gradle에 Lombok 의존성을 추가했으나, 위와 같이 @Getter, @Setter, @Entity 어노테이션 선언에 필요한 의존성을 찾을 수 없어 컴파일 에러가 납니다.
module-core 쪽의 build.gradle에는 Lombok 관련 의존성이 추가되지 않았기 때문이죠.
이 문제를 해결하기 위해서는 module-core > build.gradle에 Lombok 관련 의존성을 추가하면 됩니다.
하지만 이런 식으로 각 모듈마다 필요한 의존성을 추가하면 여러 군데의 build.gradle에 동일한 내용이 반복되고, 버전을 관리하는 포인트가 늘어나게 되어 유연함과 유지보수 편의성이 떨어지게 됩니다.
2. 루트 프로젝트에서 하위 모듈 관리하기
위와 같은 문제를 해결하기 위해 Gradle에서는 루트 프로젝트의 설정을 서브 모듈에 적용할 수 있는 기능을 제공합니다.
이전에 작성했던 build.gradle, settings.gradle을 수정하겠습니다.
[settings.gradle]
rootProject.name = 'multi-module-management'
include("module-api")
include("module-core")
먼저 settings.gradle에 include 'module-api', 'module-core'를 추가합니다.
이제 multi-module-management 라는 rootProject가 'module-api', 'module-core'를 포함(관리)할 수 있게 됩니다.
[multi-module-management > build.gradle]
plugins {
id 'java'
id 'java-library'
id 'org.springframework.boot' version '3.0.6'
id 'io.spring.dependency-management' version '1.1.0'
}
allprojects {
group = 'sh.practice'
version = '0.0.1-SNAPSHOT'
repositories {
mavenCentral()
}
}
subprojects {
apply plugin: 'java-library'
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'
runtimeOnly 'com.h2database:h2'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
}
sourceCompatibility = '17'
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
tasks.named('test') {
useJUnitPlatform()
}
}
bootJar.enabled = false
위에서 수정한 settings.gradle의 영향으로 이제 rootProject의 build.gradle이 서브 모듈에 영향을 줄 수 있게 되었습니다.
allprojects 안에 정의한 내용은 루트 프로젝트를 포함해 하위 프로젝트 전체에 영향을 주는 설정입니다. 여기서는 기본적인 group, version을 정의하고 의존성 라이브러리를 받아올 원격 저장소 repositories를 mavenCentral()로 정의했습니다.
subprojects 안에 구문을 보면 크게 apply plugin, dependencies 가 추가되었는데요.
하위 모듈들도 역시 JAVA 기반 SpringBoot 프로젝트이기 때문에 관련된 plugin을 추가하고, dependencies에도 하위 모듈들에서 사용할 JPA, Lombok과 같은 의존성을 명시해주었습니다.
마지막으로 루트 프로젝트는 BootApplication이 없기 때문에 bootJar.enable = false 설정을 해줍니다.
이 후 다시 Gradle 빌드를 진행하고 module-core를 보면 아래와 같이 필요한 라이브러리들이 정상적으로 Import되는 것을 볼 수 있습니다.
루트 프로젝트에서 필요한 내용을 대부분 정의했기 때문에 module-core > build.gradle은 아래와 같이 간단하게 쓸 수 있습니다.
[module-core > build.gradle]
plugins {
}
dependencies {
}
3. 하위 모듈 간의 설정 관리
자, 이제 module-core 에서 정의한 Book, BookRepository를 바탕으로 module-api에서 간단한 코드를 작성해보겠습니다.
[BookService.java]
@Service
@RequiredArgsConstructor
@Slf4j
public class BookService {
private final BookRepository bookRepository;
public Book purchase(String title) {
Book book = bookRepository.findByTitle(title);
if (book == null) {
log.warn("Purchase Book title : {} does not exist", title);
throw new EntityNotFoundException("해당 도서는 존재하지 않습니다.");
}
// 비즈니스 로직
// ...
return book;
}
}
현재 module-api는 루트 프로젝트에서 정의한 dependency는 알고 있으나, module-core의 Book과 BookRepository에 대한 정체를 알지 못합니다. 따라서, 아래와 같이 컴파일 에러가 발생합니다.
두 모듈 간의 의존성을 설정해주기 위해서는 다시 루트 프로젝트의 build.gradle을 수정해주어야 합니다.
아래 내용을 루트 프로젝의 build.gradle에 추가합니다.
project(":module-api") {
dependencies {
implementation project(":module-core")
}
}
module-api 프로젝트의 dependency에 module-core 프로젝트를 주입한다는 뜻입니다. 그리고 다시 빌드를 해주면 아래와 같이 정상적으로 컴파일되는 것을 볼 수 있습니다.
이제 module-api > build.gradle 역시 아래와 같이 간단하게 쓸 수 있습니다.
[module-api > build.gradle]
plugins {
}
dependencies {
}
4. module-core의 테스트 코드 작성하기
core 모듈은 어플리케이션 실행을 위해 존재하는 것이 아니기 때문에 @SpringBootApplication이 main 함수가 존재하지 않습니다.
이런 상황에서 아래와 같이 @DataJpaTest를 사용해서 테스트 코드를 작성하고 실행하면 에러가 발생합니다.
@DataJpaTest
public class BookRepositoryTest {
@Autowired
private BookRepository bookRepository;
@Test
public void find_by_title_test() {
Book helloBook = bookRepository.save(Book.builder().title("Hello").build());
Book result = bookRepository.findByTitle("Hello");
Assertions.assertEquals(helloBook, result);
}
}
애초에 @DataJpaTest는 @SpringBootConfiguration 하위의 @Entity 컴포넌트를 스캔하고 Spring Data JPA Repository를 구성하는데, 그 시작점인 @SpringBootConfiguration(@SpringBootApplication 안에 존재)가 현재 module-core에는 없기 때문입니다.
따라서 아래와 같은 경로에 ModuleCoreApplicationTests.java 클래스를 만들고, @SpringBootApplication 어노테이션을 붙입니다.
SpringBoot는 기본적으로 테스트 코드를 실행하는 클래스의 패키지와 그 상위 패키지를 스캔하여 @Component, @Service, @Repository, @Controller 등의 어노테이션을 사용하여 스프링 빈으로 등록된 클래스를 찾는데요.
이 때, @SpringootApplication이 그 시작점이고 이 클래스를 src/test 디렉토리 내부에 위치시켜도 src/main 디렉토리 내부에 정의된 스프링 빈들도 자동으로 스캔되는 것입니다.
이제 위 테스트 코드를 다시 실행하면 정상적으로 성공합니다.
5. module-api의 테스트 코드 작성하기
이제 module-api 쪽에서 테스트 코드를 작성해보겠습니다. 제목으로 책을 조회했을 때 존재하지 않는 책이라면 EntityNotFoundException을 일으킨다는 비즈니스 로직을 테스트합니다.
@ExtendWith(MockitoExtension.class)
public class BookServiceTest {
@InjectMocks
private BookService bookService;
@Mock
private BookRepository bookRepository;
@Test
public void purchase_not_existing_book_throws_exception() {
Mockito.when(bookRepository.findByTitle(anyString())).thenReturn(null);
Assertions.assertThrows(EntityNotFoundException.class, () -> bookService.purchase("Hello"));
}
}
테스트가 정상적으로 성공하는 것을 알 수 있습니다.
6. 정리
지금까지 Gradle을 통해 멀티 모듈 프로젝트를 관리하는 방법에 대해 간략히 살펴봤습니다.
build.gradle을 작성하는 방식이나 각 모듈의 역할이나 구성을 어떻게 할 것인가는 프로젝트마다 다르리라 생각합니다.
루트 프로젝트의 build.gradle은 최소한의 내용만 가지게하고 core쪽에 많은 내용을 담을 수도 있습니다. 그리고 모듈 간의 의존성도 개별 모듈 하위의 build.gradle에서 정의할 수도 있습니다.
이렇게 개별적인 설정은 프로젝트마다 다르니 상황에 맞게 적절히 구성하길 바랍니다.
끝!
7. Reference
- https://jojoldu.tistory.com/123
- https://www.wool-dev.com/backend-engineering/spring/springboot-multi-module-gradle/starter
- https://engineering.linecorp.com/ko/blog/mono-repo-multi-project-gradle-plugin
'Gradle' 카테고리의 다른 글
Gradle의 의존성 충돌(Dependency Conflict) 관리 전략 (0) | 2023.07.06 |
---|---|
Maven 기반 멀티 모듈 프로젝트, Gradle로 전환하기 (0) | 2023.05.21 |
Gradle에서src/main/java에 위치한 XML 파일 빌드하기 (0) | 2023.05.13 |