[SQLAlchemy 관계 로딩]
SQLAlchemy의 관계 로딩에 대해 공부해보자.
최근 sqlalchemy 버전을 올림과 동시에 flast to fastapi 마이그레이션 작업을 하고 있는데 모르는 개념들이 많이 생기고 있다.
특히 relationship(관계)에 대한 부분을 많이 공부해야할 것 같다는 생각이 들었다.
그래서 오늘은 sqlalchemy의 relationship loading에 대해 정리해 보려고 한다.
<STUDY>
❓ 관계 로딩 전략 (relationship loading strategy)
연관된 객체를 언제, 어떻게 가져올지를 결정하는 방법
- 구분설명쿼리 실행 시점
| Lazy Loading | 관계 필드를 접근할 때 쿼리를 실행 | 접근 시점 |
| Eager Loading | 관계를 미리 한 번에 로드 | 즉시 (Parent 쿼리 시점) |
1️⃣ Lazy Loading (기본값)
class Parent(Base):
__tablename__ = "parent"
id = Column(Integer, primary_key=True)
children = relationship("Child", lazy="select")
## 사용
parents = session.query(Parent).all()
for p in parents:
print(p.children) # 여기서 매번 쿼리 실행됨 (N+1 문제)
- 관계에 접근할 때마다 쿼리 추가로 실행
- 데이터가 많을 경우 성능 저하 (N+1 Problem)
2️⃣ joinedload
JOIN을 사용하여 한 번에 데이터를 가져오는 방식
from sqlalchemy.orm import joinedload
stmt = select(Parent).options(joinedload(Parent.children))
parents = session.execute(stmt).scalars().all()
- SQL: SELECT ... FROM parent JOIN child ...
- 장점: 쿼리 한 번으로 모든 데이터 로드
- 단점: JOIN이 많아지면 결과가 중복되고 비효율적일 수 있음
3️⃣ selectinload
JOIN 대신 IN 쿼리를 사용하여 한 번에 데이터를 가졍호는 방식
from sqlalchemy.orm import selectinload
stmt = select(Parent).options(selectinload(Parent.children))
parents = session.execute(stmt).scalars().all()
- SQL
- 첫 번째 쿼리: 부모 리스트 조회
- 두 번째 쿼리: WHERE child.parent_id IN (...)
- 장점: JOIN보다 깔끔하고 중복 감소
- 단점: 쿼리가 최소 2회 실행
4️⃣ contains_eager
직접 작성한 JOIN 결과를 ORM 관계로 매핑할 때 사용
from sqlalchemy.orm import contains_eager
stmt = (
select(Parent)
.join(Parent.children)
.options(contains_eager(Parent.children))
)
parents = session.execute(stmt).scalars().unique().all()
💡 특징
- ORM에게 “이 JOIN 결과는 이미 관계 데이터야”라고 알려줌
- JOIN 구문을 직접 제어할 수 있어, 복잡한 쿼리에 유용
- 필터링된 관계 로딩, alias / 서브쿼리 조합에서 특히 중요
✅ 비교
| lazy | 접근 시 SELECT | 접근 시점 | 단순, 느림 | 기본 관계 |
| joinedload | JOIN으로 로딩 | 즉시 | 쿼리 1회, 중복 가능 | 간단한 관계 |
| selectinload | IN 쿼리 2회 | 즉시 | 효율적, 중복 적음 | 다대일 / 일대다 |
| contains_eager | 수동 JOIN 매핑 | 즉시 | 커스텀 JOIN 가능 | 필터링된 관계, alias 사용 |
📌 contains_eager + alias
JOIN 대상이 단순한 테이블이 아니라 별칭(alias) 혹은 서브쿼리인 경우 SQLAlchemy가 관계를 자동으로 인식하지 못하기 때문에 alias 인자를 명시적으로 넘겨줌
💡 필터링된 관계 eager load
from sqlalchemy.orm import aliased, contains_eager
ChildAlias = aliased(Child)
stmt = (
select(Parent)
.join(ChildAlias, Parent.id == ChildAlias.parent_id)
.where(ChildAlias.age > 10)
.options(contains_eager(Parent.children, alias=ChildAlias))
)
parents = session.execute(stmt).scalars().unique().all()
- ChildAlias는 Child의 별칭 → ORM 자동 인식 불가
- contains_eager(..., alias=ChildAlias)로 관계 직접 지정
- 필터링된 alias 데이터를 Parent.children에 매핑 가능
💡 서브쿼리 alias
child_subq = (
select(Child)
.where(Child.age >= 10)
.subquery()
)
FilteredChild = aliased(Child, child_subq)
stmt = (
select(Parent)
.join(FilteredChild, Parent.id == FilteredChild.parent_id)
.options(contains_eager(Parent.children, alias=FilteredChild))
)
parents = session.execute(stmt).scalars().unique().all()
- 서브쿼리 기반 alias를 JOIN하면 ORM이 이를 기본 관계로 인식하지 않음
- alias 옵션을 지정해야 관계 필드(Parent.children)로 자동 매핑 가능
💡 여러 alias 관계를 동시에 eager load
AddressAlias = aliased(Address)
OrderAlias = aliased(Order)
stmt = (
select(User)
.join(AddressAlias, User.id == AddressAlias.user_id)
.join(OrderAlias, User.id == OrderAlias.user_id)
.options(
contains_eager(User.addresses, alias=AddressAlias),
contains_eager(User.orders, alias=OrderAlias),
)
)
📌 각 관계마다 별도의 alias를 지정하여 매핑 가능하다.
✅ 정리
| 기본 관계 eager load | .join(Parent.children).options(contains_eager(Parent.children)) |
| 필터링된 관계 eager load | .join(ChildAlias)... .options(contains_eager(Parent.children, alias=ChildAlias)) |
| 서브쿼리 기반 alias eager load | .join(FilteredChild)... .options(contains_eager(Parent.children, alias=FilteredChild)) |
| 여러 관계를 동시에 eager load | .options(contains_eager(..., alias=...)) 여러 개 지정 |
✅ 마무리 요약
| lazy | 관계 접근 시점에 쿼리 실행 |
| joinedload | JOIN으로 즉시 로딩 |
| selectinload | IN 쿼리로 즉시 로딩 |
| contains_eager | 직접 JOIN 결과를 ORM 관계로 매핑 |
| alias + contains_eager | 필터링된 / 서브쿼리 기반 관계를 깔끔하게 매핑할 때 필수 |
📘 자세한 내용은 공식 문서를 참고해주세요.
ez1n - Overview
Front-End Developer. ez1n has 18 repositories available. Follow their code on GitHub.
github.com