프로젝트를 끝마친 후, 시간이 지나고 회고를 하다보면 코드 한줄 한줄 따라가다가 디렉토리들이 꼬이게 되는 경우가 많았다.
한 도메인 영역에 모두 때려박았다라는 생각이 들었다. (도메인 영역 자체를 너무 포괄적으로 생각했나?)
프로젝트를 기획하던 와중, 이전보다 볼륨이 커졌다. 단일 프로젝트만 진행했던 이전과는 다르게, 여러 서버를 구축해야했다.
그렇게 되면 이전보다 더욱 스파게티 코드로 프로젝트가 범벅될 것이다.
이대론 안될 것 같아 클린 아키텍쳐를 위한 새로운 설계에 대해 공부해보고자했다. 이렇게 멀티모듈을 공부하였고, 새로운 프로젝트에 적용하고자 했다.
✅ 단일모듈의 한계
- 서로 다른 프로젝트에서 공통된 코드가 사용된다면, 코드를 중복해서 작성해주어야한다.
- 패키지끼리 의존성이 강해서 하나의 수정이 많은 오류를 발생시킬 수 있다.
- 프로젝트규모가 커지면 각 패키지가 담당하는 역할이 모호해지고 결합력이 강해진다.
이러한 단점들을 해결하고자 등장한 것이 멀티모듈이다.
🤔 멀티모듈이란??
멀티모듈이란, 말 그대로 공통된 모듈, 독립적인 모듈을 분리하여 프로젝트를 진행하는 것이다.
이는 기존의 단일 프로젝트를 다른 프로젝트 안의 모듈로서 참조할 수 있을 수 있는 구조를 제공한다.
즉, 모듈간 기능들을 분리하여, 끌어다 쓸수 있게 하여 확장성과 유지보수성을 높여준다.
또한, 하나로 운영하던 서비스가 클라이언트 서비스와 운영 시스템 등, 여러 개로 나뉘는 경우가 발생했을 때 각각의 프로그램에 있는 domain의 동일성을 보장받는 안정성이 있다.
😁 멀티모듈의 장점
- 중복 코드를 줄일 수 있다.
- 확장성과 유지보수성을 높여준다.
- 각각의 모듈과 패키지가 독립적인 역할을 해서 의존성을 낮출 수 있다.
🛠️ 설계
우선, 가장 최상위 프로젝트인 루트 프로젝트를 생성한다. (각각 모듈들을 관리해주는 역할)
여기에 나는 아래 모듈들을 넣고자한다.
- likelion-client : 클라이언트 사용자 api 로직
- likelion-admin : 백오피스 api 로직
- likelion-socket : 채팅 용 웹소켓 설정
- likelion-common : 가장 공통으로 사용되는 것들 ( 순수 자바코드로만 작성) (dependency 없이)
- likelion-core : MySQL DB 접근하는 로직
- likelion-redis : redis 로직 및 캐싱처리관련 로직
- likelion-security : web설정, 보안 설정 (client, admin에서 사용하는 설정을 분리)
- likelion-infrastructure : 외부 인프라 기술들을 사용하는 로직 (s3, sms, 외부api 호출 포함)
처음에 common 없이 진행하도록 하였으나, 직접 사용할 어노테이션과 static 상수들, 글로벌 예외 등을 따로 작성할 부분들이 너무 애매하였고, 모든 모듈에서 사용할 것이기떄문에 common 모듈을 생성하였다.
이 common 모듈은 모든 모듈이 포함하는 모듈이기때문에 순수한 자바로만 코드를 작성하였다.
(dependency는 철저히 받아들이지 않음)
또한, security모듈도 client, admin 모듈에서 발생하는 중복코드를 줄이고, 설정관련 정보들 보기편하게 하고자 분리하였다.
intelliJ 안에서 모듈 설정을 해준다.
이런식으로 위의 모듈들을 모두 넣어준다.
이렇게되면 자동적으로 루트 프로젝트의 settings.gradle에 모듈을 포함한다는 코드가 작성된다.
포함되는 하위 프로젝트들이 입력되어있다.
루트 폴더에 존재하는 build.gradle에는 하위 모듈에 공통적으로 적용되는 설정들을 추가해준다.
이제 각각 모듈 속에 있는 build.gradle을 작성해준다.
예를 들어, core 모듈에 작성해보자.
/* core module */
plugins {
id 'java-library'
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation group: 'com.mysql', name: 'mysql-connector-j'
// Querydsl
implementation 'com.querydsl:querydsl-jpa'
implementation 'com.querydsl:querydsl-core'
implementation project(path: ':likelion-common')
// Querydsl JPAAnnotationProcessor 사용 지정
annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jpa"
annotationProcessor("jakarta.persistence:jakarta.persistence-api")
annotationProcessor("jakarta.annotation:jakarta.annotation-api")
/* common 모듈 추가 */
implementation project(':likelion-common')
}
clean {
delete file('src/main/generated')
}
tasks.withType(Test) {
useJUnitPlatform()
}
bootJar {
enabled = false
}
jar {
enabled = true
}
이렇게 모듈 각각에 필요한 설정들과 dependency들을 작성해준다.
하지만 단일 모듈과의 차이점을 발견 할 수 있는데 바로 bootJar, jar 설정 부분이다.
bootJar은 실행 가능한 jar을 생성하는 작업이다. (단일모듈의 경우 디폴트값이 true)
이것을 시도하기 위해 main() 메서드가 필요하다.
jar은 실행은 불가능한 메인 클래스들을 포함한 jar를 만든다.
해당 core 모듈은 DB에 관련한 엔티티와 트랜젝션에 관여하는 부분만을 넣은 모듈이므로 main() 메서드가 필요하지 않다. 그러므로 bootJar은 끄고, jar설정을 켜주도록 한다.
다음은 client 모듈이다.
plugins {
id 'java'
}
dependencies {
/* swagger */
implementation 'org.springdoc:springdoc-openapi-ui:1.6.9'
testImplementation platform('org.junit:junit-bom:5.9.1')
testImplementation 'org.junit.jupiter:junit-jupiter'
/* core 모듈 추가 */
implementation project(':likelion-core')
/* redis 모듈 추가 */
implementation project(':likelion-redis')
/* common 모듈 추가 */
implementation project(':likelion-common')
/* security 모듈 추가 */
implementation project(':likelion-security')
/* infrastructure 모듈 추가 */
implementation project(':likelion-infrastructure')
}
test {
useJUnitPlatform()
}
bootJar {
enabled = true
}
jar {
enabled = true
}
이곳에서는 core 모듈과는 다르게 실행해야할 main함수가 필요하므로 bootJar을 켜준다. (admin모듈 동일)
❓ api vs implementation
멀티모듈 프로젝트를 구축하면서 의존성들을 구성하는 방식에는 크게 두가지가 있다.
dependencies {} 안에서 작성되는 api와 implementation다.
모두 컴파일 경로와 런타임 경로에서 의존성들을 탐색한다.
하지만 둘 사이에는 전이 의존성의 컴파일 경로 노출 여부에서 차이가 있다.
단일 모듈을 구성할때는 둘의 차이가 거의 없지만, 멀티모듈의 경우 크게 다가온다.
api의 경우, 선언된 의존성은 해당 모듈 내부와 바라보고 있는 모듈 모두에서 사용되고 빌드된다.
implementation의 경우, 선언된 의존성은 해당 모듈 내부에서만 사용되고, 다른 모듈에선 사용되지 않는다.
즉, api는 의존성을 다른 모듈에 노출하고, implementation은 의존성을 노출하지 않는다.
예를 들어, security모듈을 보자
dependencies {
api 'org.springframework.boot:spring-boot-starter-web'
/* security */
api("org.springframework.boot:spring-boot-starter-security")
/* jwt */
implementation ("io.jsonwebtoken:jjwt-api:0.11.5")
runtimeOnly ( "io.jsonwebtoken:jjwt-impl:0.11.5")
runtimeOnly ( "io.jsonwebtoken:jjwt-jackson:0.11.5")
/* jackson */
implementation 'com.fasterxml.jackson.core:jackson-databind:2.13.5'
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.13.5'
/* common 모듈 추가 */
implementation project(':likelion-common')
}
이렇듯, security를 바라보는 client모듈과 admin모듈에서 전부 사용되는 web, spring security설정은 api로 두고, 나머지는 implementation로 둔다.
implementation은 이러한 특성때문에 api에 비해 몇가지 장점이 있다.
- 줄어든 클래스 패스 덕분에 컴파일이 빨라진다.
- implementation하는 의존성들이 변경되었을 때, 바라보고 있는 다른 모듈들은 재컴파일을 하지 않아도 되므로 재컴파일 횟수가 줄어든다.
또한, 멀티모듈을 구성할 때 객체지향적으로 서로의 모듈간의 기능들은 분리해야한다고 생각한다.
따라서 불필요한 재빌드 방지와 성능, 의존성 꼬임을 막기위해 최대한 implementation으로 작성하고, 하위모듈의 의존성을 사용하는경우에 api로 변경하였다.
(client와 admin모듈은 application측 이므로 모든 의존성을 implementation으로 설정하였다.)
💨 후기
이렇게 설정을 완료하여 프로젝트를 진행하였다.
프로젝트를 진행하면서 무의식적으로 해당 모듈들의 의존성을 타 모듈에서 사용하여 로직을 짜려했다.
아마 그렇게 진행하였으면 스파게티 코드가 되고, 의존도 max 모듈들만 존재했을거다.
조금 불편하더라도 가독성 좋고 해당 의존성이 있는 모듈에서만 로직을 구성해서 라이브러리처럼 사용할 수 있도록 좋은 코드를 작성해야겠다.
나만의 컨벤션을 세워 일관성있게 코드를 작성하는게 더 좋은 코드를 작성하는 길이란 생각을 하며 이 글을 마친다.
📚 참고자료
멀티모듈 설계 이야기 with Spring, Gradle(https://techblog.woowahan.com/2637/)
'백엔드 > Spring Boot' 카테고리의 다른 글
[Spring Boot] OIDC를 적용한 로그인 구현( kakao, google) (0) | 2023.10.01 |
---|