코드체인의 지속적 통합 환경 구성

 

코드박스

개발이 진행되는 과정에서는 모든것이 변합니다. 요구사항이 변하기도 하고, 개발환경이 변하기도 하며, 이에 맞춰 코드도 변합니다. 이렇게 꾸준히 변하는 코드는 이를 건강하게 유지하려는 지속적인 관리와 투자가 없으면 금방 망가지기 마련입니다. 노력을 기울이지 않은 코드는 사소하게는 포매팅의 일관성을 잃기도 하고, 테스트케이스들이 실패하는 채로 방치되거나, 자주 사용되지 않는 빌드 플래그 뒤에 컴파일 에러가 오랜 세월 누적되어 한번에 빌드되지 않기도 합니다. 이런 식으로 망가져가는 코드베이스는 개발팀의 생산성에도, 제품의 품질에도 악영향을 끼치게 됩니다. 안정성이 중시되는 블록체인 프로젝트로서는 코드가 망가지지 않게 관리하는것이 중요합니다.

코드리뷰를 강화하고, 코드 포매팅을 규칙화 하고, 정기적으로 테스트케이스를 실행해보고, 다양한 빌드 플래그 조합으로 코드를 빌드하는 등의 방법으로 코드가 건강한 상태에서 멀어지지 않도록 해야합니다. 그러나 이런 반복적이고 꾸준히 해야하는 일의 책임을 개발자 개인에게 한없이 지울 수는 없습니다. 사람의 손에 맡기는 일은 실수의 여지가 많고, 개발자의 피로감을 높이고, 능률을 낮추며 점차 흐지부지 될 것입니다.

그렇기에 이런 일련의 도구들과 작업 흐름들을 자동화하는것이 중요합니다. 코드체인 코어 프로젝트에서는 코드가 최선의 상태로 유지될 수 있도록 하는 다양한 소프트웨어와 서비스들을 활용합니다. GitHub 저장소에 풀 리퀘스트가 만들어지는 시점에 Travis CI는 다양한 검사를 수행하는 소프트웨어들을 자동으로 수행하고, 그 결과를 보고해 줘 코드의 결함이 개발자가 인지하지 못한 채 몰래 코드베이스로 흘러 들어가는 사태를 미연에 방지합니다. Mergify는 CI와 리뷰 시스템 등과 연동되어 반복적이고, 손길이 많이 가며, 자칫 지연되기 십상인 브랜치 업데이트, 병합과 같은 GitHub의 풀 리퀘스트와 관련된 작업을 자동화하여 팀의 일손을 돕습니다.

Mergify 를 이용한 병합 과정 자동화

코드체인 코어 프로젝트는 master 브랜치로 병합되는 풀 리퀘스트에 대해서 엄격한 상태 확인을 적용하고, 리베이스 병합을 기본 병합 방식으로 사용하고 있었습니다. 엄격한 상태 확인을 사용하면 풀 리퀘스트가 병합되기 위해서는 병합되려는 브랜치에 맞춰 최신으로 업데이트 되어야 합니다. 병합을 시도할 때 리베이스나 병합 커밋 만들기가 성공적으로 완료되었어도 그 결과로 나온 코드는 올바르지 않은 코드일 수 있기 때문입니다. 예를 들어 master에 A, B 브랜치를 순차적으로 병합을 시도하는 상황을 상상해봅시다. A 브랜치에서는 함수 foo를 사용하는 코드를 추가했고, B 브랜치에서는 코드에서 foo를 삭제했습니다. 이 경우 A 병합도 성공하고, B 병합도 성공하지만, B 병합 이후에는 컴파일 오류가 납니다. 그렇기 때문에 리베이스, 혹은 병합 커밋 만들기에 성공해도 테스트를 다시 수행해서 이상이 없을 때만 master에 반영해야합니다.

이런 방식을 사용하는 코드체인 코어 프로젝트에서는 풀 리퀘스트가 master 브랜치에 병합되기 위해서는 다음과 같은 작업 흐름을 따라야 했습니다.

기여자는 풀 리퀘스트를 만듭니다.

Travis CI의 검사 결과를 기다립니다.

코드 리뷰어로 지정된 사람은 코드를 리뷰합니다.

모든 리뷰어들의 리뷰가 완료되고, Travis CI가 이상 없음을 알리면 병합을 시작합니다.

풀 리퀘스트의 브랜치가 master에 대해 fast-forward가 가능하거나, 병합 커밋이 브랜치의 최신 커밋으로 올라가 있어 최신화 되어 있는 상태이면 리베이스 병합을 시도합니다.

최신화되어있지 않으면 브랜치를 master에 대해 리베이스나 GitHub의 Update Branch 기능 등으로 최신화하고 2.로 되돌아갑니다.

그러나 이런 작업흐름에는 한가지 결점이 있었습니다. 병합은 master에 맞춰 최신화된 풀 리퀘스트 하나에서만 이뤄질 수 있고, 최신화된 풀 리퀘스트가 master에 병합되고 나면 적체된 나머지 풀 리퀘스트들은 병합되기 위해서는 다시 한번 최신화 되어야합니다. 이미 최신화된 풀 리퀘스트를 먼저 병합하지 않고, 다른 풀 리퀘스트를 최신화하고 먼저 병합해버리는 실수를 저지르면 이전에 최신화했던 풀 리퀘스트는 다시 최신화 해야 합니다. 동시에 열려있는 풀 리퀘스트의 개수가 적은 상황에서는 실수의 여지가 적지만 동시에 열린 풀 리퀘스트의 개수가 많아지고 Travis CI에 의한 검사가 지연되기 시작하면 이런 실수는 더 흔하게 일어납니다. 잦은 브랜치 최신화는 Travis CI 에 적체되는 작업의 개수를 늘리고, Travis CI는 동시 실행 가능 한 작업의 개수가 제한되어있기 때문에 다른 풀 리퀘스트들의 작업이 점차 지연됩니다. 이런 상황을 피하려면 풀 리퀘스트를 병합하는 작업자는 어떤 풀 리퀘스트가 최신화되었는지를 기억하고 신중하게 순차적으로 병합해야만 합니다.

이런 상황에서 병합 작업자의 부담을 줄이고, Travis CI의 작업량 부담을 줄여 병합 지연을 줄이고, 결과적으로 팀의 능률을 향상시키기 위해서 저희 코드체인 엔진 팀에서는 Mergify 를 도입하게 되었습니다. Mergify 는 GitHub과 연동되는 병합 관리 서비스로, 정책을 저장소에 포함된 .mergify.yaml 파일로 유연하게 설정 가능하고, 설정된 정책에 따라 조건에 맞는 풀 리퀘스트의 자동 병합을 진행하거나 중단할 수 있고, 다양한 동작들을 자동으로 수행할 수 있습니다.

저희 팀에서 사용중인 .mergify.yml 규칙은 다음과 같습니다. (링크)

pull_request_rules: - name: Merge when CI passes and resolves all requested reviews conditions: - "#approved-reviews-by>=1" - "#review-requested=0" - "#changes-requested-reviews-by=0" - status-success=continuous-integration/travis-ci/pr - status-success=clahub - base=master - label!=do-not-merge - "- title~=\\b(wip|WIP)\\b" actions: merge: method: rebase rebase_fallback: null strict: smart

Mergify의 작업흐름

이 규칙을 통해 저희 팀에서는 한 명 이상의 리뷰어가 할당되고, 리뷰가 완료되었으며, Travis CI와, Clahub의 검사를 통과한 master로 병합되는 풀 리퀘스트에 대해서 do-not-merge라벨이나 제목에 WIP가 붙지 않은 풀 리퀘스트들을 자동으로 병합할 수 있었습니다. 또한 병합될 때 리베이스 병합만 시도하며, strict 병합을 설정을 사용하게 설정하였습니다.

Mergify에서 strict 병합 설정을 사용하면 Mergify는 조건을 만족한 풀 리퀘스트를 자동으로 master에 대해서 최신화를 합니다. 그러나 이전에 최신화와 병합 순서를 일관되게 유지하는것이 중요한 이유에 대해서 설명했었습니다. 만약 Mergify가 무작위로 최신화와 병합을 시도한다고 하면 Travis CI 에 작업 부담이 늘어 지연되는 문제는 여전할 것입니다. 그렇기 때문에 strict:smart 옵션을 사용합니다. strict: smart 옵션을 사용하면 Mergify가 한번에 하나의 풀 리퀘스트가 최신화되고 병합되게끔 작업 큐를 관리해줍니다.

Travis CI 를 이용한 코드 검사 및 테스트 자동화

Mergify 는 리뷰와 Travis CI 등의 결과를 조건으로 풀 리퀘스트가 병합되는 과정을 자동화 해주는 역할만을 수행하고, 코드에서 발생할 수 있는 문제들을 점검하는 일은 Travis CI에서 이뤄집니다. GitHub에 풀 리퀘스트가 만들어지면, GitHub과의 연동에 의해서 Travis CI 에 해당 풀 리퀘스트를 검사하는 작업이 자동으로 만들어집니다. 만들어지는 작업은 저장소 루트 디렉토리의 .travis.yml 파일에 정의할 수 있는데, 코드체인 코어 프로젝트에서는 크게 두 종류의 작업이 정의됩니다. 코드의 포매팅과 작은 실수들을 점검하는 작업과, 코드체인이 배포되는 타겟 플랫폼에서 빌드가 되는 지 확인하고, 단위테스트와 E2E 테스트(the end-to-end test)를 진행하는 작업입니다. 코드체인에 정의된 작업들은 이 곳에서 확인하실 수 있습니다.

코드체인 코어 프로젝트는 Rust 코드로 작성되어있습니다. Rust 코드의 포매팅을 점검하기 위해서는 코드 포매터인 rust-fmt 를 사용하고 있고, 코드의 작은 실수들을 점검하는데에는 린트 도구인 rust-clippy 를 사용합니다. 단위테스트들은 cargo test 를 이용해 실행되고, 이 과정에서 자연스럽게 컴파일을 시도하여 컴파일 에러를 탐지합니다. E2E 테스트는 TypeScript로 작성되는데, Rust 로 작성된 코드를 테스트하는데 사용되는 스크립트지만 이 코드 역시 관리의 대상입니다. 포매팅을 점검할 때에는 포매터 prettier를 사용하고, 코드상의 작은 실수들은 린트 도구 tslint를 이용해 점검합니다. E2E 테스트는 mocha 테스트 프레임워크로 작성되어 실행됩니다.

Travis CI 에서 필요없는 작업의 실행시간 줄이기

Travis CI

코드체인 프로젝트는 작은 프로젝트가 아니기 때문에 테스트가 완료되기까지 시간이 오래 걸립니다. Rust로 작성된 코드체인 바이너리는 빌드하는데만 십수분이 걸리고, E2E 테스트는 실제 운용되는 환경과 흡사하게 빌드된 바이너리를 띄워 서로 합의하기를 기다리며 다양한 테스트를 수행하기 때문에 삼십분까지 걸립니다. 모든 작업을 직렬로 수행하면 1시간 20분가량의 시간이 소요되고, 풀 리퀘스트 하나가 만들어지면 이 작업들은 풀 리퀘스트가 처음 만들어졌을 때 풀 리퀘스트 브랜치에서 한 번, 병합하기 위해 최신화 한 직후에 풀 리퀘스트 브랜치에서 또 한 번, 그리고 master에 병합된 직후에 master 브랜치에서 마지막으로 또 한 번 총 세 번 수행되어 도합 4시간이 걸립니다. Travis CI 가 오픈소스 프로젝트에 제공하는 무료 플랜은 동시 작업 개수를 4개로 제한하고 있는데, 그러면 근무시간 8시간 동안 제공되는 총 작업시간은 8 * 4 = 32 시간에 불과합니다. 하루에 최대 8개의 풀 리퀘스트만을 만들 수 있다는 말과 같습니다. 리뷰에 의한 코드 변경과 work-in-progress 풀 리퀘스트에 의해 추가로 만들어지는 작업에 의해 이 개수는 크게 줄어들고, 풀 리퀘스트 점검 작업은 점차 밀리게 됩니다. 우리는 작업에 소요되는 시간을 최적화 할 필요가 있었습니다.

저희는 Travis CI 에서 수행되는 불필요한 작업은 최대한 일찍 끝날 수 있게 최적화했습니다. Travis CI에서는 조건부로 작업을 정의하는 방법을 제공하고 있지만, 접근 가능한 정보가 제한적이고 DSL의 자유도가 낮기 때문에 좀 더 유연한 셸 스크립트를 이용해 정의된 작업을 일찍 끝내는 방식으로 이뤄졌습니다. 셸 스크립트로 일찍 끝내는 작업의 종류는 다음과 같습니다.

풀 리퀘스트가 빌드에 영향을 주지 않는 문서의 변경사항만을 포함하고 있으면 테스트를 수행하지 않습니다.

E2E 테스트만 변경된 경우 유닛테스트를 수행하지 않습니다.

mergify에 의해서 master로 자동 병합된 직후 master 브랜치에서 수행되는 병합 테스트는 수행하지 않습니다. strict 병합 설정과, 리베이스 병합 정책으로 인해 풀 리퀘스트가 최신화된 직후와 동일하기 때문입니다.

이런 조건을 검사하고 작업을 즉시 중단하는 셸 스크립트를 작성하는것은 쉽습니다. 그러나 Travis CI 라는 특수한 상황에서 재활용 가능한 스크립트를 작성하기 위해서는 추가적인 고려가 필요했습니다. Mergify 에 의해 자동으로 병합이 진행되기 위해서는 작업이 일찍 끝내져도 Travis CI 를 “성공” 상태로 보고하게 만들어야 하고, 버그가 없는 코드는 없기 때문에 셸 스크립트 역시 예기치 못한 오류가 생기면 작업을 중단하고 “실패” 상태로 보고해야하며, 셸 스크립트는 무슨 커맨드를 실행했는 지 로그를 남기고 확인할 수 있어야 합니다.

셸 스크립트의 로그와 오류시 실패 상태를 남기며 즉시 중단하기는 상대적으로 간단했습니다. 배시 셸에는 set 명령어로 켜고 끌 수 있는 플래그가 몇가지 있는데 그 중 e 플래그와 v(혹은 x)플래그를 켜는것으로 해결됩니다. set -e 로 e플래그를 켜면 셸 스크립트 내의 커맨드에서 오류가 발생해 0 이외의 종료코드가 발생하면 즉시 해당 셸을 중단하고, 마지막으로 실패한 커맨드의 종료코드를 셸의 종료코드로 삼습니다. .travis.yml에 기재된 커맨드가 0이 아닌 종료코드로 종료되면 Travis CI는 해당 작업을 중단하고 “실패”로 보고합니다. set -v로 v플래그를 켜면 셸 스크립트 내에서 어떤 커맨드가 실행됐는지 출력합니다. v 대신에 x 플래그를 켜면, 커맨드 인자로 변수들이 전달될 때, 변수들이 어떤 값 이었는지를 확장해서 보여줍니다.

그러나 Travis CI의 작업을 “성공” 상태로 중단하는것은 셸 스크립트 내에서는 불가능했습니다. exit 0이나, travis_terminate 0와 같은 명령어는 셸 스크립트 내에서 호출했을 때 셸 자체를 중단시키는데 그치고 Travis CI의 작업을 중단시키지 않았습니다. 셸 내부에서 외부로 작업을 “중단”할것인지, “재개”할 것인지 리턴할 필요가 있었습니다. 일반적으로 서브 셸에서 값을 리턴할 때에는 두 가지 방법이 사용됩니다. 하나는 종료 코드를 리턴하는 방법이고, 나머지 하나는 표준출력으로 결과를 출력하고 부모 셸에서 표준출력을 캡쳐해 사용하는 방법입니다. 하지만 셸의 종료코드는 Travis 작업의 “실패” 여부를 판별하는데 이미 사용되고 있었고, 표준출력은 셸 스크립트의 일반 커맨드들이 상황을 로그하는데 사용하고 있었던 상황이었습니다.

그러나 Travis CI 가 표준출력 이외에 로그로 남기는게 하나 더 있습니다. 바로 표준 오류입니다. 유닉스 계열 운영체제에서는 프로세스를 실행하면 외부와 상호작용하는 세 개의 표준 스트림을 만듭니다. 파일디스크립터 0에 지정된 표준 입력, 1에 지정된 표준 출력, 2에 지정된 표준 오류입니다. 인터랙티브 셸의 경우에 표준 입력은 키보드로부터 입력을 받는데 사용되고, 표준 출력은 터미널 화면에 텍스트를 출력하기 위해 사용됩니다. 그리고 표준 오류는 표준 출력과는 구분되는 스트림으로 프로그램의 오류를 출력하기 위해 사용됩니다. 그리고 Travis CI는 표준 오류 스트림도 로그로 남깁니다.

저희 스크립트들은 이 점을 이용해 서브셸에서 값을 리턴함과 동시에 Travis CI 에 커맨드 실행 내역을 로그로 남길 수 있었습니다. 배시 셸은 강력한 입출력 리디렉션 기능을 가지고 있습니다. 셸 내에서 실행되는 개별 커맨드에 대해서 입출력을 리디렉션 할수도 있지만 셸 전체의 입출력의 리디렉션을 제어할 수도 있습니다. 저희는 다음과 같은 배시 커맨드를 이용해 셸 내에서 발생하는 모든 표준 출력(fd 1)을 셸 밖의 표준 오류(fd 2)로 리디렉션 시켰고, 셸 내에서 fd 3으로 출력되는 출력을 셸 밖의 표준 출력(fd 1)으로 리디렉션 시켰습니다.

exec 3>&1 1>&2

셸은 RESULT=$(./foo-bar) 와 같이 서브 셸의 표준출력을 캡쳐해 변수에 저장할 수 있는데, FD3을 표준출력으로 리디렉션하면 서브셸에서 echo skip >&3 echo noskip >&3 처럼 리턴값을 전달하기 위한 스트림으로 사용할 수 있습니다. 이것을 이용하여 Travis CI의 .travis.yml 에서 작업을 일찍 중단할 것인지를 결정하는 셸 스크립트를 실행하고, 리턴 값을 받아 `exit 0` 커맨드를 직접 호출합니다. 이렇게 하면 Travis CI 작업을 “성공”상태로 남김과 동시에 일찍 중단할 수 있습니다.

./.travis/check-foo

#!/usr/bin/env bash

set -ex; exec 3>&1 1>&2

function return_to_travis { echo $1 >&3; }

if some_condition_foo; then echo “Skipped!”; return_to_travis “skip”; else echo “Don’t Skip!” return_to_travis “noskip” fi

.travis.yml

RESULT=$(./.travis/check-foo); if [ “$RESULT” = “skip” ]; then terminate_travis 0; fi;

이것들을 취합하면 셸 스크립트와 Travis CI 스크립트는 위와 같습니다. 일반 커맨드들의 모든 표준출력을 표준오류 스트림으로 일괄 리디렉션 함으로써 개별 커맨드들을 일일이 리디렉션하지 않고 평소 셸 스크립트를 작성하던대로 셸을 작성해도 Travis CI 에 실행 로그가 남고, set -e 플래그를 켜서 셸 스크립트 내의 커맨드에서 예기치 못한 오류가 나면 오류와 함께 중단되고, 원하는 경우 “성공” 상태 보고와 함께 일찍 중지시킬 수 있는 Travis CI 스크립트입니다.

이번 글에서는 저희가 코드체인 코어 프로젝트의 건강한 상태를 유지하고 팀의 생산성을 향상시키기 위해 CI를 활용하고 있는지 소개해드렸습니다. Mergify 로는 GitHub 풀 리퀘스트의 작업 흐름을 자동화시켜 개발자들의 일손을 덜고, Travis CI 상에서 실행되는 다양한 검사 도구들은 코드의 이상을 풀 리퀘스트 수준에서 탐지하여 코드베이스로 이상이 흘러들어가는것을 미연에 방지합니다. 또한 Travis CI의 작업은 필요한 경우에만 확인작업을 수행하게 설정되어 한정된 자원을 최대한으로 활용합니다. 저희 CI 환경은 처음부터 이런 모습을 갖추고 있지는 않았습니다. GitHub을 중심으로 개발하면서 마주하는 다양한 생산성 문제들을 해결해 나가며 현재와 같은 모습을 갖추게 되었습니다. 앞으로도 저희의 프로젝트는 계속 될 것이고, 코드와 환경은 꾸준히 변해나갈 것 입니다. 그에 맞춰 저희 CI 환경 역시도 생산성 향상을 위해 꾸준히 변해나갈 것입니다.

기업문화 엿볼 때, 더팀스

로그인

/