Python/FastAPI

FastAPI + SQLAlchemy 로 N+1 쿼리 해결하기

검정비니 2023. 10. 9. 19:39
728x90
반응형

N+1쿼리란

ORM에서 성능 이슈가 발생하면 가장 흔한 원인으로 `N+1 Problem`이 언급된다. N+1 Problem은 쿼리 1번으로 N건의 데이터를 가져왔는데 원하는 데이터를 얻기 위해 이 N건의 데이터를 데이터 수 만큼 반복해서 2차적으로 쿼리를 수행하는 문제이다.

이러한 문제가 발생하는 이유는 Object Mapper에서 데이터를 가져올 때 찾고자하는 객체에 대한 정보를 먼저 로딩하는데, 이때 해당 객체의 멤버 변수 등으로 연결되어 있는 다른 클래스와 매핑된 테이블 내의 데이터를 JOIN을 통해 가져오지 못하고 나중에 N건의 객체에 대해 각각 다시 쿼리를 보내서 해당하는 데이터를 가져오는 것이다.

Java Spring에서는 Join Fetch 등의 방법을 통해서 이러한 N+1 쿼리 문제를 해결하는데, 파이썬에서도 해결 방법이 존재한다.

 

relationship() 함수로 Foreign key 설정하기

SQLAlchemy는 Select.join()이나 Select.join_from() 등의 메서드를 사용해서 두 개 이상의 테이블을 join시키는 기능을 제공한다. 이 때, SQLAlchemy는 서로 다른 테이블을 합치기 위한 Foreign Key가 존재하는지를 확인하려 하는데, 이러한 Foreign Key에 대한 정보를 알려주는 방법이 바로 `relationship()` 함수를 사용하는 것이다.

아래 코드는 User와 Item 테이블에 대한 맵핑 클래스들이다. 이때, user는 여러 개의 아이템의 owner가 될 수 있기에 `relationship("Item", backref="owner")`라는 코드를 통해 foreign key에 대한 정보를 남기게 된다.

class User(Base):
    __tablename__ = "users"

    id = Column(Integer, primary_key=True, index=True)
    email = Column(String, unique=True, index=True)
    hashed_password = Column(String)
    is_active = Column(Boolean, default=True)

    items = relationship("Item", backref="owner")


class Item(Base):
    __tablename__ = "items"

    id = Column(Integer, primary_key=True, index=True)
    title = Column(String, index=True)
    description = Column(String, index=True)
    owner_id = Column(Integer, ForeignKey("users.id"))

sqltap을 사용해서 SQL DB 모니터링하기

sqltap은 SQLAlchemy를 사용하는 파이썬 앱에서 SQL 모니터링을 진행하기 위해 사용하는 라이브러리이다.

설치는 pip을 사용해서 간단하게 할 수 있다.

pip install sqltap

아래 예제 코드는 FastAPI 서버 어플리케이션에 http 이벤트를 모니터링하는 custom middleware를 추가하고, 이 middleware 함수 내에서 sqltap profiler를 생성하고 해당 요청에 대한 response가 올 때까지 모니터링을 진행한 뒤, 결과를 report.txt라는 텍스트 파일로 내보내는 기능을 구현한 예제이다.

import sqltap
from fastapi import FastAPI

app = FastAPI()


@app.middleware("http")
async def add_sql_tap(request: Request, call_next):
    # Start the profiler
    profiler = sqltap.start()
    # Call the next middleware
    response = await call_next(request)
    # Collect the statistics
    statistics = profiler.collect()
    # Generate a report
    sqltap.report(statistics, "report.txt", report_format="text")
    return response

 

N+1 쿼리 해결을 위해 joinedload() 사용하기

자바 스프링에서 Join Fetch를 사용하듯이 파이썬에서는 `joinedload()`를 사용해서 먼저 JOIN을 한 후에 데이터를 fetch하는 방식으로 N+1 쿼리 문제를 해결할 수 있다.

from fastapi import Depends, FastAPI
from sqlalchemy.orm import Session
from sqlalchemy.orm import joinedload

app = FastAPI()

# Dependency
def get_db():
    try:
        db = SessionLocal()
        yield db
    finally:
        db.close()


@app.get("/users")
def read_users(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
# The fix to the N+1 issue
    users = db.query(User).options(joinedload(User.items)).offset(skip).limit(limit).all()
    return users


위와 같이 Session.query() 메서드를 호출할 때 options 함수를 통해서 `joinedload()`를 사용해서 join된 데이터를 로딩하도록 설정해주면 N+1 쿼리를 해결할 수 있다.

SQLAlchemy로 ORM 기능을 사용할 때에는 항상 JOIN 연관 내용에 대해서는 N+1 쿼리가 터지지 않도록 주의해야 하며, 테스트를 진행할 때 sqltap을 통해서 상황을 정확히 기록하면서 코드를 테스트해야 한다.

 

반응형

'Python > FastAPI' 카테고리의 다른 글

FastAPI와 SQLAlchemy로 ORM 사용하기  (0) 2023.10.09