[Spring Multi-module, Docker, Git Actions] Multi module 개발 환경 구성하기

이승현·2024년 8월 25일

Spring

목록 보기
3/3

Github

몇 번의 스프링 프로젝트를 진행하면서 구성했던 구조는 모놀리식(Monolithic) 구조였다. 하나의 spring 앱을 구현하고, 별도의 서버 환경에 이를 배포, 실행하는 구조였다. 이러한 모놀리식이 나쁜 구조는 아니다. 작은 규모에서는 프로젝트를 빠르게 구성하고 제작하는데 있어 장점을 가진다. 다만 프로젝트의 규모가 커지면 배포의 시간이 길어지고, 작은 오류가 전체 시스템을 멈추는 전형적인 "복잡성 증가 문제"가 발생한다.

이러한 문제를 해결하기 위한 방법은 모듈 방식을 도입해서 해결할 수 있다. MSA(Microservices architecture) 방식은 독립적으로 동작할 수 있는 여러 서비스를 조합해 전체를 구성하는 아키텍쳐 스타일에 해당한다. 당연히 쉽게 확장하고 유연하게 변화시킬 수 있다.


Docker-compose

도커가 가지는 컨테이너화를 통해 MSA 구조를 구현할 수 있다. Docker는 각 스프링 앱을 컨테이너로 구동하면서 필요한 부분만 빌드하여 배포하는 방식이 된다. 도커 컴포즈는 이렇게 만들어진 다수의 도커 컨테이너를 효과적으로 관리할 수 있게 된다.

Git Actions

도커에 대한 배포를 하나하나 수동으로 할 수 있지만 그러기엔 세상이 너무 좋아졌다. 간단하게 git push를 하는 것만으로도 도커의 배포까지 이루어질 수 있도록 과정을 줄일 필요가 있다.

Spring Multi-module

이제 단일 앱이 아닌 다수의 스프링 앱이 만들어져야 한다. 물론 개별적인 앱을 만들어도 되겠지만 당연히 코드의 중복이 나타나고 수정이 어렵다. 따라서 하나의 스프링 프로젝트 안에서 모듈을 통해 다수의 앱을 구현하는 방법을 사용해야 한다. Multi-module 방식을 활용해보자.


파일 구조는 다음과 같다.

root-project/
├── .github/workflows
│   └── deploy.yml
├── module-common/
│   ├── Dockerfile
│   ├── build.gradle
│   └── src/
├── module-A/
│   ├── Dockerfile
│   ├── build.gradle
│   └── src/
├── module-B/
│   ├── Dockerfile
│   ├── build.gradle
│   └── src/
├── gradle/
├── docker-compose.yml
├── gradlew
├── build.gradle
└── settings.gradle

deploy.yml : Git push 후 역할을 수행한다. 여기서 변경 감지와 도커 실행을 수행한다.
module-common/ : 다른 모듈이 참조할 수 있는 기반 모듈
module-A/ : 앱이 실행되는 모듈
docker-compose.yml : 도커 컨테이너의 이름과 경로 상의 Dockerfile을 통해 컨테이너를 만들도록 명령을 내린다.
Dockerfile : 해당 경로에서 어떤 파일을 통해 도커 컨테이너를 만들지 처리하게 된다.


이제 각 코드를 보면서 흐름을 한 번 살펴보자

deploy.yml

name: Deploy to Ubuntu Server
on:
  push:
    branches:
      - main
      - develop
jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v3
        with:
          fetch-depth: 2  # 변경 감지를 위한 이전 파일 확인

      - name: Set up SSH
        uses: webfactory/ssh-agent@v0.7.0
        with:
          ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}

      - name: Copy files via SSH
        run: |
          rsync -avz -e "ssh -o StrictHostKeyChecking=no" ./ ${{ secrets.USER }}@${{ secrets.HOST }}:/home/leesh/spring/Multi-module

      - name: Deploy with Docker Compose
        run: |
          ssh -o StrictHostKeyChecking=no ${{ secrets.USER }}@${{ secrets.HOST }} << 'EOF'
          cd /home/leesh/spring/Multi-module
          
          # Detect which modules changed
          changed_modules=$(git diff --name-only HEAD^ HEAD)

          # Build only if specific directories have changed
          if echo "$changed_modules" | grep -q '^module-A/'; then
            echo "Changes detected in module-A"
            docker-compose build module-A
          fi

          if echo "$changed_modules" | grep -q '^module-B/'; then
            echo "Changes detected in module-B"
            docker-compose build module-B
          fi
          
          docker-compose up -d
          EOF

먼저 deploy.yml를 살펴보면 각 작업이 name과 run으로 나누어져서 수행이 이루어진다. 여기서 중요한 점은 해당 서버에 SSH로 접근한다는 점이다. 당연히 해당 서버에 SSH 키를 확인해서 Git Secret 변수로 저장되어야 한다.
그 다음 SSH 접근 후 git diff를 통해서 이전 커밋과 변경점을 비교한다. 정확히는 changed_modules에 변경된 파일의 경로와 이름을 반환받게 된다. 각 파일들을 통해서 어느 모듈에 변경점이 있었는지 파악하고 build 과정을 처리하게 된다.

docker-compose.yml

version: '3.8'

services:
  mysql:
    image: mysql:8.0
    container_name: mysql
    environment:
      MYSQL_ROOT_PASSWORD: root_password
      MYSQL_DATABASE: database
      MYSQL_USER: user
      MYSQL_PASSWORD: password
    ports:
      - "3306:3306"
    volumes:
      - mysql_data:/var/lib/mysql

  redis:
    image: redis:latest
    container_name: redis
    ports:
      - "6379:6379"

  module-a:
    build:
      context: ./  # Root context for the build
      dockerfile: module-a/Dockerfile
    container_name: module-a
    ports:
      - "8081:8080"
    depends_on:
      - mysql
      - redis

  module-b:
    build:
      context: ./  # Root context for the build
      dockerfile: module-b/Dockerfile
    container_name: module-b
    ports:
      - "8082:8080"
    depends_on:
      - mysql
      - redis

volumes:
  mysql_data:

도커 빌드를 시작하면 docker-compose.yml이 다음 단계를 처리한다. 각 단계에서 mysql과 redis를 컨테이너로 만든다. (중요한 건 여기의 변수는 해당 컨테이너를 생성하는데 사용되는 변수다. 따라서 각 컨테이너의 앱이 사용할 땐 여기 사용자 이름과 변수를 사용하도록 만들자.) 이후 각 모듈 내부의 Dockerfile을 기반으로 각 컨테이너가 실행되게 된다.
포트의 경우 8081:8080과 같은 방식인데 이는 도커 컨테이너가 내부 네트워크를 가지기 때문에 나름의 포트포워딩이 필요하기 때문이다. 그래서 서버가 받는 8081의 요청은 도커의 8080으로 넘어가고 앱은 8080포트의 입력을 처리하도록 만들 필요가 있다.

module-a/Dockerfile

# Base image
FROM openjdk:17-jdk-slim

# Set working directory
WORKDIR /app

# Copy Gradle files from the root context to the service context
COPY gradlew /app/
COPY gradle /app/gradle/
COPY build.gradle /app/

COPY module-common/build.gradle /app/module-common/
COPY module-common/src /app/module-common/src

COPY module-a/build.gradle /app/module-a/
COPY module-a/src /app/module-a/src

#Copy settings.gradle for module
COPY module-a/settings.gradle /app/

# Ensure gradlew is executable
RUN chmod +x gradlew

# Build the application
RUN ./gradlew build -x test --stacktrace

# Copy the built JAR file to /app.jar
RUN cp module-a/build/libs/module-a.jar /app.jar

# Expose port
EXPOSE 8080

# Run the application
CMD ["java", "-jar", "/app.jar"]

이제 Dockerfile에서 빌드와 실행을 진행하게 되고 하나의 컨테이너로 앱이 실행된다. 중요한 점은 여기가 docker-compose.yml에서 설정한 context 경로라는 점이다. Multi-module 방식이 아니면 상관 없지만 지금은 각 모듈을 기준으로 앱을 실행해야 한다. 하지만 root에 있는 파일을 필요로 하기 때문에 일부를 복사해야 하는데 Dockerfile은 자기보다 상위 경로는 접근을 못한다. 그래서 root 경로에서 모듈 경로를 찾아 필요한 파일을 복사해서 WORKDIR로 옮긴 다음에 build의 과정을 거쳐 .jar 파일을 만드는 것이 목표다.
필요한 파일은 gradlewgradle 경로의 파일들, 루트 경로의 build.gradle 파일과 각 모듈의 build.gradlesrc의 java 파일, settings.gradle이다.
build.gradle에서는 의존성을 관리하고 settings.gradle에서는 모듈간 관계를 설정한다.

Trouble Shooting
Caused by: org.gradle.api.InvalidUserDataException: Main class name has not been configured and it could not be resolved from classpath
해당 에러때매 고생한 기록이 있어 같이 남긴다... settings.gradle의 경우 root 경로에서 복사해서 붙였더니 필요 이상의 모듈을 빌드에 포함시켜려고 하면서 나타난 문제였다. 해결 완료!

여기까지 하면 필요 파일을 이용해 복사를 진행하면 jar 파일이 만들어지고 이를 8080 포트로 컨테이너가 실행되도록 만들어주게 된다.


gradle이 하나일 경우 복잡할 일이 없지만 Multi-module이 되는 순간 너무 복잡해진다.

root/build.gradle

plugins {
	id 'java'
	id 'org.springframework.boot' version '3.3.3'
	id 'io.spring.dependency-management' version '1.1.6'
}

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

repositories {
	mavenCentral()
}

subprojects { // 모든 하위 모듈들에 이 설정을 적용합니다.
	group 'com.example'
	version '0.0.1-SNAPSHOT'

	sourceCompatibility = '17'

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

	configurations {
		compileOnly {
			extendsFrom annotationProcessor
		}
	}

	repositories {
		mavenCentral()
	}

	dependencies { // 모든 하위 모듈에 추가 될 의존성 목록입니다.
		implementation 'org.springframework.boot:spring-boot-starter'
		testImplementation 'org.springframework.boot:spring-boot-starter-test'
		implementation 'org.springframework.boot:spring-boot-starter-web'
		developmentOnly 'org.springframework.boot:spring-boot-devtools'
	}
	test {
		useJUnitPlatform()
	}
}

루트 경로에서의 build.gradle의 subprojects는 모든 모듈이 공통적으로 가지는 속성을 정의할 수 있다. 그래서 공통의 의존성을 처리하거나 plugin을 설정하기 좋다.

module-a/build.gradle

dependencies {
    implementation project(':module-common')
}

springBoot{
    mainClass.set('com.example.ModuleApplication') // 여기에 실제 메인 클래스의 경로를 지정
}

bootJar {
    archiveFileName = 'module-b.jar'
}

그래서 모듈에서는 크게 설정할 필요없지만 mainClass에 대한 경로 설정이 필요하다. (여러 모듈을 쓸 경우 해당 앱이 어느 main을 기반으로 실행할 지 알아야 하기 때문이다.) bootJar 옵션은 만들어지는 .jar 파일 이름을 정의한다.
implementation project(':module-common')를 통해 다른 모듈을 기반 모듈로 설정할 수 있다. 해당 모듈만 쓸 의존성을 추가할 수 있다.

module-common/build.gradle

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

dependencies {

}

공통의 기능들, JPA 데이터베이스나 Handler와 같이 모든 모듈이 공통으로 필요로하는 기능들은 공통 모듈로 밀어넣고 사용하는게 좋다. 여기서 중요한 점은 bootJar을 false로 설정해줘야 jar 파일을 만들지 않기 때문에 꼭 설정해주자.
공통 모듈의 경우 어떻게 처리되는지 질문을 받았는데 지금까지 확인한 건 특정 모듈이 build 과정을 처리할 때 의존이 있는 모듈도 함께 포함시켜 build를 하는 것으로 확인되었다.


0개의 댓글