안녕하세요 🙂
이번 글에서는 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의 내부 동작을 깊이 이해하고 있으면 예기치 못한 문제 상황에서도 훨씬 유연하게 대처할 수 있다는 점을 다시금 느꼈습니다.
읽어주셔서 감사합니다 😊
'ETC > 프로젝트' 카테고리의 다른 글
| 미션 시스템 구축기 : 정합성을 위한 설계 전환 (feat. Redis + Celery) (1) | 2025.07.27 |
|---|---|
| 레거시 바이너리 UUID vs. Tortoise ORM 문자열 UUID - 충돌의 비밀을 파헤치다 (4) | 2025.07.24 |
| 팀의 일원이 된다는 것 – 텔링미 프로젝트에서 배운 것들 (1) | 2025.07.24 |
| Fastapi - todo list 서버 만들기 1일차 (3) | 2024.07.12 |