저는 파이썬 기반 프레임워크를 사용해 백엔드 프로그래밍을 해오면서 ORM으로 SQLAlchemy을 애용하고 있습니다.
오늘은 orm을 사용해 본 개발자라면 자주 들어봤을 N + 1 이슈를 해결하기 위해 JPA에서는 Fetch Join을 사용하듯 Python의 SQLAlchemy는 이 이슈를 어떻게 해결하는지 소개해보려 합니다.
아래 예제에서 사용한 코드는 git에 올려두었으니 직접 실습을 원한다면 참고해 주시면 되겠습니다.
예제 코드 : https://github.com/Mactto/SqlAlchemy-join-example/
GitHub - Mactto/SqlAlchemy-join-example: Sqlalchemy에서 N+1 이슈를 해결하는 방법 예제 코드
Sqlalchemy에서 N+1 이슈를 해결하는 방법 예제 코드. Contribute to Mactto/SqlAlchemy-join-example development by creating an account on GitHub.
github.com
N + 1 문제는 한 엔티티를 조회할 때 해당 엔티티와 연관된 다른 엔티티들을 추가로 조회해야 할 때 발생하는 이슈입니다.
예를 들어, Order(주문) 테이블이 있고 Product(상품) 테이블이 있으며 1개의 주문에는 여러 개의 상품 정보가 연결될 수 있다고 가정해 봅시다.
그럼 실제 테이블은 아래와 같이 구성할 수 있습니다.
class Order(Base):
__tablename__ = 'orders'
id = Column(Integer, primary_key=True)
customer_name = Column(String)
products = relationship("Product", back_populates="order")
class Product(Base):
__tablename__ = 'products'
id = Column(Integer, primary_key=True)
name = Column(String)
order_id = Column(Integer, ForeignKey('orders.id'))
order = relationship("Order", back_populates="products")
이때 우리는 1개 혹은 다수의 주문(Order) 정보를 조회할 때 관련된 상품(Product) 정보까지 불러오고 싶습니다.
그래서 아래와 같이 코드를 작성해 보았습니다.
@app.get("/orders/lazy")
async def get_all_orders_by_joinedload() -> list[GetAllOrdersResponse]:
orders = (
session.execute(
expression.select(Order).join(Order.products)
)
.scalars()
.all()
)
return [
GetAllOrdersResponse(
id=order.id,
customer_name=order.customer_name,
products=[
GetAllOrdersResponse.Product(
id=product.id,
name=product.name
)
for product in order.products
]
)
for order in orders
]
여기서 우리가 예상한 SQL 쿼리는 아래와 같을 것입니다.
SELECT
Order.id
Order.customer_name
Product.id
Product.name
FROM
Order
JOIN
Product
ON
Product.order_id == Order.id
하지만 실제로 쿼리를 찍어보면 아래와 같이 select 쿼리가 2개 날아가는 것을 확인할 수 있습니다.
찍힌 쿼리 로그를 확인해 보면 join을 사용하지 않고 Order 객체를 불러오는 쿼리 1개, Product 객체를 불러오는 쿼리 1개가 각각 날아가는 걸 볼 수 있습니다.
이렇게 join을 사용하지 않고 쿼리가 각각 날아가는 이유는 SqlAlchemy의 기본 Join 전략이 Lazy Loading (지연 로딩)이기 때문입니다.
우리가 아무런 Join 전략을 명시해주지 않았기 때문에 실제로 코드는 아래와 같다고 볼 수 있습니다.
orders = (
session.execute(
expression.select(Order).options(lazyload(Order.products))
)
.all()
)
여기서 Lazy Loading이란 객체의 속성이 필요한 순간까지 데이터를 불러오기 않고, 해당 속성이 실제로 필요할 때 데이터를 로딩하는 전략입니다.
따라서 우리가 위에서 Order 객체를 불러올 때 관련된 Product를 불러오기 위해 join 문법을 사용했지만
실제로 불러온 정보들은 Order 객체일 뿐 관련된 Product의 정보들을 불러오기 위해 또 쿼리가 날아가는 것입니다.
따라서 Order 속성들을 불러올 때 우리가 예상한 SQL 쿼리대로 Product까지 불러오려면 Eager Loading (즉시 로딩) 전략을 사용해야 합니다.
코드 상으로 이를 적용하기 위해서는 2가지 방법이 있습니다.
1. 모델 정의 레벨에서 명시하기
class Order(Base):
__tablename__ = 'orders'
id = Column(Integer, primary_key=True)
customer_name = Column(String)
products = relationship("Product", back_populates="order", lazy="joined") // 해당 부분
2. 비즈니스 코드 레벨에서 명시하기
orders = (
session.execute(
expression.select(Order).options(joinedload(Order.products))
)
.all()
)
어떤 방법을 선택할지는 본인의 선택이며 개발하고 있는 서비스 특징이나 컨벤션에 적합한 방식을 선택하면 됩니다.
그럼 위처럼 Eager Loading을 적용했을 때 날아가는 SQL 쿼리를 살펴보면 아래와 같습니다.
이제는 Order 객체를 불러올 때 join을 통해 Product 속성도 함께 불러오며 쿼리도 1개만 날아가는 것을 확인할 수 있습니다.
근데 여기서 의문이 들 수 있습니다.
그럼 join이 필요한 상황에서 join 문법 대신 joinedload를 사용하면 join은 왜 필요한 거지?
실제로 join과 joined_load는 굉장히 헷갈리며 혼동의 여지가 충분합니다.
이를 이해하기 위해서 SqlAlchemy 팀이 이 두 문법을 나눈 이유를 알아보는 것이 좋습니다.
아래는 실제 공식문서에 있는 내용을 인용한 글입니다.
Since joined eager loading seems to have many resemblances to the use of Query.join(), it often produces confusion as to when and how it should be used. It is critical to understand the distinction that while Query.join() is used to alter the results of a query, joinedload() goes through great lengths to not alter the results of the query, and instead hide the effects of the rendered join to only allow for related objects to be present.
번역하면 실제로 joinedload와 join은 유사한 점이 많기 때문에 언제 어떻게 사용해야 하는지 혼동되는 경우가 종종 있다고 합니다.
하지만 join은 쿼리 결과를 변경하는 데 사용하는 반면 joinedload는 쿼리 결과를 변경하지 않고 렌더링 된 조인의 효과를 숨기고 관련 객체만 표시되도록 하기 위해 많은 노력을 기울였다고 합니다.
이게 무슨 말이냐 하면
joinedload는 관련된 객체를 단순히 load 하기 위한 용도로 만들어졌기 때문에
관련 필드들만 불러올 수 있지 이를 filtering 하고 sorting 하는 등의 용도로 사용할 수 없습니다.
filtering이나 sorting 등의 추가 작업들이 필요하다면 join을 사용해야 합니다.
글로는 이해가 어려울 수 있으니 직접 코드로 살펴보겠습니다.
먼저 정말 joinedload로는 filtering이 불가한지 알기 위해 바로 전 코드에 필터를 걸어보았습니다.
제 DB에는 Product 테이블에 name이 practice인 데이터가 있길래 해당 값을 가지고 있는 Order만 출력하도록 필터링하였습니다.
# joinload로 필터링 거는 예제
@app.get("/orders/eager/filtering")
async def get_orders_by_eager_loading_with_filter() -> list[GetAllOrdersResponse]:
orders = (
session.execute(
expression
.select(Order)
.filter(Product.name =="practice")
.options(joinedload(Order.products))
)
.unique()
.scalars()
.all()
)
return [
GetAllOrdersResponse(
id=order.id,
customer_name=order.customer_name,
products=[
GetAllOrdersResponse.Product(
id=product.id,
name=product.name
)
for product in order.products
]
)
for order in orders
]
실제로 해당 API를 호출해 보면 결과는 잘 나오는데 필터링이 적용되지 않고 이전과 같이 모든 Order 객체가 나오는 걸 확인할 수 있습니다.
그럼 이번엔 join 문법을 추가해 차이를 비교해 보겠습니다.
# joinload와 join으로 필터링 거는 예제
@app.get("/orders/eager/filtering_with_join")
async def get_orders_by_eager_loading_with_filter_join() -> list[GetAllOrdersResponse]:
orders = (
session.execute(
expression
.select(Order)
.join(Product) // 해당 부분 추가
.filter(Product.name =="practice")
.options(joinedload(Order.products))
)
.scalars()
.all()
)
return [
GetAllOrdersResponse(
id=order.id,
customer_name=order.customer_name,
products=[
GetAllOrdersResponse.Product(
id=product.id,
name=product.name
)
for product in order.products
]
)
for order in orders
]
아래는 API 호출 결과이며 정상적으로 Product의 name이 "practice"인 객체 하나만 잘 불러온 걸 볼 수 있습니다.
[
{
"customer_name": "Emily Olson",
"products": [
{
"id": 1,
"name": "have"
},
{
"id": 2,
"name": "practice"
},
{
"id": 3,
"name": "exactly"
}
]
}
]
정리하자면 join과 joinedload를 사용하는 상황은 아래와 같습니다.
join : 관련 객체를 로드할 때 필드까지 함께 불러오진 않으며 filtering이나 sorting 등이 가능하도록 한다.
joinedload : 관련 객체를 로드할 때 모든 필드를 불러오지만 filtering이나 sorting 등의 추가 작업은 불가능하다.
ORM은 생산성을 높여주는 도구이기도 하지만 잘못 사용하게 되면 복잡하고 비효율적인 쿼리를 발생시켜 서비스 장애를 발생시킬 수도 있는 양날의 검과 같은 도구인 것 같습니다.
따라서 항상 orm 코드를 작성했을 때 비효율적인 쿼리가 발생하진 않는지 주의하면서 사용해야 합니다.
'개발공부' 카테고리의 다른 글
동시성 이슈가 발생하는 이유 (0) | 2024.06.26 |
---|---|
Python List 내부 뜯어보기 (0) | 2024.02.18 |
비밀번호를 어떻게 암호화해야 안전할까? (2) | 2024.01.07 |
Debezium이란 무엇인가? (0) | 2023.12.21 |
Kafka란 무엇인가? (1) | 2023.12.21 |