NYO_O

공통 모듈의 역할별 분리 전략 본문

Architecture/MSA

공통 모듈의 역할별 분리 전략

NYO_O 2026. 5. 27. 08:41
반응형

지난 시간까지 공통 모듈을 설계하고, 배포 파이프라인을 구축하고, 실제로 서비스에 연동하는 과정을 살펴보았습니다. 전역 예외 처리와 페이징 표준화 같은 기능들도 공통 모듈에 하나씩 쌓아왔습니다.

2026.05.27 - [Tech/MSA] - 공통 모듈 의존성 주입 및 연동 가이드

 

공통 모듈 의존성 주입 및 연동 가이드

지난 시간, 공통 모듈을 GitHub Packages에 배포하고 GitHub Actions로 자동화 파이프라인을 구성하는 방법을 살펴보았습니다. 이제 패키지 레지스트리에는 우리가 만든 공통 모듈이 버전별로 잘 쌓여

ddangnyo.tistory.com

그런데 어느 순간 이런 고민이 생기기 시작합니다.

"공통 모듈이 점점 커지고 있는데, 이대로 계속 하나의 모듈에 모든 걸 담아도 괜찮을까?"

처음에는 하나의 모듈로 시작하는 게 자연스럽습니다. 하지만 서비스가 늘어나고 공통 모듈에 담기는 기능이 많아질수록, 단일 모듈 구조의 한계가 조금씩 드러나기 시작합니다. 오늘은 공통 모듈을 역할별로 어떻게 나눌 수 있는지, 그리고 언제 분리를 고려해야 하는지 정리해 보겠습니다.

단일 모듈 구조의 한계

공통 모듈이 하나일 때는 관리가 간단합니다. 의존성도 한 줄, 버전 관리도 하나. 하지만 규모가 커지면서 몇 가지 불편함이 생깁니다.

불필요한 의존성 포함 문제

예외 처리 유틸리티만 필요한 서비스도 JWT 처리, 페이징, 암호화 유틸리티 등 모든 기능을 함께 받아가게 됩니다. 클래스패스에 사용하지 않는 라이브러리까지 올라가는 상황입니다.

변경의 영향 범위 문제

페이징 관련 코드를 수정했을 뿐인데, 전혀 관계없는 예외 처리나 보안 유틸리티까지 새 버전을 받아야 합니다. 변경 하나가 모든 서비스의 의존성 업그레이드를 유발합니다.

빌드 및 배포 속도 문제

공통 모듈 전체를 매번 빌드하고 테스트해야 하기 때문에, 작은 수정 하나에도 전체 빌드 시간이 소요됩니다.

이런 불편함들이 쌓이기 시작하면, 역할별 분리를 고민할 시점이 된 것입니다.

역할별 분리 전략: 멀티 모듈 구조

공통 모듈을 역할에 따라 여러 개의 서브 모듈로 나누는 것이 핵심입니다. 하나의 Git 저장소 안에서 Gradle 멀티 모듈 프로젝트로 구성하면, 각 모듈을 독립적으로 관리하면서도 하나의 저장소에서 버전과 의존성을 함께 관리할 수 있습니다.

분리 기준

모듈을 나눌 때는 "이 기능들이 함께 변경될 이유가 있는가"를 기준으로 삼으면 좋습니다. 자주 함께 변경되는 것들은 같은 모듈에, 독립적으로 변경되는 것들은 별도 모듈로 분리하는 방식입니다.

아래는 실무에서 자주 활용하는 분리 방식입니다.

common-module (루트)
  ├── common-core          # 핵심 공통 요소 (응답 형식, 예외 정의, 유틸리티)
  ├── common-web           # 웹 계층 공통 요소 (예외 핸들러, 페이징, 인터셉터)
  ├── common-security      # 보안 공통 요소 (JWT, 인증/인가)
  └── common-data          # 데이터 계층 공통 요소 (JPA Auditing, 공통 엔티티)

각 모듈의 역할을 조금 더 구체적으로 살펴보겠습니다.

common-core

가장 기본이 되는 모듈입니다. 다른 공통 모듈들도 이 모듈을 의존하는 구조로 설계합니다.

  • 공통 응답 형식 (ApiResponse<T>)
  • 공통 예외 클래스 (BusinessException, ErrorCode 등)
  • 범용 유틸리티 클래스 (날짜, 문자열, 컬렉션 등)
  • 공통 상수 및 열거형(Enum)
의존성: 최소화 (spring-boot-starter 정도)
변경 빈도: 낮음 — 한번 정의하면 거의 바뀌지 않음

common-web

웹 계층과 관련된 공통 기능을 담습니다. common-core를 의존합니다.

  • 전역 예외 처리 (@RestControllerAdvice)
  • 페이징 표준화 (CustomPageResolver)
  • 공통 인터셉터, 필터
  • 요청/응답 로깅
의존성: common-core + spring-boot-starter-web
변경 빈도: 중간 — 웹 계층 정책 변경 시 수정

common-security

인증·인가와 관련된 기능을 별도로 분리합니다. 보안 관련 코드는 민감도가 높고 변경의 파급 효과가 크기 때문에, 독립된 모듈로 관리하는 것이 개인적으로는 더 안전하다고 생각합니다.

  • JWT 생성·검증 유틸리티
  • Spring Security 공통 설정
  • 인증 필터, 권한 체크 유틸리티
의존성: common-core + spring-boot-starter-security + jwt 라이브러리
변경 빈도: 낮음 — 보안 정책 변경 시 신중하게 수정

common-data

JPA, 데이터 계층과 관련된 공통 요소를 담습니다. DB를 사용하지 않는 서비스는 이 모듈을 가져가지 않아도 됩니다.

  • 공통 Base 엔티티 (BaseTimeEntity, BaseEntity)
  • JPA Auditing 설정
  • 공통 Repository 인터페이스
의존성: common-core + spring-boot-starter-data-jpa
변경 빈도: 낮음 — DB 스키마 전략 변경 시 수정

멀티 모듈 Gradle 프로젝트 구성

루트 settings.gradle

// settings.gradle

rootProject.name = 'common-module'

include 'common-core'
include 'common-web'
include 'common-security'
include 'common-data'

루트 build.gradle

// build.gradle (루트)

plugins {
    id 'java-library'
    id 'maven-publish'
    id 'org.springframework.boot' version '3.x.x' apply false
    id 'io.spring.dependency-management' version '1.x.x'
}

subprojects {
    apply plugin: 'java-library'
    apply plugin: 'maven-publish'
    apply plugin: 'io.spring.dependency-management'

    group = 'com.example'
    version = rootProject.version

    java {
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
    }

    // 공통 모듈은 라이브러리이므로 bootJar 비활성화
    tasks.withType(org.springframework.boot.gradle.tasks.bundling.BootJar) {
        enabled = false
    }
    jar { enabled = true }

    dependencyManagement {
        imports {
            mavenBom "org.springframework.boot:spring-boot-dependencies:3.x.x"
        }
    }

    publishing {
        publications {
            mavenJava(MavenPublication) {
                from components.java
                groupId = 'com.example'
                artifactId = project.name
                version = project.version
            }
        }
        repositories {
            maven {
                name = 'GitHubPackages'
                url = uri('https://maven.pkg.github.com/YOUR_GITHUB_USERNAME/YOUR_REPO_NAME')
                credentials {
                    username = System.getenv('GITHUB_ACTOR')
                    password = System.getenv('GITHUB_TOKEN')
                }
            }
        }
    }
}

각 서브 모듈 build.gradle

// common-core/build.gradle

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
}
// common-web/build.gradle

dependencies {
    api project(':common-core')  // common-core를 전이 의존성으로 노출
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
}
// common-security/build.gradle

dependencies {
    api project(':common-core')
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'io.jsonwebtoken:jjwt-api:0.12.x'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.x'
    runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.x'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
}
// common-data/build.gradle

dependencies {
    api project(':common-core')
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
}

여기서 api와 implementation의 차이를 짚어두는 것이 좋습니다. api로 선언하면 이 모듈을 가져가는 도메인 서비스에도 해당 의존성이 함께 전이됩니다. 반대로 implementation은 이 모듈 내부에서만 사용되고 전이되지 않습니다. common-core처럼 외부에서도 반드시 필요한 경우에는 api를, 내부 구현에만 필요한 경우에는 implementation을 사용하는 것이 좋습니다.

도메인 서비스에서의 선택적 의존성 선언

멀티 모듈로 분리된 공통 모듈은 도메인 서비스에서 필요한 것만 골라서 가져갈 수 있습니다.

// 일반적인 API 서비스 - 웹 계층과 데이터 계층이 필요한 경우
dependencies {
    implementation 'com.example:common-web:1.0.0'
    implementation 'com.example:common-data:1.0.0'
    // common-core는 common-web이 api로 전이해주므로 별도 선언 불필요
}

// 보안 기능이 필요한 서비스
dependencies {
    implementation 'com.example:common-web:1.0.0'
    implementation 'com.example:common-security:1.0.0'
    implementation 'com.example:common-data:1.0.0'
}

// 단순 배치 서비스 - 웹 계층 없이 핵심 기능만 필요한 경우
dependencies {
    implementation 'com.example:common-core:1.0.0'
    implementation 'com.example:common-data:1.0.0'
}

각 서비스가 자신에게 필요한 모듈만 가져가는 구조가 만들어집니다.

버전 관리 전략

멀티 모듈로 분리하면 버전 관리 방식에 대한 고민도 함께 따라옵니다. 크게 두 가지 방식을 고려해볼 수 있습니다.

통합 버전 관리 (Monorepo 방식)

모든 서브 모듈이 루트의 버전을 공유합니다. common-core:1.2.0, common-web:1.2.0처럼 항상 같은 버전으로 맞춥니다.

장점: 버전 조합을 고민하지 않아도 됩니다. "1.2.0 세트"를 쓰면 됩니다.
단점: 한 모듈만 변경해도 전체 버전이 올라갑니다.

독립 버전 관리

각 서브 모듈이 독립적인 버전을 가집니다. common-core:1.0.3, common-web:1.2.0처럼 별도로 관리합니다.

장점: 변경이 있는 모듈의 버전만 올라갑니다.
단점: 모듈 간 호환 버전을 별도로 관리해야 합니다.

팀 규모나 변경 빈도에 따라 다르겠지만, 초기에는 통합 버전 관리로 시작하는 것이 운영 부담이 적어서 개인적으로는 이 방식을 더 선호합니다. 모듈별 변경 빈도 차이가 뚜렷하게 생기는 시점에 독립 버전으로 전환을 고려해보는 것이 좋습니다.

분리 시점에 대한 현실적인 기준

역할별 분리가 이론적으로는 좋지만, 처음부터 과도하게 분리하면 오히려 복잡도만 늘어날 수 있습니다. 아래 상황들이 하나씩 해당되기 시작하면 분리를 고려해보는 것을 권장합니다.

  • 서비스가 4개 이상으로 늘어났고, 각자 필요한 공통 기능이 달라지기 시작했을 때
  • 공통 모듈의 특정 기능 변경이 다른 기능과 전혀 무관한데도 버전을 함께 올려야 하는 상황이 반복될 때
  • 공통 모듈의 빌드·테스트 시간이 길어져서 개발 생산성에 영향을 주기 시작했을 때
  • 보안 관련 코드처럼 민감도가 높은 영역을 별도로 리뷰·관리하고 싶을 때

반대로, 서비스가 2~3개 수준이고 공통 모듈이 크지 않다면 단일 모듈로도 충분합니다. 분리는 문제가 생겼을 때 해결책이지, 처음부터 갖춰야 하는 구조는 아닙니다.

마무리

오늘은 공통 모듈이 성장했을 때 역할별로 어떻게 분리할 수 있는지, 그리고 실제 Gradle 멀티 모듈 구성까지 살펴보았습니다. common-core, common-web, common-security, common-data로 나누는 방식은 하나의 예시일 뿐이고, 팀의 상황과 서비스 구조에 맞게 기준을 잡아가는 것이 중요합니다.

MSA 환경에서 공통 모듈은 처음엔 작게 시작해서 서비스와 함께 자라는 것이 자연스럽습니다. 오늘 살펴본 분리 전략이 그 성장 과정에서 하나의 나침반이 되었으면 합니다.

반응형