오랜만에 글을 작성합니다.
사내에서 여러 spring, kotlin 프로젝트를 monorepo 로 이전하게 되면서 배운 점, 과정을 기록하기 위해 작성합니다.
1. Monorepo를 도입한 이유
1.1 기존 Polyrepo 구조에서의 문제
사내, 그리고 저희 팀은 Spring 프로젝트를 담당하는 개발자가 2~3명뿐이었습니다.
하지만 운영해야 할 서비스는 10개 이상으로, 각 서비스가 개별 저장소(Polyrepo)로 관리되고 있었습니다.
Polyrepo 방식이 처음에는 큰 문제가 없어 보였지만, 서비스가 늘어나고 운영 기간이 길어지면서 다음과 같은 한계가 발생했습니다.
1) 공통 라이브러리 관리의 어려움
각 프로젝트에서 동일한 공통 코드와 라이브러리를 사용하고 있었지만, Polyrepo 구조에서는 라이브러리 관리가 서비스마다 따로 이루어지면서 버전 불일치 문제가 지속적으로 발생했습니다.
Pain Points
- 공통 기능을 활용하기 위해 별도의 라이브러리를 만들어 배포했지만, 각 프로젝트가 라이브러리의 최신 버전을 적용하지 않음
- 한 서비스에서 공통 모듈을 수정했지만, 다른 서비스에서는 여전히 이전 버전을 사용하여 예상치 못한 버그가 발생
- 새로운 기능을 공통 라이브러리에 추가해도, 각 프로젝트에서 이를 반영하려면 개별적으로 업데이트해야 하는 번거로움
결과적으로, 공통 코드가 오히려 중복되는 상황이 발생했고, 수정할 때마다 여러 프로젝트를 업데이트해야 하는 부담이 커졌습니다.
2) 프로젝트별 개발 방식 차이로 인한 유지보수 어려움
서비스마다 개발한 시기와 담당 개발자가 다르다 보니, 각 프로젝트의 패키지 구조, 코드 스타일, Gradle 설정 방식이 서로 달라지는 문제가 발생했습니다.
Pain Points
- 프로젝트마다 디렉터리 구조가 다르고, 패키지 네이밍 규칙이 다름
- 동일한 기능을 구현해도 각 프로젝트마다 구현 방식이 달라 코드 일관성이 낮아짐
- 새로운 개발자가 기존 프로젝트를 유지보수하려면 각 프로젝트의 스타일을 다시 학습해야 함
결국, 프로젝트 간 이동이 많아질수록 개발자가 적응하는 비용(컨텍스트 스위칭 비용)이 증가하게 되었습니다.
3) 배포 및 운영의 복잡성 증가
Polyrepo 구조에서는 각 프로젝트가 개별적으로 배포되었기 때문에, 배포 시점과 방법이 통일되지 않고 서비스별로 다른 운영 방식이 적용되었습니다.
Pain Points
- 한 서비스에서 수정된 공통 모듈을 다른 서비스에서도 사용하려면 각 서비스가 배포되기를 기다려야 하는 지연 발생
- 프로젝트마다 CI/CD 파이프라인 설정이 다르게 운영되어 서비스별 배포 방식이 통일되지 않음 (Helm, ArgoCD ...)
- 하나의 서비스에서 오류가 발생했을 때, 공통 모듈 문제인지 특정 서비스 문제인지 파악하기 어려움
이로 인해 서비스가 많아질수록 운영의 복잡성이 증가했고, 특히 팀의 규모에 비해 관리해야 할 서비스가 많아 유지보수 부담이 커지는 문제가 발생했습니다.
4) 중복 개발 문제
Polyrepo 구조에서는 프로젝트 간 코드 공유가 어렵기 때문에, 다른 서비스에서 이미 구현된 기능을 재사용하지 않고 다시 개발하는 일이 빈번했습니다. (대표 예시: 서비스 호출 client 인터페이스)
- 다른 프로젝트에서 유사한 기능을 이미 구현했음에도, 이를 쉽게 찾거나 복사해 쓸 수 없었음
- “이 기능은 다른 서비스에서 구현했을 텐데?“라고 생각해도, 코드가 저장된 위치를 찾거나, 해당 기능을 재사용할 수 있는지 검토하는 데 많은 시간이 소요됨
- 동일한 기능이 여러 프로젝트에 중복으로 존재하다 보니, 한 서비스에서 수정하면 다른 서비스에도 동일한 수정이 필요함
이 문제를 해결하기 위해 프로젝트 간 공유할 수 있는 코드를 찾는 시간이 점점 길어졌고, 결국 개발 생산성이 점점 낮아지는 문제로 이어졌습니다.
1.2 Monorepo로 전환 시 고려한 사항
Polyrepo 구조에서 발생한 문제를 해결하기 위해 Monorepo를 도입하기로 결정했지만, Monorepo가 만능 해결책은 아니기 때문에 몇 가지 중요한 고려 사항을 정리했습니다.
고려 사항 | 설명 | 해결 방법 |
서비스 독립성 유지 | Monorepo로 묶이더라도 각 서비스는 독립적으로 개발 및 배포 가능해야 함 | Gradle Composite Build 도입 |
공통 모듈 재사용 | 공통 라이브러리는 유지보수가 쉬워야 하고, 필요할 때만 사용할 수 있어야 함 | shared/ 디렉터리 도입 및 선택적 참조 |
빌드 성능 최적화 | 프로젝트 수가 많아지면 빌드 시간이 길어질 수 있음 | Gradle 설정 최적화 및 필요 프로젝트만 빌드 |
개발 생산성 유지 | Monorepo 환경에서 개발자가 불편함을 느끼지 않아야 함 | IDE 성능 최적화 및 각 프로젝트의 독립적 운영 보장 |
2. Monorepo 구조 설계
Monorepo를 도입하면서 가장 중요하게 고려한 점은 각 프로젝트가 독립적으로 개발 및 배포될 수 있어야 한다는 것이었습니다.
단순히 모든 프로젝트를 한 저장소에 모아두는 것이 아니라, Polyrepo에서 발생했던 문제(중복 코드, 공통 라이브러리 관리, 빌드 성능 문제)를 해결할 수 있는 구조가 필요했습니다.
처음에는 Bazel과 같은 빌드 시스템을 도입하는 것도 고려했지만, Gradle Composite Build가 우리가 해결하려는 문제에 더 적합하다고 판단했습니다.
구조 예시
monorepo-root/
├── iac/ # 인프라 관련 리소스 (Helm Charts 등)
├── gradle/ # 공통 Gradle 설정
│ ├── build-logic/ # 공통 Gradle 플러그인 및 설정
│ ├── libs.versions.toml # 버전 관리
├── services/ # Spring 애플리케이션이 들어가는 서비스
│ ├── service1/
│ ├── service2/
├── shared/ # 공통 라이브러리 (모든 서비스에서 공유)
│ ├── common-client/ # API 클라이언트 라이브러리
│ ├── common-kotlin/ # Kotlin 관련 공통 유틸
│ ├── spring-external-config/ # Spring 환경 설정 공통 모듈
└── tools/ # Monorepo 관리 도구
2.1 왜 Bazel이 아니라 Gradle Composite Build를 선택했는가?
Bazel은 강력한 빌드 시스템이지만, 다음과 같은 문제들이 있었습니다.
1) 학습 비용이 너무 큼
- Bazel은 강력한 성능을 제공하지만, 기존 Gradle 기반 프로젝트에 적용하려면 빌드 스크립트를 전면적으로 수정해야 했습니다.
- 기존의 Gradle 빌드 방식과 완전히 다르기 때문에, 팀원들이 모두 새롭게 Bazel을 학습해야 하는 부담이 있었습니다.
- 빌드 설정이 단순히 build.gradle.kts를 작성하는 것이 아니라, .bzl 파일을 다뤄야 하는 등 기존 Java/Kotlin 개발자들에게 익숙하지 않은 환경을 제공했습니다.
2) 기존 Gradle 생태계와의 충돌
- 프로젝트는 이미 Gradle을 사용하고 있었고, 다양한 플러그인과 설정이 Gradle 기반으로 최적화되어 있었습니다.
- Bazel을 도입할 경우, 기존 Gradle 플러그인과의 충돌을 해결해야 하는 추가적인 작업이 필요했습니다.
- 특히 Spring Boot 빌드 및 의존성 관리와의 호환성 문제도 발생할 가능성이 컸습니다.
3) 서비스 독립성이 중요한 Monorepo 운영 방식과 맞지 않음
- Bazel은 모든 프로젝트를 하나의 빌드 시스템으로 묶는 데 최적화되어 있습니다.
- 반면, 우리는 각 프로젝트가 독립적으로 운영될 수 있어야 한다고 판단했습니다.
- 공통 라이브러리를 반드시 전체 프로젝트에서 사용할 필요는 없었고, 필요한 프로젝트에서만 가져다 쓸 수 있는 유연한 구조가 필요했습니다.
4) 빌드 속도 차이
- Bazel은 대규모 프로젝트에서는 성능이 뛰어나지만, 서비스별 독립적인 빌드가 필요한 구조에서는 Gradle Composite Build가 더 적합했습니다.
- Gradle은 Incremental Build와 Build Cache를 적극 활용할 수 있으며, 특정 프로젝트만 빌드할 수 있는 구조를 만들기 더 쉬웠습니다.
이러한 이유로, Bazel 대신 Gradle Composite Build를 활용하기로 결정했습니다.
3. PoC 과정에서 발생한 문제와 해결 방법
Gradle Composite Build를 도입하면서 몇 가지 예상치 못한 문제가 발생했습니다.
이를 해결하는 과정에서 Gradle의 설정을 최적화하고, 각 프로젝트가 독립적으로 개발될 수 있도록 구조를 개선했습니다.
3.1 모든 프로젝트를 포함하면 Gradle 로드 시 OOM 발생
문제
- settings.gradle.kts에서 모든 프로젝트를 includeBuild로 추가했더니, IntelliJ에서 Gradle을 로드하는 과정에서 OOM(메모리 부족) 발생
- 프로젝트 개수가 많아지면서 Gradle Sync 속도가 급격히 저하됨
해결책
- Root에서 includeBuild를 하지 않고, 각 프로젝트가 개별적으로 settings.gradle.kts에서 설정
- 필요한 프로젝트만 불러오도록 개선하여 빌드 속도를 최적화
3.2 Root에서만 빌드 가능, 개별 프로젝트에서 빌드 불가
문제
- Root에서 includeBuild를 설정하면 각 서비스에서 개별적으로 빌드가 불가능
- 서비스별 독립적 개발 및 배포가 어려워짐
해결책
- 각 프로젝트의 settings.gradle.kts에서 필요한 라이브러리만 includeBuild
- 각 프로젝트가 독립적으로 개발 & 빌드 가능하도록 개선
예제
// services/service1/settings.gradle.kts
includeBuild("../../shared/common-kotlin") {
dependencySubstitution {
substitute(module("com.mdjoo0810:common-kotlin")).using(project(":"))
}
}
includeBuild("../../gradle/build-logic")
4. Gradle Composite Build 적용 후 효과
공통 코드 관리가 쉬워졌습니다.
공통 모듈을 shared/ 디렉터리에서 관리하면서, 중복 코드 없이, 필요한 프로젝트에서만 선택적으로 사용할 수 있도록 개선했습니다.
각 프로젝트가 독립적으로 운영 가능해졌습니다.
서비스별로 필요한 의존성만 설정할 수 있도록 구조를 변경하여 독립적인 개발 및 배포가 가능해졌습니다.
빌드 속도가 최적화되었습니다.
Root에서 모든 프로젝트를 포함하지 않음으로써, Gradle Sync 시간이 단축되었고, Incremental Build를 활용하여 빌드 시간을 최소화할 수 있었습니다.
Incremental Build란?
Incremental Build(증분 빌드)는 변경된 코드만 빌드하여 전체 빌드 시간을 줄이는 기법입니다.
Gradle을 포함한 많은 빌드 시스템은 이전 빌드 결과를 캐싱하여, 변경되지 않은 파일은 다시 빌드하지 않고 그대로 사용하는 방식을 제공합니다.
5. 마무리
Gradle Composite Build 기반 Monorepo 전환을 통해,
- 공통 코드 관리 효율성 증가
- 각 프로젝트의 독립성 유지
- 빌드 속도 개선
이라는 목표를 달성할 수 있었습니다.
spring 프로젝트를 많이 운영 중신 팀 중 Monorepo 도입을 고민하고 있다면, Gradle Composite Build 방식이 하나의 대안이 될 수 있을 것입니다.