Jenkins + Nx 모노레포 CI/CD 파이프라인 완전 가이드
모노레포에서 CI/CD가 복잡해지는 이유
하나의 Git 저장소에 downtime, downtime-api 두 모듈이 있다고 하자.
downtime-api의 컨트롤러에 API 하나를 추가했다. 건드린 건 이 모듈뿐이다.
그런데 Jenkins가 downtime까지 포함해서 전체 빌드를 돌리고, 전체 배포까지 한다면?
빌드 시간도 낭비고, 안 건드린 모듈이 배포되면서 불필요한 장애 리스크까지 생긴다.
반대로 downtime의 enum을 고쳤는데 downtime-api가 빌드에서 빠진다면?
downtime-api는 downtime에 의존하고 있으므로, 런타임에 깨질 수 있다.
결국 필요한 건 이런 파이프라인이다.
- 바뀐 모듈만 빌드한다
- 의존관계에 있는 모듈은 함께 빌드한다
- 커밋 메시지를 보고 버전을 자동으로 결정한다
- 배포 대상인 모듈만 배포한다
이 글에서는 Jenkins Shared Library + Nx를 활용해 이 문제를 어떻게 풀었는지, downtime 프로젝트를 예시로 설명한다.
전체 아키텍처
┌─ Git Push ──────────────────────────────────────────────────────────┐
│ │
│ Developer → GitHub → Jenkins (Webhook) → Kubernetes Pod │
│ │
│ Pipeline Stages: │
│ Setup → Detect → Version → Build → Package(모듈별) → Deploy │
│ │
│ Deploy: │
│ ECR (Docker Image) → helm-values repo 업데이트 → ArgoCD 자동 배포 │
│ │
└─────────────────────────────────────────────────────────────────────┘
프로젝트 구조 이해
downtime 프로젝트를 기준으로 본다.
com.knet.msa/downtime/ ← Git 리포 = Nx 워크스페이스
├── nx.json ← Nx 설정 (sharedGlobals, release 등)
├── build.gradle.kts ← 루트 Gradle 빌드 설정 ★ sharedGlobals
├── settings.gradle.kts ← Gradle 모듈 포함 설정 ★ sharedGlobals
├── gradle.properties ← Gradle 속성 ★ sharedGlobals
├── package.json ← 루트 npm (nx 의존성)
├── Jenkinsfile ← @Library('jenkins') _ ; nxBuild()
│
├── downtime/ ← 도메인/공유 모듈 (라이브러리)
│ ├── project.json → tags: ["lang:kotlin"]
│ ├── package.json → version: 0.0.0
│ ├── build.gradle.kts
│ └── src/main/kotlin/...
│
└── downtime-api/ ← REST API 모듈 (배포 대상)
├── project.json → tags: ["lang:kotlin", "type:deployable"]
├── package.json → version: 0.0.0
├── build.gradle.kts
├── Dockerfile
└── src/main/kotlin/...
두 모듈의 차이를 보자.
| downtime | downtime-api | |
|---|---|---|
| 역할 | 도메인 공유 라이브러리 | REST API 서버 |
| tags | lang:kotlin |
lang:kotlin, type:deployable |
| package 타겟 | Nexus에 jar publish | Docker 이미지 빌드 → ECR push |
| 배포 여부 | X | O (ArgoCD) |
| 의존관계 | 없음 | downtime에 의존 |
type:deployable 태그가 있어야 Docker 이미지를 빌드하고 ArgoCD로 배포한다.
downtime처럼 라이브러리 모듈은 Nexus에 jar만 publish하고 끝난다.
Jenkinsfile
각 프로젝트의 Jenkinsfile은 단 2줄이다.
@Library('jenkins') _
nxBuild()
모든 파이프라인 로직은 Jenkins Shared Library의 vars/nxBuild.groovy에 있다.
프로젝트마다 파이프라인 코드를 복붙할 필요 없이, 공통 라이브러리 하나로 모든 Nx 모노레포 프로젝트의 CI/CD를 처리한다.
파이프라인 스테이지 상세
1. Setup
stage("Setup") {
sh "git config --global --add safe.directory ${env.WORKSPACE}"
sh "git config --global user.name 'Jenkins'"
sh "git config --global user.email 'jenkins@knetbiz.com'"
sh "git remote set-url origin https://${GITHUB_USERNAME}:${GITHUB_PASSWORD}@github.com/private-knet/${env.GIT_REPO_NAME}.git"
sh "npm ci"
}
- Git 설정 (버전 태그 push를 위해 인증 정보 포함된 remote URL 설정)
npm ci로 Nx 및 의존성 설치
2. Detect - 변경 감지
이 스테이지가 파이프라인의 핵심이다. 세 가지를 수행한다.
전체 프로젝트 목록 추출 (위상 정렬)
def projectsResult = sh(
script: """nx graph --print 2>/dev/null | node -p "..."
""",
returnStdout: true
)
nx graph에서 의존관계 그래프를 읽어 위상 정렬(topological sort)된 순서로 프로젝트 목록을 만든다.
downtime 프로젝트의 경우 downtime이 먼저, downtime-api가 나중에 온다.
이 순서가 중요한 이유는 라이브러리(downtime)가 먼저 publish되어야 API(downtime-api)가 빌드될 수 있기 때문이다.
변경된 모듈 감지
def affectedResult = sh(
script: "nx show projects --affected --base=${base}",
returnStdout: true
)
base는 이전 성공 빌드의 커밋이다. 그 커밋 이후로 변경된 파일이 속한 모듈만 affected로 판별한다.
downtime-api의 project.json에 "implicitDependencies": ["downtime"]이 설정되어 있으므로, downtime 모듈이 변경되면 downtime-api도 자동으로 affected에 포함된다.
배포 대상 모듈 판별
def deployResult = sh(
script: "nx show projects --affected --base=${base} --with-tag type:deployable",
returnStdout: true
)
affected 모듈 중 type:deployable 태그가 있는 것만 배포 대상으로 분류한다.
3. Version - 버전 결정
Conventional Commits 규칙에 따라 자동으로 버전을 결정한다.
커밋 메시지와 버전의 관계:
| 커밋 접두사 | 버전 변화 | 예시 |
|---|---|---|
fix: |
patch (0.0.X) | fix: null 체크 누락 |
feat: |
minor (0.X.0) | feat: 조회 API 추가 |
feat!: 또는 본문에 BREAKING CHANGE: |
major (X.0.0) | feat!: 응답 구조 변경 |
chore:, docs:, refactor:, style:, ci:, test: |
변화 없음 | chore: import 정리 |
각 접두사의 의미:
- fix — 버그 수정. SemVer의 patch에 해당
- feat — feature의 줄임말. 새로운 기능 추가. SemVer의 minor에 해당
- feat! —
!는 breaking change를 의미. 하위 호환이 깨지는 변경. SemVer의 major에 해당 - chore — “허드렛일”이라는 뜻. 코드 동작에 영향 없는 잡일 (의존성 업데이트, 설정 변경 등)
- docs — documentation. 문서만 변경
- refactor — 기능 변화 없이 코드 구조 개선. 버그 수정도 아니고 기능 추가도 아닌 변경
- style — 코드 포맷팅, 세미콜론, 공백 등. 동작에 영향 없는 스타일 변경
- ci — continuous integration. CI/CD 설정 파일 변경 (Jenkinsfile, GitHub Actions 등)
- test — 테스트 코드 추가/수정. 프로덕션 코드 변경 없음
- perf — performance. 성능 개선.
fix:와 같이 patch bump 대상
이 규칙은 Conventional Commits 스펙을 따른다. Angular 팀에서 시작된 커밋 컨벤션이 표준화된 것이다.
동작 순서:
1) 각 모듈의 최신 Git 태그에서 현재 버전 조회
예: git tag -l 'downtime-api/v*' --sort=-v:refname | head -1
→ downtime-api/v0.1.0 → 현재 버전 0.1.0
2) nx release version 실행 (conventional commits 분석)
main 브랜치: nx release version
dev 브랜치: nx release version --preid=beta
3) 버전이 올라간 모듈만 Git 태그 생성
예: git tag downtime-api/v0.2.0
4) 태그 push 후, affected를 버전이 올라간 모듈만으로 축소
버전이 안 올라간 모듈은 이후 Build/Deploy에서 완전히 제외된다. 이것이 chore: 커밋이 배포를 트리거하지 않는 이유다.
4. Build
stage("Build") {
sh "nx run-many --target=build --projects=${affected.join(',')}"
}
affected 모듈만 Gradle 빌드를 실행한다. 내부적으로 각 모듈의 project.json에 정의된 build 타겟이 실행된다.
// downtime/project.json
{ "build": { "command": "sh ./gradlew downtime:build" } }
// downtime-api/project.json
{ "build": { "command": "sh ./gradlew downtime-api:build" } }
5. Package - 모듈별 스테이지
projects.each { module ->
stage(stageName) {
if (!affected.contains(module)) {
Utils.markStageSkippedForConditional(STAGE_NAME)
} else {
sh "nx run ${module}:package"
}
}
}
전체 프로젝트를 순회하되, affected가 아닌 모듈은 스킵 처리한다. Stage View에서 항상 동일한 스테이지가 보이도록 하기 위함이다.
각 모듈의 package 타겟은 역할에 따라 다르다.
downtime → gradlew downtime:publish → Nexus에 jar 배포
downtime-api → buildctl build ... → Docker 이미지 빌드 → ECR push
위상 정렬 순서로 실행되므로 downtime이 먼저 Nexus에 publish되고, 그 후 downtime-api가 빌드된다.
6. Deploy
stage("Deploy") {
// helm-values 리포 clone
sh "git clone --depth 1 ${helmValuesRepo} /tmp/helm-values"
// 각 배포 대상 모듈의 이미지 태그 업데이트
deployable.each { module ->
def valuesFile = "/tmp/helm-values/${env.GIT_REPO_NAME}/${cluster}@${module}.yaml"
sh "sed -i 's|tag:.*|tag: ${versions[module]}|' ${valuesFile}"
}
// commit & push → ArgoCD가 감지하여 자동 배포
sh """
cd /tmp/helm-values
git add .
git commit -m "downtime: downtime-api → v0.2.0"
git push origin main
"""
}
type:deployable 태그가 있는 모듈만 여기서 처리된다.
helm-values 리포의 이미지 태그를 업데이트하면 ArgoCD가 변경을 감지하고 Kubernetes에 자동 배포한다.
배포 환경은 브랜치로 결정된다.
def cluster = env.BRANCH_NAME == 'main' ? 'prod' : 'dev'
// main → prod@downtime-api.yaml
// dev → dev@downtime-api.yaml
sharedGlobals - 루트 빌드 파일 변경 감지
Nx의 affected 명령은 프로젝트 디렉토리 안의 파일 변경만 감지한다.
루트에 있는 build.gradle.kts, settings.gradle.kts, gradle.properties가 바뀌어도 Nx는 이를 모른다.
하지만 이 파일들은 모든 모듈의 빌드에 영향을 준다.
예를 들어 gradle.properties에서 Kotlin 버전을 올리면 모든 모듈이 다시 빌드되어야 한다.
nx.json에서 이 파일들을 sharedGlobals로 지정한다.
{
"namedInputs": {
"sharedGlobals": [
"{workspaceRoot}/build.gradle.kts",
"{workspaceRoot}/settings.gradle.kts",
"{workspaceRoot}/gradle.properties"
]
}
}
파이프라인의 Detect 스테이지에서 별도로 이 파일들의 변경 여부를 확인한다.
// nx.json에서 sharedGlobals 파일 목록을 읽어온다
def sharedGlobals = sh(
script: "node -p \"require('./nx.json').namedInputs.sharedGlobals.map(...)\"",
returnStdout: true
)
// git diff로 해당 파일들의 변경 여부를 확인한다
sharedGlobalsChanged = sh(
script: "git diff --name-only ${base} HEAD -- ${sharedGlobals}",
returnStdout: true
)
// nx affected가 비어있는데 sharedGlobals가 변경된 경우 → 전체 프로젝트를 affected로 추가
if (!affected && sharedGlobalsChanged) {
affected.addAll(projects)
}
그리고 Version 스테이지에서, sharedGlobals 변경으로 affected가 된 모듈은 conventional commit에 의한 bump이 없더라도 강제 patch bump한다.
if (newVersion == currentVersions[module] && sharedGlobalsChanged) {
def bumpType = env.BRANCH_NAME == 'main' ? 'patch' : 'prerelease'
sh "cd ${root} && npm version ${bumpType} --no-git-tag-version"
}
이 보완 로직 덕분에 루트 빌드 설정 변경 시 커밋 메시지와 관계없이 전체 모듈이 빌드/배포된다.
시나리오별 동작 예시
시나리오 1: API 모듈에 기능 추가
# downtime-api/src/.../controller/DowntimeController.kt 수정
git commit -m "feat: 점검 시간 조회 API 추가"
git push origin main
Detect → affected: [downtime-api], deployable: [downtime-api]
Version → feat: → minor bump → 0.1.0 → 0.2.0
태그: downtime-api/v0.2.0
Build → gradlew downtime-api:build ✅
Package → [kotlin] downtime → ⏭ 스킵 (affected 아님)
[kotlin] downtime-api → buildctl → ECR push ✅
Deploy → prod@downtime-api.yaml → tag: 0.2.0 → ArgoCD 배포 ✅
시나리오 2: 공유 모듈 버그 수정
# downtime/src/.../enums/DowntimeServiceType.kt 수정
git commit -m "fix: DowntimeServiceType 잘못된 enum 값 수정"
git push origin main
downtime-api는 downtime에 의존(implicitDependencies)하므로 함께 affected 된다.
Detect → affected: [downtime, downtime-api], deployable: [downtime-api]
Version → fix: → patch bump
downtime 0.0.0 → 0.0.1 태그: downtime/v0.0.1
downtime-api 0.1.0 → 0.1.1 태그: downtime-api/v0.1.1
Build → gradlew downtime:build + downtime-api:build ✅
Package → [kotlin] downtime → gradlew downtime:publish → Nexus ✅
[kotlin] downtime-api → buildctl → ECR push ✅
Deploy → prod@downtime-api.yaml → tag: 0.1.1 → ArgoCD 배포 ✅
downtime은 type:deployable이 아니므로 Deploy에서 제외된다.
하지만 Nexus에 새 버전이 publish되어 다른 프로젝트에서도 이 라이브러리를 가져다 쓸 수 있다.
시나리오 3: 코드 정리 (chore 커밋)
# downtime-api/src/.../service/DowntimeService.kt에서 불필요한 import 제거
git commit -m "chore: 불필요한 import 정리"
git push origin main
Detect → affected: [downtime-api], deployable: [downtime-api]
Version → chore: → 버전 변화 없음 → "No version changes detected"
currentBuild.result = 'NOT_BUILT' ❌
Build → ⏭ 스킵
Package → ⏭ 전체 스킵
Deploy → ⏭ 스킵
chore:, docs:, refactor:, style:, ci:, test: 접두사는 conventional commits에서 버전을 올리지 않는다.
빌드와 배포가 일어나지 않으므로 코드 정리성 커밋을 안심하고 push할 수 있다.
시나리오 4: 루트 빌드 설정 변경 (sharedGlobals)
# gradle.properties에서 Kotlin 버전 업데이트
git commit -m "chore: Kotlin 2.2.0 업데이트"
git push origin main
Detect → nx affected → 비어있음 (프로젝트 디렉토리 변경 없음)
git diff → gradle.properties 변경 감지 (sharedGlobals)
→ 전체 프로젝트를 affected로 추가
affected: [downtime, downtime-api], deployable: [downtime-api]
Version → chore: → nx release가 bump 안 함
하지만 sharedGlobalsChanged → 강제 patch bump
downtime 0.0.1 → 0.0.2
downtime-api 0.2.0 → 0.2.1
Build → 전체 빌드 ✅
Package → 전체 package ✅
Deploy → downtime-api ArgoCD 배포 ✅
루트 빌드 파일 변경은 커밋 메시지와 무관하게 무조건 전체 빌드/배포를 트리거한다.
시나리오 5: dev 브랜치에서 기능 개발
git checkout -b dev
# downtime-api 수정
git commit -m "feat: 점검 예약 기능 추가"
git push origin dev
Detect → affected: [downtime-api], deployable: [downtime-api]
Version → feat: → minor bump (beta 프리릴리즈)
0.2.0 → 0.3.0-beta.0
태그: downtime-api/v0.3.0-beta.0
Build → gradlew downtime-api:build ✅
Package → buildctl → ECR push (tag: 0.3.0-beta.0) ✅
Deploy → dev@downtime-api.yaml → tag: 0.3.0-beta.0 → ArgoCD dev 환경 배포 ✅
dev 브랜치에서는 --preid=beta가 붙어 프리릴리즈 버전이 만들어지고, dev 환경에만 배포된다.
시나리오 6: 변경 사항 없이 빌드
이전 성공 빌드 이후 아무 변경도 없는데 수동으로 Jenkins 빌드를 실행한 경우.
Detect → affected: [], deployable: []
sharedGlobalsChanged: ""
→ "No changes detected"
currentBuild.result = 'NOT_BUILT' ❌
이후 → 전체 스킵
시나리오 7: 여러 커밋이 쌓인 후 빌드
git commit -m "docs: README 업데이트"
git commit -m "refactor: 서비스 레이어 리팩토링"
git commit -m "feat: 점검 알림 기능 추가"
git push origin main
nx release version은 마지막 태그 이후의 모든 커밋을 분석한다.
feat:이 하나라도 있으면 minor bump이 적용된다.
Version → docs + refactor + feat → 가장 높은 bump 적용 → minor bump
0.2.0 → 0.3.0
bump 우선순위: major > minor > patch. 여러 커밋 중 가장 큰 변경이 최종 버전에 반영된다.
시나리오 8: BREAKING CHANGE
git commit -m "feat!: 점검 API 응답 구조 전면 변경
BREAKING CHANGE: DowntimeResponse의 startTime 필드가 schedule.start로 이동"
git push origin main
Version → feat!: → major bump
0.3.0 → 1.0.0
feat!: 접두사 또는 커밋 본문의 BREAKING CHANGE: 키워드가 major bump을 트리거한다.
API 호환성이 깨지는 변경을 할 때 사용한다.
버전 관리 규칙 정리
왜 package.json의 version이 항상 0.0.0인가
소스 코드의 package.json에는 "version": "0.0.0"이 고정되어 있다.
실제 버전은 Git 태그(downtime-api/v0.2.0)로 관리한다.
Version 스테이지에서 이렇게 동작한다.
1. Git 태그에서 현재 버전 조회 → downtime-api/v0.2.0 → 0.2.0
2. package.json의 version을 0.2.0으로 덮어씀
3. nx release version 실행 → conventional commits 분석 → 0.3.0으로 bump
4. 새 태그 생성: downtime-api/v0.3.0
5. 태그만 push (package.json 변경은 커밋하지 않음)
이 방식의 장점은 소스 코드에 버전 변경 커밋이 섞이지 않는다는 것이다.
릴리즈 태그 패턴
{projectName}/v{version}
예시:
downtime/v0.0.1
downtime-api/v0.2.0
downtime-api/v0.3.0-beta.0
nx.json의 release.releaseTagPattern으로 설정되어 있다.
{
"release": {
"releaseTagPattern": "{projectName}/v{version}",
"projectsRelationship": "independent"
}
}
independent 모드이므로 각 모듈이 독립적으로 버전을 관리한다.
의존관계와 빌드 순서
downtime-api의 project.json에 의존관계가 명시되어 있다.
{
"name": "downtime-api",
"implicitDependencies": ["downtime"]
}
이 설정은 두 가지 역할을 한다.
1. 변경 전파: downtime이 변경되면 downtime-api도 affected에 포함된다.
downtime 파일 변경 → affected: [downtime, downtime-api]
2. 빌드 순서 보장: Detect 스테이지에서 위상 정렬을 수행하여 의존성이 먼저 처리된다.
Package 순서: downtime (Nexus publish) → downtime-api (Docker build)
downtime-api의 Gradle 빌드가 downtime jar를 Nexus에서 가져다 쓰기 때문에,
반드시 downtime이 먼저 publish되어야 한다.
실행 환경
파이프라인은 Kubernetes Pod에서 실행된다.
node('buildkit-nx') { // buildkit-nx 라벨이 붙은 K8s Pod
container("buildkit") { // buildkit 컨테이너 안에서 실행
// 모든 스테이지가 여기서 실행
}
}
인증 정보는 Jenkins Credentials로 관리한다.
withCredentials([
usernamePassword(credentialsId: 'nexus', ...), // Nexus 저장소 인증
usernamePassword(credentialsId: 'github', ...) // GitHub 인증 (태그 push, helm-values)
])
다른 MSA 프로젝트에도 동일하게 적용
이 파이프라인은 downtime 전용이 아니다. 같은 구조를 따르는 모든 프로젝트에서 동일하게 동작한다.
com.knet.msa/
├── auth/ → Jenkinsfile → nxBuild()
├── bank/ → Jenkinsfile → nxBuild()
├── calendar/ → Jenkinsfile → nxBuild()
├── downtime/ → Jenkinsfile → nxBuild()
├── file-manager/ → Jenkinsfile → nxBuild()
├── gateway/ → Jenkinsfile → nxBuild()
├── mail/ → Jenkinsfile → nxBuild()
├── members/ → Jenkinsfile → nxBuild()
├── message/ → Jenkinsfile → nxBuild()
└── ...
새 프로젝트를 추가할 때 필요한 것은 다음과 같다.
project.json에 Nx 프로젝트 정의 (tags, targets)package.json에 version: 0.0.0- 배포 대상이면
type:deployable태그 추가 Jenkinsfile에 2줄 작성
@Library('jenkins') _
nxBuild()
파이프라인 코드를 건드릴 필요가 전혀 없다.
핵심 요약
- 변경 감지:
nx affected가 변경된 모듈만 골라내고, 의존관계로 연결된 모듈도 함께 포함 - 버전 결정: Conventional Commits(
fix:,feat:,feat!:)로 자동 결정.chore:등은 빌드/배포 안 함 - sharedGlobals: 루트 빌드 파일 변경 시 전체 모듈 강제 빌드/배포
- 배포 대상:
type:deployable태그로 판별. 라이브러리는 Nexus, 앱은 ECR+ArgoCD - 브랜치 전략: main → 정식 버전 + prod 배포, 그 외 → beta 프리릴리즈 + dev 배포
- 빌드 순서: 위상 정렬로 의존성 순서 보장
댓글