Resurrecting a Dead Library: Part One - Resuscitation

Michael Lynch

죽은 라이브러리 되살리기: 1부 - 소생

원문은 Michael Lynch님이 에 게재했습니다. 이 블로그 구독하기

현장에 도착했을 때, 광경은 참담했다.

한때 활발하고 명랑하던 파이썬 클래스들이 오랜 시간 운동 한번 하지 못한 채 위축되어 있었다. 온갖 추상화 수준의 함수들이 utils라는 이름 아래 비인간적으로 쑤셔 박혀 있었다. UI 코드를 읽어 보려 했지만 무언가가 시야를 가로막았다. 가까이 들여다보니 속이 메스꺼워졌다. 뷰 레이어를 막고 있던 것은 다름 아닌 끔찍하게 뒤엉킨 비즈니스 로직 덩어리였다.

코드는 죽어 있었다.

이번 3부작 시리즈에서는 내가 어떻게 그 코드를 되살리고 이를 기반으로 사업을 일궜는지 보여주려 한다:

  • 1부: 소생 (이번 글) - 코드를 간호해 어떤 최신 시스템에서도 동작하도록 되살리는 과정
  • 2부: 안정화 - 코드를 복구하는 동안 기능이 퇴보하지 않도록 막는 과정
  • 3부: 재활 - 본격적으로 코드 리팩터링을 시작하는 과정

파이썬을 심폐소생하는 곰 의사들

라이브러리

문제의 라이브러리는 ingredient-phrase-tagger로, The New York Times가 공개한 오픈소스 라이브러리였다. 레시피 재료 문구를 구조화된 데이터로 파싱할 수 있게 해주는 라이브러리였다.

몇 년 전, 뉴욕 타임스는 방대한 요리 레시피 아카이브를 디지털화하기로 했다. 데이터 입력 작업자를 고용해 레시피 속 날것 그대로의 재료 문구를 보고 그 안에 담긴 데이터를 하나하나 뜯어내도록 했다. 그 결과 만들어진 데이터베이스는 이런 모습이었다:

원본 재료수량단위이름비고
3 tablespoons flour3.0tablespoonflour
2 1/2 cups of finely chopped red onions2.5cupred onionsfinely chopped
2 dried pasilla chilies2.0pasilla chiliesdried

6년간 데이터베이스를 쌓은 뒤, 그들은 인간 작업자의 데이터 입력 결정을 모방하는 머신러닝 모델을 학습시킬 만큼 충분한 데이터가 모였다는 걸 깨달았다. 프로젝트는 성공했고, 그들은 모든 소스 코드와 데이터를 공개했다.

그게 나와 무슨 상관이었나?

나는 뉴욕 타임스와 똑같은 문제를 안고 있었다. 내 프로젝트인 KetoHub는 웹 곳곳의 레시피를 모아 재료로 검색할 수 있게 해준다. 레시피 사이트들은 대개 재료 목록을 구조화된 형태로 제공하지 않기 때문에, 내가 직접 구조를 뜯어내야 했다.

KetoHub 스크린샷

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

정규식 구현 스크린샷

내 끔찍한 정규식 파싱 구현의 일부

ingredient-phrase-tagger를 발견했을 당시, 나는 보기 흉하고 임시방편적인 방법, 즉 정규식으로 재료를 파싱하고 있었다.

지속 가능한 방식이 아니었다. KetoHub 색인에 새로운 레시피 사이트를 추가할 때마다 새로운 엣지 케이스를 처리하려고 길게 늘어진 정규식들을 수정해야 했다. 시간이 지나면서 재료 파싱 코드는 지옥처럼 꼬여갔고, 이해하기 어려운 방식으로 망가지기 시작했다.

내 정규식은 유지보수도 디버깅도 지루하기 짝이 없었다. 마치 눈을 가린 채 전기톱으로 재료를 썰어내는 기분이었다. 반면 뉴욕 타임스의 라이브러리는 재료를 깔끔하고 외과 수술처럼 정밀하게 해부하는 것 같았다. 나는 그게 간절히 필요했다.

하지만 먼저, 그 코드를 어떻게 실행시킬지부터 알아내야 했다.

왜 어려웠나?

뉴욕 타임스는 이 라이브러리를 해커톤 행사용으로 만들었기 때문에, 자동화된 테스트나 꼼꼼한 문서 같은 전문적인 소프트웨어 프로젝트라면 으레 갖출 요소들이 빠져 있었다. README에는 애플리케이션 설치 방법이 적혀 있었지만, Mac OS X에서만 동작했다. 테스트도, 지속적 통합 설정도 없으니 코드를 도대체 어떻게 실행해야 하는지조차 불분명했다.

OS X 설치 안내

ingredient-phrase-tagger 라이브러리의 설치 안내

물론 이런 문제를 알아챈 건 나뿐만이 아니었다. 공개 당시 뉴욕 타임스는 저명한 파이썬 개발자 D. John Trump로부터 혹독한 비판을 받았다:

코드에 대한 Trump의 트윗

Docker로 빌드하기

나는 OS에 관계없이 일관된 동작을 보장하는 방식으로 라이브러리를 빌드하고 싶었다. 이럴 때 쓰라고 있는 게 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++ 설치 안내

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 헤더 파일이 없다는 오류와 함께 실패했다.

이 코드는 아직 유지보수되고 있는 걸까?

CRF++ 변경 이력

마지막 커밋이 2015년인 CRF++ 변경 이력

맙소사! 또 하나의 죽은 저장소인가? 나는 이미 라이브러리 하나를 되살리고 있는 중이었다. 하나 더 떠맡고 싶지 않았다.

잠깐 샛길로 새기

winmain.h에 대한 CRF++ 오류 메시지는 불길한 징조였지만, 뉴욕 타임스 개발자들이 OS X에서 CRF++를 실행했다면 Windows가 아닌 환경에서도 실행할 수 있다는 뜻이었다.

혹시 누군가 이미 고쳐 놨는데 메인테이너가 병합하지 않았을지도 모른다. 저장소의 열린 풀 리퀘스트를 확인했다. 특히 이 풀 리퀘스트가 유망해 보였다:

CRF++ 풀 리퀘스트

CRF++의 대기 중인 풀 리퀘스트

그 풀 리퀘스트 제목은 마치 “야 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에서 빌드하기

아, 잠깐. 내가 하려던 게 이게 아니었다.

야크 털 깎기에 정신이 팔려 원래 목표를 잊어버렸다. 내 원래 목표는 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

흠, 이 파이썬 라이브러리가 어째서 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.pyconvert-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”라는 제품이 따로 있고 레시피에 그게 하나 필요하다고 생각한 모양이다.

머신러닝 모델이 만들어낸 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 다운로드

앞으로 이 라이브러리가 포함된 환경을 원할 때마다, 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도 운영하고 있다.

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.

댓글