코드로 우주평화

Sqlalchemy 에서 joinedload 할 때, The unique() method must be ... 에러 해결방법 본문

나는 이렇게 학습한다/DB

Sqlalchemy 에서 joinedload 할 때, The unique() method must be ... 에러 해결방법

daco2020 2023. 8. 30. 19:27

Sqlalchemy 로 ORM 영역에서 join 을 하려고 할 때 joinedload 를 자주 사용한다.

 

그런데 joinedload 방식은 복수의 ORM 객체를 불러올 때에 다음처럼 에러가 발생할 수 있다.

sqlalchemy.exc.InvalidRequestError: The unique() method must be invoked on this Result, as it contains results that include joined eager loads against collections

 

 

결론부터 말하자면 메시지의 내용대로 unique() 를 추가하면 된다. 아래처럼 말이다.

...
res = await session.execute(stmt)

res.scalars().all() # X
res.scalars().unique().all() # O

 

그런데 왜 이런 에러가 발생한 걸까? 한 번 자세히 살펴보자.

 

 

joinedload 란?

joinedload는 SQLAlchemy에서 ORM 쿼리를 할 때, 연관된 여러 테이블을 한 번의 쿼리로 가져오기 위한 옵션이다. 예를 들어, User 테이블과 Role 테이블이 있고, 하나의 User가 여러 Role를 가질 수 있다고 가정해 보자.

# ORM 모델과 생성 함수가 있다고 가정

user = await create_user()
await create_role(user.id, "첫번째 롤")
await create_role(user.id, "두번째 롤")

 

위에 처럼 user 를 생성하고 두 개의 role 을 연결시켰다. 그리고 아래처럼 joinedload 을 통해 데이터를 불러오면 의도한대로 함께 조회되는 것을 확인할 수 있다.

stmt = sa.select(User).options(joinedload(User.roles))
res = await session.execute(stmt)
user = res.scalar()

print(f"User ID: {user.id}, Roles: {[role.name for role in user.roles]}")

# User ID: 1, Roles: ['두번째', '첫번째']

 

하지만 위 코드에서 scalar() 는 여러 객체 중 하나의 객체만 호출하는 메서드다. 만약 우리가 하나의 user 만이 아닌 여러 user 를 모두 가져오려고 한다면 어떻게 해야할까?

 

 

복수 객체 불러오기

확인을 위해 user 를 3명으로 늘리고 각각 role 을 연결시켜보자

user1 = await create_user()
user2 = await create_user()
user3 = await create_user()
await create_role(user1.id, "첫번째 롤")
await create_role(user1.id, "두번째 롤")
await create_role(user2.id, "세번째 롤")
await create_role(user3.id, "네번째 롤")

 

user1 은 기존과 마찬가지로 첫번째, 두번째 롤을 갖고 나머지 user2 와 user3 은 세번째, 네번째 롤을 각각 연결하였다. 이렇게 생성한 전체 user 와 role 을 확인하기 위해 아래 처럼 코드를 작성하였다.

stmt = sa.select(User).options(joinedload(User.roles))
res = await session.execute(stmt)
users = res.scalars().all() # 수정된 부분

for user in users:
    print(f"User ID: {user.id}, Roles: {[role.name for role in user.roles]}")

 

scalars().all() 은 여러개의 객체를 ORM 모델로 가져오는 메서드이므로 우리는 정상적으로 값이 불러와질거라 기대할 것이다. 하지만 놀랍게도 앞선 에러가 재현되는 것을 볼 수 있다.

sqlalchemy.exc.InvalidRequestError: The unique() method must be invoked on this Result, as it contains results that include joined eager loads against collections

 

그 이유는 user1 에게 role 이 여러개 연결되어 있어 객체 중복이 발생하기 때문이다.

 

아래 join 된 테이블 표를 보면 이해가 쉬울 것이다.

 

 

1번 user 는 연결된 role 의 개수에 따라 두 개의 row 를 가진다. 즉 1번 user 의 row 가 현재 중복이므로 이를 ORM 객체로 변환하는데 혼란이 생긴 것이다.

 

이 때문에 Sqlalchemy 는 고유한 객체를 가져오도록 unique() 메서드 사용을 명시하고 있다. 아래는 공식문서에서 언급한 내용이다.

 

When using a joined load of a collection, it’s required that the Result.unique() method is called. The ORM will actually set a default row handler that will raise an error if this is not done, to ensure that a joined eager load collection does not return duplicate rows while still maintaining explicitness:

# 1.4 / 2.0 code

stmt = select(User).options(joinedload(User.addresses))

# statement will raise if unique() is not used, due to joinedload()
# of a collection.  in all other cases, unique() is not needed.
# By stating unique() explicitly, confusion over discrepancies between
# number of objects/ rows returned vs. "SELECT COUNT(*)" is resolved
rows = session.execute(stmt).unique().all()

 

 

문제 해결하기

res.scalars() 에 unique() 를 호출해보자. 드디어 기대한대로 동작하는 것을 확인할 수 있다.

stmt = sa.select(User).options(joinedload(User.roles))
res = await session.execute(stmt)
users = res.scalars().unique().all() # 추가된 부분

for user in users:
    print(f"User ID: {user.id}, Roles: {[role.name for role in user.roles]}")
    
# User ID: 1, Roles: ['첫번째', '두번째']
# User ID: 2, Roles: ['세번째']
# User ID: 3, Roles: ['네번째']

 

joinedload 는 join 한 테이블의 데이터를 한 번에 다 가져오는 즉시 로딩(Eager Loding)방식이다. Sqlalchemy 는 joinedload 외에도 즉시 로딩을 지원하는 다른 방법도 제공하고 있다.

 

subqueryload 와 selectinload 는 joinedload 와 마찬가지로 즉시 로딩을 지원하므로 joinedload 와 unique() 메서드를 사용하지 않고 동일한 기능을 구현할 수 있다.

 

stmt = sa.select(User).options(subqueryload(User.roles))
res = await session.execute(stmt)
users = res.scalars().all()

 

subqueryload 는 user를 먼저 가져온 후, 가져온 user의 정보를 서브쿼리로 사용해 roles를 가져온다.

 

stmt = sa.select(User).options(selectinload(User.roles))
res = await session.execute(stmt)
users = res.scalars().all()

 

이와 달리, selectinload 는 user를 먼저 가져온 후, 각 user의 id를 where in 구문을 사용해 roles를 가져온다.

 

 

마무리

지금까지 joinedload 에서 발생할 수 있는 'The unique() method must be ...' 에러와 sqlalchemy 가 제공하는 세 개의 '즉시 로딩' 방식을 소개했다. 이 셋은 ORM 영역에서 동작하며, 이 밖에도 join(), outerjoin() 이나 '지연 로딩(Lazy Loading)' 방식의 join 방법도 제공한다. 이들에 대해 자세히 살펴보고 요구사항이나 상황에 따라 적절한 join을 사용하기 바란다.

 

 

공식문서

https://docs.sqlalchemy.org/en/14/changelog/migration_20.html#overview