본문 바로가기

ETC/프로젝트

Tortoise ORM의 테스트 initializer 함수, 왜 connection을 없애버릴까?

안녕하세요 🙂

이번 글에서는 Tortoise ORM 기반 FastAPI 프로젝트에서 테스트 환경을 구성하던 중 겪었던 예상치 못한 오류와 이를 해결한 경험을 공유하려고 합니다.

저는 이전 글에서 소개했듯, Tortoise ORM에서 UUID와 관련된 문제로 인해 QueryExecutor 유틸을 만들어 raw SQL로 일부 로직을 처리하고 있었습니다. 이 구조는 실제 서버에서는 잘 동작했지만, 테스트 환경에서는 완전히 다른 문제가 발생했어요.


테스트에서만 터지는 이상한 오류

기존 테스트 코드는 다음과 같은 형태로 작성되어 있었습니다:

 
await QueryExecutor.execute_query(
    "SELECT * FROM user WHERE user_id = UNHEX(REPLACE(?, '-', ''))",
    [uuid_str],
    fetch_type="single",
)


운영 환경에서는 문제없이 작동하던 코드인데, 테스트 환경에서는 갑자기 이런 오류가 발생했습니다:

tortoise.exceptions.ConfigurationError: Connection "default" was not initialized


심지어 ORM의 메서드를 사용하는 다른 테스트는 멀쩡히 통과하는데, 유독 QueryExecutor에서만 위 오류가 발생했어요.



원인은 initializer() 함수 내부에 있었다

테스트는 pytest와 tortoise.contrib.test.initializer()를 사용해 매번 깨끗한 상태에서 테이블을 초기화하고 있었습니다:

 
from tortoise.contrib.test import finalizer, initializer

@pytest.fixture(scope="session", autouse=True)
def initialize(request: FixtureRequest) -> None:
    with patch("tortoise.contrib.test.getDBConfig", Mock(return_value=get_test_db_config())):
        initializer(modules=TORTOISE_APP_MODELS, loop=None)
    request.addfinalizer(finalizer)


하지만 이 initializer() 함수의 내부를 살펴보면,

def initializer(
    modules: Iterable[Union[str, ModuleType]],
    db_url: Optional[str] = None,
    app_label: str = "models",
    loop: Optional[AbstractEventLoop] = None,
) -> None:
    # pylint: disable=W0603
    global _CONFIG
    global _CONNECTIONS
    global _LOOP
    global _TORTOISE_TEST_DB
    global _MODULES
    global _CONN_CONFIG

    # 1. 모듈, DB URL 설정
    _MODULES = modules
    if db_url is not None:
        _TORTOISE_TEST_DB = db_url

    # 2. DB 설정 구성
    _CONFIG = getDBConfig(app_label=app_label, modules=_MODULES)

    # 3. 이벤트 루프 가져오기
    loop = loop or asyncio.get_event_loop()
    _LOOP = loop

    # 4. 초기화 비동기 함수 실행
    loop.run_until_complete(_init_db(_CONFIG))

    # 5. 연결 정보 백업
    _CONNECTIONS = connections._copy_storage()
    _CONN_CONFIG = connections.db_config.copy()

    # 6. 연결 정보 초기화 및 앱 등록 해제
    connections._clear_storage()
    connections.db_config.clear()
    Tortoise.apps = {}
    Tortoise._inited = False


테스트용 DB 스키마를 생성하고 테스트 이후 cleanup을 위해 모든 연결(connection)을 닫아버리는 로직이 포함되어 있습니다.

결국 Tortoise.get_connection("default")이나 Tortoise.atomic()을 사용하는 로직은 초기화 직후에 이미 연결이 닫혀버린 상태에서 동작하게 되기 때문에 “연결이 없음” 에러가 발생했던 것이었죠.

왜 connection을 다 없애는가?

이렇게 동작하는 이유는 테스트 환경을 완전히 격리시키기 위해서입니다. 테스트 코드가 이미 생성된 연결이나 설정을 암묵적으로 참조하는 상황을 방지하고, 각 테스트에서 명시적으로 Tortoise.init()을 다시 호출하도록 유도합니다. 즉, 테스트 실행이 내부 상태에 의존하지 않고 독립적으로 실행되도록 하는 구조입니다.

이 설계 덕분에 테스트 간 상태 공유로 인한 오류를 막을 수 있지만, ORM 외부에서 raw connection을 직접 다루는 코드나 유틸을 테스트할 경우엔 다시 수동으로 연결을 초기화해주는 처리가 필요합니다.




해결: 직접 연결을 수동으로 초기화하자

이 문제를 해결하기 위해 다음과 같이 새로운 fixture를 하나 추가했습니다:

 
@pytest.fixture()
async def init_tortoise_connection() -> AsyncGenerator[None, None]:
    await Tortoise.init(
        db_url=f"mysql://{settings.DB_USER}:{settings.DB_PASSWORD}@{settings.DB_HOST}:{settings.DB_PORT}/test",
        modules={"models": TORTOISE_APP_MODELS},
    )
    await reset_inventory_tables()
    yield
    await Tortoise.close_connections()


이 fixture는 initializer()를 우회하고, 수동으로 DB connection을 생성합니다.
덕분에 QueryExecutor 내부의 Tortoise.get_connection("default")도 정상 작동하게 되었어요.

필요한 테스트 함수에서는 이 fixture만 명시적으로 의존하게 하면 되었습니다:

 
async def test_mission(init_tortoise_connection: None) -> None:
	...

 

테스트 환경을 구성하느라 하루 종일 고생하면서, ORM이라는 도구가 개발 편의성과 일관성을 제공하는 동시에, 그 내부 동작을 정확히 이해하고 통제하지 않으면 테스트 환경에서는 오히려 장애 요소가 될 수 있다는 사실을 절감했습니다.

특히 Tortoise ORM의 경우, 테스트용으로 제공되는 initializer() 함수조차 내부적으로 모든 DB connection을 초기화(삭제)해버리기 때문에, ORM 외부에서 connection을 직접 다루는 유틸성 코드를 사용할 경우 별도의 연결 재초기화가 필요했습니다. 이 과정을 겪으며, ORM의 내부 동작을 깊이 이해하고 있으면 예기치 못한 문제 상황에서도 훨씬 유연하게 대처할 수 있다는 점을 다시금 느꼈습니다.

읽어주셔서 감사합니다 😊