CI/CD를 구축해보자2 - JaCoCo와 GitHub Actions으로 CI/CD구축해보기(추가: Report to PR)

Jeonghwa·2024년 1월 29일
1

CI/CD를 구축해보자

목록 보기
2/2

서론

공간예약플랫폼의 CI/CD 구축과정을 2편으로 이어서 기록합니다. 테스트 커버리지 레포트를 저장하고 체크할 수 있는 Jacoco라이브러리와 WorkFlow로 작업을 자동화할 수 있는 Github Actions를 사용합니다.

실습을 진행하기 전 아래 CI/CD가 무엇인지부터 알아봅시다.

CI/CD란?

  • CI(지속적 통합): 애플리케이션을 빌드, 테스트하여 이상이 없는 경우 소스코드를 레포지토리에 병합하는 자동화된 과정
  • CD(지속적 배포): 애플리케이션을 빌드, 테스트하여 이상이 없는 경우 프로덕션 환경에 배포하는 자동화된 과정

이를 구축하게 된다면, 코드 변경이 있을 때마다 자동으로 빌드 및 테스트를 수행하기때문에 소프트웨어의 품질을 유지하고 오류를 빨리 찾아낼 수 있습니다. 또한 빌드 및 테스트가 성공하면 자동으로 프로덕션 환경에 배포되기때문에 프로덕트를 사용자에게 신속하게 전달할 수 있습니다.

특히 개발자들은 코드를 안전하게 변경하고 빠르게 배포할 수 있으므로 굉장히 편하겠죠? 그래서 저는 아래와 같이 파이프라인을 만들었습니다.

모두의공간 파이프라인

CI

  1. featurebranch에서 developbranch로 PR 생성
  2. Gradle build : 소스 Build, TestCode 실행, TestCoverage 레포트 저장, TestCoverage 체크
  3. 위 과정 통과 시 develop branch에 merge 가능

CD

  1. master branch push
  2. Gradle build : 소스 Build
  3. 통과 시 Docker Image 빌드 및 서버 자동 배포

이미 develop branch에서 검증된 코드만 master로 넘어오기 때문에 배포 효율을 위해 test과정을 제외하였습니다.

참고: 프로젝트 개발 Flow

  • Issues 탭에서 이슈를 생성한다.
  • develop 브랜치에서 작업 브랜치를 생성한다.
    • feature/[issue number]-[task name]
  • 작업 완료 후, feature ➡️ develop으로 PR을 생성한다.
  • PR승인 시 develop ➡️ master 브랜치로 fast forward를 통해 머지한다.

CI/CD 툴

CI/CD툴로는 여러가지 도구가 있습니다. 대표적으로 Jenkins, Travics CI 등이 있습니다. 그리고 저는 GitHub Action을 선택하였는데 여기엔 아래와 같은 이유가 있습니다.

  • Github에서 제공하는 기능이기 때문에 GitHub Repository에서 바로 사용 가능하다.
    • 따로 무언가 설치할 필요 없이 WorkFlow만 작성해 주면 된다.
  • 일정량 무료로 제공되기 때문에 개인 프로젝트에선 과금될 가능성이 적다.

저는 현재 GitHub으로 형상관리를 하고 있기 때문에 고민 없이 선택하였습니다.


1. Github Secrets 설정

github actions에서 사용할 수 있는 변수를 설정할 수 있습니다. 보안상의 이유로 소스코드에 노출하면 안되는 정보들(ex. application설정정보, 비밀번호, api키 등)을 여기에 등록해서 사용하면됩니다.

  • [Settings] > [Secrets and variables] > [Actions]

등록된 secret값은 actions에서 ${{ secrets.APPLICATION_YAML }} 이런식으로 사용할 수 있습니다.

그리고 저는 application 설정 정보를 properties가 아닌 yaml파일로 작성하였는데, 이를 그대로 등록하게 되면 에러가 발생합니다. 따라서 꼭! Base64로 인코딩해서 등록을 해주어야합니다.

2. CI 작성하기

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이 생성될 때 워크플로우가 동작합니다.

3. Jacoco 설정하기

저는 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

jacocoTestReport {
    executionData(fileTree(project.rootDir.absolutePath).include("**/build/jacoco/*.exec"))

    reports {
        html.enabled true
        xml.enabled false
        csv.enabled false
    }
}
  • 바이너리 커버리지 결과를 다양한 타입의 리포트로 생성하여 저장합니다.
  • html 파일로 생성해서 사람이 쉽게 눈으로 확인할 수도 있고
  • Sonar Qube등 다른 프로그램과의 연동을 위해 xml, csv 형태로도 리포트를 생성할 수 있습니다.

저는 다른 프로그램과 연동하지 않기때문에 html을 제외하고는 전부 false처리 해주었습니다. 생성된 레포트가 보고싶다면 테스트를 실행하고 /build/reports/jacoco/test/html/index.html경로에서 확인할 수 있습니다.

각 요소마다 항목별로 총 개수놓친 개수(Missed)를 표시해줍니다.

코드파일로 들어가면 여러가지 색깔이 칠해진 라인들을 볼 수 있습니다.

  • 초록색 : 커버가 된 라인
  • 빨간색 : 테스트를 놓친 라인
  • 노란색 : 모든 조건 중 일부만 테스트 된 라인

jacocoTestCoverageVerification

// 테스트 커버리지 검증
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클래스라던지 예외하고 싶은 클래스들을 패키지+클래스명형식으로 와일드카드를 사용하여 지정할 수 있습니다.

참고:

추가: JacocoReport to PR

Jacoco로 Report를 만들었으면 이를 PR에 가시적으로 추가해주는 방법 또한 존재합니다. 이를 위해서는 아래와 같은 설정이 필요합니다.

  1. xml로 레포트 생성하기
  2. 테스트 커버리지 기준 변경 (선택사항)
  3. CI 설정하기

xml로 레포트 생성하기

위에선 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']
        }
    }
}

CI 설정하기

워크플로우에게 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파일이 생성되는 경로를 잡아주고, tokensecrets.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

HttpError가 난다면?

만약 CI가 돌아가는 도중 HttpError가 난다면 워크플로우가 권한이 없는 경우입니다. 자세한건 아래 이슈를 확인해주세요!

이슈참고: https://github.com/Madrapps/jacoco-report/issues/24

4. CD 작성하기

이제 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편에 이어 긴글 읽어주셔서 감사합니다 : )

profile
backend-developer🔥

1개의 댓글

comment-user-thumbnail
2024년 2월 8일

잘 보고 갑니다~

답글 달기

관련 채용 정보