이번 시간에는 Spring 프로젝트를 모듈로 변경하는 방법에 대하여 실습하겠습니다. 모듈이란 기능상 성격이 비슷하고 연관성 있는 부분들의 집합을 말합니다. 대부분의 프로그램은 작고 단순한 것에서 시작해서 크고 복잡한 것으로 점차 변화합니다. 예를 들자면 다음과 같은 경우입니다.

  1. 단순 기능 / 운영을 위한 통합 서비스( 한 프로젝트에 모든 기능을 구현) -> 서비스가 커지면서 클라이언트 / 운영 시스템 분리가 필요
  2. 국내 서비스 흥행 -> 전 세계 출시를 위해 동일 기능의 다국어 서비스 개발이 필요

이렇게 비대해지는 프로그램을 좀 더 효율적으로 관리하기 위하여 다양한 방법이 있겠지만 여기서는 MSA와 모듈로 분리하는 방법에 대해 간략히 살펴보겠습니다.

MicroService

첫 번째와 두 번째 예는 서로 다른 상황이지만 오리지널 프로젝트를 기반으로 하여 서비스를 확장해 나가야 한다는 공통점이 있습니다. 확장을 위한 선택지로 마이크로 서비스(Micro Service Architecture : MSA)를 생각해볼 수 있습니다. 첫 번째 예시의 경우 서로 다른 성격의 서비스가 하나의 프로젝트 안에 공존하고 있어 아래 그림처럼 독립적으로 동작하는 마이크로 서비스로 변경하는 것을 고려해 볼 수 있습니다.

Module로 분리

두 번째 예시의 경우 첫 번째와 다르게 서로 다른 서비스로 분리하기보다는 비슷한 기능의 서비스를 하나 더 만든다고 볼수 있습니다. 물론 특정 국가에 한정된 변경사항이 생길 수 도 있습니다만, 이러한 경우에는 프로젝트의 모듈화를 통해 서비스를 확장하는 것을 고려해 볼 수 있습니다.

단일 소스를 기반으로 서비스가 모듈화되므로 공통으로 사용하는 소스코드의 유지 보수에 유리합니다.

단순하게 풀어나가고 싶으면 기존 프로젝트 소스를 그대로 복제해서 사용해도 됩니다. 하지만 이렇게 하면 양쪽 서비스에서 공통으로 사용하는 코드와 한쪽에서 불필요해진 서비스 코드들이 난잡하게 뒤섞이게 되어 관리가 힘들어지게 됩니다.

실습에서는 기존 REST API 프로젝트를 활용하여 모듈 분리를 실습해 보겠습니다. 구조는 다음과 같이 3개의 모듈로 재구성할 예정입니다.

Clone Source

인텔리제이에서 아래 Git 소스를 clone 합니다. File – New – Project From Version Control…
>> https://github.com/codej99/SpringRestApi.git

프로젝트 구성 방법 및 H2관련한 내용은 다음 포스팅을 참고 하시면 됩니다.

멀티 모듈로 변경

신규 모듈 생성

기존 프로젝트에서 New – Module…을 선택하여 모듈을 생성합니다. Gradle 모듈로 생성해야 하니 아래와 같은 방법으로 진행합니다.
New Module -> 왼쪽 메뉴 Gradle 선택 -> 오른쪽 화면 Java 선택 후 Next -> 모듈명 입력 후 finish

반복하여 아래와 같이 3개의 모듈을 생성합니다.

  • module-common
  • module-user
  • module-board

3개의 모듈이 추가된 후의 프로젝트 구조

프로젝트 구조 변경

일단 프로젝트의 기본 디렉터리인 src 디렉터리를 module-common 하위로 이동시킵니다.
실습에서는 일단 common 모듈에 전체 소스를 옮겨 동작을 확인한 다음 Step by Step으로 기능에 맞는 모듈로 옮겨 나갈 것입니다.
소스 코드를 옮기는 과정에서 파일 내부에 선언된 classe들을 인식 못할 수 있는데 이때는 import 내용을 모두 지우고 다시 설정합니다. 그리고 rebuild project 및 gradle reload도 수행해줍니다.

  • src 디렉터리 내용을 모두 module-common 하위로 이동시키고 root의 src는 삭제합니다.
  • 프로젝트 root의 build 디렉터리는 이제 필요없으므로 삭제합니다.
  • settings.gradle에 신규로 생성한 모듈의 내용을 추가합니다.
  • build.gradle에 모듈별 설정을 추가합니다.

settings.gradle 수정

모듈을 만들면 아래와 같이 자동으로 내용이 생성됩니다. 혹시 아래와 같은 내용이 없을 경우 참고하여 수정합니다.

pluginManagement {
    repositories {
        gradlePluginPortal()
    }
}
rootProject.name = 'api'
include 'module-common'
include 'module-board'
include 'module-user'

build.gradle 수정

프로젝트 root에 위치한 build.gradle에 모든 모듈의 라이브러리 설정 내용을 작성해도 되고 각각의 모듈별 root에 있는 build.gradle에 관련 내용을 작성해도 됩니다. 이건 선택이며 실습에서는 프로젝트 root의 build.gradle에 내용을 작성하겠습니다. 작성 후에는 모듈별로 적용한 내용을 반영하기 위해 build.gradle을 한번 reload 해 줍니다.

buildscript {
    ext {
        springBootVersion = '2.1.4.RELEASE'
    }
    repositories {
        mavenCentral()
    }

    dependencies {
        classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
        classpath "io.spring.gradle:dependency-management-plugin:0.6.0.RELEASE"
    }
}

subprojects {
    apply plugin: 'java'
    apply plugin: 'org.springframework.boot'
    apply plugin: 'io.spring.dependency-management'

    sourceCompatibility = 1.8

    repositories {
        mavenCentral()
    }

    configurations {
        compileOnly {
            extendsFrom annotationProcessor
        }
    }

    // 모든 모듈에서 사용하는 라이브러리
    dependencies {
        implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
        implementation 'org.springframework.boot:spring-boot-starter-freemarker'
        implementation 'org.springframework.boot:spring-boot-starter-web'
        implementation 'org.springframework.boot:spring-boot-starter-actuator'
        implementation 'org.springframework.boot:spring-boot-starter-data-redis'
        //embedded-redis
        implementation 'it.ozimov:embedded-redis:0.7.2'
        implementation 'io.springfox:springfox-swagger2:2.6.1'
        implementation 'io.springfox:springfox-swagger-ui:2.6.1'
        implementation 'net.rakugakibox.util:yaml-resource-bundle:1.1'
        implementation 'com.google.code.gson:gson'
        compileOnly 'org.projectlombok:lombok'
        runtimeOnly 'com.h2database:h2'
        runtimeOnly 'mysql:mysql-connector-java'
        annotationProcessor 'org.projectlombok:lombok'
        testImplementation 'org.springframework.security:spring-security-test'
        testImplementation 'org.springframework.boot:spring-boot-starter-test'
    }
}

project(':module-common') {
    // common 모듈에만 필요한 라이브러리가 발생하면 이곳에 추가한다.
    dependencies {
        implementation 'org.springframework.boot:spring-boot-starter-security'
        implementation 'io.jsonwebtoken:jjwt:0.9.1'
    }
}

project(':module-user') {
    // user 모듈에만 필요한 라이브러리가 발생하면 이곳에 추가한다.
    dependencies {
        compile project(':module-common')
    }
}

project(':module-board') {
    // board 모듈에만 필요한 라이브러리가 발생하면 이곳에 추가한다.
    dependencies {
        compile project(':module-common')
    }
}

module-common build.gradle 수정

common 모듈은 실행 가능한 boot jar로는 패키징 할 필요가 없으므로 다음과 같이 build.gradle에 bootJar {}, jar {} 내용을 추가합니다.

plugins {
    id 'java'
}

bootJar {
    enabled = false
}
jar {
    enabled = true
}

group 'com.rest'
version '0.0.1-SNAPSHOT'

repositories {
    mavenCentral()
}

dependencies {
    testCompile group: 'junit', name: 'junit', version: '4.12'
}

module-user, module-board 수정

모듈별로 서버를 구동해야 하므로 모듈별로 root에 com.rest.api 패키지를 생성하고 다음과 같이 SpringBoot Application을 작성해 줍니다.

module-board Application 작성

@SpringBootApplication
public class BoardApplication {
    public static void main(String[] args) {
        SpringApplication.run(BoardApplication.class, args);
    }
}

module-user Application 작성

@SpringBootApplication
public class UserApplication {
    public static void main(String[] args) {
        SpringApplication.run(UserApplication.class, args);
    }
}

여기까지 진행했는데 클래스 파일들이 빨간색으로 오류가 발생한다면…

  • src 디렉터리를 옮기는 과정에서 import 클래스를 찾지 못하는 경우가 있습니다. import 내용을 모두 지우고 다시 작성해 줍니다.
  • lombok 설정 중 Annotation Processors -> Enable annotation processing 체크되어있는지 확인합니다.

.gitignore 생성

build 디렉터리가 모듈 내부로 변경되면서 class파일들이 Unversioned Files로 노출되는데 .gitignore 파일을 추가하여 버저닝에서 제외시킵니다. 수고스럽게 따로 생성할 필요 없이 프로젝트 root에 기존에 생성해 두었던 .gitignore 파일을 각 모듈의 root에 복사하면 됩니다.

모듈로 분리 한 후의 디렉터리 구조

멀티 모듈 작동확인

각 모듈 별로 서버를 실행하여 서버가 문제없이 구동되는지 확인합니다. BoardApplication, UserApplication을 하나씩 Run 하여 SpringBoot Server가 정상적으로 구동되는지 확인합니다. 서버가 정상적으로 구동되면 Swagger 페이지에 접근하여 정상 구동 여부를 확인할 수 있습니다. 현재는 모든 로직이 module-common에 있으므로 어떤 모듈이든 모든 기능을 다 사용할 수 있습니다.

실행가능한 jar 파일이 모듈별로 생성되는지 확인

모듈이 분리되었으므로 BoardApplication, UserApplication 각각에 대하여 실행 가능한 Jar 파일을 생성할 수 있습니다. 프로젝트 root에서 bootJar 명령을 실행하면 모듈 root의 build/libs 디렉터리에 파일이 생성됩니다.

common module은 빌드시 자동으로 모듈에 합쳐져서 패키징 됩니다.

BoardApplication 빌드/패키징 테스트

$ ./gradlew bootJar -p ./module-board
Starting a Gradle Daemon, 1 incompatible Daemon could not be reused, use --status for details

BUILD SUCCESSFUL in 9s
5 actionable tasks: 4 executed, 1 up-to-date
$ cd module-board/build/libs 
$ ls
module-board-0.0.1-SNAPSHOT.jar

UserApplication 빌드/패키징 테스트

$ ./gradlew bootJar -p ./module-user

BUILD SUCCESSFUL in 2s
5 actionable tasks: 2 executed, 3 up-to-date
$ cd module-user/build/libs 
$ ls
module-user-0.0.1-SNAPSHOT.jar

변경 전에는 프로젝트 root의 build/libs에 jar 파일이 생성되었으나 멀티 모듈로 변경 후에는 개별 모듈 하위의 build/libs에 jar파일이 생성되는 것을 확인할 수 있습니다.

공통 내용을 담고 있는 module-common은 따로 Jar파일을 만들 필요가 없습니다. 개별 모듈의 jar 파일 생성시 module-common은 자동으로 패키징 됩니다. 실제로 BoardApplication이나 UserApplication의 Jar 파일 생성 후 module-common의 build/lib에 들어가 보면 이미 jar가 생성되어있음을 확인할 수 있습니다.

$ cd module-common/build/libs
$ ls
module-common-0.0.1-SNAPSHOT.jar

모듈별 기능 분리

이제 BoardApplication과 UserApplication에 기능을 분리해보겠습니다.

UserApplication 기능 분리

module-common에서 User관련 파일들을 module-user로 이동시킵니다. 시작점이 Controller이므로 UserController부터 이동합니다. module-common에서는 UserController를 삭제합니다.

옮기고 나면 security관련 라이브러리가 없어서 컴파일 오류가 발생합니다. root의 build.gradle의 module-user에 security 관련 라이브러리를 추가합니다. 수정후 build.gradle을 리로드하면 오류가 사라지는것을 확인할 수 있습니다.

project(':module-user') {
    // user 모듈에만 필요한 라이브러리가 발생하면 이곳에 추가한다.
    dependencies {
        implementation 'org.springframework.boot:spring-boot-starter-security'
        compile project(':module-common')
    }
}

여기까지만 옮기고 BoardApplication을 실행해서 Swagger 페이지에 들어가 보면 User관련 내용이 사라진 것을 확인할 수 있습니다. 이것으로 모듈 별로 기능이 분리되었음을 알 수 있습니다.

일단 여기까지 하면 간단하게나마 기능이 분리됩니다. Controller에서 사용 중인 UserJpaRepo, ResponseService도 옮기는 것을 고려해 볼 수 있겠지만 UserJpaRepo는 BoardApplication에서도 사용 중이고, ResponseService는 공통 서비스이므로 옮길 필요가 없습니다.

BoardApplication 기능 분리

위에서 User를 분리한 것처럼 Board 관련 내용도 module-common에서 분리합니다. module-common에서 BoardController를 module-board로 이동시키고 common에서 삭제합니다. 옮기고 나면 security관련 라이브러리가 없어서 컴파일 오류가 발생합니다. 프로젝트 root의 build.gradle에 security 관련 라이브러리를 추가합니다. 수정 후 build.gradle을 리로드하면 오류가 사라지는것을 확인할 수 있습니다.

project(':module-board') {
    // board 모듈에만 필요한 라이브러리가 발생하면 이곳에 추가한다.
    dependencies {
        implementation 'org.springframework.boot:spring-boot-starter-security'
        compile project(':module-common')
    }
}

이제 UserApplication을 실행하고 Swagger에 접근해보면 User와 관련없는 Board 내용이 사라진 것을 확인할 수있습니다.

BoardController에서 사용중인 BoardService 및 class들은 다른곳에서 사용중이지 않으므로 module-common에서 분리하여 module-board에 옮겨올수 있습니다. 대상은 다음과 같습니다.

  • Service
    • BoardService
  • Repository
    • BoardJpaRepo
    • PostJpaRepo
  • Entity
    • Board
    • Post
  • Model
    • ParamsPost
  • Test
    • CacheRepo
    • CacheTest
    • CustomKeyGenerator

파일을 옮긴 후 import 된 class들을 잘 인식하지 못해 컴파일 오류가 발생할 수 있습니다. 이때는 import를 모두 지우고 다시 import 하면 인식이 됩니다. 그리고 Rebuild Project 및 Gradle reload도 수행해 줍니다. 정상적으로 이동이 완료되면 다음과 같은 구조로 module-board가 완성됩니다.

테스트를 위해 서버를 실행 후 swagger 페이지에 접근하여 board api를 테스트하면 정상적으로 작동하는 것을 확인할 수 있습니다.

여기까지 간략하게 싱글 모듈을 멀티 모듈로 변경하는 실습을 해보았습니다. 공통으로 사용하는 부분을 별도 모듈로 빼고 도메인에 따라 모듈을 분리한 다음 빌드하여 결과물을 만들 수 있다는 점이 멀티 모듈의 장점입니다. 실습에서는 class단 까지만 분리를 해보았지만 application.yml이나 message.properties 같이 좀 더 세밀한 부분에 대해서도 분리가 가능하니 더 진행해 보는 것을 추천해 드립니다.

실습에서 사용한 소스는 다음 주소에서 확인할 수 있습니다.

https://github.com/codej99/SpringRestApi-MultiModule