레거시 바이너리 UUID vs. Tortoise ORM 문자열 UUID - 충돌의 비밀을 파헤치다
안녕하세요 🙂
이번 글에서는 텔링미 서비스 개발 과정에서 겪었던 UUID 관련 문제와 이를 해결한 경험을 공유하려고 합니다.
제가 합류했을 당시, 기존 백엔드 서버는 Java 기반이었고, Hibernate ORM을 사용해 데이터베이스와 연동하고 있었습니다.
신규 기능은 Python 기반의 FastAPI + Tortoise ORM 구조로 마이그레이션을 진행하던 중이었고,
저는 유저의 미션 시스템 개발을 맡게 되었어요.
이때 가장 큰 골칫거리 중 하나가 바로 UUID 처리 방식의 차이였습니다.
UUID, 왜 이렇게 골치 아플까?
텔링미의 사용자 데이터는 아래와 같은 보안상의 이유로 모두 UUID 기반으로 관리되고 있었습니다.
- 사용자 식별자는 랜덤 UUID 기반으로 생성
→ 외부에서 ID를 추정하거나 예측하기 어렵게 설계 - 데이터베이스에는 UUID가 BINARY(16) Type으로 저장됨
→ 직접 확인도 어려움 - 자동 증가 PK가 아닌 UUID 사용
→ 보안상 유리하지만 성능과 개발 난이도에서 불리한 측면 존재
이러한 구조는 운영 환경에서는 문제없이 잘 동작하고 있었습니다.
왜냐하면 사용자 생성은 Java 서버에서 담당하고 있었고,
이 서버는 Hibernate ORM을 통해 UUID를 BINARY 타입으로 잘 처리하고 있었기 때문입니다.
여기까지는 큰 문제가 없다고 생각했지만,
FastAPI + Tortoise ORM 기반으로 신규 기능을 개발하는 과정에서 예상치 못한 큰 이슈를 마주하게 되었습니다.
Tortoise ORM은 UUID를 기본적으로 문자열(string) 형태로 처리합니다.
하지만 기존 DB는 UUID를 16바이트 바이너리(BINARY(16))로 저장하고 있었기 때문에,
두 시스템 간 데이터 타입 불일치(type mismatch) 문제가 발생했습니다.
그 결과 다음과 같은 문제가 생겼습니다:
- UUID 조건으로 쿼리 시 정상적으로 조회되지 않음
- 새로운 UUID를 저장해도 데이터베이스에 제대로 반영되지 않음
- UUID가 BLOB 형태로 저장되어 있어 디버깅조차 매우 어려움
특히 테스트 환경에서는 문제가 더 컸습니다.
신규 기능을 테스트하기 위해 직접 user를 생성하고, 해당 UUID를 기반으로 다른 엔티티를 생성하거나 쿼리해야 했는데,
이 UUID 값을 직접 다루기가 매우 까다로웠기 때문입니다.
결과적으로, ORM은 UUID를 문자열로 다루고 있고, DB는 바이너리로 저장하고 있는 상황에서
중간에서 연결고리가 끊긴 채 제대로 통신하지 못하는 구조가 되어버렸습니다.
UUID 문제를 해결하기 위해 아래와 같은 3가지 방법을 시도했습니다:
1. Tortoise ORM 커스텀 필드 사용 시도
처음에는 기존 ORM 구조를 그대로 유지하고 싶어서,
Tortoise ORM의 UUIDField를 오버라이딩하거나 새로운 BinaryUUIDField를 만들어
Python UUID 객체를 BINARY(16) 형식으로 자동 변환해주는 로직을 구현하려고 했습니다.
class BinaryUUIDField(fields.BinaryField):
def to_db_value(self, value: uuid.UUID, instance):
return value.bytes if isinstance(value, uuid.UUID) else value
def to_python_value(self, value: bytes) -> uuid.UUID:
return uuid.UUID(bytes=value)
하지만 실제 사용 시:
- uuid.UUID(...).bytes로 저장한 값이 MySQL에서는 BINARY 순서와 일치하지 않아 WHERE 조건에서 매칭이 안 되거나
- uuid.UUID(bytes=...)로 역변환할 때 b'...' 형식이 SQL syntax error를 발생시키는 문제
등이 반복적으로 발생했습니다.
tortoise.exceptions.OperationalError: (1064, "You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'b'\\x00\\x0e\\xa6m\\x1a\\x95M\\xe5\\x8fC\\x1d\\xeb\\xb0N\\xaa\\x88' LIMIT 2' at line 1")
디버깅 난이도가 높고, ORM 자체의 한계를 마주했기 때문에 보류했습니다.
2. 다른 ORM(SQLAlchemy 등)으로 교체
Tortoise의 한계를 보완하고자, FastAPI에서 자주 쓰이는 SQLAlchemy를 적용하려 했습니다.
특히 BINARY(16)과 UUIDType을 매핑해주는 SQLAlchemy의 유연성을 기대했습니다.
user_id = Column(UUID(as_uuid=True), primary_key=True)
그러나 Tortoise ORM의 한계를 극복하기 위해 SQLAlchemy 등 다른 ORM 도입도 시도했지만, MySQL의 BINARY(16) UUID 필드와의 직접적인 호환성 문제로 동일한 타입 불일치 오류가 발생했고, 비동기 환경 구성을 위한 추가적인 구조 변경 부담까지 있어 결국 도입을 보류하게 되었습니다.
3. 핵심 로직만 raw SQL로 처리
결국 최종적으로는 UUID 기반 조회/저장과 같이 ORM이 처리하기 까다로운 핵심 로직 일부에만 raw SQL을 적용하는 방식을 선택했습니다.
Tortoise ORM의 일반 쿼리로는 BINARY(16) UUID 값을 제대로 다루기 어려웠기 때문에,
UNHEX(REPLACE(...)) 문법을 직접 사용하는 방식으로 QueryExecutor 유틸 클래스를 구현해 사용했습니다.
from tortoise import Tortoise
class QueryExecutor:
@staticmethod
async def execute_query(query: str, values: Any = (), fetch_type: str = "multiple") -> Any:
connection = Tortoise.get_connection("default")
processed_values = tuple(v[0] if isinstance(v, tuple) else v for v in values) if isinstance(values, tuple) else (values,)
result = await connection.execute_query_dict(query, processed_values)
if result:
return result[0] if fetch_type == "single" else result
return 0 if fetch_type == "single" else []
@staticmethod
async def execute_write_query(query: str, values: Any = ()) -> None:
connection = Tortoise.get_connection("default")
processed_values = tuple(v[0] if isinstance(v, tuple) else v for v in values) if isinstance(values, tuple) else (values,)
await connection.execute_query(query, processed_values)
물론 이 방식은 일부 코드가 복잡해진다는 단점은 있었지만,
기존 DB와의 호환성과 안정적인 운영 측면에서 가장 현실적인 해결책이었습니다.
📌 [업데이트] RawSQL 표현식 도입
최근에는 Tortoise ORM에 RawSQL 표현식을 직접 쿼리 필터에 사용할 수 있는 기능이 추가되면서,
ORM 내부 로직을 유지한 채로도 복잡한 쿼리를 처리할 수 있게 되었습니다.
예를 들어 아래와 같이 UUID 문자열을 UNHEX로 변환하여 조회할 수 있습니다:
from tortoise.expressions import RawSQL
await User.get_or_none(user_id=RawSQL("UNHEX(REPLACE(?, '-', ''))", [uuid_str]))
이 경험을 통해 ORM이 개발 생산성을 높여주긴 하지만, 항상 모든 케이스를 완벽하게 커버해주지는 않는다는 점을 절감하게 됐습니다.
특히 레거시 시스템과 최신 기술 스택이 혼재된 환경에서는, DB를 직접 다뤄야 하는 경우가 반드시 발생한다는 걸 실감했습니다.
또한 마이그레이션 과정에서 기존 시스템의 특성을 사전에 파악하고 대비하는 것이 얼마나 중요한지도 새삼 느꼈어요.
앞으로는 이런 경험을 바탕으로, 기술 선택 시 더 깊이 고민하고,
문제 상황에 유연하고 빠르게 대응할 수 있는 역량을 더 키워나가고 싶습니다.
다음 글에서는 테스트 환경에서 발생한 문제와, 비동기 작업을 어떻게 최적화했는지에 대해 소개할 예정이에요.
읽어주셔서 감사합니다 🙂