상세 컨텐츠

본문 제목

스럽 서버 멀티모듈 전환기

Backend

by 준빅 2024. 9. 30. 15:14

본문

1. 문제

현재 스럽의 서버는 다음과 같이 총 3개로 이루어져 있습니다.

Admin API Batch

 

또한 스럽의 데이터베이스는 50개가 넘어가는 테이블을 가지고 있습니다.

 

스럽 DB



이 두 가지 상황이 겹쳐 문제가 발생하였습니다.

JPA를 사용하는 환경에서 데이터베이스 테이블이 50개가 넘어가면, JPA Entity 또한 50개가 넘어가는 상황이 됩니다. 이에 따라서 하나의 JPA Entity가 수정/추가/삭제되면 3번의 중복된 작업을 해야 했습니다.

 

2. 해결할 방법 모색

2-1. 각 서버에서 필요한 JPA Entity만 사용하기

가장 먼저 떠올린 방법은 JPA Entity의 특징을 사용하는 것이었습니다. JPA Entity가 있고, 데이터베이스 테이블이 없을 때는 문제가 발생하지만, 데이터베이스 테이블은 있고, JPA Entity가 없을 때는 문제가 발생하지 않습니다.

 

이 특징을 이용해서, 각 서버별로 필요한 JPA Entity만 생성해서 사용하는 방법이었습니다.

 

하지만, 스럽의 핵심인 연예인(Celeb)과 같은 JPA Entity는 셀럽 크롤링을 하는 Batch 서버에도  필요하고, 크롤링 된 데이터를 검증해서 반영하는 Admin 서버에도 필요하고, 실제 서비스를 담당하는 API 서버에도 필요합니다. 이렇게 한다면 중복이 줄어들긴 하겠지만, 여전히 중복이 존재하게 됩니다.

 

2-2. 하나의 서버에 통합

이 방법은 1차원적으로 3개의 서버를 한 개로 합치는 것입니다.

어찌 보면 가장 단순한 방법일 순 있지만, Batch 서버의 Job이 돌아가는 시간에 서비스가 느려진다던지, Batch 서버와 Admin 서버의 트래픽이 실제 사용자에게 서비스를 제공하는 API 서버의 성능에 영향을 줄 수 있는 구조입니다.

 

2-3. 멀티모듈

이 방법은 기능을 모듈단위로 나누어서 하나의 프로젝트로 합치는 것입니다.

2번 방법과 비슷하지만 모듈로 나누어서 관리할 수 있다는 점이 장점입니다.

 

2-4. MSA

도메인 혹은 기능을 기준으로 서버를 나누고, API 혹은 Messaging Queue로 서로가 필요한 정보를 주고받는 구조입니다.

하지만 서버에 대한 리소스도 많이 필요하고, MSA의 책임을 분리한다는 장점을 완벽하게 가져가려면 DB까지 분리해야 하기에 인력도 많이 필요하다는 특징이 있습니다.


3번 멀티모듈로 결정

 

1번은 문제가 줄어들긴 하지만, 해결되지 않을 것이라 생각했습니다.

2번의 문제를 멀티모듈의 모듈에 Dockerfile을 심어 각 모듈별로 배포한다면 해결할 수 있을 것이라 판단했습니다.

4번은 기본적으로 서버, DB 리소스도 많이 필요하고 MSA를 구축하고 유지 보수하기 위한 인력이 부족하다고 판단했습니다.

 

최종적으로, 스럽 팀은 위 방법들 중 3번인 멀티모듈로의 전환을 통해 문제를 해결하기로 결정했습니다.

 

3. 멀티모듈 전환 과정

도메인별로 모듈을 나누는 방법과 기능별로 모듈을 나누는 방법 중에서 기능별로 모듈을 나누기로 결정했습니다.

 

이유는 도메인별로 모듈을 나누면 너무 많은 모듈이 생길 것이라 생각했고, 기존의 구조에서 전환 시 비용이 가장 적게 발생할 것이라 판단하였습니다.

3-1. 구조 설계

기본적으로 Repository와 Entity를 Domain 모듈로 분리하였고, 각 모듈의 Service와 Repository 사이에 책임을 분리하기 위해서 DomainService를 두었습니다.

기존 구조(좌)와 개선된 구조(우)

 

최종적으로 Infra와 Common까지 분리하여 다음과 같은 구조로 개선하였습니다.

최종 구조

 

3-2. Build 파일 설정

가장 먼저 root 폴더 안에 settings.gradle을 작성해 주었습니다.

/* ~/settings.gradle */
rootProject.name = 'sluv'
include 'sluv-api'
include 'sluv-domain'
include 'sluv-common'
include 'sluv-infra'
include 'sluv-admin'
include 'sluv-batch'

 

해당 파일에 모듈들의 이름을 적으면 모듈로 인식될 것입니다.

 

이후 멀티모듈을 위해선 Gradle 파일을 수정해야 했습니다.

/* ~/build.gradle */
plugins {
    id 'java'
    id 'org.springframework.boot' version '3.0.2'
    id 'io.spring.dependency-management' version '1.1.0'
}

bootJar.enabled = false

repositories {
    mavenCentral()
}

subprojects {
    group = 'com.sluv'
    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'

    repositories {
        mavenCentral()
    }

    dependencies {
        implementation 'org.springframework.boot:spring-boot-starter'
        implementation 'org.springframework.boot:spring-boot-starter-validation:3.0.4'

        compileOnly 'org.projectlombok:lombok'
        annotationProcessor 'org.projectlombok:lombok'


        testImplementation 'org.springframework.boot:spring-boot-starter-test'
        testImplementation 'com.h2database:h2'


    }

    tasks.withType(ProcessResources) {
        from(rootProject.file('env')) {
            include '.env', 'firebase/*'
            into 'env'
        }
    }

    test {
        useJUnitPlatform()
    }
}

 

root 폴더에 build.gradle을 위와 같이 설정하였습니다. 위 설정은 모든 모듈에 적용됩니다.

특징 있는 부분은 환경 변수를 submodule로 정해서 build 시 각 모듈의 resource에 주입해 주는 코드가 추가되어 있습니다.


이후 각 모듈별로 필요한 설정을 각 모듈의 build.gradle에 작성해 주면 됩니다.

스럽 팀은 서버 배포의 기준이 될 모듈(Admin, Api, Batch)과, 각 배포될 모듈에 하나씩 추가될 모듈(Common, Domain, Infra)로 나누었습니다.

 

서버 배포의 기준이 될 모듈(예시 : Api 모듈)

/* api-module/build.gradle  */

plugins {
    id 'java'
}

group 'com.sluv'
version 'unspecified'

bootJar.enabled = true

jar.enabled = false

dependencies {

    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-security'

    //swagger
    implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.3'

    testImplementation 'org.springframework.security:spring-security-test'

    implementation project(':sluv-domain')
    implementation project(':sluv-common')
    implementation project(':sluv-infra')

}

tasks.named('test') {
    dependsOn ':sluv-domain:test', ':sluv-common:test', ':sluv-infra:test'
}

 

서버 배포의 기준이 될 모듈은 위와 같이 구성했습니다. 포인트는 plugins, group, version, bootJar.enabled, jar.enabled 옵션을 추가합니다. 이후 dependencies에 해당 모듈에 필요한 종속성을 추가하고 해당 모듈에서 필요한 기능을 가지고 있는 모듈도 추가해 줍니다.

마지막으론 해당 모듈의 Test를 진행 시, 함께 Test를 진행할 모듈도 정해줍니다.

 

각 배포될 모듈에 하나씩 추가될 모듈(예시 : Common 모듈)

/* common-module */

dependencies {

    // JWT
    implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
    implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
    implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
    implementation 'com.nimbusds:nimbus-jose-jwt:9.30.2'

    // Jasypt
    implementation 'com.github.ulisesbocchio:jasypt-spring-boot-starter:3.0.5'

}

 

위와는 다르게 plugins, group, version, bootJar.enabled, jar.enabled는 모두 작성하지 않고 dependencies만 작성해 주었습니다.

 

이후 명령어는 IDE의 Gralde 탭을 사용하거나, 기존에 ./gradle clean build라고 했던 것을 ./gralde :sluv-api:clean :sluv-api:build 라고 작성해 주면 됩니다.

 

 

4. DevOps

Github Actions와 도커를 이용하여 배포하였습니다.

4-1. Dockerfile 설정

배포될 Docker 이미지를 만드는 Dockerfile을 만들어줍니다.

하나의 프로젝트에서 총 3개의 서버를 배포할 예정이기 때문에, Dockerfile은 각각 Admin 모듈, Api 모듈, Batch 모듈에 각각 1개씩 총 3개를 만들어줍니다.

 

/* Dockerfile */
FROM eclipse-temurin:17-jdk
ARG JAR_FILE_PATH=build/libs/*.jar
COPY ${JAR_FILE_PATH} app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

 

4-2. Github Actions 스크립트

CI/CD 파이프라인을 만들 때 집중한 부분은 Admin, Api, Batch 총 3개의 도커 컨테이너를 띄울 것이고, 각 컨테이너가 업데이트될 땐, 다른 2개의 컨테이너는 변경 없이 그대로 있어야 한다는 점입니다.

- Admin 모듈 수정 -> Admin 컨테이너 업데이트
- Api 모듈 수정 -> Api 컨테이너 업데이트
- Batch 모듈 수정 -> Batch 컨테이너 업데이트
- Common, Domain, Infra 모듈 수정 -> Admin, Api, Batch 컨테이너 업데이트

 

때문에 기존에 Github 브랜치 단위로 작성된 스크립트를 모듈별로 작성하였습니다.

 

기존 스크립트 파일들(좌)과 개선된 스크립트 파일들(우)

 

# api-module-ci-cd


name: Api-Module CI/CD

on:
  push:
    branches: [ main, develop ]
    paths:
      - 'sluv-api/**' # 스크립트 별로 이 부분만 수정
      - 'sluv-domain/**'
      - 'sluv-common/**'
      - 'sluv-infra/**'

concurrency:
  group: ${{ github.ref_name }}

jobs:
  api-ci-cd:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout Latest Repo
        uses: actions/checkout@v3
        with:
          token: ${{ secrets.TOKEN_OF_GITHUB }}
          submodules: true

      - name: Set up JDK 17
        uses: actions/setup-java@v3
        with:
          java-version: '17'
          distribution: 'temurin'

      - name: Grant execute permission for gradlew
        run: chmod +x gradlew

      - name: Build with Gradle
        run: ./gradlew :sluv-api:clean :sluv-api:build

      - name: Docker build
        run: |
          docker login -u ${{ secrets.DOCKERHUB_USERNAME }} -p ${{ secrets.DOCKERHUB_TOKEN }}
          docker build -t {tagName} sluv-api/.
          docker tag {tagName} {dockerhub-name}/{tagName}:latest
          docker push {dockerhub-name}/{tagName}:latest

      - name: Deploy to Local Server
        uses: appleboy/ssh-action@master
        with:
          host: ${{ secrets.LOCAL_SERVER_HOST }}
          username: ${{ secrets.LOCAL_SERVER_USER }}
          password: ${{ secrets.LOCAL_SERVER_SSH_PASSWORD }}
          script: |
            branchName=${{ github.ref_name }}
            echo ${{ secrets.LOCAL_SERVER_SUDO_PASSWORD }} | sudo -S chmod +x {build-script-path}
            echo ${{ secrets.LOCAL_SERVER_SUDO_PASSWORD }} | sudo -S {build-script-path}

 

Github Actions의 paths 옵션을 사용해서 모듈의 코드가 수정됐을 경우 감지해서 알맞는 도커 컨테이너를 업데이트할 수 있게 끔 설정했습니다.

 

이후 Docker-Compose를 통해 컨테이너를 제어하고, 스크립트를 작성해서 docker-compose pull과 up을 통해서 컨테이너를 업데이트했습니다.

 

5. 전환 과정 중 어려웠던 점

전환 과정 중 어려웠던 부분은 Dto와 관련해서 타입의 import 문제와 CI/CD 파이프라인 구축에 있었습니다.

5-1. Redis 사용 중 Dto 타입의 Import 문제

아이템 게시글 상세조회 페이지에서는 JPA Entity가 총 11개가 사용됩니다. 이로 인한 성능을 개선하기 위해서 데이터의 변동성을 분석하고 변동 확률 이 낮은 데이터를 Redis에 캐싱 하는 전략을 사용하였습니다.

 

이 부분을 멀티모듈로 전환하면서 Dto 관련된 문제가 발생했습니다.

Redis는 Infra 모듈에 있고, 캐싱 될 데이터를 저장하거나 쓰는 곳은 Api 모듈이였습니다. Redis 저장하거나 사용될 때 ItemFixData라는 Dto를 사용했습니다. 하지만, 해당 Dto의 필드들은 Api 모듈에서 공용으로 사용하는 Dto들이였습니다. 

 

ItemFixData를 Infra 모듈에 넣자니, 해당 Dto를 이루는 필드 들은 Api 모듈에서 공용으로 사용하는 Dto였기에 불가능합니다. 그렇다고 Api 모듈에 넣자니, Infra 모듈이 Api 모듈의 종속성을 갖을 수 없고(종속성 순환 문제 발생), 이로 인해 ItemFixData를 import 할 수 없었습니다.

 

이 문제를 해결하고자 ItemFixData와 구조가 같은 Dto를 Infra 모듈에 만들고, Api 모듈에서 Mapper를 통해 맵핑하는 방법을 생각했습니다. 하지만 해당 작업은 중복 코드의 증가를 초래했기에 다른 방법을 모색했고, 중복 코드를 사용하지 않고 처리하기 위해 Object 클래스를 사용했습니다.

public interface CacheService {

	void saveItemDetailFixData(Long itemId, Object itemDetailFixData);

	Object findItemDetailFixDataByItemId(Long itemId);

}

 

Infra 모듈의 CacheService에서 해당 Dto의 필드에 접근해서 캐싱 하는 것이 아닌, Serialize를 통해 Dto를 통으로 캐싱 하기 때문에 Object 클래스로 받아도 충분할 것이라고 판단했습니다.

 

 

5-2. Github Actions 동시 Run 시 충돌 문제

각 모듈의 도커 컨테이너가 업데이트될 때 다른 컨테이너에 영향을 끼치지 않게 Github Actions 스크립트를 3개로 분리했습니다. 이렇게 되면, Admin, Api, Batch 모델이 수정되면 문제가 없지만, Common, Domain, Infra 모듈이 수정될 경우 3개의 스크립트가 동시에 비동기로 Run 되기 때문에 문제가 발생했습니다.

 

 

3개의 워크플로우가 동시에 비동기로 Run 되기 때문에 docker-compose up을 하는 과정에서 이미 있는 컨테이너가 존재한다면서 에러가 발생하는 문제였습니다.

 

needs로 해결 시도

최초에는 needs를 사용해서 워크플로우가 끝나면 순서대로 실행되게 할 수 있을 것이라 생각했습니다. 하지만 이 경우, Admin->Api->Batch 순서로 needs를 설정하면 Api 모듈에만 수정이 일어났을 때 Admin 모듈의 job은 실행되지 않기 때문에 Api에 대한 워쿼플로우도 실행되지 않게 된다는 걸 알 수 있었습니다.

 

concurrency를 통해 해결.

최종적으로는 Github Actions의 concurrency group을 통해 해결하였습니다.

 

concurrency group은 같은 그룹 내의 워크플로우는 동시에 실행되지 않게 해주는 기능입니다. 따라서 Admin, Api, Batch를 같은 그룹으로 묶어 놓으면 동시 실행되지 않아 충돌을 방지할 수 있었습니다.

 

 

6. 최종 아키텍처

 

6-1. 디렉토리 구조

최종 구조

6-2. CI/CD 파이프라인

CI/CD 파이프라인

 

 

관련글 더보기

댓글 영역