클라우드 CI 과정 구체화 - 100-hours-a-week/16-Hot6-wiki GitHub Wiki
CI/CD 환경에서 테스트는 필수지만, 테스트 결과를 보기 좋게 정리해서 확인하는 과정은 종종 놓치기 쉽다. 그래서 GitHub Actions에서 Java(Spring Boot) 기반 프로젝트의 테스트 결과를 HTML 리포트로 출력하고, 린트 도구까지 자동화해보았다.
Spring Boot에서 ./gradlew test
를 실행하면 Gradle은 기본적으로 build/reports/tests/test/index.html
에 HTML 형식의 리포트를 생성한다.
이걸 GitHub Actions 워크플로우 내에서 **아티팩트(artifact)**로 업로드하면, CI 결과로 바로 다운받아 확인할 수 있다.
build/
└── reports/
└── tests/
└── test/
├── index.html <- 요약 페이지
├── css/ <- 스타일 시트
├── classes/ <- 클래스별 HTML 리포트
참고: 클래스별 HTML을 모두 올리면 너무 많은 파일이 생기므로
index.html
과css/
만 올리는 걸 추천한다.
- name: Run tests
run: ./gradlew test
- name: Upload test report HTML
if: always()
uses: actions/upload-artifact@v4
with:
name: test-report-html
path: |
build/reports/tests/test/index.html
build/reports/tests/test/css/
-
if: always()
를 설정하면 테스트가 실패하더라도 리포트는 업로드된다. - GitHub Actions의 실행 결과 페이지 하단에서 "Artifacts"로 확인 가능.
기능 테스트는 코드가 **"정상적으로 동작하는지"**를 확인하지만, Lint는 **"코드 스타일이나 잠재적인 오류"**를 사전에 잡아주는 정적 분석 도구다.
도구 | 역할 |
---|---|
Checkstyle | 코드 스타일 검사 (들여쓰기, 네이밍 등) |
plugins {
id 'java'
id 'org.springframework.boot' version '3.4.5'
id 'io.spring.dependency-management' version '1.1.7'
id 'checkstyle'
}
group = 'com.kakaotech.ott'
version = '0.0.1-SNAPSHOT'
compileJava.options.encoding = 'UTF-8'
compileTestJava.options.encoding = 'UTF-8'
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
tasks.withType(Checkstyle) {
reports {
xml.required = true
html.required = true
}
}
checkstyle {
configFile = file("checkstyle/naver-checkstyle-rules.xml")
configProperties = ["suppressionFile": "checkstyle/naver-checkstyle-suppressions.xml"]
}
}
<?xml version="1.0"?>
<!DOCTYPE module PUBLIC
"-//Puppy Crawl//DTD Check Configuration 1.3//EN"
"http://www.puppycrawl.com/dtds/configuration_1_3.dtd">
<!-- Naver coding convention for Java (version 1.2) -->
<!-- This rule file requires Checkstyle version 8.24 or above. -->
<!--
The following rules in the Naver coding convention cannot be checked by this configuration file.
- [avoid-korean-pronounce]
- [class-noun]
- [interface-noun-adj]
- [method-verb-preposition]
- [space-after-bracket]
- [space-around-comment]
-->
<module name = "Checker">
<property name="severity" value="warning"/>
<property name="fileExtensions" value="java"/>
<!-- [encoding-utf8] -->
<property name="charset" value="UTF-8"/>
<!-- [newline-lf] -->
<module name="RegexpMultiline">
<property name="format" value="\r\n"/>
<property name="message" value="[newline-lf] Line must end with LF, not CRLF"/>
</module>
<!-- [newline-eof] -->
<module name="NewlineAtEndOfFile">
<property name="lineSeparator" value="lf"/>
</module>
<!-- [no-trailing-spaces] -->
<module name="RegexpSingleline">
<property name="format" value="^(?!\s+\* $).*?\s+$"/>
<property name="message" value="[no-trailing-spaces] Line has trailing spaces."/>
</module>
<!-- [line-length-120] -->
<property name="tabWidth" value="4"/>
<module name="LineLength">
<property name="max" value="120"/>
<property name="ignorePattern" value="^package.*|^import.*|a href|href|http://|https://|ftp://"/>
<message key="maxLineLen"
value="[line-length-120] Line is longer than {0,number,integer} characters (found {1,number,integer})"/>
</module>
<module name="TreeWalker">
<!-- Start of Naming chapter -->
<!-- [list-uppercase-abbr] -->
<module name="AbbreviationAsWordInName">
<property name="ignoreFinal" value="false"/>
<property name="allowedAbbreviationLength" value="1"/>
<message key="abbreviation.as.word"
value="[list-uppercase-abbr] Abbreviation in name ''{0}'' must contain no more than {1}"/>
<property name="allowedAbbreviations" value="DAO,BO"/>
</module>
<!-- [package-lowercase] -->
<module name="PackageName">
<property name="format" value="^[a-z]+(\.[a-z][a-z0-9]*)*$"/>
<message key="name.invalidPattern"
value="[package-lowercase] Package name ''{0}'' must match pattern ''{1}''."/>
</module>
<!-- [class-interface-lower-camelcase] -->
<module name="TypeName">
<message key="name.invalidPattern"
value="[class-interface-lower-camelcase] Type name ''{0}'' must match pattern ''{1}''."/>
</module>
<!-- [method-lower-camelcase] -->
<module name="MethodName">
<property name="format" value="^[a-z][a-z0-9][a-zA-Z0-9_]*$"/>
<message key="name.invalidPattern"
value="[method-lower-camelcase] Method name ''{0}'' must match pattern ''{1}''."/>
</module>
<!-- [var-lower-camelcase] -->
<module name="MemberName">
<property name="format" value="^[a-z][a-zA-Z0-9][a-zA-Z0-9]*$"/>
<message key="name.invalidPattern"
value="[var-lower-camelcase] Member name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="ParameterName">
<property name="format" value="^[a-z][a-zA-Z0-9][a-zA-Z0-9]*$"/>
<message key="name.invalidPattern"
value="[var-lower-camelcase] Parameter name ''{0}'' must match pattern ''{1}''."/>
</module>
<!-- [var-lower-camelcase], [avoid-1-char-var] -->
<module name="LocalVariableName">
<property name="tokens" value="VARIABLE_DEF"/>
<property name="format" value="^[a-z][a-zA-Z0-9][a-zA-Z0-9]*$"/>
<property name="allowOneCharVarInForLoop" value="true"/>
<message key="name.invalidPattern"
value="[var-lower-camelcase][avoid-1-char-var] Local variable name ''{0}'' must match pattern ''{1}''."/>
</module>
<!-- End of Naming chapter -->
<!-- Start of Declarations chapter -->
<!-- [1-top-level-class] -->
<module name="OneTopLevelClass"/>
<message key="name.invalidPattern"
value="[1-top-level-class] one.top.level.class=Top-level class {0} has to reside in its own source file."/>
<!-- [avoid-star-import] -->
<module name="AvoidStarImport">
<property name="allowStaticMemberImports" value="true"/>
<message key="import.avoidStar"
value="[avoid-star-import] Using the ''.*'' form of import should be avoided - {0}."/>
</module>
<!-- [modifier-order] -->
<module name="ModifierOrder">
<message key="mod.order"
value="[modifier-order] ''{0}'' modifier out of order with the JLS suggestions."/>
<message key="annotation.order"
value="[modifier-order] ''{0}'' annotation modifier does not precede non-annotation modifiers."/>
</module>
<!-- [newline-after-annotation] -->
<module name="AnnotationLocation">
<property name="tokens" value="CLASS_DEF, INTERFACE_DEF, ENUM_DEF, METHOD_DEF, CTOR_DEF"/>
<property name="allowSamelineSingleParameterlessAnnotation" value="true"/>
<message key="annotation.location.alone"
value="[newline-after-annotation] Annotation ''{0}'' should be alone on line."/>
</module>
<!-- [1-state-per-line] -->
<module name="OneStatementPerLine">
<message key="needBraces"
value="[1-state-per-line] Only one statement per line allowed."/>
</module>
<!-- [1-var-per-declaration] -->
<module name="MultipleVariableDeclarations">
<message key="multiple.variable.declarations"
value="[1-var-per-declaration] Only one variable definition per line allowed."/>
<message key="multiple.variable.declarations.comma"
value="[1-var-per-declaration] Each variable declaration must be in its own statement."/>
</module>
<!-- [array-square-after-type] -->
<module name="ArrayTypeStyle">
<message key="array.type.style"
value="[array-square-after-type] Array brackets at illegal position."/>
</module>
<!-- [long-value-suffix] -->
<module name="UpperEll">
<message key="upperEll" value="[long-value-suffix] Should use uppercase ''L''."/>
</module>
<!-- [special-escape] -->
<module name="IllegalTokenText">
<property name="tokens" value="STRING_LITERAL, CHAR_LITERAL"/>
<property name="format" value="\\u00(08|09|0(a|A)|0(c|C)|0(d|D)|22|27|5(C|c))|\\(0(10|11|12|14|15|42|47)|134)"/>
<property name="message" value="[array-square-after-type] Avoid using corresponding octal or Unicode escape."/>
</module>
<!-- End of Declarations chapter -->
<!-- Start of Indentation chapter -->
<!-- [4-spaces-tab] -->
<property name="tabWidth" value="4"/>
<!-- [indentation-tab] -->
<module name="RegexpSinglelineJava">
<property name="format" value="^\t* "/>
<property name="message" value="[indentation-tab] Indent must use tab characters"/>
<property name="ignoreComments" value="true"/>
</module>
<!-- End of Indentation chapter -->
<!-- Start of Braces chapter -->
<!-- [braces-knr-style]-->
<module name="LeftCurly">
<message key="line.break.after"
value="[braces-knr-style] ''{0}'' at column {1} should have line break after."/>
<message key="line.new"
value="[braces-knr-style] ''{0}'' at column {1} should be on a new line."/>
<message key="line.previous"
value="[braces-knr-style] ''{0}'' at column {1} should be on the previous line."/>
</module>
<module name="RightCurly">
<property name="option" value="alone"/>
<property name="tokens" value="CLASS_DEF, METHOD_DEF, CTOR_DEF, LITERAL_FOR, LITERAL_WHILE, STATIC_INIT, INSTANCE_INIT"/>
<message key="line.alone"
value="[braces-knr-style] ''{0}'' at column {1} should be alone on a line."/>
<message key="line.break.before"
value="[braces-knr-style] =''{0}'' at column {1} should have line break before."/>
<message key="line.new"
value="[braces-knr-style] ''{0}'' at column {1} should be on a new line."/>
</module>
<!-- [sub-flow-after-brace] -->
<module name="RightCurly">
<property name="option" value="same"/>
<property name="tokens" value="LITERAL_TRY, LITERAL_CATCH, LITERAL_FINALLY, LITERAL_IF, LITERAL_ELSE, LITERAL_DO"/>
<message key="line.same"
value="[sub-flow-after-brace] ''{0}'' at column {1} should be on the same line as the next part of a multi-block statement (one that directly contains multiple blocks: if/else-if/else or try/catch/finally)."/>
<message key="line.break.before"
value="[sub-flow-after-brace] ''{0}'' at column {1} should have line break before."/>
</module>
<!-- [need-braces] -->
<module name="NeedBraces">
<message key="needBraces"
value="[need-braces] ''{0}'' statement must use '''{}'''s."/>
</module>
<!-- End of Braces chapter -->
<!-- Start of Line-wrapping chapter -->
<!-- [1-line-package-import]-->
<module name="NoLineWrap">
<property name="tokens" value="PACKAGE_DEF, IMPORT"/>
<message key="no.line.wrap"
value="[1-line-package-import] {0} statement should not be line-wrapped."/>
</module>
<!-- [block-indentation][indentation-after-line-wrapping] -->
<!-- checkstyle 6.16 이상을 써야지 탭을 쓸때의 인덴트 체크가 제대로 동작함 -->
<module name="Indentation">
<property name="basicOffset" value="4"/>
<property name="braceAdjustment" value="0"/>
<property name="caseIndent" value="4"/>
<property name="throwsIndent" value="4"/>
<property name="lineWrappingIndentation" value="4"/>
<property name="arrayInitIndent" value="4"/>
</module>
<!-- [line-wrapping-position] -->
<module name="SeparatorWrap">
<property name="tokens" value="COMMA"/>
<property name="option" value="EOL"/>
<message key="line.previous"
value="[line-wrapping-position] ''{0}'' should be on the previous line."/>
</module>
<module name="SeparatorWrap">
<property name="tokens" value="DOT"/>
<property name="option" value="NL"/>
<message key="line.new"
value="[line-wrapping-position] ''{0}'' should be on a new line."/>
</module>
<module name="OperatorWrap">
<property name="option" value="NL"/>
<property name="tokens" value="BAND, BOR, BSR, BXOR, DIV, EQUAL, GE, GT, LAND, LE, LITERAL_INSTANCEOF, LOR, LT, MINUS, MOD, NOT_EQUAL, PLUS, QUESTION, SL, SR, STAR "/>
<message key="line.new"
value="[line-wrapping-position] ''{0}'' should be on a new line."/>
</module>
<!-- End of Line-wrapping chapter -->
<!-- Start of Blank lines chapter -->
<!-- [blankline-after-package] -->
<!--
This module always requires an empty line between header and package.
But it is not forced in the Naver guide.
See https://github.com/checkstyle/checkstyle/issues/1035
<module name="EmptyLineSeparator">
<property name="tokens" value="PACKAGE_DEF"/>
<message key="empty.line.separator"
value="[blankline-after-package] ''{0}'' should be separated from previous statement."/>
</module>
-->
<!-- [import-grouping] -->
<module name="ImportOrder">
<property name="groups" value="java., javax., org., net., /com\.(?!nhncorp|navercorp|naver)/, /(?!java\.|javax\.|com\.|org\.|net\.)/, com.nhncorp., com.navercorp., com.naver."/>
<property name="ordered" value="true"/>
<property name="separated" value="true"/>
<property name="option" value="top"/>
<property name="sortStaticImportsAlphabetically" value="true"/>
<message key="import.groups.separated.internally"
value="[import-grouping] Extra separation in import group before ''{0}''"/>
<message key="import.ordering"
value="[import-grouping] Wrong order for ''{0}'' import."/>
<message key="import.separation"
value="[import-grouping] ''{0}'' should be separated from previous imports."/>
</module>
<!-- [blankline-between-methods] -->
<module name="EmptyLineSeparator">
<property name="tokens" value="METHOD_DEF"/>
<message key="empty.line.separator"
value="[blankline-between-methods] ''{0}'' should be separated from previous statement."/>
</module>
<!-- End of Blank lines chapter -->
<!-- Start of Whitespace chapter -->
<!-- [space-around-brace] -->
<module name="WhitespaceAround">
<property name="allowEmptyConstructors" value="true"/> <!-- [permit-concise-empty-block] -->
<property name="allowEmptyMethods" value="true"/> <!-- [permit-concise-empty-block] -->
<property name="allowEmptyLoops" value="true"/> <!-- [permit-concise-empty-block] -->
<property name="tokens" value="LCURLY, RCURLY, SLIST"/>
<message key="ws.notFollowed"
value="[space-around-brace] ''{0}'' is not followed by whitespace."/>
<message key="ws.notPreceded"
value="[space-around-brace] ''{0}'' is not preceded with whitespace."/>
</module>
<!-- [space-between-keyword-parentheses] -->
<module name="WhitespaceAround">
<property name="tokens" value="
DO_WHILE, LITERAL_ASSERT, LITERAL_CATCH, LITERAL_DO, LITERAL_ELSE, LITERAL_FINALLY, LITERAL_FOR, LITERAL_IF, LITERAL_RETURN,
LITERAL_SWITCH, LITERAL_SYNCHRONIZED, LITERAL_TRY, LITERAL_WHILE,"
/>
<message key="ws.notFollowed"
value="[space-between-keyword-parentheses] ''{0}'' is not followed by whitespace."/>
<message key="ws.notPreceded"
value="[space-between-keyword-parentheses] ''{0}'' is not preceded with whitespace."/>
</module>
<!-- [no-space-between-identifier-parentheses] -->
<module name="MethodParamPad">
<message key="line.previous"
value="[no-space-between-identifier-parentheses] ''{0}'' should be on the previous line."/>
<message key="ws.preceded"
value="[no-space-between-identifier-parentheses] ''{0}'' is preceded with whitespace."/>
</module>
<!-- [no-space-typecasting] -->
<module name="TypecastParenPad">
<property name="tokens" value="RPAREN,TYPECAST"/>
<message key="ws.followed"
value="[no-space-typecasting] ''{0}'' is followed by whitespace."/>
<message key="ws.preceded"
value="[no-space-typecasting] ''{0}'' is preceded with whitespace."/>
</module>
<!-- [generic-whitespace] -->
<module name="GenericWhitespace">
<message key="ws.followed"
value="[generic-whitespace] ''{0}'' is followed by whitespace."/>
<message key="ws.preceded"
value="[generic-whitespace] ''{0}'' is preceded with whitespace."/>
<message key="ws.illegalFollow"
value="[generic-whitespace] ''{0}'' should followed by whitespace."/>
<message key="ws.notPreceded"
value="[generic-whitespace] ''{0}'' is not preceded with whitespace."/>
</module>
<!-- [space-after-comma-semicolon] -->
<module name="WhitespaceAfter">
<property name="tokens" value="COMMA,SEMI"/>
<message key="ws.notFollowed"
value="[space-after-comma-semicolon]: ''{0}'' is not followed by whitespace."/>
</module>
<module name="NoWhitespaceBefore">
<property name="tokens" value="COMMA,SEMI"/>
<message key="ws.preceded"
value="[space-after-comma-semicolon] ''{0}'' is preceded with whitespace."/>
</module>
<!-- [space-around-colon] -->
<module name="WhitespaceAround">
<property name="tokens" value="COLON"/>
<property name="ignoreEnhancedForColon" value="false"/>
<message key="ws.notFollowed"
value="[space-around-colon] ''{0}'' is not followed by whitespace."/>
<message key="ws.notPreceded"
value="[space-around-colon] ''{0}'' is not preceded with whitespace."/>
</module>
<!-- [no-space-unary-operator] -->
<module name="NoWhitespaceBefore">
<property name="tokens" value="POST_INC, POST_DEC"/>
<message key="ws.preceded"
value="[no-space-unary-operator] ''{0}'' is preceded with whitespace."/>
</module>
<module name="NoWhitespaceAfter">
<property name="tokens" value="INC, DEC, UNARY_MINUS, UNARY_PLUS, BNOT, LNOT"/>
<message key="ws.followed"
value="[no-space-unary-operator] whitespace ''{0}'' is followed by whitespace."/>
</module>
<!-- [space-around-binary-ternary-operator] -->
<module name="WhitespaceAround">
<property name="tokens" value="ASSIGN, BAND, BAND_ASSIGN, BOR, BOR_ASSIGN, BSR, BSR_ASSIGN, BXOR, BXOR_ASSIGN, DIV,
DIV_ASSIGN, EQUAL, GE, GT, LAND, LE, LOR, LT, MINUS, MINUS_ASSIGN, MOD, MOD_ASSIGN, NOT_EQUAL,
PLUS, PLUS_ASSIGN, QUESTION, SL, SL_ASSIGN, SR, SR_ASSIGN, STAR, STAR_ASSIGN, TYPE_EXTENSION_AND"/>
<message key="ws.notFollowed"
value="[space-around-binary-ternary-operator] ''{0}'' is not followed by whitespace."/>
<message key="ws.notPreceded"
value="[space-around-binary-ternary-operator] ''{0}'' is not preceded with whitespace."/>
</module>
<!-- End of Whitespace chapter -->
<!--- Suppression -->
<module name="SuppressionCommentFilter">
<property name="offCommentFormat" value="@checkstyle:off"/>
<property name="onCommentFormat" value="@checkstyle:on"/>
</module>
<module name="SuppressWithNearbyCommentFilter">
<property name="commentFormat" value="@checkstyle:ignore"/>
<property name="checkFormat" value=".*"/>
<property name="influenceFormat" value="0"/>
</module>
</module>
<!--- Suppression -->
<module name="SuppressionFilter">
<property name="file" value="${suppressionFile}"/>
<property name="optional" value="true"/>
</module>
<!--- Exclude module-info.java -->
<module name="BeforeExecutionExclusionFileFilter">
<property name="fileNamePattern" value="module\-info\.java$"/>
</module>
</module>
<?xml version="1.0"?>
<!DOCTYPE suppressions PUBLIC
"-//Puppy Crawl//DTD Suppressions 1.1//EN"
"http://www.puppycrawl.com/dtds/suppressions_1_1.dtd">
<suppressions>
</suppressions>
./gradlew checkstyleMain # 스타일 검사
./gradlew pmdMain # 코드 규칙 위반 검사
./gradlew spotbugsMain # 버그 가능성 정적 분석
./gradlew check # 위 세 가지 + 테스트 통합 실행
./gradlew check
하나로 테스트와 린트를 한꺼번에 실행할 수 있어 매우 편리하다.
- name: Run lint check
run: ./gradlew check
이렇게 설정하면 test
와 린트 검사까지 한 번에 돌릴 수 있다.
테스트와 린트를 CI에 자연스럽게 통합하면,
- 코드 품질 유지
- 디버깅 효율 향상
- 협업 시 신뢰도 확보
라는 세 마리 토끼를 잡을 수 있다. CI 환경에 작은 자동화를 더하는 것만으로도 팀 전체의 개발 경험이 한 단계 업그레이드될 수 있다.
기존 CI 설정에서는 테스트와 린트를 한 번에 실행하는 ./gradlew check
를 사용하고 있었다.
그런데 두 가지 문제가 있었다:
- 린트 오류가 있을 경우 테스트까지 실행되며 불필요한 시간 낭비 발생
- 실패 원인을 한눈에 파악하기 어려워 디버깅이 불편
그래서 테스트와 린트를 명확히 분리하고, 각각의 결과 리포트를 업로드하는 구조로 개선했다.
✅ GitHub Actions 워크플로우에서:
-
Checkstyle 린트:
checkstyleMain
,checkstyleTest
로 코드 스타일 검사 -
테스트 실행:
./gradlew test
로 분리 실행 -
리포트 업로드:
- 테스트 결과:
index.html
기준 HTML 리포트 업로드 - 린트 결과:
build/reports/checkstyle/
내 HTML 리포트 업로드
- 테스트 결과:
-
gradlew
에 실행 권한 부여 (macOS 로컬 실행 시 필요)
# 1. Checkstyle 린트
- name: Run Checkstyle
run: ./gradlew checkstyleMain checkstyleTest
# 2. Checkstyle 리포트 업로드
- name: Upload checkstyle report
if: always()
uses: actions/upload-artifact@v4
with:
name: checkstyle-report
path: build/reports/checkstyle/
# 3. 테스트 실행
- name: Run tests
run: ./gradlew test
# 4. 테스트 리포트 업로드
- name: Upload test report
if: always()
uses: actions/upload-artifact@v4
with:
name: test-report-html
path: |
build/reports/tests/test/index.html
build/reports/tests/test/css/
-
checkstyle
에서 warning만 있어도 리포트가 생성되지만, 빌드 실패는 error일 때만 발생 - 초기 도입 시
severity="error"
로 설정을 바꾸지 않으면 CI는 통과하므로, 필요 시 강화할 수 있음 - 테스트 리포트는 GitHub Actions에서 HTML 다운로드로 바로 확인 가능 → 슬랙 연동 없이도 확인 쉬움