무엇이든 직접 해봐야 이해가 될 것이라 생각이 든다. 이제 CI 를 직접 구축해보려 한다.
여기서는 아래 내용들을 순차적으로 다뤄볼 예정이다.
먼저 예시에 언급되었던 명령어인 ./gradlew build
의 한계를 분석해보려 한다.
맨 처음에 CI 사용 목적에 대해 간단히 이야기했을 때 아래 3가지를 이야기했었다.
- 배포할 브랜치에서 프로젝트 빌드가 잘 되는지,
- 컨벤션 및 코드스타일 규칙을 지켰는지
- 로직 오류 검증을 위한 테스트 코드를 실행하고 결과를 확인해주는지
1~3번의 경우 어떤 명령어들을 활용할 수 있는지, 내가 했던 경험에 기반하여 간단한 예시코드도 언급하려 한다.
2번의 경우 추가로 코드스타일 관련 도구들
도 짤막하게 언급할 예정이다.
우리는 이전에 ./gradlew build
를 통해 빌드가 잘 되는지를 간접적으로 확인할 수 있었다.
실제 android sdk version 으로 인해 빌드 실패를 확인 후 수정하여 프로젝트의 영속성을 유지시킬 수 있었다.
그런데 이 명령어에 대해 자세하게 알 필요가 있다.
아래 내용을 통해 gradlew build
명령어에 대해 깊게 분석해보자.
먼저 뭘 실행하는지를 알아보자.
./gradlew build
를 실행하게 되면 아래의 task 들이 실행된다.
> Task :app:preBuild UP-TO-DATE
> Task :app:preDebugBuild UP-TO-DATE
> Task :app:compileDebugAidl NO-SOURCE
> Task :app:compileDebugRenderscript NO-SOURCE
> Task :app:generateDebugBuildConfig UP-TO-DATE
> Task :app:checkDebugAarMetadata UP-TO-DATE
> Task :app:generateDebugResValues UP-TO-DATE
> Task :app:generateDebugResources UP-TO-DATE
> Task :app:mergeDebugResources UP-TO-DATE
> Task :app:createDebugCompatibleScreenManifests UP-TO-DATE
> Task :app:extractDeepLinksDebug UP-TO-DATE
> Task :app:processDebugMainManifest UP-TO-DATE
> Task :app:processDebugManifest UP-TO-DATE
> Task :app:processDebugManifestForPackage UP-TO-DATE
> Task :app:processDebugResources UP-TO-DATE
> Task :app:compileDebugKotlin UP-TO-DATE
> Task :app:javaPreCompileDebug UP-TO-DATE
> Task :app:compileDebugJavaWithJavac UP-TO-DATE
> Task :app:compileDebugSources UP-TO-DATE
> Task :app:mergeDebugNativeDebugMetadata NO-SOURCE
> Task :app:mergeDebugShaders UP-TO-DATE
> Task :app:compileDebugShaders NO-SOURCE
> Task :app:generateDebugAssets UP-TO-DATE
> Task :app:mergeDebugAssets UP-TO-DATE
> Task :app:compressDebugAssets UP-TO-DATE
> Task :app:processDebugJavaRes NO-SOURCE
> Task :app:mergeDebugJavaResource UP-TO-DATE
> Task :app:checkDebugDuplicateClasses UP-TO-DATE
> Task :app:desugarDebugFileDependencies UP-TO-DATE
> Task :app:mergeExtDexDebug UP-TO-DATE
> Task :app:mergeLibDexDebug UP-TO-DATE
> Task :app:dexBuilderDebug
> Task :app:mergeProjectDexDebug UP-TO-DATE
> Task :app:mergeDebugJniLibFolders UP-TO-DATE
> Task :app:mergeDebugNativeLibs NO-SOURCE
> Task :app:stripDebugDebugSymbols NO-SOURCE
> Task :app:validateSigningDebug UP-TO-DATE
> Task :app:writeDebugAppMetadata UP-TO-DATE
> Task :app:writeDebugSigningConfigVersions UP-TO-DATE
> Task :app:preReleaseBuild UP-TO-DATE
> Task :app:compileReleaseAidl NO-SOURCE
> Task :app:compileReleaseRenderscript NO-SOURCE
> Task :app:generateReleaseBuildConfig UP-TO-DATE
> Task :app:checkReleaseAarMetadata UP-TO-DATE
> Task :app:generateReleaseResValues UP-TO-DATE
> Task :app:generateReleaseResources UP-TO-DATE
> Task :app:mergeReleaseResources UP-TO-DATE
> Task :app:createReleaseCompatibleScreenManifests UP-TO-DATE
> Task :app:extractDeepLinksRelease UP-TO-DATE
> Task :app:processReleaseMainManifest UP-TO-DATE
> Task :app:processReleaseManifest UP-TO-DATE
> Task :app:processReleaseManifestForPackage UP-TO-DATE
> Task :app:processReleaseResources UP-TO-DATE
> Task :app:compileReleaseKotlin UP-TO-DATE
> Task :app:javaPreCompileRelease UP-TO-DATE
> Task :app:writeReleaseApplicationId UP-TO-DATE
> Task :app:analyticsRecordingRelease
> Task :app:compileReleaseJavaWithJavac UP-TO-DATE
> Task :app:compileReleaseSources UP-TO-DATE
> Task :app:extractProguardFiles UP-TO-DATE
> Task :app:bundleReleaseClasses UP-TO-DATE
> Task :app:mergeReleaseJniLibFolders UP-TO-DATE
> Task :app:mergeReleaseNativeLibs NO-SOURCE
> Task :app:stripReleaseDebugSymbols NO-SOURCE
> Task :app:extractReleaseNativeSymbolTables NO-SOURCE
> Task :app:mergeReleaseNativeDebugMetadata NO-SOURCE
> Task :app:checkReleaseDuplicateClasses UP-TO-DATE
> Task :app:dexBuilderRelease
> Task :app:desugarReleaseFileDependencies UP-TO-DATE
> Task :app:mergeExtDexRelease UP-TO-DATE
> Task :app:mergeDexRelease UP-TO-DATE
> Task :app:mergeReleaseArtProfile UP-TO-DATE
> Task :app:compileReleaseArtProfile UP-TO-DATE
> Task :app:mergeReleaseShaders UP-TO-DATE
> Task :app:compileReleaseShaders NO-SOURCE
> Task :app:generateReleaseAssets UP-TO-DATE
> Task :app:mergeReleaseAssets UP-TO-DATE
> Task :app:compressReleaseAssets UP-TO-DATE
> Task :app:processReleaseJavaRes NO-SOURCE
> Task :app:mergeReleaseJavaResource UP-TO-DATE
> Task :app:optimizeReleaseResources UP-TO-DATE
> Task :app:collectReleaseDependencies UP-TO-DATE
> Task :app:sdkReleaseDependencyData UP-TO-DATE
> Task :app:writeReleaseAppMetadata UP-TO-DATE
> Task :app:writeReleaseSigningConfigVersions UP-TO-DATE
> Task :app:bundleDebugClasses UP-TO-DATE
> Task :app:packageDebug
> Task :app:assembleDebug
> Task :app:compileDebugUnitTestKotlin UP-TO-DATE
> Task :app:preDebugUnitTestBuild UP-TO-DATE
> Task :app:javaPreCompileDebugUnitTest UP-TO-DATE
> Task :app:compileDebugUnitTestJavaWithJavac NO-SOURCE
> Task :app:processDebugUnitTestJavaRes NO-SOURCE
> Task :app:lintVitalAnalyzeRelease
> Task :app:testDebugUnitTest
> Task :app:lintVitalRelease SKIPPED
> Task :app:compileReleaseUnitTestKotlin UP-TO-DATE
> Task :app:preReleaseUnitTestBuild UP-TO-DATE
> Task :app:javaPreCompileReleaseUnitTest UP-TO-DATE
> Task :app:compileReleaseUnitTestJavaWithJavac NO-SOURCE
> Task :app:processReleaseUnitTestJavaRes NO-SOURCE
> Task :app:testReleaseUnitTest
> Task :app:test
> Task :app:packageRelease
> Task :app:assembleRelease
> Task :app:assemble
> Task :app:lintAnalyzeDebug
정말 많지만
필요한 task 들(unitTest, assembleRelease 등)도 눈에 보인다.
그래도 너무 많기에 좀 더 자세하게 들여다보면 우리는 일부 규칙성을 찾을 수 있다.
variant
종류들인 debug 와 release 로 분류하여 확인해보자.
맨 위에는 variant 이름에 겹치지 않은 task 들을 적은 것이고
그 아래로 왼쪽엔 debug
, 오른쪽엔 release
키워드로 정리한 내용이다.
app:compile
를 블록처리한 부분을 기준으로 좌우 차이를 찾아보면 우리는 아래 내용을 알 수 있다.
build 명령어로 실행된 task 중 일부는, variant 만 다르고 명령어의 동작은 동일하구나.
다음은 실행 가능한 task 개수를 기준으로 분석해보자.
아래 내용을 참고해보면 gradlew build 명령어가 더 많은 task 를 실행하는 것을 확인할 수 있다.
정확한 연산은 힘들지만 26개
, 27개
에 비해
73개
라는 task 숫자가 보통 숫자는 아니라 느낄 수 있다.
위 내용들로 보아, 시간적 차원으로는 ./gradlew build
가 정답이 아님을 알 수 있다.
variant 개수
만큼 많은 task
들을 실행하기 때문에 그 만큼 많은 시간이 걸린다.
그리고 지금은 테스트 코드도 없고, 코드의 규모도 작기에 속도의 차이가 안 느껴지는 것이지
프로젝트가 커질수록 시간도 늘어나기 때문에
우리는 ./gradlew build
를 대체해야 할 시기를 마주하게 될 수도 있다.
위를 통해 gradlew build 명령어의 한계는 알 수 있었지만, 이 명령어가 CI 의 전부는 아니다.
막상 CI 에서 어떤 것이 필요할지, 그리고 gradlew build 명령어의 거대한 로직 중 어떤 것만을 뽑아 실행시킬지 정해야 한다.
개발자 자신이 마음대로 CI 를 만들 수 있는만큼 첫 접근을 위한 사용 예시
가 필요하지 않을까하여,
필자가 github actions CI 코드에서 사용했던 명령어들을 순차적으로 아래에 적어볼까한다.
github actions 에서는 여러 값들이 내장 되어 있고 이를 통해 확인할 수 있는 내용들이 있다.
이에 대한 자세한 내용은 github actions 공식문서에서 확인할 수 있다.
때로는 어떤 event 인지에 따라 (ex. push, pull request) 볼 수 있는 내용이 달라질 때가 있다.
이에 필자는 브랜치, 태그 이름에 기반한 작업을 하거나, 디렉터리 확인 등을 위해
아래와 같이 github actions 에서 볼 수 있는 모든 항목들에 대한 로깅을 했었다.
# 실행할 job 설정
jobs:
# CI 작업
integration:
runs-on: ubuntu-18.04
steps:
# 로깅1. github context
- name: 로깅1. GitHub context 확인
env:
GITHUB_CONTEXT: ${{ toJson(github) }}
run: echo "$GITHUB_CONTEXT"
if: always()
# 로깅2. job context
- name: 로깅2. job context 확인
env:
JOB_CONTEXT: ${{ toJson(job) }}
run: echo "$JOB_CONTEXT"
if: always()
# 로깅3. steps context
- name: 로깅3. steps context 확인
env:
STEPS_CONTEXT: ${{ toJson(steps) }}
run: echo "$STEPS_CONTEXT"
if: always()
# 로깅4. runner context
- name: 로깅4. runner context 확인
env:
RUNNER_CONTEXT: ${{ toJson(runner) }}
run: echo "$RUNNER_CONTEXT"
if: always()
# 로깅5. strategy context
- name: 로깅5. strategy context 확인
env:
STRATEGY_CONTEXT: ${{ toJson(strategy) }}
run: echo "$STRATEGY_CONTEXT"
if: always()
# 로깅6. matrix context
- name: 로깅6. matrix context 확인
env:
MATRIX_CONTEXT: ${{ toJson(matrix) }}
run: echo "$MATRIX_CONTEXT"
if: always()
context 라고도 지칭하는 이 값들은 json 형태로 되어 있고, echo 를 통해 아래와 같이 확인이 가능하다.
전체 내용을 보여줄 수 없어 일부 내용만 캡처했다.
이 내용으로 각 event 를 구분하고, 그에 따라 필요한 값들을 뽑아올지 확인하여 코드를 작성할 수 있다.
이 내용은 CI 에 영향을 주진 않는 코드이다.
하지만 github actions 플러그인 활용법
과 관련된 이야기이기도 하여 사용 예만 언급하고 넘어가려 한다.
필자의 경우 CI 를 통해 테스트를 완료하거나, CD 를 통해 apk 를 배포할 때
슬랙과 연동하여 결과를 슬랙에 전달하는 로직을 넣었었다.
이 때 CI/CD 를 시작한 시간을 가져오기 위해 Get Current Time
이라는 플러그인을 사용했다.
이 링크로 들어가면 관련 내용을 자세히 볼 수 있고, 안드로이드 의존성을 implement 하여 사용하는 듯한 느낌을 얻을 수 있을 것이다.
jobs:
# CI 작업
integration:
runs-on: ubuntu-18.04
steps:
.
.
.
# 현재 시간 설정 및 출력 내용 확인
# [Get Current Time](https://github.com/marketplace/actions/get-current-time)
# 1. 현재 시간 설정하기
- name: 1. 현재 시간 설정하기
uses: 1466587594/get-current-time@v1
id: current-time
with:
format: YYYY.MM.DD_LT
utcOffset: "+09:00"
if: always()
# 2. 현재 시간 내용 확인
- name: 2. 현재 시간 내용 확인
env:
TIME: "${{ steps.current-time.outputs.time }}"
F_TIME: "${{ steps.current-time.outputs.formattedTime }}"
run: echo $TIME $F_TIME
if: always()
필자는 이런 식으로 현재 시간을 체크하였다.
한번 위에 언급한 플러그인 링크
를 직접 보고 작성해보는 것도 추천한다.
github Repository 에 직접 올리기에 꺼림칙한 일부 파일이 있을 것이다. google-service.json
, kakao-strings.xml
, 키스토어 파일
등이 그 예가 될 수 있다. (왜 꺼림칙한지는 따로 이야기하지 않겠다.)
보안을 필요로 하지만 빌드나 배포 등을 위해서는 반드시 repository 내에서 인식할 수 있어야 한다.
그러면 어떻게 해야할까? 필자는 비밀번호를 통한 파일 압축 방법을 언급한 링크를 통해 고민을 해소했다.
비밀번호의 경우에는 github 에서 제공하는 SECRET
를 활용했다.
github 내에서는 SECRETS
라는 key-value 형태로 값을 저장할 수 있는 기능이 있다. 해당 기능을 활용하면 간단한 문자열을 저장할 수 있다. 아래는 실제 저장한 예이다.
주의할 점이 있다면 SECRETS 에 한 번 저장한 이후 그 값을 다시 확인할 수 있는 길은 없다.
자기자신은 이 값을 기억하고 있어야 할 것이다.
위 내용들을 종합하여 필자는 아래와 같이 작성했었다.
jobs:
# CI 작업
integration:
runs-on: ubuntu-18.04
steps:
.
.
.
# 압축 파일 해독 [Github action에 .gitignore 파일 포함하기](https://sys09270883.github.io/ci/cd/78/)
# 1. services.tar gpg 해독
- name: 1. Decrypt services.tar
run: gpg --quiet --batch --yes --always-trust --decrypt --passphrase="$SERVICE_TAR_PASSWORD" --output services.tar services.tar.gpg
env:
SERVICE_TAR_PASSWORD: ${{ secrets.SERVICE_TAR_PASSWORD }}
# 2. services.tar 압축 풀기 (지정되었던 경로로 자동으로 파일이 만들어짐)
- name: 2. services.tar 압축 풀기
run: tar xvf services.tar
여러 개의 파일을 압축할 경우 각 파일이 그 위치를 기억하고 있다.
그러므로 일일히 하나씩 명령어를 작성할 필요는 없다.
본인의 입맛에 맞춰 사용하면 좋을 듯하다.
우리는 작업하면서 많은 안드로이드 의존성 라이브러리를 사용한다. (retrofit, glide 등)
그러면서 라이브러리가 충돌되는 case 가 발생할 수 있고 이를 CI 상에서도 확인할 수 있다.
jobs:
# CI 작업
integration:
runs-on: ubuntu-18.04
steps:
.
.
.
# 의존성 라이브러리 다운로드 및 확인
- name: 의존성 라이브러리 다운로드 및 확인
run: ./gradlew androidDependencies
직접 의존성 라이브러리들을 설치하고 관련 내용을 아래와 같이 확인할 수 있다.
이 또한 variant 에 따라 여러번 실행되므로 이로 인한 시간을 줄이고 싶다면 그에 대한 방법을 찾아 처리할 수 있다.
다시 build 를 맞이하게 되었지만, 이젠 다른 build 를 사용해야 할 것이다.
우리는 특정 variant 에 맞는 build
를 원하므로 이에 맞춰 자유롭게 명령어를 작성해주면 된다.
Android Studio 와 비슷하게 처리해주려면 아래와 같이 작성하면 된다.
다른 variant 로 처리하고 싶을 경우, assemble 뒤에 다른 variant 를 적어주면 된다.
jobs:
# CI 작업
integration:
runs-on: ubuntu-18.04
steps:
.
.
.
# 프로젝트 빌드
- name: 프로젝트 빌드
run: ./gradlew assembleDebug
각 언어마다 코드 작성 스타일 가이드가 존재한다.
안드로이드 스타일 가이드의 경우 안드로이드 공식 문서 링크, 코틀린 공식 문서 링크 등을 통해 확인할 수 있다.
물론 숙지하고 코드를 작성하겠지만 우리는 사람인만큼 실수할 수 있고 스타일 가이드를 놓칠 수 있다.
이를 개발자 대신 체크해주거나, 고쳐주는 기능이 있다면 더할나위없이 편할 것이다.
안드로이드에서는 크게 3가지 도구가 있다.
안드로이드 공식 문서, 코틀린 공식 문서에서 언급한 스타일 가이드를 확인해주는 도구이다.
틀릴 경우 안내(ktlintCheck
)해주거나, 직접 수정(ktlintFormat
)도 해준다.
github link를 통해 프로젝트에 적용이 가능하며, 이를 github actions 에 언급하여 CI 도중에 스타일 테스트를 할 수 있다.
jobs:
# CI 작업
integration:
runs-on: ubuntu-18.04
steps:
.
.
.
# 코틀린 스타일 테스트
- name: 코틀린 스타일 테스트
run: ./gradlew ktlintCheck
코틀린 정적 코드 분석 도구이다.
코드 순환 검사, 코드의 복잡성 등 전반적인 코드의 품질을 검사해주는 도구이며 github link를 참고하여 적용할 수 있다.
이 역시 CI 도중에 스타일 테스트를 할 수 있다.
jobs:
# CI 작업
integration:
runs-on: ubuntu-18.04
steps:
.
.
.
# kotlin style 테스트
- name: kotlin style 테스트
run: ./gradlew detekt
프로젝트의 구조 및 불필요한 코드가 있는지 확인하고 체크해주는 도구이다.
기본으로 제공해주며 안드로이드 공식 문서에서 확인할 수 있다.
jobs:
# CI 작업
integration:
runs-on: ubuntu-18.04
steps:
.
.
.
# android lint 테스트
- name: android lint 테스트
run: ./gradlew lint
특정 variant 에 대해서만 실행시켜주는 것도 가능하다. (ex. ./gradlew lintDebug
)
위와 같이 안드로이드에서는 크게 3가지 코드 스타일 테스트가 있으며
이들을 적절히 활용하여 코드의 품질을 사전에 체크하고 향상시킬 수 있다.
다양한 테스트 (UnitTest, UITest) 를 통해
프로젝트 내의 로직이 원하는 대로 작동하는지 UI 가 정상동작하는 지 검증할 수 있다.
아래는 UnitTest 의 예이다.
jobs:
# CI 작업
integration:
runs-on: ubuntu-18.04
steps:
.
.
.
# 프로젝트 Unit 테스트
- name: 프로젝트 Unit 테스트
run: ./gradlew testdebugUnitTest
위에 언급했던 5. 프로젝트 build
와 비슷하게 특정 variant 에 대응하여 처리가 가능하다.
다양한 variant 에 대한 테스트가 필요없을 경우 위와 같이 처리하여 시간을 줄일 수 있다.
다른 gradlew 명령어를 수행하거나, 다른 작업을 수행할 수 있다.
필자의 경우에는 슬랙으로 메시지 보내는 과정
을 추가했으며, 경우에 따라 커버리지 리포트를 업로드
하거나, dokka 등을 활용하여 주석에 기반한 전용 문서
를 만들어 낼수도 있다.
원래는 직접 스타일 가이드를 적용하고 테스트 코드를 작성하는 것도 하려고 했지만
문단이 너무 길어질 것 같아 여기에서 마무리 지으려 한다.
여기까지가 이론과 예시였으니, 다음 과에서 직접 코드 스타일 라이브러리와 테스트 코드를 작성하여
실제 github actions 에 어떻게 보이게 되는지를 확인해보자.