[품질분석] SonarCloud+Jacoco기반의 CI-based analysis 구축

2022. 7. 18. 20:35[ DevOps ]/[ CI-CD ]

Static Analysis vs Dynamic Analysis

정적분석과 동적분석은 모두 코드의 취약점을 발견하는데 목표를 두고 있다. 두 분석의 차이점은 분석기가 개발 주기에서 어느 시점에 수행되느냐에 있다. 동적분석은 애플리케이션의 실행 이후 시점에 수행되고 정적분석은 애플리케이션의 실행 이전 시점에 수행된다. 동적분석의 경우 애플리케이션을 실행시켜 애플리케이션 내 결함 및 취약점 분석, 메모리 및 쓰레드 결함 등을 분석할 수 있다. 이와 반대로 정적분석은 애플리케이션을 실행하지 않고 코드의 취약점을 분석하는데 목적이 있다.

 

정적분석과 동적분석의 목적에 대한 차이는 다음과 같다.

- 정적분석: 개발 중 하면 안 될 것을 했는지 검사
- 동적분석: 개발 중 하려고 한 것을 잘했는지 검사

 

대표적인 분석 도구는 다음과 같다.

- 동적분석 도구: nGrinder, Junit, Jmeter

- 정적분석 도구: SonarQube, PMD

 

SonarQube

SonarQube는 '소스 코드 품질 분석도구'로써, 버그, 보안 취약점 등을 발견할 목적으로 코드의 정적분석과 자동리뷰 기능을 제공하는 오픈소스이다. 주로 SonarQube는 중복 코드, 코딩 표준, 코드 복잡도, 주석, 버그 및 보안 취약점의 보고서를 제공한다. SonarQube의 정적분석 단계를 CI/CD 파이프라인 상에 설정할 때에는 일반적으로 CI/CD 파이프라인의 초반 단계에 수행하거나 변경사항 커밋 전 IDE에서 직접 수행할 수 있다. SonarQube의 장점은 정적분석 기능을 넘어 Maven, Gradle, Github, CI 툴(Jenkins 등)과도 연동하여 여러 가지 기능을 수행할 수 있다는 점이다. 대표적인 사례로 SonarQube를 빌드툴과 연동하여 테스트 커버리지를 지속적으로 관리하는 경우가 있다.

 

SonarQube를 통해 확인할 수 있는 주요 지표

- Bugs: 코딩 오류

- Vulnerabilities: 공격당할 수 있는 취약점

- Code Smells: 유지보수가 어려운 코드

- Duplications: 중복코드

 

 

SonarQube vs SonarCloud

[SonarQube]

- 분석할 코드가 개발자가 직접 관리하는 서버에 위치할 경우 '설치'해서 사용한다.

- SonarQube를 설치하고 분석 결과를 기록할 데이터베이스 등도 직접 구축해서 관리해야 한다. 따라서 SonarQube 버전의 업그레이드도 개발자가 직접 관리해야 한다.

- 소스코드를 private하게 관리하고자 할 때 사용한다.

- Community edition은 무료이며 Enterprise edition는 유료이다.

 

[SonarCloud]

- 코드가 GitHub 같은 원격 저장소에 올라가 있고 이와 통합되어 함께 사용할 수 있을 경우 사용한다.

- SonarCloud를 사용하면 CI서버에 따로 설치할 필요도 없고 버전 관리도 자동으로 진행된다.

- Open Source Projects의 경우 무료로 사용할 수 있고 private repository의 경우 요금이 청구될 수 있다.

 

 

[Sonar 커뮤니티 참고 글]

SonarCloud는 GitHub, BitBucket Cloud and Azure Devops, CI/CD tool 등과 같이 third-party와의 통합 서비스를 제공하고 있고 이러한 통합은 SonarQube에서는 사용할 수 없다고 한다. 왜냐하면 SonarQube는 on-premise 형태로 직접 설치하여 구축해야 하기 때문이다.

 

 

 


 

SonarCloud + GitHub 레포지토리 연동 과정

아래 과정은 SonarCloud를 일반적인 '정적분석'으로만 사용할 경우에 GitHub 레포지토리와 연동하는 과정이다. 정적분석 기능을 넘어 테스트 커버리지 계산 등의 기능을 함께 사용하고자 한다면 아래 GitHub Actions와 빌드툴과 연동하는 과정을 참고하길 바란다.

 

 

 

1. 아래 사이트 로그인

 

2. 오가니제이션 또는 개인 레포지토리 Import

GitHub에 SonarCloud를 설치할 레포지토리를 선택한다.

 

3. Sonar에서 깃헙 레포지토리에 접근할 키 이름 생성

 

4. 무료 플랜 선택

 

5. 연동이 완료되면 대시보드 화면이 나오고 이 중에서 Rules탭을 선택해서 언어별로 정적 분석할 Rule들을 관리할 수 있다.

 

 

 

아래와 같이 Java언어의 경우 약 650개 정도의 Rule들이 지원된다.

 

 

6. Project탭을 눌러보면 아래와 같이 어느 라인에서 문제가 되는 코드를 발견했는지에 대한 정보도 제공한다.

 

 

cf. 무료 버전은 SonarCloud 분석 결과 페이지 링크만 있으면 누구나 접근 가능하다. 

 

 

기본적으로 Automatic Analysis 기능이 켜져 있는데 이 기능은 PR시에 자동으로 분석 결과 메시지를 달아주는 기능이다. 만약 이 기능을 끄고 싶다면 프로젝트의 레포지토리로 들어와서 하단의 Administration -> Analysis Method에서 상단에 Automatic Analysis를 꺼주면 된다.

 


 

SonarCloud+Jacoco기반의 CI-based analysis 구축하기

CI서버와 SonarCloud를 연동하여 코드의 정적분석 과정을 CI/CD 파이프라인의 가장 첫 단계로 설정할 수 있다. 또한 SonarCloud를 빌드툴과 연동하면 정적분석 이상의 기능까지 제공한다. 예를 들어 테스트 커버리지 분석(Jacoco)등이 포함된다.

 

아래 예시에서는 Gradle에 Jacoco를 추가하여 테스트 커버리지를 계산 결과 리포트를 SonarQube가 읽어갈 수 있도록 연동하는 과정이다. 이 과정에서는 SonarQube가 Jacoco의 테스트 결과 리포트를 읽어갈 수 있어야 하기 때문에 Gradle에 SonarQube까지 연동해 줘야 한다.

 

 

CI 서버 선택

빌드를 하고 테스트 커버리지를 계산하여 SonarQube에 넘겨 이에 대한 기록을 남기기 위해서 CI서버가 필요하다. 아래는 다양한 CI 솔루션을 비교한 자료이다.

 

https://munokkim.medium.com/ci-%EC%84%9C%EB%B2%84-%EC%86%94%EB%A3%A8%EC%85%98-%EB%B9%84%EA%B5%90%ED%95%98%EA%B8%B0-ca5ef36ce793

 

현재 필자는 클라우드 인스턴스나 CI서버를 새로 구할 수 없는 상황이며, 진행 중인 프로젝트는 Github에 public 레포지토리로 관리되기 때문에 Github Actions를 선택했다. Github Actions는 클라우드 기반으로 CI 서버를 제공하며 무료 버전의 경우 최대 20개까지 동시 작업을 추가할 수 있다.(단 macOS는 5개) 다음은 Github Actions 클라우드에서 제공하는 가상 머신의 스펙이다. (추후 변동 가능)

 

 

 

https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners#supported-runners-and-hardware-resources

 

 

 

 

 

 

1. 빌드툴에 Jacoco 추가

1) build. gradle에 Jacoco 플러그인 추가

plugins {
    id 'jacoco'
}

 

Gradle Task 목록 확인

 

 

* Task 설명

1) jacocoTestReport

테스트 커버리지 결과를 원하는 형태의 리포트로 생성해주는 Task이다. 분석 리포트 수행 시 생성되는 파일 종류(xml, html, csv 등)를 설정할 수 있으며, build 디렉토리에 생성되는 파일을 원하는 위치로 가져올 수 있다.

 

2) jacocoTestCoverageVerification

jacocoTestReport에서 분석된 코드 커버리지가 미리 설정한 기준치에 충족하는지 여부를 확인하는 Task이다. 참고로 limit 구문에 들어갈 수 있는 Key 값과 Value 값을 통해 다양하게 설정 가능하다. (공식문서 참고)

** 테스트들은 모두 통과하더라도, violationRules에 정한 기준치를 통과하지 못하면 결국 jacocoTestCoverageVerification Task가 실패해서 빌드도 실패라고 결과가 뜨니 Rule을 잘 설정할 것.

 

 

 

2) 추가한 Jacoco 플러그인 설정 (디폴트 설정 사용시 Skip)

jacoco {
    toolVersion = '0.8.7' 
    // reportsDir = ${project.reporting.baseDir}/jacoco - 따로 설정해 주지 않을 경우 기본 경로
}

 



 

참고) 롬복 어노테이션 테스트 커버리지 대상에서 제외하기

0.8.0 버전부터 lombok 사용으로 생기는 @Getter, @Builder 등을 테스트 커버리지에서 제외시키기 위한 설정을 적용할 수 있다. lombok.config 파일을 gradlew가 위치한 경로(프로젝트 root경로)에 생성하고 다음과 같이 작성한다.

lombok.addLombokGeneratedAnnotation = true

 

 

 

3) jacocoTestReport 설정

jacocoTestReport {
    reports {
        html.enabled true // 로컬에서 확인용으로 html 리포트 파일 생성
        html.destination file("$buildDir/reports/test/jacocoTestReportHtml")

        xml.enabled true  // SonarCloud로 전송하기 위해 XML 리포트 생성
        xml.destination file("$buildDir/reports/test/jacocoTestReport.xml")

        csv.enabled false // csv는 생성하지 않음
    }
}

 

* Sonar와 연동하기 위해서 XML 파일을 만들어야 한다.

 

* destination 설정을 하지 않은 경우,

- html은 build/reports/jacoco/test/html/index.html에 생성된다.

- xml은 build/reports/jacoco/test/jacocoTestReport.xml에 생성된다.

 

4) 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
            }

            // 커버리지 체크를 제외할 클래스들
            excludes = [
                    '**.*Application*',
                    '**.*Request*',
                    '**.*Response*',
                    '**.*OAuthClient*',
                    '**.*Interceptor*',
                    '**.*Exception*',
            ] + Qdomains
        }
    }
}

Jacoco를 사용하면 테스트 커버리지에 적용할 Rule을 정의할 수 있다. 테스트들은 모두 통과하더라도, violationRules에 정한 기준치를 통과하지 못하면 결국 jacocoTestCoverageVerification Task가 실패해서 빌드도 실패라고 결과가 뜨니 Rule을 현실적으로 잘 타협 후 설정해야 한다.

 

 

 

참고) afterEvaluate와 excludes옵션

afterEvaluate 옵션은 분석 리포트 생성 시 특정 파일들을 제외시킨다. 만약 QueryDSL을 사용하는 경우 추가적으로 생성되는 QClass 파일들을 분석 리포트 생성 및 커버리지 조건 검사에서 제외시켜야 한다. 따라서 jacocoTestCoverageVerification의 excludes옵션과 jacocoTestReport의 afterEvaluate 옵션을 사용하여 리포트 파일 및 커버리지 계산에서 제외시킨다.

 

- jacocoTestCoverageVerification의 excludes옵션: 테스트 커버리지 조건 충족 여부에서 제외시킴

- jacocoTestReport의 afterEvaluate 옵션: 분석 리포트 생성 시 제외시킬 파일 지정

 

 

 

5) 실행 순서 결정

test {
    ...
    finalizedBy 'jacocoTestReport'
}

 

jacocoTestReport {
    ...
    
    // 코드 커버리지 기준을 만족해야지 build 성공
    finalizedBy 'jacocoTestCoverageVerification'
}

 

위와 같이 finalizedBy 키워드를 활용해서 실행 순서를 결정할 수 있다.  설정한 순서는 test -> jacocoTestReport -> jacocoTestCoverageVerification이다.

 

 

위와 같이 Jacoco 설정이 완료되면 테스트 빌드 후 지정한 경로에 html 또는 xml 리포트가 생성된 것을 확인할 수 있다.

 

 

 

2. SonarCloud 연동으로 CI-based analysis 구축하기

Jacoco는 테스트 커버리지만 계산할 뿐 코드 품질을 검사하는 기능은 없다. Jacoco의 테스트 결과를 SonarQube와 연동시키면 다음과 같은 기능을 활용할 수 있다.

 

- 테스트 커버리지를 지속적으로 기록하고 관리할 수 있다.

- 테스트 커버리지를 로컬뿐만이 아니라 온라인 상에서 대시보드 형태로 팀원과 공유할 수 있다.

- 등등..

 

아래는 Jacoco의 테스트 결과 리포트를 SonarQube에게 제공하기 위해서 Gradle에 SonarQube를 연동해주는 과정이다. 주요 과정은 Jacoco의 테스트 리포트 결과를 SonarScanner에게 전달하고 SonarScanner가 SonarCloud로 결과를 전달하는 절차를 따른다.

 

지금 설정해야 할 과정은 크게 2가지이다.

1) Jacoco 리포트 생성 단계가 SonarScanner 단계 '전에' 실행되도록 빌드 프로세스를 조정하기

2) SonarScanner가 정의된 경로에서 리포트 파일을 선택하도록 빌드의 스캔 단계를 구성하기

 

cf. 위 절차는 다음과 같이 SonarCloud 대시보드에서 왼쪽 하나의 Administration -> Analysis Method -> Github Actions의 Follow the tutorial을 참고했다.

 

1) build. gradle에 SonarQube 플러그인 추가

plugins {
    id "org.sonarqube" version "3.3"
}

SonarQube 플러그인을 설치했지만 우리는 그 안에 있는 SonarScanner를 사용할 것이다.

 

참고) SonarScanner는 XML 리포트 파일을 읽는다. 따라서 Jacoco가 XML 형태의 리포트를 생성할 수 있도록 설정했는지 다시 한번 확인하자.

jacocoTestReport {
    reports {
        html.enabled true // 로컬에서 확인용으로 html 리포트 파일 생성
        html.destination file("$buildDir/reports/test/jacocoTestReport.html")

        xml.enabled true  // SonarCloud로 전송하기 위해 XML 리포트 생성
        xml.destination file("$buildDir/reports/test/jacocoTestReport.xml")

        csv.enabled false // csv는 생성하지 않음
    }
    
    . . .
}

 

* 리포트 저장 경로를 설정하지 않으면 기본적으로 build/reports/jacoco 디렉토리에 생성됨

 

 

 

2) SonarQube 프로퍼티 설정

sonarqube {
    properties {
        property "sonar.projectKey", "price-offer_offer-be"
        property "sonar.organization", "price-offer-sonar-cloud-key"
        property "sonar.host.url", "https://sonarcloud.io"
        property 'sonar.coverage.jacoco.xmlReportPaths', "$buildDir/reports/test/jacocoTestReport.xml"
    }
}

위와 같이 Jacoco가 생성한 리포트 파일을 SonarScanner가 읽어갈 수 있도록 프로퍼티를 지정해준다. 만약 Gradle이 아니라 Maven을 사용한다면 해당 Sonar 프로퍼티 내용은 build.gradle이 아닌 .sonarcloud.properties에 등록해줘도 무방하다.

 

 

참고) SonarCloud의 Automatic Analysis 설정은 테스트 커버리지에 대한 정보를 제공하지 않는다. 따라서 해당 설정은 아래와 같이 끄고, CI-based analysis를 기반으로 정적분석과 테스트 커버리지 연동까지 함께 사용하도록 한다.

 

 

 

3) CI-Based Analysis 스크립트 파일 작성

* GitHub Actions를 기반으로 작성한 스크립트이다.

name: Sonar Cloud
on:
  push:
    branches: [main, dev]
  pull_request:
    types: [opened, synchronize, reopened]
    
jobs:
  build:
    name: Sonar Analyze
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
        with:
          fetch-depth: 0  # Shallow clones should be disabled for a better relevancy of analysis
      - name: Set up JDK 11
        uses: actions/setup-java@v1
        with:
          java-version: 11
      - name: Cache SonarCloud packages
        uses: actions/cache@v1
        with:
          path: ~/.sonar/cache
          key: ${{ runner.os }}-sonar
          restore-keys: ${{ runner.os }}-sonar
      - name: Cache Gradle packages
        uses: actions/cache@v1
        with:
          path: ~/.gradle/caches
          key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }}
          restore-keys: ${{ runner.os }}-gradle
      - name: Build and analyze
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}  # Needed to get PR information, if any
          SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
        run: ./gradlew build sonarqube --info

 

4) SONAR_TOKEN 생성 및 등록

GitHub Action이 SonarCloud에 접근할 수 있도록 토큰을 생성한다. 토큰 등록 링크(https://sonarcloud.io/account/security/)에 접속해서 생성할 토큰 이름을 입력하고 토큰을 생성한다. 그리고 GitHub 레포지토리 설정에서 Secrets -> Actions에 발급받은 토큰을 등록한다.

 

 

모든 설정이 완료된 후 PR을 보내면 sonar cloud bot이 현재 PR의 정적분석 및 커버리지, 중복검사 등을 진행한 후 알려준다. SonarCloud 대시보드에서도 아래와 같이 확인 가능하다.

 

 

 

 

전체적인 Flow

 

지금까지 구축한 CI-based analysis의 전체적인 흐름은 위와 같다. 개발자가 로컬 환경에서 작업한 뒤 원격 저장소로 Push 하면 해당 코드가 CI 서버인 Github Actions로 넘어가고 CI 서버에서는 애플리케이션을 빌드(테스팅 과정 포함) 한 후 테스트 커버리지 결과 리포트를 만든다. 만들어진 리포트는 SonarCloud로 전송되며 코드의 정적분석과 테스트 커버리지에 대한 지속적인 관리와 기록을 수행한다.

 

 

 

 

 

Reference

- SonarCloud + Github Action Sample: https://github.com/SonarSource/sonarcloud-github-action-samples

https://docs.sonarqube.org/latest/analysis/scan/sonarscanner-for-gradle/

- https://docs.sonarcloud.io/appendices/scanner-environment/

- https://docs.sonarcloud.io/advanced-setup/ci-based-analysis/sonarscanner-for-gradle/

- Jacoco+Sonar 연동, https://velog.io/@max9106/정적-분석-with-Jacoco-SonarQube

- Jacoco+Sonar+QueryDSL 연동, https://xlffm3.github.io/devops/jacoco-sonarcube/

- https://www.baeldung.com/sonarqube-jacoco-code-coverage

- GitHub Actions, https://docs.github.com/en/actions/learn-github-actions/understanding-github-actions

- Jacoco+Sonar, https://docs.sonarcloud.io/enriching/test-coverage/java-test-coverage/

- Gradle+Jacoco Docs, https://docs.gradle.org/current/userguide/jacoco_plugin.html

- JaCoCo Rule 작성법 블로그, https://seller-lee.github.io/java-code-coverage-tool-part2