Resurrecting a Dead Library: Part Three - Rehabilitation

Michael Lynch

죽은 라이브러리 되살리기: 3부 - 재활

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

나는 리팩터링을 좋아한다. 스파게티 코드를 풀어헤쳐 그 안에 숨은 논리를 명확하고 직관적인 형태로 드러내는 것만큼 만족스러운 일은 없다.

리팩터링에는 꼼꼼함이 필요하다는 걸 배웠다. 더 어리고 무모했던 시절에는 레거시 코드베이스에 뛰어들어 제어된 변경 따위는 신경 쓰지 않고 코드를 마구 뜯어고치곤 했다. 그러면 어김없이 며칠 혹은 몇 주 뒤, 사소해 보여서 지워버린 코드 조각이 사실은 특정한 예외 상황에서 핵심적인 역할을 한다는 걸 깨달으며 코드가 망가졌다는 걸 알게 되곤 했다.

이번 글에서는 신중하게 리팩터링하는 방법을 보여주려 한다. 실제 레거시 Python 라이브러리를 리팩터링하면서 적용했던 기법들을 설명할 것이다. 실수를 최소화하기 위해 사용한 개발 툴체인과 기존 동작을 고정하기 위해 유닛 테스트를 추가한 과정도 포함한다.

이 글은 버려진 라이브러리를 되살려 내 SaaS 비즈니스를 뒷받침하게 된 과정을 다룬 3부작 시리즈의 마지막 글이다. 그 주인공은 머신러닝을 이용해 요리 재료 문구(예: “2 cups milk”)를 구조화된 데이터로 파싱하는 라이브러리인 ingredient-phrase-tagger다. 전체 맥락은 1부에서 확인할 수 있지만, 요약하자면 버려진 라이브러리를 발견해 다시 살려낸 이야기다:

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

껍질에서 꺼내지고 있는 소라게

우리는 어디까지 왔나?

앞선 두 글에서 나는 이 라이브러리를 어디서든 사용할 수 있도록 커스텀 Docker 이미지를 만들고, 상위 수준의 동작을 보존하기 위해 end-to-end 테스트를 추가했다. 코드베이스에 변경이 있을 때마다 Travis CI가 모든 의존성을 빌드하고 제어된 환경에서 테스트를 실행하도록 했다.

여기까지 나는 코드 자체를 수정하지 않았다. 기존 코드 위에 그 동작을 검증하기 위한 도구와 스크립트만 추가했을 뿐이다. 이제 코드를 안전하게 수정할 수 있는 모든 장치를 마련했으니, 마침내 리팩터링을 시작할 수 있었다.

공백 규칙 강제하기

개발자는 공백 때문에 정신적 에너지를 낭비해서는 안 된다. 나는 새로운 소프트웨어 프로젝트를 시작할 때마다 가능한 한 빨리 공백 포맷팅을 자동화한다.

Python 프로젝트에서는 YAPF(Yet Another Python Formatter)로 이를 구현한다. 이 프로젝트에서 내가 처음 한 코드 변경은 내가 선호하는 표준인 Google Python Style Guide에 맞춰 모든 파일을 다시 포맷팅한 것이었다:

yapf \
  --in-place \
  --recursive \
  --style google \
  ./ \
  --exclude="third_party/*" \
  --exclude="build/*"

이로 인해 상당한 코드 변경이 발생했지만, YAPF는 성숙한 도구이고 end-to-end 테스트도 여전히 통과했기에 안전한 변경이라고 확신했다.

다른 변경 사항이 잡음에 묻혀 Pull Request 리뷰를 어렵게 만들지 않도록, 해당 Pull Request는 공백 변경에만 국한하도록 신중을 기했다.

YAPF 변경으로 인한 diff

YAPF로 공백을 수정한 뒤의 diff

향후 변경 사항도 동일한 스타일 규칙을 따르도록 빌드 스크립트에 새로운 검사를 추가했다:

yapf \
  --diff \
  --recursive \
  --style google \
  ./ \
  --exclude="third_party/*" \
  --exclude="build/*"

앞서 실행한 명령과 동일하지만 --in-place 플래그 대신 --diff 플래그를 사용했다. YAPF가 공백 규칙 위반을 감지하면 내용을 출력한 뒤 실패 종료 코드를 반환해 빌드 스크립트가 실패로 종료되도록 한다.

정적 분석 도입하기

pyflakes는 내가 Python 툴체인에 항상 추가하는 또 다른 유용한 도구다. 정적 분석을 이용해 초기화되지 않은 변수나 사용되지 않는 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라는 단일 클래스로 이루어져 있었고, 다음과 같은 메서드들을 가지고 있었다:

  • run
  • generate_data
  • parseNumbers
  • matchUp
  • addPrefixes
  • bestTag
  • _parse_args

내가 가장 먼저 해야 할 일은 Cli 클래스를 간소화해 커맨드라인 인터페이스라는 추상화에 더 논리적으로 부합하도록 만드는 것이었다.

Cli 클래스 해부하기

Cli 클래스를 나누려면 시작점이 필요했다. generate_data는 사용자 인터페이스를 담당하는 클래스에 속할 것 같지 않았지만, 바로 옮길 수는 없었다. generate_dataself 파라미터를 통해 Cli의 다른 메서드들을 호출하고 있었고, 이는 클래스의 나머지 부분과 상태를 공유한다는 의미였기 때문이다.

정말 그랬을까? cli.py의 모든 함수는 Cli 클래스의 멤버 메서드였지만, 실제로 인스턴스 변수를 공유하고 있었을까?

Cli의 생성자를 확인해 봤다:

def __init__(self, argv):
      self.opts = self._parse_args(argv)
      self._upstream_cursor = None

생성자는 self._upstream_cursor에 값을 할당했지만, 그 변수를 참조하는 곳은 어디에도 없었다. 죽은 코드였으니 쉽게 삭제할 수 있었다.

다른 멤버 변수인 self.opts는 죽은 코드는 아니었지만, 이를 참조하는 메서드는 rungenerate_data 두 개뿐이었다.

공유하는 상태가 없으니, Cli의 다른 public 메서드들이 굳이 메서드일 이유가 없었다. 모두 모듈 수준의 독립 함수로 충분히 존재할 수 있었다. 더 나아가 cli보다 그 목적을 더 잘 설명하는 완전히 새로운 모듈로 옮길 수도 있었다.

깔끔한 추상화 만들기

Cli의 메서드 대부분이 다른 모듈에 있을 수 있다는 걸 알게 된 뒤에는 그 새 모듈을 설계해야 했다. 물론 모든 함수를 옮겨 전부 public으로 만들 수도 있었지만, 나는 Cli 클래스와 이 새 모듈 사이의 최소한의 인터페이스를 찾고 싶었다.

Cli가 다른 모든 함수를 generate_data의 루프 본문 안에서 호출한다는 걸 깨달았다. 그 코드를 새 함수로 추출하면, Cli는 이전 메서드들 대신 그 새 함수 하나에만 접근하면 되게 된다.

YAPF 변경으로 인한 diff

generate_data의 루프 본문을 translate_row라는 새 함수로 추출하기

이 변경으로 Cli 클래스는 더 간결해지고 논리적으로 더 응집력 있게 되었다. 이제 단 두 개의 public 메서드와 하나의 private 메서드로 구성된다:

  • run
  • generate_data
  • _parse_args

여전히 완벽하진 않았지만, 이전의 비대했던 인터페이스보다는 나아졌다. 분명 더 만들고 싶은 변경이 많았지만, 그건 나중으로 미뤄야 했다.

실수할 확률을 최소화하기 위해 리팩터링 과정의 각 Pull Request 범위를 좁게 유지했다. 파일을 옮길 때는 이동 자체만으로도 라인 단위 수정을 알아차리기 어려워지므로 변경을 최소화하는 것이 특히 중요하다.

end-to-end 테스트가 통과했고, 이는 이동 과정에서 중요한 것을 망가뜨리지 않았다는 뜻이었지만, 작업은 아직 끝나지 않았다. 리팩터링으로 새 함수가 생겼으니, 이를 검증할 새로운 유닛 테스트가 필요했다.

첫 번째 유닛 테스트

유닛 테스트를 만드는 건 쉬웠다. 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 파일에 담긴 라이브러리의 학습 데이터를 처리한다는 걸 알게 되었다:

indexinputnameqtyrange_endunitcomment
1622 cups flourflour2.00.0cup

이 함수는 라이브러리의 머신러닝 엔진이 이해하는 탭으로 구분된 값들의 집합을 반환했다.

몇 개의 유닛 테스트를 더 추가해 다양한 유형의 재료를 다루도록 했다. 분수가 포함된 재료("1 1/2 teaspoons salt")와 코멘트가 붙은 재료("Half a vanilla bean, split lengthwise, seeds scraped")를 다루는 테스트였다.

유닛 테스트를 빌드에 통합하기

유닛 테스트는 빌드 과정에 통합되지 않으면 큰 의미가 없으므로, 빌드 스크립트를 업데이트해 테스트를 포함시켰다:

빌드 스크립트에 유닛 테스트 실행을 추가하는 diff 스크린샷

빌드 스크립트에 유닛 테스트 실행 추가하기

Travis CI가 이미 모든 코드 변경 시 빌드 스크립트를 실행하고 있었기 때문에, 다음 Travis 빌드에서 유닛 테스트 출력을 확인할 수 있었다:

유닛 테스트 로그 출력

Travis 빌드 출력에서의 유닛 테스트 로그

코드 커버리지 추가하기

리팩터링을 하면서 점점 더 많은 코드를 테스트하게 되어 코드 커버리지 수치가 올라가는 걸 보는 걸 좋아한다. Python 프로젝트에서는 coverage 모듈로 커버리지 정보를 수집하고, Coveralls를 이용해 결과를 웹 대시보드에서 볼 수 있게 한다.

Python의 기본 유닛 테스트 러너에서 coverage로 전환하는 데는 빌드 스크립트에 아주 사소한 변경만 필요했다:

-python -m unittest discover
+coverage run -m unittest discover

그 다음 Travis 설정에 after_success 키를 추가해 Travis가 코드 커버리지 정보를 Coveralls에 업로드하도록 했다.

after_success:
  - pip install pyyaml coveralls
  - coveralls

Coveralls를 확인하며 코드 커버리지 통계를 기대했는데…

결과를 표시하지 않는 Coveralls 스크린샷

Coveralls에 코드 커버리지 정보가 표시되지 않음

아무것도 없었다.

코드 커버리지는 어디로 갔을까?

과거에 수십 개 프로젝트에서 Coveralls를 사용해 왔기 때문에 왜 아무것도 표시되지 않는지 이해할 수 없었다. 그냥 단순한 Python 프로젝트일 뿐이었다. 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 스크린샷

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

Coveralls가 파일을 찾을 수 없었던 이유는 .coverage에 기록된 경로가 Docker 컨테이너의 파일시스템 관점을 기준으로 했기 때문이다. /app 경로는 Travis 파일시스템에는 존재하지 않았다.

동일한 파일에 대해 서로 호환되지 않는 관점을 가진 두 환경을 어떻게 연결할 수 있을까? 해결책을 찾긴 했지만, 다소 복잡한 방법이었다.

경로를 변환하는 우회적인 방법

coverage 문서에서 여러 파일시스템의 경로를 결합하는 것에 대해 설명하는 paths 옵션을 지원한다는 걸 알게 됐다:

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 스크린샷

Coveralls에 마침내 코드 커버리지 정보가 표시됨.

이제 이 라이브러리가 부활했다고 선언한다

코드 커버리지 추적을 통합한 뒤, 이 라이브러리가 다시 살아났다는 느낌이 들었다. 품질로 상을 받을 정도는 아니었지만, 나나 다른 개발자가 높은 신뢰도를 가지고 코드를 계속 개선해 나갈 수 있는 인프라는 갖춰진 셈이었다.

이 블로그 시리즈 전반에 걸쳐 나는 라이브러리를 작고 개별적인 단계들로 어떻게 개선했는지 설명했다. 이는 버그 가능성을 최소화했지만, 어쩌면 전체적인 그림을 가리기도 했다. 관점을 넓혀, 라이브러리를 되살리는 과정에서 내가 만든 상위 수준의 개선 사항들을 정리해 보겠다:

이전이후
OS X에서만 빌드됨Docker를 지원하는 모든 환경에서 빌드됨
end-to-end 테스트 없음철저한 end-to-end 테스트 보유
유닛 테스트 없음소수의 유닛 테스트와 더 쉽게 추가할 수 있는 메커니즘 보유
코드 커버리지 정보 없음모든 커밋마다 코드 커버리지를 측정하고 시간에 따른 커버리지 기록 유지
자동화된 빌드 없음모든 커밋마다 코드를 자동으로 빌드하고 테스트함
일관되지 않은 코드 스타일자동화된 도구를 통해 스타일 규칙 강제
개발자가 사용되지 않는 import와 초기화되지 않은 변수를 수동으로 찾아야 함정적 분석을 적용해 부주의한 오류를 자동으로 포착함

버릴 것을 전제로 한 리팩터링

이러한 변화들이 자랑스러웠던 만큼, 코드를 몇 주 더 개선한 뒤 결국 이를 버리고 완전히 새로 작성했다는 사실이 놀랍게 들릴 수도 있다.

…하나쯤은 버릴 것을 계획하라. 어차피 버리게 될 테니까.

-Fred Brooks, The Mythical Man-Month: Essays on Software Engineering

코드를 리팩터링하면 할수록 근본적인 아키텍처의 문제점이 더 많이 보였다. 그렇다고 코드를 개선하는 데 들인 노력이 헛됐다는 뜻은 아니다. 깊은 이해를 발전시키려면 직접 손을 더럽혀야 했다. 모든 것을 이해하고 나서야 더 나은 유지보수성과 성능을 위해 처음부터 다시 작성할 자신이 생겼다.

그 결과 Zestful이라는 서비스가 탄생했다. ingredient-phrase-tagger와 유사한 기능을 제공하지만, 호스팅된 API 형태로 제공된다. 클라이언트는 내가 원래 라이브러리를 동작하게 만들기 위해 거쳐야 했던 온갖 복잡한 과정을 거치지 않고도 즉시 재료를 파싱할 수 있다.

Zestful이 실제로 동작하는 모습을 보고 싶다면 라이브 데모를 확인해 보라:

Zestful 재료 파싱 데모 스크린샷


커버 일러스트: Loraine Yow. 내가 포크한 ingredient-phrase-tagger 라이브러리는 GitHub에서 볼 수 있다. 이 라이브러리를 기반으로 한 관리형 서비스인 Zestful을 제공하고 있다.

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

댓글