[FastAPI 시리즈 18편] 캐시와 성능 최적화 첫걸음

English version

API 응답 속도는 사용자 경험과 인프라 비용을 동시에 좌우합니다. 캐시는 "한 번 계산한 결과를 저장해 두었다가 다시 쓰는" 저장소입니다. 이 개념을 이해하면 DB 조회 횟수와 네트워크 트래픽을 줄일 수 있습니다. 이번 편에서는 FastAPI에서 쉽게 적용할 수 있는 캐시, 쿼리 최적화, uvicorn 파라미터 튜닝을 소개합니다.

준비: 캐시를 쉬운 말로 이해하기

  • 같은 질문이 여러 번 들어오면, 지난 답을 메모장에 적어 두고 다시 읽어주는 것이 캐시입니다.
  • 그 메모장이 서버 안에 있으면 "메모리 캐시", 여러 서버가 함께 쓰면 "Redis 캐시"입니다.
  • 캐시에는 만료 시간(유통기한)이 있어서, 그 시간이 지나면 새 답을 적어야 합니다.

이 이미지만 잡고 아래 실습을 따라가면 각 코드 조각이 왜 등장하는지 바로 연결됩니다.

가장 쉬운 예: 10초 동안 같은 답 내보내기

from fastapi import FastAPI
from datetime import datetime, timedelta

app = FastAPI()
cached_value: dict | None = None
expires_at: datetime | None = None

@app.get("/slow-value")
def slow_value():
    global cached_value, expires_at
    if cached_value and expires_at and datetime.utcnow() < expires_at:
        return cached_value
    cached_value = {"value": datetime.utcnow().isoformat()}
    expires_at = datetime.utcnow() + timedelta(seconds=10)
    return cached_value

이 간단한 코드만으로도 "첫 요청만 계산하고 이후 10초 동안 같은 응답을 돌려준다"는 캐시 개념을 바로 체험할 수 있습니다. 이후 섹션에서 FastAPI 프로젝트에 어울리는 모양으로 확장합니다.

이번 글에서 새로 나오는 용어

  1. 캐시(Cache): 이미 계산한 결과를 저장해 두었다가 다시 꺼내 쓰는 저장소로, DB나 외부 API 호출을 줄여 응답 속도를 높입니다.
  2. lru_cache: 파이썬 함수 결과를 메모리 안에서 재사용하게 해 주는 데코레이터로, 단일 프로세스 기준 캐시 구조를 연습할 때 사용합니다.
  3. Redis: 메모리 기반 키-값 저장소로, 여러 서버가 같은 캐시 데이터를 공유할 수 있어 FastAPI 워커가 많을 때 필수입니다.
  4. TTL(Time To Live): 캐시 항목이 유지되는 시간으로, 너무 길면 오래된 데이터가 남고 너무 짧으면 캐시 효과가 줄어듭니다.
  5. uvloop/httptools: uvicorn이 더 빠른 이벤트 루프·HTTP 파서를 사용하게 해 주는 옵션으로, 고성능 튜닝 섹션에서 다룹니다.

실습 카드

  • 예상 소요 시간: 55분 (핵심) / +30분 (선택 확장)
  • 사전 준비: 17편 설정/Redis URL, 기본 SQLModel 쿼리
  • 실습 목표: HTTP/Redis 캐시를 붙여 캐시 히트 효과를 체감하고, 시간이 남으면 성능 튜닝 도구를 맛본다

핵심 실습: HTTP/Redis 캐시

변하지 않는 데이터에 캐시 헤더를 붙이고, Redis 캐시를 통해 반복 조회를 줄이는 것이 이번 세션의 필수 범위입니다. 메모리 캐시는 흐름을 이해하기 위한 중간 단계로 포함합니다.

HTTP 캐시 헤더

변하지 않는 데이터에는 캐시 헤더를 붙여 클라이언트나 CDN이 재사용할 수 있게 합니다.

from fastapi import Response

@app.get("/public/config")
async def public_config(response: Response):
    response.headers["Cache-Control"] = "public, max-age=300"
    return {"theme": "light"}

max-age=300은 300초(5분) 동안 캐시를 재사용해도 된다는 의미입니다.

서버 측 캐시(메모리)

파이썬 딕셔너리를 이용한 단순 메모리 캐시는 하나의 uvicorn 프로세스 안에서만 유효하지만, 구조를 이해하기에 좋습니다.

from functools import lru_cache

@lru_cache(maxsize=128)
def get_exchange_rate(base: str, target: str) -> Decimal:
    return fetch_from_provider(base, target)

@app.get("/exchange")
def exchange(base: str, target: str):
    rate = get_exchange_rate(base, target)
    return {"rate": rate}

lru_cache는 동일 프로세스 내 함수 호출 결과를 재사용합니다. 단, 워커가 여러 개면 캐시가 분산되므로 Redis 같은 외부 캐시가 필요합니다.

즉, lru_cache프로세스 내부에서만 통하는 간단한 캐시, Redis는 여러 프로세스/서버가 함께 쓰는 공유 캐시라고 이해하면 구분이 쉽습니다.

Redis 캐시 패턴


redis_client = redis.from_url(settings.redis_url, encoding="utf-8", decode_responses=True)

async def cache_get(key: str):
    value = await redis_client.get(key)
    if value:
        return json.loads(value)

async def cache_set(key: str, data: dict, ttl: int = 60):
    await redis_client.set(key, json.dumps(data), ex=ttl)

@app.get("/tasks/{task_id}")
async def retrieve_task(task_id: int, session: Session = Depends(get_session)):
    cache_key = f"task:{task_id}"
    cached = await cache_get(cache_key)
    if cached:
        return cached
    task = session.get(Task, task_id)
    if not task:
        raise HTTPException(404, "Task not found")
    data = TaskRead.model_validate(task).model_dump()
    await cache_set(cache_key, data, ttl=120)
    return data

ttl(Time To Live)은 캐시의 유효 시간입니다. 적절히 설정하면 데이터 신선도를 유지하면서 DB 부하를 줄일 수 있습니다.

(선택) 튜닝과 벤치마크

여유가 있을 때만 아래 내용을 실습합니다. 캐시만으로도 충분한 개선을 체감했다면 다음 세션으로 미뤄도 됩니다.

(선택) 데이터베이스 튜닝 기본

  • N+1 쿼리를 피하기 위해 selectinload, joinedload 같은 SQLModel/SQLAlchemy 로더 옵션을 사용합니다.
  • 필요 컬럼만 선택해 네트워크 전송량을 줄입니다.
statement = select(Task.id, Task.title).where(Task.owner_id == current_user.id)

(선택) uvicorn/uv loop 설정

uvloop는 asyncio 이벤트 루프를 C로 구현해 속도를 끌어올립니다.

다만 이런 설정은 환경에 따라 차이가 크므로, 항상 빨라진다고 단정하기보다 실제 서비스에서 측정한 뒤 적용하는 편이 안전합니다.

pip install uvloop
uvicorn app.main:app --workers 4 --loop uvloop --http httptools

httptools는 Node.js에도 사용되는 HTTP 파서로, 기본 파서보다 빠릅니다.

(선택) 모니터링과 벤치마크

wrk, hey, k6 같은 도구로 간단히 부하를 걸어봅니다.

hey -n 1000 -c 50 https://api.example.com/tasks
  • -n 요청 수, -c 동시 요청 수.
  • 평균 응답 시간, 95/99 퍼센타일, 에러율을 확인합니다.

벤치마크 수치는 서버 사양, 네트워크, 데이터 크기에 따라 크게 달라집니다. 따라서 한 번의 측정 결과를 일반화하지 말고, 같은 조건에서 전후 비교하는 용도로 쓰는 것이 좋습니다.

기대 출력 확인

캐시는 적용 여부보다 전후 비교 결과가 더 중요합니다.

:::terminal{title="캐시 전후 비교 예시", showFinalPrompt="false"}

[
  { "cmd": "hey -n 30 -c 5 http://127.0.0.1:8000/slow-value", "output": "Summary:\n  Total:\t1.84 secs\n  Slowest:\t0.412 secs\n  Fastest:\t0.201 secs\n  Average:\t0.298 secs", "delay": 500 },
  { "cmd": "hey -n 30 -c 5 http://127.0.0.1:8000/slow-value?cached=true", "output": "Summary:\n  Total:\t0.51 secs\n  Slowest:\t0.091 secs\n  Fastest:\t0.010 secs\n  Average:\t0.041 secs", "delay": 500 }
]

:::

  • 확인할 점: 같은 조건에서 전/후를 비교했는지
  • 확인할 점: 총 시간과 평균 시간이 함께 줄었는지
  • 확인할 점: 성능이 좋아졌더라도 코드 복잡도가 과도하게 늘지 않았는지

D2: 캐시 계층 구조

ClientCDNFastAPIRedis CacheDatabase hitmiss

캐시 히트(hit)는 캐시에 이미 데이터가 있어 DB를 거치지 않는 경우, 미스(miss)는 캐시에 없어 DB를 조회해야 하는 경우를 의미합니다.

캐시 도입은 복잡도를 높이지만 트래픽이 증가할수록 투자 가치가 커집니다. 다음 편에서는 로그와 모니터링, 서비스 운영 중 오류를 다루는 방법을 살펴봅니다.

실습

  • 따라 하기: HTTP 캐시 헤더를 붙인 /public/config와 Redis 캐시를 적용한 /tasks/{id}를 호출해 캐시 히트 여부를 확인한다.
  • 확장하기: 선택 섹션을 따라 selectinload 등 로더 옵션을 적용하거나 uvicorn --loop uvloop 설정을 실험한다.
  • 디버깅: Redis 캐시 미스/히트 로그를 남기고 TTL이 지나면 다시 DB를 조회하는지 검증한다.
  • 완료 기준: 최소 한 엔드포인트에서 캐시가 작동하고 적어도 하나의 튜닝/벤치마크 명령을 기록했다.

마무리

HTTP 헤더, 메모리, Redis처럼 단계별 캐시를 익혀 두면 문제 성격에 맞는 도구를 고를 수 있습니다. 측정과 로그를 함께 남기면서 실험하면 캐시 전략이 제대로 효과를 내는지 쉽게 판단할 수 있습니다.

💬 댓글

이 글에 대한 의견을 남겨주세요