공간예약플랫폼의 CI/CD 구축과정을 2편으로 이어서 기록합니다. 테스트 커버리지 레포트를 저장하고 체크할 수 있는 Jacoco라이브러리와 WorkFlow로 작업을 자동화할 수 있는 Github Actions를 사용합니다.
실습을 진행하기 전 아래 CI/CD가 무엇인지부터 알아봅시다.
이를 구축하게 된다면, 코드 변경이 있을 때마다 자동으로 빌드 및 테스트를 수행하기때문에 소프트웨어의 품질을 유지하고 오류를 빨리 찾아낼 수 있습니다. 또한 빌드 및 테스트가 성공하면 자동으로 프로덕션 환경에 배포되기때문에 프로덕트를 사용자에게 신속하게 전달할 수 있습니다.
특히 개발자들은 코드를 안전하게 변경하고 빠르게 배포할 수 있으므로 굉장히 편하겠죠? 그래서 저는 아래와 같이 파이프라인을 만들었습니다.
feature
branch에서 develop
branch로 PR 생성develop
branch에 merge 가능master
branch push이미 develop branch에서 검증된 코드만 master로 넘어오기 때문에 배포 효율을 위해 test과정을 제외하였습니다.
참고: 프로젝트 개발 Flow
- Issues 탭에서 이슈를 생성한다.
- develop 브랜치에서 작업 브랜치를 생성한다.
feature/[issue number]-[task name]
- 작업 완료 후,
feature
➡️develop
으로 PR을 생성한다.- PR승인 시
develop
➡️master
브랜치로fast forward
를 통해 머지한다.
CI/CD툴로는 여러가지 도구가 있습니다. 대표적으로 Jenkins, Travics CI 등이 있습니다. 그리고 저는 GitHub Action을 선택하였는데 여기엔 아래와 같은 이유가 있습니다.
저는 현재 GitHub으로 형상관리를 하고 있기 때문에 고민 없이 선택하였습니다.
github actions에서 사용할 수 있는 변수를 설정할 수 있습니다. 보안상의 이유로 소스코드에 노출하면 안되는 정보들(ex. application설정정보, 비밀번호, api키 등)을 여기에 등록해서 사용하면됩니다.
등록된 secret값은 actions에서 ${{ secrets.APPLICATION_YAML }}
이런식으로 사용할 수 있습니다.
그리고 저는 application 설정 정보를 properties
가 아닌 yaml
파일로 작성하였는데, 이를 그대로 등록하게 되면 에러가 발생합니다. 따라서 꼭! Base64로 인코딩해서 등록을 해주어야합니다.
Gradle 프로젝트이기때문에 Java with Gradle
워크플로우를 선택하여 작성하였습니다.
여기서 작성하는 내용을 통해 어떤 조건에서 어떤 작업을 수행하는지 직접 정의할 수 있습니다.
name: CI
# 설정한 조건이 발생하면 워크플로우를 실행합니다.
on:
pull_request:
branches: [ "develop" ]
# 워크플로우가 Repository 코드를 읽을 수 있도록 권한을 줍니다.
permissions:
contents: read
# 워크플로우가 실행할 내용을 정의합니다.
jobs:
CI:
runs-on: ubuntu-latest #ubuntu 최신환경에서 실행합니다.
steps:
- name : Checkout # 해당 브랜치를 체크아웃합니다.
uses: actions/checkout@v3
- name: Set up JDK 11 # jdk11을 설치합니다.
uses: actions/setup-java@v3
with:
java-version: '11'
distribution: 'temurin'
- name : Grant Execute permission for gradlew # gradlew파일에 실행할 권한을 줍니다.
run: chmod +x gradlew
shell: bash
- name: Make application.yml # GitHub Secrets에서 가져온 값으로 디코딩 후 application.yml을 만들어줍니다.
run: |
cd ./src/main/resources
touch ./application.yml
echo "${{ secrets.APPLICATION_YAML }}" | base64 --decode > ./application.yml
shell: bash
- name: Build with Gradle # Gradle을 통해 소스를 빌드하고 테스트코드를 실행합니다.
run: ./gradlew clean build
shell: bash
작성한 파일을 등록해주면 develop브랜치로 PR이 생성될 때 워크플로우가 동작합니다.
저는 TestConverage 레포트를 저장하고 명시된 Coverage를 통과하지 못하면 테스트를 실패하게 만들기 위해 Jacoco 라이브러리을 프로젝트에 설정해주었습니다.
// 1. 플러그인 추가
plugins {
id 'java'
id 'jacoco'
...
}
group = 'com'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'
repositories {
mavenCentral()
}
jacoco {
toolVersion '0.8.8' // 2. 버전명시
}
dependencies {
...
}
// 3. Test가 끝나고 수행할 jacoco 작업 명시
tasks.named('test') {
useJUnitPlatform()
finalizedBy jacocoTestReport, jacocoTestCoverageVerification
}
...
// 테스트 커버리지 결과를 리포트 형태로 저장.
jacocoTestReport {
executionData(fileTree(project.rootDir.absolutePath).include("**/build/jacoco/*.exec"))
reports {
html.enabled true
xml.enabled false
csv.enabled false
}
}
// 테스트 커버리지 검사
jacocoTestCoverageVerification {
violationRules {
rule {
element 'CLASS'
limit {
counter = 'BRANCH'
value = 'COVEREDRATIO'
minimum = 0.8 // 테스트 커버리지 최소 80%
}
// 커버리지 체크를 제외할 클래스들
excludes = ['*.*Controller', '*.dto.*', '*.config.*', '*.domain.Q*']
}
}
}
Jacoco에는 두가지 task가 있습니다.
1. jacocoTestReport
2. jacocoTestCoverageVerification
jacocoTestReport {
executionData(fileTree(project.rootDir.absolutePath).include("**/build/jacoco/*.exec"))
reports {
html.enabled true
xml.enabled false
csv.enabled false
}
}
저는 다른 프로그램과 연동하지 않기때문에 html을 제외하고는 전부 false처리 해주었습니다. 생성된 레포트가 보고싶다면 테스트를 실행하고 /build/reports/jacoco/test/html/index.html
경로에서 확인할 수 있습니다.
각 요소마다 항목별로 총 개수와 놓친 개수(Missed)를 표시해줍니다.
코드파일로 들어가면 여러가지 색깔이 칠해진 라인들을 볼 수 있습니다.
// 테스트 커버리지 검증
jacocoTestCoverageVerification {
violationRules {
rule {
// 룰을 체크할 단위는 클래스
element 'CLASS'
// 브랜치 커버리지를 최소한 80% 만족시켜야한다.
limit {
counter = 'BRANCH'
value = 'COVEREDRATIO'
minimum = 0.8
}
// 커버리지 체크를 제외할 클래스들
excludes = ['*.*Controller', '*.dto.*', '*.config.*', '*.domain.Q*']
}
}
}
violationRules
로 커버리지 기준을 설정하는 rule을 정의해봅시다.
element 'CLASS'
: rule을 체크하는 범위를 지정합니다.BUNDLE(패키지번들)
, PACKAGE(자바패키지)
, CLASS
, SOURCEFILE
, METHOD
counter = 'BRANCH'
: rule을 체크하는 기준을 지정합니다. INSTRUCTION(바이트코드명령수)
, LINE(빈줄을 제외한 실제 코드 라인 수)
, BRANCH(조건문 등의 분기 수)
, COMPLEXITY(복잡도)
, METHOD(메서드 수)
, CLASS(클래스 수)
value = 'COVEREDRATIO'
: 기준의 임계값을 지정합니다.TOTALCOUNT(전체 개수)
, MISSEDCOUNT(커버되지 않은 개수)
, COVEREDCOUNT(커버된 개수)
, MISSEDRATIO(커버되지 않은 비율)
, COVEREDRATIO(커버된 비율)
만약 해당 기준으로 minimum
에 도달하지 못하면 테스트는 실패합니다.
excludes
설정을 통해 테스트에서 예외도 시킬 수 있습니다.
excludes = ['*.*Controller', '*.dto.*', '*.config.*', '*.domain.Q*']
따로 검증이 필요없는 QueryDsl의 Q클래스라던지, 비지니스로직과 관련없는 DTO클래스라던지 예외하고 싶은 클래스들을 패키지+클래스명
형식으로 와일드카드를 사용하여 지정할 수 있습니다.
참고:
Jacoco로 Report를 만들었으면 이를 PR에 가시적으로 추가해주는 방법 또한 존재합니다. 이를 위해서는 아래와 같은 설정이 필요합니다.
위에선 xml을 false로 지정해주었지만, 레포트를 PR에 올리기위해 사용할 라이브러리는 xml파일을 사용하기때문에 true 처리합니다.
// 테스트 커버리지 결과를 리포트 형태로 저장.
jacocoTestReport {
executionData(fileTree(project.rootDir.absolutePath).include("**/build/jacoco/*.exec"))
reports {
html.enabled true
xml.enabled true // true 처리
csv.enabled false
}
}
이부분은 선택사항입니다. 위에선 limit 기준을 BRANCH
로 설정하였으나, 사용할 라이브러리는 INSTRUCTION
기준을 사용하기 때문에 동일하게 맞춰주었습니다.
출처: https://github.com/Madrapps/jacoco-report/blob/main/src/process.ts
다만,, PR에 올라가는 레포트는 제외클래스 지정이 불가능하므로 이 부분은 감안해야합니다.
// 테스트 커버리지
jacocoTestCoverageVerification {
violationRules {
rule {
element 'CLASS'
limit {
counter = 'INSTRUCTION' // INSTRUCTION로 변경
value = 'COVEREDRATIO'
minimum = 0.8
}
excludes = ['*.*Application', '*.*Controller', '*.dto.*', '*.config.*', '*.common.*', '*.domain.Q*', '*.*Builder']
}
}
}
워크플로우에게 Repository를 Write할 수 있는 권한을 부여하였으며, jacoco-report라이브러리를 사용하여 Report작성관련 설정해줍니다.
name: CI
on:
pull_request:
branches: [ "develop" ]
# WRITE 할 수 있는 권한을 부여합니다. (중요)
permissions: write-all
jobs:
CI:
runs-on: ubuntu-latest
steps:
- name : Checkout
uses: actions/checkout@v3
- name: Set up JDK 11
uses: actions/setup-java@v3
with:
java-version: '11'
distribution: 'temurin'
- name : Grant Execute permission for gradlew
run: chmod +x gradlew
shell: bash
- name: Make application.yml
run: |
cd ./src/main/resources
touch ./application.yml
echo "${{ secrets.APPLICATION_YAML }}" | base64 --decode > ./application.yml
shell: bash
- name: Build with Gradle
run: ./gradlew clean build
shell: bash
# 테스트커버리Report를 PR에 Comment에 등록합니다. (Instruction 기준)
- name: Jacoco Report to PR
id: jacoco
uses: madrapps/jacoco-report@v1.6.1
with:
paths: ${{ github.workspace }}/build/reports/jacoco/test/jacocoTestReport.xml
token: ${{ secrets.GITHUB_TOKEN }}
min-coverage-overall: 75
min-coverage-changed-files: 75
title: "⭐️Code Coverage"
update-comment: true
paths
를 통해 xml파일이 생성되는 경로를 잡아주고, token
의 secrets.GITHUB_TOKEN
은 github 기본설정값이니 secrets에 따로 등록할필요는 없습니다.min-coverage-overall
는 전체파일 최소커버리지, min-coverage-changed-files
는 변경된파일 최소커버리지 기준을 설정할 수 있습니다. update-comment
는 CI가 돌때마다 comment를 새로 등록하는게 아닌 업데이트하도록 설정한 것입니다.CI를 돌리면 아래와 같이 레포트가 생성되는 것을 볼 수 있습니다.
설정파일참고: https://github.com/Madrapps/jacoco-report?tab=readme-ov-file
만약 CI가 돌아가는 도중 HttpError가 난다면 워크플로우가 권한이 없는 경우입니다. 자세한건 아래 이슈를 확인해주세요!
이제 CD WorkFlow를 작성해보겠습니다.
name: CD
# 설정한 조건이 발생하면 워크플로우를 실행합니다.
on:
push:
branches: [ "master" ]
# 워크플로우가 Repository 코드를 읽을 수 있도록 권한을 줍니다.
permissions:
contents: read
# 워크플로우가 실행할 내용을 정의합니다.
jobs:
CD:
runs-on: ubuntu-latest #ubuntu 최신환경에서 실행합니다.
steps:
- name : Checkout # 해당 브랜치를 체크아웃합니다.
uses: actions/checkout@v3
- name: Set up JDK 11 # jdk11을 설치합니다.
uses: actions/setup-java@v3
with:
java-version: '11'
distribution: 'temurin'
- name : Grant Execute permission for gradlew # gradlew파일에 실행할 권한을 줍니다.
run: chmod +x gradlew
shell: bash
- name: Make application.yml # application.yml을 만들어줍니다.
run: |
cd ./src/main/resources
touch ./application.yml
echo "${{ secrets.APPLICATION_YAML }}" | base64 --decode > ./application.yml
shell: bash
- name: Build with Gradle # Gradle을 통해 소스를 빌드합니다. (검증된 코드이므로 테스트 제외)
run: ./gradlew clean build -x test
shell: bash
- name: Docker build & Docker push # DockerFile로 이미지를 빌드하고 Docker Repository 업로드 합니다.
run: |
docker login -u ${{ secrets.USERNAME }} -p ${{ secrets.PASSWORD }}
docker build -f Dockerfile -t ${{ secrets.USERNAME }}/modoospace .
docker push ${{ secrets.USERNAME }}/modoospace
- name: WAS access & deploy # 서버 접속하여 이미지를 다운받고 실행합니다.
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.WAS_HOST }}
username: ${{ secrets.WAS_USERNAME }}
password: ${{ secrets.WAS_PASSWORD }}
port: ${{ secrets.WAS_SSH_PORT }}
script: |
docker stop modoocontainer
docker rm modoocontainer
docker pull ${{ secrets.USERNAME }}/modoospace
docker run -d -p 8080:8080 --name modoocontainer ${{ secrets.USERNAME }}/modoospace
말씀드렸다시피 검증된 코드만 master로 넘어오기 때문에 배포 효율을 위해 test과정은 제외하였습니다.
이 때 중요한건 외부에 노출되면안되는 값들은 전부 Git Secrets에 등록하였다는 것입니다.
작성한 파일을 등록해주면 master브랜치에 push가 일어날 때 아래와 같이 워크플로우가 동작하고 서버에 변경사항이 반영된 것을 확인할 수 있습니다.
실제로 저희 회사에서도 최근 레거시 시스템의 CI/CD를 구축하였는데요.
과거엔 변경사항이 있다면 개발자가 개발 후 직접 로컬에서 빌드하였고 빌드된 파일을 FileZila로 서버로 전송한 뒤 해당 패키지에 각각 복사해준 후 '직접' 서버를 재기동시켜주는 과정을 거쳤습니다. 소스 형상도 제대로 관리가 안 되었기 때문에 서버엔 backup된 파일들로 가득했고 해당 프로젝트를 주로 개발하는 분이 최신 소스를 가지고 있는 사람이었습니다.
특히 내가 가지고 있던 소스와 서버에 올라가있는 소스가 달라서 운영에 이슈가 생겼을 땐 정말 아찔했습니다. 그래서 개인적으로라도 먼저 구축한 뒤 회사에 적용해 보자라는 마인드로 개인 프로젝트를 진행했던 기억이 나네요.
현재는 관리가 되고 있지 않던 소스를 서버와 맞추는 과정을 거친 후 Github 올려 사용하고 있으며, 운영 branch에 merge후 Jenkins에서 Maven 빌드를 통해 서버에 반영하는 과정을 자동화하였습니다. 👏
그만큼 CI/CD의 중요성을 정말 뼈저리게 느꼈던 경험이었고 혼자 구축해 본 경험이 많은 도움이 되었습니다.
1편에 이어 긴글 읽어주셔서 감사합니다 : )
잘 보고 갑니다~