죽은 라이브러리 되살리기: 1부 - 소생
현장에 도착했을 때 광경은 참담했습니다.
한때 활발하고 생기 넘치던 Python 클래스들이 몇 년간 방치된 채 처참하게 위축되어 있었습니다. 추상화 수준이 전혀 다른 함수들이 utils라는 이름 아래 비인간적으로 쑤셔 박혀 있었습니다. UI 코드를 읽어보려 했지만 무언가가 앞을 가로막고 있었습니다. 자세히 들여다보니 속이 메스꺼워졌습니다. 뷰 레이어를 가로막고 있던 것은 다름 아닌 피투성이 비즈니스 로직 덩어리였습니다.
코드는 죽어 있었습니다.
이 3부작 시리즈에서는 제가 어떻게 코드를 되살리고 이를 바탕으로 비즈니스를 만들었는지 보여드리겠습니다.
- 1부: 소생 (현재 글) - 코드를 간호해 다시 건강하게 만들어 모든 최신 시스템에서 실행되도록 하는 과정
- 2부: 안정화 - 코드를 복구하는 동안 기능이 퇴보하지 않도록 방지하는 과정
- 3부: 재활 - 본격적으로 코드를 리팩터링하는 과정
라이브러리
그 라이브러리는 ingredient-phrase-tagger로, The New York Times가 공개한 오픈소스 라이브러리였습니다. 사용자가 레시피 재료 문구를 구조화된 데이터로 파싱할 수 있게 해줍니다.
몇 년 전, Times는 방대한 요리 레시피 역사 아카이브를 디지털화하기로 결정했습니다. 데이터 입력 작업자들을 고용해 이 레시피들의 원시 재료 텍스트를 살펴보고 그 안에 담긴 데이터를 하나하나 뜯어내도록 했습니다. 그 결과는 다음과 같은 데이터베이스였습니다.
| 원시 재료 | 수량 | 단위 | 이름 | 비고 |
|---|---|---|---|---|
| 3 tablespoons flour | 3.0 | tablespoon | flour | |
| 2 1/2 cups of finely chopped red onions | 2.5 | cup | red onions | finely chopped |
| 2 dried pasilla chilies | 2.0 | pasilla chilies | dried |
6년간 데이터베이스를 쌓은 뒤 이들은 인간 작업자의 데이터 입력 결정을 모방할 수 있는 머신러닝 모델을 학습시킬 만큼 충분한 데이터가 모였다는 것을 깨달았습니다. 머신러닝 모델을 학습시킬 수 있게 된 것입니다. 프로젝트는 성공했고, 이들은 모든 소스 코드와 데이터를 공개했습니다.
이게 나와 무슨 상관인가
저 역시 Times와 같은 문제를 겪고 있었습니다. 제 프로젝트인 KetoHub는 웹 곳곳의 레시피를 모아 재료로 검색할 수 있게 해줍니다. 레시피 웹사이트들은 보통 재료 목록을 구조화된 형태로 제공하지 않기 때문에 저 역시 구조를 직접 뜯어내야 했습니다.

KetoHub에서 ‘avocado’로 검색한 결과

제 끔찍한 정규식 파싱 구현 일부
ingredient-phrase-tagger를 발견했을 당시 저는 재료를 끔찍하고 야매 같은 방식으로, 즉 정규 표현식으로 파싱하고 있었습니다.
지속 가능한 방식이 아니었습니다. KetoHub 색인에 새로운 레시피 사이트를 추가할 때마다 새로운 엣지 케이스를 처리하기 위해 길게 늘어선 정규식들을 수정해야 했습니다. 시간이 지나면서 재료 파싱 코드는 지옥처럼 복잡해졌고 이해할 수 없는 방식으로 망가지기 시작했습니다.
정규식을 유지보수하고 디버깅하는 일은 지루하기 짝이 없었습니다. 마치 눈을 가린 채 전기톱으로 재료를 써는 기분이었습니다. Times의 라이브러리는 재료를 깨끗하고 외과 수술처럼 정밀하게 해부하는 것처럼 보였습니다. 저는 간절히 그 라이브러리가 필요했습니다.
하지만 먼저 그 코드를 어떻게 실행시킬지부터 알아내야 했습니다.
왜 어려웠을까
Times는 이 라이브러리를 해커톤 행사를 위해 만들었기 때문에 자동화된 테스트나 충실한 문서 같은 전문적인 소프트웨어 프로젝트라면 갖췄을 법한 요소들이 빠져 있었습니다. README에는 애플리케이션 설치 방법이 적혀 있었지만 Mac OS X에서만 동작했습니다. 테스트나 지속적 통합 설정이 없으니 코드를 도대체 어떻게 실행해야 할지 알 수 없었습니다.

물론 이런 문제를 알아챈 건 저만이 아니었습니다. 라이브러리가 공개됐을 당시 Times는 유명 Python 개발자 D. John Trump로부터 혹독한 비판을 받았습니다.
Docker로 빌드하기
저는 운영체제에 관계없이 일관된 동작을 보장하는 방식으로 라이브러리를 빌드하고 싶었습니다. 이럴 때 쓰라고 있는 게 Docker 같았습니다.
Docker를 사용하면 개발자가 어디서나 실행되는 애플리케이션용 독립 환경을 구축할 수 있습니다. 빌드용 Ubuntu 기본 환경을 띄우는 데는 단 한 줄의 명령이면 충분했습니다.
$ docker run -it --rm ubuntu:16.04 /bin/bash
root@164475279c95:/# cat /etc/*release | head -n 2
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=16.04재료 파싱 라이브러리의 첫 번째 의존성은 머신러닝 엔진인 CRF++라는 C++ 애플리케이션이었습니다.

CRF++ 설치 방법
CRF++ 빌드 방법은 충분히 간단해 보였기에 Ubuntu 컨테이너 안에서 명령을 실행했습니다.
$ apt-get update && apt-get install git build-essential -y
...
$ git clone https://github.com/taku910/crfpp.git
$ cd crfpp
$ ./configure
...
$ make
...
g++ -DHAVE_CONFIG_H -I. -O3 -Wall -c -o crf_learn.o crf_learn.cpp
crf_learn.cpp:9:21: fatal error: winmain.h: No such file or directory
compilation terminated.이런, make가 Windows 헤더 파일이 없다는 오류와 함께 실패했습니다.
이 코드는 아직 유지보수되고 있는 걸까요?

마지막 커밋이 2015년인 CRF++ 변경 이력
맙소사! 또 죽은 저장소인가요? 이미 라이브러리 하나를 되살리는 중인데 하나를 더 떠맡고 싶지는 않았습니다.
작은 우회
winmain.h에 대한 CRF++ 오류 메시지는 불길한 신호였지만, Times 개발자들이 OS X에서 CRF++를 실행했다면 Windows가 아닌 환경에서도 실행할 수 있다는 뜻이었습니다.
아마 누군가 이미 수정했지만 메인테이너가 병합하지 않았을지도 모릅니다. 저장소의 열린 풀 리퀘스트를 확인해 보니 특히 하나가 유망해 보였습니다.

이 풀 리퀘스트의 제목은 마치 “야, Michael! 네가 애먹고 있는 문제를 내가 딱 해결했어”라고 말하는 것 같았습니다. 그래서 저는 @humem님의 패치를 적용했습니다.
$ git remote add humem https://github.com/humem/crfpp.git
$ git checkout -b patch
Switched to a new branch 'patch'
$ git pull humem patch
remote: Counting objects: 2, done.
remote: Total 2 (delta 1), reused 1 (delta 1), pack-reused 1
Unpacking objects: 100% (2/2), done.
From https://github.com/humem/crfpp
* branch patch -> FETCH_HEAD
* [new branch] patch -> humem/patch
Updating 1dc92a6..37ec31f
Fast-forward
winmain.h | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 69 insertions(+)
create mode 100644 winmain.h…그리고 다시 빌드를 시도했습니다.
$ make
make all-am
make[1]: Entering directory '/crfpp'
g++ -DHAVE_CONFIG_H -I. -O3 -Wall -c -o crf_learn.o crf_learn.cpp
/bin/bash ./libtool --tag=CXX --mode=link g++ -O3 -Wall -o crf_learn crf_learn.o libcrfpp.la -lpthread -lpthread -lm -lm -lm
libtool: link: g++ -O3 -Wall -o .libs/crf_learn crf_learn.o ./.libs/libcrfpp.so -lpthread -lm
g++ -DHAVE_CONFIG_H -I. -O3 -Wall -c -o crf_test.o crf_test.cpp
/bin/bash ./libtool --tag=CXX --mode=link g++ -O3 -Wall -o crf_test crf_test.o libcrfpp.la -lpthread -lpthread -lm -lm -lm
libtool: link: g++ -O3 -Wall -o .libs/crf_test crf_test.o ./.libs/libcrfpp.so -lpthread -lm
make[1]: Leaving directory '/crfpp'좋습니다! make가 성공했습니다.
이제 make install을 실행하고 CRF++를 구동하기만 하면 됐습니다.
$ make install
...
$ crf_test --version
crf_test: error while loading shared libraries: libcrfpp.so.0: cannot open shared object file: No such file or directory아니! 설치는 성공했지만 CRF++가 라이브러리를 찾을 수 없다는 오류와 함께 즉시 터져버렸습니다.
오류 메시지를 구글링하자 StackOverflow 답변에서 ldconfig 명령을 실행하라고 하더군요. 시도해 보니…
$ ldconfig
$ crf_test --version
CRF++ of 0.59와! 됐습니다!
진짜 Docker로 빌드하기
아, 잠깐. 제가 원래 하려던 건 그게 아니었습니다.
삽질(yak shaving)에 빠져 원래 목표를 잊어버렸습니다. 제 원래 목표는 Docker 컨테이너 안에서 ingredient-phrase-tagger를 실행하는 것이었습니다.
그럼에도 최악은 지났기를 바랐습니다. 남은 설치 단계는 라이브러리의 setuptools 인스톨러를 실행하는 것뿐이었고 저는 보통 setuptools와는 궁합이 잘 맞았습니다.
$ apt-get install python python-pip -y
...
$ git clone https://github.com/NYTimes/ingredient-phrase-tagger.git
$ cd ingredient-phrase-tagger
$ python setup.py install
...
Installed /usr/local/lib/python2.7/dist-packages/six-1.11.0-py2.7.egg
Finished processing dependencies for ingredient-phrase-tagger==0.0.0.dev0휴! 문제없이 넘어갔습니다. 모든 것이 설치된 것으로 보였습니다.
시운전해 보기
저장소의 “Quick Start” 안내에는 라이브러리 기능을 종단 간으로 실행해 보는 roundtrip.sh라는 셸 스크립트가 언급되어 있었습니다.
$ ./roundtrip.sh
...
visualizing...
./roundtrip.sh: 18: ./roundtrip.sh: ruby: not found흠, 이 Python 라이브러리가 웬일로 Ruby를 필요로 하는군요. 알겠습니다, 다시 시도해 보죠.
$ apt-get install ruby -y
...
$ ./roundtrip.sh약 5분간 실행되며 많은 출력을 쏟아낸 뒤 마침내 이렇게 끝났습니다.
Sentence-Level Stats:
correct: 1487
total: 1999
% correct: 74.3871935968
Word-Level Stats:
correct: 10391
total: 11450
% correct: 90.7510917031성공입니다!
아, 잠깐. 대체 뭘 한 거죠?
내 재료로 테스트하기
라이브러리는 뭔가 하고 있었지만 무슨 일이 일어나는지 알 수 없었습니다. 문서에는 임의의 재료를 파싱하기 위한 두 스크립트, parse-ingredients.py와 convert-to-json.py가 언급되어 있어서 그걸 시도해 보았습니다.
$ echo "1 pinch Garlic Powder" >> input.txt
$ echo "1 Cup Mozzarella, shredded" >> input.txt
$ echo "6 slices cooked bacon" >> input.txt
$ python bin/parse-ingredients.py input.txt > results.txt
$ python bin/convert-to-json.py results.txt
[
{
"input": "1 pinch Garlic Powder",
"display": "<span class='qty'>1</span><span class='unit'>pinch</span><span class='name'>Garlic Powder</span>",
"name": "Garlic Powder",
"unit": "pinch",
"qty": "1"
},
{
"comment": "shredded",
"name": "Cup Mozzarella",
"qty": "1",
"other": ",",
"input": "1 Cup Mozzarella, shredded",
"display": "<span class='qty'>1</span><span class='name'>Cup Mozzarella</span><span class='other'>,</span><span class='comment'>shredded</span>"
},
{
"comment": "cooked",
"name": "bacon",
"qty": "6",
"input": "6 slices cooked bacon",
"display": "<span class='qty'>6</span><span class='unit'>slices</span><span class='comment'>cooked</span><span class='name'>bacon</span>",
"unit": "slice"
}
]됐습니다!
뭐, 대부분은요. 모델은 “1 Cup Mozzarella, shredded”에서 “Cup”을 단위로 인식하지 못했습니다. 머신러닝 모델은 “Cup Mozzarella”라는 제품이 있고 레시피에 그게 하나 필요하다고 생각한 모양입니다.

머신러닝 모델이 만들어낸 제품
더 쉽게 만들기
라이브러리를 실행할 때마다 이런 단계를 전부 반복하고 싶지 않았기 때문에 설치 과정을 단축할 방법이 필요했습니다.
먼저 @humem님의 수정을 포함한 제 CRF++ 저장소 포크를 만들었습니다. 덕분에 Linux에서 깔끔하게 빌드되는 CRF++ 소스의 편리한 복사본을 확보할 수 있었습니다. 그런 다음 제가 실행했던 모든 셸 명령을 Dockerfile로 모았습니다.
FROM ubuntu:16.04
RUN apt-get update -y && apt-get upgrade -y
RUN apt-get install -y build-essential git python2.7 python-pip ruby
# Install CRF++.
RUN git clone https://github.com/mtlynch/crfpp.git && \
cd crfpp && \
./configure && \
make && \
make install && \
ldconfig && \
cd ..
# Install ingredient-phrase-tagger.
RUN git clone https://github.com/NYTimes/ingredient-phrase-tagger && \
cd ingredient-phrase-tagger && \
python setup.py install
WORKDIR /ingredient-phrase-tagger앞으로 이 라이브러리가 포함된 환경이 필요하면 Dockerfile이 있는 디렉터리에서 다음 두 명령만 실행하면 됩니다.
docker build --tag phrase-tagger .
docker run -it --rm phrase-tagger /bin/bash이 명령들은 재료 파싱 라이브러리의 모든 의존성을 충족하는 Docker 컨테이너를 생성합니다. 이 환경 안에서는 roundtrip.sh 스크립트를 오류 없이 실행할 수 있습니다.
$ ./roundtrip.sh
...
Done!434.32 s
testing...
visualizing...
evaluating...
Sentence-Level Stats:
correct: 1487
total: 1999
% correct: 74.3871935968
Word-Level Stats:
correct: 10391
total: 11450
% correct: 90.7510917031더 쉽게 만들기 위해 컨테이너 이미지를 Docker Hub에 업로드했으니 집에 계신 여러분도 다음 한 줄 명령으로 제 컨테이너를 사용할 수 있습니다.
docker run -it --rm mtlynch/ingredient-phrase-tagger:nyt-untouched /bin/bash아래 데모는 Docker 외에는 아무것도 설치되지 않은 Ubuntu 시스템에서 녹화한 것입니다. Docker Hub에서 제 커스텀 이미지를 받아 머신러닝 모델을 학습시키고 새로운 재료를 파싱하는 과정을 담았습니다.
앞으로
코드는 성공적으로 실행됐고 제 작업은 Docker를 지원하는 모든 시스템에서 재현 가능했습니다. 다음은 무엇일까요?
아직 소스를 깊게 들여다보진 않았지만 스크립트를 실행하면서 이상한 점들을 알아챘습니다. 특히 사용 스크립트가 불투명하고 경직되어 있었습니다. 학습 데이터, 파일 위치, 모델 파라미터가 모두 셸 스크립트 안에 하드코딩되어 묻혀 있었던 것입니다. 사용자가 모델 정확도를 최적화하기 위해 이런 값들을 조정할 수 있는 자유가 있었으면 했습니다.
또, 파싱된 출력에서 이 부분을 눈치채셨나요?
"display": "<span class='qty'>2</span><span class='unit'>tablespoons</span><span class='name'>lemon juice</span>",왜 머신러닝 모델이 재료 데이터를 구조화하는 일과 HTML 생성까지 책임져야 할까요? 뇌수술 전문의에게 뇌수술과 병원 가구 조립을 동시에 맡기는 것과 마찬가지입니다.
당장 코드에 뛰어들어 대대적인 기능 변경을 하고 싶었지만 먼저 반드시 거쳐야 할 중요한 단계가 있었습니다. 바로 안정화입니다. 기능에 가하는 모든 변경이 명시적이고 의도적인 것이 되도록 라이브러리의 기존 동작을 고정시켜야 했습니다.
그 내용은 이 시리즈의 2부에서 다룹니다. 다음을 설명합니다.
- 실수로 무언가를 망가뜨리지 않도록 종단 간 테스트를 어떻게 추가했는지
- 코드에 변경을 적용하기 전에 테스트가 자동으로 실행되도록 어떻게 설정했는지
- 유지보수를 용이하게 하기 위해 표준 도구 모음을 코드베이스에 어떻게 추가했는지
표지 일러스트: Loraine Yow. ingredient-phrase-tagger 라이브러리의 제 포크는 GitHub에서 볼 수 있습니다. 이 라이브러리를 기반으로 한 관리형 서비스인 Zestful도 운영하고 있습니다.
글을 무작위로 읽기

