# ORM으로 관련 개체 작업하기


이번 챕터에서는 다른 객체를 참조하는 매핑된 객체와 상호작용하는 방식인 또 하나의 필수적인 ORM 개념을 다룰 것입니다.
relationship()은 매핑된 두 객체 간의 관계를 정의하며, 자기 참조관계라고도 합니다.
기본적인 구조를 위해 Column 매핑 및 기타 지시문을 생략하고 짧은 형식으로 relationship()을 설명드리겠습니다.


from sqlalchemy.orm import relationship


class User(Base):
    __tablename__ = 'user_account'

    # ... Column mappings

    addresses = relationship("Address", back_populates="user")


class Address(Base):
    __tablename__ = 'address'

    # ... Column mappings

    user = relationship("User", back_populates="addresses")

위 구조를 보면 User 객체에는 addresses 변수, Address 객체에는 user 라는 변수가 있습니다.
공통적으로 relationship 객체로 생성되어져 있는 것을 볼 수 있습니다. 이는 실제 데이터베이스에 컬럼으로 존재하는 변수는 아니지만 코드 상에서 쉽게 접근할 수 있도록 하기 위해 설정 되었습니다.
즉, User 객체에서 Address 객체로 쉽게 찾아갈 수 있게 해줍니다.

또한 relationship 선언시 파라미터로 back_populates 항목은 반대의 상황 즉, Address 객체에서 User 객체를 찾아 갈 수 있게 해줍니다.

관계형으로 보았을 경우 1 : N 관계를 자연스럽게 N : 1 관계로 해주는 설정입니다.

다음 섹션에서 relationship() 객체의 인스턴스가 어떤 역할을 하는지, 동작하는지 보겠습니다.


# 관계된 객체 사용하기


새로운 User 객체를 만들면 .addresses 컬렉션이 나타나는데 List 객체임을 알 수 있습니다.

>>> u1 = User(name='pkrabs', fullname='Pearl Krabs')    
>>> u1.addresses
[]

list.append()를 사용하여 Address 객체를 추가할 수 있습니다.

>>> a1 = Address(email_address="pear1.krabs@gmail.com")
>>> u1.addresses.append(a1)

# u1.addresses 컬렉션에 새로운 Address 객체가 포함되었습니다.
>>> u1.addresses
[Address(id=None, email_address='pearl.krabs@gmail.com')]

Address 객체를 인스턴스 User.addresses 컬렉션과 연관시켰다면 변수 u1 에는 또 다른 동작이 발생하는데,
User.addressesAddress.user 관계가 동기화 되어

  • User 객체에서 Address 이동할 수 있을 뿐만 아니라
  • Address 객체에서 다시 User 객체로 이동할 수도 있습니다.
>>> a1.user
User(id=None, name='pkrabs', fullname='Pearl Krabs')

두개의 relationshiop() 객체 간의 relationship.back_populates 을 사용한 동기화 결과입니다.

매개변수 relationshiop() 는 보완적으로 할당/목록 변형이 발생할때 다른 변수로 지정할 수 있습니다. 다른 Address 객체를 생성하고 해당 Address.user 속성에 할당하면 해당 객체 Address에 대한 User.addresses 컬렉션의 일부가 되는것도 확인 할 수 있습니다.

>>> a2 = Address(email_address="pearl@aol.com", user=u1)
>>> u1.addresses
[Address(id=None, email_address='pearl.krabs@gmail.com'), Address(id=None, email_address='pearl@aol.com')]

우리는 실제로 객체(Address)에 선언된 속성처럼 user의 키워드 인수로 u1 변수를 사용했습니다.
다음 사실 이후에 속성을 할당하는 것과 같습니다.

# equivalent effect as a2 = Address(user=u1)
>>> a2.user = u1

# Session에 객체 캐스케이딩


이제 메모리의 양방향 구조와 연결된 두 개의 User, Address 객체가 있지만 이전에 ORM으로 행 삽입하기 에서 언급했듯이 이러한 객체는 객체와 연결될 때까지 일시적인 Session 상태에 있습니다.

우리는 Session.add() 를 사용하고, User 객체에 메서드를 적용할 때 관련 Address 객체도 추가된다는 점을 확인해 볼 필요가 있습니다.

>>> session.add(u1)
>>> u1 in session
True
>>> a1 in session
True
>>> a2 in session 
True

세 개의 객체는 이제 보류 상태에 있으며, 이는 INSERT 작업이 진행되지 않았음을 의미합니다.
세 객체는 모두 기본 키가 할당되지 않았으며, 또한 a1 및 a2 객체에는 열(user_id)을 참조 속성이 있습니다.
이는 객체가 아직 실제 데이터베이스 연결되지 않았기 때문입니다.

>>> print(u1.id)
None
>>> print(a1.user_id)
None

데이터베이스에 저장해봅시다.

>>> session.commit()

구현한 코드를 SQL 쿼리로 동작을 해본다면 이와 같습니다.

INSERT INTO user_account (name, fullname) VALUES (?, ?)
[...] ('pkrabs', 'Pearl Krabs')
INSERT INTO address (email_address, user_id) VALUES (?, ?)
[...] ('pearl.krabs@gmail.com', 6)
INSERT INTO address (email_address, user_id) VALUES (?, ?)
[...] ('pearl@aol.com', 6)
COMMIT

session을 사용하여 SQL의 INSERT, UPDATE, DELETE 문을 자동화할 수 있습니다. 마지막으로 Session.commit()을 실행하여 모든 단계를 올바른 순서로 호출되며 user_accountaddress.user_id 기본키가 적용됩니다.


# 관계 로드


Session.commit() 을 호출한 이후에는 u1 객체에 생성된 기본 키를 볼 수 있게됩니다.

>>> u1.id
6

위 코드는 다음 쿼리를 실행하는 것과 같습니다.

BEGIN (implicit)
SELECT user_account.id AS user_account_id, user_account.name AS user_account_name,
user_account.fullname AS user_account_fullname
FROM user_account
WHERE user_account.id = ?
[...] (6,)

다음처럼 u1.addresses 에 연결된 객체들에도 id가 들어와있는 것을 볼 수 있습니다. 해당 객체를 검색하기 위해 우리는 lazy load 방식으로 볼 수 있습니다.

lazy loading : 누군가 해당 정보에 접근하고자 할때 그때 SELECT문을 날려서 정보를 충당하는 방식. 즉, 그때그때 필요한 정보만 가져오는 것입니다.

>>> u1.addresses
[Address(id=4, email_address='pearl.krabs@gmail.com'), Address(id=5, email_address='pearl@aol.com')]

위 코드는 다음 쿼리를 실행하는 것과 같습니다.

SELECT address.id AS address_id, address.email_address AS address_email_address,
address.user_id AS address_user_id
FROM address
WHERE ? = address.user_id
[...] (6,)

SQLAlchemy ORM의 기본 컬렉션 및 관련 특성은 lazy loading 입니다. 즉, 한번 relationship 된 컬렉션은 데이터가 메모리에 존재하는 한 계속 접근을 사용할 수 있습니다.

>>> u1.addresses
[Address(id=4, email_address='pearl.krabs@gmail.com'), Address(id=5, email_address='pearl@aol.com')]

lazy loading은 최적화를 위한 명시적인 단계를 수행하지 않으면 비용이 많이 들 수 있지만, 적어도 lazy loading은 중복 작업을 수행하지 않도록 최적화되어 있습니다.

u1.addresses의 컬렉션에 a1a2 객체들 또한 볼 수 있습니다.

>>> a1
Address(id=4, email_address='pearl.krabs@gmail.com')
>>> a2
Address(id=5, email_address='pearl@aol.com')

relationship 개념에 대한 추가 소개는 이 섹션의 후반부에 더 설명드리겠습니다.


# 쿼리에서 relationship 사용하기


이 섹션에서는 relationship() 이 SQL 쿼리 구성을 자동화하는데 도움이 되는 여러 가지 방법을 소개합니다.


# relationship()을 사용하여 조인하기

FROM절과 JOIN명시하기WHERE절 섹션에서는 Select.join()Select.join_from() 메서드를 사용하여 SQL JOIN을 구성하였습니다.
테이블간에 조인하는 방법을 설명하기 위해 이러한 메서드는 두 테이블을 연결하는 ForeignKeyConstraint 객체가 있는지 여부에 따라 ON 절을 유추하거나 특정 ON 절을 나타내는 SQL Expression 구문을 제공 할 수 있습니다.

relationship() 객체를 사용하여 join의 ON 절을 설정할 수 있습니다. relationship() 에 해당하는 객체는 Select.join()단일 인수로 전달될 수 있으며, right join과 ON 절을 동시에 나타내는 역할을 합니다.

>>> print(
...     select(Address.email_address).
...     select_from(User).
...     join(User.addresses)
... )

위 코드는 다음 쿼리를 실행하는 것과 같습니다.

SELECT address.email_address
FROM user_account JOIN address ON user_account.id = address.user_id

매핑된 relationship()있는 경우 Select.join() 또는 Select.join_from() 지정하지 않을 경우 ON 절은 사용되지 않습니다.
즉, userAddress 객체의 relationship() 객체가 아니라 매핑된 두 테이블 객체 간의 ForeignKeyConstraint로 인해 작동합니다.

>>> print(
...    select(Address.email_address).
...    join_from(User, Address)
... )

위 코드는 다음 쿼리를 실행하는 것과 같습니다.

SELECT address.email_address
FROM user_account JOIN address ON user_account.id = address.user_id

# 별칭(aliased)을 사용하여 조인하기

relationship()을 사용하여 SQL JOIN을 구성하는 경우 [PropComparator.of_type()] 사용하여 조인 대상이 aliased()이 되는 사용 사례가 적합합니다. 그러나 relationship()를 사용하여 [ORM Entity Aliases]에 설명된 것과 동일한 조인을 구성합니다.

>>> from sqlalchemy.orm import aliased
>>> address_alias_1 = aliased(Address)
>>> address_alias_2 = aliased(Address)
>>> print(
...     select(User).
...     join_from(User, address_alias_1).
...     where(address_alias_1.email_address == 'patrick@aol.com').
...     join_from(User, address_alias_2).
...     where(address_alias_2.email_address == 'patrick@gmail.com')
... )

위 코드는 다음 쿼리를 실행하는 것과 같습니다.

SELECT user_account.id, user_account.name, user_account.fullname
FROM user_account
JOIN address AS address_1 ON user_account.id = address_1.user_id
JOIN address AS address_2 ON user_account.id = address_2.user_id
WHERE address_1.email_address = :email_address_1
AND address_2.email_address = :email_address_2

relationship()을 사용하여 aliased()에서 조인을 직접 사용할 수 있습니다.

>>> user_alias_1 = aliased(User)
>>> print(
...     select(user_alias_1.name).
...     join(user_alias_1.addresses)
... )

위 코드는 다음 쿼리를 실행하는 것과 같습니다.

SELECT user_account_1.name
FROM user_account AS user_account_1
JOIN address ON user_account_1.id = address.user_id

# ON 조건 확대

relation()으로 생성된 ON 절에 조건을 추가할 수 있습니다. 이 기능은 관계된 경로에 대한 특정 조인의 범위를 신속하게 제한하는 방법뿐만 아니라 마지막 섹션에서 소개하는 로더 전략 구성과 같은 사용 사례에도 유용합니다.
PropComparator.and_() 메서드는 AND를 통해 JOIN의 ON 절에 결합되는 일련의 SQL 식을 위치적으로 허용합니다. 예를 들어,
UserAddress을 활용하여 ON 기준을 특정 이메일 주소로만 제한하려는 경우 이와 같습니다.

>>> stmt = (
...   select(User.fullname).
...   join(User.addresses.and_(Address.email_address == 'pearl.krabs@gmail.com'))
... )

>>> session.execute(stmt).all()
[('Pearl Krabs',)]

위 코드는 다음 쿼리를 실행하는 것과 같습니다.

SELECT user_account.fullname
FROM user_account
JOIN address ON user_account.id = address.user_id AND address.email_address = ?
[...] ('pearl.krabs@gmail.com',)

# EXISTS has() , and()

EXISTS 서브쿼리들 섹션에서는 SQL EXISTS 키워드를 스칼라 서브 쿼리, 상호연관 쿼리 섹션과 함께 소개했습니다.
relationship() 은 관계 측면에서 공통적으로 서브쿼리를 생성하는데 사용할 수 있는 일부 도움을 제공합니다.


User.addresses와 같은 1:N (one-to-many) 관계의 경우 PropComparator.any()를 사용하여 user_account테이블과 다시 연결되는 주소 테이블에 서브쿼리를 생성할 수 있습니다. 이 메서드는 하위 쿼리와 일치하는 행을 제한하는 선택적 WHERE 기준을 허용합니다.

>>> stmt = (
...   select(User.fullname).
...   where(User.addresses.any(Address.email_address == 'pearl.krabs@gmail.com'))
... )

>>> session.execute(stmt).all()
[('Pearl Krabs',)]

위 코드는 다음 쿼리를 실행하는 것과 같습니다.

SELECT user_account.fullname
FROM user_account
WHERE EXISTS (SELECT 1
FROM address
WHERE user_account.id = address.user_id AND address.email_address = ?)
[...] ('pearl.krabs@gmail.com',)

이와 반대로 관련된 데이터가 없는 객체를 찾는 것은 ~User.addresses.any()을 사용하여 User 객체에 검색하는 방법입니다.

>>> stmt = (
...   select(User.fullname).
...   where(~User.addresses.any())
... )

>>> session.execute(stmt).all()
[('Patrick McStar',), ('Squidward Tentacles',), ('Eugene H. Krabs',)]

위 코드는 다음 쿼리를 실행하는 것과 같습니다.

SELECT user_account.fullname
FROM user_account
WHERE NOT (EXISTS (SELECT 1
FROM address
WHERE user_account.id = address.user_id))
[...] ()

PropComparator.has() 메서드는 PropComparator.any()와 비슷한 방식으로 작동하지만, N:1 (Many-to-one) 관계에 사용됩니다.
예시로 "pearl"에 속하는 모든 Address 객체를 찾으려는 경우 이와 같습니다.

>>> stmt = (
...   select(Address.email_address).
...   where(Address.user.has(User.name=="pkrabs"))
... )

>>> session.execute(stmt).all()
[('pearl.krabs@gmail.com',), ('pearl@aol.com',)]

위 코드는 다음 쿼리를 실행하는 것과 같습니다.

SELECT address.email_address
FROM address
WHERE EXISTS (SELECT 1
FROM user_account
WHERE user_account.id = address.user_id AND user_account.name = ?)
[...] ('pkrabs',)

# 관계 연산자

relationship()와 함께 제공되는 SQL 생성 도우미에는 다음과 같은 몇 가지 종류가 있습니다.

  • N : 1 (Many-to-one) 비교
    특정 객체 인스턴스를 N : 1 관계와 비교하여 대상 엔티티의 외부 키가 지정된 객체의 기본 키값과 일치하는 행을 선택할 수 있습니다.
>>> print(select(Address).where(Address.user == u1))

위 코드는 다음 쿼리를 실행하는 것과 같습니다.

SELECT address.id, address.email_address, address.user_id
FROM address
WHERE :param_1 = address.user_id
  • NOT N : 1 (Many-to-one) 비교
    같지 않은 연산자(!=)를 사용할 수 있습니다.
>>> print(select(Address).where(Address.user != u1))

위 코드는 다음 쿼리를 실행하는 것과 같습니다.

SELECT address.id, address.email_address, address.user_id
FROM address
WHERE address.user_id != :user_id_1 OR address.user_id IS NULL
  • 객체가 1 : N (one-to-many) 컬렉션에 포함되어있는지 확인하는 방법입니다.
>>> print(select(User).where(User.addresses.contains(a1)))

위 코드는 다음 쿼리를 실행하는 것과 같습니다.

SELECT user_account.id, user_account.name, user_account.fullname
FROM user_account
WHERE user_account.id = :param_1
  • 객체가 1 : N 관계에서 특정 상위 항목에 있는지 확인하는 방법입니다.
    with_parent()은 주어진 상위 항목이 참조하는 행을 반환하는 비교를 생성합니다. 이는 == 연산자를 사용하는 것과 동일합니다.
>>> from sqlalchemy.orm import with_parent
>>> print(select(Address).where(with_parent(u1, User.addresses)))

위 코드는 다음 쿼리를 실행하는 것과 같습니다.

SELECT address.id, address.email_address, address.user_id
FROM address
WHERE :param_1 = address.user_id

# Loading relationshiop의 종류


관계 로드 섹션에서는 매핑된 객체 인스턴스로 작업할 때 relationship()을 사용하여 매핑된 특성에 엑세스하면 이 컬렉션에 있어야 하는 객체를 로드하며, 컬렉션이 채워지지 않은 경우 lazy load가 발생한다는 개념을 도입했습니다.

Lazy loading 방식은 가장 유명한 ORM 패턴 중 하나이며, 가장 논란이 많은 ORM 패턴이기도 합니다.
메모리에 있는 수십개의 ORM 객체가 각각 소수의 언로드 속성을 참조하는 경우, 객체의 일상적인 조작은 누적이 될 수 있는 많은 문제(N+1 Problem)를 암묵적으로 방출될 수 있습니다. 이러한 암시적 쿼리는 더 이상 사용할 수 없는 데이터베이스 변환을 시도할 때 또는 비동기화 같은 대체 동시성 패턴을 사용할 때 실제로 전혀 작동하지 않을 수 있습니다.

N + 1 Problem이란?
쿼리 1번으로 N건의 데이터를 가져왔는데 원하는 데이터를 얻기 위해 이 N건의 데이터를 데이터 수 만큼 반복해서 2차적으로 쿼리를 수행하는 문제입니다.

lazy loading 방식은 사용 중인 동시성 접근법과 호환되고 다른 방법으로 문제를 일으키지 않을 때 매우 인기있고 유용한 패턴입니다. 이러한 이유로 SQLAlchemy의 ORM은 이러한 로드 동작을 제허하고 최적화할 수 있는 기능에 중점을 둡니다.

무엇보다 ORM의 lazy loading 방식을 효과적으로 사용하는 첫 번째 단계는 Application을 테스트하고 SQL을 확인하는 것입니다.
Session에서 분리된 객체에 대해 로드가 부적절하게 발생하는 경우, Loading relationship의 종류 사용을 검토해야 합니다.

Select.options() 메서드를 사용하여 SELECT 문과 연결할 수 있는 객체로 표시됩니다.

for user_obj in session.execute(
    select(User).options(selectinload(User.addresses))
).scalars():
    user_obj.addresses  # access addresses collection already loaded

relationship.lazy를 사용하여 relationship()의 기본값으로 구성할 수도 있습니다.

from sqlalchemy.orm import relationship
class User(Base):
    __tablename__ = 'user_account'

    addresses = relationship("Address", back_populates="user", lazy="selectin")

가장 많이 사용되는 loading 방식 몇 가지를 소개합니다.

참고
관계 로딩 기법의 2가지 기법
Configuring Loader Strategies at Mapping Time - relationship() 구성에 대한 세부정보
Relationship Loading with Loader Options - 로더에 대한 세부정보


# Select IN loading 방식

최신 SQLAlchemy에서 가장 유용한 로딩방식 옵션은 selectinload()입니다. 이 옵션은 관련 컬렉션을 참조하는 객체 집합의 문제인 가장 일반적인 형태의 "N + 1 Problem"문제를 해결합니다.
대부분의 경우 JOIN 또는 하위 쿼리를 도입하지 않고 관련 테이블에 대해서만 내보낼 수 있는 SELET 양식을 사용하여 이 작업을 수행합니다. 또한 컬렉션이 로드되지 않은 상위 객체에 대한 쿼리만 수행합니다.
아래 예시는 User 객체와 관련된 Address 객체를 selectinload()하여 보여줍니다.
Session.execute() 호출하는 동안 데이터베이스에서는 두 개의 SELECT 문이 생성되고 두 번째는 관련 Address 객체를 가져오는 것입니다.

>>> from sqlalchemy.orm import selectinload
>>> stmt = (
...   select(User).options(selectinload(User.addresses)).order_by(User.id)
... )
>>> for row in session.execute(stmt):
...     print(f"{row.User.name}  ({', '.join(a.email_address for a in row.User.addresses)})")
spongebob  (spongebob@sqlalchemy.org)
sandy  (sandy@sqlalchemy.org, sandy@squirrelpower.org)
patrick  ()
squidward  ()
ehkrabs  ()
pkrabs  (pearl.krabs@gmail.com, pearl@aol.com)

위 코드는 다음 쿼리를 실행하는 것과 같습니다.

SELECT user_account.id, user_account.name, user_account.fullname
FROM user_account ORDER BY user_account.id
[...] ()
SELECT address.user_id AS address_user_id, address.id AS address_id,
address.email_address AS address_email_address
FROM address
WHERE address.user_id IN (?, ?, ?, ?, ?, ?)
[...] (1, 2, 3, 4, 5, 6)

# Joined Loading 방식

Joined Loading은 SQLAlchemy에서 가장 오래됬으며, 이 방식은 eager loading의 일종으로 joined eager loading이라고도 합니다. N : 1 관계의 객체를 로드하는 데 가장 적합하며, relationship()에 명시된 테이블을 SELECT JOIN하여 모든 테이블의 데이터들을 한꺼번에 가져오는 방식으로 Address 객체에 연결된 사용자가 있는 다음과 같은 경우에 OUTER JOIN이 아닌 INNER JOIN을 사용할 수 있습니다.

>>> from sqlalchemy.orm import joinedload
>>> stmt = (
...   select(Address).options(joinedload(Address.user, innerjoin=True)).order_by(Address.id)
... )
>>> for row in session.execute(stmt):
...     print(f"{row.Address.email_address} {row.Address.user.name}")

spongebob@sqlalchemy.org spongebob
sandy@sqlalchemy.org sandy
sandy@squirrelpower.org sandy
pearl.krabs@gmail.com pkrabs
pearl@aol.com pkrabs

위 코드는 다음 쿼리를 실행하는 것과 같습니다.

SELECT address.id, address.email_address, address.user_id, user_account_1.id AS id_1,
user_account_1.name, user_account_1.fullname
FROM address
JOIN user_account AS user_account_1 ON user_account_1.id = address.user_id
ORDER BY address.id
[...] ()

joinedload()는 1 : N 관계를 의미하는 컬렉션에도 사용되지만 중접 컬렉션 및 더 큰 컬렉션이므로 selectinload() 처럼 사례별로 평가해야 하는 것과 같은 다른 옵션과 비교 합니다.

SELECT 쿼리문의 WHERE 및 ORDER BY 기준은 joinload()에 의해 렌더링된 테이블을 대상으로 하지 않는다는 점에 유의하는 것이 중요합니다. 위 SQL 쿼리에서 직접 주소를 지정할 수 없는 *익명 별칭**이 user_account테이블에 적용된 것을 볼 수 있습니다. 이 개념은 Zen of joined Eager Loading 섹션에서 더 자세히 설명합니다.

joinedload()에 의해 ON 절은 이전 ON 조건 확대에서 설명한 방법 joinedload()을 사용하여 직접 영향을 받을 수 있습니다.

참고
일반적인 경우에는 "N + 1 problem"가 훨씬 덜 만연하기 때문에 다대일 열망 로드가 종종 필요하지 않다는 점에 유의하는 것이 중요합니다. 많은 객체가 모두 동일한 관련 객체를 참조하는 경우(예: Address 각각 동일한 참조하는 많은 객체) 일반 지연 로드를 사용하여 User객체에 대해 SQL이 한 번만 내보내 집니다. 지연 로드 루틴은 Session가능한 경우 SQL을 내보내지 않고 현재 기본 키로 관련 객체를 조회 합니다.


# Explicit Join + Eager load 방식

일반적인 사용 사례는 contains_eager()옵션을 사용하며, 이 옵션은 JOIN을 직접 설정했다고 가정하고 대신 COLUMNS 절의 추가 열이 반환된 각 객체의 관련 속성에 로드해야 한다는 점을 제외하고는 joinedload() 와 매우 유사합니다.

>>> from sqlalchemy.orm import contains_eager

>>> stmt = (
...   select(Address).
...   join(Address.user).
...   where(User.name == 'pkrabs').
...   options(contains_eager(Address.user)).order_by(Address.id)
... )

>>> for row in session.execute(stmt):
...     print(f"{row.Address.email_address} {row.Address.user.name}")

pearl.krabs@gmail.com pkrabs
pearl@aol.com pkrabs

위 코드는 다음 쿼리를 실행하는 것과 같습니다.

SELECT user_account.id, user_account.name, user_account.fullname,
address.id AS id_1, address.email_address, address.user_id
FROM address JOIN user_account ON user_account.id = address.user_id
WHERE user_account.name = ? ORDER BY address.id
[...] ('pkrabs',)

위에서 user_account.name을 필터링하고 user_account의 반환된 Address.user속성으로 로드했습니다.
joinedload()를 별도로 적용했다면 불필요하게 두 번 조인된 SQL 쿼리가 생성되었을 것입니다.

>>> stmt = (
...   select(Address).
...   join(Address.user).
...   where(User.name == 'pkrabs').
...   options(joinedload(Address.user)).order_by(Address.id)
... )
>>> print(stmt)  # SELECT has a JOIN and LEFT OUTER JOIN unnecessarily

위 코드는 다음 쿼리를 실행하는 것과 같습니다.

SELECT address.id, address.email_address, address.user_id,
user_account_1.id AS id_1, user_account_1.name, user_account_1.fullname
FROM address JOIN user_account ON user_account.id = address.user_id
LEFT OUTER JOIN user_account AS user_account_1 ON user_account_1.id = address.user_id
WHERE user_account.name = :name_1 ORDER BY address.id

참고
관계 로딩 기법의 2가지 기법
Zen of joined Eager Loading - 해당 로딩 방식에 대한 세부정보
Routing Explicit Joins/Statements into Eagerly Loaded Collections - using contains_eager()


# 로더 경로 설정

PropComparator.and_() 방법은 실제로 대부분의 로더 옵션에서 일반적으로 사용할 수 있습니다. 예를 들어 sqlalchemy.org도메인에서 사용자 이름과 이메일 주소를 다시 로드하려는 경우 selectinload() 전달된 인수에 PropComparator.and_()를 적용하여 다음 조건을 제한할 수 있습니다.

>>> from sqlalchemy.orm import selectinload
>>> stmt = (
...   select(User).
...   options(
...       selectinload(
...           User.addresses.and_(
...             ~Address.email_address.endswith("sqlalchemy.org")
...           )
...       )
...   ).
...   order_by(User.id).
...   execution_options(populate_existing=True)
... )

>>> for row in session.execute(stmt):
...     print(f"{row.User.name}  ({', '.join(a.email_address for a in row.User.addresses)})")

spongebob  ()
sandy  (sandy@squirrelpower.org)
patrick  ()
squidward  ()
ehkrabs  ()
pkrabs  (pearl.krabs@gmail.com, pearl@aol.com)

위 코드는 다음 쿼리를 실행하는 것과 같습니다.

SELECT user_account.id, user_account.name, user_account.fullname
FROM user_account ORDER BY user_account.id
[...] ()
SELECT address.user_id AS address_user_id, address.id AS address_id,
address.email_address AS address_email_address
FROM address
WHERE address.user_id IN (?, ?, ?, ?, ?, ?)
AND (address.email_address NOT LIKE '%' || ?)
[...] (1, 2, 3, 4, 5, 6, 'sqlalchemy.org')

위에서 매우 중요한 점은 .execution_options(populate_existing=True) 옵션이 추가되었다는 점 입니다.
행을 가져올 때 적용되는 이 옵션은 로더 옵션이 이미 로드된 객체의 기존 컬렉션 내용을 대체해야 함을 나타냅니다.
Session객체로 반복 작업하므로 위에서 로드되는 객체는 본 튜토리얼의 ORM 섹션 시작 시 처음 유지되었던 것과 동일한 Python 인스턴스입니다.


# raise loading 방식

raiseload()옵션은 일반적으로 느린 대신 오류를 발생시켜 N + 1 문제가 발생하는 것을 완전히 차단하는데 사용됩니다.
예로 두 가지 변형 모델이 있습니다. SQL이 필요한 lazy load 와 현재 Session만 참조하면 되는 작업을 포함한 모든 "load" 작업을 차단하는 raiseload.sql_only 옵션입니다.

class User(Base):
    __tablename__ = 'user_account'

    # ... Column mappings

    addresses = relationship("Address", back_populates="user", lazy="raise_on_sql")


class Address(Base):
    __tablename__ = 'address'

    # ... Column mappings

    user = relationship("User", back_populates="addresses", lazy="raise_on_sql")

이러한 매핑을 사용하면 응용 프로그램이 'lazy loading'에 차단되어 특정 쿼리에 로더 전략을 지정해야 합니다.

u1 = s.execute(select(User)).scalars().first()
u1.addresses
sqlalchemy.exc.InvalidRequestError: 'User.addresses' is not available due to lazy='raise_on_sql'

예외는 이 컬렉션을 대신 먼저 로드해야 함을 나타냅니다.

u1 = s.execute(select(User).options(selectinload(User.addresses))).scalars().first()

lazy="raise_on_sql" 옵션은 N : 1 관계에도 현명하게 시도합니다.
위에서 Address.user속성이 Address에 로드되지 않았지만 해당 User 객체가 동일한 Session에 있는 경우 "raiseload"은 오류를 발생시키지 않습니다.

참고
raiseload를 사용하여 원치 않는 lazy loading 방지
relatonship에서 lazy loading 방지