FastAPI 순환 참조 해결하기

꽃봉우리·2024년 11월 24일

부트캠프 이후 정말 오랜만에 블로그를 써본다. 캠프 이후 운좋게 한달인턴 프로그램을 신청해서 뽑힌 후, 출근한지 이제 2달 반 넘어가는 시점동안 블로그에 내가 학습한 것들을 충분히 쓸 수 있었지만 미루고 미루다 보니 이제 처음 쓰게 된다
spring으로 시작했지만 어쩌보다보니 FastAPI를 하고 있는데 나름 재미있고 배운 것들도 많은 것 같다 서론은 여기까지하고 최근에 있었던 트러블 슈팅을 포스팅하려고 합니다!!

순환 참조가 생긴 이유!!

spring을 하면서도 본 적이 없는 오류였다. 이번에 참여하는 프로젝트에서 매핑 관계가 좀 얽혀있다 보니까 생긴 문제인 것 같다. 이번 프로젝트에서는 상위 테이블과 하위 테이블 개념이 있다.
예를 들어서 artist라는 유저가 만들어지기 전에 기본적인 user라는 테이블이 만들어지면서 동시에 생성되는 구조라고 해보자
이럴 때 두 엔티티에는 각자의 클래스를 참조해야 하기 때문에 나는 처음에 서로 설정을 이렇게 했다.

user.py

if TYPE_CHECKING:
    from app.artist.domain.entity.artist import Artist
    
artist.py
if TYPE_CHECKING:
    from app.user.domain.entity.user import User

엔티티 설계를 할 때 user는 artist의 객체를 가지고 있어야 하고, 반대로도 객체를 불러와야하는 설계를 했었다.

해결 방안들

첫 번째로 해결 할 수 있는 방법은 TYPE_CHECKING을 사용하는 것이였다.

user.py

if TYPE_CHECKING:
    from app.artist.domain.entity.artist import Artist
    
artist.py
if TYPE_CHECKING:
    from app.user.domain.entity.user import User

이 방법은 TYPE_CHECKING이 런타임때 실행되지 않고 타입 체크 시에만 사용되기 때문에 순환 참조 문제를 피할 수 있는 방법이다

두 번째로는 method에서 Pydantic 모델들에서 artist타입 힌트를 문자열로 변경시키는 방법도 있었다.

@classmethod
def create(
    cls,
    name: str,
    username: str,   
    password: str,
    user_role: UserRole
    service_product: "Artist",  # 문자열로 변경
) -> "User":

class UserCreate(BaseModel):
    model_config = ConfigDict(arbitrary_types_allowed=True)
    # ... 다른 필드들 ...
    artist: Optional["Artist"] = Field(None, description="아티스트 객체")  # 문자열로 변경)

세 번째는 from future import annotations를 사용하는 방법이다

from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field, ConfigDict
from sqlalchemy import BigInteger, String, TIMESTAMP, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from core.entity.base_entity import BaseEntity

class Artist(BaseEntity):
    __tablename__ = "Artist"

    artist_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
    user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("user.id"), nullable=False)
    user: Mapped[User] = relationship("User", back_populates="user", uselist=False)
    nickname: Mapped[str] = mapped_column(String(50), nullable=False)

여기서

from __future__ import annotations

이 녀석의 하는 일이 TYPE_CHECKING을 사용해서 수동으로 문자열 타입 힌트를 할 필요가 없고 임포트 하나로 자동으로 처리해준다!! 근데 여기서 궁금증이 하나 생겼는데 객체가 필요한데 왜 문자열로 타입 힌트를 하는 것일까?

순환 참조가 일어나는 과정

먼저 순환 참조가 일어나는 과정을 알 필요가 있다.

  • Python이 user.py를 import 시작
  • user.py에서 from app.artist.domain.entity.artist import Artist를 실행
  • user.py에서 Artist import 시작
  • artist.py에서 from app.user.domain.entity.user import User를 실행
  • user.py가 아직 완전히 로드되지 않은 상태(맨 처음이 아직 실행중)
  • 서로가 서로를 필요로 하는 상황에서 순환참조 에러 발생

이렇기 때문에 순환 참조가 발생한다. 근데 이 문제를 어떻게 타입 힌트를 문자열로 바꾸는 것만으로 해결이 되는걸까?

왜 문자열 처리가 방법일까?

user: Mapped["User"] = relationship("User", back_populates="artist", uselist=False)
  • Python은 이 라인을 실행할 때 실제 User 클래스를 즉시 필요X
  • 문자열 “User”은 나중에 SQLAlchemy가 실제로 관계를 설정할 때 평가 됨
  • 그 시점에는 이미 모든 클래스가 로드되어 있어서 문제가 발생 X
    → 즉 문자열 처리는 지연 평가(lazy evaluation)를 가능하게 해서 순환참조 문제를 피할 수 있음!

굳이 스프링으로 비교하면 lazy loading이라고 볼 수 있을 것 같다.
그럼 파이썬 orm인 sqlalchemy에서는 문자열을 어떻게 처리하는지도 궁금해져서 찾아보았다

ORM에서 어떻게 다시 클래스로 평가될까?

  • SQLAlchemy의 relationship 동작 과정
    • 초기 클래스 정의 시
      class Artist(BaseEntity):
      user: Mapped["User"] = relationship("User", back_populates="artist", uselist=False)
      -> 이 때는 "User"는 문자열
  • SQLAlchemy가 모든 모델을 초기화 할 때
    metadata.create_all() 또는 첫 DB 세션 생성 시점에서 SQLAlchemy는 내부적으로 모든 선언된 모델들의 레지스트리를 가지고 있음
    → 이 레지스트리에서 "User" 문자열과 매칭되는 실제 클래스를 찾아서 연결
  • 실제 사용 시점
# 이런 코드 실행할 때
artist = session.query(Artist).first()
user = artist.user  # 여기서 실제 User 객체 반환

이렇게 사용이 된다. 조금 더 심연으로 가서 SQLAlchemy 내부 과정도 한번 봤다

class Registry:
    def __init__(self):
        self._models = {}  # 모든 모델 클래스 저장
    
    def register(self, model_class):
        self._models[model_class.__name__] = model_class
    
    def resolve_relationship(self, model_name: str):
        return self._models[model_name]  # 문자열 -> 실제 클래스

# SQLAlchemy 내부에서
def setup_relationships():
    for model in registry._models.values():
        for relationship in model.__relationships__:
            if isinstance(relationship.target, str):
                # 문자열을 실제 클래스로 변환
                real_class = registry.resolve_relationship(relationship.target)
                relationship.target = real_class

이렇게 실행이 되고 있는 것을 볼 수 있다.

  • 결론(SQLAlchemy에서)
    • 모든 모델 클래스들을 자신의 레지스트리에 등록
    • 실제 DB 연결이나 쿼리 실행 전에 문자열로 된 관계들을 실제 클래스로 해석
    • 이후 쿼리 실행 시 완전히 해석된 관계 정보를 사용

0개의 댓글