부트캠프 이후 정말 오랜만에 블로그를 써본다. 캠프 이후 운좋게 한달인턴 프로그램을 신청해서 뽑힌 후, 출근한지 이제 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을 사용해서 수동으로 문자열 타입 힌트를 할 필요가 없고 임포트 하나로 자동으로 처리해준다!! 근데 여기서 궁금증이 하나 생겼는데 객체가 필요한데 왜 문자열로 타입 힌트를 하는 것일까?
먼저 순환 참조가 일어나는 과정을 알 필요가 있다.
user.py에서 from app.artist.domain.entity.artist import Artist를 실행artist.py에서 from app.user.domain.entity.user import User를 실행user.py가 아직 완전히 로드되지 않은 상태(맨 처음이 아직 실행중)이렇기 때문에 순환 참조가 발생한다. 근데 이 문제를 어떻게 타입 힌트를 문자열로 바꾸는 것만으로 해결이 되는걸까?
user: Mapped["User"] = relationship("User", back_populates="artist", uselist=False)
굳이 스프링으로 비교하면 lazy loading이라고 볼 수 있을 것 같다.
그럼 파이썬 orm인 sqlalchemy에서는 문자열을 어떻게 처리하는지도 궁금해져서 찾아보았다
class Artist(BaseEntity):
user: Mapped["User"] = relationship("User", back_populates="artist", uselist=False)-> 이 때는 "User"는 문자열metadata.create_all() 또는 첫 DB 세션 생성 시점에서 SQLAlchemy는 내부적으로 모든 선언된 모델들의 레지스트리를 가지고 있음# 이런 코드 실행할 때
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
이렇게 실행이 되고 있는 것을 볼 수 있다.