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 ์ ์ธ
}
}