๋ฉ€ํ‹ฐ ๋ชจ๋“ˆ ํ”„๋กœ์ ํŠธ CI ์‹œ ์ „์ฒด Test Coverage ๋„์ถœํ•˜๊ธฐ (Jacoco, GitLab)

1. ๊ฐœ์š”

GitLab์—๋Š” CICD ์‹œ ํ”„๋กœ์ ํŠธ์˜ Test Coverage๋ฅผ ์ถ”์ถœํ•˜์—ฌ ํ‘œ์‹œํ•  ์ˆ˜ ์žˆ๋Š” ๊ธฐ๋Šฅ์„ ์ œ๊ณตํ•ด์ค€๋‹ค.

 

Build ์‹œ Test Coverage ์ˆ˜์น˜๋ฅผ ๋กœ๊ทธ๋กœ ์ฐ์–ด๋‘๋ฉด, ์ •๊ทœ์‹์„ ํ™œ์šฉํ•˜์—ฌ ์ถ”์ถœํ•ด๋‚ผ ์ˆ˜ ์žˆ๊ณ , ์•„๋ž˜ ์‚ฌ์ง„๊ณผ ๊ฐ™์ด Readme์— ํ‘œํ˜„ํ•  ์ˆ˜ ์žˆ๋‹ค. 

๊ด€๋ จ ๊ณต์‹ ๋ฌธ์„œ๋Š” ์—ฌ๊ธฐ ๋ฅผ ์ฐธ๊ณ ํ•˜๋ฉด ๋œ๋‹ค.

 

์ด๋ ‡๊ฒŒ ์ถ”์ถœํ•ด๋‘” ์ปค๋ฒ„๋ฆฌ์ง€๋Š” ํšŒ์‚ฌ์—์„œ ๊ฐ ํ”„๋กœ์ ํŠธ๋“ค์˜ ํ…Œ์ŠคํŠธ ์ปค๋ฒ„๋ฆฌ์ง€ ํ˜„ํ™ฉ์„ ๊ด€๋ฆฌํ•  ๋•Œ ์‚ฌ์šฉํ•˜๊ณ  ์žˆ๋‹ค. ํ…Œ์ŠคํŠธ ์ปค๋ฒ„๋ฆฌ์ง€๊ฐ€ ๋†’๋‹ค๊ณ  ์žฅ์• ๊ฐ€ ์—†๋Š”, ๋ฆฌํŒฉํ† ๋ง์— ์•ˆ์ „ํ•œ ํ”„๋กœ์ ํŠธ๋ผ๊ณ  ์žฅ๋‹ดํ•  ์ˆ˜ ์—†์ง€๋งŒ ์ „ํ˜€ ์˜๋ฏธ ์—†๋Š” ์ˆ˜์น˜๋Š” ์•„๋‹ˆ๋ผ๊ณ  ์ƒ๊ฐํ•œ๋‹ค. 

 

์•„๋ฌดํŠผ ์šฐ๋ฆฌ ํŒ€์€ Spring Boot Multi Module ํ˜•์‹์œผ๋กœ ํ”„๋กœ์ ํŠธ๋ฅผ ์ง„ํ–‰ํ•˜๊ณ  ์žˆ์—ˆ๊ธฐ ๋•Œ๋ฌธ์— ์ „์ฒด ๋ชจ๋“ˆ์— ๋Œ€ํ•œ ์ปค๋ฒ„๋ฆฌ์ง€๋ฅผ ํ•˜๋‚˜๋กœ ํ‘œํ˜„ํ•  ๋ฐฉ๋ฒ•์„ ๋ชจ์ƒ‰ํ•ด์•ผ ํ–ˆ๋‹ค. 

 

์—ฌ๋Ÿฌ ๋ฐฉ๋ฒ•๋“ค์„ ์‹œ๋„ํ•ด๋ณด๋ฉฐ ์žˆ์—ˆ๋˜ ๊ณผ์ •์„ ๊ธฐ๋กํ•˜๋ คํ•œ๋‹ค. 

 

๋‚ด๊ฐ€ ์‹œ๋„ํ•ด๋ณธ ๋ฐฉ๋ฒ•์€ ํฌ๊ฒŒ 3๊ฐ€์ง€์ด๋‹ค. 

 

 

1. shell script๋ฅผ ํ†ตํ•œ ์ „์ฒด ์ปค๋ฒ„๋ฆฌ์ง€ ๊ณ„์‚ฐ

 

2. Summary๋ฅผ ๋‹ด๋‹นํ•˜๋Š” ๋ชจ๋“ˆ์„ ๋งŒ๋“ค์–ด  'jacoco-report-aggregation' ํ”Œ๋Ÿฌ๊ทธ์ธ ์ด์šฉ

 

3. .gradle์— report๋ฅผ mergeํ•˜๋Š” task ์ƒ์„ฑ

 

 

์‹œ์ž‘ํ•˜๊ธฐ ์•ž์„œ ๊ตฌ๊ธ€๋ง ํ–ˆ์„ ๋•Œ ๋‚˜์™”๋˜ ๋ฐฉ์‹์€ ์ „๋ถ€ 2๋ฒˆ ๋ฐฉ์‹์ด์˜€๊ณ ,

๋ณ„๋„์˜ ๋ชจ๋“ˆ์„ ์ƒ์„ฑํ•˜๊ธฐ ์‹ซ์–ด ์‹œ๋„ํ–ˆ๋˜ ๋ฐฉ์‹์ด 1๋ฒˆ,

๊ทธ๋ฆฌ๊ณ  1, 2๋ฒˆ ๋ฐฉ๋ฒ•์œผ๋กœ ๊ตฌํ˜„ ํ›„ ๋” ๋‚˜์€ ๋ฐฉ๋ฒ•์ด ์—†๋‚˜ ์ง€ํ”ผํ‹ฐ๋ž‘ ํ† ๋ก ํ•˜๋‹ค ๊ตฌํ˜„ํ•œ๊ฒŒ 3๋ฒˆ ๋ฐฉ์‹์ด๋‹ค. 

 

์ตœ์ข…์ ์œผ๋ก  3๋ฒˆ ๋ฐฉ์‹์„ ์‚ฌ์šฉํ•  ๊ฒƒ ๊ฐ™๋‹ค.

 

 

 

 

 

 

 

 

2.1 shell script๋ฅผ ํ†ตํ•œ ์ „์ฒด ์ปค๋ฒ„๋ฆฌ์ง€ ๊ณ„์‚ฐ

๊ทธ๋ƒฅ ๊ฐ€์žฅ ๋ฌด์‹ํ•œ ๋ฐฉ๋ฒ•์ด๋‹ค. ์ฒ˜์Œ์—” jacoco-report-aggregation ํ”Œ๋Ÿฌ๊ทธ์ธ ์‚ฌ์šฉํ•˜๋Š” ๋ฐฉ๋ฒ•์œผ๋กœ ์‹œ๋„ํ•˜๋‹ค ์‚ฝ์งˆ์„ ์ข€ ํ•˜๊ธฐ๋„ ํ–ˆ๊ณ , ๋ณ„๋„์˜ ๋ชจ๋“ˆ์„ ์ƒ์„ฑํ•˜๋Š” ๊ฒƒ ์กฐ์ฐจ ๋งˆ์Œ์— ๋“ค์ง€ ์•Š์•˜๊ธฐ ๋•Œ๋ฌธ์— ์‹œ๋„ํ–ˆ๋˜ ๋ฐฉ๋ฒ•์ด๋‹ค. 

 

๊ทธ๋ƒฅ ๊ฐ ์„œ๋ธŒ ๋ชจ๋“ˆ์— ์ƒ์„ฑ๋œ report๋ฅผ gitlab ci ์‹คํ–‰ ์‹œ find๋กœ ์ฐพ์•„ mergeํ•˜์—ฌ ํ•˜๋‚˜์˜ ํŒŒ์ผ๋กœ ๋งŒ๋“œ๋Š” ๋ฐฉ๋ฒ•์ด๋‹ค. 

 

์–ด๋–ป๊ฒŒ๋“  ์ปค๋ฒ„๋ฆฌ์ง€๋ฅผ ๋„์šฐ๊ฒ ๋‹ค๋Š” ๋งˆ์Œ์œผ๋กœ ๋ฌด์‹ํ•˜๊ฒŒ ๋จธ๋ฆฌ๋ฐ•์€ ๋ฐฉ์‹์ด๋ผ ์ถ”์ฒœํ•˜์ง„ ์•Š๋Š”๋‹ค. 2.2, 2.3 ๋ฐฉ์‹์œผ๋กœ ๋ฐ”๋กœ ๋„˜์–ด๊ฐ€๋„ ์ข‹๋‹ค. 

 

build ํ›„ ์‚ฌ์šฉํ–ˆ๋˜ ์Šคํฌ๋ฆฝํŠธ ํŒŒ์ผ์€ ์•„๋ž˜์™€ ๊ฐ™๋‹ค.

#!/bin/bash

# JaCoCo ํ—ค๋” ์ •์˜
JACOCO_HEADER="GROUP,PACKAGE,CLASS,INSTRUCTION_MISSED,INSTRUCTION_COVERED,BRANCH_MISSED,BRANCH_COVERED,LINE_MISSED,LINE_COVERED,COMPLEXITY_MISSED,COMPLEXITY_COVERED,METHOD_MISSED,METHOD_COVERED"

echo "๋ชจ๋“  ํ…Œ์ŠคํŠธ ์ปค๋ฒ„๋ฆฌ์ง€ ๋ฆฌํฌํŠธ๋ฅผ MERGE ์‹œ์ž‘ ..."
echo "$JACOCO_HEADER" > merged_jacoco.csv

# ํ”„๋กœ์ ํŠธ ์ „์ฒด์—์„œ jacocoTestReport.csv ์ฐพ๊ธฐ
find "$CI_PROJECT_DIR" -type f -name "jacocoTestReport.csv" | while read file; do
  echo "๐Ÿ“‚ MERGE ์ค‘: $file"
  tail -n +2 "$file" >> merged_jacoco.csv
done

# ์ปค๋ฒ„๋ฆฌ์ง€ ๊ณ„์‚ฐ
echo "MERGE ๋œ ํŒŒ์ผ์˜ ํ…Œ์ŠคํŠธ ์ปค๋ฒ„๋ฆฌ์ง€ ๊ณ„์‚ฐ ์ค‘ ..."
COVERAGE=$(awk -F"," 'NR>1 { instructions += $4 + $5; covered += $5 }
END { if (instructions > 0) print 100*covered/instructions; else print 0; }' merged_jacoco.csv)

# ์ „์ฒด Coverage ์ถœ๋ ฅ
echo "Total Coverage: $COVERAGE%"

 

 

 

 

 

 

 

 

 

 

 

 

 

2.2 'jacoco-report-aggregation' ํ”Œ๋Ÿฌ๊ทธ์ธ ์ด์šฉ

๋ถ„๋ฆฌ๋œ jacoco coverage reportsํŒŒ์ผ์„ ํ•ฉ์น˜๊ธฐ ์œ„ํ•ด์„œ, jacoco-aggregation ํ”Œ๋Ÿฌ๊ทธ์ธ์„ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋‹ค.

๊ณต์‹ ๋ฌธ์„œ๋Š” ์—ฌ๊ธฐ๋ฅผ ์ฐธ๊ณ ํ•˜๋ฉด ๋œ๋‹ค.

 

Jacoco Test Coverage ReportํŒŒ์ผ์„ ํ•ฉ์น˜๊ธฐ ์œ„ํ•ด ๋ณ„๋„์˜ Module์„ ์ƒ์„ฑํ•ด์ฃผ์ž.

 

๋‚˜๋Š” jacoco ๋ผ๋Š” ๋ชจ๋“ˆ์„ ์ƒ์„ฑํ•ด์ฃผ์—ˆ๊ณ , build.gradle ํŒŒ์ผ์— ์•„๋ž˜์™€ ๊ฐ™์ด ์„ค์ •ํ•ด์ฃผ์—ˆ๋‹ค. 

plugins {
    id 'base'
    id 'jacoco-report-aggregation'
}

repositories {
    mavenCentral()
}

dependencies {
    jacocoAggregation project(":server1")
    jacocoAggregation project(":server2")
}

bootJar.enabled = false
jar.enabled = true

// command: ./gradlew testCodeCoverageReport

 

๊ทธ๋ฆฌ๊ณ  build๋ฅผ ํ•˜๊ฑฐ๋‚˜, ./gralde testCodeCoverageReport๋ฅผ ์‹คํ–‰ํ•˜๋ฉด jacoco/build ์•ˆ์œผ๋กœ ํ•ฉ์ณ์ง„ report ํŒŒ์ผ์ด ๋–จ์–ด์ง„๋‹ค. 

 

 

 

 

 

 

 

 

 

 

2.3 ์‹คํ–‰ ๋ฐ์ดํ„ฐ ํŒŒ์ผ(test.exec) ๋ฐ ํด๋ž˜์Šค ์ •๋ณด๋“ค์„ ๊ฐ€์ ธ์™€ report ์ƒ์„ฑํ•˜๋Š” task ์ƒ์„ฑ (์ตœ์ข…)

- build.gradle

plugins {
	id 'java'
	id 'org.springframework.boot' version '3.4.3'
	id 'io.spring.dependency-management' version '1.1.7'
	id 'java-library'
	id 'jacoco'
}

bootJar.enabled = false;

repositories {
	mavenCentral()
}

subprojects {
	group = 'com.example'
	version = '0.0.1-SNAPSHOT'

	apply {
		plugin 'java'
		plugin 'org.springframework.boot'
		plugin 'io.spring.dependency-management'
		plugin 'java-library'
		plugin 'jacoco'
	}

	java {
		toolchain {
			languageVersion = JavaLanguageVersion.of(21)
		}
	}

	repositories {
		mavenCentral()
	}

	dependencies {
		implementation 'org.springframework.boot:spring-boot-starter'
		testImplementation 'org.springframework.boot:spring-boot-starter-test'
		testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
	}

	tasks.named('test') {
		useJUnitPlatform()
	}
}

apply from: "${rootDir}/jacoco.gradle"

tasks.named('build') {
	finalizedBy tasks.named('testCodeCoverageReport')
}

 

 

 

- jacoco.gralde

subprojects {
    apply plugin: 'jacoco'

    tasks.named('jacocoTestReport') {
        dependsOn test

        reports {
            xml.required = false
            csv.required = true
            html.required = true
        }

        executionData.from = fileTree(dir: "$buildDir/jacoco", includes: ["*.exec"])
    }
}

task testCodeCoverageReport(type: JacocoReport) {
    dependsOn subprojects.test  // ๋ชจ๋“  ํ•˜์œ„ ๋ชจ๋“ˆ์˜ test ์‹คํ–‰ ํ›„ ์‹คํ–‰

    // ํ•˜์œ„ ๋ชจ๋“ˆ์˜ test.exec ๋ฅผ ๊ฐ€์ ธ์˜ด
    executionData.from = files(subprojects.jacocoTestReport.executionData)

    // ํ•˜์œ„ ๋ชจ๋“ˆ์˜ ํด๋ž˜์Šค ํŒŒ์ผ ๊ฐ€์ ธ์˜ด
    additionalClassDirs.setFrom(files(subprojects.sourceSets.main.output))

    // ํ•˜์œ„ ๋ชจ๋“ˆ์˜ ์†Œ์Šค ์ฝ”๋“œ ๊ฒฝ๋กœ๋ฅผ ๊ฐ€์ ธ์˜ด
    sourceDirectories.setFrom(files(subprojects.sourceSets.main.allSource.srcDirs))

    reports {
        xml.required = false
        csv.required = true
        html.required = true
    }
}

 

 

์œ„์™€ ๊ฐ™์ด ์„ค์ •ํ•˜๊ณ  buildํ•˜๋ฉด ์•„๋ž˜์™€ ๊ฐ™์ด ์ตœ์ƒ์œ„ ๋ชจ๋“ˆ์˜ build ์•„๋ž˜์— coverage report๊ฐ€ ๋–จ์–ด์ง„๋‹ค.

 

html report๋ฅผ ์—ด์–ด๋ณด๋ฉด ํ•˜์œ„ ๋ชจ๋“ˆ๋“ค์˜ ์ปค๋ฒ„๋ฆฌ์ง€๊ฐ€ ์ „๋ถ€ ํ•ฉ์ณ์ ธ ๋ณด์—ฌ์ง„๋‹ค. 

 

 

์ด์ œ ํŒŒ์ผ์„ ํŒŒ์‹ฑ ํ›„ build log๋ฅผ ์ฐ์–ด์ฃผ๊ณ , gitlab-ci.yml ํŒŒ์ผ์—์„œ ์ •๊ทœ์‹์„ ํ†ตํ•ด ์ปค๋ฒ„๋ฆฌ์ง€ ๋กœ๊ทธ๋ฅผ ํŒŒ์‹ฑํ•ด์ฃผ๋ฉด ๋œ๋‹ค. 

 

 

ํŒŒ์‹ฑํ•˜๋Š” ์Šคํฌ๋ฆฝํŠธ ํŒŒ์ผ์€ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ž‘์„ฑํ•ด์ฃผ์—ˆ๋‹ค.

 

- parse_jacoco_report.sh

#!/bin/bash

# JaCoCo HTML ๋ฆฌํฌํŠธ ํŒŒ์ผ ๊ฒฝ๋กœ
REPORT_FILE="$CI_PROJECT_DIR/build/reports/jacoco/testCodeCoverageReport/html/index.html"

# ํŒŒ์ผ ์กด์žฌ ์—ฌ๋ถ€ ํ™•์ธ
if [[ ! -f "$REPORT_FILE" ]]; then
    echo "JaCoCo ๋ฆฌํฌํŠธ ํŒŒ์ผ์ด ์กด์žฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค: $REPORT_FILE"
    exit 1
fi

# JaCoCo ์ปค๋ฒ„๋ฆฌ์ง€ ํŒŒ์‹ฑ
COVERAGE=$(grep -oP '(?<=Total).*?\d+%' "$REPORT_FILE" | grep -oP '\d+%')

# ์ถ”์ถœ๋œ ์ปค๋ฒ„๋ฆฌ์ง€๊ฐ€ ์—†๋Š” ๊ฒฝ์šฐ
if [[ -z "$COVERAGE" ]]; then
    echo "์ปค๋ฒ„๋ฆฌ์ง€ ๊ฐ’์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."
    exit 1
fi

# ๊ฒฐ๊ณผ ์ถœ๋ ฅ
echo "Total Coverage: $COVERAGE"

 

 

- .gitlab-ci.yml

stages:
  - Build-Test

build_test:
  stage: Build-Test
  script:
    - chmod +x .buildfile/gitlab-ci-script/*.sh  # ์‹คํ–‰ ๊ถŒํ•œ ๋ถ€์—ฌ
    - .buildfile/gitlab-ci-script/build_project.sh  # ํ”„๋กœ์ ํŠธ ๋นŒ๋“œ ๋ฐ Docker ์ด๋ฏธ์ง€ ์ƒ์„ฑ
    - .buildfile/gitlab-ci-script/parse_jacoco_report.sh  # JaCoCo ์ปค๋ฒ„๋ฆฌ์ง€ ์ถ”์ถœ
  coverage: '/Total.*?([0-9]{1,3})%/'
  only:
    - develop

 

 

 

 

 

gitlab ์—์„œ Jobs๋ฅผ ํ™•์ธํ•ด๋ณด๋ฉด ์ด๋Ÿฐ์‹์œผ๋กœ ์ปค๋ฒ„๋ฆฌ์ง€๋ฅผ ํ‘œํ˜„ํ•  ์ˆ˜ ์žˆ๋‹ค. 

 

 

 

 

 

 

 

 

 

 

 

3. ์ฝ”๋“œ ์ปค๋ฒ„๋ฆฌ์ง€ ๋ถ„์„ ๋Œ€์ƒ ์ œ์™ธ

dto๋‚˜ mapper๊ฐ™์€ ์ฝ”๋“œ๋“ค๋„ ๊ฒ€์ฆํ•˜๋ฉด ๋‚˜์  ๊ฑด ์—†์ง€๋งŒ ๋„๋ฉ”์ธ ๋กœ์ง์— ๋น„ํ•ด์„  ์ƒ๋Œ€์ ์œผ๋กœ ๋œ ์ค‘์š”ํ•˜๋‹ค. ์ด๋Ÿฐ ํด๋ž˜์Šค๋“ค์„ ๊ฒ€์ฆ ๋Œ€์ƒ์—์„œ ์ œ์™ธ์‹œํ‚ค๋Š” ๋ฐฉ๋ฒ•์€ ์•„๋ž˜์™€ ๊ฐ™๋‹ค.

 

- ๋ฐฉ๋ฒ• 1.

classDirectories.setFrom(
        files(sourceSets.main.output)
                .filter { it.exists() }
                .asFileTree.matching {
            exclude "**/dto/**"      // DTO ํŒจํ‚ค์ง€ ์ œ์™ธ
            exclude "**/Q*.class"    // QueryDSL Q-Class ์ œ์™ธ
        }
)

 

 

- ๋ฐฉ๋ฒ• 2.

jacocoTestReport {
    reports {
        html.required  = true
        xml.required  = false
        csv.required  = false
    }
    excludedClassFilesForReport(classDirectories)
    dependsOn test
    finalizedBy 'jacocoTestCoverageVerification'
}

private excludedClassFilesForReport(classDirectories) {
    classDirectories.setFrom(
            files(classDirectories.files.collect {
                fileTree(dir: it, exclude: [
                        "com/konggogi/veganlife/global/**/*",
                        "com/konggogi/veganlife/**/dto/**",
                        "**/OauthService.class",
                        "**/*domain*/**",
                        "**/*Dto*",
                        "**/*Request*",
                        "**/*Response*",
                        "**/*Interceptor*",
                        "**/*Application*",
                        "**/*Mapper*",
                        "**/*Exception*"
                ])
            })
    )
}

 

๋ฐฉ๋ฒ•์€ ๋˜๊ฒŒ ๋งŽ์œผ๋‹ˆ ๊ฐ์ž ํŽธํ•œ๋Œ€๋กœ ์„ค์ •ํ•˜๋ฉด ๋  ๊ฒƒ ๊ฐ™๋‹ค. ์ฐธ๊ณ ๋กœ ๋‚˜๋Š” 1๋ฒˆ ๋ฐฉ์‹์œผ๋กœ ๊ตฌํ˜„ํ•ด๋’€๋‹ค. 

 

 

 

 

 

 

 

 

 

 

 

+ 4. report ๋ณ‘ํ•ฉ ์‹œ ์ปค๋ฒ„๋ฆฌ์ง€ ์ธก์ • ๋Œ€์ƒ ์ œ์™ธํ•œ ํด๋ž˜์Šค๋„ ํฌํ•จ๋˜๋Š” ๋ฌธ์ œ ํ•ด๊ฒฐ

๊ตฌ๊ธ€๋ง ํ•ด๋ณด๋‹ˆ ์—ฌ๋Ÿฌ ์‚ฌ๋žŒ๋“ค์ด ๊ฒช์—ˆ๋˜ ๋ฌธ์ œ์˜€๋‹ค. 

 

2.2 ํ”Œ๋Ÿฌ๊ทธ์ธ ๋ฐฉ์‹์—์„œ๋„ ์ปค๋ฒ„๋ฆฌ์ง€ ์ธก์ • ๋Œ€์ƒ์—์„œ ์ œ์™ธํ•œ ํด๋ž˜์Šค๊ฐ€ ๋ณ‘ํ•ฉ ์‹œ ํฌํ•จ๋˜๋Š” ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ•˜๋Š”๋“ฏ ํ–ˆ๋‹ค. (์ฐธ๊ณ )

 

๋‚˜๋Š” testCodeCoverageReport task ์‹คํ–‰ ์‹œ ํด๋ž˜์Šค ํŒŒ์ผ๊ณผ ์†Œ์Šค ์ฝ”๋“œ๋ฅผ ๊ฐ€์ ธ์˜ค๋Š” ์ฝ”๋“œ๊ฐ€ ์žˆ๋Š”๋ฐ ์—ฌ๊ธฐ์„œ filter๋ฅผ ๊ฑธ์–ด ํ•ด๊ฒฐํ•  ์ˆ˜ ์žˆ์—ˆ๋‹ค. 

 

task testCodeCoverageReport(type: JacocoReport) {

    // service ํ•˜์œ„ ๋ชจ๋“ˆ๋“ค์— ๋Œ€ํ•ด์„œ๋งŒ
    def microserviceModules = subprojects.findAll { it.path.startsWith(":service:") }

    // ํ…Œ์ŠคํŠธ ๋๋‚œ ํ›„ ์‹คํ–‰
    dependsOn microserviceModules.test

    // test.exec ๊ฐ€์ ธ์˜ค๊ธฐ
    executionData.from = files(microserviceModules.jacocoTestReport.executionData)

    // ํด๋ž˜์Šค ํŒŒ์ผ ๊ฐ€์ ธ์˜ค๊ธฐ
    additionalClassDirs.setFrom(
            files(microserviceModules*.sourceSets.main.output)
                    .filter { it.exists() }
                    .asFileTree.matching(excludePatternRules())
    )

    // ์†Œ์Šค ์ฝ”๋“œ ๊ฐ€์ ธ์˜ค๊ธฐ
    sourceDirectories.setFrom(
            files(microserviceModules*.sourceSets.main.allSource.srcDirs)
                    .filter { it.exists() }
                    .asFileTree.matching(excludePatternRules())
    )

    reports {
        xml.required = false
        csv.required = true
        html.required = true
    }
}

def excludePatternRules() {
    return {
        exclude "**/dto/**"      // DTO ํŒจํ‚ค์ง€ ์ œ์™ธ
        exclude "**/Q*.class"    // QueryDSL Q-Class ์ œ์™ธ
    }
}