SQLAlchemy 2.0 #5 관계 매핑: 1:N, N:M, 그리고 N+1 문제
테이블 하나만 다루는 앱은 없습니다. 사용자는 주문을 여러 개 갖고, 게시글은 태그를 여러 개 갖습니다. ORM에서 이 연결을 담당하는 것이 relationship()이고, 편리한 만큼 ORM 최대의 성능 함정인 N+1 문제의 진원지이기도 합니다. 이번 편에서 선언법과 함정 대응을 함께 정리합니다.
1:N — 외래 키와 relationship의 역할 분담 #
사용자 한 명이 주소를 여러 개 갖는 관계를 선언합니다.
from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "user_account"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(30))
addresses: Mapped[list["Address"]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)
class Address(Base):
__tablename__ = "address"
id: Mapped[int] = mapped_column(primary_key=True)
email_address: Mapped[str] = mapped_column(String(100))
user_id: Mapped[int] = mapped_column(ForeignKey("user_account.id"))
user: Mapped["User"] = relationship(back_populates="addresses")역할이 명확히 나뉩니다.
ForeignKey는 DB의 것입니다.address.user_id컬럼에 외래 키 제약을 만듭니다. 이것이 없으면 관계는 성립하지 않습니다.relationship()은 파이썬의 것입니다. DB에는 아무 컬럼도 만들지 않고,user.addresses와address.user라는 객체 탐색 경로를 만듭니다.back_populates: 두 경로가 같은 관계의 양면임을 알려 주는 설정입니다. 한쪽에address.user = user라고 대입하면 반대쪽user.addresses에도 자동으로 나타납니다.
사용은 컬렉션 조작처럼 자연스럽습니다.
with SessionLocal.begin() as session:
user = User(name="김관계")
user.addresses.append(Address(email_address="kim@example.com"))
user.addresses.append(Address(email_address="kim@work.com"))
session.add(user)
# user INSERT → 발급된 id로 address 두 건 INSERT까지 세션이 알아서 처리user_id를 직접 채우지 않았다는 점에 주목하시기 바랍니다. 세션이 flush 시점에 부모의 기본 키를 자식의 외래 키에 채워 넣습니다.
cascade: 부모가 지워지면 자식은? #
위 선언의 cascade="all, delete-orphan"은 실무에서 가장 흔한 조합입니다.
all: 부모를session.add하면 자식도 함께, 부모를 삭제하면 자식도 함께 처리하는 등 주요 작업을 전파합니다.delete-orphan: 컬렉션에서 빠진 자식(user.addresses.remove(addr))을 고아로 보고 DELETE합니다.
“주소는 사용자에 종속된 소유물"이라면 이 조합이 맞습니다. 반대로 “게시글의 작성자"처럼 자식이 독립적으로 존재해야 하는 관계라면 cascade에 delete를 넣으면 안 됩니다. 소유 관계인지 참조 관계인지를 먼저 판단하고 cascade를 정하는 순서입니다.
N:M — secondary 테이블 #
게시글과 태그처럼 양쪽 다 여럿인 관계는 중간 테이블을 둡니다.
from sqlalchemy import Column, ForeignKey, Table
post_tag = Table(
"post_tag",
Base.metadata,
Column("post_id", ForeignKey("post.id"), primary_key=True),
Column("tag_id", ForeignKey("tag.id"), primary_key=True),
)
class Post(Base):
__tablename__ = "post"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(100))
tags: Mapped[list["Tag"]] = relationship(secondary=post_tag, back_populates="posts")
class Tag(Base):
__tablename__ = "tag"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(30), unique=True)
posts: Mapped[list["Post"]] = relationship(secondary=post_tag, back_populates="tags")중간 테이블은 모델 클래스가 아니라 Table로 두고 secondary=로 연결하는 것이 기본형입니다. post.tags.append(tag)만 하면 중간 테이블의 INSERT는 세션이 처리합니다. 단, 중간 테이블에 부가 컬럼(태그를 단 시각, 단 사람)이 필요해지면 Table 대신 정식 모델 클래스로 승격시키고 1:N 두 개로 풀어야 합니다. 이를 연관 객체(association object) 패턴이라고 부릅니다.
N+1 문제: ORM 성능 이슈의 8할 #
기본 설정에서 관계 속성은 지연 로딩(lazy loading)입니다. user.addresses에 처음 접근하는 순간 SELECT가 나갑니다. 이것이 루프와 만나면 사고가 됩니다.
with SessionLocal() as session:
users = session.scalars(select(User)).all() # 쿼리 1번
for user in users:
print(user.name, len(user.addresses)) # 사용자마다 쿼리 1번씩!사용자가 100명이면 쿼리가 1 + 100 = 101번 나갑니다. 이것이 N+1입니다. 개발 중에는 데이터가 적어 티가 안 나다가, 운영에서 데이터가 쌓이면 목록 페이지가 갑자기 느려지는 전형적 패턴입니다. echo=True를 켜고 목록 조회 코드를 돌려 보면 같은 모양의 SELECT가 반복되는 것으로 바로 확인할 수 있습니다.
해결은 처음부터 함께 로드하겠다고 선언하는 것입니다. 대표 전략이 둘 있습니다.
from sqlalchemy.orm import joinedload, selectinload
# selectinload: IN 절을 쓰는 두 번째 쿼리로 자식들을 한 번에 로드
stmt = select(User).options(selectinload(User.addresses))
# joinedload: LEFT JOIN 한 방으로 로드
stmt = select(User).options(joinedload(User.addresses))
users = session.scalars(stmt).unique().all()| 전략 | 쿼리 형태 | 잘 맞는 곳 |
|---|---|---|
selectinload | SELECT 2번 (부모, 자식 IN(…)) | 컬렉션(1:N, N:M). 행 중복이 없어 예측 가능 |
joinedload | JOIN 1번 | 단일 객체 참조(N:1). 컬렉션에 쓰면 행이 부풀어 unique() 필요 |
기본 선택은 단순합니다. 컬렉션은 selectinload, 단일 참조는 joinedload. 어느 쪽이든 “쿼리 수가 데이터 개수에 비례하지 않게” 만드는 것이 목적입니다. 관계 선언 자체에 lazy="selectin"을 박아 기본 동작을 바꿀 수도 있지만, 항상 자식까지 필요한 것은 아니므로 쿼리별 options() 지정을 기본으로 삼는 편이 낭비가 없습니다.
또 하나, 4편에서 본 detached 상태와 조합하면 이렇게 됩니다. 세션이 닫힌 뒤 지연 로딩 속성에 접근하면 DetachedInstanceError가 납니다. API 응답 직전에 관계 속성을 건드리다 만나는 에러의 정체가 대부분 이것이고, 답은 조회 시점에 eager loading으로 함께 가져오는 것입니다.
정리 #
- 관계는 두 부품입니다.
ForeignKey가 DB 제약을,relationship()이 파이썬 탐색 경로를 만들고,back_populates로 양방향을 잇습니다. - cascade는 소유 관계(
all, delete-orphan)인지 참조 관계(전파 없음)인지 먼저 판단하고 정합니다. - N:M은
Table+secondary가 기본형이고, 중간 테이블에 컬럼이 붙는 순간 연관 객체 패턴으로 승격합니다. - 지연 로딩 + 루프 = N+1입니다. 컬렉션은
selectinload, 단일 참조는joinedload로 조회 시점에 함께 로드합니다. - 다음 편은 쿼리 심화입니다. 조인, 집계, 서브쿼리, 페이지네이션, 대량 처리를 2.0 스타일로 다룹니다.