HOWTO · Python
파이썬에서 원숭이 패치하기
이 기사에서는 Monkey Patching이 무엇이며 Python에서 어떻게 구현할 수 있는지 설명합니다. 두 개의 섹션이 있습니다. 첫 번째는 함수의 원숭이 패치를 보여주고 두 번째는 파일의 단위 테스트에서 구현될 수 있는 방법을 보여줍니다.
이 페이지의 내용
몽키 패칭은 프로그램이 실행되는 동안 속성을 변경하거나 추가하는 기법입니다. Python에서는 클래스에 함수를 할당하거나 모듈의 이름이 가리키는 값을 바꾸면 이후의 속성 조회 결과가 달라집니다. 범위를 좁힌 테스트에 유용하지만, 변경 시점과 위치에 따라 동작이 달라질 수도 있습니다.
Python에서 몽키 패칭이란
Python에서는 프로그램이 실행되는 동안 여러 객체에 속성을 할당할 수 있습니다. 몽키 패칭은 보통 기존 속성을 바꾸거나 새 속성을 추가해 코드가 대체 동작을 사용하도록 하는 것을 말합니다. 원본 소스 파일을 수정할 필요는 없습니다. 또한 동적 언어만 사용할 수 있는 기법도 아닙니다. 다른 언어도 각 언어의 런타임이나 테스트 도구를 통해 비슷한 기능을 제공할 수 있습니다.
이 글에서 다루는 두 사례는 관련은 있지만 서로 다릅니다. 클래스에 함수를 할당하면 해당 클래스의 인스턴스에 동작이 추가됩니다. 모듈 전역 이름을 교체하면 그 모듈의 함수가 실행될 때 읽는 값이 바뀝니다. 두 번째 방식은 운영 코드가 설정된 경로나 협력 객체를 사용할 때 테스트에서 자주 유용합니다.
런타임에 클래스에 메서드 추가하기
일반 함수를 클래스에 할당하면 Python의 디스크립터 동작에 따라 인스턴스를 통해 함수에 접근할 때 해당 인스턴스가 함수의 첫 번째 인수로 바인딩됩니다. 함수에 self 매개변수를 정의한 다음 클래스로 할당하세요. 이렇게 하면 기존 클래스 객체가 바뀌며, 새 클래스가 만들어지는 것은 아닙니다.
함수를 클래스에 할당하면 디스크립터로 저장됩니다. 인스턴스를 통해 접근하면 해당 인스턴스가 첫 번째 인수로 바인딩되므로 self를 매개변수로 정의해야 합니다. 다음 예제를 실행해 보세요.
이 섹션의 코드와 표시되는 레이블 또는 출력은 영어로 유지됩니다.
class Greeter:
pass
def greet(self, name):
return f"Hello, {name}!"
Greeter.greet = greet
print(Greeter().greet("Ada"))
Hello, Ada!
이 할당은 현재 프로세스의 Greeter 클래스와 그 인스턴스에 적용됩니다. 대신 함수 하나를 단일 인스턴스에 붙이면 그 인스턴스에 저장되며, 클래스에서 찾은 함수와 같은 방식으로 메서드 바인딩이 자동 적용되지는 않습니다. 모든 인스턴스에서 추가된 메서드를 사용할 수 있어야 한다면 클래스에 할당하세요.
pytest 테스트에서 모듈 전역 이름 패치하기
pipeline.py에 DATA_DIR = Path(__file__).parent / "data"가 정의되어 있고, collect_files(pattern) 함수가 list(DATA_DIR.glob(pattern))을 반환한다고 가정해 보겠습니다. 이 함수는 호출될 때 pipeline 모듈에서 DATA_DIR을 조회합니다. 테스트에서 임시 디렉터리를 만들기만 해서는 이 조회 위치가 바뀌지 않으므로, 함수가 이름을 찾는 모듈에서 해당 이름을 교체해야 합니다.
pytest 테스트에서 tmp_path는 임시 Path를 제공합니다. 아래 예제는 collect_files 함수가 모듈의 네임스페이스에서 DATA_DIR을 조회하는 작은 pipeline 모듈을 정의합니다. 모듈의 기본 데이터 디렉터리는 비어 있고, 테스트 디렉터리에는 file1.txt가 있습니다. 패치하기 전에는 assert pipeline.collect_files("*.txt") == [expected_file] 검증이 실패합니다. 비교 결과가 [] == [expected_file]이기 때문입니다. monkeypatch.setattr을 적용한 뒤에는 함수가 테스트 디렉터리의 파일을 반환합니다. 이는 제시된 설정에서 예상되는 결정적 실패를 설명한 것이며, 실제 pytest 실행 결과를 기록한 것은 아닙니다.
이 섹션의 코드와 표시되는 레이블 또는 출력은 영어로 유지됩니다.
from pathlib import Path
import tempfile
import types
pipeline = types.ModuleType("pipeline")
production_data = tempfile.TemporaryDirectory()
pipeline.DATA_DIR = Path(production_data.name)
exec(
"def collect_files(pattern):\n"
" return list(DATA_DIR.glob(pattern))\n",
pipeline.__dict__,
)
def test_collect_files_uses_temporary_data(tmp_path, monkeypatch):
test_data = tmp_path / "data"
test_data.mkdir()
expected_file = test_data / "file1.txt"
expected_file.write_text("sample", encoding="utf-8")
files_before_patch = pipeline.collect_files("*.txt")
assert files_before_patch == []
monkeypatch.setattr(pipeline, "DATA_DIR", test_data)
assert pipeline.collect_files("*.txt") == [expected_file]
패치 대상이 중요합니다. collect_files가 해당 모듈 전역 이름을 조회하므로 pipeline.DATA_DIR을 패치해야 합니다. 테스트 모듈에 있는 같은 이름의 변수를 패치해도 pipeline.collect_files가 사용하는 전역 값은 바뀌지 않습니다. 픽스처 이름은 monkeypatch이며, 속성 값을 교체하는 작업은 monkeypatch.setattr으로 수행합니다. pytest는 테스트가 실패하더라도 테스트가 끝난 뒤 원래 값을 복원하므로 변경 범위를 해당 테스트 안으로 제한할 수 있습니다. 픽스처가 값 교체를 관리해 주는 것이지, 테스트의 임시 파일이나 디렉터리를 모듈의 데이터 디렉터리로 자동 지정하는 것은 아닙니다.
몽키 패칭은 신중하게 사용하기
운영 코드의 호출 경로를 바꾸지 않고 테스트에서 의존성을 교체하거나 모듈 수준 설정을 다른 값으로 바꿔야 할 때 몽키 패칭이 유용합니다. 교체는 해당 테스트 안에서만 수행하고, 코드가 실제로 조회하는 이름을 대상으로 삼으세요. 테스트가 끝난 뒤 원래 속성이 복원되도록 pytest의 monkeypatch 픽스처를 사용하는 편이 좋습니다.
공유 클래스나 모듈을 바꾸면 이후에 실행되는 관련 없는 코드에도 영향을 줄 수 있으며, 결과가 import 순서나 테스트 순서에 따라 달라질 수 있습니다. 일반 설정, 의존성 주입 또는 작은 래퍼 함수로 의존성을 명시하는 편이 더 명확하다면 애플리케이션 전체에 영향을 주는 패치는 피하세요. 추가한 메서드를 해당 클래스의 인스턴스에서 사용할 수 있어야 할 때는 클래스를 패치하고, 함수가 테스트에서 교체하려는 모듈 수준 이름을 읽을 때는 모듈 전역을 패치하는 것이 적절합니다. 이 차이를 이해하면 실패 원인을 더 쉽게 찾고 예상치 못한 동작을 줄일 수 있습니다.