SQLAlchemy 하이브리드 속성으로 테넌트 지표 계산, 다중 테넌트 AI 사용량 집계 시 SELECT N+1 방지

발행: (2026년 6월 6일 PM 05:47 GMT+9)
8 분 소요
원문: Dev.to

출처: Dev.to

Ugur Aslim

다중 테넌트 계층에서 AI 기능 사용량을 집계할 때 SELECT N+1을 피하는 SQLAlchemy 하이브리드 프로퍼티

CitizenApp에서 3주 동안 성능 최적화에 매진했지만, 문제는 FastAPI 엔드포인트나 React 렌더링이 아니라 한 개의 대시보드 쿼리가 47,000개의 데이터베이스 호출을 발생시킨다는 것이었습니다. 원인? 테넌트 메트릭을 파이썬에서 계산했기 때문에 SQLAlchemy가 작업을 PostgreSQL로 넘기지 못한 것이었습니다.

대부분의 팀이 이 함정에 빠지는 이유는 직관적으로 더 쉽다고 느껴지기 때문입니다. 테넌트를 로드하고, 각 테넌트의 기능 사용량을 순회하며 메모리에서 채택률을 계산합니다. 코드가 자연스럽게 보이고, 개발 환경에서는 잘 동작합니다. 그런데 프로덕션에서 동시 사용자 500명을 맞이하면 데이터베이스 커넥션 풀이 금방 고갈됩니다.

이번 글에서는 SQLAlchemy의 hybrid_property 데코레이터가 왜 존재하는지, 다중 테넌트 시스템에 왜 필수적인지, 그리고 계산을 애플리케이션 레이어가 아니라 확장성이 뛰어난 데이터베이스 레이어로 옮기는 방법을 보여드리겠습니다.


문제: 애플리케이션 코드에서 계산된 메트릭

AI 기능 사용량을 다중 테넌트 계층 전체에 걸쳐 추적한다고 가정해봅시다. 데이터 모델은 대략 다음과 같습니다:

from sqlalchemy import Column, Integer, String, ForeignKey, DateTime, func
from sqlalchemy.orm import relationship
from datetime import datetime

class Tenant(Base):
    __tablename__ = "tenants"
    id = Column(Integer, primary_key=True)
    name = Column(String, nullable=False)
    parent_id = Column(Integer, ForeignKey("tenants.id"), nullable=True)
    children = relationship("Tenant", remote_side=[id], backref="parent")
    ai_features = relationship("AIFeatureUsage", back_populates="tenant")

class AIFeatureUsage(Base):
    __tablename__ = "ai_feature_usage"
    id = Column(Integer, primary_key=True)
    tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=False)
    feature_name = Column(String, nullable=False)
    inference_cost = Column(Float, default=0.0)
    created_at = Column(DateTime, default=datetime.utcnow)
    tenant = relationship("Tenant", back_populates="ai_features")

Enter fullscreen mode
Exit fullscreen mode

대시보드 엔드포인트가 모든 테넌트를 로드하고 채택률(9개의 AI 기능 중 몇 개를 사용했는지)를 계산한다고 하면 코드는 다음과 같습니다:

# FastAPI endpoint — WRONG APPROACH
@app.get("/tenants/metrics")
async def get_tenant_metrics(session: Session = Depends(get_session)):
    tenants = session.query(Tenant).all()  # 1 query

    metrics = []
    for tenant in tenants:
        # 1 query per tenant to count features
        feature_count = session.query(AIFeatureUsage)\
            .filter(AIFeatureUsage.tenant_id == tenant.id)\
            .distinct(AIFeatureUsage.feature_name)\
            .count()

        adoption_rate = feature_count / 9
        metrics.append({
            "tenant_id": tenant.id,
            "adoption_rate": adoption_rate
        })

    return metrics

Enter fullscreen mode
Exit fullscreen mode

테넌트가 100개라면 총 101개의 쿼리가 실행됩니다. 1,000개라면 1,001개의 쿼리! 대시보드는 금방 사용 불가능해집니다.


해결책: 하이브리드 프로퍼티

SQLAlchemy의 hybrid_property를 사용하면 두 가지 상황에서 동작하는 계산된 속성을 정의할 수 있습니다:

  • 인메모리 파이썬 표현식 – 인스턴스를 다룰 때
  • SQL 표현식 – 쿼리 안에서 사용될 때

올바른 접근 방식은 다음과 같습니다:

from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy import and_

class Tenant(Base):
    __tablename__ = "tenants"
    id = Column(Integer, primary_key=True)
    name = Column(String, nullable=False)
    parent_id = Column(Integer, ForeignKey("tenants.id"), nullable=True)
    children = relationship("Tenant", remote_side=[id], backref="parent")
    ai_features = relationship("AIFeatureUsage", back_populates="tenant")

    @hybrid_property
    def feature_adoption_rate(self) -> float:
        """
        Python-side: Count distinct features used by this tenant.
        """
        if not self.ai_features:
            return 0.0

        distinct_features = len(set(f.feature_name for f in self.ai_features))
        return distinct_features / 9

    @feature_adoption_rate.expression
    @classmethod
    def feature_adoption_rate(cls):
        """
        SQL-side: Compute adoption rate as a subquery.
        This runs in the database, not in Python.
        """
        from sqlalchemy.sql import select, func as sql_func

        feature_count = select(sql_func.count(
            sql_func.distinct(AIFeatureUsage.feature_name)
        )).where(
            AIFeatureUsage.tenant_id == cls.id
        ).correlate(cls).scalar_subquery()

        return feature_count / 9

Enter fullscreen mode
Exit fullscreen mode

이제 엔드포인트는 이렇게 간단해집니다:

@app.get("/tenants/metrics")
async def get_tenant_metrics(session: Session = Depends(get_session)):
    tenants = session.query(Tenant).add_columns(
        Tenant.feature_adoption_rate.label("adoption_rate")
    ).all()

    return [
        {
            "tenant_id": t.id,
            "adoption_rate": t.adoption_rate
        }
        for t in tenants
    ]

Enter fullscreen mode
Exit fullscreen mode

단 한 번의 쿼리만 실행됩니다. 단일 JOIN으로 데이터베이스 레이어에서 채택률을 계산하므로 성능이 크게 개선됩니다.


다중 테넌트 계층: 트리 상위로 계산된 값 전파하기

실제 다중 테넌트 시스템은 계층 구조를 가집니다. 상위 테넌트는 하위 테넌트들의 집계 사용량을 물려받습니다. 이때 하이브리드 프로퍼티가 특히 유용합니다:

class Tenant(Base):
    __tablename__ = "tenants"
    id = Column(Integer, primary_key=True)
    name = Column(String, nullable=False)
    parent_id = Column(Integer, ForeignKey("tenants.id"), nullable=True)
    children = relationship("Tenant", remote_side=[id], backref="parent")
    ai_features = relationship("AIFeatureUsage", back_populates="tenant")

    @hybrid_property
    def total_inference_cost(self) -> float:
        """
        Include costs from this tenant + all descendants.
        """
        own_cost = sum(f.inference_cost for f in self.ai_features)
        children_cost = sum(c.total_inference_cost for c in self.children)
        return own_cost + children_cost

    @total_inference_cost.expression
    @classmethod
    def total_inference_cost(cls):
        """
        Recursive CTE in SQL (PostgreSQL 12+).
        """
        from sqlalchemy import text

        # Direct costs for this tenant
        direct = select(func.coalesce(
            func.sum(AIFeatureUsage.inference_cost), 0
        )).where(
            AIFeatureUsage.tenant_id == cls.id
        ).correlate(cls).scalar_subquery()

        # Children costs (you'd typically use a recursive CTE for large trees)
        # Simplified here for clarity
        children_costs = select(func.coalesce(
            func.sum(Tenant.total_inference_cost), 0
        )).where(
            Tenant.parent_id == cls.id
        ).correlate(cls).scalar_subquery()

        return direct + children_costs

Enter fullscreen mode
Exit fullscreen mode


함정: 하이브리드 프로퍼티가 조용히 실패할 때

제가 겪은 가장 큰 실수는 하이브리드 프로퍼티가 SQL로 변환되지 않을 때였습니다. 일부 표현식은 너무 복잡하거나 파이썬 전용 로직을 사용하기 때문에 SQL로 변환되지 않습니다:

class Tenant(Base):
    @hybrid_property
    def permission_inheritance_depth(self) -> int:
        """
        Count how many levels deep in the hierarchy.
        This LOOKS like it should work...
        """
        if self.parent:
            ret

(코드가 중간에 끊겼지만, 핵심은 파이썬 전용 로직(if self.parent: ret)이 포함돼 있으면 SQL 변환이 불가능하다는 점입니다.)

교훈

  1. 가능하면 순수 SQL 함수와 연산만 사용하세요.
  2. 복잡한 파이썬 로직이 필요하면 별도 서브쿼리/CTE로 분리하거나, 데이터베이스 함수(예: PostgreSQL PL/pgSQL)로 옮기세요.
  3. hybrid_property를 정의한 뒤 **<property>.expression**이 실제로 SQLAlchemy가 생성한 SQL을 확인해 보세요. str(query) 혹은 `query.statement.compile(dialect=
0 조회
Back to Blog

관련 글

더 보기 »

모바일 한여름 열풍

!Cover image for Mobile Midsommer Madnesshttps://media2.dev.to/dynamic/image/width=1000,height=420,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploa...