본문으로 건너뛰기

[Open WebUI] N+1 쿼리 제거: Function Valves 일괄 조회 최적화

PR 링크: open-webui/open-webui#22301 상태: Merged | 변경: +32 / -1

들어가며

Open WebUI에서 모델 목록을 로드할 때, 각 모델의 액션(action) 우선순위를 결정하기 위해 get_action_priority() 함수가 호출됩니다. 이 함수가 개별 액션마다 Functions.get_function_valves_by_id()를 호출하여 DB에 한 번씩 쿼리를 보내는 전형적인 N+1 쿼리 패턴이었습니다. 모델 10개에 액션 5개씩이면 50번의 DB 라운드트립이 발생합니다.

핵심 코드 분석

Before: N+1 쿼리 패턴

def get_action_priority(action_id):
    try:
        function_module = request.app.state.FUNCTIONS.get(action_id)
        if function_module and hasattr(function_module, "Valves"):
            valves_db = Functions.get_function_valves_by_id(action_id)  # 매번 DB 쿼리
            valves = function_module.Valves(**(valves_db if valves_db else {}))
            return getattr(valves, "priority", 0)
    except Exception:
        ...

get_action_priority는 모든 모델의 모든 액션에 대해 호출되므로, 총 DB 쿼리 수는 (액션 수 × 모델 수)입니다.

After: WHERE IN 일괄 조회

새로 추가된 배치 조회 메서드:

def get_function_valves_by_ids(
    self, ids: list[str], db: Optional[Session] = None
) -> dict[str, dict]:
    """
    Batch fetch valves for multiple functions in a single query.
    Returns a dict mapping function_id -> valves dict.
    """
    if not ids:
        return {}
    try:
        with get_db_context(db) as db:
            functions = (
                db.query(Function.id, Function.valves)
                .filter(Function.id.in_(ids))
                .all()
            )
            return {
                f.id: (f.valves if f.valves else {}) for f in functions
            }
    except Exception as e:
        log.exception(f"Error batch-fetching function valves: {e}")
        return {}

호출부 변경:

# 모든 function valve를 한 번에 조회
all_function_valves = Functions.get_function_valves_by_ids(
    list(all_function_ids)
)

def get_action_priority(action_id):
    try:
        function_module = request.app.state.FUNCTIONS.get(action_id)
        if function_module and hasattr(function_module, "Valves"):
            valves_db = all_function_valves.get(action_id)  # dict lookup만 수행
            valves = function_module.Valves(**(valves_db if valves_db else {}))
            return getattr(valves, "priority", 0)
    except Exception:
        ...

왜 이게 좋은가

1. DB 라운드트립 감소

N+1 → 1로 줄어듭니다. 액션이 50개든 100개든 DB에는 단 1회만 쿼리합니다. WHERE IN 절은 DB 인덱스를 효율적으로 활용하며, 네트워크 라운드트립 오버헤드가 가장 비싼 비용이므로 효과가 큽니다.

2. SQLAlchemy의 filter(Column.in_(list)) 패턴

SQLAlchemy에서 in_() 필터는 단일 SQL 문으로 변환됩니다:

SELECT id, valves FROM function WHERE id IN ('func1', 'func2', 'func3', ...)

기존의 개별 쿼리 N개가 하나로 합쳐지므로, connection pool 부담도 줄어듭니다.

3. 안전한 변경

기존 로직과 동일한 결과를 반환합니다. function이 없는 경우 빈 dict {}를 반환하고, 예외 발생 시에도 빈 dict를 반환하여 기존 동작을 보존합니다.

4. N+1 쿼리 안티패턴의 교훈

N+1 쿼리는 ORM을 사용하는 거의 모든 프로젝트에서 발생합니다. 해결 패턴은 일관됩니다:

  1. Eager loading: JOIN으로 관련 데이터를 한 번에 가져오기
  2. Batch loading: WHERE IN으로 필요한 ID를 모아서 한 번에 조회 (이 PR의 방식)
  3. DataLoader 패턴: 요청을 모아서 배치 처리 (GraphQL에서 주로 사용)

참고 자료

댓글

관련 포스트

PR Analysis 의 다른글