죽은 라이브러리 되살리기: 2부 - 안정화
원문은 Michael Lynch님이 에 게재했습니다. 이 블로그 구독하기
이번 글에서는 테스트가 전혀 없는 레거시 라이브러리에 자동화 테스트를 나중에 도입하는 방법을 보여준다.
이 글은 3부작 시리즈 중 두 번째 글로, 요리 재료 구문(예: “2 cups milk”)을 머신러닝으로 파싱해 구조화된 데이터로 변환하는 라이브러리인 ingredient-phrase-tagger를 어떻게 되살렸는지에 대한 이야기다. 전체 맥락은 1부를 참고하면 되지만, 요약하자면 버려진 라이브러리를 발견해 내 SaaS 비즈니스를 구동할 수 있도록 되살렸다는 것이다:
- 1부: 소생 - 코드가 최신 시스템 어디서든 동작하도록 복구하는 과정
- 2부: 안정화(이번 글) - 코드를 복구하는 동안 기능이 퇴보하지 않도록 방지하는 과정
- 3부: 재활 - 코드 리팩터링을 시작하는 과정
지속적 통합에서 실행하기
1부가 끝날 무렵, 나는 어떤 시스템에서든 라이브러리를 실행할 수 있는 Docker 이미지를 만들었다. 다음 단계는 라이브러리를 지속적 통합 환경에서 실행하는 것이었다.
지속적 통합이란 코드가 변경될 때마다 독립적이고 통제된 환경에서 소프트웨어를 테스트하는 관행이다. 내가 선호하는 지속적 통합 솔루션은 Travis다. 설정 파일이 직관적이고, 오픈소스 프로젝트에는 무제한 무료 빌드를 제공한다.
Travis와 연동하기 위해 내가 포크한 ingredient-phrase-tagger를 Travis 설정 페이지에서 추가한 뒤 빌드를 활성화했다:

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++ 출력과 로컬 환경에서의 동일 출력 라인을 비교해 봤다:

crf_learn이 Travis에서는 두 스레드로, 로컬 환경에서는 여덟 스레드로 실행된다
바로 그거였다!
--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을 제공하고 있다.
글을 무작위로 읽기


댓글
로그인하고 댓글 남기기