[Part 4] App-Builder Plugin 실전 분석 - CI/CD 자동화 플러그인
CI/CD 학습 시리즈
- Part 1: Kotlin 문법 기초
- Part 2: Gradle Plugin 개발
- Part 3: Jenkins Pipeline + Groovy 기초
- Part 4: App-Builder Plugin 실전 분석 (현재 글)
사전 지식: Part 1~3을 먼저 학습하면 이 문서를 더 쉽게 이해할 수 있습니다.
이 문서는 프로젝트를 처음 접하는 개발자를 위한 학습 가이드다.
목차
- 프로젝트 개요
- 전체 CI/CD 흐름
- Jenkins 연동 - Shared Library
- Gradle 플러그인 구조
- 데이터 모델 - Entity
- 모듈 감지 - GetTargetsTask
- 빌드 실행 - AppBuilderTask
- 3가지 Executor
- 학습 로드맵
- 전체 흐름 요약
1. 프로젝트 개요
이 플러그인은 무엇인가?
다른 프로젝트들(order, payment 등)을 빌드하기 위한 CI/CD 자동화 도구다.
┌─ order 프로젝트 ─────────────────┐
│ Jenkinsfile: │
│ @Library('app-builder-gradle-plugin') _
│ build() │
└───────────────────────────────┘
│
│ 이 플러그인이 order 프로젝트를 분석하고 빌드
▼
┌─ 결과 ────────────────────────┐
│ • Git 태그 생성 │
│ • Docker 이미지 ECR 푸시 │
│ • Helm values 업데이트 │
│ • ArgoCD 자동 배포 │
└───────────────────────────────┘
기본 정보
- 이름: App-Builder Gradle Plugin
- 기술 스택: Kotlin, Gradle, Java 21
- 핵심 기능:
- Git Tag 기반 버전 관리
- 모듈별 증분 빌드 자동화
- 다중 언어 지원 (JAVA, NODE, GO, PYTHON, PHP, NGINX, HELM)
- GitOps 통합 (ArgoCD + Helm values 자동 업데이트)
멀티 모듈 프로젝트와 증분 빌드
이 플러그인의 핵심 기능은 멀티 모듈 프로젝트에서 변경된 모듈만 선택적으로 빌드하는 것이다.
멀티 모듈 프로젝트란?
하나의 Git 저장소 안에 여러 개의 하위 프로젝트(모듈)가 있는 구조다.
C:\repo\com.example.msa\order\ ← Git 저장소 (루트)
├── settings.gradle.kts ← 모듈 목록 정의
├── build.gradle.kts ← 루트 빌드 설정 + 플러그인 적용
├── Jenkinsfile ← CI/CD 파이프라인
│
├── order-api/ ← 모듈 1: API 서버
│ ├── build.gradle.kts
│ ├── Dockerfile.app.java ← app 타입 (배포 대상)
│ └── src/
│
├── order-batch/ ← 모듈 2: 배치 서버
│ ├── build.gradle.kts
│ ├── Dockerfile.app.java
│ └── src/
│
├── order-common/ ← 모듈 3: 공통 라이브러리
│ ├── build.gradle.kts
│ ├── Dockerfile.lib.java ← lib 타입 (배포 안 함)
│ └── src/
│
└── order-client/ ← 모듈 4: 외부 연동 클라이언트
├── build.gradle.kts
├── Dockerfile.lib.java
└── src/
// settings.gradle.kts
rootProject.name = "order"
include("order-api")
include("order-batch")
include("order-common")
include("order-client")
왜 변경 감지가 필요한가?
전체 빌드는 시간과 리소스 낭비다.
┌─ 전체 빌드 (비효율) ─────────────────────────────────┐
│ │
│ order-common만 수정했는데... │
│ │
│ ❌ order-api 빌드 (3분) │
│ ❌ order-batch 빌드 (3분) │
│ ❌ order-common 빌드 (1분) │
│ ❌ order-client 빌드 (1분) │
│ │
│ 총 8분 소요 (불필요한 빌드 포함) │
│ │
└──────────────────────────────────────────────────────┘
┌─ 증분 빌드 (효율적) ─────────────────────────────────┐
│ │
│ order-common만 수정했을 때... │
│ │
│ ✅ order-common 빌드 (1분) ← 직접 변경됨 │
│ ✅ order-api 빌드 (3분) ← order-common 의존 │
│ ⏭️ order-batch 스킵 ← order-common 의존 없음 │
│ ⏭️ order-client 스킵 ← 변경 없음 │
│ │
│ 총 4분 소요 (필요한 것만 빌드) │
│ │
└──────────────────────────────────────────────────────┘
변경 감지 로직
플러그인이 변경을 감지하는 방법:
┌─ 1단계: Git diff로 변경된 파일 확인 ─────────────────┐
│ │
│ $ git diff --name-only HEAD~1 HEAD │
│ │
│ 결과: │
│ order-common/src/main/java/Utils.java │
│ order-common/build.gradle.kts │
│ │
│ → order-common 모듈에서 변경 발생! │
│ │
└──────────────────────────────────────────────────────┘
│
▼
┌─ 2단계: 의존성 분석 ────────────────────────────────┐
│ │
│ order-api/build.gradle.kts: │
│ dependencies { │
│ implementation(project(":order-common")) ← 의존! │
│ } │
│ │
│ order-batch/build.gradle.kts: │
│ dependencies { │
│ // order-common 의존 없음 │
│ } │
│ │
│ → order-common이 변경되면 order-api도 빌드 필요! │
│ │
└──────────────────────────────────────────────────────┘
│
▼
┌─ 3단계: 빌드 대상 결정 ─────────────────────────────┐
│ │
│ modules-data.json: │
│ { │
│ "order-api": { "changed": true }, ← 빌드! │
│ "order-batch": { "changed": false }, ← 스킵 │
│ "order-common": { "changed": true }, ← 빌드! │
│ "order-client": { "changed": false } ← 스킵 │
│ } │
│ │
└──────────────────────────────────────────────────────┘
의존성 전파 규칙
order-common (lib) 변경됨
│
│ 의존성 전파
▼
┌─────────────────────────────────────────────────────┐
│ order-api가 order-common을 의존 │
│ → order-api도 changed: true │
│ │
│ order-batch는 order-common을 의존하지 않음 │
│ → order-batch는 changed: false (스킵) │
└─────────────────────────────────────────────────────┘
실제 빌드 흐름 예시
개발자가 order-common/src/Utils.java 수정 후 push
│
▼
┌─ gradle GetTargets ─────────────────────────────────┐
│ │
│ 1. Git diff 실행 → order-common 변경 감지 │
│ 2. 의존성 분석 → order-api가 order-common 의존 │
│ 3. modules-data.json 생성: │
│ - order-common: changed=true, type=lib │
│ - order-api: changed=true, type=app │
│ - order-batch: changed=false │
│ - order-client: changed=false │
│ │
└──────────────────────────────────────────────────────┘
│
▼
┌─ Jenkins Pipeline ──────────────────────────────────┐
│ │
│ Stage 1: [JAVA] order-common │
│ └─ gradle AppBuilder -PtargetModule=order-common │
│ └─ lib이므로 Docker 빌드만 (ECR 푸시 안 함) │
│ │
│ Stage 2: [JAVA] order-api │
│ └─ gradle AppBuilder -PtargetModule=order-api │
│ └─ app이므로 Docker 빌드 + ECR 푸시 + Helm │
│ │
│ Stage 3: [JAVA] order-batch ⏭️ SKIPPED │
│ └─ changed=false이므로 스킵 │
│ │
│ Stage 4: [JAVA] order-client ⏭️ SKIPPED │
│ └─ changed=false이므로 스킵 │
│ │
└──────────────────────────────────────────────────────┘
모듈 타입 (app vs lib)
| 타입 | Dockerfile | 설명 | 빌드 결과 |
|---|---|---|---|
| app | Dockerfile.app.* |
배포 가능한 애플리케이션 | ECR 푸시 + Helm 업데이트 |
| lib | Dockerfile.lib.* |
라이브러리 (다른 모듈이 의존) | Docker 빌드만 (ECR 푸시 안 함) |
order-api/Dockerfile.app.java → type: "app" → ECR 푸시 O, Helm O
order-common/Dockerfile.lib.java → type: "lib" → ECR 푸시 X, Helm X
플러그인 정보
| 항목 | 값 |
|---|---|
| Plugin ID | com.example.plugins.app-builder-gradle-plugin |
| Version | 1.0.0 (또는 1.0.0-SNAPSHOT) |
| 구현 클래스 | com.example.plugins.gradle.AppBuilderPlugin |
Plugin vs Library 차이
이 프로젝트는 두 가지 컴포넌트로 구성되어 있다:
| 구분 | Jenkins Shared Library | Gradle Plugin |
|---|---|---|
| 용도 | Jenkins 파이프라인 코드 공유 | Gradle 빌드 로직 확장 |
| 언어 | Groovy | Kotlin |
| 호출 방법 | @Library('...') _ |
plugins { id("...") } |
| 위치 | vars/build.groovy |
AppBuilderPlugin.kt |
app-builder-plugin/
├── app-builder-gradle-plugin/ ← Gradle Plugin (Kotlin)
│ └── src/main/kotlin/.../
│ └── AppBuilderPlugin.kt
│
└── app-builder-gradle-plugin-pipeline/ ← Jenkins Shared Library (Groovy)
└── vars/
└── build.groovy
실제 호출 흐름:
┌─ order/Jenkinsfile ─────────────────────────────────────┐
│ @Library('app-builder-gradle-plugin') _ │
│ build() │
└───────────────────────────────────────────────────────┘
│
│ ① Jenkins Shared Library 호출
▼
┌─ vars/build.groovy ───────────────────────────────────┐
│ def call() { │
│ sh "gradle GetTargets" ← Gradle Plugin 호출 │
│ sh "gradle AppBuilder" ← Gradle Plugin 호출 │
│ } │
└───────────────────────────────────────────────────────┘
│
│ ② Gradle Plugin 실행
▼
┌─ AppBuilderPlugin.kt ─────────────────────────────────┐
│ GetTargetsTask, AppBuilderTask 실행 │
└───────────────────────────────────────────────────────┘
요약: @Library로 호출하는 것은 Jenkins Shared Library이고, 이 Library가 내부에서 Gradle Plugin을 호출한다. 사용자는 Library만 호출하면 된다.
2. 전체 CI/CD 흐름
트리거 흐름 (Git Push → Jenkins 실행)
┌─ 개발자 ─────────────────────────────────────────────────┐
│ git push origin develop │
└──────────────────────────────────────────────────────────┘
│
▼
┌─ GitHub ─────────────────────────────────────────────────┐
│ 코드 수신 → Webhook 발송 (Jenkins에 알림) │
└──────────────────────────────────────────────────────────┘
│
│ POST /github-webhook/
▼
┌─ Jenkins ────────────────────────────────────────────────┐
│ ① Webhook 수신 │
│ ② 해당 프로젝트 소스 checkout (git clone/pull) │
│ ③ Jenkinsfile 읽기 │
│ ④ @Library('app-builder-gradle-plugin') 로드 │
│ ⑤ build() 실행 │
└──────────────────────────────────────────────────────────┘
| 단계 | 주체 | 하는 일 |
|---|---|---|
| 1 | GitHub | Webhook으로 Jenkins에 “push 이벤트 발생” 알림 |
| 2 | Jenkins | 소스 코드 pull + Jenkinsfile 읽기 |
| 3 | Jenkins | Shared Library 로드 + build() 실행 |
참고: GitHub이 Jenkinsfile을 직접 실행하는 게 아니라, GitHub은 “변경됐다”고 알려주기만 하고, Jenkins가 소스를 가져와서 Jenkinsfile을 읽고 실행한다.
큰 그림
┌─ 사용자 프로젝트 (order, payment 등) ──────────────────────────────┐
│ │
│ Jenkinsfile: │
│ @Library('app-builder-gradle-plugin') _ │
│ build() │
│ │
└──────────────────────────────────────────────────────────────────┘
│
▼
┌─ Jenkins Pipeline (build.groovy) ────────────────────────────────┐
│ │
│ Stage 1: 변경 감지 │
│ └─ gradle GetTargets │
│ └─ 모듈 발견, 변경 감지, modules-data.json 생성 │
│ │
│ Stage 2~N: 모듈별 빌드 │
│ └─ gradle AppBuilder -PtargetModule={module} │
│ ├─ 버전 계산 + Git 태그 생성 │
│ ├─ Docker 이미지 빌드 + ECR 푸시 │
│ └─ Helm values 업데이트 │
│ │
└──────────────────────────────────────────────────────────────────┘
│
▼
ArgoCD 자동 배포
상세 흐름
Jenkins Pipeline
│
├─ 1. gradle GetTargets
│ └─ DetectorExecutor
│ ├─ settings.gradle 파싱 → 모듈 목록
│ ├─ Dockerfile 확인 → 타입(app/lib)
│ ├─ Git diff → 변경된 모듈 감지
│ └─ modules-data.json 생성
│
├─ 2. modules-data.json에서 changed=true인 모듈 추출
│
└─ 3. 각 모듈별 반복:
└─ gradle AppBuilder -PtargetModule={module}
└─ AppBuilderExecutor
├─ VersionExecutor (버전 계산, Git 태그)
├─ BuildExecutor (Docker 빌드, ECR 푸시)
└─ HelmExecutor (Helm 업데이트, app만)
│
▼
ArgoCD 자동 배포
3. Jenkins 연동 - Shared Library
두 개의 진입점
이 프로젝트는 두 개의 진입점이 있다:
| 구분 | 진입점 | 파일 위치 |
|---|---|---|
| Jenkins | @Library → build() |
app-builder-gradle-plugin-pipeline/vars/build.groovy |
| Gradle | AppBuilderPlugin.kt |
app-builder-gradle-plugin/src/.../AppBuilderPlugin.kt |
@Library('app-builder-gradle-plugin') _
build()
│
▼
build.groovy (Jenkins Shared Library)
│
│ 내부에서 Gradle 태스크 호출:
│
├─ gradle GetTargets → GetTargetsTask.kt
│
└─ gradle AppBuilder → AppBuilderTask.kt
사용자 프로젝트에서의 사용법
Jenkinsfile (기본):
@Library('app-builder-gradle-plugin') _
build()
Jenkinsfile (설정 오버라이드):
@Library('app-builder-gradle-plugin') _
build(
logLevel: 'debug',
skipStages: '1,3',
appBuilderConfig: [
workspace: '/app-builde'
]
)
build.groovy 상세 분석
파일: app-builder-gradle-plugin-pipeline/vars/build.groovy
이 파일은 Jenkins Shared Library의 진입점으로, 전체 CI/CD 파이프라인을 오케스트레이션한다.
전체 구조
def call(Map args = [:]) {
// 1. Jenkins 빌드 파라미터 정의
generateBuildParameters()
// 2. 설정값 생성 (args + Jenkins params 병합)
def config = generateConfig(args, params)
// 3. 로깅: 실행자, 설정 출력
echo "👨💼실행자: ${getUser()}"
echo "⚙️설정: ${config}"
// 4. Pipeline 실행
node('buildkit') {
checkout scm
ansiColor('xterm') {
withCredentials([...]) {
def buildData = [:]
// Stage 1: 변경 감지
stage("🔍 변경 감지") { ... }
// Stage 2~N: 모듈별 빌드
buildData.modules.each { name, module ->
stage("[${module.language}] ${module.name}") { ... }
}
}
}
}
}
데이터 흐름
┌─ Jenkinsfile ───────────────────────────────────────────┐
│ build(logLevel: 'debug', appBuilderConfig: [...]) │
└─────────────────────────────────────────────────────────┘
│ args
▼
┌─ generateConfig() ──────────────────────────────────────┐
│ │
│ 입력: │
│ - args (Jenkinsfile에서 전달) │
│ - params (Jenkins UI 빌드 파라미터) │
│ │
│ 출력 (config): │
│ { │
│ logLevel: 'debug', │
│ skipStages: [1, 3], │
│ appBuilderConfig: { │
│ appBuilderPath: '/path/to/plugin', │
│ workspace: '/workspace/project' │
│ } │
│ } │
│ │
└─────────────────────────────────────────────────────────┘
│ config
▼
┌─ getTargetsTask() ──────────────────────────────────────┐
│ │
│ 실행: gradle GetTargets │
│ │
│ 출력 (buildData): │
│ { │
│ commit: { repo, branch, author, commitMessage }, │
│ modules: { │
│ "order-api": { index, name, language, type, │
│ changed, version, status }, │
│ "order-common": { ... } │
│ } │
│ } │
│ │
└─────────────────────────────────────────────────────────┘
│ buildData
▼
┌─ modules.each → appBuilderTask() ───────────────────────┐
│ │
│ 각 모듈별로 반복: │
│ - isSkipped() 체크 → 스킵 or 빌드 │
│ - gradle AppBuilder -PtargetModule={module} │
│ - 결과: version, status 업데이트 │
│ │
└─────────────────────────────────────────────────────────┘
핵심 함수 상세
1. generateBuildParameters()
Jenkins UI에서 선택할 수 있는 빌드 파라미터 정의:
def generateBuildParameters() {
return properties([
parameters([
choice(
name: 'LOG_LEVEL',
choices: ['warn', 'debug', 'info', 'error'],
description: 'Gradle 빌드 스크립트의 로그 레벨'
),
string(
name: 'SKIP_STAGES',
defaultValue: '',
description: '스킵할 스테이지 번호 (예: 1,3,5)'
)
])
])
}
┌─ Jenkins UI ────────────────────────────────────────────┐
│ │
│ Build with Parameters │
│ ───────────────────── │
│ │
│ LOG_LEVEL: [warn ▼] │
│ - warn (기본) │
│ - debug │
│ - info │
│ - error │
│ │
│ SKIP_STAGES: [___________] │
│ 예: "1,3" 입력 시 1번, 3번 모듈 스킵 │
│ │
│ [Build] 버튼 │
│ │
└─────────────────────────────────────────────────────────┘
2. generateConfig()
설정 우선순위와 병합 로직:
def generateConfig(Map args, Map parameters) {
// 1. logLevel 우선순위: args > Jenkins params > 기본값
def logLevel = (args.logLevel ?: parameters.LOG_LEVEL ?: "warn").toLowerCase()
// 2. skipStages 파싱
def strSkipStages = args.skipStages ?: parameters.SKIP_STAGES ?: ""
def skipStages = parseSkipStages(strSkipStages)
// 3. appBuilderConfig 처리
def appBuilderConfig = args.appBuilderConfig ?: [:]
// 4. appBuilderConfig 값을 환경변수로 설정 (Gradle에서 사용)
appBuilderConfig.forEach { key, value ->
if (key == "workspace") {
env.APP_BUILDER_WORKSPACE = value // 특수 처리
} else {
env[camelToSnake(key)] = value // ecrRegistry → ECR_REGISTRY
}
}
return [
logLevel : logLevel,
skipStages : skipStages,
appBuilderConfig: appBuilderConfig
]
}
설정 우선순위:
┌─────────────────────────────────────────────────────────┐
│ 설정 우선순위 │
├─────────────────────────────────────────────────────────┤
│ │
│ 1순위: Jenkinsfile args │
│ build(logLevel: 'debug') │
│ │ │
│ ▼ │
│ 2순위: Jenkins UI 파라미터 │
│ LOG_LEVEL 선택 │
│ │ │
│ ▼ │
│ 3순위: 기본값 │
│ 'warn' │
│ │
└─────────────────────────────────────────────────────────┘
3. getTargetsTask()
변경 감지 Gradle Task 실행:
def getTargetsTask(Map config) {
// Gradle GetTargets 태스크 실행
sh """
gradle -p ${config.appBuilderConfig.appBuilderPath} \
GetTargets \
--no-daemon \
--stacktrace \
--${config.logLevel}
"""
// 결과 JSON 파일 읽기
def jsonContent = sh(
script: "cat ${config.appBuilderConfig.appBuilderPath}/build/app-builder/modules-data.json",
returnStdout: true
).trim()
// JSON → Map 변환
def moduleDataMap = readJSON text: jsonContent
return moduleDataMap
}
실행 명령어 예시:
gradle -p /workspace/app-builder-plugin GetTargets --no-daemon --stacktrace --debug
4. isSkipped()
스킵 여부 판단:
def isSkipped(Map config, Map module) {
// 조건 1: 변경되지 않은 모듈 → 자동 스킵
// 조건 2: 수동 스킵 목록에 포함 → 수동 스킵
return !module.changed || config.skipStages.contains(module.index + 1 as int)
}
┌─ 스킵 판단 로직 ────────────────────────────────────────┐
│ │
│ module.changed = false? │
│ │ │
│ ├─ YES → ⏭️ 자동 스킵 (변경 없음) │
│ │ │
│ └─ NO (변경됨) → skipStages에 포함? │
│ │ │
│ ├─ YES → ⏭️ 수동 스킵 │
│ │ │
│ └─ NO → ✅ 빌드 실행 │
│ │
└─────────────────────────────────────────────────────────┘
5. appBuilderTask()
모듈별 빌드 실행:
def appBuilderTask(Map config, String moduleKey, Map module) {
// Gradle AppBuilder 태스크 실행
sh """
gradle -p ${config.appBuilderConfig.appBuilderPath} \
AppBuilder \
-PtargetModule=${module.name} \
--no-daemon \
--stacktrace \
--${config.logLevel}
"""
// 업데이트된 모듈 정보 읽기
def jsonContent = sh(
script: "cat ${config.appBuilderConfig.appBuilderPath}/build/app-builder/modules-data.json",
returnStdout: true
).trim()
def moduleDataMap = readJSON text: jsonContent
def result = moduleDataMap?.modules?.get(moduleKey)
return result // { version: "1.2.3", status: "SUCCESS" }
}
실행 명령어 예시:
gradle -p /workspace/app-builder-plugin AppBuilder -PtargetModule=order-api --no-daemon --stacktrace --debug
6. 유틸리티 함수들
// camelCase → SNAKE_CASE 변환
def camelToSnake(String camelCase) {
return camelCase.replaceAll('([a-z])([A-Z]+)', '$1_$2').toUpperCase()
}
// 예: ecrRegistry → ECR_REGISTRY
// helmValuesRepo → HELM_VALUES_REPO
// 빌드 실행자 조회
def getUser() {
def BuildCauses = currentBuild.getBuildCauses('hudson.model.Cause$UserIdCause')
if (BuildCauses) {
return BuildCauses.userName // 수동 실행 시 사용자명
} else {
return "Push event" // Webhook 트리거 시
}
}
// Stage 스킵 마킹 (Jenkins UI에 SKIPPED로 표시)
def skipStage(String stageName) {
script {
org.jenkinsci.plugins.pipeline.modeldefinition.Utils.markStageSkippedForConditional(stageName)
}
}
파이프라인 실행 예시
┌─ Jenkins Console Output ────────────────────────────────┐
│ │
│ 👨💼실행자: hong.gildong │
│ ⚙️설정(Jenkins): { │
│ logLevel: debug │
│ skipStages: [] │
│ } │
│ ⚙️설정(AppBuilder DSL Override): { ... } │
│ │
│ ───────────────────────────────────────────────────── │
│ Stage: 🔍 변경 감지 │
│ ───────────────────────────────────────────────────── │
│ 📋Git 정보: { │
│ repo: order │
│ branch: develop │
│ author: hong.gildong │
│ message: feat: 주문 API 개선 │
│ } │
│ 📦대상 │
│ ⭕ order-api │
│ ❌ order-batch │
│ ⭕ order-common │
│ │
│ ───────────────────────────────────────────────────── │
│ Stage: [JAVA] order-common │
│ ───────────────────────────────────────────────────── │
│ 📦모듈: { name: order-common, language: JAVA, type: lib }
│ 🛠️결과: { status: SUCCESS, version: null → 1.0.1-SNAPSHOT }
│ │
│ ───────────────────────────────────────────────────── │
│ Stage: [JAVA] order-api │
│ ───────────────────────────────────────────────────── │
│ 📦모듈: { name: order-api, language: JAVA, type: app } │
│ 🛠️결과: { status: SUCCESS, version: null → 1.2.3-beta }│
│ │
│ ───────────────────────────────────────────────────── │
│ Stage: [JAVA] order-batch ⏭️ SKIPPED │
│ ───────────────────────────────────────────────────── │
│ 📦모듈: { name: order-batch, language: JAVA, type: app }
│ 🛠️결과: { status: SKIPPED } │
│ │
│ 완료 │
│ │
└─────────────────────────────────────────────────────────┘
Jenkins 빌드 파라미터
| 파라미터 | 설명 | 기본값 | 예시 |
|---|---|---|---|
LOG_LEVEL |
Gradle 로그 레벨 | warn | debug, info, warn, error |
SKIP_STAGES |
스킵할 스테이지 번호 | ”” | “1,3,5” |
Credentials
Jenkins Credentials에 등록 필요:
| ID | 타입 | 용도 |
|---|---|---|
nexus |
Username/Password | Maven 저장소 인증 |
github |
Username/Password | GitHub 저장소 인증 (Helm repo) |
withCredentials([
usernamePassword(credentialsId: 'nexus',
usernameVariable: 'NEXUS_USERNAME',
passwordVariable: 'NEXUS_PASSWORD'),
usernamePassword(credentialsId: 'github',
usernameVariable: 'GITHUB_USERNAME',
passwordVariable: 'GITHUB_PASSWORD')
]) {
// 이 블록 안에서 환경변수로 사용 가능
// Gradle Task가 System.getenv()로 읽음
}
4. Gradle 플러그인 구조
4.1 플러그인 진입점 - AppBuilderPlugin
파일: AppBuilderPlugin.kt
class AppBuilderPlugin : Plugin<Project> {
override fun apply(project: Project) {
// 1. 인코딩 설정
System.setProperty("file.encoding", "UTF-8")
// 2. 전역 설정 Extension 등록
project.extensions.create(AppBuilderExtension.NAME, AppBuilderExtension::class.java, ...)
// 3. 태스크 등록
registerTasks(project)
}
private fun registerTasks(project: Project) {
project.tasks.register(GetTargetsTask.TASK_NAME, GetTargetsTask::class.java)
project.tasks.register(AppBuilderTask.TASK_NAME, AppBuilderTask::class.java)
}
}
구조
AppBuilderPlugin
│
├─ AppBuilderExtension (전역 설정)
│
├─ GetTargetsTask (모듈 감지)
│
└─ AppBuilderTask (빌드 실행)
헷갈리기 쉬운 부분: Extension과 Task 이름
AppBuilderExtension과 AppBuilderTask, 이름이 비슷한데 다른 건가요?
네! 완전히 다른 것입니다. 같은 이름이지만 다른 저장소에 등록돼요.
이름 생성 로직
// AppBuilderExtension.kt
companion object {
val NAME = AppBuilderExtension::class.simpleName.toString().replace("Extension", "")
// "AppBuilderExtension" → "AppBuilder"
}
// AppBuilderTask.kt
companion object {
val TASK_NAME = AppBuilderTask::class.simpleName.toString().replace("Task", "")
// "AppBuilderTask" → "AppBuilder"
}
둘 다 “AppBuilder”지만 다른 곳에 등록됨
// Plugin에서 등록할 때
project.extensions.create("AppBuilder", AppBuilderExtension::class.java) // Extension 저장소
project.tasks.register("AppBuilder", AppBuilderTask::class.java) // Task 저장소
┌─────────────────────────────────────────────────────────────┐
│ 같은 이름, 다른 저장소! │
├─────────────────────────────────────────────────────────────┤
│ │
│ project.extensions (Extension 저장소) │
│ └── "AppBuilder" → AppBuilderExtension │
│ ↓ │
│ appBuilder { ... } (build.gradle) │
│ │
│ project.tasks (Task 저장소) │
│ └── "AppBuilder" → AppBuilderTask │
│ ↓ │
│ $ gradle AppBuilder (명령어) │
│ │
└─────────────────────────────────────────────────────────────┘
사용할 때
// build.gradle - Extension 사용 (소문자로 시작하는 DSL)
appBuilder {
ecrRegistry = 'xxx.dkr.ecr.ap-northeast-2.amazonaws.com'
}
# 명령어 - Task 실행 (대문자로 시작)
$ gradle AppBuilder -PtargetModule=my-module
정리
- Extension “AppBuilder” →
project.extensions에 저장 →appBuilder { }DSL로 설정- Task “AppBuilder” →
project.tasks에 저장 →gradle AppBuilder명령어로 실행Gradle은 Extension과 Task를 다른 저장소에 관리하기 때문에 같은 이름이어도 충돌하지 않음!
두 종류의 Extension: 전역 설정 vs Task 파라미터
AppBuilderExtension과 GetTargetsTaskExtension은 값 넣는 방식이 완전히 달라요!
1. AppBuilderExtension - build.gradle에서 DSL로 설정
// build.gradle.kts (또는 build.gradle)
plugins {
id("com.knet.plugins.app-builder-gradle-plugin") version "1.0.0"
}
// DSL 블록으로 값 설정
appBuilder {
ecrRegistry.set("123456789.dkr.ecr.ap-northeast-2.amazonaws.com")
helmValuesRepo.set("https://github.com/company/helm-values.git")
workspace.set(file("/path/to/workspace"))
// 중첩 Extension
nexus {
url.set("https://nexus.company.com")
username.set("admin")
password.set("secret")
}
github {
username.set("github-user")
password.set("github-token")
}
git {
email.set("ci@company.com")
name.set("CI Bot")
}
}
2. GetTargetsTaskExtension - 명령어 -P 파라미터로 설정
# 명령어로 값 전달
$ gradle GetTargets \
-PtargetModule=order-api \
-PexcludeDirectoryPatterns=".git,node_modules,build"
// GetTargetsTaskExtension.kt 내부 동작
abstract class GetTargetsTaskExtension @Inject constructor(properties: Map<String, Object>) {
// -PtargetModule=order-api → properties["targetModule"] = "order-api"
var targetModule: String = properties["targetModule"]?.toString() ?: ""
// -PexcludeDirectoryPatterns=... → properties["excludeDirectoryPatterns"]
var excludeDirectoryPatterns: List<String> = ...
}
비교 요약
┌─────────────────────────────────────────────────────────────┐
│ 두 가지 Extension의 값 설정 방식 │
├─────────────────────────────────────────────────────────────┤
│ │
│ AppBuilderExtension (전역 설정) │
│ ───────────────────────────── │
│ 설정 위치: build.gradle │
│ 설정 방식: DSL 블록 │
│ 설정 시점: 빌드 구성 단계 (Configuration Phase) │
│ │
│ appBuilder { │
│ ecrRegistry.set("...") │
│ nexus { │
│ url.set("...") │
│ } │
│ } │
│ │
├─────────────────────────────────────────────────────────────┤
│ │
│ GetTargetsTaskExtension (Task 파라미터) │
│ ─────────────────────────────────────── │
│ 설정 위치: 명령어 │
│ 설정 방식: -P 옵션 │
│ 설정 시점: 실행 시점 (Execution Phase) │
│ │
│ $ gradle GetTargets -PtargetModule=order-api │
│ │
└─────────────────────────────────────────────────────────────┘
| 구분 | AppBuilderExtension | GetTargetsTaskExtension |
|---|---|---|
| 용도 | 프로젝트 전역 설정 | Task 실행 파라미터 |
| 설정 위치 | build.gradle |
명령어 -P 옵션 |
| 설정 방식 | DSL 블록 appBuilder { } |
-PtargetModule=... |
| 값 타입 | Property<String> (Gradle API) |
일반 String |
| 설정 시점 | 빌드 구성 시 | Task 실행 시 |
| 변경 빈도 | 프로젝트당 1번 설정 | 매 실행마다 다를 수 있음 |
요약
- AppBuilderExtension: “이 프로젝트의 ECR은 뭐야? Nexus는 어디야?” → 고정 설정
- GetTargetsTaskExtension: “이번에 어떤 모듈 빌드해?” → 실행 시 파라미터
build() 빈 값으로도 동작하는 이유
의문:
@Library('app-builder-gradle-plugin') _ build()호출 시 아무 값도 안 넣는데 어떻게 동작하지?
실제로 빌드 대상 프로젝트(예: com.knet.msa/fax)의 build.gradle을 보면:
// fax/build.gradle - appBuilder 설정이 없다!
plugins {
id 'java'
id 'org.springframework.boot' version '3.0.0'
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
}
// appBuilder { } 블록 없음!
// fax/Jenkinsfile
@Library('app-builder-gradle-plugin') _
build() // 빈 값!
그런데도 build()가 정상 동작하는 이유는 4가지 폴백 메커니즘 때문입니다.
1. Task 기본값 폴백 (try-catch 패턴)
// Task 내부 코드
val workspace: File = try {
extension.workspace.get() // Extension에서 가져오기 시도
} catch (e: Exception) {
project.rootDir // 실패하면 프로젝트 루트 사용
}
appBuilder.workspace가 설정되지 않으면 →project.rootDir사용- 대부분의 설정이 이런 안전한 기본값을 가짐
2. Jenkins 환경변수 자동 제공
Jenkins가 빌드 실행 시 자동으로 주입하는 환경변수:
// build.groovy에서 환경변수 변환
appBuilderConfig.forEach { key, value ->
env[camelToSnake(key)] = value // camelCase → SNAKE_CASE
}
| Jenkins 환경변수 | 용도 | 자동 제공 |
|---|---|---|
BRANCH_NAME |
현재 브랜치 | ✅ Jenkins 자동 |
GIT_PREVIOUS_COMMIT |
이전 커밋 해시 | ✅ Jenkins 자동 |
GIT_COMMIT |
현재 커밋 해시 | ✅ Jenkins 자동 |
WORKSPACE |
Jenkins 작업 디렉토리 | ✅ Jenkins 자동 |
BUILD_NUMBER |
빌드 번호 | ✅ Jenkins 자동 |
3. Jenkins Credentials
build.groovy에서 withCredentials로 인증 정보 주입:
// vars/build.groovy
withCredentials([
usernamePassword(credentialsId: 'nexus',
usernameVariable: 'NEXUS_USERNAME',
passwordVariable: 'NEXUS_PASSWORD'),
usernamePassword(credentialsId: 'github',
usernameVariable: 'GITHUB_USERNAME',
passwordVariable: 'GITHUB_PASSWORD')
]) {
// 여기서 gradle 실행 - 환경변수로 인증 정보 전달됨
}
NEXUS_USERNAME,NEXUS_PASSWORD→ Nexus 저장소 인증GITHUB_USERNAME,GITHUB_PASSWORD→ GitHub 인증
4. 프로젝트 구조 자동 감지
Task가 프로젝트 구조를 분석해서 자동 설정:
// 모듈 언어 자동 감지
val language = when {
file("$modulePath/pom.xml").exists() -> "java"
file("$modulePath/build.gradle").exists() -> "java"
file("$modulePath/package.json").exists() -> "node"
file("$modulePath/go.mod").exists() -> "go"
else -> "unknown"
}
// 빌드 타입 자동 감지
val type = when {
file("$modulePath/Dockerfile.app.java").exists() -> "app"
file("$modulePath/Dockerfile.lib.java").exists() -> "lib"
else -> "unknown"
}
동작 흐름 요약
┌─────────────────────────────────────────────────────────────┐
│ build() 호출 시 값 주입 순서 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1️⃣ build.gradle의 appBuilder { } 값 │
│ └→ 없으면 ↓ │
│ │
│ 2️⃣ Jenkins Credentials (nexus, github) │
│ └→ 환경변수로 NEXUS_*, GITHUB_* 주입 │
│ │
│ 3️⃣ Jenkins 자동 환경변수 │
│ └→ BRANCH_NAME, GIT_COMMIT, WORKSPACE 등 │
│ │
│ 4️⃣ Task 기본값 폴백 │
│ └→ project.rootDir, 자동 감지 로직 │
│ │
│ ✅ 결과: 아무 설정 없어도 빌드 가능! │
│ │
└─────────────────────────────────────────────────────────────┘
결론
appBuilder { }설정은 선택사항 (Optional)- Jenkins 환경 + 기본값 폴백으로 대부분 자동 처리
- 커스텀이 필요할 때만
appBuilder { }나build(appBuilderConfig: [...])사용
4.2 전역 설정 - AppBuilderExtension
파일: extension/AppBuilderExtension.kt
이 플러그인 vs 빌드 대상 프로젝트
| 구분 | 이 플러그인 (app-builder-plugin) | 빌드 대상 프로젝트 (order, payment 등) |
|---|---|---|
| 역할 | 플러그인 제공 | 플러그인 사용 |
| 전역 설정 | 설정 스키마 정의 (AppBuilderExtension.kt) | 설정 값 세팅 (build.gradle.kts) |
┌─ 이 플러그인 (app-builder-plugin) ────────────┐
│ │
│ AppBuilderExtension.kt: │
│ abstract class AppBuilderExtension { │
│ var ecrRegistry: Property<String> // 속성 정의
│ var helmValuesRepo: Property<String> │
│ val nexus: NexusConfig │
│ ... │
│ } │
│ │
└──────────────────────────────────────────────┘
┌─ order 프로젝트 (빌드 대상) ─────────────────────┐
│ │
│ build.gradle.kts: │
│ plugins { │
│ id("com.example.plugins.app-builder-gradle-plugin")
│ } │
│ │
│ appBuilder { ← 실제 값 세팅! │
│ ecrRegistry.set("123.ecr.aws.com") │
│ helmValuesRepo.set("github.com/...") │
│ nexus { │
│ username.set(System.getenv("NEXUS_USERNAME"))
│ } │
│ } │
│ │
└──────────────────────────────────────────────┘
기본 설정
| 속성 | 설명 |
|---|---|
ecrRegistry |
AWS ECR 주소 (Docker 이미지 저장소) |
helmValuesRepo |
Helm values Git 저장소 (ArgoCD 배포용) |
workspace |
작업 디렉토리 |
modulesFileName |
모듈 정보 파일명 (기본: modules-data.json) |
서비스별 Config
| Config | 용도 |
|---|---|
nexus |
Maven 저장소 인증 |
github |
GitHub 인증 (Helm repo 접근) |
git |
Git 커밋 작성자 정보 |
configServer |
Spring Cloud Config Server 주소 |
spring |
Spring 프로파일 설정 |
node |
Node.js 환경 설정 |
go |
Go 프로파일 설정 |
값 설정 방법
값이 설정되는 경로는 두 가지다:
| 설정 | 어디서 오는지 |
|---|---|
NEXUS_USERNAME/PASSWORD |
Jenkins Credentials (nexus) |
GITHUB_USERNAME/PASSWORD |
Jenkins Credentials (github) |
ECR_REGISTRY, helmValuesRepo 등 |
사용자 프로젝트 build.gradle.kts에서 직접 설정 |
값 주입 흐름
┌─ Jenkinsfile ─────────────────────────────────┐
│ @Library('app-builder-gradle-plugin') _ │
│ build() ← 파라미터 없이 호출 │
└───────────────────────────────────────────────┘
│
▼
┌─ build.groovy ────────────────────────────────┐
│ withCredentials([...]) { │
│ // Jenkins Credentials에서 자동 주입 │
│ env.NEXUS_USERNAME = "xxx" │
│ env.NEXUS_PASSWORD = "xxx" │
│ env.GITHUB_USERNAME = "xxx" │
│ env.GITHUB_PASSWORD = "xxx" │
│ } │
└───────────────────────────────────────────────┘
│
▼
┌─ 사용자 프로젝트 build.gradle.kts ─────────────┐
│ appBuilder { │
│ ecrRegistry.set("123.ecr.aws.com") // 직접 설정
│ nexus { │
│ username.set(System.getenv("NEXUS_USERNAME"))
│ password.set(System.getenv("NEXUS_PASSWORD"))
│ } │
│ } │
└───────────────────────────────────────────────┘
│
▼
┌─ AppBuilderExtension ─────────────────────────┐
│ ecrRegistry, nexus.username 등 값 저장 │
└───────────────────────────────────────────────┘
5. 데이터 모델 - Entity
데이터 모델이 담는 정보
데이터 모델은 플러그인 자체의 정보가 아니라, 플러그인을 호출한 프로젝트의 정보를 담는다.
| 모델 | 담는 정보 |
|---|---|
CommitInfo |
빌드 대상 프로젝트(order, payment 등)의 Git 커밋 정보 |
ModuleInfo |
빌드 대상 프로젝트의 각 모듈 정보 |
BuildInfo |
위 두 개를 합친 전체 빌드 컨텍스트 |
빌드 대상 프로젝트에서 플러그인 사용법
// order 프로젝트의 build.gradle.kts
plugins {
id("com.example.plugins.app-builder-gradle-plugin") version "1.0.0"
}
appBuilder {
ecrRegistry.set("123456789.dkr.ecr.ap-northeast-2.amazonaws.com")
helmValuesRepo.set("https://github.com/myorg/helm-values.git")
nexus {
username.set(System.getenv("NEXUS_USERNAME"))
password.set(System.getenv("NEXUS_PASSWORD"))
}
github {
username.set(System.getenv("GITHUB_USERNAME"))
password.set(System.getenv("GITHUB_PASSWORD"))
}
}
예시: order 프로젝트 → 데이터 모델 생성
┌─ order 프로젝트 ────────────────────────────────┐
│ │
│ build.gradle.kts: │
│ plugins { │
│ id("com.example.plugins.app-builder-gradle-plugin")
│ } │
│ appBuilder { ... } │
│ │
│ Jenkinsfile: │
│ @Library('app-builder-gradle-plugin') _ │
│ build() │
│ │
│ settings.gradle.kts: │
│ include("order-api") │
│ include("order-batch") │
│ include("order-common") │
│ │
└──────────────────────────────────────────────┘
│
│ 플러그인이 order 프로젝트를 분석
▼
┌─ 생성되는 데이터 모델 ────────────────────────┐
│ BuildInfo { │
│ commit: { │
│ repo: "order", │
│ branch: "develop", │
│ author: "홍길동" │
│ }, │
│ modules: { │
│ "order-api": { language: "JAVA", type: "app", changed: true },
│ "order-batch": { language: "JAVA", type: "app", changed: false },
│ "order-common": { language: "JAVA", type: "lib", changed: true }
│ } │
│ } │
└──────────────────────────────────────────────┘
값 세팅에 사용되는 유틸리티 클래스
DetectorExecutor에서 각 유틸리티 클래스를 사용해서 값을 세팅한다.
CommitInfo 값 세팅
// DetectorExecutor.kt
val gitUtils = GitUtils(project, logger, workspace)
val commitInfo = gitUtils.collectCommitInfo()
| 필드 | 세팅하는 곳 | 방법 |
|---|---|---|
repo |
GitUtils.getRepoName() |
Git remote URL에서 추출 |
branch |
GitUtils.getCurrentBranch() |
현재 브랜치 |
author |
GitUtils.getCommitAuthor() |
최근 커밋 작성자 |
commitMessage |
GitUtils.getCommitMessage() |
최근 커밋 메시지 |
ModuleInfo 값 세팅
// DetectorExecutor.kt
val allModules = ModuleDiscoveryUtils.discoverAllModules(...)
val moduleTypes = DockerfileUtils.determineModuleTypes(...)
val moduleLanguages = LanguageDetectionUtils.detectLanguagesForModules(...)
val changedModules = DependencyAnalysisUtils.detectChangedModulesWithDependencyAnalysis(...)
| 필드 | 세팅하는 유틸리티 | 방법 |
|---|---|---|
index |
forEachIndexed |
순서대로 0, 1, 2… |
name |
ModuleDiscoveryUtils |
settings.gradle 파싱 |
language |
LanguageDetectionUtils |
Dockerfile FROM절 / 빌드파일 확인 |
type |
DockerfileUtils |
Dockerfile.app.* → “app”, Dockerfile.lib.* → “lib” |
changed |
DependencyAnalysisUtils |
Git diff + 의존성 전파 분석 |
version |
GitTagUtils / ModuleVersionUtils |
Git 태그 또는 build.gradle에서 추출 |
status |
기본값 | “SUCCESS” |
값 세팅 흐름도
DetectorExecutor.execute()
│
├─ GitUtils.collectCommitInfo()
│ └─ CommitInfo { repo, branch, author, commitMessage }
│
├─ ModuleDiscoveryUtils.discoverAllModules()
│ └─ ["order-api", "order-batch", "order-common"]
│
├─ DockerfileUtils.determineModuleTypes()
│ └─ { "order-api": "app", "order-common": "lib" }
│
├─ LanguageDetectionUtils.detectLanguagesForModules()
│ └─ { "order-api": "JAVA", "order-batch": "JAVA" }
│
├─ DependencyAnalysisUtils.detectChangedModules...()
│ └─ ["order-api", "order-common"] // 변경된 것만
│
└─ createAndSaveBuildInfo()
└─ ModuleInfo 조합해서 BuildInfo 생성
데이터 클래스 정의
CommitInfo
data class CommitInfo(
val repo: String, // 저장소 이름
val branch: String, // 브랜치
val author: String, // 커밋 작성자
val commitMessage: String // 커밋 메시지
)
ModuleInfo
data class ModuleInfo(
var index: Int, // 빌드 순서 (토폴로지 정렬 결과)
val name: String, // 모듈 이름
val language: String, // 언어 (JAVA, NODE, GO 등)
val type: String, // 타입 (app: 배포용, lib: 라이브러리)
var changed: Boolean, // 변경 여부 (true면 빌드 대상)
var version: String?, // 버전
var status: String, // 상태 (pending, SUCCESS, FAILED)
)
BuildInfo
data class BuildInfo(
val commit: CommitInfo, // Git 커밋 정보
val modules: Map<String, ModuleInfo> // 모듈명 → 모듈정보 맵
)
6. 모듈 감지 - GetTargetsTask
Gradle의 Project 객체란?
GetTargetsTask 코드에서 project는 Gradle의 핵심 객체다:
val executor = DetectorExecutor(project, logger)
org.gradle.api.Project는 Gradle이 빌드할 때 자동으로 주입하는 객체로, 현재 빌드 중인 프로젝트의 모든 정보에 접근할 수 있다.
┌─ org.gradle.api.Project ────────────────────────────────────────┐
│ │
│ 프로젝트 정보 │
│ ├─ project.name → "app-builder-gradle-plugin" │
│ ├─ project.rootDir → /workspace/app-builder-plugin │
│ ├─ project.projectDir → /workspace/app-builder-plugin │
│ └─ project.buildDir → /workspace/.../build │
│ │
│ 빌드 설정 │
│ ├─ project.extensions → 플러그인 Extension들 (AppBuilderExtension)
│ ├─ project.tasks → 등록된 Task들 │
│ └─ project.properties → gradle.properties 값들 │
│ │
│ 로깅 │
│ └─ project.logger → Gradle 로거 (debug, info, warn...) │
│ │
│ 서브프로젝트 (멀티모듈) │
│ ├─ project.rootProject → 루트 프로젝트 │
│ ├─ project.subprojects → 하위 프로젝트들 │
│ └─ project.allprojects → 전체 프로젝트들 │
│ │
└──────────────────────────────────────────────────────────────────┘
중요: 이 플러그인에서 project는 app-builder-plugin 프로젝트를 가리킨다. 빌드 대상인 order, payment 프로젝트가 아니다!
┌─ Jenkins workspace ────────────────────────────────────────────┐
│ │
│ /workspace/order/ ← 빌드 대상 프로젝트 (workspace) │
│ │ │
│ └─ /workspace/app-builder-plugin/ ← 플러그인 (project) │
│ │
└─────────────────────────────────────────────────────────────────┘
따라서 workspace 파라미터로 실제 빌드 대상 경로를 별도로 전달한다.
GetTargetsTask 상세
파일: tasks/targets/GetTargetsTask.kt
open class GetTargetsTask : DefaultTask() {
// Gradle이 주입하는 project 객체 (DefaultTask에서 상속)
// this.project → org.gradle.api.Project
@Internal
val extension = project.extensions.getByName(GetTargetsTaskExtension.NAME)
@Internal
val appBuilderExtension = project.extensions.getByName(AppBuilderExtension.NAME)
@TaskAction
fun execute() {
// 1. 파라미터 및 환경변수 처리
val (targetModule, workspace) = processParameters()
// 2. DetectorExecutor에 핵심 로직 위임
val executor = DetectorExecutor(project, logger)
executor.execute(
workspace = workspace, // 빌드 대상 프로젝트 경로
excludeDirectoryPatterns = extension.excludeDirectoryPatterns,
excludeFilePatterns = extension.excludeFilePatterns
)
}
private fun processParameters(): Pair<String?, File> {
// workspace: 환경변수 또는 Extension에서 가져옴
val workspace = try {
appBuilderExtension.workspace.get()
} catch (e: Exception) {
project.rootDir // 기본값: 플러그인 프로젝트 루트
}
return Pair(targetModule, workspace)
}
}
DetectorExecutor - 핵심 로직
파일: tasks/targets/executor/DetectorExecutor.kt
class DetectorExecutor(
private val project: Project, // Gradle Project 객체
private val logger: Logger // Gradle 로거
) {
fun execute(workspace: File, ...): BuildInfo {
// 1. Git 커밋 정보 수집
val gitUtils = GitUtils(project, logger, workspace)
val commitInfo = gitUtils.collectCommitInfo()
// 2. 모든 모듈 발견 (settings.gradle 파싱)
val allModules = ModuleDiscoveryUtils.discoverAllModules(project, workspace)
// 3. 모듈 타입 결정 (Dockerfile 기반 app/lib)
val moduleTypes = DockerfileUtils.determineModuleTypes(allModules, workspace, project)
// 4. 모듈 언어 감지 (JAVA, NODE, GO...)
val moduleLanguages = LanguageDetectionUtils.detectLanguagesForModules(allModules, workspace, project)
// 5. 변경된 모듈 감지 (Git diff + 의존성 분석)
val changedModules = detectChangedModules(allModules, workspace, ...)
// 6. BuildInfo 생성 및 JSON 저장
val buildInfo = createAndSaveBuildInfo(commitInfo, allModules, ...)
return buildInfo
}
}
실행 흐름
./gradlew GetTargets
│
▼
┌─ GetTargetsTask ──────────────────────────────────────────────┐
│ processParameters() │
│ └─ workspace = /workspace/order (빌드 대상 프로젝트) │
└────────────────────────────────────────────────────────────────┘
│
▼
┌─ DetectorExecutor ─────────────────────────────────────────────┐
│ │
│ ① GitUtils.collectCommitInfo() │
│ └─ Git 명령어로 repo, branch, author, message 수집 │
│ │
│ ② ModuleDiscoveryUtils.discoverAllModules() │
│ └─ settings.gradle 파싱하여 모듈 목록 추출 │
│ │
│ ③ DockerfileUtils.determineModuleTypes() │
│ └─ Dockerfile.app.* / Dockerfile.lib.* 파일로 타입 결정 │
│ │
│ ④ LanguageDetectionUtils.detectLanguagesForModules() │
│ └─ build.gradle, package.json 등으로 언어 감지 │
│ │
│ ⑤ DependencyAnalysisUtils.detectChangedModulesWithDependency..│
│ ├─ Git diff로 변경 파일 감지 │
│ ├─ 의존성 그래프 구축 │
│ ├─ 의존성 전파 분석 │
│ └─ 토폴로지 정렬로 빌드 순서 결정 │
│ │
│ ⑥ createAndSaveBuildInfo() │
│ └─ modules-data.json 파일 생성 │
│ │
└─────────────────────────────────────────────────────────────────┘
│
▼
build/app-builder/modules-data.json 생성
유틸리티 클래스 상세
1. GitUtils - Git 정보 수집
파일: utils/GitUtils.kt
class GitUtils(
private val project: Project,
private val logger: Logger,
private val workspace: File // Git 저장소 위치
) {
// Git 명령어 실행
fun execGitCommand(vararg args: String): String {
val process = ProcessBuilder("git", *args)
.directory(workspace) // workspace에서 실행!
.start()
return process.inputStream.bufferedReader().readText()
}
// 커밋 정보 수집
fun collectCommitInfo(): CommitInfo {
val repo = getRepoName() // git config --get remote.origin.url
val branch = getCurrentBranch() // git branch --show-current
val author = getCommitAuthor() // git log -1 --pretty=format:%an <%ae>
val commitMessage = getCommitMessage() // git log -1 --pretty=format:%s
return CommitInfo.create(repo, branch, author, commitMessage)
}
}
실행되는 Git 명령어:
| 정보 | Git 명령어 | 예시 결과 |
|---|---|---|
| repo | git config --get remote.origin.url → 이름 추출 |
order |
| branch | BRANCH_NAME 환경변수 또는 git branch --show-current |
develop |
| author | git log -1 --pretty=format:%an <%ae> |
hong <hong@example.com> |
| message | git log -1 --pretty=format:%s |
feat: 주문 API 개선 |
2. ModuleDiscoveryUtils - 모듈 발견
파일: tasks/targets/utils/ModuleDiscoveryUtils.kt
settings.gradle[.kts] 파일을 파싱하여 모든 모듈을 발견한다:
object ModuleDiscoveryUtils {
fun discoverAllModules(project: Project, workspace: File): List<String> {
return parseSettingsFile(workspace, project)
}
private fun parseSettingsFile(workspace: File, project: Project): List<String> {
val modules = mutableListOf<String>()
// settings.gradle 또는 settings.gradle.kts 파일 찾기
val settingsFile = listOf(
File(workspace, "settings.gradle"),
File(workspace, "settings.gradle.kts")
).firstOrNull { it.exists() }
if (settingsFile != null) {
val content = settingsFile.readText()
// include 구문 파싱
// include("order-api")
// include 'order-batch'
val pattern1 = Regex("""include\s*\(\s*['"]([^'"]+)['"]\s*\)""")
val pattern2 = Regex("""include\s+['"]([^'"]+)['"]""")
pattern1.findAll(content).forEach { modules.add(it.groupValues[1]) }
pattern2.findAll(content).forEach { modules.add(it.groupValues[1]) }
}
return modules.distinct()
}
}
예시:
// settings.gradle.kts
rootProject.name = "order"
include("order-api") // ← 추출됨
include("order-batch") // ← 추출됨
include("order-common") // ← 추출됨
결과: ["order-api", "order-batch", "order-common"]
3. DockerfileUtils - 모듈 타입 결정
파일: tasks/targets/utils/DockerfileUtils.kt
Dockerfile 이름 규칙으로 모듈 타입(app/lib)을 결정한다:
object DockerfileUtils {
const val MODULE_TYPE_APP = "app" // 배포 대상 (Docker 이미지 생성)
const val MODULE_TYPE_LIB = "lib" // 라이브러리 (Maven 배포만)
fun determineModuleTypes(modules: List<String>, workspace: File, project: Project): Map<String, String> {
return modules.associateWith { module ->
determineModuleType(module, workspace)
}
}
private fun determineModuleType(module: String, workspace: File): String {
// Dockerfile.app.{module} 파일이 있으면 app
val appDockerfile = File(workspace, "Dockerfile.app.$module")
if (appDockerfile.exists()) return MODULE_TYPE_APP
// Dockerfile.lib.{module} 파일이 있으면 lib
val libDockerfile = File(workspace, "Dockerfile.lib.$module")
if (libDockerfile.exists()) return MODULE_TYPE_LIB
return MODULE_TYPE_LIB // 기본값
}
// Dockerfile이 있는 모듈만 필터링 (빌드 대상)
fun filterModulesWithDockerfiles(modules: List<String>, workspace: File, project: Project): List<String> {
return modules.filter { module ->
File(workspace, "Dockerfile.app.$module").exists() ||
File(workspace, "Dockerfile.lib.$module").exists()
}
}
}
예시:
/workspace/order/
├── Dockerfile.app.order-api ← order-api → "app"
├── Dockerfile.app.order-batch ← order-batch → "app"
├── Dockerfile.lib.order-common ← order-common → "lib"
└── order-client/ ← Dockerfile 없음 → 빌드 제외
4. ChangeDetectionUtils - 변경 감지
파일: tasks/targets/utils/ChangeDetectionUtils.kt
Git diff를 사용하여 변경된 파일을 감지하고, 해당 파일이 속한 모듈을 찾는다:
object ChangeDetectionUtils {
fun detectChangedModulesWithFiltering(
modules: List<String>,
workspace: File,
project: Project,
gitUtils: GitUtils,
excludeDirectoryPatterns: List<String>,
excludeFilePatterns: List<String>
): List<String> {
// 1. 변경된 파일 목록 조회 (3단계 시도)
val changedFiles = getChangedFilesMultiStage(workspace, project, gitUtils)
// 2. 제외 패턴 필터링
val filteredFiles = processAndFilterFiles(changedFiles, excludePatterns...)
// 3. 변경된 모듈 찾기
val changedModules = findDirectlyChangedModules(filteredFiles, modules, workspace)
return changedModules
}
}
Base Commit 결정 (GitUtils):
Jenkins Git 플러그인이 제공하는 환경변수를 활용하여 비교 기준점을 결정한다:
// GitUtils.kt
/**
* 환경변수에서 base commit 가져오기 (Jenkins Git 플러그인)
*
* 우선순위:
* 1. GIT_PREVIOUS_SUCCESSFUL_COMMIT (마지막 성공 빌드 기준)
* 2. GIT_PREVIOUS_COMMIT (이번 push 범위)
*/
fun getBaseCommitFromEnv(): String? {
return System.getenv("GIT_PREVIOUS_SUCCESSFUL_COMMIT")?.takeIf { it.isNotBlank() }
?: System.getenv("GIT_PREVIOUS_COMMIT")?.takeIf { it.isNotBlank() }
}
┌─ Base Commit 결정 우선순위 ──────────────────────────────────────┐
│ │
│ 1순위: GIT_PREVIOUS_SUCCESSFUL_COMMIT │
│ └─ 마지막 성공 빌드의 커밋 │
│ └─ 빌드 실패 후 재시도 시 누락된 변경사항 포함 │
│ │
│ 2순위: GIT_PREVIOUS_COMMIT │
│ └─ 이번 push 직전 커밋 │
│ └─ 단일 push에 여러 커밋 포함 시 유용 │
│ │
│ 3순위: Merge 커밋 감지 │
│ └─ 첫 번째 부모와 비교 │
│ │
│ 4순위: HEAD~1 (기본값) │
│ └─ 환경변수 없을 때 직전 커밋과 비교 │
│ │
└───────────────────────────────────────────────────────────────────┘
빌드 실패 재시도 시나리오:
┌─ 시나리오: 빌드 실패 후 수정 ────────────────────────────────────┐
│ │
│ 커밋 A (성공) ← GIT_PREVIOUS_SUCCESSFUL_COMMIT │
│ ↓ │
│ 커밋 B: api, batch 변경 → 빌드 실행 → api 빌드 실패 │
│ ↓ │
│ 커밋 C: api 수정 → 빌드 재시도 │
│ │
│ 비교: 커밋 A ~ 커밋 C │
│ 결과: api, batch 모두 빌드 대상 (batch는 커밋 B에서 변경됨) │
│ │
└───────────────────────────────────────────────────────────────────┘
3단계 변경 파일 감지:
┌─ 변경 파일 감지 전략 ───────────────────────────────────────────┐
│ │
│ 1단계: 커밋 비교 (Jenkins에서 주로 사용) │
│ ├─ git diff --name-only {baseCommit} HEAD │
│ ├─ baseCommit = getBaseCommit() 으로 결정 │
│ └─ Merge 커밋: git diff --name-only {첫번째 부모} HEAD │
│ │
│ ↓ 파일 없으면 │
│ │
│ 2단계: Staged 파일 │
│ └─ git diff --cached --name-only │
│ │
│ ↓ 파일 없으면 │
│ │
│ 3단계: Working directory │
│ └─ git diff --name-only │
│ │
└──────────────────────────────────────────────────────────────────┘
모듈 매칭 로직:
private fun isModuleChanged(module: String, changedFiles: List<String>, ...): Boolean {
return changedFiles.any { file ->
when {
// 모듈 디렉토리 내 파일 변경
file.startsWith("$module/") -> true
// 모듈 빌드 파일 변경
file == "$module/build.gradle.kts" -> true
file == "$module/package.json" -> true
// 모듈별 Dockerfile 변경
file == "Dockerfile.app.$module" -> true
file == "Dockerfile.lib.$module" -> true
else -> false
}
}
}
전역 변경 파일 (전체 빌드 트리거):
private fun findGlobalChanges(changedFiles: List<String>): List<String> {
val rootFiles = setOf(
"Jenkinsfile",
"settings.gradle",
"settings.gradle.kts",
"gradle.properties"
)
// 이 파일이 변경되면 모든 모듈 빌드
return changedFiles.filter { it in rootFiles }
}
5. DependencyAnalysisUtils - 의존성 분석
파일: tasks/targets/utils/DependencyAnalysisUtils.kt
변경된 모듈에 의존하는 다른 모듈도 함께 빌드해야 한다:
object DependencyAnalysisUtils {
fun detectChangedModulesWithDependencyAnalysis(
allModules: List<String>,
workspace: File,
project: Project,
logger: Logger,
excludePatterns...
): List<String>
{
// 1. 직접 변경된 모듈 감지
val directlyChangedModules = ChangeDetectionUtils.detectChangedModulesWithFiltering(...)
// 2. 의존성 그래프 구축
val dependencyGraph = buildDependencyGraph(project, allModules, workspace)
// 예: { "order-api": ["order-common"], "order-batch": ["order-common"] }
// 3. Dockerfile이 있는 모듈만 필터링
val dockerfileModules = DockerfileUtils.filterModulesWithDockerfiles(...)
// 4. 의존성 전파 분석
// order-common 변경 → order-api, order-batch도 빌드 필요
val allAffectedModules = DependencyPropagationUtils.findAffected(
changedModules = directlyChangedModules,
dependencyGraph = dependencyGraph,
dockerfileModules = dockerfileModules
)
// 5. 토폴로지 정렬 (의존성 순서대로 빌드)
// order-common → order-api → order-batch 순서
val orderedModules = TopologyUtils.topologicalSort(dependencyGraph)
.filter { it in allAffectedModules }
return orderedModules
}
}
의존성 전파 예시:
┌─ 의존성 그래프 ──────────────────────────────────────────────────┐
│ │
│ order-api ──────┐ │
│ ├──→ order-common (라이브러리) │
│ order-batch ────┘ │
│ │
├───────────────────────────────────────────────────────────────────┤
│ │
│ [시나리오] order-common 변경됨 │
│ │
│ 직접 변경: order-common │
│ 의존성 전파: order-api, order-batch (order-common 사용) │
│ │
│ 최종 빌드 대상 (토폴로지 순서): │
│ 1. order-common ← 먼저 빌드 (라이브러리) │
│ 2. order-api ← 다음 빌드 (order-common 사용) │
│ 3. order-batch ← 마지막 빌드 │
│ │
└───────────────────────────────────────────────────────────────────┘
토폴로지 정렬(Topological Sort)이란?
의존성이 있는 작업들을 올바른 순서로 정렬하는 알고리즘이다.
핵심 원리: “A가 B에 의존한다” → “B를 먼저 처리해야 한다”
실제 fax 프로젝트 예시:
┌─ 의존성 관계 ─────────────────────────────────────────────────┐
│ │
│ fax-api ──────────┐ │
│ ├──→ fax (공통 라이브러리) │
│ fax-batch ────────┤ │
│ │ │
│ fax-scheduler ────┘ │
│ │
│ fax-vendor-hanafax ──→ fax │
│ fax-vendor-lg ───────→ fax │
│ │
└────────────────────────────────────────────────────────────────┘
토폴로지 정렬 결과:
fax → fax-api → fax-batch → fax-scheduler → fax-vendor-*
↑
반드시 먼저! (다른 모듈들이 의존함)
왜 필요한가?
❌ 잘못된 순서 (fax-api 먼저 빌드)
┌─────────────────────────────────────────────────────────────────┐
│ 1. fax-api 빌드 시도 │
│ → 컴파일 에러! fax 라이브러리가 아직 없음 │
└─────────────────────────────────────────────────────────────────┘
✅ 올바른 순서 (토폴로지 정렬 적용)
┌─────────────────────────────────────────────────────────────────┐
│ 1. fax 빌드 → Maven에 배포 (1.0.0-SNAPSHOT) │
│ 2. fax-api 빌드 → fax:1.0.0-SNAPSHOT 사용 가능! │
│ 3. fax-batch 빌드 │
│ 4. ... │
└─────────────────────────────────────────────────────────────────┘
알고리즘 원리:
1. 의존성이 없는 노드(진입 차수 0)를 찾는다 → fax
2. 해당 노드를 결과에 추가하고 그래프에서 제거
3. 제거된 노드에 의존하던 노드들의 진입 차수 감소
4. 반복
┌─ 단계별 ──────────────────────────────────────────────────────┐
│ │
│ 초기: fax(0), fax-api(1), fax-batch(1), fax-scheduler(1) │
│ ↑ 진입차수 0 = 아무것도 의존하지 않음 │
│ │
│ 1단계: fax 추출 → [fax] │
│ 나머지 진입차수 감소: fax-api(0), fax-batch(0), ... │
│ │
│ 2단계: fax-api 추출 → [fax, fax-api] │
│ 3단계: fax-batch 추출 → [fax, fax-api, fax-batch] │
│ ... │
│ │
└────────────────────────────────────────────────────────────────┘
실제 코드 (TopologyUtils.kt):
fun topologicalSort(dependencyGraph: Map<String, List<String>>): List<String> {
val result = mutableListOf<String>()
val visited = mutableSetOf<String>()
fun visit(node: String) {
if (node in visited) return
visited.add(node)
// 의존하는 모듈들 먼저 방문 (DFS)
dependencyGraph[node]?.forEach { dependency ->
visit(dependency)
}
result.add(node)
}
dependencyGraph.keys.forEach { visit(it) }
return result
}
요약: lib 모듈을 app 모듈보다 먼저 빌드하기 위한 정렬 알고리즘이다.
최종 출력: modules-data.json
{
"commit": {
"repo": "order",
"branch": "develop",
"author": "hong.gildong",
"commitMessage": "feat: 주문 API 개선"
},
"modules": {
"order-common": {
"index": 0,
"name": "order-common",
"language": "JAVA",
"type": "lib",
"changed": true,
"version": "1.0.0-SNAPSHOT",
"status": "SUCCESS"
},
"order-api": {
"index": 1,
"name": "order-api",
"language": "JAVA",
"type": "app",
"changed": true,
"version": "1.2.3-develop",
"status": "SUCCESS"
},
"order-batch": {
"index": 2,
"name": "order-batch",
"language": "JAVA",
"type": "app",
"changed": false,
"version": null,
"status": "SUCCESS"
}
}
}
7. 빌드 실행 - AppBuilderTask
GetTargetsTask vs AppBuilderTask 비교
| 구분 | GetTargetsTask | AppBuilderTask |
|---|---|---|
| 역할 | 빌드 대상 감지 | 실제 빌드 실행 |
| 입력 | workspace (프로젝트 경로) | targetModule (모듈명) |
| 출력 | modules-data.json 생성 | modules-data.json 업데이트 |
| 호출 | 파이프라인 시작 시 1회 | 모듈별로 N회 반복 호출 |
┌─ build.groovy 파이프라인 ───────────────────────────────────────┐
│ │
│ stage("🔍 변경 감지") { │
│ gradle GetTargets ← 1회 호출 │
│ } │
│ │
│ buildData.modules.each { module -> │
│ stage("[${module.language}] ${module.name}") { │
│ gradle AppBuilder -PtargetModule=${module.name} │
│ } ↑ 모듈별 반복 호출 │
│ } │
│ │
└──────────────────────────────────────────────────────────────────┘
AppBuilderTask 상세
파일: tasks/builder/AppBuilderTask.kt
open class AppBuilderTask : DefaultTask() {
companion object {
// Task 이름: "AppBuilder" (Task 접미사 제거)
val TASK_NAME = AppBuilderTask::class.simpleName.toString().replace("Task", "")
}
// Extension에서 설정값 가져오기
@Internal
val extension = project.extensions.getByName(AppBuilderTaskExtension.NAME)
@Internal
val appBuilderExtension = project.extensions.getByName(AppBuilderExtension.NAME)
@TaskAction
fun execute() {
try {
// 1. 파라미터 및 환경변수 처리
val (targetModule, workspace) = processParameters()
// 2. AppBuilderExecutor에 핵심 로직 위임
val executor = AppBuilderExecutor(project, logger)
val result = executor.execute(targetModule, workspace)
// 3. build.groovy를 위한 결과 출력 (파싱용)
println("[AppBuilderTask] $targetModule -> status=${result.status}, version=${result.version}")
} catch (e: Exception) {
logger.error("❌ AppBuilderTask 실행 실패: ${e.message}", e)
throw e
}
}
private fun processParameters(): Pair<String, File> {
// targetModule: 필수 파라미터 (-PtargetModule=fax-api)
val targetModule = extension.targetModule.trim()
if (targetModule.isEmpty()) {
throw IllegalArgumentException(
"targetModule 파라미터가 필요합니다. -PtargetModule=모듈명 을 사용하세요."
)
}
// workspace: 빌드 대상 프로젝트 경로
val workspace = try {
appBuilderExtension.workspace.get()
} catch (e: Exception) {
project.rootDir // 기본값
}
return Pair(targetModule, workspace)
}
}
사용법:
# Gradle 직접 실행
./gradlew AppBuilder -PtargetModule=fax-api
# build.groovy에서 호출
gradle -p ${appBuilderPath} AppBuilder -PtargetModule=${module.name} --no-daemon
AppBuilderExecutor - 오케스트레이션
파일: tasks/builder/executor/AppBuilderExecutor.kt
3개의 Executor를 순차적으로 실행하고, 각 단계 결과를 modules-data.json에 저장한다.
class AppBuilderExecutor(
private val project: Project,
private val logger: Logger
) {
// JSON 파일 유틸리티 (modules-data.json 읽기/쓰기)
private val jsonUtil = JsonUtil(
baseDir = project.layout.buildDirectory.get().asFile,
appBuilderPath = extension.appBuilderPath.getOrElse("/app-builder"),
modulesFileName = extension.modulesFileName.getOrElse("modules-data.json")
)
fun execute(targetModule: String, workspace: File): ModuleInfo {
try {
// 1. modules-data.json에서 현재 모듈 정보 로드
val buildInfo = loadBuildInfo()
val moduleInfo = getModuleInfo(buildInfo, targetModule)
// 2. 모듈 유효성 검증
validateModule(moduleInfo, targetModule)
// 3. 버전 관리 (VersionExecutor)
val versionExecutor = VersionExecutor(project, logger, workspace)
val versionResult = versionExecutor.execute(moduleInfo)
updateModuleInfo(targetModule, versionResult) // JSON 업데이트
// 버전 실패 시 즉시 중단
if (versionResult.status != "SUCCESS") {
logger.error("버전 처리 실패로 빌드를 중단합니다.")
return versionResult
}
// 4. 모듈 빌드 (BuildExecutor)
val buildExecutor = BuildExecutor(project, logger, workspace)
val buildResult = buildExecutor.execute(versionResult)
updateModuleInfo(targetModule, buildResult) // JSON 업데이트
// 빌드 실패 시 즉시 중단
if (buildResult.status != "SUCCESS") {
logger.error("빌드 실패로 파이프라인을 중단합니다.")
return buildResult
}
// 5. Helm 업데이트 (HelmExecutor) - app 모듈만
if (shouldUpdateHelm(buildResult)) {
val helmExecutor = HelmExecutor(project, logger, workspace)
val helmResult = helmExecutor.execute(buildResult)
updateModuleInfo(targetModule, helmResult) // JSON 업데이트
return helmResult
}
return buildResult
} catch (e: Exception) {
// 실패 시 모듈 상태를 FAILED로 업데이트
val failedInfo = createFailedModuleInfo(targetModule, e.message)
updateModuleInfo(targetModule, failedInfo)
throw e
}
}
// Helm 업데이트 필요 여부 판단
private fun shouldUpdateHelm(moduleInfo: ModuleInfo): Boolean {
// 1. app 타입만
if (moduleInfo.type != "app") return false
// 2. batch 모듈 제외
val excludedPatterns = listOf("batch")
val isExcluded = excludedPatterns.any { pattern ->
moduleInfo.name.contains(pattern, ignoreCase = true)
}
return !isExcluded
}
// modules-data.json 업데이트
private fun updateModuleInfo(targetModule: String, updatedInfo: ModuleInfo) {
val currentBuildInfo = jsonUtil.readBuildInfo()
val updatedModules = currentBuildInfo.modules.toMutableMap()
updatedModules[targetModule] = updatedInfo
val updatedBuildInfo = BuildInfo.create(currentBuildInfo.commit, updatedModules)
jsonUtil.writeBuildInfoAtomic(updatedBuildInfo)
}
}
실패 처리 흐름:
┌─ 실패 시나리오 ─────────────────────────────────────────────────┐
│ │
│ VersionExecutor 실패? │
│ │ │
│ ├─ YES → status: "FAILED" 반환 → 빌드 중단 │
│ │ (BuildExecutor, HelmExecutor 스킵) │
│ │ │
│ └─ NO → BuildExecutor 실행 │
│ │ │
│ ├─ 실패 → status: "FAILED" 반환 │
│ │ (HelmExecutor 스킵) │
│ │ │
│ └─ 성공 → HelmExecutor 실행 (app만) │
│ │
└──────────────────────────────────────────────────────────────────┘
실행 흐름 상세
./gradlew AppBuilder -PtargetModule=fax-api
│
▼
┌─ AppBuilderTask ───────────────────────────────────────────────┐
│ processParameters() │
│ ├─ targetModule = "fax-api" (-P 파라미터에서) │
│ └─ workspace = /workspace/fax (환경변수에서) │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─ AppBuilderExecutor ────────────────────────────────────────────┐
│ │
│ ① loadBuildInfo() │
│ └─ modules-data.json 읽기 (GetTargetsTask가 생성한 파일) │
│ │
│ ② getModuleInfo("fax-api") │
│ └─ { name: "fax-api", type: "app", language: "JAVA", ... } │
│ │
│ ③ VersionExecutor.execute(moduleInfo) │
│ ├─ Git 태그 fetch │
│ ├─ 다음 버전 계산 → "1.2.3-beta" │
│ ├─ Git 태그 생성: fax-api/v1.2.3-beta │
│ └─ updateModuleInfo() → JSON 저장 │
│ │
│ ④ BuildExecutor.execute(versionResult) │
│ ├─ BuildContext 생성 (Dockerfile, 환경변수 등) │
│ ├─ build.gradle 버전 업데이트 │
│ ├─ buildctl 명령어 생성 및 실행 │
│ └─ updateModuleInfo() → JSON 저장 │
│ │
│ ⑤ HelmExecutor.execute(buildResult) ← app 타입만 │
│ ├─ Helm 저장소 클론 │
│ ├─ values.yaml image.tag 업데이트 │
│ ├─ Git 커밋 & 푸시 │
│ └─ updateModuleInfo() → JSON 저장 │
│ │
└──────────────────────────────────────────────────────────────────┘
│
▼
println("[AppBuilderTask] fax-api -> status=SUCCESS, version=1.2.3-beta")
│
▼
build.groovy가 출력 파싱하여 결과 확인
8. 3가지 Executor 상세
각 Executor는 단일 책임 원칙에 따라 분리되어 있다.
┌─────────────────────────────────────────────────────────────────┐
│ Executor 체인 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ModuleInfo { version: null } │
│ │ │
│ ▼ │
│ ┌─ VersionExecutor ─────────────────────────────────────────┐ │
│ │ 입력: version 없음 │ │
│ │ 처리: Git 태그 기반 버전 계산 → 태그 생성 │ │
│ │ 출력: version: "1.2.3-beta" │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─ BuildExecutor ───────────────────────────────────────────┐ │
│ │ 입력: version 있음 │ │
│ │ 처리: build.gradle 수정 → Docker 빌드 → ECR 푸시 │ │
│ │ 출력: status: "SUCCESS" │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ (app 타입만, batch 제외) │
│ ┌─ HelmExecutor ────────────────────────────────────────────┐ │
│ │ 입력: 빌드 완료된 ModuleInfo │ │
│ │ 처리: Helm values.yaml 업데이트 → Git 푸시 │ │
│ │ 출력: status: "SUCCESS" │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ModuleInfo { version: "1.2.3-beta", status: "SUCCESS" } │
│ │
└─────────────────────────────────────────────────────────────────┘
8.1 VersionExecutor - 버전 관리
파일: tasks/builder/executor/VersionExecutor.kt
Git 태그 기반으로 다음 버전을 계산하고 태그를 생성한다.
class VersionExecutor(
private val project: Project,
private val logger: Logger,
private val workspace: File
) {
private val gitUtils = GitUtils(project, logger, workspace)
fun execute(moduleInfo: ModuleInfo): ModuleInfo {
try {
// 1. Git 태그 초기화 (원격 태그 가져오기)
gitUtils.fetchModuleSpecificTags(moduleInfo.name)
// 2. 다음 버전 계산
val versionInfo = calculateNextVersion(moduleInfo)
// 3. Git 태그 생성 및 푸시
if (versionInfo.shouldCreateTag) {
GitTagUtils.createAndPushTag(
tagName = versionInfo.tagName, // fax-api/v1.2.3-beta
version = versionInfo.version, // 1.2.3-beta
gitUtils = gitUtils,
logger = logger
)
}
// 4. ModuleInfo 업데이트
return moduleInfo.copy(
version = versionInfo.version,
status = "SUCCESS"
)
} catch (e: Exception) {
// 실패 시 fallback 버전 사용
val fallbackVersion = VersionCalculationUtils.createFallbackVersion(moduleInfo.type)
return moduleInfo.copy(
version = fallbackVersion,
status = "FAILED"
)
}
}
private fun calculateNextVersion(moduleInfo: ModuleInfo): VersionInfo {
// 1. build.gradle의 현재 버전 확인
val buildGradleVersion = ModuleVersionUtils.extractVersionFromBuildGradle(
moduleName = moduleInfo.name,
workspace = workspace,
logger = logger
)
// 2. Git Tag에서 최신 버전들 확인
val latestReleaseVersion = GitTagUtils.getLatestReleaseVersion(...) // v1.2.2
val latestPreReleaseVersion = GitTagUtils.getLatestPreReleaseVersion(...) // v1.2.3-beta
// 3. 브랜치 컨텍스트 결정
val branch = gitUtils.getCurrentBranch()
val isMainBranch = branch.equals("main", ignoreCase = true)
// 4. 통합 버전 계산 로직
return VersionValidationUtils.calculateNextVersion(
moduleInfo = moduleInfo,
buildGradleVersion = buildGradleVersion,
latestReleaseVersion = latestReleaseVersion,
latestPreReleaseVersion = latestPreReleaseVersion,
isMainBranch = isMainBranch,
...
)
}
}
버전 계산 로직:
┌─ 버전 결정 흐름 ─────────────────────────────────────────────────┐
│ │
│ 1. 정보 수집 │
│ ├─ build.gradle version: "1.2.0" │
│ ├─ 최신 릴리스 태그: fax-api/v1.2.2 │
│ └─ 최신 개발 태그: fax-api/v1.2.3-beta.5 │
│ │
│ 2. 브랜치 확인 │
│ └─ 현재 브랜치: develop │
│ │
│ 3. 다음 버전 계산 │
│ ├─ main 브랜치: v1.2.3 (릴리스) │
│ └─ 기타 브랜치: v1.2.3-beta.6 (프리릴리스) │
│ │
│ 4. Git 태그 생성 │
│ └─ git tag fax-api/v1.2.3-beta.6 │
│ └─ git push origin fax-api/v1.2.3-beta.6 │
│ │
└───────────────────────────────────────────────────────────────────┘
버전 규칙:
| 브랜치 | app 모듈 | lib 모듈 | 태그 예시 |
|---|---|---|---|
| main | v1.2.3 | v1.2.3 | fax-api/v1.2.3 |
| develop | v1.2.3-beta | v1.2.3-SNAPSHOT | fax-api/v1.2.3-beta |
| feature/* | v1.2.3-beta | v1.2.3-SNAPSHOT | fax-api/v1.2.3-beta |
8.2 BuildExecutor - Docker 빌드
파일: tasks/builder/executor/BuildExecutor.kt
build.gradle을 수정하고 Docker 이미지를 빌드하여 ECR에 푸시한다.
class BuildExecutor(
private val project: Project,
private val logger: Logger,
private val workspace: File
) {
private val gitUtils = GitUtils(project, logger, workspace)
private val appBuilderExtension = project.extensions.getByName(AppBuilderExtension.NAME)
fun execute(moduleInfo: ModuleInfo): ModuleInfo {
try {
// 1. ModuleBuildContext 생성
val buildContext = createBuildContext(moduleInfo)
// 2. build.gradle 업데이트
performVersionIntegration(moduleInfo, buildContext)
// 3. 언어별 buildctl 명령어 생성
val buildCommand = createBuildCommand(buildContext)
// 4. buildctl 실행 (실시간 로그 출력)
executeBuildCommand(buildCommand)
// 5. 빌드 완료 상태 업데이트
return moduleInfo.copy(status = "SUCCESS")
} catch (e: Exception) {
throw e // 상위에서 FAILED 처리
}
}
}
BuildContext 생성:
private fun createBuildContext(moduleInfo: ModuleInfo): ModuleBuildContext {
// Dockerfile 경로 및 빌드 정보 조회
val dockerfileBuildInfo = DockerfileBuildUtils.resolveBuildContext(
module = moduleInfo.name,
moduleType = moduleInfo.type,
project = project,
logger = logger
)
// 브랜치에 따른 환경 값 결정
val currentBranch = gitUtils.getCurrentBranch()
val (nodeEnv, goEnv, springEnv) = determineEnvironmentByBranch(currentBranch)
return ModuleBuildContext(
module = moduleInfo.name, // fax-api
moduleType = moduleInfo.type, // app
tagVersion = moduleInfo.version, // 1.2.3-beta
dockerfilePath = dockerfileBuildInfo.path, // Dockerfile.app.fax-api
detectedLanguage = moduleInfo.language, // JAVA
ecrRegistry = "123456789.dkr.ecr.ap-northeast-2.amazonaws.com",
gitRepoName = gitUtils.getRepoName(), // fax
branchName = currentBranch, // develop
nexusUsername = extension.nexus.username,
nexusPassword = extension.nexus.password,
nodeEnvironment = nodeEnv, // dev
goProfile = goEnv, // dev
springProfilesActive = springEnv // cloudconfig,test
)
}
브랜치별 환경 설정:
| 브랜치 | NODE_ENV | GO_PROFILE | SPRING_PROFILES |
|---|---|---|---|
| main | prod | prod | cloudconfig,prod |
| develop | dev | dev | cloudconfig,test |
| feature/* | dev | dev | cloudconfig,test |
build.gradle 업데이트:
private fun performVersionIntegration(moduleInfo: ModuleInfo, buildContext: ModuleBuildContext) {
// 1. 버전 업데이트
// version = "1.0.0" → version = "1.2.3-beta"
ModuleVersionUtils.updateBuildGradleVersion(
moduleInfo.name,
buildContext.tagVersion,
workspace,
logger
)
// 2. 로컬 의존성 → 원격 의존성 변환
// implementation(project(":fax")) → implementation("com.example:fax:1.2.3-SNAPSHOT")
ModuleVersionUtils.convertLocalProjectToRemoteDependency(
moduleInfo.name,
workspace,
logger
)
}
buildctl 명령어 실행:
private fun executeBuildCommand(command: String) {
val processBuilder = ProcessBuilder("sh", "-c", command)
processBuilder.directory(workspace)
processBuilder.redirectErrorStream(true)
val process = processBuilder.start()
// 실시간 로그 출력
process.inputStream.bufferedReader().use { reader ->
reader.lineSequence().forEach { line ->
println(formatLogLine(line))
}
}
val exitCode = process.waitFor()
if (exitCode != 0) {
throw RuntimeException("빌드 실패 (종료 코드: ${exitCode})")
}
}
생성되는 buildctl 명령어 예시 (JAVA):
buildctl build \
--frontend dockerfile.v0 \
--local context=. \
--local dockerfile=. \
--opt filename=Dockerfile.app.fax-api \
--opt build-arg:MODULE_NAME=fax-api \
--opt build-arg:MODULE_VERSION=1.2.3-beta \
--opt build-arg:NEXUS_USERNAME=admin \
--opt build-arg:NEXUS_PASSWORD=*** \
--opt build-arg:SPRING_PROFILES_ACTIVE=cloudconfig,test \
--output type=image,name=123456789.dkr.ecr.ap-northeast-2.amazonaws.com/fax/fax-api:1.2.3-beta,push=true
8.3 HelmExecutor - Helm 업데이트
파일: tasks/builder/executor/HelmExecutor.kt
ArgoCD가 감지할 수 있도록 Helm values 저장소의 image.tag를 업데이트한다.
class HelmExecutor(
private val project: Project,
private val logger: Logger,
workspace: File
) {
private val gitUtils = GitUtils(project, logger, workspace)
private val extension = project.extensions.getByName(AppBuilderExtension.NAME)
fun execute(moduleInfo: ModuleInfo): ModuleInfo {
try {
val version = moduleInfo.version
?: throw IllegalArgumentException("모듈 버전이 없습니다")
// 1. 임시 작업 디렉터리 생성
val workDir = project.layout.buildDirectory
.dir("app-builder/helm-values").get().asFile
workDir.deleteRecursively()
workDir.mkdirs()
try {
// 2. Helm 저장소 클론
cloneHelmRepo(workDir)
// 3. YAML 파일에서 image.tag 업데이트
val updateSuccess = updateModuleValues(workDir, moduleInfo.name, version)
if (updateSuccess) {
// 4. Git 커밋 및 푸시 (재시도 로직 포함)
commitAndPushChanges(workDir, moduleInfo.name, version)
}
return moduleInfo.copy(status = "SUCCESS")
} finally {
// 5. 임시 디렉터리 정리
workDir.deleteRecursively()
}
} catch (e: Exception) {
throw e
}
}
}
Helm 저장소 클론:
private fun cloneHelmRepo(workDir: File) {
val helmRepo = extension.helmValuesRepo.orNull
?: throw IllegalArgumentException("Helm values 저장소 URL이 지정되지 않았습니다.")
val githubUsername = gitUtils.getGitHubUsername()
val githubPassword = gitUtils.getGitHubPassword()
// 인증 정보를 URL에 포함
val authenticatedUrl = helmRepo.replace(
"https://",
"https://$githubUsername:$githubPassword@"
)
executeCommand("git clone -b main \"$authenticatedUrl\" .", workDir)
}
YAML 파일 탐색 경로:
private fun updateModuleValues(workDir: File, moduleName: String, version: String): Boolean {
val repoName = gitUtils.getRepoName() // fax
val currentBranch = gitUtils.getCurrentBranch()
val environment = if (currentBranch == "main") "prod" else "dev"
// 가능한 파일 경로들 (우선순위순)
val possiblePaths = listOf(
"$repoName/${environment}@$moduleName.yaml", // fax/dev@fax-api.yaml
"$repoName/${environment}/$moduleName.yaml", // fax/dev/fax-api.yaml
"${environment}@$moduleName.yaml", // dev@fax-api.yaml
"${environment}/$moduleName.yaml", // dev/fax-api.yaml
"$moduleName.yaml" // fax-api.yaml
)
for (path in possiblePaths) {
val file = File(workDir, path)
if (file.exists()) {
return updateYamlFile(file, path, moduleName, version)
}
}
return false // 파일 없음
}
YAML 파일 업데이트:
private fun updateYamlFile(file: File, path: String, moduleName: String, version: String): Boolean {
val yaml = Yaml(DumperOptions().apply {
defaultFlowStyle = DumperOptions.FlowStyle.BLOCK
indent = 2
})
val values = yaml.load<Map<String, Any>>(file.readText())?.toMutableMap()
val imageMap = (values["image"] as? Map<String, Any>)?.toMutableMap()
val oldTag = imageMap["tag"]?.toString() ?: ""
// 버전이 같으면 업데이트 불필요
if (oldTag == version) return false
// image.tag 업데이트
imageMap["tag"] = version
values["image"] = imageMap
file.writeText(yaml.dump(values))
println("[$moduleName] 태그 업데이트 완료: $oldTag → $version ($path)")
return true
}
Git 푸시 재시도 로직:
private fun commitAndPushChanges(workDir: File, moduleName: String, version: String) {
// Git 설정
executeCommand("git config user.name \"${gitUtils.getGitAuthName()}\"", workDir)
executeCommand("git config user.email \"${gitUtils.getGitAuthEmail()}\"", workDir)
executeCommand("git add .", workDir)
executeCommand("git commit -m \"${gitUtils.getCommitMessage()}\"", workDir)
// Push with retry (최대 5회)
var pushSuccess = false
var retryCount = 0
val maxRetries = 5
while (!pushSuccess && retryCount < maxRetries) {
try {
if (retryCount == 0) {
// 1차: 일반 push 시도
executeCommand("git push origin HEAD:main", workDir)
} else {
// 2차~: fetch → rebase → push
executeCommand("git fetch origin main", workDir)
executeCommand("git rebase origin/main", workDir)
executeCommand("git push origin HEAD:main", workDir)
}
pushSuccess = true
} catch (e: Exception) {
retryCount++
if (retryCount >= maxRetries) {
throw RuntimeException("Git push 실패 - 다른 변경사항과 충돌")
}
// 대기 후 재시도 (2초, 4초, 6초...)
Thread.sleep(retryCount * 2000L)
}
}
}
재시도 흐름:
┌─ Git Push 재시도 로직 ──────────────────────────────────────────┐
│ │
│ 1차 시도: git push origin HEAD:main │
│ │ │
│ ├─ 성공 → 완료 │
│ │ │
│ └─ 실패 (다른 파이프라인이 먼저 push함) │
│ │ │
│ ▼ │
│ 2차 시도: git fetch → git rebase → git push │
│ │ │
│ ├─ 성공 → 완료 │
│ │ │
│ └─ 실패 → 2초 대기 │
│ │ │
│ ▼ │
│ 3차~5차: 반복 (4초, 6초, 8초 대기) │
│ │ │
│ └─ 5회 모두 실패 → 예외 발생 │
│ │
└──────────────────────────────────────────────────────────────────┘
왜 재시도가 필요한가?
┌─ 동시 빌드 시나리오 ────────────────────────────────────────────┐
│ │
│ Pipeline A (fax-api) Pipeline B (fax-batch) │
│ │ │ │
│ ▼ ▼ │
│ Helm repo clone Helm repo clone │
│ │ │ │
│ ▼ ▼ │
│ values 수정 values 수정 │
│ │ │ │
│ ▼ │ │
│ git push ✓ │ │
│ │ ▼ │
│ │ git push ✗ (충돌!) │
│ │ │ │
│ │ ▼ │
│ │ fetch → rebase → push ✓ │
│ │ │
└──────────────────────────────────────────────────────────────────┘
Helm 업데이트 후 ArgoCD 자동 배포
┌─ GitOps 배포 흐름 ──────────────────────────────────────────────┐
│ │
│ 1. HelmExecutor가 values.yaml 업데이트 │
│ └─ image.tag: "1.2.2-beta" → "1.2.3-beta" │
│ │
│ 2. Git push → Helm 저장소 변경 │
│ │
│ 3. ArgoCD가 저장소 변경 감지 (Polling 또는 Webhook) │
│ │
│ 4. ArgoCD가 새 이미지로 Kubernetes 배포 │
│ └─ kubectl set image deployment/fax-api ... │
│ │
│ 5. Pod 롤링 업데이트 │
│ │
└──────────────────────────────────────────────────────────────────┘
9. 학습 로드맵
코드를 깊이 보고 싶다면 아래 순서로 학습하면 된다.
추천 학습 순서
1단계: AppBuilderPlugin.kt (진입점, 30줄)
↓
2단계: Entity 폴더 (데이터 모델 3개)
↓
3단계: GetTargetsTask → DetectorExecutor (모듈 감지)
↓
4단계: AppBuilderTask → AppBuilderExecutor (빌드 오케스트레이션)
↓
5단계: VersionExecutor, BuildExecutor, HelmExecutor (세부 실행기)
핵심 파일 위치
app-builder-gradle-plugin/src/main/kotlin/com/example/plugins/gradle/
├── AppBuilderPlugin.kt # 1단계: 진입점
├── entity/ # 2단계: 데이터 모델
│ ├── BuildInfo.kt
│ ├── ModuleInfo.kt
│ └── CommitInfo.kt
├── extension/
│ └── AppBuilderExtension.kt # 전역 설정
├── tasks/
│ ├── targets/
│ │ ├── GetTargetsTask.kt # 3단계: 모듈 감지 태스크
│ │ └── executor/
│ │ └── DetectorExecutor.kt # 모듈 감지 로직
│ └── builder/
│ ├── AppBuilderTask.kt # 4단계: 빌드 태스크
│ └── executor/
│ ├── AppBuilderExecutor.kt # 빌드 오케스트레이션
│ ├── VersionExecutor.kt # 5단계: 버전 관리
│ ├── BuildExecutor.kt # Docker 빌드
│ └── HelmExecutor.kt # Helm 업데이트
부록: 주요 유틸리티 클래스
| 클래스 | 역할 |
|---|---|
GitUtils |
Git 명령어 실행, 커밋 정보 수집 |
ModuleDiscoveryUtils |
settings.gradle 파싱해서 모듈 목록 추출 |
DockerfileUtils |
Dockerfile 존재 여부로 타입(app/lib) 결정 |
LanguageDetectionUtils |
Dockerfile/빌드파일로 언어 감지 |
DependencyAnalysisUtils |
Git diff + 의존성 전파로 변경 모듈 감지 |
VersionCalculationUtils |
Universal 버전 파싱/비교/증가 |
GitTagUtils |
Git 태그에서 버전 추출 |
ModuleVersionUtils |
build.gradle에서 버전 추출/업데이트 |
BuildCommandGenerator |
언어별 buildctl 명령어 생성 |
JsonUtil |
BuildInfo를 JSON으로 읽기/쓰기 |
10. 전체 흐름 요약
개발자가 코드를 푸시하면 배포까지 어떻게 진행되는지 한눈에 보는 흐름이다.
┌─────────────────────────────────────────────────────────────────────────────┐
│ │
│ 👨💻 개발자: git push origin develop │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ 🔔 GitHub Webhook → Jenkins 빌드 트리거 │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ 📄 Jenkinsfile 실행 │
│ │
│ @Library('app-builder-gradle-plugin-pipeline') _ │
│ build() │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ 🔍 Stage: 변경 감지 │
│ │
│ gradle GetTargets │
│ ├─ git diff로 변경된 파일 확인 │
│ ├─ 변경된 모듈 + 의존 모듈 찾기 │
│ └─ modules-data.json 생성 │
│ │
│ 결과: fax-common(변경됨), fax-api(의존), fax-batch(변경없음→스킵) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ 🔨 Stage: [JAVA] fax-common (lib) │
│ │
│ gradle AppBuilder -PtargetModule=fax-common │
│ ├─ 버전 계산: 1.0.5-SNAPSHOT │
│ ├─ Git 태그: fax-common/v1.0.5-SNAPSHOT │
│ ├─ Docker 빌드 → Nexus에 jar 배포 │
│ └─ (lib는 Helm 스킵) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ 🔨 Stage: [JAVA] fax-api (app) │
│ │
│ gradle AppBuilder -PtargetModule=fax-api │
│ ├─ 버전 계산: 2.1.0-beta │
│ ├─ Git 태그: fax-api/v2.1.0-beta │
│ ├─ Docker 빌드 → ECR에 이미지 푸시 │
│ └─ Helm values.yaml 업데이트 (image.tag: 2.1.0-beta) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ ⏭️ Stage: [JAVA] fax-batch → SKIPPED (변경 없음) │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ ✅ Jenkins 빌드 완료 │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ 🚀 ArgoCD가 Helm 저장소 변경 감지 │
│ │
│ → Kubernetes에 fax-api:2.1.0-beta 배포 │
│ → Pod 롤링 업데이트 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ │
│ 🎉 배포 완료! │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
댓글