CI/CD 학습 시리즈

사전 지식: Part 1~3을 먼저 학습하면 이 문서를 더 쉽게 이해할 수 있습니다.

이 문서는 프로젝트를 처음 접하는 개발자를 위한 학습 가이드다.

목차

  1. 프로젝트 개요
  2. 전체 CI/CD 흐름
  3. Jenkins 연동 - Shared Library
  4. Gradle 플러그인 구조
  5. 데이터 모델 - Entity
  6. 모듈 감지 - GetTargetsTask
  7. 빌드 실행 - AppBuilderTask
  8. 3가지 Executor
  9. 학습 로드맵
  10. 전체 흐름 요약

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 @Librarybuild() 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 코드에서 projectGradle의 핵심 객체다:

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   → 전체 프로젝트들                       │
│                                                                  │
└──────────────────────────────────────────────────────────────────┘

중요: 이 플러그인에서 projectapp-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 롤링 업데이트                                                     │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘
                                    │
                                    ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│                                                                             │
│  🎉 배포 완료!                                                              │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘