죽은 라이브러리 되살리기: 3부 - 재활
저는 리팩터링을 좋아합니다. 스파게티 코드를 풀어헤쳐 그 안에 숨어 있던 논리를 명확하고 직관적인 형태로 드러낼 때만큼 만족스러운 일은 없습니다.
리팩터링에는 꼼꼼함이 필요하다는 것을 배웠습니다. 젊고 무모했던 시절에는 레거시 코드베이스에 뛰어들어 제어가 가능한 변경인지 따지지도 않고 코드를 마구 뜯어고치곤 했습니다. 그러면 어김없이 며칠 혹은 몇 주 뒤에, 사소해 보여서 지워버린 코드 조각이 실은 특정한 상황에서 꼭 필요한 코드였다는 사실을 깨닫고는 했습니다.
이번 글에서는 신중하게 리팩터링하는 방법을 보여드리겠습니다. 실제 레거시 파이썬 라이브러리를 리팩터링하면서 적용했던 기법들을 설명하겠습니다. 실수를 최소화하기 위해 사용한 개발 툴체인과 기존 동작을 고정하기 위해 단위 테스트를 추가한 과정도 함께 다룹니다.
이 글은 제가 ingredient-phrase-tagger를 부활시킨 과정을 담은 3부작 시리즈의 마지막 글입니다. ingredient-phrase-tagger는 머신러닝을 이용해 요리 재료 문구(예: “2 cups milk”)를 구조화된 데이터로 파싱하는 라이브러리입니다. 전체 맥락은 1부를 참고하시기 바랍니다. 간략히 말하면 버려진 라이브러리를 발견해 되살려 제 SaaS 비즈니스를 구동하는 데 활용했다는 이야기입니다:
- 1부: 소생 - 코드가 어떤 최신 시스템에서도 동작하도록 되살려 냅니다
- 2부: 안정화 - 코드를 복구하는 동안 기능이 퇴보하지 않도록 방지합니다
- 3부: 재활 (이번 글) - 코드 리팩터링을 시작합니다
지금 어디까지 왔을까?
이전 두 편의 글에서 저는 커스텀 Docker 이미지를 만들어 이 라이브러리를 어디서든 사용할 수 있게 했고, 엔드 투 엔드 테스트를 추가해 상위 수준의 동작을 보존했습니다. 코드베이스가 변경될 때마다 Travis 지속적 통합이 모든 의존성을 빌드하고 통제된 환경에서 테스트를 실행했습니다.
지금까지 저는 코드 자체를 수정하지 않았습니다. 기존 코드 위에 동작을 검증하기 위한 도구와 스크립트만 추가했을 뿐입니다. 이제 코드를 안전하게 수정할 수 있는 모든 장치를 갖췄으니 마침내 리팩터링을 시작할 수 있었습니다.
공백 규칙 강제하기
개발자는 공백에 정신적 에너지를 낭비해서는 안 됩니다. 저는 새로운 소프트웨어 프로젝트를 시작할 때마다 공백 포맷팅을 가능한 한 일찍 자동화합니다.
파이썬 프로젝트에서는 YAPF(Yet Another Python Formatter)로 이를 구현합니다. 이 프로젝트에서 제가 한 첫 번째 코드 변경은 모든 파일을 제가 선호하는 표준인 Google 파이썬 스타일 가이드에 맞춰 다시 포맷팅한 것이었습니다:
yapf \
--in-place \
--recursive \
--style google \
./ \
--exclude="third_party/*" \
--exclude="build/*"이로 인해 코드 변경량이 크게 발생했지만, YAPF는 성숙한 도구이고 엔드 투 엔드 테스트도 여전히 통과했기 때문에 안전한 변경이라고 확신했습니다.
다른 변경 사항이 잡음에 묻혀 풀 리퀘스트를 리뷰하기 어렵게 만들지 않도록, 제 풀 리퀘스트는 오직 공백 변경만 포함하도록 신중하게 제한했습니다.

YAPF로 공백을 수정한 후의 diff
향후 변경 사항도 동일한 스타일 규칙을 따르도록 빌드 스크립트에 새로운 검사를 추가했습니다:
yapf \
--diff \
--recursive \
--style google \
./ \
--exclude="third_party/*" \
--exclude="build/*"앞선 명령과 동일하지만 --in-place 플래그 대신 --diff 플래그를 사용했습니다. YAPF가 공백 규칙 위반을 감지하면 이를 출력한 뒤 실패 종료 코드를 반환해 빌드 스크립트가 실패로 종료되도록 합니다.
정적 분석 추가하기
pyflakes는 제가 파이썬 툴체인에 항상 추가하는 유용한 도구 중 하나입니다. 정적 분석을 이용해 초기화되지 않은 변수나 사용되지 않는 import 같은 부주의한 오류를 찾아냅니다.
ingredient-phrase-tagger 빌드 스크립트에 추가했더니 곧바로 사용되지 않는 import를 잡아냈습니다:
$ pyflakes \
bin/ \
ingredient_phrase_tagger/
ingredient_phrase_tagger/training/utils.py:3: 'string' imported but unused이제 코드를 읽어볼 시간
이 과정 내내 제가 코드를 이해하려는 시도를 피해 왔다는 것을 눈치채셨을지도 모르겠습니다. 라이브러리의 동작을 피상적으로만 이해한 채 넘어왔을 뿐입니다.
제가 찾은 코드를 읽는 가장 좋은 방법은 진행하면서 리팩터링하고 테스트하는 것입니다. 저명한 소프트웨어 전문가인 마틴 파울러(Martin Fowler)가 이 과정을 가장 잘 설명합니다:
낯선 코드를 볼 때, 나는 그것이 무슨 일을 하는지 이해하려고 노력합니다. 몇 줄을 보고 스스로에게 말하죠, ‘아, 이 코드 조각이 이런 일을 하는구나’라고요. 리팩터링을 할 때는 그 머릿속 메모에서 멈추지 않습니다. 실제로 코드를 변경해 내 이해를 더 잘 반영하도록 만들고, 그다음 코드를 다시 실행해 여전히 제대로 동작하는지 확인함으로써 그 이해를 테스트합니다.
-마틴 파울러(Martin Fowler), Refactoring: Improving the Design of Existing Code
잘못된 코드 구성 바로잡기
라이브러리 전체 코드의 80%가 단 두 파일, 즉 cli.py(커맨드 라인 인터페이스)와 utils.py(유틸리티)에 몰려 있었습니다. 다시 말해 저자들은 코드를 “사용자 인터페이스”와 “그 외 모든 것”이라는 두 바구니로 나눈 셈입니다. 하지만 그 구분조차 깔끔하지 않았습니다.
cli.py에 있는 코드 중 커맨드 라인에서 읽고 쓰는 것과 관련된 부분은 거의 없었습니다. Cli라는 단일 클래스로 구성되어 있었고, 다음과 같은 메서드들을 가지고 있었습니다:
rungenerate_dataparseNumbersmatchUpaddPrefixesbestTag_parse_args
제가 가장 먼저 할 일은 Cli 클래스를 다이어트시켜 커맨드 라인 인터페이스에 대한 더 논리적인 추상화가 되도록 만드는 것이었습니다.
Cli 클래스 해부하기
Cli 클래스를 쪼개기 위해 시작점이 필요했습니다. generate_data는 사용자 인터페이스를 담당하는 클래스에 속해 있을 것 같지 않았지만, 바로 옮길 수는 없었습니다. generate_data가 self 파라미터를 통해 Cli의 다른 메서드들을 호출하고 있었고, 이는 클래스의 나머지 부분과 상태를 공유한다는 의미였기 때문입니다.
정말 그랬을까요? cli.py의 모든 함수는 Cli 클래스의 멤버 메서드였지만, 실제로 인스턴스 변수를 공유하고 있었을까요?
Cli의 생성자를 확인했습니다:
def __init__(self, argv):
self.opts = self._parse_args(argv)
self._upstream_cursor = None생성자는 self._upstream_cursor에 값을 할당했지만, 그 변수를 참조하는 곳은 어디에도 없었습니다. 죽은 코드였으므로 쉽게 삭제할 수 있었습니다.
다른 멤버 변수인 self.opts는 죽은 코드는 아니었지만, 이를 참조하는 메서드는 run과 generate_data 두 개뿐이었습니다.
공유하는 상태가 없으니 Cli의 다른 public 메서드들이 굳이 메서드일 이유가 없었습니다. 모두 모듈 레벨의 자유 함수로 존재해도 충분했습니다. 더 나아가 cli보다 그 목적을 더 잘 설명하는 완전히 새로운 모듈로 옮길 수도 있었습니다.
깔끔한 추상화 만들기
Cli의 대부분 메서드가 다른 모듈에 존재할 수 있다는 것을 알게 된 뒤, 그 새로운 모듈을 설계해야 했습니다. 물론 모든 함수를 옮겨 전부 public으로 만들 수도 있었지만, 저는 Cli 클래스와 이 새로운 모듈 사이의 최소한의 인터페이스를 찾고 싶었습니다.
Cli가 다른 모든 함수들을 generate_data의 루프 본문 안에서 호출한다는 것을 깨달았습니다. 그 코드를 새로운 함수로 추출한다면, Cli는 이전 메서드들 대신 그 새로운 함수에만 접근하면 될 것이었습니다.

generate_data의 루프 본문을 translate_row라는 새로운 함수로 추출하기
이 변경으로 Cli 클래스는 더 날씬해지고 논리적으로 더 응집력 있게 되었습니다. 이제 단 두 개의 public 메서드와 하나의 private 메서드로 구성됩니다:
rungenerate_data_parse_args
아직 완벽하지는 않았지만 이전의 비대했던 인터페이스보다는 나아졌습니다. 물론 더 하고 싶은 변경은 많았지만, 그것은 나중으로 미뤄야 했습니다.
실수 가능성을 최소화하기 위해 리팩터링 과정의 각 풀 리퀘스트 범위를 좁게 유지했습니다. 파일을 옮길 때는 이동 자체가 라인 단위 수정을 알아차리기 어렵게 만들기 때문에 변경을 최소화하는 것이 특히 중요합니다.
엔드 투 엔드 테스트가 통과했고, 이는 이동 과정에서 중요한 것을 망가뜨리지 않았다는 뜻이었습니다. 하지만 작업은 아직 끝나지 않았습니다. 리팩터링으로 새로운 함수가 생겼으니 이를 검증할 새로운 단위 테스트가 필요했습니다.
첫 번째 단위 테스트
단위 테스트를 만드는 일은 쉬웠습니다. translator.translate_row의 시작과 끝에 디버그 로그 문을 임시로 추가해 입력과 출력을 출력했습니다. 그 값들이 첫 번째 단위 테스트의 입력과 예상 출력이 되었습니다:
def test_translates_row_with_simple_phrase(self):
row = {
'index': 162,
'input': '2 cups flour',
'name': 'flour',
'qty': 2.0,
'range_end': 0.0,
'unit': 'cup',
'comment': '',
}
self.assertMultiLineEqual("""
2\tI1\tL4\tNoCAP\tNoPAREN\tB-QTY
cups\tI2\tL4\tNoCAP\tNoPAREN\tB-UNIT
flour\tI3\tL4\tNoCAP\tNoPAREN\tB-NAME
""".strip(),
translator.translate_row(row).strip())이 함수가 정확히 무슨 일을 하는지 아직 완전히 이해하지는 못했지만, 단위 테스트 덕분에 조금 더 가까워졌습니다. 이 함수가 다음과 같이 생긴 CSV 파일에 담긴 라이브러리의 학습 데이터를 처리한다는 것을 알게 되었습니다:
| index | input | name | qty | range_end | unit | comment |
|---|---|---|---|---|---|---|
| 162 | 2 cups flour | flour | 2.0 | 0.0 | cup |
함수는 라이브러리의 머신러닝 엔진이 이해하는 탭으로 구분된 값들의 집합을 반환했습니다.
다양한 유형의 재료를 다루기 위해 몇 가지 단위 테스트를 더 추가했습니다: 분수가 포함된 재료("1 1/2 teaspoons salt")와 설명이 붙은 재료("Half a vanilla bean, split lengthwise, seeds scraped")입니다.
빌드에 단위 테스트 통합하기
단위 테스트는 빌드 과정에 통합되지 않으면 큰 의미가 없으므로, 이를 포함하도록 빌드 스크립트를 업데이트했습니다:

빌드 스크립트에 단위 테스트 실행 추가
Travis 지속적 통합이 이미 모든 코드 변경마다 제 빌드 스크립트를 실행하고 있었기 때문에, 다음 Travis 빌드에서 단위 테스트 출력을 확인할 수 있었습니다:

Travis 빌드 출력에서의 단위 테스트 로그
코드 커버리지 추가하기
리팩터링을 하면서 더 많은 코드를 테스트하게 되어 코드 커버리지 수치가 올라가는 것을 보는 것을 좋아합니다. 파이썬 프로젝트에서는 coverage 모듈로 커버리지 정보를 수집하고 Coveralls로 그 결과를 웹 대시보드에서 확인할 수 있도록 합니다.
파이썬 기본 단위 테스트 러너에서 coverage로 전환하는 데는 빌드 스크립트에 아주 사소한 변경만 필요했습니다:
-python -m unittest discover
+coverage run -m unittest discover그런 다음 Travis 설정에 after_success 키를 추가해 Travis가 코드 커버리지 정보를 Coveralls에 업로드하도록 했습니다.
after_success:
- pip install pyyaml coveralls
- coverallsCoveralls를 확인하며 코드 커버리지 통계를 기대했는데, 결과는…

Coveralls에 코드 커버리지 정보가 표시되지 않음
아무것도 없었습니다.
내 코드 커버리지는 어디로 갔을까?
저는 과거에 수십 개의 프로젝트에서 Coveralls를 사용해 왔기 때문에 왜 아무것도 표시되지 않는지 이해할 수 없었습니다. 그저 단순한 파이썬 프로젝트일 뿐이었습니다. coverage 명령은 코드 커버리지 정보가 담긴 .coverage라는 파일을 생성하고, coveralls 명령은 이를 Coveralls 대시보드에 업로드해야 했습니다.
아, 그거였군요! coverage 명령은 제 Docker 컨테이너 안에서 실행됐지만, coveralls 바이너리는 표준 Travis 환경에서 실행됐기 때문에 .coverage 파일을 찾을 수 없었던 것입니다. Docker 컨테이너에서 바깥의 Travis 환경으로 파일을 복사하지 않았던 것입니다.
간단히 고칠 수 있었습니다. Docker 컨테이너에서 .coverage 파일을 추출하는 명령을 추가하기만 하면 됐습니다:
after_success:
- pip install pyyaml coveralls
- docker cp ingredient-phrase-tagger-container:/app/.coverage ./
- coveralls그런데도 Coveralls 대시보드에는 여전히 아무것도 표시되지 않았습니다:

Coveralls에 여전히 코드 커버리지 정보가 표시되지 않음
하지만 Travis 빌드에서는 이전 빌드에서는 보이지 않던 출력이 나타났습니다:
$ coveralls
Submitting coverage to coveralls.io...
No source for /app/ingredient_phrase_tagger/__init__.py
No source for /app/ingredient_phrase_tagger/training/__init__.py
No source for /app/ingredient_phrase_tagger/training/cli.py
No source for /app/ingredient_phrase_tagger/training/translator.py
No source for /app/ingredient_phrase_tagger/training/utils.py
Coverage submitted!
Job #177.1
https://coveralls.io/jobs/39259674그때 또 다른 문제가 있다는 것을 깨달았습니다.
Travis와 Docker는 파일시스템을 서로 다르게 바라보고 있었습니다. 예를 들어, 두 환경이 cli.py 파일을 바라보는 경로는 다음과 같았습니다:
| 환경 | 파일 경로 |
|---|---|
| Docker 컨테이너 | /app/ingredient_phrase_tagger/training/cli.py |
| Travis | /home/travis/ingredient_phrase_tagger/training/cli.py |
그렇다면 Travis에서 coveralls가 출력한 오류 메시지가 이해가 됩니다:
No source for /app/ingredient_phrase_tagger/training/cli.py.coverage 안의 경로가 Docker 컨테이너의 파일시스템 관점을 기준으로 작성되었기 때문에 Coveralls가 파일을 찾을 수 없었던 것입니다. /app 경로는 Travis 파일시스템에는 존재하지 않았습니다.
동일한 파일을 서로 다르게 바라보는 두 환경 사이의 간극을 어떻게 메울 수 있을까요? 해결책을 찾긴 했지만 다소 복잡했습니다.
우회적인 경로 변환 방법
coverage 문서에서 여러 파일시스템의 경로를 결합하는 것을 다루는 paths 옵션 지원을 발견했습니다:

paths 옵션 문서
이 옵션을 사용하기 위해 다음과 같은 .coveragerc 파일을 만들었습니다:
[run]
source = ingredient_phrase_tagger
; Run in parallel mode so that coverage can canonicalize the source paths
; regardless of whether it runs locally or within a Docker container.
parallel = True
[paths]
; the first path is the path on the local filesystem
; the second path is the path as it appears within the Docker container
source =
ingredient_phrase_tagger/
/app/ingredient_phrase_tagger/새로운 해결책은 Docker 컨테이너 안에서 coverage 명령을 실행한 뒤, Travis 환경에서 coverage combine 기능을 실행해 모든 경로를 Travis 파일시스템 기준으로 정규화하는 것이었습니다.
이 해결책을 적용한 뒤, 제 Travis 설정의 after_success 섹션은 다음과 같이 바뀌었습니다:
after_success:
- pip install pyyaml coveralls
# Copy the .coverage.* file from the Docker container to the local filesystem.
- docker cp ingredient-phrase-tagger-container:/app/$(docker exec -it ingredient-phrase-tagger-container bash -c "ls -a .coverage.*" | tr -d '\r') ./
# Use coverage combine to canonicalize the source paths.
- coverage combine
# Upload coverage information to Coveralls.
- coveralls마침내 코드 커버리지를 얻다
전체 해결책을 테스트에 부쳤습니다. 마침내 Coveralls가 결과를 수신하고 제 코드 커버리지 수치를 표시했습니다:

Coveralls에 마침내 코드 커버리지 정보가 표시됨.
이제 이 라이브러리가 부활했다고 선언합니다
코드 커버리지 추적을 통합한 뒤, 이 라이브러리가 다시 살아났다는 느낌이 들었습니다. 품질로 상을 받을 정도는 아니었지만, 저나 다른 개발자가 높은 신뢰도를 가지고 코드를 계속 개선해 나갈 수 있는 인프라는 갖추어졌습니다.
이 블로그 시리즈 전반에 걸쳐 저는 라이브러리를 작고 개별적인 단계로 어떻게 개선했는지 설명했습니다. 이는 버그 발생 가능성을 최소화했지만 전체 그림을 다소 가렸을 수도 있습니다. 관점을 잡기 위해 부활 과정에서 라이브러리에 가한 큰 차원의 개선 사항을 정리해 보겠습니다:
| 이전 | 이후 |
|---|---|
| OS X에서만 빌드됨 | Docker를 지원하는 모든 환경에서 빌드 가능 |
| 엔드 투 엔드 테스트 없음 | 철저한 엔드 투 엔드 테스트 보유 |
| 단위 테스트 없음 | 소수의 단위 테스트와 추가를 위한 간편한 메커니즘 보유 |
| 코드 커버리지 정보 없음 | 모든 커밋마다 코드 커버리지를 측정하고 시간에 따른 커버리지 이력을 유지 |
| 자동화된 빌드 없음 | 모든 커밋마다 코드를 자동으로 빌드하고 테스트 |
| 일관되지 않은 코드 스타일 | 자동화된 도구를 통해 스타일 규칙 강제 |
| 개발자가 사용되지 않는 import와 초기화되지 않은 변수를 수동으로 찾아야 함 | 정적 분석을 적용해 부주의한 오류를 자동으로 포착 |
버리기 위한 리팩터링
이러한 변화가 자랑스러웠던 만큼, 몇 주 더 코드를 개선한 뒤 결국 이를 버리고 완전히 새로 작성했다는 사실이 놀라울 수도 있습니다.
…하나를 버릴 계획으로 하라. 어차피 그렇게 될 것이다.
-프레드 브룩스(Fred Brooks), The Mythical Man-Month: Essays on Software Engineering
코드를 리팩터링하면 할수록 근본적인 아키텍처의 문제점을 더 많이 인식하게 되었습니다. 그렇다고 코드를 개선하는 데 들인 노력이 헛되었다는 뜻은 아닙니다. 깊은 이해를 얻기 위해서는 직접 손을 더럽혀야 했습니다. 모든 것을 이해하고 나니 유지보수성과 성능을 높이기 위해 처음부터 다시 작성하는 것이 편안하게 느껴졌습니다.
그 결과 Zestful이라는 서비스가 탄생했습니다. ingredient-phrase-tagger와 유사한 기능을 제공하지만 호스팅된 API 형태로 제공합니다. 클라이언트는 제가 원래 라이브러리를 동작하게 만들기 위해 거쳐야 했던 온갖 번거로운 과정을 거치지 않고도 즉시 재료를 파싱할 수 있습니다.
Zestful이 동작하는 모습을 보고 싶다면 라이브 데모를 확인해 보세요:
표지 일러스트: Loraine Yow. 제가 포크한 ingredient-phrase-tagger 라이브러리는 GitHub에서 보실 수 있습니다. 이 라이브러리를 기반으로 한 관리형 서비스인 Zestful을 제공하고 있습니다.
글을 무작위로 읽기

