CI 적용하여 Long-Run하는 백엔드 시스템 만들기

ssongkim·2023년 3월 19일
0

Overview

백엔드 시스템은 혼자서 개발하고 유지보수하기 어려우며, 보통 여러 명이 협업하여 개발하고 유지보수합니다. 이러한 이유로 우리는 코드 컨벤션을 정하고, 좋은 코드를 작성하며, 코드 커버리지를 높이는 등의 행위를 통해 소프트웨어 유지보수를 용이하게 합니다.

이렇게 개발된 백엔드 시스템은 5년, 10년, 길면 20년 넘게 사용됩니다. 그 과정에서 개발된 소프트웨어가 수명을 다 할 때까지 초기 개발자가 끝까지 자리에 있을 것이라는 보장은 없습니다. 개발 중간에, 혹은 유지보수하다가 나가고 새로 들어올 수 있습니다.

소프트웨어가 탄생하고 죽을 때까지 모든 유지보수를 나 혼자서만 수행한다면 좋은코드, 코드 컨벤션, 코드 커버리지 등 전혀 필요없습니다. 백엔드가 의도대로 동작만 한다면, 나만 알아볼 수 있으면 문제가 발생해도 내가 있기에 해결할 수 있기 때문이죠.

하지만 그렇지 않기 때문에 우리는 평소 백엔드 애플리케이션을 개발할 때 다른 사람들과 협업을 용이하기 위해 코드 컨벤션을 정하고, 소프트웨어 유지보수를 용이하게 하기위해 좋은 코드란 무엇인가 고민하며 코드커버리지를 강제합니다.

또한 우리는 log4j 취약점 등 보안 위협에 대한 대응도 중요합니다. 모든 디펜던시를 확인하고 수동으로 대응하기는 어렵습니다.

우리팀은 잘하고 있나?

팀 차원에서 협업 및 유지보수를 용이하기 위해 코드 컨벤션을 정하고 코드 커버리지를 강제하여 코드 품질을 유지하려고 노력한다고 해봅니다.
이것을 다 지켰는지 확인하는 방법은 무엇일까요? 바로 코드리뷰입니다.

task가 할당되고 이에 대해 브랜치를 따서 개발한 후에 작업 내용을 반영하기 위하여 리뷰어를 지정하며 pull request를 올립니다.

그럼 리뷰어는 많은 것을 점검합니다. 1. 코드 컨벤션을 지켰는 지, 2. 코드 커버리지를 만족했는 지, 3. 이 사람이 사용한 라이브러리가 취약점이 존재하는 버전은 아닌지 4. 실수하지는 않았는지(알고보니 세미콜론 빼먹어서 빌드실패 등등) 5. 코드스멜이 있지는 않은지 등등... 리뷰어 입장에서 수동으로 점검할 것이 정말 많네요. 검토시간이 굉장히 길어집니다. 근데 리뷰어도 가끔 할 일이 많아서 리뷰를 깊게 해주지는 못할 것 같기도 하구요.

개발자는 사람입니다. 사람은 어떤 존재냐, 실수할 수 있는 존재죠. 개발을 한 사람은 코드 컨벤션을 지켰다고 생각했지만 실수로 빼먹을 수 있는 것이고, 그것을 리뷰어가 캐치해주길 바라지만 리뷰어도 사람이기에 캐치하지 못할 수 있습니다. 그럼 그 코드는 컨벤션을 어겼지만 머지되고 말겠죠.

이런 일이 반복될수록 코드 품질은 계속 떨어지게 됩니다. 개발을 해서 올린사람, 리뷰어 모두 사람이기에 휴먼에러는 캐치하기가 정말 어렵습니다.

리뷰어의 코드리뷰 검토시간을 단축하고 위에서 발생한 다양한 문제를 사람이 아닌 기계적으로 해결하려는 것이 CI입니다.

이번시간에는 DevOps에 빠질 수 없는 구성요소 CI를 적용해보겠습니다.

CI란

지속적 통합(continuous integration, CI)은 팀원이 수행한 작은 단위의 작업에 대해 지속적으로 통합하며 품질 관리를 적용하는 프로세스를 실행하는 것을 의미합니다.

CI는 모든 개발을 완료한 뒤에 품질 관리를 적용하는 고전적인 방법을 대체하는 방법으로서 소프트웨어의 질적 향상과 소프트웨어를 배포하는데 걸리는 시간을 줄이는데 초점이 맞추어져 있습니다.

오늘은 JaCoCo, Sonarqube, checkstyleCI 과정에서 적용하기 위해 알아보고자 합니다.

1. JaCoCo

1-a, JaCoCo란

JaCoCoJava code coverage의 약자로 junit 테스트의 결과를 바탕으로 커버리지를 결과를 리포트 해주는 코드 정적분석도구입니다.

JaCoCo는 코드정적분석도구인 sonarqube와 연계하여 사용하는 경우가 많습니다.

코드 커버리지는 소스 코드를 기반으로 수행하는 화이트 박스 테스트를 통해 측정하며 사전에 정한 코드커버리지 이상 테스트코드를 작성하였는지 검증할 수 있습니다.

1-b, JaCoCo를 적용하고자 하는 이유

JaCoCo를 적용하고자 하는 이유는, JaCoCo를 통해 단위테스트코드 작성을 강제하고 일정 이상의 테스트커버리지를 보장하여 소스코드의 신뢰성을 보장하기 위함입니다.

1-c, 코드 커버리지 측정 기준

코드 커버리지 측정기준은 아래 크게 3가지로 나뉩니다.

  • 구문(Statement)
  • 조건(Condition)
  • 결정(Decision)

구문

라인(Line) 커버리지라고 부르기도 합니다. 코드 한 줄이 한번 이상 실행된다면 충족됩니다.

void test(int n) {
  // 함수 A 실행 - 1번
  if (n > 0) { //- 2번
    // 함수 B 실행 - 3번
  }
  // 함수 C 실행 - 4번
}

위에의 코드를 n 이 음수로 들어오는 테스트 하나만 했다고 해봅니다. 그러면 1~4번 구문중 3번 구문이 실행이 안돼서 구문 커버리지는 3 / 4 * 100% = 75% 인 테스트가 됩니다.

조건

조건식의 모든 내부 조건이 true/false을 가지게 되면 충족됩니다.

void foo (int x, int y) {
    system.out("start line"); // 1번
    if (x > 0 && y < 0) { // 2번
        system.out("middle line"); // 3번
    }
    system.out("last line"); // 4번
}

내부조건이라는 것은 조건식 내부의 각각의 조건이라고 이해하면 편합니다.

위 코드에서 조건문은 2번 if 문이 있고, 그중 내부 조건은 조건식 내부의 x > 0, y < 0 2개를 말합니다.

위의 코드를 테스트한다고 가정하면 조건 커버리지를 만족하는 테스트 케이스로는 1. (x = 1, y = -1), 2.(x = -1, y = -1)이 있습니다.

첫 번째 테스트케이스는 true/false 를 만족하고 두 번째 테스트케이스는 false/true를 만족합니다.

각 내부조건에 대해 true/false 모두 존재하므로 조건 커버리지를 충족한다고 할 수 있습니다.

하지만 조건식의 결과는 두 케이스 모두 false입니다. 그래서 조건 커버리지를 만족하더라도 구문 커버리지나 결정 커버리지를 만족하지 못하는 경우가 있습니다.

결정

브랜치(Branch) 커버리지라고 부르기도 합니다. 모든 조건식이 true/false을 가지게되면 충족됩니다.

void foo (int x, int y) {
    system.out("start line"); // 1번
    if (x > 0 && y < 0) { // 2번
        system.out("middle line"); // 3번
    }
    system.out("last line"); // 4번
}

위의 코드를 테스트하면 if문의 조건에 대해 true/false 모두 가질 수 있는 테스트 케이스로는 x = 1, y = -1, x = -1, y = 1이 있습니다.

위의 두 데이터 모두 if문 관점에서 true/false를 모두 반환하므로 결정 커버리지를 충족합니다.

어떤 코드 커버리지 측정 기준을 많이 사용할까?

셋 다 모두 혼합하여 사용하는 것이 가장 베스트입니다. 하지만 하나만 선택한다면 위의 세 가지 코드 커버리지 중에서는 구문 커버리지가 가장 대표적으로 많이 사용되고 있다고 합니다.

나머지 조건 커버리지나 브랜치 커버리지의 경우 코드 실행에 대한 테스트보다는 로직의 시나리오에 대한 테스트에 더 가깝다고 볼 수 있기 때문입니다.

구문 커버리지를 만족한다면, 모든 코드를 테스트 코드가 커버했다고는 말할 수 있습니다. 물론 위에 결정 커버리지의 코드 예시에서 조건식이 false인 시나리오에 대해서 테스트 됐다고 보장할 수 없지만 그래도 조건문 내부의 코드가 실행 되었을 때 문제가 없다는 것은 보장할 수 있습니다.

1-d, JaCoCo 적용하기

공식 문서를 참고했습니다.
https://docs.gradle.org/current/userguide/jacoco_plugin.html

gradle 설정

plugins {
   .....
   id 'jacoco'
}
...
 
// test -> jacocoTestReport -> jacocoTestCoverageVerification 순으로 실행되어야 한다.
jacoco {
   toolVersion = "0.8.8"
}
 
test {
   useJUnitPlatform()
   finalizedBy jacocoTestReport // test 성공 / 실패 여부 없이 jacocoTestReport 실행
}
 
 
jacocoTestReport {
   dependsOn test
 
   reports {
      xml.required = true // 소나큐브 연동 위해서 xml 포맷으로 생성한다.
      html.required = true // 로컬에서 쉽게 보기 위해 html 포맷으로 생성한다.
      csv.required = false // csv 포맷은 생성하지 않음
      html.outputLocation = layout.buildDirectory.dir('jacocoHtml')
   }
 
   finalizedBy jacocoTestCoverageVerification
}
 
jacocoTestCoverageVerification {
   violationRules {
      rule {
         enabled = true // 이 rule을 적용할 것이다.
         element = 'CLASS' // class 단위로
 
         // 브랜치 커버리지 최소 50%
         limit {
            counter = 'BRANCH'
            value = 'COVEREDRATIO'
            minimum = 0.50
         }
 
         // 라인 커버리지 최소한 80%
         limit {
            counter = 'LINE'
            value = 'COVEREDRATIO'
            minimum = 0.80
         }
 
         // 빈 줄을 제외한 코드의 라인수 최대 300라인
         limit {
            counter = 'LINE'
            value = 'TOTALCOUNT'
            maximum = 300
         }
 
 
         //해당 파일만 검사하거나
         //includes = ["com.wmp.ep.*"]
         // 코드 커버리지를 만족하는지 확인할 대상 중 제외
        excludes = [
                '*.*Application',
                '*.*Exception',
                '*.dto.*',
                'com.vertica.*',
                // ...
        ]
      }
   }
}

이렇게 설정할경우 기본값으로 지정되어 html은 build/reports/jacoco/test/html/index.html에 생기고, xml은 build/reports/jacoco/test/jacocoTestReport.xml에 생성됩니다.

jacocoTestCoverageVerification

jacocoTestCoverageVerification은 테스트 커버리지를 설정하는 부분입니다.
rule.element: BUNDLE(default), CLASS, GROUP, METHOD, PACKAGE, SOURCEFILE

rule.limit.counter: INSTRUCTION(default),BRANCH, CLASS, COMPLEXITY, METHOD, LINE

rule.limit.value: COVEREDRATIO(default), COVEREDCOUNT, MISSEDCOUNT, MISSEDRATIO, TOTALCOUNT

lombok은 테스트커버리지에 추가하지 않기

getter setter 등 롬복 관련된 사항은 테스트커버리지에 반영하지 않도록 합니다.

루트 디렉토리에 다음과 같은 파일을 생성합니다.

lombok.addLombokGeneratedAnnotation = true

정적분석 실행

다음 명령어로 테스트코드를 돌려 리포트를 뽑아내면 build/jacocoHtml/index.html 에서 확인이 가능합니다.

./gradlew clean test jacocoTestReport

2. Sonarqube

2-a, 소나큐브란

소나큐브는 지속적으로 코드의 품질을 높이고 유지 보수하는데 도움을 주는 정적분석 도구입니다.

중복 코드, 코딩 표준, 유닛 테스트, 코드 커버리지, 코드 복잡도, 주석, 버그 및 보안 취약점 등등에 대해서 검사하고 결과를 보고서로 작성해줍니다.

2-b, 소나큐브를 적용하고자 하는 이유

  • 프로그램을 실행하지 않고 버그나 취약점을 분석할 수 있습니다.
  • 개발 단계에서 작성한 코드의 구조적인 문제나 휴먼에러를 찾아낼 수 있습니다.
  • 코드 작성단계에서 차후 코드를 실행 했을 때 발생할 가능성이 높은 문제를 미리 찾고 대처할 수 있습니다.
  • 단순히 버그나 오류를 찾아내는 것 뿐만 아니라 더 좋은 코드를 위한 개선점을 제시해줍니다.

2-c, 소나큐브 m1에 설치하기

공식문서를 참고합니다.
https://docs.sonarqube.org/latest/try-out-sonarqube/

소나큐브 시작/종료하기

./sonarqube-9.9.0.65466/bin/macosx-universal-64/sonar.sh start
 
 
./sonarqube-9.9.0.65466/bin/macosx-universal-64/sonar.sh stop

2-d, 로컬에서 정적분석 해보기

프로젝트 생성

http://localhost:9000 으로 접속하여 admin / admin 으로 로그인하고 프로젝트를 생성합니다.

토큰발급


토큰을 발급받고 해당 토큰값을 gradle 설정에서 사용 예정입니다.

gradle 설정

plugins {
....
   id "org.sonarqube" version "3.5.0.2730"
}
 
 
sonar {
   // id, password 없어도 원격 분석 가능하다.
   // local 에서 직접 원격 sonarqube 서버로 분석할떄 다음 명령어를 사용.
   // ./gradlew sonar -x test
   properties {
      property 'sonar.host.url', 'http://localhost:9000'
      property 'sonar.projectKey', 'test-sonar'
      property 'sonar.login', 'sqp_4148f9a13e536bceed9534b18eadd8f27cbb7b12'
      property 'sonar.java.binaries', '.'
 
      property "sonar.language", "java"
      property "sonar.sourceEncoding", "UTF-8"
      property "sonar.sources", "src/main/java"
      property "sonar.tests", "src/test/java"
      property "sonar.coverage.jacoco.xmlReportPaths", "${buildDir}/reports/jacoco/test/jacocoTestReport.xml" // jacoco xml 파일 위치
      property "sonar.java.binaries", "${buildDir}/classes"
      property "sonar.test.inclusions", "**/*Test.java"
      property "sonar.exclusions", "**/util/**, **/support/**, **/dto/**, **/*Application*" // 제외할 클래스들
   }
}

이전 시간에 자코코 실행 시 xml 포맷을 생성하였었습니다. 해당 경로를 지정합니다.

즉 소나큐브 정적분석은 자코코 실행 후에 진행되어야 합니다.

자코코에서 커버리지 확인 제외 jacocoTestCoverageVerification exclude를 설정하였어도 소나큐브 커버리지에 반영되지 않습니다. sonar.exclusions에 추가로 지정해주어야 합니다.

정적분석 실행

./gradlew sonar -x test

위에서 토큰을 프로퍼티에 삽입하였는데 저렇게 하지 않고 명령어 실행 시 환경변수로 넣어줄 수도 있습니다.

./gradlew sonar -x test -Dsonar.login=${위에서 발급 받은 토큰}

3. checkstyle

3-a, checkstyle이란

Checkstyle은 Java 소스 코드가 지정된 코딩 규칙을 준수하는지 확인하기 위해 소프트웨어 개발에 사용되는 정적 코드 분석 도구입니다.

즉 정해진 코드 컨벤션을 준수했는지 알려줍니다.

3-b, 시작하기

공식문서를 참고합니다.
https://docs.gradle.org/current/userguide/checkstyle_plugin.html

checkstyle 포맷 파일지정

먼저 팀 차원에서 코드 컨벤션을 기입한 checkstyle 포맷의 파일을 만듭니다.

그리고 default 경로에 넣습니다.

{root}/config/checkstyle/checkstyle.xml

gradle 설정

plugins {
....
   id 'checkstyle'
}

정적분석 실행

./gradlew checkstyleMain // 메인
./gradlew checkstyleTest // 테스트

4. 통합테스트, 단위테스트 구동 환경 분리

기본적으로 ./gradlew clean test 명령어 수행 시 모든 테스트코드가 돌아갑니다.

문제는...

CI를 적용하는 과정에서 통합테스트를 돌리고 싶지 않을 때입니다. 통합테스트는 원할 때만 돌릴 수 있도록 분리하는 작업을 수행해주어야 합니다.
이미 단위테스트 클래스가 존재하는 경우, 통합테스트는 어느 클래스에 작성하는가, 뒤에 IntegrationTest라고 명명 해야하는가..

이번 장에서는 gradle에서 별도의 통합테스트 코드를 구성하는 방법을 소개합니다.

4-a, integrationtest 디렉토리 생성

root/src/ 하위 디렉토리에 integrationtest 라는 디렉토리와 그 하위에 java라는 디렉토리를 추가합니다.

4-b, build.gradle 설정

// build.gradle 설정
sourceSets {
    integrationtest {
        java {
            compileClasspath += main.output + test.output
            runtimeClasspath += main.output + test.output
        }
    }
}
 
 
configurations {
......
    integrationtestImplementation.extendsFrom(testImplementation)
    integrationtestRuntimeOnly.extendsFrom(testRuntimeOnly)
}

gradle을 설정한다. 반드시 디렉토리 명이랑 일치시켜주어야 합니다.

디렉토리명이랑 일치시키고 싶지 않다면 다음과 같이 지정도 가능합니다.

sourceSets {
    intTest {
        java {
            srcDirs("src/integrationtest")
 
            compileClasspath += main.output + test.output
            runtimeClasspath += main.output + test.output
        }
    }
}
 
 
... configuration

그 다음 task를 선언합니다.

// 통합테스트 수행 시 사용
task integrationTest(type: Test) {
    description = "Run integration tests"
    group = "verification"
    testClassesDirs = sourceSets.integrationtest.output.classesDirs
    classpath = sourceSets.integrationtest.runtimeClasspath
    useJUnitPlatform()
}

4-c, 통합테스트 수행

./gradlew integrationTest

마무리

오늘은 로컬에서 CI를 적용하기 위한 자코코, 소나큐브, 체크스타일 정적분석 방법을 알아보았습니다. 예제에서는 정적분석 명령어 실행을 수동으로 해주었지만 이를 젠킨스같은 CI 도구를 이용해 webhook이 날라올 때마다 자동화해주면 CI적용은 끝이 납니다.

profile
鈍筆勝聰✍️

0개의 댓글