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
'나는 이렇게 학습한다 > DB' 카테고리의 다른 글
Supabase의 RPC를 활용해 실시간 카운터 기능 구현하기 (1) | 2024.09.14 |
---|---|
Sqlalchemy 의 scalar 메서드들을 살펴보자 (0) | 2024.01.24 |
SQLAlchemy _ ForeignKey 로 연결된 개체 한 번에 삭제하기 (0) | 2023.03.24 |
데이터베이스에서 인덱스는 왜 사용하는 걸까? (0) | 2022.03.19 |
트랜젝션이란? (0) | 2022.03.18 |