Pingu
영차영차! Backend

마이크로서비스에서 Common Package를 도입하게 된 계기와 경험

2026년 1월 30일
8개 태그
마이크로서비스
Common Package
Multi-Repo
GitHub Packages
Kotlin
Spring Boot
코드 중복
공통 라이브러리

마이크로서비스에서 Common Package를 도입하게 된 계기와 경험

들어가며

마이크로서비스 아키텍처를 구축하면서 여러 서비스 간에 공통으로 사용되는 코드가 점점 늘어나게 되었습니다. 처음에는 각 서비스에 중복 코드를 두었지만, 유지보수의 어려움과 일관성 문제를 겪으면서 공통 라이브러리를 만들어야 한다는 결론에 도달했습니다.

Apache Maven

Apache Maven

이 글에서는 Multi-Repo 구조에서 Common Package를 도입하게 된 배경, 고민했던 점들, 그리고 실제 구현 과정에서 얻은 경험을 공유합니다.

프로젝트 배경

초기 구조: Multi-Repo

우리 프로젝트는 Multi-Repo 구조로 시작했습니다:

s-class/
├── account-service/      # 계정 서비스 (별도 레포지토리)
├── payment-service/      # 결제 서비스 (별도 레포지토리)
├── lms-service/         # 학습 관리 서비스 (별도 레포지토리)
├── notification-service/ # 알림 서비스 (별도 레포지토리)
└── ...

각 서비스는 독립적으로 배포되고 관리되었습니다. 이 구조의 장점은:

  • 독립적인 배포: 각 서비스를 독립적으로 배포 가능
  • 팀별 소유권: 각 서비스를 다른 팀이 소유 가능
  • 기술 스택 자유도: 서비스별로 다른 기술 스택 사용 가능
  • 장애 격리: 한 서비스의 문제가 다른 서비스에 영향 최소화

문제점: 코드 중복

하지만 시간이 지나면서 여러 문제가 발생했습니다:

1. ULID 생성 로직 중복

모든 서비스에서 ULID를 사용하는데, 각 서비스마다 동일한 코드가 반복되었습니다:

// account-service에서
val id = UlidCreator.getUlid().toString()

// payment-service에서
val id = UlidCreator.getUlid().toString()

// lms-service에서
val id = UlidCreator.getUlid().toString()

2. API 응답 형식 불일치

각 서비스마다 API 응답 형식이 달랐습니다:

// account-service
data class Response<T>(val data: T, val message: String)

// payment-service
data class ApiResult<T>(val result: T, val success: Boolean)

// lms-service
data class ServiceResponse<T>(val payload: T, val status: Int)

3. 예외 처리 방식 차이

각 서비스마다 예외 처리 방식이 달라 일관성이 없었습니다:

// account-service
throw AccountException("User not found")

// payment-service
throw PaymentError("Payment failed")

// lms-service
throw LmsException("Course not found")

4. 유틸리티 함수 중복

날짜 변환, 검증 로직 등이 각 서비스에 중복으로 존재했습니다.

고민: Multi-Module vs Multi-Repo

공통 코드를 관리하기 위해 두 가지 옵션을 고려했습니다:

옵션 1: Mono-Repo (Multi-Module)

s-class/
├── common/
│   ├── common-util/
│   ├── common-dto/
│   └── common-web/
├── account-service/
├── payment-service/
└── ...

장점:

  • 코드 공유가 쉬움
  • 버전 관리가 단순함
  • 리팩토링이 용이함

단점:

  • 모든 서비스가 같은 레포지토리에 있어야 함
  • 빌드 시간이 길어질 수 있음
  • 서비스별 독립성이 떨어짐
  • CI/CD 파이프라인이 복잡해짐

옵션 2: Multi-Repo + Common Package

s-class-common/          # 별도 레포지토리
├── common-kotlin-lib/
└── ...

account-service/         # 별도 레포지토리
└── build.gradle.kts에서 common-kotlin-lib 의존성 추가

payment-service/         # 별도 레포지토리
└── build.gradle.kts에서 common-kotlin-lib 의존성 추가

장점:

  • 서비스별 독립성 유지
  • Common Package만 별도로 버전 관리
  • 각 서비스의 CI/CD 파이프라인 독립적
  • 필요시 Common Package만 업데이트 가능

단점:

  • 버전 관리가 복잡할 수 있음
  • 의존성 업데이트 시 여러 서비스 수정 필요

선택: Multi-Repo + Common Package

우리는 Multi-Repo + Common Package 방식을 선택했습니다. 이유는:

  1. 이미 Multi-Repo 구조로 시작: 기존 구조를 크게 바꾸지 않고도 해결 가능
  2. 서비스 독립성 유지: 각 서비스의 독립적인 배포와 관리 가능
  3. 점진적 도입: 기존 서비스에 영향을 최소화하면서 도입 가능
  4. 팀 구조와 맞음: 각 서비스를 다른 팀이 소유하는 구조와 잘 맞음

Common Package 설계 원칙

1. 최소한의 의존성

Common Package는 최소한의 의존성만 포함하도록 설계했습니다:

// 필수 의존성만 implementation
implementation(libs.ulid.creator)

// 선택적 의존성은 compileOnly
compileOnly(libs.spring.web)
compileOnly(libs.spring.data.commons)

이렇게 하면:

  • 라이브러리 크기가 작아짐
  • 사용하는 서비스에서 필요한 버전 선택 가능
  • 불필요한 의존성 충돌 방지

2. 선택적 의존성 관리

Spring 같은 프레임워크는 compileOnly로 선언하고, Maven POM에 optional=true로 표시했습니다:

publishing {
    publications {
        create<MavenPublication>("maven") {
            from(components["java"])
            pom {
                // compileOnly 의존성을 optional로 표시
                withXml {
                    val optionalDeps = listOf(
                        Triple("org.springframework", "spring-web", "6.1.5"),
                        Triple("org.springframework.data", "spring-data-commons", "3.2.0"),
                        // ...
                    )
                    optionalDeps.forEach { (groupId, artifactId, version) ->
                        val dep = dependencies.appendNode("dependency")
                        dep.appendNode("groupId", groupId)
                        dep.appendNode("artifactId", artifactId)
                        dep.appendNode("version", version)
                        dep.appendNode("optional", "true")
                    }
                }
            }
        }
    }
}

3. 단일 모듈 구조

처음에는 각 유틸리티를 별도 모듈로 분리하는 것을 고려했지만, 단일 모듈 구조를 선택했습니다:

common-kotlin-lib/
├── src/main/kotlin/com/sclass/common/
│   ├── util/          # 유틸리티
│   ├── dto/           # DTO
│   ├── exception/     # 예외 처리
│   ├── domain/        # 도메인 모델
│   └── web/           # 웹 관련

이유:

  • 관리가 단순함
  • 하나의 JAR로 배포되어 사용이 쉬움
  • 의존성 관리가 단순함

구현 과정

1. Common Package 생성

# 새 레포지토리 생성
git clone https://github.com/passion-edu/s-class-common.git
cd s-class-common

2. 공통 코드 추출

각 서비스에서 공통으로 사용되는 코드를 추출했습니다:

  • ULID 유틸리티: Ulid.generate(), Ulid.isValid()
  • API 응답 DTO: ApiResponse<T>, PageResponse<T>, ErrorResponse
  • 예외 처리: BusinessException, GlobalExceptionHandler
  • 유틸리티: PaginationUtils, DateTimeUtils, ValidationUtils
  • 로깅: LoggerUtils, LoggerExtensions, @Loggable

3. GitHub Packages 배포

Common Package를 GitHub Packages에 배포했습니다:

// build.gradle.kts
publishing {
    repositories {
        maven {
            name = "GitHubPackages"
            url = uri("https://maven.pkg.github.com/passion-edu/s-class-common")
            credentials {
                username = project.findProperty("gpr.user") as String?
                password = project.findProperty("gpr.token") as String?
            }
        }
    }
}

4. 서비스에 통합

각 서비스의 build.gradle.kts에 의존성을 추가했습니다:

repositories {
    mavenCentral()
    maven {
        name = "GitHubPackages"
        url = uri("https://maven.pkg.github.com/passion-edu/s-class-common")
        credentials {
            username = project.findProperty("gpr.user") as String?
            password = project.findProperty("gpr.token") as String?
        }
    }
}

dependencies {
    implementation("com.s-class:common-kotlin-lib:1.0.0")
}

5. 자동 배포 설정

GitHub Actions를 사용하여 자동 배포를 설정했습니다:

# .github/workflows/publish.yml
on:
  push:
    branches:
      - main  # main 브랜치에 push 시 자동 배포
    tags:
      - 'v*'  # 태그 푸시 시 릴리즈 배포

얻은 효과

1. 코드 중복 제거

이전에는 각 서비스마다 동일한 코드가 있었지만, 이제는 Common Package에서 한 번만 관리합니다:

// 이전: 각 서비스마다
val id = UlidCreator.getUlid().toString()

// 이후: Common Package 사용
val id = Ulid.generate()

2. 일관성 확보

모든 서비스가 동일한 API 응답 형식과 예외 처리 방식을 사용합니다:

// 모든 서비스에서 동일한 형식
return ApiResponse.success(data)
return ApiResponse.error("ERROR_CODE", "Error message")

3. 유지보수 용이

버그 수정이나 기능 추가 시 Common Package만 수정하면 모든 서비스에 반영됩니다.

4. 테스트 커버리지 향상

Common Package에 대한 테스트를 한 번만 작성하면 모든 서비스에서 검증됩니다.

겪은 어려움과 해결

1. 버전 관리

문제: 여러 서비스에서 다른 버전의 Common Package를 사용할 수 있음

해결: Semantic Versioning을 사용하고, 주요 변경사항은 CHANGELOG에 명시

2. 의존성 충돌

문제: Common Package와 서비스의 의존성 버전이 충돌할 수 있음

해결: 선택적 의존성(compileOnly)을 사용하여 서비스에서 필요한 버전 선택 가능

3. 순환 의존성

문제: Common Package가 서비스에 의존하면 안 됨

해결: Common Package는 순수 유틸리티만 포함하고, 서비스별 로직은 제외

4. 배포 프로세스

문제: Common Package 업데이트 시 모든 서비스를 수동으로 업데이트해야 함

해결: GitHub Actions로 자동 배포하고, 버전 업데이트는 점진적으로 진행

향후 계획

1. 멀티 모듈 구조 검토

현재는 단일 모듈이지만, 필요시 멀티 모듈로 전환을 고려하고 있습니다:

common-kotlin-lib/
├── common-util-ulid/
├── common-util-pagination/
├── common-dto/
└── common-web/

2. API 안정성 보장

@Deprecated 어노테이션을 사용하여 하위 호환성을 유지하고, 마이그레이션 가이드를 제공할 계획입니다.

3. 성능 벤치마크

JMH를 사용한 성능 테스트를 추가하여 회귀 테스트를 강화할 예정입니다.

결론

Multi-Repo 구조에서 Common Package를 도입하면서 코드 중복을 제거하고 일관성을 확보할 수 있었습니다. 초기에는 설정과 배포 과정이 복잡했지만, 자동화를 통해 관리 부담을 줄일 수 있었습니다.

핵심 교훈:

  1. 점진적 도입: 한 번에 모든 것을 바꾸지 말고 점진적으로 도입
  2. 최소 의존성: Common Package는 최소한의 의존성만 포함
  3. 자동화: CI/CD를 통해 배포 프로세스 자동화
  4. 문서화: 사용 가이드와 변경 이력을 명확히 문서화

이 경험을 통해 마이크로서비스 아키텍처에서 공통 코드를 관리하는 방법을 배울 수 있었고, 앞으로도 지속적으로 개선해 나갈 계획입니다.

참고 자료

댓글

?