SQLAlchemy 2.0 #5 관계 매핑: 1:N, N:M, 그리고 N+1 문제

5 분 소요

테이블 하나만 다루는 앱은 없습니다. 사용자는 주문을 여러 개 갖고, 게시글은 태그를 여러 개 갖습니다. ORM에서 이 연결을 담당하는 것이 relationship()이고, 편리한 만큼 ORM 최대의 성능 함정인 N+1 문제의 진원지이기도 합니다. 이번 편에서 선언법과 함정 대응을 함께 정리합니다.

1:N — 외래 키와 relationship의 역할 분담 #

사용자 한 명이 주소를 여러 개 갖는 관계를 선언합니다.

models.py
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.addressesaddress.user라는 객체 탐색 경로를 만듭니다.
  • back_populates: 두 경로가 같은 관계의 양면임을 알려 주는 설정입니다. 한쪽에 address.user = user라고 대입하면 반대쪽 user.addresses에도 자동으로 나타납니다.

사용은 컬렉션 조작처럼 자연스럽습니다.

main.py
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 테이블 #

게시글과 태그처럼 양쪽 다 여럿인 관계는 중간 테이블을 둡니다.

models.py
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가 나갑니다. 이것이 루프와 만나면 사고가 됩니다.

main.py
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가 반복되는 것으로 바로 확인할 수 있습니다.

해결은 처음부터 함께 로드하겠다고 선언하는 것입니다. 대표 전략이 둘 있습니다.

main.py
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()
전략쿼리 형태잘 맞는 곳
selectinloadSELECT 2번 (부모, 자식 IN(…))컬렉션(1:N, N:M). 행 중복이 없어 예측 가능
joinedloadJOIN 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 스타일로 다룹니다.
X