❒ Description
새로운 토이 프로젝트는 multi module 구조로 시작해봤다. 이 포스팅에서는 multi module을 설정하고,
프로젝트에 기본적으로 필요한 dependency들을 설정하는 방법과 과정에서 마주쳤던 issue들을 어떻게
해결했는지 정리하려고 한다.
그리고 이번 gilboard 프로젝트에 사용되는 기술들의 버전 정보는 다음과 같다.
Java | 21 |
Spring boot | 3.3.3 |
Gradle | 8.10 |
Querydsl | 5.0.0 |
mysql-connector | 8.0.33 |
hibernate-spatial | 6.3.1.Final |
❒ Add Modules
module을 등록하는 것은 간단하다.
모듈이 추가되면 build.gradle.kts 파일과 src, gradle 디렉토리를 제외하고 제거해준다.
그리고 root 디렉토리의 src를 제거한다. 왜냐면 모든 파일들은 모듈에서 관리하기 때문이다.
나는 아래와 같이 domain과 api 모듈을 추가하였다.
❒ [Root] settings.gradle.kts
이렇게 모듈을 추가하고 나면 root 디렉토리에서 모듈을 인식할 수 있게끔 설정해줘야 한다.
해당 설정은 settings.gradle.kts 내에 정의하면 된다.
rootProject.name = "gilboard"
include("gilboard-api")
include("gilboard-domain")
❒ [Root] build.gradle.kts
이제는 root 디렉토리에 위치한 build.gradle.kts 파일로 가자.
여기서 해줘야 할 설정은 아래와 같다.
- plugin 설정 정보
- 각 모듈 혹은 전체 모듈에 대한 설정 정보
- dependency 정의
플러그인 설정
plugins {
java
id("org.springframework.boot") version "3.3.3"
id("io.spring.dependency-management") version "1.1.6"
}
모든 프로젝트에 적용할 공통 설정
allprojects {
group = "com.gilbertkdbshop"
version = "0.0.1-SNAPSHOT"
tasks.withType<JavaCompile> {
sourceCompatibility = "21"
}
}
하위 프로젝트에 적용할 공통 설정
subprojects {
apply(plugin = "java")
apply(plugin = "org.springframework.boot")
apply(plugin = "io.spring.dependency-management")
repositories {
mavenCentral()
}
dependencies {
// dependency 정보
}
tasks.named<Test>("test") {
useJUnitPlatform()
}
tasks.register("prepareKotlinBuildScriptModel") {
// 빈 작업
}
}
여기서 prepareKotlinBuildScriptModel task를 추가해주었는데 이유는 여기를 참고했다.
추가적으로 task를 추가하는 방법은 두 가지가 있는데 아래 접은글을 확인하자.
Gradle에서 task와 tasks.register는 둘 다 작업을 정의하는 방법이지만, 약간의 차이점이 있다.
이 둘의 주요 차이는 타이밍과 메모리 효율성에서 나온다.
1. task (Eager Configuration)
task("exampleTask") {
doLast {
println("This is a custom task")
}
}
즉시 실행(Eager Execution) 방식으로 task 키워드는 해당 작업이 선언되는 즉시 빌드 스크립트에서
실행되어 구성된다. 작은 프로젝트에서는 문제없지만, 큰 프로젝트에서는 비효율적일 수 있다.
모든 작업을 필요 여부와 관계없이 메모리에 미리 올리기 때문입니다. 주로 간단한 작업을 정의할 때 사용된다.
2. tasks.register (Lazy Configuration)
tasks.register<Delete>("clean") {
delete("build")
}
지연 실행(Lazy Execution) 방식으로 tasks.register는 해당 작업이 필요할 때만 생성되고 실행된다.
작업을 선언할 때는 실제로 작업 객체가 생성되지 않으며, 해당 작업이 필요로 될 때(즉, 실행될 때)만 작업이
구성되고 실행되기 때문에 메모리와 성능에 더 효율적이다.
큰 프로젝트에서는 성능 최적화를 위해 주로 사용며, 타입을 명시할 수 있어서 타입 안전성이 보장된다.
예를 들어 Delete 타입 작업을 등록할 때 tasks.register<Delete>("clean")처럼 지정할 수 있다.
❒ [Modules] build.gradle.kts
root 디렉토리의 build.gradle.kts 설정이 마무리 되었다면, 이제 각 모듈별로 설정을 또 해줘야 한다.
우선 domain 모듈의 build.gradle.kts를 보면 다음과 같다.
bootJar, jar
import org.springframework.boot.gradle.tasks.bundling.BootJar
val bootJar: BootJar by tasks
bootJar.enabled = false
val jar: Jar by tasks
jar.enabled = true
타입 안전한 방식으로 bootJar와 jar 인스턴스를 가져오는 방식.
※ 참고 - bootJar와 jar의 차이
dependency 추가
dependencies {
implementation("org.hibernate:hibernate-spatial:6.3.1.Final")
runtimeOnly("mysql:mysql-connector-java:8.0.33")
implementation("com.querydsl:querydsl-jpa:5.0.0:jakarta")
annotationProcessor("com.querydsl:querydsl-apt:5.0.0:jakarta")
annotationProcessor("jakarta.annotation:jakarta.annotation-api")
annotationProcessor("jakarta.persistence:jakarta.persistence-api")
}
Domain 모듈은 DB와 연관된 클래의 집합이므로, 관련 의존성을 추가해주었다.
Querydsl 관련 추가 설정
val buildDir = layout.buildDirectory.get()
val mainDir = "generated/sources/annotationProcessor/java/main"
sourceSets {
main {
java {
srcDir("$buildDir/$mainDir")
}
}
}
tasks.withType<JavaCompile> {
options.generatedSourceOutputDirectory.set(file("$buildDir/$mainDir"))
}
tasks.register<Delete>("cleanGenerated") {
delete(file("$buildDir/generated"))
}
tasks.named("clean") {
dependsOn("cleanGenerated")
}
여기서 주목해볼 부분은 buildDir 부분이다. 기존 방식인 `$buildDir/~` 은 deprecated 되었기 때문에,
layout 객체를 통해 경로를 지정해주는 방식이 권장되기 때문이다.
그리고 tasks.withType<JavaCompile> 블록은 컴파일 단계에서 생성된 소스 파일(어노테이션 프로세서가
생성한 파일들)을 특정 디렉토리에 저장하는 역할을 한다. 즉, Q class 파일이 해당 디렉토리에 저장된다.
참고로 withType은 특정 작업 유형에 대해 설정을 일괄 적용할 때 사용한다.
module간 의존 관계 설정하기
domain 모듈은 현재까지는 딱히 의존 관계를 설정해줄 모듈이 없기 때문에 위의 설정은 하지 않았지만,
api 모듈의 경우 domain 모듈에 의존적이다. 따라서 이 관계를 api 모듈의 build.gradle.kts에 정의해줘야 한다.
dependencies {
implementation(project(":gilboard-domain"))
// 기타 의존 관계
}
이렇게 설정하면 gilboard-api 모듈은 gilboard-domain 모듈에 대한 의존성을 가지게 된다.
이를 통해 api 모듈은 domain의 모든 기능과 클래스들을 사용할 수 있으며, 빌드 시 두 모듈 간의 관계가 성립된다.
❒ Spring Boot 설정
여기가 가장 험난한 파트였다.
Spring Boot 서버를 구동할 때 보통 api 모듈에서 실행하게 된다. 이 경우, domain 모듈에 정의된 클래스들은
별도로 Bean으로 등록되지 않으므로, 만약 JPA를 사용하는 상황이라면 Entity 클래스들이 제대로 스캔되지 않아
데이터베이스 테이블이 정상적으로 생성되지 않게 된다.
물론 gradle에서 module 간 의존성을 정의해줬지만, 이 설정은 gradle이 모듈 간의 빌드 타임 의존성을 관리하기
위한 것이지, Spring Boot는 이 의존성 정보를 알지 못한다. 따라서 Spring Boot에서도 의존성 관련 설정을 진행
해야 한다.
Domain 모듈 application-domain-dbsource.yml
db 정보를 api 모듈에 정의하면 위에서 말했던 테이블이 정상적으로 생성되지 않는 문제는 발생하지 않는다.
하지만 이렇게 하면 domain 모듈이 api 모듈에 종속되는 구조가 되어버리며, 두 모듈 간의 책임 분리가 흐려진다.
즉, 모듈화의 이점인 유지 보수성과 확장성이 저하되는 것이다.
또한, 다른 프로젝트에서 domain 모듈만 재사용하거나 다른 모듈에서 독립적으로 사용할 수 있는 가능성도
낮아지게 된다.
# application-domain-dbsource.yml
spring:
datasource:
url: ${DB_URL}
username: ${DB_USER_NAME}
password: ${DB_PASSWORD}
jpa:
database: mysql
open-in-view: false
hibernate:
ddl-auto: update
properties:
hibernate:
format_sql: true
use_sql_comments: true
dialect: org.hibernate.spatial.dialect.mysql.MySQLSpatialDialect
show-sql: true
Api 모듈 application.yml 설정
api 모듈을 구동할 때 domain 모듈에 있는 dbsource.yml을 인식 시켜줘야한다.
# application-api.yml
spring:
mvc:
path-match:
matching-strategy: ANT_PATH_MATCHER
profiles:
active: local
application:
name: gilboard-api
server:
port: 9090
---
# application-api-local.yml
spring:
config:
activate:
on-profile: local
import: classpath:application-domain-dbsource.yml
application-api-local.yml의 마지막 줄을 보면 import 설정을 사용하였다. 해당 설정에 대한 공식문서다.
해당 설정을 하게 되면 다른 위치의 설정 정보를 불러올 수 있다. 이 속성으로 지정된 설정들은 발견되는 즉시
처리되며, 설정 파일 내에서 import를 선언한 부분 바로 아래에 추가 문서처럼 삽입된다.
참고로 Intellij IDE는 `classpath:application-domain-dbsource.yml` 경로를 인식하지 못한다.
그래서 IDE에서는 빨간 경고문으로 노출되는데 나는 이걸 off 시켰다. 물론 Spring Boot 정상적으로 인식한다.
암튼, import가 수행되면 아래와 같이 application-api.yml의 최종 소스는 다음과 같을 것으로 생각된다.
# 최종적으로 Spring Boot 실행될 때의 application-api.yml
spring:
mvc:
path-match:
matching-strategy: ANT_PATH_MATCHER
profiles:
active: local
datasource:
url: ${DB_URL}
username: ${DB_USER_NAME}
password: ${DB_PASSWORD}
jpa:
database: mysql
open-in-view: false
hibernate:
ddl-auto: update
properties:
hibernate:
format_sql: true
use_sql_comments: true
dialect: org.hibernate.spatial.dialect.mysql.MySQLSpatialDialect
show-sql: true
application:
name: gilboard-api
server:
port: 9090
Spring Boot 메인 클래스
1. 클래스 Scan
멀티 모듈 프로젝트에서 Entity와 Repository, 또는 그외 클래스들이 다른 모듈에 있을 경우,
Spring Boot가 해당 클래스들을 자동으로 스캔하지 못하는 문제가 발생한다.
이 문제를 해결하기 위해서 Spring Boot가 올바르게 다른 모듈에 있는 클래스를 인식할 수 있게 해줘야 한다.
@SpringBootApplication(scanBasePackages = "com.gilboard")
@EntityScan("com.gilboard.domain.model")
@EnableJpaRepositories({"com.gilboard.domain.repository"})
public class GilboardApiApplication {
public static void main(String[] args) {
System.setProperty("spring.config.name", "application-api");
SpringApplication.run(GilboardApiApplication.class, args);
}
}
Entity와 Repository 클래스가 위치한 디렉토리를 명시해줌으로 써, 다른 모듈에 있는 클래스를 인식할 수 있게 된다.
2. System.setProperty(...)
Spring Boot는 기본적으로 application.yml을 인식하는데, Api 모듈에서는 파일명을 application-api.yml로
변경해놨다. 따라서 Sprign Boot가 인식할 수 있도록 System 클래스의 property 속성의 값을 설정해준 것이다.
System.setProperty("spring.config.name", "application-api");
Querydsl config 클래스 작성
@Configuration
public class QuerydslConfig {
@PersistenceContext
private EntityManager entityManager;
@Bean
public JPAQueryFactory jpaQueryFactory() {
return new JPAQueryFactory(entityManager);
}
}
❒ Run Spring Boot
드디어 모든 설정들을 마무리 하였다. 이제 정상동작 하는지 검증을 해보자.
외부 모듈 클래스 scan
domain 모듈에 정의 된 클래스가 Bean으로 잘 등록되었다.
Table 생성
테이블 member와 team이 제대로 생성되었다.
Q class 생성
Q class가 설정한 디렉토리에 잘 설정됐음을 확인했다. (bash에서 `% tree -C` 커맨드를 통해 확인)