죽은 라이브러리 되살리기: 2부 - 안정화
이 글에서는 테스트가 전혀 없던 레거시 라이브러리에 자동화 테스트를 추가하는 방법을 보여줍니다.
이 글은 요리 재료(예: “우유 2컵”)를 구조화된 데이터로 파싱하는 머신러닝 라이브러리 ingredient-phrase-tagger를 어떻게 되살렸는지 다루는 3부작 시리즈의 2부입니다. 자세한 배경은 1부에서 확인할 수 있습니다. 간단히 말하면, 버려진 라이브러리를 발견했고 이를 되살려 제 SaaS 사업의 기반으로 쓸 수 있게 만들었습니다.
- 1부: 소생 - 코드를 치료해 최신 시스템 어디서나 실행되도록 만든 과정
- 2부: 안정화(이 글) - 코드를 복원하는 동안 기능이 퇴행하지 않도록 막은 과정
- 3부: 재활 - 코드 리팩터링을 시작한 과정
지속적 통합에서 실행하기
1부가 끝날 무렵, 라이브러리를 어떤 시스템에서든 실행할 수 있게 해 주는 Docker 이미지를 만들었습니다. 다음 단계는 라이브러리를 지속적 통합 환경에서 실행하는 일이었습니다.
지속적 통합(continuous integration)은 코드가 변경될 때마다 독립적이고 통제된 환경에서 소프트웨어를 테스트하는 방식입니다. 제가 선호하는 지속적 통합 솔루션은 Travis입니다. 설정 파일이 직관적이고 오픈 소스 프로젝트에는 무료 빌드를 무제한으로 제공합니다.
Travis와 연동하기 위해 Travis 설정 페이지에서 ingredient-phrase-tagger의 제 포크를 추가한 다음 빌드를 활성화했습니다.

ingredient-phrase-tagger 라이브러리의 Travis 빌드 활성화
그런 다음 Travis에 라이브러리 빌드 방법을 알려 주는 .travis.yml 파일을 만들었습니다.
sudo: required
services: docker
script: docker build .커밋을 GitHub에 푸시하고 풀 리퀘스트를 만들자 Travis가 성공적으로 빌드했습니다.

Travis에서 처음으로 성공한 빌드
엔드투엔드 테스트 추가하기
Travis가 Docker 이미지를 빌드하기는 했지만, 아직 그 빌드에는 의미가 없었습니다. 라이브러리의 의존성만 빌드했을 뿐, 라이브러리의 동작은 전혀 실행해 보지 않았기 때문입니다. 라이브러리의 기능을 망가뜨렸을 때 알려 주는 빌드를 원했습니다. 그러려면 엔드투엔드 테스트가 필요했습니다.
엔드투엔드 테스트는 완전한 실제 상황이 예상대로 작동하는지 검증합니다. 일반적으로 다음과 같은 구조를 따릅니다.
- 미리 생성한 입력과 그에 대한 예상 출력(“골든 출력”이라고도 함)을 준비합니다.
- 자동화 도구를 사용해 입력을 라이브러리에 전달합니다.
- 라이브러리의 출력을 골든 출력과 비교합니다.
원래 저장소에는 엔드투엔드 테스트와 비슷한 roundtrip.sh라는 스크립트가 들어 있었습니다. 이 스크립트는 미리 생성된 입력을 라이브러리에 전달하고, 입력의 일부를 사용해 새로운 머신러닝 모델을 학습한 다음, 그 모델로 입력의 다른 부분을 파싱했습니다. 빠져 있던 부분은 결과를 정상으로 알려진 출력과 비교하는 단계뿐이었습니다.
기본 엔드투엔드 테스트
1부에서 roundtrip.sh 스크립트의 최종 결과가 모델 성능에 대한 요약 통계라는 것을 보여 드렸습니다.
Sentence-Level Stats:
correct: 1487
total: 1999
% correct: 74.3871935968
Word-Level Stats:
correct: 10391
total: 11450
% correct: 90.7510917031이 정도면 간단한 엔드투엔드 테스트를 만들 수 있었습니다. roundtrip.sh 스크립트의 마지막 단계를 다시 실행하되, 콘솔 출력을 tests/golden/eval_output이라는 새 파일로 리디렉션하고 소스 관리에 추가했습니다.
python bin/evaluate.py tmp/test_output > tests/golden/eval_output이제 정상 출력이 준비됐으므로 roundtrip.sh의 마지막 부분을 수정해 앞으로 생성되는 모든 출력을 저장해 둔 출력과 비교하도록 했습니다.
python bin/evaluate.py tmp/test_output > tmp/eval_output
diff tests/golden/eval_output tmp/eval_output코드가 망가졌을 때 테스트가 알아챌까?
엔드투엔드 테스트는 버그를 잡아낼 수 있을 때만 유용합니다. 그래서 다음 단계로 일부러 변경 사항을 만들어 라이브러리를 망가뜨린 뒤 엔드투엔드 테스트가 이를 잡아내는지 확인했습니다.
cli.py에는 숫자열(예: "83625")과 일치하는 정규 표현식이 있었습니다.
m3 = re.match('^\d+$', ss)실험 삼아 숫자에 9가 포함되어 있으면 인식하지 못하도록 정규 표현식을 살짝 바꿨습니다.
m3 = re.match('^[0-8]+$', ss)그런 다음 수정한 roundtrip.sh 스크립트를 다시 실행했습니다.
3c3
< correct: 1487
---
> correct: 1486
5c5
< % correct: 74.3871935968
---
> % correct: 74.33716858429제대로 작동했습니다!
코드에 9는 더 이상 숫자로 간주하지 말라고 하자 라이브러리의 정확도가 떨어졌고, 스크립트는 실패를 나타내는 종료 코드와 함께 종료됐습니다.
엔드투엔드 테스트 확장하기
앞에서 만든 기본 엔드투엔드 테스트는 유용했지만, roundtrip.sh는 여러 단계로 이루어진 데이터 파이프라인을 실행합니다. 어느 단계에서 문제가 발생했는지 알 수 있다면 편리할 것 같아서 엔드투엔드 테스트에 포함할 출력을 더 찾아봤습니다.
이 스크립트는 콘솔에 출력을 표시하는 것 외에도 tmp/라는 하위 디렉터리에 파일을 작성했습니다.
$ file tmp/*
tmp/model_file: data
tmp/output.html: HTML document, ASCII text, with very long lines
tmp/test_file: ASCII text
tmp/test_output: ASCII text
tmp/train_file: ASCII texttest_file, test_output, train_file은 모두 다음과 비슷하게 생긴 일반 텍스트 파일이었습니다.
$ head -n 16 tmp/test_file
1 I1 L12 NoCAP NoPAREN B-QTY
boneless I2 L12 NoCAP NoPAREN I-COMMENT
pork I3 L12 NoCAP NoPAREN B-NAME
tenderloin I4 L12 NoCAP NoPAREN I-NAME
, I5 L12 NoCAP NoPAREN B-COMMENT
about I6 L12 NoCAP NoPAREN I-COMMENT
1 I7 L12 NoCAP NoPAREN B-QTY
pound I8 L12 NoCAP NoPAREN I-COMMENT
Salt I1 L8 YesCAP NoPAREN B-NAME
and I2 L8 NoCAP NoPAREN I-NAME
freshly I3 L8 NoCAP NoPAREN B-COMMENT
ground I4 L8 NoCAP NoPAREN I-COMMENT
black I5 L8 NoCAP NoPAREN B-NAME
pepper I6 L8 NoCAP NoPAREN I-NAME아직 파일 형식을 이해하지 못했지만, 그럴 필요도 없었습니다. 제게 필요한 건 파일이 변경됐는지 감지할 방법뿐이었습니다.
이 파일들을 tests/golden에 복사한 뒤, 추가 골든 출력으로 소스 관리에 저장했습니다. 그런 다음 빌드 스크립트에 diff를 추가해 출력 파일이 변경되는지 감지하도록 했습니다.
완성된 빌드 스크립트
roundtrip.sh를 모두 수정한 뒤, 이를 build.sh라는 새 파일로 저장했습니다. 내용은 다음과 같습니다.
#!/bin/bash
# Exit build script on first failure
set -e
# Echo commands to stdout.
set -x
COUNT_TRAIN=20000
COUNT_TEST=2000
OUTPUT_DIR=$(mktemp -d)
ACTUAL_CRF_TRAINING_FILE="${OUTPUT_DIR}/training_data.crf"
ACTUAL_CRF_TESTING_FILE="${OUTPUT_DIR}/testing_data.crf"
ACTUAL_CRF_MODEL_FILE="${OUTPUT_DIR}/model.crfmodel"
ACTUAL_TESTING_OUTPUT_FILE="${OUTPUT_DIR}/testing_output"
ACTUAL_EVAL_OUTPUT_FILE="${OUTPUT_DIR}/eval_output"
bin/generate_data \
--data-path=nyt-ingredients-snapshot-2015.csv \
--count=$COUNT_TRAIN \
--offset=0 > "$ACTUAL_CRF_TRAINING_FILE"
bin/generate_data \
--data-path=nyt-ingredients-snapshot-2015.csv \
--count=$COUNT_TEST \
--offset=$COUNT_TRAIN > "$ACTUAL_CRF_TESTING_FILE"
crf_learn \
template_file "$ACTUAL_CRF_TRAINING_FILE" "$ACTUAL_CRF_MODEL_FILE"
crf_test \
-m "$ACTUAL_CRF_MODEL_FILE" \
"$ACTUAL_CRF_TESTING_FILE" > "$ACTUAL_TESTING_OUTPUT_FILE"
python bin/evaluate.py "$ACTUAL_TESTING_OUTPUT_FILE" > "$ACTUAL_EVAL_OUTPUT_FILE"
# Check against golden output.
GOLDEN_DIR=tests/golden
GOLDEN_CRF_TRAINING_FILE="${GOLDEN_DIR}/training_data.crf"
GOLDEN_CRF_TESTING_FILE="${GOLDEN_DIR}/testing_data.crf"
GOLDEN_TESTING_OUTPUT_FILE="${GOLDEN_DIR}/testing_output"
GOLDEN_EVAL_OUTPUT_FILE="${GOLDEN_DIR}/eval_output"
diff --context=2 "$GOLDEN_CRF_TRAINING_FILE" "$ACTUAL_CRF_TRAINING_FILE"
diff --context=2 "$GOLDEN_CRF_TESTING_FILE" "$ACTUAL_CRF_TESTING_FILE"
diff --context=2 "$GOLDEN_TESTING_OUTPUT_FILE" "$ACTUAL_TESTING_OUTPUT_FILE"
diff "$GOLDEN_EVAL_OUTPUT_FILE" "$ACTUAL_EVAL_OUTPUT_FILE"그런 다음 이 스크립트를 라이브러리 전용 Docker 컨테이너 안에서 실행하는 docker_build라는 간단한 래퍼를 추가했습니다.
#!/bin/bash
# Exit on first failing command.
set -e
# Echo commands to console.
set -x
IMAGE_NAME="ingredient-phrase-tagger-image"
CONTAINER_NAME="ingredient-phrase-tagger-container"
docker build \
--tag "$IMAGE_NAME" \
.
docker run \
--tty \
--detach \
--name "$CONTAINER_NAME" \
"$IMAGE_NAME"
docker exec "$CONTAINER_NAME" ./build.shdocker_build 스크립트를 사용하면 Docker를 지원하는 모든 시스템에서 엔드투엔드 테스트를 실행할 수 있었습니다. 당연히 이 테스트를 지속적 통합 환경에서도 실행하고 싶었습니다.
지속적 통합에서 엔드투엔드 테스트 실행하기
앞서 작성한 Travis 설정은 Docker 이미지를 빌드하기만 했을 뿐 라이브러리를 실행해 보지는 않았습니다. 이제 철저한 테스트 스크립트가 생겼으니 .travis.yml 파일을 업데이트해 이 스크립트를 실행하도록 했습니다.
sudo: required
services: docker
-script: docker build .
+script: ./docker_build어디서나 일관되게 실행되는 제 천재적인 테스트의 위용을 볼 생각에 변경 사항을 푸시했습니다. 하지만 결과는 달랐습니다. 테스트가 실패했습니다.

내 로컬 머신에서는 통과했지만 Travis에서는 실패한 엔드투엔드 테스트
빌드가 깨진 것을 보니 기분이 좋지는 않았지만, 엔드투엔드 테스트가 무언가를 잡아냈다는 점은 반가웠습니다. 이제 그게 무엇인지 알아내기만 하면 됐습니다.
불일치 디버깅하기
Docker 컨테이너의 핵심은 프로그램이 어디서나 똑같이 동작해야 한다는 것입니다. 그런데 어떻게 같은 컨테이너를 두 곳에서 실행하고 서로 다른 출력을 볼 수 있었을까요?
Travis 빌드 로그를 보니 testing_output 파일의 diff에서 테스트가 실패한 것으로 나타났습니다.
+ diff --context=2 tests/golden/testing_output /tmp/tmp.W5S3C5T4if/testing_output
*** tests/golden/testing_output Fri Jul 27 02:44:20 2018
--- /tmp/tmp.W5S3C5T4if/testing_output Fri Jul 27 03:03:56 2018
***************
*** 173,178 ****
1 I1 L8 NoCAP NoPAREN B-QTY B-QTY
tablespoon I2 L8 NoCAP NoPAREN B-UNIT B-UNIT
! dark I3 L8 NoCAP NoPAREN B-COMMENT B-COMMENT
! corn I4 L8 NoCAP NoPAREN B-NAME B-NAME
syrup I5 L8 NoCAP NoPAREN I-NAME I-NAME
--- 173,178 ----
1 I1 L8 NoCAP NoPAREN B-QTY B-QTY
tablespoon I2 L8 NoCAP NoPAREN B-UNIT B-UNIT
! dark I3 L8 NoCAP NoPAREN B-COMMENT B-NAME
! corn I4 L8 NoCAP NoPAREN B-NAME I-NAME
syrup I5 L8 NoCAP NoPAREN I-NAME I-NAMEtesting_output 파일은 build.sh 스크립트의 다음 두 줄이 만든 결과였습니다.
crf_learn \
template_file "$ACTUAL_CRF_TRAINING_FILE" "$ACTUAL_CRF_MODEL_FILE"
crf_test \
-m "$ACTUAL_CRF_MODEL_FILE" \
"$ACTUAL_CRF_TESTING_FILE" > "$ACTUAL_TESTING_OUTPUT_FILE"crf_learn과 crf_test는 ingredient-phrase-tagger의 머신러닝 로직을 구동하는 엔진인 CRF++의 명령줄 유틸리티였습니다. 이 유틸리티를 잘 알지는 못했지만, 문법만 봐도 crf_learn은 머신러닝 모델을 만들고 crf_test는 그 모델을 사용해 데이터를 분류한다는 것을 추론할 수 있었습니다.
엔드투엔드 테스트를 통해 $ACTUAL_CRF_TRAINING_FILE과 $ACTUAL_CRF_TESTING_FILE의 내용이 골든 버전과 일치한다는 사실은 확인했습니다. 즉, 로컬 시스템과 지속적 통합 환경에서 crf_learn과 crf_test에 입력된 값은 동일했지만 환경에 따라 서로 다른 출력이 나온 것입니다.
CRF++ 더 깊이 들여다보기
CRF++이 비결정적이었던 걸까요? 로컬에서 테스트를 다시 실행해 봤습니다. 통과했습니다. Travis에서 다시 실행하자 같은 방식으로 실패했습니다. 같은 환경에서는 CRF++이 실행할 때마다 일관된 결과를 내지만, 환경이 달라지면 결과가 달라진다는 뜻이었습니다.
이 결론은 마음에 들지 않았습니다. CRF++의 동작이 시스템의 하드웨어에 좌우된다는 뜻처럼 보였기 때문입니다. Intel CPU와 AMD CPU에서 결과가 다를 수도 있었습니다. Travis는 하드웨어 환경을 보장하지 않으니 곤란한 일이 아닐 수 없습니다. 게다가 하드웨어에 따라 결과가 달라진다면 Docker 컨테이너의 존재 이유 자체가 무색해집니다.
다급한 마음으로 CRF++의 명령줄 문서를 확인하며 하드웨어 의존성을 암시하는 내용이 있는지 찾아봤습니다.
$ crf_learn --help
...
-p, --thread=INT number of threads (default auto-detect)--thread 플래그가 눈에 들어왔습니다. 자세한 내용을 확인하기 위해 전체 문서를 찾아봤습니다.
-p NUM:
PC에 CPU가 여러 개 있다면 멀티스레딩을 사용해 학습을 더 빠르게 만들 수 있습니다. NUM은 스레드 수입니다.
가능성이 있어 보였습니다.
Travis의 CRF++ 출력과 로컬 환경에서 동일한 출력 줄을 비교했습니다.

Travis에서는 crf_learn이 두 개의 스레드로 실행되지만 로컬 환경에서는 여덟 개로 실행된다
아하!
--thread 플래그를 생략했기 때문에 CRF++이 사용 가능한 CPU 코어 수를 기준으로 스레드 수를 자동으로 설정한 것이었습니다. Travis 환경에는 CPU 코어가 두 개 있었고, 제 로컬 머신에는 여덟 개가 있었습니다.
build.sh 스크립트를 수정해 스레드 수를 명시적으로 설정했습니다.
-crf_learn template_file "$ACTUAL_CRF_TRAINING_FILE" "$ACTUAL_CRF_MODEL_FILE"
+crf_learn \
+ --thread=2 \
+ template_file "$ACTUAL_CRF_TRAINING_FILE" "$ACTUAL_CRF_MODEL_FILE"그런 다음 새로 생성된 출력 파일을 골든 사본으로 저장했습니다. 변경 사항을 GitHub에 푸시하자 기분 좋은 광경이 눈앞에 펼쳐졌습니다. 엔드투엔드 테스트가 통과한 것입니다.

Travis에서 통과한 엔드투엔드 테스트
좋은 테스트의 가치
엔드투엔드 테스트는 아주 빠르게 가치를 증명했습니다. 라이브러리 의존성 중 하나의 문서를 샅샅이 살펴보는 일은 지루했지만, 테스트 덕분에 라이브러리가 실행 환경에 따라 일관되지 않은 결과를 낸다는 사실을 발견할 수 있었습니다. 아마 라이브러리의 원래 작성자들은 이런 사실을 전혀 몰랐을 것입니다.
엔드투엔드 테스트를 마련하고 지속적 통합을 실행하자 라이브러리가 기대한 기능을 보여 주는 기준 환경이 생겼습니다. 이 테스트는 제가 무심코 라이브러리의 동작을 바꾸는 변경을 하더라도 이를 막아 주는 든든한 안전장치가 됐습니다.
다음은 무엇일까?
테스트를 통해 자신감을 얻었으니 이제 소프트웨어 프로젝트에서 제가 가장 좋아하는 일을 할 차례였습니다. 바로 리팩터링입니다. 무언가 너무 멍청한 짓을 하면 빌드가 요란하게 깨질 테니, 대규모로 코드를 변경해도 걱정할 필요가 없었습니다.
이 시리즈의 3부에서는 다음 내용을 다룹니다.
- 단위 테스트 추가
- 코드에 스타일 규칙을 자동으로 적용
- 정적 분석을 빌드에 통합
표지 일러스트: Loraine Yow. 제가 포크한 ingredient-phrase-tagger 라이브러리는 GitHub에서 확인할 수 있습니다. 이 라이브러리를 기반으로 한 관리형 서비스 Zestful도 운영하고 있습니다.
글을 무작위로 읽기

