콘텐츠로 이동

성능 최적화 — 더 빠르게, 더 효율적으로

성능이 왜 중요한가?

사용자는 느린 서비스를 떠납니다.

연구 결과:
  - 응답 시간 100ms → 1s: 사용자 만족도 크게 감소
  - 페이지 로드 3초 이상: 사용자 53%가 이탈

성능 문제는 대부분 몇 가지 공통 원인에서 비롯됩니다.

1. 불필요하게 많은 DB 쿼리
2. 인덱스 없는 검색
3. 같은 결과를 반복해서 계산
4. 너무 많은 데이터를 한 번에 반환

데이터베이스 인덱스

인덱스가 없는 검색

-- todos 테이블에 user_id 인덱스가 없을 때
SELECT * FROM todos WHERE user_id = 5;
DB가 하는 일:
  1행 확인 → user_id가 5인가? NO
  2행 확인 → user_id가 5인가? NO
  3행 확인 → user_id가 5인가? YES → 결과에 추가
  ...
  10만 행 전부 확인

이를 Full Table Scan이라고 합니다.
데이터가 많을수록 느려집니다.

인덱스가 있는 검색

-- 인덱스 생성
CREATE INDEX idx_todos_user_id ON todos(user_id);

-- 같은 쿼리
SELECT * FROM todos WHERE user_id = 5;
DB가 하는 일:
  인덱스(정렬된 구조)에서 user_id=5 위치로 바로 점프
  해당 행들만 반환

10만 행이 있어도 거의 일정한 속도

언제 인덱스를 만드는가?

-- WHERE 절에 자주 쓰이는 컬럼
CREATE INDEX idx_todos_user_id ON todos(user_id);

-- JOIN에 사용되는 컬럼 (보통 FK)
-- FK에는 자동으로 인덱스가 생성되기도 함

-- 자주 사용하는 복합 조건
CREATE INDEX idx_todos_user_done ON todos(user_id, is_done);
-- 인덱스 확인 (SQLite)
.indices todos

-- 쿼리 실행 계획 확인
EXPLAIN QUERY PLAN SELECT * FROM todos WHERE user_id = 5;

모든 컬럼에 인덱스를 만들면 안 됩니다.
인덱스는 읽기 속도를 높이지만 쓰기(INSERT/UPDATE) 속도를 낮춥니다.

페이지네이션 (Pagination) — 한 번에 모두 보내지 않기

# 나쁜 예 — 전체 데이터를 한 번에 반환
@app.get("/todos")
def get_todos():
    rows = conn.execute("SELECT * FROM todos").fetchall()
    return [dict(r) for r in rows]  # 10만 건이면 응답이 수백 MB
# 좋은 예 — 페이지 단위로 반환
@app.get("/todos")
def get_todos(page: int = 1, size: int = 20):
    if size > 100:
        size = 100  # 최대 100개 제한

    offset = (page - 1) * size
    rows = conn.execute(
        "SELECT * FROM todos ORDER BY created_at DESC LIMIT ? OFFSET ?",
        (size, offset)
    ).fetchall()

    total = conn.execute("SELECT COUNT(*) FROM todos").fetchone()[0]

    return {
        "items": [dict(r) for r in rows],
        "total": total,
        "page": page,
        "size": size,
        "pages": (total + size - 1) // size
    }
GET /todos           → 1페이지, 20개
GET /todos?page=2    → 2페이지, 20개
GET /todos?size=50   → 1페이지, 50개

커서 기반 페이지네이션

대용량 데이터에서 OFFSET은 느려집니다 (앞 행을 모두 세야 함).
커서를 사용하면 더 빠릅니다.

@app.get("/todos")
def get_todos(cursor: int | None = None, size: int = 20):
    if cursor:
        rows = conn.execute(
            "SELECT * FROM todos WHERE id < ? ORDER BY id DESC LIMIT ?",
            (cursor, size)
        ).fetchall()
    else:
        rows = conn.execute(
            "SELECT * FROM todos ORDER BY id DESC LIMIT ?",
            (size,)
        ).fetchall()

    items = [dict(r) for r in rows]
    next_cursor = items[-1]["id"] if len(items) == size else None

    return {"items": items, "next_cursor": next_cursor}
GET /todos                  → 처음 20개
GET /todos?cursor=100       → id < 100인 것 중 최신 20개

캐싱 (Caching) — 결과를 저장해 재사용하기

같은 결과를 반복해서 계산하거나 DB를 조회하는 것은 낭비입니다.

캐시:  결과를 메모리에 저장해 두고, 같은 요청이 오면 저장된 값 반환

적합한 데이터:
  - 자주 조회되지만 잘 변하지 않는 것
  - "인기 항목 Top 10"
  - 사용자 프로필 (자주 바뀌지 않음)

부적합한 데이터:
  - 실시간성이 중요한 것 (잔액, 재고)
  - 자주 바뀌는 것

Python 함수 결과 캐싱

from functools import lru_cache

@lru_cache(maxsize=128)
def get_user_by_id(user_id: int):
    # 같은 user_id로 호출되면 DB 조회 없이 캐시된 결과 반환
    conn = get_db()
    row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
    conn.close()
    return dict(row) if row else None

lru_cache는 서버 메모리에 저장됩니다.
서버를 재시작하거나 여러 서버를 운영하면 캐시가 공유되지 않습니다.
여러 서버라면 Redis 같은 별도 캐시 서버를 사용합니다.

N+1 쿼리 문제

가장 흔한 성능 문제 중 하나입니다.

# 나쁜 예 — N+1 쿼리
users = conn.execute("SELECT * FROM users").fetchall()  # 쿼리 1번

for user in users:
    todos = conn.execute(
        "SELECT COUNT(*) FROM todos WHERE user_id = ?",
        (user["id"],)
    ).fetchone()  # 사용자 수만큼 쿼리 실행 (N번)

# 사용자 100명이면 101번 쿼리 실행
# 좋은 예 — JOIN으로 한 번에
rows = conn.execute("""
    SELECT users.id, users.name, COUNT(todos.id) AS todo_count
    FROM users
    LEFT JOIN todos ON todos.user_id = users.id
    GROUP BY users.id
""").fetchall()

# 쿼리 1번으로 끝

느린 쿼리 찾기

import time
import logging

logger = logging.getLogger(__name__)

def execute_query(conn, query, params=()):
    start = time.time()
    result = conn.execute(query, params)
    duration = time.time() - start

    if duration > 0.1:  # 100ms 이상이면 경고
        logger.warning(f"느린 쿼리 ({duration:.3f}s): {query[:80]}")

    return result

응답 크기 줄이기 — 필요한 것만 반환

# 나쁜 예 — 필요 없는 데이터까지 포함
@app.get("/todos")
def get_todos():
    rows = conn.execute("SELECT * FROM todos").fetchall()
    return [dict(r) for r in rows]
# is_done, created_at, user_id, updated_at 전부 포함

# 좋은 예 — 필요한 컬럼만 선택
@app.get("/todos")
def get_todos():
    rows = conn.execute("SELECT id, title, is_done FROM todos").fetchall()
    return [dict(r) for r in rows]

성능 측정 — 개선 전에 먼저 측정

"빠르게 만들자"보다 "어디가 느린지 먼저 찾자"가 중요합니다.

# 간단한 타이머
import time

start = time.time()
# ... 측정할 코드 ...
print(f"소요 시간: {time.time() - start:.3f}s")
성능 최적화 순서:
1. 느린 부분 측정 (타이머, 로그)
2. 병목 찾기 (DB 쿼리? 계산? 네트워크?)
3. 개선
4. 다시 측정 (정말 빨라졌는가?)

실습 미션

미션 1: 인덱스 추가

todos 테이블에 다음 인덱스를 추가하세요:
1. user_id 인덱스
2. created_at 인덱스
EXPLAIN QUERY PLAN으로 인덱스 사용 여부를 확인하세요.

미션 2: 페이지네이션 구현

GET /todos 엔드포인트에 페이지네이션을 추가하세요:
- page, size 쿼리 파라미터 지원
- size 최대값 100 제한
- 응답에 total, page, pages 포함

미션 3: N+1 쿼리 제거

사용자 목록과 각 사용자의 할 일 개수를 반환하는 API를 만드세요.
처음에는 N+1 방식으로 만들고,
JOIN으로 바꾼 뒤 두 방식의 쿼리 실행 횟수를 비교하세요.

미션 4 (심화): 요청 타이머 미들웨어

모든 요청의 소요 시간을 로그로 남기는 미들웨어를 만들고:
- 100ms 이상: INFO 레벨
- 500ms 이상: WARNING 레벨
- 1s 이상: ERROR 레벨로 기록하세요.

핵심 요약

개념 설명
인덱스 WHERE 조건에 쓰이는 컬럼에 추가해 검색 속도 향상
페이지네이션 한 번에 전체 반환 대신 페이지 단위로 나눠 반환
캐싱 반복 계산 결과를 저장해 재사용
N+1 쿼리 루프 안에서 DB 호출 → JOIN으로 해결
커서 기반 페이지네이션 대용량에서 OFFSET보다 빠른 방식

"추측하지 말고 측정하세요."
성능 문제는 생각과 다른 곳에 있을 때가 많습니다.