문제 발생 경위
얼마 전 작성돼있는 llm 서비스에 요청을 보내는 용도로 사용하는 객체를 생성하는 함수가 해당 서비스 모듈 자체에 작성돼있지 않고, 이를 호출하는 라우터들마다 별개로 정의돼있다는 사실을 확인할 수 있어 이에 대한 리팩토링을 진행했습니다.
해당 변경은 워낙 마이너했기 때문에 작업 자체는 오랜 시간이 걸리지 않았습니다만, 문제는 이후에 객체를 생성하던 라우터들에 대한 모든 유닛 테스트가 깨져버렸다는 것입니다.
llm에 요청을 보내는 서비스 클래스를 LLMService, 이를 활용하는 모듈의 이름을 assistant.py라 부르면 다음과 같은 예시를 생각하시면 될 것 같습니다.
old_assistant.py
from AI import LLMService
def get_LLM():
return LLMService()
def request_assist():
LLM = get_LLM()
LLM.request_assist()
new_assistant.py
from AI import get_LLM
def request_assist():
LLM = get_LLM()
LLM.request_assist()
new_AI.py
class LLMService():
def request_assist():
...
def get_LLM():
return LLMService()
test_assistant.py
import pytest
from new_assistant import request_assist
@patch('AI.LLMService')
def test_request_assist(mock_get_LLM):
mock_LLM = MagicMock()
mock_get_LLM.return_value = mock_LLM
mock_LLM.request_assist.return_value = "Hello, world!"
result = request_assist()
assert result == "Hello, world!"
mock_LLM.request_assist.assert_called_once()
위의 내용을 트러블슈팅하는 과정에서, 문제를 해결하기 위해서는 파이썬의 import가 동작하는 방식에 대한 이해를 할 필요가 있다는 사실을 알게 돼 해당 이슈를 간단히 살펴볼 필요가 있었어서 이에 대한 기록을 공유합니다.
파이썬에서 모듈을 임포트하는 방식
파이썬은 외부의 모듈을 임포트 할 때 가장 먼저 sys.modules라는 이름의 모듈들이 매핑한 딕셔너리를 찾아보게 됩니다. 이 딕셔너리에서 모듈이 존재하는 것을 확인했을 경우, 그 하위의 함수, 클래스와 같은 최상위 구성 요소들을 호출했을 때 네임스페이스들을 확인하고 사용하는 것이 그 원리라 할 수 있습니다.
이 모듈들이 최초에 딕셔너리에 로드되는 시점은, 모듈에 대한 호출이 발생했고 미스가 발생했을 때입니다. 미스가 발생할 경우 import를 위해 모듈 내부에 작성된 loader의 load_module 또는 exec_module을 수행해 해당 모듈을 자신의 네임스페이스에서 동작시키고, 모듈을 로드합니다. 이 때 load_module은 python 3.4이전의 구버전에서 동작하는 방식이고, exec_module은 이후에 정의된 새로운 방식입니다.
이 과정에서 .pyc라고 하는 파이썬 인터프리터에서 해석하기 위한 바이트코드가 생성되며, 최종적으로 우리가 임포트하는 것은 이 바이트 코드가 파싱되고 실행되어 메모리에 로드된 모듈 객체입니다. 우리가 변수를 통해 접근하는 것은 그 모듈 객체에 대한 참조입니다.
왜 기존의 @patch는 깨지는가
pytest의 @patch는 해당 네임스페이스의 모듈을 호출하려는 주소 값을 모킹하는 형태로 동작합니다.
즉, 위의 예시를 들어 설명하자면 AI.LLMService
라는 주소에 접근하는 시점에 대한 모킹이 발생한다는
것입니다.
문제는 get_LLM
의 층위가 AI
모듈로 이동하면서 동일한 네임스페이스에 위치하게 됐다는 점에서
기인합니다. get_LLM
은 더이상 AI.LLMSerivce
의 형태로 해당 생성자를 호출하지 않습니다.
이 때문에 기존에 AI.LLMService
를 호출하는 경우에 대한 메모리를 목 객체로 치환해주는
로직은 더 이상 동작하지 않는 것입니다.
어떻게 위의 테스트 코드를 변경해야하는가
지루하고 현학적인 설명이 길었습니다만, 결론적으로 해당 리팩토링이 이루어진 이후에 바꿔줘야하는 변경사항은 크지 않습니다.
new_test_assistant.py
import pytest
from new_assistant import request_assist
from unittest.mock import patch, MagicMock
@patch('AI.get_LLM')
def test_request_assist(mock_get_LLM):
mock_LLM = MagicMock()
mock_get_LLM.return_value = mock_LLM
mock_LLM.request_assist.return_value = "Hello, world!"
result = request_assist()
assert result == "Hello, world!"
mock_LLM.request_assist.assert_called_once()
후기
생각지도 못한 문제가 발생하고, 이에 대한 트러블슈팅을 하는 과정에서 파이썬에 대해 꽤나 깊게 파고들어 볼 수 있었던 시간이 생겨 나름 재밌는 경험이었습니다.
이 글이 다른 비슷한 문제를 겪는 분들께 도움이 되길 바랍니다.
참고자료
- https://docs.python.org/ko/dev/reference/import.html
- https://docs.python.org/3/library/sys.html
- https://peps.python.org/pep-0302/
- https://docs.oracle.com/en/graalvm/enterprise/22/docs/reference-manual/python/ParserDetails/