[Spring] Spring Boot 프로젝트 멀티 모듈로 분리하기

Loopy·2024년 11월 5일
1

삽질기록

목록 보기
30/31
post-thumbnail

멀티 모듈이란?

멀티 모듈이란, 여러 개의 모듈로 구성된 단일 프로젝트를 의미한다.

멀티 모듈로 구성하면 코드의 재사용성이 높아지고, 의존성을 명확히 분리해 높은 유지보수성을 가지고 갈 수 있다. 무엇보다, 각 모듈은 독립적으로 빌드되고 배포될 수 있다.

현재 우리 프로젝트에서는 스케줄러 기능이 있는데, 단일 어플리케이션 서버에서 ECS로 바꾸면서 파드가 여러대가 되었을 경우 스케줄러가 정상적으로 "한번만" 동작하는 것을 보장하기 위해 스케줄러 서버를 별도로 분리했다.

이렇게 서로 다른 서버에 배포해야 하는 경우, 각각 단일 모듈 프로젝트로 구성한다면 공유하고 있는 코드들이 없어 코드의 중복이 무지막지하게 생길 것이다. 따라서, 멀티 모듈로 분리하는 작업을 진행했는데 해당 과정과 트러블 슈팅을 기록해보려고 한다.

1) 모듈 나누는 기준 정하기

그렇다면 모듈을 어떤 기준으로 어떻게 분리해야 할까?

우리 프로젝트에서는 헥사고날 아키텍처를 사용하고 있었기에, 멀티 모듈을 어떻게 나눠야할지 정말 고민을 많이 했다. 헥사고날 아키텍처란, 고수준 모듈은 저수준 모듈에 의존하지 않고, 구현을 추상화한 인터페이스에만 의존하는 DIP(의존성 역전 원칙)를 지킨 클린 아키텍처를 의미한다.

  1. 엔티티
    영속성에 의존하지 않는 순수 엔티티 로직이며, 데이터와 비즈니스적 로직이 모두 들어있다. 예를 들어, ORM 에 의존적인 @Entity 가 클래스에 붙으면 안된다.

  2. 유즈케이스(Application Service)
    인터페이스 형태이며, 구현체인 서비스는 어댑터의 구현체인 입력 포트와 출력 포트 두개를 가진다. 그리고 포트를 통해 실제 구현체인 외부 어댑터들과 통신한다.

  3. 포트(Port)
    유즈케이스에 어댑터에 대한 명세만을 제공하는 계층을 의미한다. 단순히 인터페이스 정의만 존재하며, Dependecny Injection 을 위해 사용된다. ex) IN 포트 : 컨트롤러 인터페이스 / OUT 포트 : DB, AWS 등과 연결하는 인터페이스

  4. 어댑터(Adapter)
    격리된 도메인 로직에서 포트를 통해 실제 인프라와 연결하는 부분을 담당한 구현체를 의미하며, 실질적으로 포트 인터페이스의 구현체이다.

이런 구조를 지니고 있기 때문에 처음에 생각했던 방향은 가운데 코어(엔티티 + 서비스 + 포트)를 이루는 모듈, 그리고 외부 모듈(어댑터)들로 분리하면 좋을 것 같다고 생각했다. 실제로, 유일한 멀티모듈 헥사고날 아키텍처 : 메시지 허브 적용기 에서도 비슷하게 구성을 했다.

하지만 다음과 같은 이유로 우선 스케줄러 및 API에서 공통으로 필요한 엔티티와 레포지토리만 core에 넣어두고, 각각에서 의존해서 공유하는 형태로 구성했다.

  1. 개발 공수를 고려해, 애초에 순수 도메인이 아닌 JPA 엔티티를 사용했기에 JPA와 같은 외부 의존성을 별도 모듈로 분리하기 힘들었다.

  2. 스케줄러, API 를 각각의 코어 모듈로 분리하면 스케줄러에서 API에서 쓰는 도메인 및 레파지토리 / 외부 어댑터인 Redis 등의 의존성들이 필요했기 때문에 중복 코드가 많이 발생했다.

1. core

엔티티, 레포지토리(영속성 계층) 관련 로직이 담긴 모듈이다.

2. application

컨트롤러(뷰 계층)와 서비스 관련 로직이 담긴 모듈이, 코어 패키지를 공유하고 있다. @SpringBootApplication이 존재한다.

3. scheduler

스케줄러 관련 의존성, 패키지가 담긴 모듈이며, 코어 패키지를 공유하고 있다.
@SpringBootApplication이 존재한다.

사실 헥사고날 아키텍처의 이점을 잘 고려하지 못한 모듈 분리라고 생각하기 때문에, 추후에 다시 리팩토링해볼 예정이다. 우선은 어떻게 멀티 모듈을 구성했는지에 대해 더 초점을 맞춰보자.

2) build.gradle 의존성 분리

그렇다면 이제 위에서 분리한 모듈을 바탕으로, 가장 핵심인 build.gradle 을 수정해보자.

runtimeOnly vs compiletimeOnly vs Implementation

우선 build.gradle을 보면 의존성 관리에 여러 어노테이션들이 존재하는 것을 볼 수 있다.

  1. runtimeOnly
    해당 라이브러리가 런타임에서만 필요하다는 것을 나타낸다. 따라서 프로젝트 빌드 시점에 라이브러리를 classpath에 추가하지 않고, 런타임에 필요한 경우 동적으로 라이브러리를 프로젝트에 포함한다. ex) DB Connector 라이브러리
runtimeOnly("com.h2database:h2:2.1.214")
runtimeOnly("com.mysql:mysql-connector-j")
  1. compiletimeOnly
    해당 라이브러리가 컴파일 시점에만 필요하다는 것을 나타낸다. 따라서 프로젝트 빌드 시점에 해당 라이브러리를 참조해서 컴파일에 사용하고, 빌드된 결과물에는 포함하지 않는다.

  2. Implementation
    해당 라이브러리가 컴파일 및 런타임 시점 모두 필요하다는 것을 나타낸다.따라서 프로젝트 빌드 시점에 해당 라이브러리를 컴파일에 사용하고, 빌드된 결과물에도 포함시킨다. 이 경우 라이브러리 클래스 및 메서드를 프로젝트에서 직접 참조할 수 있다.

implementation("org.springframework.boot:spring-boot-starter")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
  1. testImplementation
    테스트시에만 사용된다.
testImplementation("org.junit.jupiter:junit-jupiter:5.8.1")
testImplementation("org.testcontainers:testcontainers:1.18.1")
testImplementation("org.testcontainers:junit-jupiter:1.18.1")

project를 통해 하위 모듈 생성하기

이제 각각의 의존성들에 자세히 알았으니, 모듈 분리 기준에 따라 필요한 의존성들을 적절히 이동시켜보자. 각 모듈은 아래와 같이 project 를 사용해 의존성을 분리할 수 있다.

project(":core") {
	 dependencies {}
}

각 프로젝트를 생성했다면, settings.gradle 에 아래와 같이 하위 모듈들을 추가해주자.

include("core")
include("scheduler")
include("application")

그리고 모든 하위 프로젝트들에서 공통적으로 적용하고 싶은게 있다면, subprojects 를 사용해서 코드 중복을 줄일 수 있다. 나 같은 경우는 플러그인, 각종 spring boot 공통 의존성, 서브 모듈의 application.yml을 실제 resources/ 경로로 복사하는 태스크 등을 추가했다.

subprojects { // <-> allprojects : 루트 + 하위 모듈 모두 적용

    // 해당 내용이 core, scheduler, application 하위 모듈들에 적용됌
    apply(plugin = "org.springframework.boot") 
    ...
    
    dependencies {
        ...
    }
}
  • apply : 멀티 모듈 프로젝트에서 공통 플러그인(빌드 기능 확장 도구)를 적용할 때 사용

BootJar vs Jar

단, 여기서 주의할 점이 하나 있다. 바로 @SpringBootApplication 이 붙은 메인 클래스가 존재하는 모듈은, bootJar를 활성화시키고 Jar을 비활성화 시켜야 한다는 것이다.

bootJar는 뭐고 일반 Jar는 뭘까?

Java 애플리케이션을 배포하고 실행하기 위해 압축된 Jar 파일에는 두가지 유형이 있다.

1. Plain jar

단순 컴파일러를 통해 변환한 바이트 코드 모음(클래스 파일)을 의미한다. 단독적으로 실행은 불가능하며, 단순히 라이브러리로 제공할 때 사용된다. 아래처럼 외부 라이브러리에서 보이는 jar는 다 plain jar이다.

2. Executable Jar(BootJar)

JVM에서 실행이 가능한 JAR (Executable Jar)를 의미하며, 스프링 부트의 장점 중 하나가 바로 이렇게 BootJar를 통해 프로젝트를 바로 실행할 수 있다는 것이다.

🔗 압축 파일 구조

  1. BOOT-INF
    개발자가 직접 작성한 클래스 파일, 리소스 파일, 의존성 주입을 통한 jar 파일들로 구성된다.
 "spring-boot-starter-web-2.3.4.RELEASE.jar"
 "spring-boot-starter-data-jpa-2.3.4.RELEASE.jar"
 "querydsl-jpa-4.3.1.jar"
  1. META-INF
    jar 실행을 위한, 즉 메인 클래스 실행을 위한 메타 데이터를 포함하는 폴더이다.
    일반적인 JAR 파일에서는 Main-Class 속성이 실제 메인 클래스의 경로를 가리키지만, Spring Boot에서는 JarLauncher 클래스로 설정된다.

    즉, 내장 jar를 인식해와서 Launcher를 실행시키면, Launcher에서 리플렉션을 통해 Start-Class에 선언된 메인 메서드 실행하는 방식이다.

    my-app.jar
    ├── META-INF/
    │   ├── MANIFEST.MF  <-실행 정보를 포함한 파일
    ├── com/
    │   ├── example/
    │   │   ├── Main.class  <-프로그램의 시작점
    ├── lib/  <- 의존성 라이브러리 (fat JAR일 경우 포함)
    │   ├── spring-core.jar
    │   ├── jackson.jar
    └── application.properties  <- 설정 파일
     Manifest-Version: 1.0
     Spring-Boot-Classpath-Index: BOOT-INF/classpath.idx
     Start-Class: com.example.demo.DemoApplication
     Spring-Boot-Classes: BOOT-INF/classes/
     Spring-Boot-Lib: BOOT-INF/lib/
     Spring-Boot-Version: 2.3.4.RELEASE
     Main-Class: org.springframework.boot.loader.JarLauncher
  2. org
    Spring Boot Loader 클래스 모듈들이 존재한다.

이 과정에들에서 Spring Boot Gradle Plugin 이 중요한 역할을 한다.
애플리케이션을 실행할 수 있도록 내장 컨테이너(예: Tomcat)를 포함하고,
애플리케이션과 그 의존 라이브러리를 하나의 Fat JAR로 패키징하는 작업을 수행한다.

따라서, 프로젝트를 실행하지 않고 단순 코드 공유용으로 사용되는 core module은 bootJar가 아닌 Plain Jar 를 생성하도록 하고, 프로젝트를 실행시켜야 하는 어플리케이션 및 스케줄러 모듈은 bootJar 를 생성하도록 해주는 것이 좋다.

project(":core-module") {
 	...
    tasks.getByName<Jar>("bootJar") {  // BootJar 비활성화
        enabled = false
    }

    tasks.getByName<Jar>("jar") {   // Jar 활성화
        enabled = true
    }
}
project(":schedule-module") {
    ...
    tasks.getByName<Jar>("bootJar") {  // BootJar 활성화
        enabled = true
    }

    tasks.getByName<Jar>("jar") {  // Jar 비활성화
        enabled = false
    }
}

공통 모듈 의존성 가져오기 - 트러블 슈팅

이제 application, scheduler 모듈이 core 모듈을 의존하기 위한 설정을 추가해주자. project() 함수를 이용해서 의존성을 추가해주면, 해당 모듈(core)의 클래스들만 사용할 수 있도록 연결해주고, core 모듈이 먼저 빌드되도록 순서를 지정해줄 수 있다.

implementation(project(":core-module"))

단, 주의할 점이 core 모듈의 의존성까지 자동으로 가져오는 것이 아니다. 즉, 해당 모듈과 중복으로 작성해야 하는 의존성들이 있더라도 별도로 작성해줘야 한다는 것이다.

Q. 그러면 의존성 자체를 상속 받을 수 있는 함수는 없을까?

바로 api()를 사용하면 해당 모듈을 의존하는 다른 모듈들도 그 의존성을 함께 상속받을 수 있다.

단, 해당 함수는 runtimeClasspath 뿐만 아니라 compileClasspath 까지 의존을 하는 쪽에 노출된다는 단점이 있다. 컴파일 타임에 불필요한 의존성이 노출되어 모듈 간 결합도가 올라가는 것이다.

반면 implementationruntimeClasspath 종속성으로만 나타난다. 그러니 웬만하면 implementation 를 사용하자!

  • compileClasspath : 컴파일 타임에 사용되는 종속성으로, 컴파일 타임에 자바 컴파일러는 소스 코드(.java 파일)를 바이트 코드로 변환한다.
  • runtimeClasspath : 런타임에 사용되는 종속성으로, 런타임에 JVM은 바이트 코드를 기계어로 해석해서 실행될 수 있도록 한다.

이제 진짜 거의 끝났다. build.gradle이 완성되었다면, 다음과 같이 모듈을 새롭게 생성해 코드를 분리해주면 된다.

4) 모듈 별 배포 파일 생성

어플리케이션 모듈은 ECS, 스케줄러 모듈은 일반 EC2에 배포하는 구성이다. 배포 파일은 깃허브 액션을 사용했으며, 다음과 같이 두개의 워크플로우를 생성하면 된다.

이때, 다른 모듈을 변경했는데 배포 워크플로우가 동작하면 매우 비효율적이니 paths-ignore 를 통해 서로 다른 모듈의 변경 사항을 무시해서 독립적으로 동작할 수 있도록 해주자.

application-deploy

name: CI/CD Pipeline for Dev API

on:
  push:
    branches:
      - develop
    paths-ignore:
      - 'scheduler/**'   # 다른 모듈의 변경과 독립적으로 동작하도록 경로 무시 필요
      - '.github/**'
  workflow_dispatch:

jobs:
  build:
    runs-on: ubuntu-latest
    env:
      MODULE_NAME: application
    ...

scheduler-deploy

name: CI/CD Pipeline for Dev Batch

on:
  push:
    branches:
      - develop
    paths-ignore:
      - 'application/**'  # 다른 모듈의 변경과 독립적으로 동작하도록 경로 무시 필요
      - '.github/**'
      - 'scheduler/cron.Dockerfile'

  workflow_dispatch:

jobs:
  build:
    runs-on: ubuntu-latest
    env:
      MODULE_NAME: scheduler

참고 자료
https://docs.gradle.org/current/userguide/java_library_plugin.html
https://docs.gradle.org/current/userguide/declaring_dependencies_basics.html#sec:project-dependencies
https://docs.spring.io/spring-boot/docs/3.2.5/gradle-plugin/reference/htmlsingle/
https://medium.com/@garvitbhardwaj06022000/difference-between-gradle-plugin-and-dependencies-1d95fa928eca

profile
개인용으로 공부하는 공간입니다. 피드백 환영합니다 🙂

0개의 댓글

관련 채용 정보