무거운 SQL 쿼리 최적화: 5초 -> 300ms 개선과정


이 글에서 다루는 코드와 데이터는 기밀 유지를 위해 일부 이름과 용어를 익명화했습니다.

1. 현재 상황과 아키텍처

내부 관리 플랫폼에서 ChangeRequest(변경 요청) 관리 기능을 제공하고 있습니다. 사용자가 리소스의 배포, 중지, 재배포 등을 요청하면 권한을 가진 관리자가 이를 승인하거나 반려하는 구조입니다.

이번 글에서 다루는 문제는 단순히 “쿼리 하나가 느리다” 수준이 아니라, 목록 조회 전체가 여러 단계의 쿼리와 ORM 로딩 비용까지 겹치면서 응답 시간이 크게 증가하던 사례입니다.

데이터 모델

ChangeRequest
  ├─ target_type (SERVICE, WORKFLOW, DASHBOARD, ...)
  ├─ target_version_id ──→ Version (ServiceVersion, WorkflowVersion, ...)
  ├─ submitter_id ──→ User
  └─ reviewer_id  ──→ User

Version ──→ Parent (Service, Workflow, Dashboard, ...)
Parent  ──→ TargetMeta ──→ ProjectSpace

change_request_tb 스키마

컬럼 타입 설명
id INT PK
target_type VARCHAR SERVICE, WORKFLOW, DASHBOARD 등 6종
target_version_id INT Version/Deployment 테이블의 FK
submitter_id INT FK → user_tb 요청자
reviewer_id INT FK → user_tb 승인자
requested_action VARCHAR Activate, Deactivate, Reactivate
review_status VARCHAR NULL(대기), Approved, Denied
comment TEXT 코멘트

3-Phase 쿼리 구조

목록 조회는 3단계로 실행됩니다.

  1. Phase 1 (COUNT): base_query를 서브쿼리로 감싸 SELECT count(*)
  2. Phase 2 (PAGE IDs): base_query에서 ORDER BY + OFFSET/LIMIT으로 현재 페이지 ID 추출
  3. Phase 3 (DETAIL): WHERE id IN (page_ids)로 상세 데이터 조회

구조 자체는 흔한 패턴이지만, 실제 실행 결과는 꽤 무거웠습니다.

즉 병목은 단일 SQL 한 줄에만 있는 것이 아니라, COUNT / 페이지 조회 / 상세 조회 / ORM 추가 로딩이 모두 합쳐진 형태였습니다.


2. SQL 프로파일러

SQLAlchemy를 사용할 때는 ORM이 생성하는 실제 SQL과 바인드 파라미터, 그리고 각 쿼리의 실행 시간을 한눈에 보기 어렵습니다. 그래서 이번에는 커스텀 프로파일러를 만들어 아래 기능을 확인할 수 있도록 했습니다.

SQLAlchemy 이벤트 훅

@event.listens_for(engine.sync_engine, "before_cursor_execute")
def _before_execute(conn, cursor, statement, parameters, context, executemany):
    conn.info.setdefault("_query_start", []).append(time.perf_counter())

@event.listens_for(engine.sync_engine, "after_cursor_execute")
def _after_execute(conn, cursor, statement, parameters, context, executemany):
    elapsed_ms = round(
        (time.perf_counter() - conn.info["_query_start"].pop()) * 1000, 3
    )
    _sql_profiles.append({
        "ms": elapsed_ms,
        "sql": _inline_params(statement, parameters),
    })

파라미터 인라인 헬퍼

def _inline_params(stmt: str, params) -> str:
    """바인드 파라미터를 SQL에 직접 삽입하여 실행 가능한 쿼리 생성."""
    if isinstance(params, dict):
        escaped = {k: _escape(v) for k, v in params.items()}
        return re.sub(r"%\((\w+)\)s", lambda m: escaped.get(m.group(1), "NULL"), stmt)
    # ... tuple/list 처리

SQL_NO_CACHE 컨텍스트 매니저

MySQL 쿼리 캐시 영향을 최대한 줄이고, 순수 실행 시간 경향을 보기 위해 SQL_NO_CACHE를 주입했습니다.

@contextmanager
def mysql_sql_no_cache(engine):
    def _inject(conn, cursor, statement, parameters, context, executemany):
        stripped = statement.lstrip()
        if not stripped.upper().startswith("SELECT "):
            return statement, parameters
        return f"SELECT SQL_NO_CACHE {stripped[7:]}", parameters

    event.listen(sync_engine, "before_cursor_execute", _inject, retval=True)
    try:
        yield
    finally:
        event.remove(sync_engine, "before_cursor_execute", _inject)

프로파일 출력 예시

======================================================================
  SQL PROFILE REPORT — 144 queries, 5807.37ms total
======================================================================

  [10] 🐢 SLOW  1677.123ms
  ------------------------------------------------------------------
  SELECT SQL_NO_CACHE count(*) FROM (SELECT ... ) AS anon_1

  [11] 🐢 SLOW  1666.456ms
  ------------------------------------------------------------------
  SELECT SQL_NO_CACHE anon_1.approval_id FROM (SELECT ... ) AS anon_1 ...

3. 프로파일 결과 분석

최적화 전 (Admin, 144 쿼리, 5,807ms)

쿼리 번호 소요시간 내용
[1]–[9] ~156ms 사전 설정 (statements, writable IDs 등)
[10] 1,677ms Phase 1 — COUNT
[11] 1,666ms Phase 2 — PAGE IDs
[12] 150ms Phase 3 — DETAIL
[13]–[144] ~2,145ms ORM selectin cascade (132쿼리)

여기서 바로 보이는 것은 다음 두 가지입니다.

  1. Phase 1, 2가 비정상적으로 무겁다
  2. Phase 3 이후 ORM 추가 쿼리가 과도하게 발생한다

3가지 병목 식별

병목 1: Phase 1, 2의 target_meta_tb BNL join

base_querytarget_meta_tb가 6-arm OR 조건으로 outer join되어 있었습니다.

base_query = (
    select(
        ChangeRequest.id.label('request_id'),
        # ...
        ProjectSpace.name.label('project_space_name'),  # ← 이것 때문에 join 필요
    )
    # ... 12개 revision/parent outerjoin ...
    .outerjoin(
        TargetMeta,
        or_(
            and_(TargetMeta.target_type == 'SERVICE',
                 TargetMeta.target_version_id == ServiceVersion.service_id),
            and_(TargetMeta.target_type == 'WORKFLOW',
                 TargetMeta.target_version_id == WorkflowVersion.workflow_id),
            # ... 4개 더
        )
    )
    .outerjoin(ProjectSpace, ProjectSpace.id == TargetMeta.project_space_id)
)

문제는 이 join이 COUNT와 PAGE IDs 단계에도 그대로 포함되어 있었다는 점입니다. 즉 실제로 아직 상세 데이터가 필요하지 않은 단계에서도, 무거운 join 비용을 계속 지불하고 있었습니다.

병목 2: Phase 3에서 ORM 객체 전체 SELECT → selectin cascade

query = select(
    ChangeRequest,
    # ...
    ServiceVersion,          # ← ORM 객체
    AutomationRelease,       # ← ORM 객체
    Automation,              # ← ORM 객체
)

이렇게 ORM 객체 전체를 SELECT에 포함하면, 결과 반환 이후 SQLAlchemy가 lazy='selectin' 관계를 따라 추가 로딩을 수행할 수 있습니다.

이번 케이스에서는 이 비용이 매우 크게 나타났고, 결과적으로 132개 추가 쿼리가 발생했습니다.

병목 3: Non-admin N+1 get_user_statements()

for request, ..., project_space_name in rows:
    if not is_admin:
        # 캐시 미스마다 DB 조회 발생
        updatable = await permission_service.can_user_create_resource(
            resource_type=request.target_type,
            project_space_name=project_space_name,
            ...
        )

권한 체크 자체는 필요하지만, 동일한 성격의 데이터를 반복 조회하면서 N+1 형태가 발생하고 있었습니다.


4. EXPLAIN ANALYZE로 쿼리 분석

Phase 1 COUNT 쿼리에 ANALYZE SQL_NO_CACHE를 실행했습니다.

table type rows r_rows Extra
change_request_tb ALL 6,428 6,293 Using where
service_version_tb eq_ref 1 0.24 Using where
service_tb eq_ref 1 0.20 Using where
... eq_ref 1 ... ...
target_meta_tb ALL 5,173 5,177 Using where; BNL join

change_request_tb 6,293행과 target_meta_tb 5,177행이 결합되면서, 대략 3,260만 번 수준의 비교 연산이 발생하고 있었습니다.

OR 기반 join 조건이 인덱스 활용을 어렵게 만들고, 그 결과 BNL(Block Nested Loop) 풀 스캔이 발생한 것입니다.


5. 근본 원인 분석

정리하면 병목은 아래 3가지로 분류할 수 있었습니다.

# 근본 원인 영향 범위 소요시간
1 Phase 1,2의 target_meta_tb + project_space_tb join Phase 1+2 3,343ms (58%)
2 Phase 3 ORM selectin cascade 132 쿼리 ~2,145ms (37%)
3 N+1 get_user_statements() ~14 쿼리 ~170ms (non-admin)

6. 수정 전략

수정 방향은 다음 3가지로 잡았습니다.

  1. Fix 1: base_query에서 target_meta_tb / project_space_tb join 제거, Phase 3에서만 유지
  2. Fix 2: Phase 3 SELECT에서 ORM 객체를 스칼라 컬럼으로 교체
  3. Fix 3: user_statements를 사전 조회하여 동기 헬퍼에 전달

7. 테스트 먼저 — 회귀 방지

최적화 작업에서 종종 놓치기 쉬운 것이 “성능은 좋아졌는데 기능이 깨지는 문제”입니다. 그래서 코드 변경 전에 성능 회귀 테스트와 기능 회귀 테스트를 먼저 작성합니다.

QueryCounter

class QueryCounter:
    """SQLAlchemy 엔진의 쿼리 수와 총 소요시간을 측정하는 컨텍스트 매니저."""

    def __init__(self, engine):
        self.engine = engine.sync_engine
        self.queries = []

    def _before_execute(self, conn, cursor, stmt, params, context, executemany):
        conn.info.setdefault("_qc_start", []).append(time.perf_counter())

    def _after_execute(self, conn, cursor, stmt, params, context, executemany):
        start = conn.info["_qc_start"].pop()
        self.queries.append({'time': (time.perf_counter() - start) * 1000})

    def __enter__(self):
        self.queries.clear()
        event.listen(self.engine, "before_cursor_execute", self._before_execute)
        event.listen(self.engine, "after_cursor_execute", self._after_execute)
        return self

    def __exit__(self, *args):
        event.remove(self.engine, "before_cursor_execute", self._before_execute)
        event.remove(self.engine, "after_cursor_execute", self._after_execute)

    @property
    def count(self): return len(self.queries)

    @property
    def total_time_ms(self): return sum(q['time'] for q in self.queries)

성능 회귀 테스트

async def test_admin_default_query_count(self, ...):
    with QueryCounter(engine) as qc:
        total, approvals = await list_change_requests(user=admin, pg=1, pg_size=10)

    assert total == 1000
    assert qc.count <= 25      # 쿼리 수 상한
    assert qc.total_time_ms < 500  # 시간 상한 (ms)

async def test_non_admin_default_query_count(self, ...):
    with QueryCounter(engine) as qc:
        total, _ = await list_change_requests(user=non_admin, pg=1, pg_size=10)

    assert total == 500
    assert qc.count <= 35
    assert qc.total_time_ms < 500

기능 회귀 테스트

성능 개선 과정에서 정렬, 필터 등 기존 기능이 깨지지 않았는지도 함께 검증합니다. 예를 들어 base_query의 join을 변경하면 ORDER BY 동작이 달라질 수 있으므로, 정렬 테스트를 미리 작성해 두었습니다.

async def test_order_by_id_desc(self, ...):
    """ID 내림차순 정렬이 올바르게 동작하는지 검증."""
    await create_multiple_change_requests(
        session, admin, project_space, base_resources,
        count=10, prefix='order-test'
    )

    total, responses = await list_change_requests(
        user=admin, pg=1, pg_size=10,
        order_field=OrderField.id, order_type=OrderType.desc,
        session=session,
    )

    ids = [r.id for r in responses]
    assert ids == sorted(ids, reverse=True), "IDs should be in descending order"

async def test_order_by_target_type(self, ...):
    """target_type 정렬: asc와 desc가 서로 역순인지 검증."""
    await create_multiple_change_requests(
        session, admin, project_space, base_resources,
        count=12, prefix='order-test'
    )

    _, responses_asc = await list_change_requests(
        user=admin, pg=1, pg_size=20,
        order_field=OrderField.target_type, order_type=OrderType.asc,
        session=session,
    )
    _, responses_desc = await list_change_requests(
        user=admin, pg=1, pg_size=20,
        order_field=OrderField.target_type, order_type=OrderType.desc,
        session=session,
    )

    types_asc = [r.target_type for r in responses_asc]
    types_desc = [r.target_type for r in responses_desc]
    assert types_asc == list(reversed(types_desc))

이런 기능 테스트가 있으면 join을 제거하거나 SELECT 컬럼을 변경해도 정렬/필터 동작 등 기능이 그대로인지 즉시 확인할 수 있습니다.

테스트 데이터

이 테스트로 다시 느려지지 않도록 상한을 고정하는 역할도 합니다.


8. 코드 수정 적용

Fix 1: base_query에서 target_meta_tb join 제거

Before — Phase 1, 2 모두 target_meta_tb BNL join 포함

base_query = (
    select(
        ChangeRequest.id.label('request_id'),
        # ...
        ProjectSpace.name.label('project_space_name'),
    )
    # ... revision/parent outerjoins ...
    .outerjoin(
        TargetMeta,
        or_(
            and_(TargetMeta.target_type == 'SERVICE',
                 TargetMeta.target_version_id == ServiceVersion.service_id),
            # ... 5개 더
        )
    )
    .outerjoin(ProjectSpace, ProjectSpace.id == TargetMeta.project_space_id)
    .where(...)
)

Aftertarget_meta_tb / project_space_tb join 제거, Phase 3에서만 유지

base_query = (
    select(
        ChangeRequest.id.label('request_id'),
        # ...
        # project_space_name 제거됨 — Phase 3에서만 필요
    )
    # ... revision/parent outerjoins 유지 ...
    # target_meta_tb, project_space_tb는 Phase 3에서만 사용
    .where(...)
)

핵심: COUNT와 PAGE IDs 단계에서 필요하지 않은 join은 제거한다는 것입니다.

Fix 2: Phase 3 ORM 객체 → 스칼라 컬럼

Before

query = select(
    ChangeRequest,
    # ...
    ServiceVersion,          # ORM 객체 전체 → selectin cascade 유발
    AutomationRelease,       # ORM 객체 전체
    Automation,              # ORM 객체 전체
)

for request, ..., svc_version, auto_release, automation in rows:
    if request.target_type == 'AUTOMATION':
        parent_id = auto_release.automation_id
        parent_name = automation.name
    # ...
    if svc_version is None:
        raise ...
    data.update(is_external=svc_version.is_external)

After

query = select(
    ChangeRequest,
    # ...
    # 스칼라 컬럼만 조회 — ORM 로딩 방지
    ServiceVersion.is_external.label('is_external'),
    AutomationRelease.automation_id.label('auto_parent_id'),
    Automation.name.label('auto_name'),
)

for request, ..., is_external, auto_parent_id, auto_name in rows:
    if request.target_type == 'AUTOMATION':
        parent_id = auto_parent_id
        parent_name = auto_name
    # ...
    if is_external is None:
        raise ...
    data.update(is_external=is_external)

실제로 사용하는 값이 몇 개 안 된다면 ORM 객체 전체를 읽을 이유가 없습니다. 필요한 스칼라 컬럼만 가져오는 편이 훨씬 안전하고 예측 가능합니다.

Fix 3: N+1 → 사전 조회 statements 전달

Before

# 각 고유 (target_type, project_space_name) 쌍마다 DB 조회
updatable = await permission_service.can_user_create_resource(
    resource_type=request.target_type,
    user=user,
    project_space_name=project_space_name,
    session=session     # ← 내부에서 get_user_statements() DB 호출
)

After

# 함수 시작 시 이미 조회한 user_statements 재사용
updatable = permission_service.can_user_create_resource_from_statements(
    statements=user_statements,   # ← 사전 조회된 데이터
    resource_type=request.target_type,
    project_space_name=project_space_name,
)

반복 DB 조회를 제거하고 이미 읽은 데이터를 재사용하도록 개선했습니다.


9. 최적화 후 프로파일 비교

After (Admin, 19 쿼리, 337ms)

쿼리 번호 소요시간 내용
[1]–[9] ~156ms 사전 설정 (statements, writable IDs)
[10] 12ms Phase 1 — COUNT (target_meta join 제거)
[11] 18ms Phase 2 — PAGE IDs (target_meta join 제거)
[12] 45ms Phase 3 — DETAIL (target_meta BNL, 10행 입력)
[13]–[19] ~106ms User → Role → Policy 관계 eager-load

전후 비교

항목 Admin 전 Admin 후 Non-admin 전 Non-admin 후
총 쿼리 수 144 19 110 28
총 소요시간 5,807ms 337ms (17×) 1,692ms 543ms (3.1×)
Phase 1 (COUNT) 1,677ms 12ms 62ms 32ms
Phase 2 (PAGE IDs) 1,666ms 18ms 52ms 26ms
Phase 3 (DETAIL) 150ms 45ms 131ms 121ms
ORM selectin 132쿼리, 2,145ms 0쿼리 75쿼리, 900ms 0쿼리
N+1 statements 14쿼리, 170ms 0쿼리

현재 병목 분포 (Admin 337ms)

사전 설정 (46%) ████████████████████████
Phase 1+2  (9%) █████
Phase 3   (13%) ███████
User 관계 로딩 (31%) ████████████████

Fix 2로 리소스 객체의 selectin cascade(132쿼리)는 완전히 제거되었습니다. 남은 31%는 ChangeRequestsubmitter/reviewerUserRolePolicy 관계의 eager-load로, 이번 최적화 대상과는 별개의 쿼리입니다.


10. 결론

3가지 수정을 통해 다음과 같은 개선을 얻었습니다.

Fix 내용 효과
1 Phase 1,2에서 target_meta_tb join 제거 3,343ms → 30ms
2 ORM 객체 → 스칼라 컬럼 132쿼리 제거
3 user_statements 사전 조회 전달 14쿼리 제거

핵심 교훈

향후 개선 여지