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

Apache Maven
프로젝트 배경
초기 구조: 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 방식을 선택했습니다. 이유는:
- 이미 Multi-Repo 구조로 시작: 기존 구조를 크게 바꾸지 않고도 해결 가능
- 서비스 독립성 유지: 각 서비스의 독립적인 배포와 관리 가능
- 점진적 도입: 기존 서비스에 영향을 최소화하면서 도입 가능
- 팀 구조와 맞음: 각 서비스를 다른 팀이 소유하는 구조와 잘 맞음
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를 도입하면서 코드 중복을 제거하고 일관성을 확보할 수 있었습니다. 초기에는 설정과 배포 과정이 복잡했지만, 자동화를 통해 관리 부담을 줄일 수 있었습니다.
핵심 교훈:
- 점진적 도입: 한 번에 모든 것을 바꾸지 말고 점진적으로 도입
- 최소 의존성: Common Package는 최소한의 의존성만 포함
- 자동화: CI/CD를 통해 배포 프로세스 자동화
- 문서화: 사용 가이드와 변경 이력을 명확히 문서화
이 경험을 통해 마이크로서비스 아키텍처에서 공통 코드를 관리하는 방법을 배울 수 있었고, 앞으로도 지속적으로 개선해 나갈 계획입니다.