fastapi:~/app$ cat fastapi-htmx.md

FastAPI + HTMX: 빌드 과정이 없는 풀스택

# React나 webpack 없이 프로덕션 웹 앱을 만드세요: FastAPI, HTMX, Alpine.js, Jinja2, 순수 CSS, Bootstrap 패턴, i18n, 배포, SEO, 성능.

author: words: 9611 read_time: 49m updated: 2026-08-17 02:00
$ less fastapi-htmx.md

요약: FastAPI + HTMX + Alpine.js + Jinja2 + 순수 CSS 조합은 빌드 도구도, node_modules/도 전혀 없이 완벽한 Lighthouse 점수를 내는 프로덕션 웹 애플리케이션을 만들 수 있습니다. 이 가이드는 아키텍처부터 배포까지 전체 시스템을 다루며, 단 하나의 번들러, 컴파일러, 트랜스파일러도 없이 210개의 블로그 글, 인터랙티브 JavaScript 컴포넌트, 11개의 핵심 가이드, 48개의 디자인 스터디, 영어와 9개의 번역 로케일을 제공하는 프로덕션 레퍼런스로 blakecrosley.com을 사용합니다.1

현대 웹 개발 스택은 React, webpack, TypeScript, 빌드 파이프라인이 필요하다고 전제합니다. 하지만 콘텐츠 중심 사이트, 내부 도구, CRUD 애플리케이션, 포트폴리오 사이트, 문서 플랫폼처럼 상당히 많은 유형의 애플리케이션에서는 그 전제가 맞지 않습니다. 이 가이드에서 설명하는 스택은 프론트엔드 빌드 툴체인 전체를 제거하면서도 Lighthouse에서 100/100/100/100을 받는 사이트를 만들 수 있게 해줍니다.2

이것은 옹호론이 아닙니다. 측정 결과입니다. 여기서 설명하는 아키텍처는 프로덕션에서 실행되고 있으며, 10개 언어로 실제 사용자에게 서비스되고 있고, 그 수치는 검증할 수 있습니다.


핵심 요점

  • 서버 렌더링 HTML는 세 가지 문제 영역을 완전히 제거합니다: 클라이언트 상태 관리, JSON 직렬화 경계, 그리고 하이드레이션 불일치입니다. HTMX는 서버 응답을 최종 출력물로 만듭니다 — 클라이언트 측 렌더링 단계가 없습니다.
  • 빌드 도구가 없으면 빌드 실패도 없습니다. npm install peer dependency 충돌도 없고, 건드리지도 않은 파일에서 발생하는 TypeScript 컴파일러 오류도 없으며, 한 번도 가져온 적 없는 전이 의존성에 대한 Dependabot PR도 없습니다. 배포 파이프라인은 git push입니다.
  • Alpine.js는 HTMX가 처리할 수 없는 클라이언트 전용 상태를 담당합니다. 드롭다운, 모달, 모바일 내비게이션 토글, 그리고 순전히 브라우저에만 존재하는 모든 UI 상태는 Alpine.js의 영역입니다. 경계는 명확합니다: 상태가 서버를 필요로 한다면 HTMX를 사용하세요. 그렇지 않다면 Alpine.js를 사용하세요.
  • 커스텀 속성을 활용한 일반 CSS는 Sass와 Tailwind를 대체합니다. CSS 커스텀 속성은 런타임에 캐스케이드되고, 상속되며, 미디어 쿼리에 반응합니다. 전처리기 변수는 정적 값으로 컴파일되고 사라집니다. 브라우저는 커스텀 속성을 직접 읽습니다 — 컴파일 단계가 없습니다.
  • 이 접근 방식에는 명확한 경계가 있습니다. 컴포넌트 인터페이스를 공유하는 대규모 팀, 복잡한 클라이언트 측 상태를 가진 SaaS 제품, 그리고 npm 생태계 라이브러리에 의존하는 애플리케이션에는 적합하지 않습니다. 섹션 15의 의사결정 프레임워크가 그 경계를 정확히 규정합니다.
  • blakecrosley.com이 그 증거입니다. 이 가이드의 핵심 패턴(HTMX, Alpine.js, Jinja2, 일반 CSS)은 blakecrosley.com 프로덕션에서 실제로 동작하고 있습니다. Bootstrap과 SQLAlchemy 섹션은 이 특정 사이트에서는 사용되지 않지만 해당 스택의 표준 패턴을 다룹니다. 모든 주장에는 파일 경로, 설정 블록, 또는 PageSpeed Insights에서 직접 검증할 수 있는 Lighthouse 감사 결과가 뒷받침합니다.2

이 가이드를 사용하는 방법

이 가이드는 종합 참고 자료입니다. 자신의 경험 수준에 맞는 곳에서 시작하세요:

경험 시작 지점 다음으로 살펴볼 곳
HTMX를 처음 접하는 Python 개발자 The No-Build ThesisArchitecture OverviewHTMX Deep Dive Alpine.js Patterns, Security
대안을 검토 중인 React/Vue 개발자 The No-Build ThesisDecision Framework Architecture Overview, Performance
상호작용성을 추가하려는 FastAPI 개발자 HTMX Deep DiveAlpine.js Patterns i18n and Localization, Deployment
처음부터 구축하는 풀스택 개발자 Architecture Overview부터 순차적으로 읽기 지속적인 활용을 위한 Quick Reference Card

특정 패턴이나 속성을 검색하려면 Ctrl+F / Cmd+F를 사용하세요. 마지막에 있는 Quick Reference Card는 한눈에 훑어볼 수 있는 요약을 제공합니다.


The No-Build Thesis

이 명제는 좁고 구체적입니다: 솔로 개발자나 소규모 팀이 운영하는 콘텐츠 중심 사이트에서, 빌드 도구는 당신이 가지고 있지도 않은 문제를 해결하는 동시에, 실제로 가지고 있는 문제를 만들어냅니다.

다음은 blakecrosley.com의 실제 측정 지표입니다:

측정 항목 blakecrosley.com (No-Build) 일반적인 Next.js 프로젝트3
의존성 17개의 Python 패키지 311개 이상의 npm 패키지
빌드 설정 파일 0개 5-8개 (next.config, tsconfig, postcss, tailwind 등)
node_modules/ 크기 존재하지 않음 기본 187 MB, 추가 시 250-400 MB
설치 시간 pip install: 8초 npm install: 30-90초
빌드 단계 없음 next build: 15-60초
배포 파이프라인 git push → 약 40초 만에 라이브 설치 → 빌드 → 배포: 2-5분
Lighthouse Performance 100 명시적 최적화 없이 70-904

17개의 Python 패키지에는 FastAPI, Jinja2, Pydantic, uvicorn, nh3, 그리고 12개의 다른 패키지가 포함됩니다. 그중 빌드 도구는 없습니다. 컴파일러도 없습니다. 번들러도 없습니다.5

포기하는 것

정직하게 다루려면 실제 비용을 나열해야 합니다:

TypeScript가 없습니다. 모든 .js 파일은 일반 JavaScript입니다. 타입 오류는 컴파일러가 아니라 테스트와 코드 분석으로 잡습니다. 이 방식은 솔로 개발자에게는 적합합니다. 하지만 컴포넌트 인터페이스를 공유하는 10명 규모의 팀에서는 통하지 않을 것입니다.

Hot Module Replacement가 없습니다. CSS 변경 사항은 브라우저를 수동으로 새로고침해야 반영됩니다. HTMX의 hx-boost는 내비게이션을 충분히 빠르게 만들어 전체 새로고침을 견딜 만하지만, 시각적 반복 작업이 빠듯할 때는 HMR이 시간을 절약해 줍니다.

Tree Shaking이 없습니다. 작성한 모든 바이트의 JavaScript가 브라우저로 전송됩니다. 이 제약은 규율을 강제합니다: 거대한 유틸리티 모듈 대신 작고 집중된 파일로 구성하게 됩니다.

npm 컴포넌트 라이브러리가 없습니다. Radix도, shadcn/ui도, Headless UI도 없습니다. 모든 인터랙티브 요소는 직접 만들거나 Bootstrap 5에 내장된 컴포넌트를 사용합니다.

npm에서 가져오는 디자인 시스템 토큰이 없습니다. 디자인 시스템은 CSS 커스텀 속성에 존재합니다. 다른 프로젝트에서 패키지 형태로 가져올 수 없습니다.

이러한 트레이드오프는 1-3명의 개발자가 운영하는 콘텐츠 중심 사이트에서는 받아들일 만합니다. 15명 규모의 엔지니어링 팀이 있는 SaaS 제품에서는 받아들이기 어려울 것입니다. 섹션 15에서 의사결정 프레임워크를 제시합니다.

얻는 것

빌드 실패 제로. npm install이 peer dependency 충돌로 실패할 일이 없습니다. 건드리지도 않은 파일의 TypeScript 오류 때문에 next build가 실패할 일도 없습니다.6

View Source로 디버깅. 브라우저에서 실행되는 JavaScript는 당신이 작성한 JavaScript 그 자체입니다. 소스 맵이 필요 없습니다.

즉시 시작되는 로컬 환경. uvicorn app.main:app --reload는 2초 안에 시작됩니다.

구체적인 요청 워터폴. 첫 방문 시 로드되는 항목: 하나의 HTML 문서(gzip 약 15KB), 하나의 CSS 파일(약 8KB), HTMX(약 16KB, 캐시됨), Alpine.js(약 15KB, 캐시됨), 그리고 페이지의 인터랙티브 JS(약 4-8KB). 총합: 첫 방문 기준 대략 55-65KB.1

미래에도 유효한 프론트엔드. 클라이언트 측 코드는 HTML, CSS, JavaScript를 사용합니다 — 30년 동안 하위 호환성을 유지해 온 표준입니다.7 Webpack 4 → 5 마이그레이션도, Create React App 지원 종료도, Next.js App Router 마이그레이션도 없습니다.

스택 비교

측정 가능한 차원에서 no-build 스택이 일반적인 대안과 어떻게 비교되는지 살펴봅니다:

차원 FastAPI+HTMX (이 가이드) Next.js (React) Astro 11ty
브라우저로 전송되는 JS 35-40KB (HTMX+Alpine+소규모 페이지 스크립트) 85-250KB+ (React 런타임) 기본 0KB, 옵트인 islands 기본 0KB
빌드 단계 없음 필수 (webpack/turbopack) 필수 (Vite) 필수 (커스텀)
설정 파일 0개 5-8개 (next.config, tsconfig 등) 1-3개 (astro.config, tsconfig) 1-2개 (.eleventy.js)
배포 파이프라인 git push (40초) 설치+빌드+배포 (2-5분) 설치+빌드+배포 (1-3분) 설치+빌드+배포 (1-2분)
서버 측 상호작용성 네이티브 (HTMX) API 라우트 + 클라이언트 fetch 제한적 (form actions) 없음 (정적 출력)
클라이언트 상태 관리 Alpine.js (15KB) React state/context/Redux 프레임워크 islands 수동 JS
백엔드 언어 Python JavaScript/TypeScript JavaScript/TypeScript JavaScript
i18n 접근 방식 서버 측 (미들웨어) next-intl 또는 유사 패키지 @astrojs/i18n 수동
Lighthouse Performance 100 (실측) 일반적으로 70-904 일반적으로 95-100 일반적으로 95-100
적합한 용도 콘텐츠 사이트, CRUD, 대시보드 복잡한 SPA, 대규모 팀 콘텐츠 사이트, 마케팅 정적 블로그, 문서

Astro와 11ty는 콘텐츠 사이트에서 가장 가까운 경쟁자입니다. 둘 다 훌륭한 정적 출력물을 만들어내지만 빌드 단계와 JavaScript 툴체인이 필요합니다. FastAPI+HTMX 스택은 빌드 단계를 추가하지 않고도 서버 측 상호작용성(카테고리 필터링, 폼 처리, 실시간 검색)을 얻기 위해 정적 사이트의 성능을 일부 양보합니다. 사이트가 서버 상호작용 없이 순수하게 정적이라면 Astro나 11ty가 더 나은 선택일 수 있습니다.


아키텍처 개요

요청 흐름

모든 요청은 네 개의 레이어를 거치는 하나의 경로를 따릅니다:

Browser                FastAPI                Jinja2              HTMX/Alpine
  |                      |                     |                     |
  |--- GET /about ------>|                     |                     |
  |                      |-- render template ->|                     |
  |                      |                     |-- base.html ------->|
  |                      |                     |   + about.html      |
  |                      |<-- full HTML -------|                     |
  |<--- HTML response ---|                     |                     |
  |                                                                  |
  |--- hx-get /search ------------------------------------------------>|
  |                      |<-- HTMX request ----|                     |
  |                      |-- render partial -->|                     |
  |                      |                     |-- _results.html     |
  |                      |<-- HTML fragment ---|                     |
  |<--- HTML fragment ---|                     |                     |
  |--- DOM swap -------------------------------------------------------->|

전체 페이지 로드는 완전한 HTML 문서(기본 템플릿 + 페이지 템플릿)를 반환합니다. HTMX 요청은 HTML 프래그먼트(파셜)를 반환합니다. 서버는 요청 유형에 따라 무엇을 렌더링할지 결정합니다. Alpine.js는 서버와 통신하지 않는 클라이언트 전용 상태를 관리합니다.

컴포넌트 역할

컴포넌트 역할 범위
FastAPI 라우팅, 비즈니스 로직, 데이터 접근, 유효성 검사 서버
Jinja2 템플릿 렌더링, 상속, 매크로 서버
HTMX 서버 기반 인터랙티비티 (폼, 페이지네이션, 검색) 클라이언트 ↔ 서버
Alpine.js 클라이언트 전용 상태 (드롭다운, 모달, 토글) 클라이언트 전용
Bootstrap 5 그리드 시스템, 유틸리티 클래스, 반응형 레이아웃 클라이언트 (CSS)
Plain CSS 커스텀 속성, 컴포넌트 스타일, 디자인 토큰 클라이언트 (CSS)
Pydantic 요청/응답 유효성 검사, 설정 서버

프로젝트 구조

app/
├── main.py              # FastAPI app, middleware, templates
├── config.py            # Pydantic settings management
├── routes/
│   ├── pages.py         # Page routes (HTML responses)
│   └── api.py           # API routes (JSON/HTML fragment responses)
├── content.py           # Markdown loading, blog post parsing
├── security/
│   ├── headers.py       # CSP, HSTS, security headers middleware
│   ├── csrf.py          # HMAC-signed CSRF tokens
│   ├── rate_limit.py    # 3-tier rate limiting
│   └── logging.py       # Security event logging
├── i18n/
│   ├── config.py        # Supported locales, mappings
│   ├── middleware.py     # URL-based locale detection
│   ├── jinja.py         # Translation functions for templates
│   └── d1_client.py     # Cloudflare D1 translation storage
├── cache_assets.py      # Content-hash asset versioning
└── templates/
    ├── base.html         # Base layout with Alpine.js state
    ├── components/       # Reusable partials (_language_switcher.html, etc.)
    └── pages/            # Page templates (home.html, about.html, etc.)

content/
├── blog/                # Markdown blog posts with YAML frontmatter
└── guides/              # Multi-section guide markdown

static/
├── css/                 # Plain CSS (no preprocessors)
├── js/                  # Vanilla JavaScript (no bundlers)
│   └── vendor/          # Self-hosted HTMX, Alpine.js
└── images/              # Optimized images with WebP srcset

이 구조는 하나의 원칙을 따릅니다: 각 디렉터리에는 한 가지 유형의 항목만 포함합니다. 라우트는 routes/에, 템플릿은 templates/에, 정적 에셋은 static/에 있습니다. 빌드 단계가 하나를 다른 것으로 변환하는 과정은 없습니다.

SPA 아키텍처와의 비교

React + Next.js 프로젝트에서 동일한 구조는 다음과 같습니다:

src/
├── components/       # React components (JSX)
├── pages/            # Route handlers (also JSX)
├── api/              # API routes (also in pages/)
├── hooks/            # Custom React hooks
├── context/          # React context providers
├── lib/              # Utility functions
├── styles/           # CSS modules or Tailwind config
└── types/            # TypeScript type definitions

# Plus build configuration
next.config.js
tsconfig.json
postcss.config.js
tailwind.config.js
eslint.config.js
package.json
package-lock.json
node_modules/         # 187+ MB of dependencies

SPA 아키텍처는 이러한 디렉터리 간의 빌드 타임 조율을 필요로 합니다. TypeScript는 .tsx를 JavaScript로 컴파일합니다. PostCSS는 Tailwind 디렉티브를 CSS로 처리합니다. Webpack(또는 Turbopack)이 출력을 청크로 번들링합니다. 각 단계는 독립적으로 실패할 수 있습니다.

노빌드 아키텍처는 이런 조율이 필요하지 않습니다. 템플릿이 CSS 파일을 참조하고, 해당 CSS 파일은 static/css/에 존재하며, 브라우저가 직접 로드합니다. 파일 이름을 변경하면 템플릿 참조가 빌드 타임이 아닌 요청 시점에 깨집니다. 이는 컴파일 타임 오류를 런타임 오류로 전환하는 것이며, 실질적인 트레이드오프입니다. 개발 중 uvicorn --reload를 실행하는 개인 개발자에게 런타임 오류는 브라우저에서 즉시 나타납니다. 대규모 팀에서는 TypeScript가 잡아내는 컴파일 타임 오류가 런타임 오류로는 방지할 수 없는 유형의 버그를 사전에 차단합니다.


FastAPI 패턴

애플리케이션 설정

애플리케이션은 명시적인 미들웨어 순서로 main.py에서 초기화됩니다.

from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.middleware.gzip import GZipMiddleware

app = FastAPI(
    title="Blake Crosley",
    docs_url=None,     # Disable docs in production
    redoc_url=None,
    openapi_url=None,  # Prevent /openapi.json exposure
)

# Middleware order matters: last added = first executed
app.add_middleware(SecurityHeadersMiddleware)
app.add_middleware(GZipMiddleware, minimum_size=500)
app.add_middleware(LocaleMiddleware)
app.add_middleware(RateLimitMiddleware)
app.add_middleware(SecurityLogMiddleware, site_name="blakecrosley.com")

# Static files
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")

# Templates
templates = Jinja2Templates(directory=TEMPLATES_DIR)

여기서 세 가지 설계 결정이 중요합니다. 첫째, docs_url=Noneopenapi_url=None은 자동 API 문서 엔드포인트를 비활성화합니다. 공개 콘텐츠 사이트에는 /docs/openapi.json을 인터넷에 노출할 필요가 없습니다.8 둘째, 미들웨어 순서는 중요합니다. 보안 로깅은 가장 먼저 실행되도록 마지막에 추가되므로, 속도 제한으로 거부된 요청을 포함한 모든 요청을 캡처합니다. 셋째, GZipMiddleware는 500바이트를 초과하는 응답을 압축하며, 일반적으로 HTML 전송 크기를 70~80% 줄입니다. Starlette 1.5.0부터는 모든 항목을 압축하지 않습니다. 기본 제외 목록이 이미 압축된 페이로드와 바이너리 페이로드(gzip 및 zip 아카이브, PNG, JPEG, WebP, GIF, AVIF, audio/*, video/*, WOFF 및 WOFF2 폰트, text/event-stream)를 건너뛰는데, 이는 바람직한 동작입니다. PNG를 다시 압축하면 CPU만 소모하고 파일 크기는 약간 더 커집니다. 목록에서 image/*를 의도적으로 제외하므로 image/svg+xml은 계속 압축됩니다. 키워드 전용 exclude_content_types 매개변수로 이를 재정의할 수 있습니다.28

라우팅

라우트는 두 범주로 나뉩니다. 페이지 라우트는 전체 HTML 문서를 반환하고, API 라우트는 JSON 또는 HTML 조각을 반환합니다.

# routes/pages.py — full HTML responses
from fastapi import APIRouter, Request

router = APIRouter()

@router.get("/about")
async def about(request: Request):
    templates = request.app.state.templates
    return templates.TemplateResponse("pages/about.html", {
        "request": request,
        "page_title": "About — Blake Crosley",
        "page_description": "Designer, developer, dad.",
    })
# routes/api.py — JSON or HTML fragment responses
@router.get("/api/quiz/{quiz_id}/step")
async def quiz_step(request: Request, quiz_id: str, answers: str = ""):
    # Parse answers, compute next question or result
    question = get_next_question(quiz_id, answers)
    templates = request.app.state.templates
    return templates.TemplateResponse("components/_quiz_step.html", {
        "request": request,
        "question": question,
        "answers": answers,
        "step": len(answers.split(",")) if answers else 0,
    })

이 구분은 HTMX에서 중요합니다. 전체 페이지 라우트는 base.html을 확장하는 문서를 반환합니다. API 라우트는 HTMX가 기존 DOM 요소에 교체하는 HTML 조각을 반환합니다. 두 경우 모두 동일한 Jinja2 템플릿 엔진으로 렌더링하므로 별도의 API 계층은 필요하지 않습니다.

의존성 주입

FastAPI의 Depends() 시스템은 라우트 핸들러와 공통 로직을 깔끔하게 분리합니다.

from fastapi import Depends, Request

def get_templates(request: Request):
    """Get templates from app state."""
    return request.app.state.templates

def get_current_locale(request: Request) -> str:
    """Get locale from middleware-set request state."""
    return getattr(request.state, "locale", "en")

@router.get("/blog/{slug}")
async def blog_post(
    request: Request,
    slug: str,
    templates=Depends(get_templates),
    locale: str = Depends(get_current_locale),
):
    post = load_post_by_slug(slug)
    if not post:
        raise HTTPException(404, "Post not found")
    return templates.TemplateResponse("pages/blog/post.html", {
        "request": request,
        "post": post,
        "locale": locale,
    })

의존성은 조합할 수 있습니다. get_db 의존성은 요청에 의존하는 get_current_locale에 의존할 수 있습니다. FastAPI가 이 체인을 자동으로 해결합니다.

Pydantic 설정

구성에는 환경 변수 우선순위를 갖는 Pydantic의 BaseSettings를 사용합니다.

from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    D1_WORKER_URL: str = ""
    D1_AUTH_SECRET: str = ""
    CLOUDFLARE_ACCOUNT_ID: str = ""
    CLOUDFLARE_API_TOKEN: str = ""
    ANALYTICS_PASSKEY: str = ""

    class Config:
        env_file = ".env"
        env_file_encoding = "utf-8"

settings = Settings()

환경 변수는 .env 파일 값을 재정의합니다. 프로덕션(Railway)에서는 시크릿을 환경 변수로 설정합니다. 로컬에서는 .env 파일이 기본값을 제공합니다. Settings 클래스는 시작 시점에 타입을 검증하므로, 필수 필드가 없으면 런타임이 아니라 즉시 실패합니다.

Async 패턴

FastAPI 라우트는 기본적으로 async입니다. I/O 바운드 작업(데이터베이스 쿼리, HTTP 요청, 파일 읽기)에서는 async를 사용하면 이벤트 루프가 차단되지 않습니다.

@asynccontextmanager
async def lifespan(app: FastAPI):
    # Load translations into memory cache at startup
    async with httpx.AsyncClient() as client:
        for locale in SUPPORTED_LOCALES:
            resp = await client.post(f"{D1_URL}/query", ...)
            TRANSLATIONS[locale] = resp.json()["results"]
    yield
    # Cleanup on shutdown (if needed)

app = FastAPI(lifespan=lifespan)

이제 lifespan만이 유일한 시작/종료 경로입니다. Starlette는 2026년 3월 첫 안정 릴리스인 1.0에 도달했고(8월 8일 기준 1.6.0), 오랫동안 deprecated 상태였던 on_event, on_startup, on_shutdown 훅을 제거했습니다. lifespan(위 코드)이 유일한 메커니즘이며, @app.route() / @app.websocket_route()routes 목록의 Route / WebSocketRoute로 대체되었습니다. FastAPI 0.137.0(2026년 6월 14일)은 자체 라우터 내부 구조도 리팩터링했습니다. 이제 router.routes는 평면적인 APIRoute 객체 목록이 아니라 중간 노드 트리이므로, 순회할 대상이 아니라 내부 세부 사항으로 취급해야 합니다. 대신 include_router() 이후에 라우터에 추가한 라우트도 즉시 반영되며, 하위 라우터는 라우트를 정의하기 전에 포함할 수 있습니다. FastAPI 자체는 Starlette를 1.x 라인에 고정하지 않습니다. 런타임 요구 사항은 0.136.3 이후 starlette>=0.46.0이라는 최소 버전만 명시하며, 0.140.7까지 변경되지 않았습니다. 상한은 없고 Starlette 0.4x도 여전히 이를 충족합니다. 0.137.0 릴리스 노트의 1.x 버전 번호는 앱의 런타임 제약이 아니라 저장소 자체 테스트 lockfile의 dependabot 업데이트입니다.24 이 가이드의 패턴에는 아무 영향이 없습니다. 가이드는 전반적으로 lifespan과 표준 라우트 선언을 사용합니다. 다만 router.routes를 순회하는 도구를 유지 관리하거나 레거시 @app.on_event 핸들러를 아직 사용한다면 0.137.0 / Starlette 1.0은 호환성이 깨지는 변경입니다. FastAPI 0.137.2(2026년 6월 18일)는 router.routes가 내부 구현이 된 후 라우트를 열거하는 지원 방식인 iter_route_contexts()를 추가했습니다. 이어서 FastAPI 0.138.0(2026년 6월 20일)은 빌드된 정적 프런트엔드를 제공하는 app.frontend("/", directory="dist") / router.frontend(...)를 추가했습니다. 별도 SPA 빌드를 배포한다면 유용하지만, 이 가이드의 빌드 없는 서버 렌더링 접근과는 별개입니다(서버에서 HTML을 렌더링하는 대신 dist/ 디렉터리를 마운트합니다).25 FastAPI 0.139.0(2026년 7월 1일)은 app.frontend()의 의존성 지원으로 이를 확장했습니다. 예를 들어 제공되는 프런트엔드에 자동 쿠키 인증을 적용할 수 있으며, API 라우트에서 사용하던 동일한 Depends() 메커니즘을 정적 프런트엔드 마운트에도 적용합니다.26 FastAPI 0.141.0(2026년 7월 29일)은 app.frontend(check_dir="auto")를 추가해, 프런트엔드 빌드를 실행하기 전에 서버를 시작하는 일반적인 경우 빌드 디렉터리가 아직 없어 fastapi dev가 실패하지 않도록 했습니다. 같은 날 릴리스된 FastAPI 0.141.1은 app.frontend()의 의존성이 백그라운드 작업과 응답 헤더를 조용히 누락하던 문제를 수정했습니다. 쿠키를 설정하거나 BackgroundTask를 예약한 의존성은 API 라우트에서는 정상 작동했지만 프런트엔드 마운트에서는 해당 작업이 버려졌습니다. 0.139.0의 의존성 지원을 도입했다면, 0.141.1부터 앱의 나머지 부분과 동일하게 동작합니다.29

FastAPI 0.140.0은 2025년 11월 이후 모든 릴리스에 포함됐던 메모리 회귀를 끝냈습니다. 업그레이드하세요. 2026년 7월 24일 릴리스는 영향이 매우 큰 단일 리팩터링입니다. 모든 라우트 의존성 그래프의 모든 노드에 대해 FastAPI가 생성하는 내부 객체 Dependant에는 0.121.0(2025년 11월 3일)부터 functools.cached_property 속성이 누적되어 있었고, 0.139.2에서는 10개에 이르렀습니다. 캐시된 속성은 결과를 기록하기 위해 인스턴스별 __dict__가 필요하므로, 비용이 앱의 모든 그래프에 있는 모든 노드에 걸쳐 늘어났습니다. PR #16049는 해당 로직을 클래스 밖의 모듈 수준 헬퍼(_get_cache_key(), _get_oauth_scopes(), _uses_scopes())로 옮기고 Dependant@dataclass(slots=True)로 선언해 순수 데이터 홀더로 남겼습니다. 병합된 PR에서 FastAPI 자체 CodSpeed 실행 결과 test_dependency_graph 메모리 벤치마크는 17.5 MB → 1.1 MB, 16배 감소를 기록했습니다. 이 작업을 촉발한 보고서는 0.120.4에서 약 400 MB 미만을 유지하던 프로덕션 서비스가 0.121.3에서 OOM에 도달했다고 설명합니다. 이 가이드가 이후 권장한 모든 버전(0.137.x, 0.138.0, 0.139.2)에 이 문제가 포함되어 있었습니다. 앱에 깊거나 넓은 의존성 트리(중첩된 Depends(), 보안 스킴, 다수의 포함된 라우터)가 있다면 0.140.0은 애플리케이션 코드 변경 없이 얻을 수 있는 메모리 개선입니다.27

0.140.0은 시작일 뿐 전체 수정이 아닙니다. 0.140.7 이상으로 고정하세요. 해당 릴리스 3일 뒤인 2026년 7월 27일, FastAPI는 5시간 반 동안 7개 버전을 추가로 배포했습니다. 0.140.1부터 0.140.7까지 모두 같은 의존성 메커니즘을 리팩터링한 릴리스입니다. 작업은 두 부분으로 나뉩니다. 첫째는 평면화된 의존성 트리입니다. FastAPI는 이전에 각 라우트의 의존성 그래프를 평면화한 사본을 만들고 유지했는데, 0.140.2부터 이를 유지하지 않으며 0.140.3, 0.140.5, 0.140.6, 0.140.7에서는 OpenAPI 생성, 본문 필드, 요청 매개변수, 다시 OpenAPI에서 이를 재생성하던 나머지 위치를 제거했습니다. 0.140.4는 아무도 읽지 않는 반복 의존성 추적용 부기 작업을 제거했습니다. 둘째이자 눈에 보이는 임계값이 있는 유일한 변경은 0.140.1입니다. 사용자가 1,024개를 초과하는 고유 의존성을 가진 앱에서 캐시 스래싱을 보고했기 때문에, fastapi/dependencies/models.py의 호출 가능 객체 분류 헬퍼에 대한 lru_cache를 명명된 _CALLABLE_CLASSIFICATION_CACHE_SIZE 상수 뒤에서 1,024개에서 4,096개 항목으로 늘렸습니다. 여기서는 호출하는 API가 변경되지 않으므로 업그레이드는 버전 범프만으로 충분합니다. 두 가지 주의 사항은 분명히 짚어둘 가치가 있습니다. 릴리스 속도상 이 라인은 계속 변경되고 있으므로 0.140.7이 끝이라고 가정하지 말고 릴리스 노트를 읽어야 합니다. 또한 FastAPI는 같은 기간에 이 작업을 측정하는 OpenAPI 의존성 벤치마크(PR #16075)를 추가했으므로, 공개된 수치는 전체 7개 버전의 흐름이 아니라 마지막 몇 릴리스를 다룹니다.30

CPU 바운드 작업(Markdown 렌더링, CSS 추출)에는 동기 함수를 사용할 수 있습니다. 라우트 핸들러를 async로 선언하지 않으면 FastAPI가 자동으로 스레드 풀에서 실행합니다.

# Sync function — FastAPI runs it in a thread pool
@router.get("/blog/{slug}")
def blog_post(slug: str):
    post = load_post_by_slug(slug)  # CPU-bound Markdown parsing
    return templates.TemplateResponse(...)

규칙은 간단합니다. 함수가 I/O를 await하면 async로 만드세요. CPU 작업을 수행한다면 동기식으로 유지하세요. 같은 함수에서 await와 차단 호출을 섞지 마세요.9


Jinja2 템플릿

템플릿 상속

Jinja2의 상속 시스템은 React의 컴포넌트 합성을 더 단순한 모델로 대체해요. 하나의 기본 템플릿이 페이지 뼈대를 정의하고, 자식 템플릿이 이름이 지정된 블록을 채우는 방식이에요:

<!-- base.html — the skeleton -->
<!DOCTYPE html>
<html lang="{{ lang_attr() }}">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>{{ page_title | default("Blake Crosley") }}</title>
  <meta name="description" content="{{ page_description | default('...') }}">

  <!-- CSS — single file, no preprocessor -->
  <link rel="stylesheet" href="{{ asset('css/styles.css') }}">

  <!-- JSON-LD structured data -->
  <script type="application/ld+json">
  { "@context": "https://schema.org", "@graph": [...] }
  </script>

  {% block head %}{% endblock %}
</head>
<body>
  <header class="header">...</header>

  <main id="main" role="main">
    {% block content %}{% endblock %}
  </main>

  <footer class="footer">...</footer>

  <!-- Scripts deferred for performance -->
  <script defer src="{{ asset('js/vendor/alpine.min.js') }}"></script>
  <script defer src="{{ asset('js/vendor/htmx.min.js') }}"></script>
  <script defer src="{{ asset('js/main.js') }}"></script>

  {% block scripts %}{% endblock %}
</body>
</html>
<!-- pages/about.html — fills the blocks -->
{% extends "base.html" %}

{% block head %}
<script type="application/ld+json">
{ "@type": "AboutPage", "name": "About Blake Crosley", ... }
</script>
{% endblock %}

{% block content %}
<section class="hero">
  <h1>About</h1>
  <p>Designer, developer, dad.</p>
</section>
{% endblock %}

{% extends %} 지시문은 부모-자식 관계를 설정해요. 자식 템플릿은 오버라이드할 블록만 정의하면 돼요. 나머지 — <head>, 헤더, 푸터, 스크립트 태그 — 는 모두 기본 템플릿에서 가져와요. 이는 구성이 아닌 제거를 통한 합성이에요.

asset() 전역 함수

정적 자산은 캐시 무효화를 위해 콘텐츠 해시 버전 관리를 사용해요:

# cache_assets.py
def build_asset_map(static_dir: Path) -> dict[str, str]:
    """Compute MD5 hashes of all static files at startup."""
    asset_map = {}
    for filepath in static_dir.rglob("*"):
        if filepath.is_file():
            rel_path = str(filepath.relative_to(static_dir))
            content_hash = hashlib.md5(filepath.read_bytes()).hexdigest()[:10]
            asset_map[rel_path] = content_hash
    return asset_map

def make_asset_url(asset_map: dict, path: str) -> str:
    """Generate versioned URL: /static/css/styles.css?v=a3f8b2c1d0"""
    clean_path = path.lstrip("/")
    version = asset_map.get(clean_path, "0")
    return f"/static/{clean_path}?v={version}"

템플릿에서 {{ asset('css/styles.css') }}/static/css/styles.css?v=a3f8b2c1d0으로 렌더링돼요. 파일이 변경되면 해시도 변경되어 CDN 캐시를 무효화해요. 이 방식은 webpack의 [contenthash] 파일명 전략을 시작 시 계산되는 30줄의 Python로 대체해요.

재사용 가능한 파셜을 위한 Include

여러 페이지에서 반복되는 컴포넌트는 {% include %}를 사용해요:

<!-- base.html -->
{% include "components/_language_switcher.html" %}
<!-- components/_language_switcher.html -->
{%- set current = current_locale() -%}
{%- set locales = all_locales() -%}

<div class="language-switcher"
     x-data="{ open: false }"
     @click.away="open = false">
  <button @click="open = !open" :aria-expanded="open">
    {{ current_locale_native() }}
  </button>
  <ul class="language-switcher-menu"
      :class="{ 'is-open': open }"
      x-cloak>
    {% for locale in locales %}
    <li>
      <a href="{{ locale_url(request.url.path, locale.code) }}"
         hreflang="{{ locale.code }}">
        {{ locale.native }}
      </a>
    </li>
    {% endfor %}
  </ul>
</div>

언더스코어 접두사(_language_switcher.html)는 파셜 — 독립적으로 렌더링되지 않는 템플릿 조각 — 을 나타내는 규칙이에요. 이 컴포넌트는 Alpine.js(드롭다운 토글용)와 Jinja2(로케일 목록용)을 모두 사용해요. 경계가 명확해요: Alpine.js가 열기/닫기 상태를 관리하고, Jinja2가 데이터를 담당해요.

재사용 가능한 컴포넌트를 위한 매크로

매크로는 Jinja2의 함수 — 매개변수를 가진 재사용 가능한 템플릿 블록이에요:

<!-- components/_macros.html -->
{% macro card(title, description, href, badge=None) %}
<article class="card">
  <a href="{{ href }}" class="card__link">
    {% if badge %}
    <span class="card__badge">{{ badge }}</span>
    {% endif %}
    <h3 class="card__title">{{ title }}</h3>
    {% if description %}
    <p class="card__description">{{ description }}</p>
    {% endif %}
  </a>
</article>
{% endmacro %}

{% macro optimized_image(image_config, loading="lazy") %}
{% if image_config.get("svg") %}
  <img src="{{ image_config.svg }}"
       width="{{ image_config.width }}"
       height="{{ image_config.height }}"
       alt="{{ image_config.alt }}">
{% else %}
  <picture>
    <source type="image/webp"
            srcset="{{ image_config.webp_srcset }}"
            sizes="(max-width: 768px) 100vw, 50vw">
    <img src="{{ image_config.fallback }}"
         width="{{ image_config.width }}"
         height="{{ image_config.height }}"
         alt="{{ image_config.alt }}"
         loading="{{ loading }}">
  </picture>
{% endif %}
{% endmacro %}

페이지 템플릿에서 매크로를 가져와 사용해요:

{% from "components/_macros.html" import card, optimized_image %}

<section class="projects">
  {% for project in projects %}
    {{ card(
      title=project.title,
      description=project.description,
      href=project.link,
      badge="New" if project.is_new else None
    ) }}
  {% endfor %}
</section>

매크로는 프레젠테이션 패턴에서 React 컴포넌트를 대체해요. 매개변수를 받고, 기본값을 지원하며, 다른 매크로와 합성할 수 있어요. 차이점은: 매크로는 서버에서 한 번 렌더링되어 정적 HTML를 생성해요. React 컴포넌트는 클라이언트에서 렌더링되며 상태를 유지해요. 콘텐츠 표시에는 매크로가 적합한 도구예요.

템플릿 컨텍스트와 전역 함수

Jinja2 전역 함수는 명시적으로 전달하지 않아도 모든 템플릿에서 사용할 수 있는 함수예요:

# In main.py — register globals
templates.env.globals["asset"] = lambda path: make_asset_url(_asset_map, path)
templates.env.globals["csrf_token"] = generate_csrf_token
templates.env.globals["analytics_script"] = analytics.tracking_script

asset() 전역 함수는 버전이 포함된 URL을 생성해요. csrf_token() 전역 함수는 새로운 CSRF 토큰을 생성해요. analytics_script() 전역 함수는 추적 스니펫을 주입해요. 이 함수들은 라우트 핸들러가 명시적으로 전달하지 않아도 모든 템플릿에서 호출할 수 있어요.

i18n의 경우 설정이 더 복잡해요 — 번역 함수가 현재 요청의 로케일에 접근해야 하기 때문이에요:

# i18n/jinja.py
def setup_i18n_jinja(env):
    """Register translation functions as Jinja2 globals."""
    env.globals["_"] = get_translation        # _('ui.nav.about')
    env.globals["locale_prefix"] = get_locale_prefix  # '/ja' or ''
    env.globals["current_locale"] = get_current_locale
    env.globals["all_locales"] = get_all_locales
    env.globals["alternate_urls"] = get_alternate_urls
    env.globals["lang_attr"] = get_lang_attr  # 'ja' for HTML lang
    env.globals["og_locale"] = get_og_locale  # 'ja_JP' for og:locale
    env.globals["jsonld_lang"] = get_jsonld_lang  # 'ja-JP' for JSON-LD

각 함수는 로케일 미들웨어가 설정한 요청 컨텍스트 변수에서 로케일을 읽어요. 템플릿에서 {{ _('ui.nav.about') }}를 호출하면 명시적인 로케일 매개변수 없이도 현재 요청 로케일에 맞는 번역된 문자열을 반환해요.

조건부 블록

Jinja2의 블록 시스템은 조건부 오버라이드를 지원해요:

<!-- base.html -->
{% block head %}{% endblock %}

<!-- pages/blog/post.html -->
{% block head %}
<script type="application/ld+json">
{
  "@type": "Article",
  "headline": "{{ post.meta.title }}",
  "author": { "@id": "https://blakecrosley.com/#person" },
  "datePublished": "{{ post.meta.date.isoformat() }}",
  "dateModified": "{{ post.meta.updated.isoformat() if post.meta.updated else post.meta.date.isoformat() }}"
}
</script>

{% if post.meta.scripts %}
{% for script in post.meta.scripts %}
<script defer src="{{ asset(script.lstrip('/static/')) }}"></script>
{% endfor %}
{% endif %}

{% if post.meta.styles %}
{% for style in post.meta.styles %}
<link rel="stylesheet" href="{{ asset(style.lstrip('/static/')) }}">
{% endfor %}
{% endif %}
{% endblock %}

블로그 글은 YAML 프론트매터(scripts: ["/static/js/boids.js"])에서 의존성을 선언해요. 템플릿이 이를 조건부로 포함해요. 추가 스크립트나 스타일이 필요 없는 페이지는 아무것도 제공하지 않아요 — 죽은 코드도, 사용하지 않는 임포트도 없어요.

커스텀 필터

Jinja2 필터는 렌더링 시 데이터를 변환해요. sanitize 필터는 사용자 생성 콘텐츠에서 XSS를 방지해요:

import nh3

ALLOWED_TAGS = {"a", "b", "blockquote", "br", "code", "em", "h1", "h2",
                "h3", "h4", "h5", "h6", "hr", "i", "img", "li", "ol",
                "p", "pre", "span", "strong", "table", "td", "th", "tr", "ul"}

def sanitize_html(value: str) -> str:
    """Sanitize HTML to prevent XSS attacks."""
    if not value:
        return ""
    return nh3.clean(
        value,
        tags=ALLOWED_TAGS,
        attributes={"a": {"href", "title"}, "img": {"src", "alt"}},
        link_rel="noopener noreferrer",
    )

templates.env.filters["sanitize"] = sanitize_html

템플릿에서는 {{ user_content | sanitize }}로 사용해요. nh3 라이브러리는 Rust 기반 HTML 새니타이저로 — 빠르고 안전해요. 허용 목록에 없는 태그나 속성을 제거하여, 콘텐츠가 신뢰할 수 없는 출처에서 왔더라도 저장형 XSS를 방지해요.10


HTMX 심화 학습

HTMX는 모든 HTML 요소에서 HTTP 요청을 보내고 응답을 DOM에 교체할 수 있게 해줍니다. 핵심 아키텍처 인사이트는 다음과 같습니다: 서버에서 렌더링된 HTML가 곧 API입니다. 서버가 최종 표현을 반환하며, 클라이언트 사이드 렌더링도, JSON 직렬화도, 하이드레이션도 없습니다.

핵심 속성

속성 용도 예시
hx-get GET 요청 전송 hx-get="/search?q=term"
hx-post POST 요청 전송 hx-post="/contact"
hx-target 응답을 배치할 위치 hx-target="#results"
hx-swap 응답을 삽입하는 방식 hx-swap="innerHTML" (기본값), outerHTML, beforeend
hx-trigger 요청을 트리거하는 이벤트 hx-trigger="click", keyup changed delay:300ms, load
hx-indicator 요청 중 표시할 요소 hx-indicator="#spinner"
hx-push-url 브라우저 URL 업데이트 hx-push-url="true"
hx-replace-url 히스토리 항목 없이 URL 교체 hx-replace-url="true"

패턴 1: 인터랙티브 퀴즈 (다단계 서버 상태)

blakecrosley.com에는 사용자에게 도구 선택을 안내하는 인터랙티브 퀴즈가 있습니다. 퀴즈의 전체 상태는 서버에 존재하며, 클라이언트 사이드 상태 관리가 전혀 없습니다:

<!-- _quiz_container.html — initial load -->
<div hx-get="/api/quiz/claude-vs-codex/step?answers="
     hx-trigger="load"
     hx-swap="innerHTML"
     id="quiz-wrapper">
  <p>Loading quiz...</p>
</div>
<!-- _quiz_step.html — each question -->
<div class="quiz-step" id="quiz-container">
  <p>Question {{ step }} of {{ total }}</p>
  <h3>{{ question.question }}</h3>
  <div class="quiz-step__options">
    {% for opt in question.options %}
    <button class="quiz-step__btn"
            hx-get="/api/quiz/claude-vs-codex/step?answers={{ answers }},{{ opt.value }}"
            hx-target="#quiz-container"
            hx-swap="outerHTML">
      {{ opt.label }}
    </button>
    {% endfor %}
  </div>
</div>

버튼을 클릭할 때마다 누적된 답변이 쿼리 파라미터로 전송됩니다. 서버는 답변 히스토리를 기반으로 다음 질문이나 최종 결과를 계산합니다. 상태는 URL에 누적되며, 쿠키도, 세션도, 클라이언트 사이드 JavaScript도 필요 없습니다. 퀴즈는 outerHTML 스왑을 통해 진행됩니다: 각 응답이 퀴즈 단계 요소 전체를 교체합니다.

패턴 2: 페이지네이션이 있는 블로그 목록

글쓰기 페이지는 HTMX를 사용해 URL을 업데이트하는 매끄러운 페이지네이션을 구현합니다:

<!-- Pagination link -->
<a href="/writing?page=2&category=Engineering"
   hx-get="/writing?page=2&category=Engineering"
   hx-target="#writing-content"
   hx-swap="innerHTML"
   hx-replace-url="true"
   hx-indicator="#writing-loading"
   aria-label="Go to page 2">
  2
</a>

네 가지 속성이 함께 동작합니다:

  1. hx-gethref와 동일한 URL로 요청을 보냅니다 (점진적 향상 — JavaScript 없이도 동작)
  2. hx-target은 응답을 #writing-content 컨테이너에 배치합니다
  3. hx-replace-url="true"는 히스토리 항목을 추가하지 않고 브라우저 URL을 업데이트합니다
  4. hx-indicator는 요청 중 로딩 스피너를 표시합니다

서버는 HX-Request 헤더를 통해 HTMX 요청을 감지하고, 전체 페이지 대신 게시물 목록 프래그먼트만 반환합니다. 보안 헤더 미들웨어에서 Vary: HX-Request를 추가하는 이유가 바로 이것입니다 — CDN 캐시가 전체 페이지와 프래그먼트를 별도로 저장하도록 하기 위해서입니다.11

패턴 3: 디바운스가 적용된 검색

<input type="search" name="q"
       hx-get="/api/search"
       hx-trigger="keyup changed delay:300ms"
       hx-target="#results"
       hx-indicator="#search-spinner" />
<div id="results"></div>

hx-trigger 속성은 세 가지 수정자를 조합합니다:

  • keyup은 키를 놓을 때 발생합니다
  • changed는 값이 실제로 변경된 경우에만 발생합니다 (수정자 키로 인한 중복 요청 방지)
  • delay:300ms는 디바운스 기능으로, 마지막 keyup 후 300ms를 대기한 뒤 요청을 전송합니다

서버는 렌더링된 HTML 프래그먼트를 반환합니다:

@router.get("/api/search")
async def search(request: Request, q: str = ""):
    results = search_content(q)
    return templates.TemplateResponse("components/_search_results.html", {
        "request": request,
        "results": results,
        "query": q,
    })

클라이언트 사이드 상태가 없습니다. 디바운스 라이브러리도 없습니다. useEffect도 없습니다. 템플릿이 결과를 렌더링하고, HTMX가 이를 교체하며, 서버가 단일 소스 오브 트루스 역할을 합니다.

패턴 4: Out-of-Band (OOB) 스왑

하나의 서버 액션으로 여러 DOM 요소를 업데이트해야 할 때가 있습니다. HTMX의 out-of-band 스왑 메커니즘이 클라이언트 사이드 오케스트레이션 없이 이를 처리합니다:

<!-- Server returns multiple elements in one response -->
<!-- Primary target: swapped normally via hx-target -->
<div id="cart-items">
  <ul>
    <li>Widget A — $29.99</li>
    <li>Widget B — $14.99</li>
  </ul>
</div>

<!-- OOB target: swapped independently via hx-swap-oob -->
<span id="cart-count" hx-swap-oob="true">2 items</span>
<span id="cart-total" hx-swap-oob="true">$44.98</span>

hx-swap-oob="true" 속성은 HTMX에게 hx-target과 관계없이 DOM 내 어디서든 id로 해당 요소를 찾아 교체하도록 지시합니다. 이는 React의 “상태 끌어올리기” 패턴을 대체합니다 — 서버가 모든 파생 상태를 계산하고, 하나의 응답에서 각 요소의 최종 HTML를 전송합니다.

연락처 폼이 좋은 예시입니다: 폼을 제출하면 폼 본문을 성공 메시지로 교체하면서 동시에 OOB 스왑을 통해 알림 배지를 업데이트할 수 있습니다:

패턴 5: Boosted 링크

HTMX는 표준 네비게이션 링크를 전체 페이지 로드 대신 AJAX를 사용하도록 “부스트”할 수 있습니다:

<nav hx-boost="true">
  <a href="/about">About</a>
  <a href="/writing">Writing</a>
  <a href="/guides">Guides</a>
</nav>

hx-boost="true"를 적용하면, 링크를 클릭할 때 AJAX로 페이지를 가져오고, <body> 콘텐츠를 교체하며, 전체 페이지 리로드 없이 URL을 업데이트합니다. 브라우저 히스토리는 정상적으로 작동합니다 (뒤로/앞으로 버튼). JavaScript가 실패하면 링크는 표준 네비게이션으로 동작합니다.

이점은 체감 성능입니다: 부스트된 네비게이션은 브라우저가 CSS를 다시 파싱하거나, 스크립트를 다시 평가하거나, 레이아웃을 다시 렌더링할 필요가 없으므로 즉각적으로 느껴집니다. <body> 콘텐츠만 변경됩니다. 부스트된 링크는 메인 네비게이션 요소에 적합하며, SPA 아키텍처 없이도 싱글 페이지 애플리케이션처럼 페이지 전환이 느껴지게 합니다.

패턴 6: HTMX 요청 헤더

HTMX는 모든 요청에 커스텀 헤더를 전송합니다:

헤더 활용 사례
HX-Request true 서버 사이드에서 HTMX 요청 감지
HX-Target 요소 ID 응답을 받을 요소 확인
HX-Trigger 요소 ID 요청을 트리거한 요소 확인
HX-Current-URL 전체 URL 사용자의 현재 페이지 확인

서버는 HX-Request를 사용해 다른 응답을 반환할 수 있습니다:

@router.get("/writing")
async def writing(request: Request, page: int = 1, category: str = None):
    posts = load_all_posts(page=page, category=category)
    context = {"request": request, "posts": posts, "current_page": page}

    # HTMX request: return only the post list fragment
    if request.headers.get("HX-Request"):
        return templates.TemplateResponse(
            "pages/writing/_post_list.html", context
        )

    # Normal request: return the full page
    return templates.TemplateResponse("pages/writing/index.html", context)

이 이중 응답 패턴은 아키텍처의 핵심입니다. 전체 페이지 로드는 완전한 문서(베이스 템플릿 + 페이지 콘텐츠)를 반환합니다. HTMX 네비게이션은 변경된 콘텐츠만 반환합니다. 결정권은 클라이언트가 아닌 서버에 있습니다.

패턴 7: 점진적 향상

blakecrosley.com의 모든 HTMX 링크에는 표준 href 속성이 포함되어 있습니다:

<a href="/writing?page=2"
   hx-get="/writing?page=2"
   hx-target="#writing-content"
   hx-swap="innerHTML">
  Next Page
</a>

JavaScript가 로드되지 않으면 href가 일반 링크로 동작합니다. HTMX가 로드되면 클릭을 가로채 AJAX 스왑을 수행합니다. 이것이 점진적 향상입니다: 사이트는 JavaScript 없이도 작동하며, HTMX가 사용 가능할 때 경험을 향상시킵니다.

패턴 8: 로딩 상태

<button hx-post="/api/contact"
        hx-target="#form-result"
        hx-indicator="#submit-spinner">
  <span id="submit-spinner" class="htmx-indicator">Sending...</span>
  <span>Send Message</span>
</button>

HTMX는 요청 중 트리거 요소에 htmx-request 클래스를 추가합니다. hx-indicator 속성은 요청 중 표시되는 요소를 가리킵니다. CSS로 스타일링하세요:

.htmx-indicator {
  display: none;
}
.htmx-request .htmx-indicator,
.htmx-request.htmx-indicator {
  display: inline;
}

로딩 상태 관리가 필요 없습니다. useState(false)도 없습니다. setLoading(true)도 없습니다. CSS가 가시성을 처리하고, HTMX가 클래스 토글을 처리합니다.


Alpine.js 패턴

Alpine.js는 HTMX이 다루지 않는 영역, 즉 서버와 통신할 필요가 없는 클라이언트 전용 상태를 관리합니다. 사용자가 드롭다운을 클릭해서 열리는 상태는 브라우저에만 존재합니다. Alpine.js는 이런 상태를 HTML 속성으로 관리합니다.

경계 규칙

HTMX과 Alpine.js의 경계는 명확합니다:

상태 유형 도구 예시
서버 데이터가 필요한 경우 HTMX 검색 결과, 폼 유효성 검사, 페이지네이션
브라우저에만 존재하는 경우 Alpine.js 드롭다운 열기/닫기, 모바일 메뉴 토글, 모달 표시
둘 다 해당하는 경우 둘 다 언어 전환기 (Alpine.js 토글, HTMX 방식 내비게이션)

모바일 내비게이션

기본 템플릿은 전체 헤더를 Alpine.js 컴포넌트로 감싸고 있습니다:

<div x-data="{ navOpen: false, langOpen: false }"
     @keydown.escape.window="navOpen = false; langOpen = false">

  <!-- Mobile hamburger button -->
  <button @click="navOpen = !navOpen; langOpen = false"
          :aria-expanded="navOpen"
          :class="navOpen ? 'nav__toggle is-open' : 'nav__toggle'"
          aria-label="Toggle navigation">
    <span class="nav__toggle-icon">
      <span class="nav__toggle-bar"></span>
      <span class="nav__toggle-bar"></span>
      <span class="nav__toggle-bar"></span>
    </span>
  </button>

  <!-- Mobile menu panel -->
  <div class="mobile-menu" x-show="navOpen" x-cloak>
    <nav class="mobile-menu__nav">
      <a href="/about" @click="navOpen = false">About</a>
      <a href="/#work" @click="navOpen = false">Work</a>
      <a href="/writing" @click="navOpen = false">Writing</a>
    </nav>
  </div>
</div>

주요 Alpine.js 패턴:

  • x-data는 컴포넌트 범위와 초기 상태를 선언합니다
  • x-show는 상태에 따라 표시 여부를 토글합니다 (CSS display: none 사용)
  • x-cloak은 Alpine.js가 초기화될 때까지 요소를 숨깁니다 (스타일이 적용되지 않은 콘텐츠가 깜빡이는 현상 방지)
  • @click은 표현식으로 클릭 핸들러를 바인딩합니다
  • :aria-expanded (x-bind:aria-expanded의 축약형)는 속성을 동적으로 설정합니다
  • @keydown.escape.window는 전역적으로 Escape 키를 감지하여 패널을 닫습니다

드롭다운 컴포넌트

언어 전환기는 Alpine.js로 토글 상태를 관리하며, @click.away로 외부 클릭 시 닫히도록 구현합니다:

<div x-data="{ open: false }"
     @click.away="open = false"
     @keydown.escape.window="open = false">

  <button @click="open = !open"
          :aria-expanded="open"
          aria-haspopup="listbox">
    English
    <svg :class="{ 'rotated': open }">...</svg>
  </button>

  <ul :class="{ 'is-open': open }"
      :aria-hidden="!open"
      role="listbox"
      x-cloak>
    <li role="option">
      <a href="/ja/about">日本語</a>
    </li>
    <!-- more languages -->
  </ul>
</div>

@click.away 수정자는 외부를 클릭하면 드롭다운을 닫습니다. Alpine.js는 이 기능을 속성 하나로 처리합니다 — 이벤트 리스너 등록도, 정리도, ref 관리도 필요 없습니다.

Alpine.js와 순수 JavaScript 중 선택 기준

Alpine.js가 적합한 경우:

  • 상태가 단일 DOM 요소에 한정되는 경우 (드롭다운, 모달, 토글)
  • 상호작용이 이진적이거나 단순한 경우 (열기/닫기, 표시/숨기기, 토글)
  • 여러 요소가 같은 상태 변화에 반응해야 하는 경우
  • 접근성 속성이 표시 상태와 동기화되어야 하는 경우

순수 JavaScript가 적합한 경우:

  • 복잡한 연산이 포함된 상호작용 (시각화, 시뮬레이션)
  • 자체 렌더링 루프가 있는 컴포넌트 (캔버스, 애니메이션)
  • 성능이 중요한 경우 (Alpine.js는 x-data 컴포넌트마다 오버헤드가 발생)
  • 로직이 Alpine.js 표현식 20-30줄을 초과하는 경우

blakecrosley.com에서는 내비게이션, 언어 전환, 콘텐츠 토글에 Alpine.js를 사용합니다. 20개의 인터랙티브 블로그 컴포넌트(보이드 시뮬레이션, 해밍 코드 시각화 도구 등)는 캔버스 렌더링과 복잡한 상태 머신이 필요하므로 순수 JavaScript를 사용합니다.


실전 예제: /writing 페이지의 카테고리 필터링

이 섹션에서는 프로덕션 코드베이스의 실제 기능을 라우트, 템플릿, HTMX 상호작용, 보안, 캐싱, 렌더링 결과까지 모든 레이어에 걸쳐 추적합니다. 해당 기능은 글쓰기 페이지에서 전체 페이지 새로고침 없이 블로그 게시물을 필터링하는 카테고리 탭입니다.

라우트 (app/routes/pages.py:508)

async def writing_listing(request: Request, page: int = 1, category: str | None = None):
    """Writing page — blog posts and external publications."""
    templates = get_templates(request)
    markdown_posts = load_all_posts(published_only=True)
    all_posts = CUSTOM_BLOG_POSTS + markdown_posts

    # Filter by category if specified
    if category and category in CATEGORY_MAP:
        display_name = CATEGORY_MAP[category]
        all_posts = [
            p for p in all_posts
            if _get_post_category(p).lower() == display_name.lower()
        ]

    # Pagination
    total_pages = max(1, (len(all_posts) + POSTS_PER_PAGE - 1) // POSTS_PER_PAGE)
    page = max(1, min(page, total_pages))
    paginated = all_posts[(page - 1) * POSTS_PER_PAGE : page * POSTS_PER_PAGE]

    template_context = {
        "request": request,
        "posts": paginated,
        "categories": categories,
        "current_category": category,
        "current_page": page,
        "total_pages": total_pages,
        # ... SEO: canonical, prev/next URLs
    }

    # HTMX partial: return just the post list fragment
    if request.headers.get("HX-Request"):
        return templates.TemplateResponse(
            "pages/writing/_post_list.html",
            template_context,
        )

    # Full page for direct navigation
    return templates.TemplateResponse(
        "pages/writing/index.html",
        template_context,
    )

HX-Request 헤더 확인이 핵심 패턴입니다: 같은 라우트, 같은 데이터, 다른 템플릿. HTMX은 프래그먼트를 받고, 브라우저는 전체 페이지를 받습니다.

카테고리 탭 (HTMX)

<!-- Category filter tabs -->
<nav class="writing-categories">
  <a href="/writing"
     hx-get="/writing"
     hx-target="#post-list"
     hx-push-url="true"
     class="category-tab {% if not current_category %}active{% endif %}">
    All ({{ total_posts }})
  </a>
  {% for cat in categories %}
  <a href="/writing?category={{ cat.slug }}"
     hx-get="/writing?category={{ cat.slug }}"
     hx-target="#post-list"
     hx-push-url="true"
     class="category-tab {% if current_category == cat.slug %}active{% endif %}">
    {{ cat.name }} ({{ cat.count }})
  </a>
  {% endfor %}
</nav>

<div id="post-list">
  {% include "pages/writing/_post_list.html" %}
</div>

각 탭에는 href (JavaScript 없이도 동작)와 hx-get (게시물 목록만 교체)이 모두 있습니다. hx-push-url은 브라우저 URL을 업데이트하여 필터링된 뷰를 공유하고 북마크할 수 있게 합니다.

부분 템플릿 (pages/writing/_post_list.html)

부분 템플릿은 페이지 로드 시 포함되든 HTMX으로 교체되든 동일하게 렌더링됩니다:

{% for post in posts %}
<article class="post-card">
  <a href="{{ locale_prefix() }}/blog/{{ post.meta.slug }}">
    <h3>{{ post.meta.title }}</h3>
    <p>{{ post.meta.description }}</p>
    <time>{{ post.meta.date }}</time> · {{ post.reading_time }}m
  </a>
</article>
{% endfor %}

부분 템플릿에는 HTMX 전용 마크업이 없습니다. 클라이언트 측 렌더링 로직도 없습니다. 동일한 HTML가 초기 페이지 로드와 이후 모든 필터링에 사용됩니다.

보안

카테고리 값은 필터링 전에 CATEGORY_MAP(서버 측 딕셔너리)을 기준으로 검증됩니다. 유효하지 않은 카테고리는 무시되며, 사용자에게 다시 반환되지 않습니다. 사용자 입력이 SQL이나 HTML에 직접 삽입되지 않습니다. CSP 헤더가 인라인 스크립트를 차단합니다.

캐싱

카테고리 응답은 동적이므로 CDN 캐시를 사용하지 않습니다. 하지만 정적 자산(CSS, HTMX, Alpine.js)은 콘텐츠 해시를 적용하여 최초 로드 후 무기한 캐시됩니다. 이후 카테고리 전환 시에는 HTML 부분 템플릿(약 3-5KB)만 전송됩니다 — CSS, JS, 이미지는 다시 불러오지 않습니다.

이 예제가 보여주는 것

하나의 기능, 실제 프로덕션 코드, 빌드 도구 없음. 서버가 HTML를 필터링하고 렌더링합니다. HTMX이 게시물 목록을 교체합니다. Alpine.js는 관여하지 않습니다 (클라이언트 상태가 필요 없으므로). URL이 공유 가능하도록 업데이트됩니다. 점진적 향상: 탭은 JavaScript 없이도 일반 링크로 동작합니다. 이 기능에 사용된 커스텀 JavaScript: 0줄.


선택적 확장

다음 섹션에서는 핵심 스택을 보완하지만 blakecrosley.com에서는 사용하지 않는 패턴을 다룹니다. 이 아키텍처를 도입하는 팀에서 가장 흔히 추가하는 패턴이므로 포함했습니다.


Sass 없이 Bootstrap 5 사용하기

참고: blakecrosley.com은 커스텀 프로퍼티를 활용한 순수 CSS를 사용하며 Bootstrap은 사용하지 않습니다. 이 섹션에서는 빌드 과정 없이 유틸리티 프레임워크를 원하는 팀을 위한 옵션으로 Bootstrap 5를 다룹니다. Bootstrap의 컴파일된 CSS는 CDN에서 로드하거나 스타일시트에 번들링할 수 있습니다. 아래 패턴들은 범용적이며 이전 섹션에서 설명한 HTMX + Alpine.js 접근 방식과 함께 사용할 수 있습니다.

Bootstrap 5는 jQuery 의존성을 제거했으며 독립적인 CSS 사용을 지원합니다. Bootstrap의 그리드 시스템과 유틸리티 클래스를 사용하는 데 Sass, PostCSS, 또는 어떤 빌드 도구도 필요하지 않습니다.

CDN 없이 자체 호스팅하기

blakecrosley.com은 모든 벤더 라이브러리를 자체 호스팅합니다:

<!-- base.html — no CDN, no external requests -->
<script defer src="{{ asset('js/vendor/alpine.min.js') }}"></script>
<script defer src="{{ asset('js/vendor/htmx.min.js') }}"></script>

자체 호스팅은 외부 의존성을 제거하고, CDN 장애로 인한 사이트 중단을 방지하며, 콘텐츠 해시 URL을 통한 불변 캐싱을 가능하게 합니다. Bootstrap의 컴파일된 CSS(Sass 소스가 아닌)를 다운로드하여 static/css/vendor/에 배치하세요.

그리드 시스템

Bootstrap의 그리드는 순수 HTML 클래스로 동작합니다:

<div class="container">
  <div class="row">
    <div class="col-12 col-md-8">
      <article>Main content</article>
    </div>
    <div class="col-12 col-md-4">
      <aside>Sidebar</aside>
    </div>
  </div>
</div>

Sass 믹스인이 필요 없습니다. @include make-col()도 필요 없습니다. 컴파일된 CSS에 반응형 그리드 클래스가 이미 포함되어 있습니다. Bootstrap 기본 브레이크포인트를 넘어서는 커스텀 브레이크포인트가 필요하면 순수 CSS 미디어 쿼리를 작성하세요.

순수 CSS로 오버라이드하기

CSS 커스텀 프로퍼티와 표준 셀렉터로 Bootstrap의 기본값을 오버라이드할 수 있습니다:

/* Custom design tokens — no Sass, no Tailwind */
:root {
  --color-bg-dark:        #000000;
  --color-text-primary:   #ffffff;
  --color-text-secondary: rgba(255, 255, 255, 0.65);
  --color-text-tertiary:  rgba(255, 255, 255, 0.40);
  --spacing-sm:           1rem;
  --spacing-md:           1.5rem;
  --spacing-lg:           2rem;
  --gutter:               48px;
  --font-size-lg:         1.25rem;
}

/* Responsive override — the browser reads this at runtime */
@media (max-width: 768px) {
  :root {
    --gutter: var(--spacing-md);  /* 48px → 24px on mobile */
  }
}

/* Override Bootstrap's default body styles */
body {
  background: var(--color-bg-dark);
  color: var(--color-text-primary);
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
}

CSS 커스텀 프로퍼티는 DOM을 따라 캐스케이드되고, 부모 요소로부터 상속되며, 런타임에 미디어 쿼리에 반응합니다. Sass 변수는 정적 값으로 컴파일되어 사라집니다. 이 차이는 테마 적용에서 중요합니다: 하나의 커스텀 프로퍼티 변경만으로 재컴파일 없이 모든 파생 값을 업데이트할 수 있습니다.12

유틸리티 클래스 vs. 컴포넌트 CSS

일회성 간격과 레이아웃에는 Bootstrap 유틸리티 클래스를 사용하고, 반복되는 패턴에는 컴포넌트 CSS를 사용하세요:

<!-- Bootstrap utility for one-off spacing -->
<div class="mt-4 mb-3 px-2">One-off layout</div>

<!-- Component class for repeated patterns -->
<article class="writing__item">
  <h3 class="writing__item-title">Post Title</h3>
  <p class="writing__item-description">Description</p>
</article>
/* Component CSS — BEM naming, reusable */
.writing__item {
  padding: var(--spacing-md);
  border-bottom: 1px solid rgba(255, 255, 255, 0.1);
  transition: background 0.15s ease;
}
.writing__item:hover {
  background: rgba(255, 255, 255, 0.03);
}
.writing__item-title {
  font-size: var(--font-size-lg);
  margin-bottom: 0.5rem;
}

원칙은 이렇습니다: 레이아웃 메커니즘(마진, 패딩, 플렉스박스)에는 Bootstrap 유틸리티를 사용하고, 시각적 아이덴티티(색상, 타이포그래피, 애니메이션)에는 커스텀 CSS를 사용합니다. 같은 역할에 대해 유틸리티 클래스와 컴포넌트 스타일링을 혼용하지 마세요.


i18n과 현지화

blakecrosley.com은 영어, 일본어, 한국어, 중국어 간체, 중국어 번체, 독일어, 프랑스어, 스페인어, 폴란드어, 포르투갈어(브라질) 등 10개 언어로 콘텐츠를 제공합니다.

URL 기반 로케일 라우팅

로케일은 URL 경로에 포함됩니다: /about(영어), /ja/about(일본어), /zh-Hans/about(중국어 간체). 영어가 기본값이며 접두사가 없습니다.

# i18n/config.py
SUPPORTED_LOCALES = [
    "en", "zh-Hans", "zh-Hant", "fr", "de", "ja", "ko", "pl", "pt-BR", "es"
]
DEFAULT_LOCALE = "en"

로케일 미들웨어는 URL 경로에서 로케일을 추출합니다:

# i18n/middleware.py
class LocaleMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request, call_next):
        path = request.url.path
        # Check if path starts with a supported locale
        for locale in SUPPORTED_LOCALES:
            if path.startswith(f"/{locale}/") or path == f"/{locale}":
                request.state.locale = locale
                # Strip locale prefix for route matching
                request.scope["path"] = path[len(f"/{locale}"):]
                break
        else:
            request.state.locale = DEFAULT_LOCALE

        response = await call_next(request)
        return response

미들웨어는 라우트 매칭 전에 로케일 접두사를 제거합니다. 따라서 라우트 핸들러에 로케일별 경로를 지정할 필요가 없습니다 — /about은 영어(/about)와 일본어(/ja/about) 모두 처리하는데, 미들웨어가 경로를 정규화하기 때문입니다.

템플릿의 번역 함수

Jinja2 전역 변수가 번역 함수를 제공합니다:

<!-- Template usage -->
<h3>{{ _('ui.footer.navigate') | default('Navigate') }}</h3>
<a href="{{ locale_prefix() }}/about">
  {{ _('ui.nav.about') | default('About') }}
</a>

_() 함수는 메모리 캐시에서 번역 키를 조회합니다. | default() 필터는 번역이 없을 때 영어 폴백을 제공합니다. locale_prefix() 함수는 현재 로케일의 URL 접두사를 반환합니다(영어는 "", 일본어는 "/ja").

Hreflang 태그

모든 페이지에는 지원하는 모든 로케일의 hreflang 태그가 포함됩니다:

<!-- Generated in base.html -->
{% for alt in alternate_urls(request.url.path) %}
<link rel="alternate" hreflang="{{ alt.hreflang }}" href="{{ alt.url }}">
{% endfor %}

결과는 다음과 같습니다:

<link rel="alternate" hreflang="en" href="https://blakecrosley.com/about">
<link rel="alternate" hreflang="ja" href="https://blakecrosley.com/ja/about">
<link rel="alternate" hreflang="zh-Hans" href="https://blakecrosley.com/zh-Hans/about">
<!-- ... all 10 locales -->
<link rel="alternate" hreflang="x-default" href="https://blakecrosley.com/about">

검색 엔진은 hreflang을 사용해 검색 결과에서 올바른 언어 버전을 제공합니다. x-default 항목은 폴백으로 영어 버전을 가리킵니다.13

번역 저장소와 메모리 캐시

번역은 Cloudflare D1(엣지의 SQLite)에 저장되며, lifespan 핸들러를 통해 인메모리 캐시로 로드됩니다:

@asynccontextmanager
async def lifespan(app: FastAPI):
    # Load translations into memory at startup
    for locale in SUPPORTED_LOCALES:
        data = await fetch_translations(locale)
        TRANSLATIONS[locale] = data
    yield

app = FastAPI(lifespan=lifespan)

메모리 캐시를 사용하면 페이지 렌더링마다 데이터베이스를 쿼리할 필요가 없습니다. 번역을 업데이트하려면 캐시를 새로고침해야 합니다(관리자 엔드포인트나 배포를 통해 트리거). 이 아키텍처는 최신성 대신 성능을 선택한 것입니다 — 번역은 자주 변경되지 않지만, 페이지 렌더링은 매 요청마다 발생합니다.

상태 모니터링

blakecrosley.com에는 로케일별 번역 커버리지를 모니터링하는 i18n 상태 확인 엔드포인트가 있습니다:

@app.get("/health/i18n")
async def health_i18n():
    cache = get_translation_cache()
    result = {
        "status": "healthy",
        "cache_loaded": cache.is_loaded,
        "locales": {},
        "alerts": [],
    }

    # Check coverage for each locale
    for locale in SUPPORTED_LOCALES:
        coverage = await calculate_coverage(locale, en_count)
        result["locales"][locale] = {"coverage": round(coverage, 2)}

        if coverage < 99.5:
            result["alerts"].append(
                f"{locale}: {coverage:.1f}% coverage (threshold: 99.5%)"
            )
            result["status"] = "warning"

    return result

99.5% 커버리지 임계값은 사용자가 번역되지 않은 문자열을 만나기 전에 누락된 번역을 감지합니다. 상태 엔드포인트는 Railway의 모니터링과 통합되어 커버리지가 떨어지면 알림을 보냅니다 — 예를 들어, 아직 번역되지 않은 새로운 UI 문자열을 추가한 후에 알림이 발생합니다.

로케일 인식 콘텐츠 렌더링

블로그 게시물과 가이드는 로케일별 메타데이터 및 콘텐츠 번역을 지원합니다:

# In route handler
translated = get_blog_translation(post.meta.slug, locale)
return templates.TemplateResponse("pages/blog/post.html", {
    "request": request,
    "post": post,
    "translated_title": translated.title if translated else post.meta.title,
    "translated_description": translated.description if translated else post.meta.description,
})
<!-- In template -->
<h1>{{ translated_title }}</h1>
<p class="post__description">{{ translated_description }}</p>
<!-- Body content falls back to English if translation unavailable -->
{{ post.html | sanitize | safe }}

패턴은 일관됩니다: 번역된 콘텐츠를 먼저 시도하고, 없으면 영어로 폴백합니다. 이를 통해 부분 번역이 가능합니다 — 일본어 사용자는 전체 본문이 영어로 남아 있더라도 번역된 제목과 설명을 볼 수 있습니다. | default() Jinja2 필터는 이 패턴을 하나의 파이프로 표현합니다:

{{ translated.title if translated else post.meta.title }}

로케일 데이터 번역

프로젝트 설명이나 내비게이션 라벨 같은 정적 콘텐츠는 동일한 데이터 구조를 유지하면서 로케일별 문자열로 교체하는 헬퍼 함수를 통해 번역됩니다:

# i18n/data.py
def translate_projects(projects: list, locale: str) -> list:
    """Return projects with translated titles and descriptions."""
    if locale == "en":
        return projects
    translated = []
    for project in projects:
        t = get_translation(f"project.{project['slug']}.title", locale)
        d = get_translation(f"project.{project['slug']}.description", locale)
        translated.append({
            **project,
            "title": t or project["title"],
            "description": d or project["description"],
        })
    return translated

이 접근 방식은 번역 계층을 데이터 계층과 분리합니다. 라우트는 로케일에 관계없이 동일한 projects 리스트를 전달합니다. 번역 함수가 데이터를 투명하게 래핑합니다.

Hreflang 대체 링크가 포함된 사이트맵

동적 사이트맵에는 모든 로케일의 모든 페이지와 상호 참조가 포함됩니다:

@app.get("/sitemap.xml")
async def sitemap():
    for page in static_pages:
        for locale in SUPPORTED_LOCALES:
            # Each URL entry includes alternates for all locales
            locale_path = f"/{locale}{path}" if locale != "en" else path
            xml_parts.append(f"<loc>{base_url}{locale_path}</loc>")
            # Add xhtml:link alternates
            for alt_locale in SUPPORTED_LOCALES:
                alt_path = f"/{alt_locale}{path}" if alt_locale != "en" else path
                hreflang = LOCALE_TO_HREFLANG[alt_locale]
                xml_parts.append(
                    f'<xhtml:link rel="alternate" hreflang="{hreflang}" '
                    f'href="{base_url}{alt_path}"/>'
                )

이렇게 하면 페이지당 10개의 URL 항목(로케일당 하나)이 생성되고, 각 항목에 11개의 대체 링크(10개 로케일 + x-default)가 포함됩니다. 50개의 페이지가 있는 사이트의 경우, 사이트맵에는 500개의 URL 항목과 5,500개의 hreflang 링크가 포함됩니다. 사이트맵은 동적으로 생성되며 1시간 동안 캐시됩니다.


Database 패턴

참고: blakecrosley.com은 모든 영구 데이터에 SQLAlchemy가 아니라 HTTP를 통해 Cloudflare D1(serverless SQLite)을 사용해요. 이 섹션에서는 관계형 데이터베이스가 필요한 FastAPI 프로젝트를 위한 표준 SQLAlchemy async 패턴을 다룹니다. 이 스택에서 가장 흔한 프로덕션 설정이에요.

SQLAlchemy 2.0 Async

관계형 데이터베이스가 필요한 애플리케이션에서는 SQLAlchemy 2.0의 async 지원이 FastAPI와 깔끔하게 통합됩니다.

from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker, DeclarativeBase

engine = create_async_engine("sqlite+aiosqlite:///./data.db")
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)

class Base(DeclarativeBase):
    pass

설치 참고(SQLAlchemy 2.0.50+): 2.0.50부터 async 스택의 greenlet 의존성은 기본으로 설치되지 않습니다. 함께 설치되도록 asyncio extra를 사용하세요. 그렇지 않으면 engine에 대한 첫 await에서 greenlet 누락 오류가 발생합니다.23

pip install "sqlalchemy[asyncio]" aiosqlite

SQLAlchemy 2.0.50은 Python 3.10 이상도 필요하며(3.7-3.9 지원 중단), free-threaded(3.13t) wheel을 추가합니다.23

데이터베이스 세션을 위한 Dependency Injection

from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession

async def get_db() -> AsyncGenerator[AsyncSession, None]:
    async with async_session() as session:
        try:
            yield session
            await session.commit()
        except Exception:
            await session.rollback()
            raise

@router.get("/users/{user_id}")
async def get_user(request: Request, user_id: int, db: AsyncSession = Depends(get_db)):
    result = await db.execute(select(User).where(User.id == user_id))
    user = result.scalar_one_or_none()
    if not user:
        raise HTTPException(404, "User not found")
    return templates.TemplateResponse("pages/user.html", {
        "request": request, "user": user
    })

get_db 의존성은 세션 생명주기를 관리합니다. 세션을 열고, route handler에 넘겨주며, 성공하면 commit하고 예외가 발생하면 rollback해요. 모든 데이터베이스 작업은 parameterized query를 사용합니다. 문자열 보간은 절대 사용하지 마세요.

Pydantic 통합

Pydantic 모델은 API 경계에서 입력을 검증하고, 템플릿에 전달할 출력을 직렬화합니다.

from pydantic import BaseModel, EmailStr

class ContactForm(BaseModel):
    name: str
    email: EmailStr
    message: str

@router.post("/contact")
async def submit_contact(request: Request, form: ContactForm):
    # form.name, form.email, form.message are validated
    await send_email(form)
    return templates.TemplateResponse("components/_contact_success.html", {
        "request": request
    })

Pydantic은 route handler가 실행되기 전에 타입, 형식(email, URL), 제약 조건(최소/최대 길이)을 검증합니다. 잘못된 입력은 자동으로 422 응답을 반환해요. 이는 클라이언트 측 form validation 라이브러리를 대체합니다. 서버가 검증하고, HTMX는 성공 메시지나 오류 피드백을 바꿔 넣습니다.

Alembic으로 Migration 관리

Alembic은 데이터베이스 schema 변경을 관리합니다.

# Generate a migration from model changes
alembic revision --autogenerate -m "add user preferences table"

# Apply migrations
alembic upgrade head

# Roll back one migration
alembic downgrade -1

autogenerate 기능은 SQLAlchemy 모델을 현재 데이터베이스 schema와 비교해 migration script를 생성합니다. 이 script들은 repository에 보관되는 versioned Python 파일입니다.

# alembic/versions/001_add_user_preferences.py
def upgrade():
    op.create_table(
        "user_preferences",
        sa.Column("id", sa.Integer, primary_key=True),
        sa.Column("user_id", sa.Integer, sa.ForeignKey("users.id")),
        sa.Column("locale", sa.String(10), default="en"),
        sa.Column("theme", sa.String(20), default="dark"),
    )

def downgrade():
    op.drop_table("user_preferences")

Migration은 배포 중에 실행됩니다(애플리케이션 시작 전). 이렇게 하면 데이터베이스 schema가 애플리케이션 코드와 일치해요. blakecrosley.com에서는 대부분의 데이터가 Cloudflare D1(HTTP를 통해 접근)에 있으므로, Alembic migration은 session 데이터와 analytics에 사용하는 로컬 SQLite 또는 PostgreSQL 데이터베이스에 적용됩니다.

Cloudflare D1 패턴

blakecrosley.com은 Cloudflare Worker proxy를 통해 접근하는 원격 데이터베이스로 Cloudflare D1을 사용합니다.

class D1Client:
    """HTTP client for Cloudflare D1 via Worker proxy."""

    def __init__(self, worker_url: str, auth_secret: str):
        self.worker_url = worker_url
        self.auth_secret = auth_secret

    async def fetch_all(self, sql: str, params: list = None) -> list[dict]:
        async with httpx.AsyncClient() as client:
            response = await client.post(
                f"{self.worker_url}/query",
                json={"sql": sql, "params": params or []},
                headers={"Authorization": f"Bearer {self.auth_secret}"},
            )
            return response.json()["results"]

이 패턴은 데이터베이스가 필요하지만 데이터베이스 서버를 관리하고 싶지 않은 애플리케이션에 적합합니다. D1은 Cloudflare edge에 있는 SQLite이며, HTTP를 통해 접근합니다. Worker proxy는 인증과 rate limiting을 처리합니다. trade-off는 latency입니다. 모든 query가 HTTP 요청(~50-100ms)이 되므로 로컬 데이터베이스 연결(~1-5ms)보다 느립니다. 시작 시점의 in-memory cache는 번역처럼 읽기 비중이 큰 workload에서 이를 완화합니다.


보안

요청 본문 크기 제한

Starlette 1.6.0에는 이 스택에 없던 제어 기능인 max_body_size가 추가되었습니다. 이를 설정하지 않으면 클라이언트가 앱으로 제한 없는 본문을 스트리밍해 메모리가 실패 지점이 될 수 있습니다. Starlette, Router, Mount, 개별 Route에 설정하거나 모든 ASGI 앱을 RequestBodyLimitMiddleware로 감쌀 수 있습니다. 중첩 라우트는 앱 전체 제한을 높이거나 낮출 수 있으므로 업로드 엔드포인트는 넉넉하게 설정하면서 나머지는 엄격하게 유지할 수 있습니다.

app = FastAPI(lifespan=lifespan)
app.router.max_body_size = 2 * 1024 * 1024  # 2 MB default for the whole app

이 제한은 multipart 파일 데이터를 포함해 ASGI 서버에서 실제로 수신한 바이트를 계산하며, Content-Length는 빠른 실패 확인 용도로만 처리합니다. 헤더가 없거나 실제 크기보다 작게 설정되어도 우회할 수 없습니다. 기본값은 무제한을 의미하는 None이므로, 이 기능은 선택적으로 적용해야 합니다.28

보안 헤더 미들웨어

blakecrosley.com은 커스텀 미들웨어를 통해 강화된 보안 헤더를 구현합니다.

class SecurityHeadersMiddleware(BaseHTTPMiddleware):
    CSP_DIRECTIVES = {
        "default-src": "'self'",
        "script-src": "'self' 'unsafe-inline' 'unsafe-eval'",
        "style-src": "'self' 'unsafe-inline'",
        "img-src": "'self' data: https:",
        "connect-src": "'self'",
        "frame-ancestors": "'self'",
        "base-uri": "'self'",
        "form-action": "'self'",
        "upgrade-insecure-requests": "",
    }

    async def dispatch(self, request, call_next):
        response = await call_next(request)
        response.headers["X-Content-Type-Options"] = "nosniff"
        response.headers["X-Frame-Options"] = "SAMEORIGIN"
        response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
        response.headers["Strict-Transport-Security"] = (
            "max-age=31536000; includeSubDomains"
        )
        response.headers["Cross-Origin-Opener-Policy"] = "same-origin"
        response.headers["Content-Security-Policy"] = self.csp
        response.headers["Permissions-Policy"] = self.PERMISSIONS_POLICY
        return response

CSP에는 표현식 평가를 위해 Alpine.js에서 요구하는 'unsafe-inline''unsafe-eval'이 포함됩니다. 대안으로 제약이 있는 Alpine.js의 CSP 호환 빌드를 사용할 수 있습니다.14 그 외 모든 기능은 엄격하게 제한됩니다. frame-ancestors는 클릭재킹을 방지하고, form-action은 폼 제출을 동일 출처로 제한하며, upgrade-insecure-requests는 HTTPS을 강제합니다.

HTMX를 사용한 CDN 캐시 안전성

보안 헤더 미들웨어는 HTMX 응답에 Vary: HX-Request를 추가합니다.

if request.headers.get("HX-Request"):
    existing_vary = response.headers.get("Vary", "")
    if "HX-Request" not in existing_vary:
        parts = [v.strip() for v in existing_vary.split(",") if v.strip()]
        parts.append("HX-Request")
        response.headers["Vary"] = ", ".join(parts)

이 헤더가 없으면 CDN이 HTMX 프래그먼트 응답을 캐시한 뒤 HTMX이 아닌 요청에 전체 페이지처럼 제공하거나, 그 반대로 동작할 수 있습니다. Vary 헤더는 CDN에 HX-Request 헤더 값에 따라 별도의 캐시 항목을 저장하도록 지시합니다.11

CSRF 보호

HTMX 폼은 상태를 저장하지 않는 HMAC 서명 CSRF 토큰을 사용합니다.

# csrf.py
def generate_csrf_token() -> str:
    """Token format: timestamp:random:HMAC-SHA256-signature"""
    timestamp = str(int(time.time()))
    random_value = secrets.token_hex(16)
    payload = f"{timestamp}:{random_value}"
    signature = hmac.new(
        CSRF_SECRET.encode(), payload.encode(), hashlib.sha256
    ).hexdigest()
    return f"{payload}:{signature}"

def validate_csrf_token(token: str) -> bool:
    """Verify signature and check expiration (1 hour)."""
    timestamp, random_value, signature = token.split(":")
    if int(time.time()) - int(timestamp) > 3600:
        return False
    expected = hmac.new(
        CSRF_SECRET.encode(),
        f"{timestamp}:{random_value}".encode(),
        hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature)

토큰은 템플릿에서 Jinja2 전역을 통해 생성되며 HTMX 폼 요청에 포함됩니다.

<form hx-post="/contact" hx-target="#form-result">
  <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
  <!-- form fields -->
</form>

상태 비저장 토큰은 서버 측 세션 저장소가 필요하지 않습니다. HMAC 서명은 토큰이 서버에서 생성되었음을 보장합니다. 타임스탬프는 재전송 공격을 방지합니다. hmac.compare_digest는 타이밍 공격을 방지합니다.15

HTML 정화

사용자 생성 콘텐츠는 렌더링 전에 nh3를 거칩니다.

templates.env.filters["sanitize"] = sanitize_html
# In templates: {{ content | sanitize }}

nh3 라이브러리는 허용 목록에 없는 태그와 속성을 제거합니다. 링크에는 자동으로 rel="noopener noreferrer"가 추가됩니다. 이 방어책은 CSP와 독립적입니다. 렌더링 계층에서는 저장형 XSS를 방지하고, CSP는 브라우저 계층에서 삽입된 스크립트를 차단합니다. 심층 방어입니다.

입력 검증

Pydantic 모델은 API 경계에서 모든 입력을 검증합니다.

from pydantic import BaseModel, Field, EmailStr

class ContactRequest(BaseModel):
    name: str = Field(..., min_length=1, max_length=100)
    email: EmailStr
    message: str = Field(..., min_length=10, max_length=5000)

FastAPI은 잘못된 입력에 대해 자동으로 422 Unprocessable Entity를 반환합니다. 매개변수화된 데이터베이스 쿼리와 함께 사용하면(SQLAlchemy는 문자열을 직접 삽입하지 않음) SQL 삽입을 방지하고 경계에서 타입 안전성을 보장합니다.


성능

Lighthouse 100/100/100/100

blakecrosley.com은 Lighthouse의 4개 카테고리인 Performance, Accessibility, Best Practices, SEO에서 모두 100점을 받습니다. PageSpeed Insights에서 확인하세요.2

핵심 최적화는 다음과 같습니다.

CSS 로딩 전략

blakecrosley.com은 단일 <link> 태그와 불변 캐싱을 위한 콘텐츠 해시 URL로 CSS을 로드합니다.

<link rel="stylesheet" href="{{ asset('css/styles.css') }}">

asset() 헬퍼는 콘텐츠 해시(?v=a3b2c1d4)를 추가하므로 콘텐츠가 변경될 때까지 브라우저가 파일을 무기한 캐시합니다. 중요 CSS 추출, print-media 트릭, JavaScript 기반 로딩이 필요하지 않습니다. CSS 파일은 gzip 기준 약 8KB로 작기 때문에 최적화 기법 없이도 단일 요청 방식으로 Lighthouse Performance 100점을 받을 수 있습니다.

GZip 압축

app.add_middleware(GZipMiddleware, minimum_size=500)

500바이트를 초과하는 응답은 Starlette 1.5.0에서 도입된 기본 콘텐츠 타입 제외 항목(아카이브, 이미지, 오디오, 비디오, 폰트, SSE)을 제외하고 압축됩니다. HTML은 70-80% 압축되어 15KB 문서를 3-4KB로 줄입니다.28

불변 정적 자산 캐싱

# In security headers middleware
if request.url.path.startswith("/static/"):
    if os.environ.get("RAILWAY_ENVIRONMENT"):
        response.headers["Cache-Control"] = "public, max-age=31536000, immutable"

콘텐츠 해시 URL(?v=a3f8b2c1d0)이 있는 정적 자산은 immutable과 함께 1년간 캐시됩니다. 파일이 변경되면 해시도 변경되어 브라우저와 CDN이 새 버전을 가져오도록 합니다.

지연 스크립트 로딩

<script defer src="{{ asset('js/vendor/alpine.min.js') }}"></script>
<script defer src="{{ asset('js/vendor/htmx.min.js') }}"></script>
<script defer src="{{ asset('js/main.js') }}"></script>

defer 속성은 HTML 파싱과 병렬로 스크립트를 다운로드하지만 문서 파싱이 끝난 뒤 실행합니다. 이를 통해 async 로딩과 실행 순서 관리의 복잡성 없이 렌더링 차단을 방지합니다.

이미지 최적화

이미지는 반응형 srcset과 명시적인 크기를 갖춘 WebP를 사용합니다.

OPTIMIZED_IMAGES = {
    "vision-sprint": {
        "webp_srcset": (
            "/static/images/optimized/vision-sprint-400w.webp 400w, "
            "/static/images/optimized/vision-sprint-800w.webp 800w, "
            "/static/images/optimized/vision-sprint-1200w.webp 1200w"
        ),
        "fallback": "/static/images/optimized/vision-sprint-fallback.jpg",
        "width": 1200,
        "height": 1045,
    },
}
<picture>
  <source type="image/webp"
          srcset="{{ image.webp_srcset }}"
          sizes="(max-width: 768px) 100vw, 50vw">
  <img src="{{ image.fallback }}"
       width="{{ image.width }}"
       height="{{ image.height }}"
       alt="{{ image.alt }}"
       loading="lazy">
</picture>

명시적인 widthheight 속성은 Cumulative Layout Shift (CLS)를 방지합니다. loading="lazy" 속성은 화면 밖 이미지를 지연 로드합니다. WebP는 동일한 품질에서 JPEG보다 파일 크기가 25-35% 작습니다.16

Early Hints

# In main.py
app.state.preload_links = [
    f'<{make_asset_url(_asset_map, "css/styles.css")}>; rel=preload; as=style',
]

# In security headers middleware
if "text/html" in content_type:
    preload_links = getattr(request.app.state, "preload_links", [])
    if preload_links:
        response.headers["Link"] = ", ".join(preload_links)

rel=preload가 포함된 Link 헤더는 Cloudflare에 103 Early Hints 응답을 전송하도록 지시하여, 서버가 HTML 응답 생성을 완료하기 전에 브라우저가 CSS을 가져오기 시작할 수 있게 합니다.17

최소 JavaScript

전체 JavaScript 용량은 다음과 같습니다.

라이브러리 크기(최소화 + gzip)
HTMX ~16 KB
Alpine.js ~15 KB
페이지별 JS 4-8 KB
합계 35-39 KB

일반적인 React 애플리케이션은 애플리케이션 코드 이전에 프레임워크 JavaScript만 100-300 KB를 전송합니다.18 빌드 없는 접근 방식은 전송할 JavaScript 자체가 적기 때문에 더 적은 JavaScript을 전송합니다.

배포

Railway

blakecrosley.com은 git push를 통해 Railway에 배포해요.

# railway.toml
[build]
builder = "nixpacks"

[deploy]
startCommand = "uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000}"
healthcheckPath = "/health"
healthcheckTimeout = 300
restartPolicyType = "ON_FAILURE"
restartPolicyMaxRetries = 10

Railway의 Nixpacks 빌더는 requirements.txt에서 Python 프로젝트를 감지하고, 의존성을 설치한 뒤 시작 명령을 실행해요. Dockerfile은 필요하지 않아요. 상태 확인 엔드포인트는 애플리케이션이 트래픽을 받기 전에 정상적으로 응답하는지 확인해요.

@app.get("/health")
async def health():
    return {"status": "healthy"}

배포 파이프라인

git push origin main
  → Railway detects push
  → Nixpacks installs Python + requirements.txt (cached)
  → uvicorn starts
  → Health check passes
  → Traffic routes to new deployment
  → ~40 seconds total

npm install도, npm run build도, webpack 컴파일도, TypeScript 컴파일도 없어요. 유일한 설치 단계는 pip install -r requirements.txt이며, 배포 간에 캐시돼요.

Procfile

web: uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000}

Procfile은 Heroku와 호환되는 대안을 제공해요. Railway은 railway.tomlProcfile을 모두 지원해요. ${PORT:-8000} 구문은 플랫폼에서 제공하는 포트를 사용하며, 로컬 개발에서는 기본값으로 8000을 사용해요.

Uvicorn 프로덕션 설정

트래픽이 많은 배포 환경에서는 여러 워커를 사용하세요.

uvicorn app.main:app \
  --host 0.0.0.0 \
  --port ${PORT:-8000} \
  --workers 4 \
  --loop uvloop \
  --http httptools
  • --workers 4는 워커 프로세스 4개를 실행해요. 일반적인 기준은 CPU 코어 수의 2배에 1을 더한 값이에요.
  • --loop uvloop는 더 빠른 uvloop 이벤트 루프를 사용해요. asyncio를 그대로 대체할 수 있어요.
  • --http httptools는 더 빠른 httptools HTTP 파서를 사용해요.

각 워커는 앱 사본을 독립적으로 보유하는 별도 프로세스이므로, 프로세스별 메모리는 워커 수만큼 늘어나요. 바로 이 지점에서 FastAPI 0.140.0의 의존성 그래프 수정 효과가 나타나요. 의존성이 많은 앱에서 워커 4개를 0.139.2로 실행하면 기존 Dependant 오버헤드도 4배로 발생해요.27

개발 환경에서는 --reload가 파일 변경을 감시해요.

uvicorn app.main:app --reload --port 8000

Docker 대안

Docker이 필요한 플랫폼에서는 다음과 같이 설정하세요.

FROM python:3.11-slim

WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

slim 베이스 이미지를 사용하면 컨테이너 크기를 작게 유지할 수 있어요. --no-cache-dir은 pip가 다운로드한 패키지를 이미지 레이어에 저장하지 않도록 해요.

Cloudflare CDN

blakecrosley.com은 CDN 캐싱, DNS, Workers에 Cloudflare을 사용해요.

# Cache headers for HTML pages (set in security middleware)
response.headers["Cache-Control"] = (
    "public, max-age=300, s-maxage=3600, "
    "stale-while-revalidate=86400"
)
  • max-age=300 — 브라우저에서 5분 동안 캐시해요.
  • s-maxage=3600 — CDN에서 1시간 동안 캐시해요.
  • stale-while-revalidate=86400 — 24시간 동안 백그라운드에서 캐시를 갱신하면서 오래된 콘텐츠를 제공해요.

정적 자산은 콘텐츠 해시 URL로 최신 상태가 보장되므로 max-age=31536000, immutable을 사용해요.


선택 기준

빌드 도구가 필요한가요?

다음 4가지 질문에 답해 보세요.

1. 5명이 넘는 개발자가 JavaScript 인터페이스를 공유하나요? 그렇다면 TypeScript의 컴파일 시점 타입 검사가 런타임 테스트에서 너무 늦게 발견되는 통합 버그를 방지해 줘요. 빌드 단계를 추가하세요.

2. 애플리케이션에서 복잡한 클라이언트 측 상태를 관리하나요? 드래그 앤 드롭, 실시간 협업 또는 오프라인 우선 데이터가 있으면 좋은 기능이 아니라 핵심 기능이라면 React나 Svelte 같은 프레임워크를 도입할 만한 가치가 있어요. 빌드 단계를 추가하세요.

3. 여러 제품에서 공유 컴포넌트 라이브러리를 사용하나요? 그렇다면 해당 라이브러리에는 npm 패키징, 시맨틱 버전 관리, 트리 셰이킹이 필요해요. 빌드 단계를 추가하세요.

4. 번들러를 전제로 하는 npm 생태계 라이브러리에 의존하나요? Radix, Framer Motion, TanStack Query 또는 이와 비슷한 라이브러리가 제품의 핵심이라면 빌드 파이프라인이 반드시 필요해요.

4가지 질문에 모두 “아니요”라고 답했다면 빌드 없는 방식을 사용할 수 있어요. 하나라도 “예”라면 빌드 도구가 실제 문제를 해결해 줘요. 4가지 질문에 모두 “아니요”인데도 빌드 도구를 추가하는 것이 문제예요. 존재하지 않는 문제를 해결하려다가 불필요한 의존성 관리 부담만 만들게 돼요.1

스택 비교

범주 빌드 없음(이 가이드) React + 빌드 도구
적합한 용도 콘텐츠 사이트, 포트폴리오, 내부 도구, CRUD 앱 SaaS 제품, 복잡한 SPA, 디자인 시스템 사용 제품
팀 규모 개발자 1~5명 개발자 5~50명 이상
상태 관리 서버(HTMX) + 클라이언트(Alpine.js) 클라이언트(React 상태, Redux, Zustand)
타입 안전성 런타임(서버 측 Pydantic) 컴파일 시점(TypeScript)
컴포넌트 재사용 Jinja2 include + 매크로 npm 패키지, 공유 라이브러리
SEO 기본적으로 서버에서 렌더링 SSR/SSG 설정 필요
기본 성능 높음(최소한의 JS, 서버 렌더링) 다양함(프레임워크 오버헤드)
복잡성 상한선 낮음(오프라인이나 복잡한 클라이언트 상태 미지원) 높음(모든 클라이언트 상호작용 구현 가능)
의존성 Python 패키지 17개 npm 패키지 300개 이상
빌드 시간 0초 15~60초

HTMX이 적합하지 않은 경우

HTMX은 클라이언트 상태를 서버 왕복 요청으로 대체해요. 이 방식은 지연 시간이 중요해지는 순간부터 적합하지 않아요.

  • 드래그 앤 드롭 인터페이스 — 드래그 이벤트마다 200ms가 걸리는 서버 왕복 요청은 허용하기 어려워요.
  • 실시간 협업 — WebSocket 기반 상태에는 클라이언트 측 충돌 해결이 필요해요.
  • 오프라인 우선 애플리케이션 — 서버가 없으면 HTMX도 사용할 수 없어요.
  • 상태에 연결된 복잡한 애니메이션 — Framer Motion과 React Spring은 React 조정 모델을 전제로 해요.
  • Canvas/WebGL 애플리케이션 — 렌더링 루프는 본질적으로 클라이언트 측에서 실행돼요.

이러한 사용 사례에는 클라이언트 측 프레임워크가 적합해요. 빌드 없는 방식은 이런 프레임워크를 대체하려는 것이 아니에요.


빠른 참조 카드

FastAPI

# Development
source venv/bin/activate
uvicorn app.main:app --reload --port 8000

# Production
uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000}

# Testing
python -m pytest -v --cov=app

# Database migrations
alembic upgrade head
alembic revision --autogenerate -m "description"

HTMX 속성

hx-get="/url"                     <!-- GET request -->
hx-post="/url"                    <!-- POST request -->
hx-target="#element"              <!-- Where to put response -->
hx-swap="innerHTML"               <!-- How to insert (innerHTML, outerHTML, beforeend) -->
hx-trigger="click"                <!-- What triggers request -->
hx-trigger="keyup changed delay:300ms"  <!-- Debounced input -->
hx-trigger="load"                 <!-- Fire on element load -->
hx-indicator="#spinner"           <!-- Show during request -->
hx-push-url="true"                <!-- Update browser URL -->
hx-replace-url="true"             <!-- Replace URL (no history) -->

Alpine.js 속성

x-data="{ open: false }"         <!-- Component scope + state -->
x-show="open"                    <!-- Toggle visibility -->
x-cloak                          <!-- Hide until Alpine inits -->
@click="open = !open"            <!-- Event handler -->
@click.away="open = false"       <!-- Outside click -->
@keydown.escape="open = false"   <!-- Keyboard event -->
:class="{ 'active': open }"      <!-- Dynamic class -->
:aria-expanded="open"            <!-- Dynamic attribute -->
x-text="count"                   <!-- Dynamic text content -->
x-init="fetchData()"             <!-- Run on init -->

CSS 사용자 지정 속성

:root {
  --color-bg:     #000000;
  --color-text:   #ffffff;
  --spacing-sm:   1rem;
  --spacing-md:   1.5rem;
  --font-size-lg: 1.25rem;
}
@media (max-width: 768px) {
  :root { --gutter: 24px; }
}

보안 헤더

Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-eval'
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
Referrer-Policy: strict-origin-when-cross-origin
Cross-Origin-Opener-Policy: same-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()

프로젝트 설정 체크리스트

[ ] FastAPI app with Jinja2Templates
[ ] Security headers middleware (CSP, HSTS, X-Frame-Options)
[ ] CSRF token generation and validation
[ ] GZip middleware (minimum_size=500)
[ ] Content-hash asset versioning (cache busting)
[ ] HTMX self-hosted in /static/js/vendor/
[ ] Alpine.js self-hosted in /static/js/vendor/
[ ] CSS custom properties for design tokens
[ ] Health check endpoint (/health)
[ ] Error handlers (404, 500)
[ ] robots.txt, sitemap.xml, llms.txt
[ ] JSON-LD structured data in base template
[ ] Hreflang tags for i18n (if multi-language)
[ ] HTML sanitization filter (nh3)
[ ] Rate limiting middleware
[ ] Deferred script loading

자주 묻는 질문

HTMX은 실제 웹 애플리케이션의 프로덕션 환경에서 사용할 준비가 되었나요?

네. HTMX은 2020년부터 안정적으로 유지되어 왔으며 여러 업계의 프로덕션 환경에서 사용되고 있어요. 개발자인 Carson Gross는 하위 호환성을 핵심 설계 원칙으로 삼고 있어요. HTMX 문서에는 같은 메이저 버전 안에서 기존 애플리케이션이 중단되는 변경을 하지 않는다고 명시되어 있어요.19 이 라이브러리는 축소하고 gzip으로 압축하면 약 16KB이며, 의존성이 없고 시맨틱 버전 관리를 따라요. blakecrosley.com은 3년 동안 프로덕션 환경에서 HTMX을 사용했으며, HTMX과 관련된 버그는 한 건도 없었어요.20

빌드 단계 없이 TypeScript을 사용할 수 있나요?

일부 용도로는 가능해요. TypeScript 파일은 출력 파일을 생성하지 않고 tsc --noEmit으로 타입 검사를 할 수 있으므로, 컴파일 시점 검사를 린터처럼 활용할 수 있어요. 하지만 브라우저는 .ts 파일을 직접 실행할 수 없어서 TypeScript을 제공하려면 여전히 빌드 단계가 필요해요. 다른 방법으로는 일반 .js 파일에 JSDoc 타입 주석을 사용하는 방식이 있어요. TypeScript은 컴파일 없이도 이 파일을 검사할 수 있어요. 따라서 표준 JavaScript을 배포하면서 개발 중에는 타입 안전성을 확보할 수 있어요.

이 접근 방식은 Astro나 11ty와 어떻게 다른가요?

Astro와 11ty는 최소한의 클라이언트 JavaScript만 포함된 일반 HTML을 생성하는 정적 사이트 생성기이지만, 빌드 단계가 필요해요(Node.js, npm install, 빌드 명령어). 빌드 없는 접근 방식은 이 단계를 없애고 서버가 요청마다 HTML을 렌더링해요. 여기에는 장단점이 있어요. Astro와 11ty는 서버 연산이 없는 더 빠른 정적 페이지를 만들지만, FastAPI + HTMX은 별도의 API 계층 없이도 사용자별 데이터, 양식 제출, 실시간 업데이트 같은 동적 콘텐츠를 기본적으로 처리해요.

React의 서버 측 렌더링(SSR)은 어떤가요?

Next.js SSR과 FastAPI + HTMX 접근 방식은 서버에서 렌더링한 HTML을 브라우저로 전송한다는 목표를 공유해요. 차이는 최초 렌더링 이후에 일어나는 과정에 있어요. Next.js는 React로 페이지를 하이드레이션하면서 프레임워크 런타임과 컴포넌트 코드를 클라이언트에 전송해요. FastAPI + HTMX은 하이드레이션하지 않으며, HTML 자체가 최종 결과물이에요. 이후 상호작용은 HTMX이 서버에 새로운 HTML 조각을 요청하는 방식으로 처리해요. 그 결과 FastAPI + HTMX이 전송하는 전체 JavaScript 용량은 약 35~40KB인 반면, Next.js 애플리케이션은 약 100~300KB를 전송해요.18

이 스택에서는 양식 검증을 어떻게 처리하나요?

서버에서 처리해요. 양식이 제출되면 Pydantic이 입력값을 검증해요. 검증에 실패하면 서버는 오류 메시지가 포함된 양식을 반환하고, HTMX이 응답을 DOM에 교체해 넣어요.

<form hx-post="/contact" hx-target="#form-container" hx-swap="outerHTML">
  <input type="email" name="email" required>
  <button type="submit">Send</button>
</form>
@router.post("/contact")
async def contact(request: Request, email: str = Form(...)):
    if not validate_email(email):
        return templates.TemplateResponse("components/_contact_form.html", {
            "request": request,
            "error": "Please enter a valid email address",
            "email": email,  # Preserve input
        })
    await send_email(email)
    return templates.TemplateResponse("components/_contact_success.html", {
        "request": request
    })

서버가 검증하고 오류 상태를 렌더링하면 HTMX이 그 결과로 교체해요. 클라이언트 측 검증 라이브러리는 필요하지 않아요. HTML의 required 속성은 첫 번째 방어선으로 브라우저 수준의 기본 검증을 제공해요.

실시간 기능(WebSocket)을 추가할 수 있나요?

네. FastAPI은 WebSocket을 기본으로 지원해요.

from fastapi import WebSocket

@app.websocket("/ws/notifications")
async def websocket_endpoint(websocket: WebSocket):
    await websocket.accept()
    while True:
        data = await get_notification()
        await websocket.send_text(render_notification_html(data))

HTMX에는 요소를 WebSocket 엔드포인트에 연결하는 WebSocket 확장 기능(hx-ws)이 있어요.

<!-- HTMX 2.x WebSocket extension syntax -->
<div hx-ext="ws" ws-connect="/ws/notifications">
  <div id="notifications" ws-send></div>
</div>

참고: HTMX 1.x에서는 hx-ws="connect:..." 문법을 사용했어요. HTMX 2.x에서는 WebSocket 지원이 별도의 확장 기능(htmx-ext-ws)으로 이동했으며, 위에 나온 ws-connectws-send 속성을 사용해요. HTMX 1.x를 사용한다면 기존 hx-ws 문법도 계속 작동해요.

HTMX 4.0 베타 진행 상황: 현재 htmx 4.0.0-beta6는 npm의 next 태그와 4.0 문서에서 제공되고 있어요(beta6는 2026년 7월 23일에 게시되었어요). 반면 htmx.org의 빠른 시작 가이드와 npm의 latest 태그는 여전히 2.0.10을 사용해요. 이 가이드는 계속 HTMX 2.x를 기준으로 하며, 4.0이 안정화되기 전까지 프로덕션 작업에는 2.x를 권장해요. 2.x에서 4.x로의 마이그레이션은 2.x의 단순한 포인트 릴리스 업데이트가 아니라 세대가 바뀌는 큰 전환이에요. big-skies-software의 버전 관리 방식은 홀수 메이저 버전을 건너뛰므로 2.x 다음 버전은 4.0이에요.2122

4.0 문서에서 주목할 내용: 4.0 정식 출시 전에 보안 및 아키텍처 측면에서 검토할 만한 기능이 두 가지 있어요. 새로운 hx-live 확장 기능에는 참조하는 상태가 변경될 때 다시 평가되는 DOM 반응형 표현식이 도입되며, 새로운 hx-nonce 확장 기능은 CSP 논스를 통해 htmx 속성 처리를 제한해요. 4.0 마이그레이션 가이드에서는 여러 설정 개념의 위치도 변경하고, 일부 이벤트와 기록 동작을 복원하거나 변경하며, 핵심 기능에서 일부 JavaScript 도우미를 제거해요. 4.0은 2.x에 바로 적용하는 패치가 아니라 별도의 마이그레이션 프로젝트로 다뤄야 해요.21

서버의 메시지는 HTTP 응답과 동일한 대상 지정 및 교체 방식으로 DOM에 삽입돼요. 서버는 WebSocket을 통해 HTML 조각을 전송하고, HTMX이 이를 삽입해요.

이 스택은 SEO를 어떻게 처리하나요?

서버에서 렌더링한 HTML은 크롤러가 JavaScript을 실행하지 않아도 완성된 페이지 콘텐츠를 받으므로 본질적으로 SEO에 유리해요. blakecrosley.com은 다음과 같은 여러 SEO 계층을 추가해요.

  • 모든 페이지의 <head>에 포함되는 JSON-LD 구조화 데이터(Person, Article, WebSite, FAQPage 스키마)
  • 10개 로케일 모두에 대한 hreflang 대체 링크를 포함하는 동적 사이트맵
  • /blog/feed.xml에서 제공되는 RSS 피드
  • AI 크롤러가 사이트를 쉽게 찾을 수 있도록 루트에 배치한 llms.txt
  • 기본 템플릿의 표준 URLOpen Graph 태그
  • 시맨틱 HTML: <article>, <section>, <main>, 올바른 제목 계층 구조

SSR 설정은 필요하지 않아요. getStaticProps도 필요하지 않고 ISR도 필요하지 않아요. HTML은 모든 요청에서 렌더링되며, 이는 최적화 기법이 아니라 기본 동작이에요.

React와 비교하면 학습 난이도는 어떤가요?

Python 개발자라면 학습 난이도가 훨씬 낮아요. 이미 해당 언어를 알고 있기 때문이에요. FastAPI의 라우트 핸들러는 템플릿 응답을 반환하며, Flask나 Django 뷰와 같은 방식으로 이해할 수 있어요. HTMX에서는 몇 가지 HTML 속성(hx-get, hx-target, hx-swap)만 익히면 돼요. Alpine.js에서는 여기에 몇 가지 속성(x-data, x-show, @click)이 더해져요. JSX, 가상 DOM, 훅 시스템, 상태 관리 라이브러리, 빌드 도구 설정은 배울 필요가 없어요.

HTMX 문서는 긴 페이지 하나에 모두 담겨 있어요. Alpine.js 문서도 몇 페이지 분량이에요. 반면 React 문서는 훅, 컨텍스트, ref, 이펙트, 서스펜스, 서버 컴포넌트, 스트리밍 SSR을 다루며 수백 페이지에 걸쳐 있어요.

JavaScript/React 개발자에게는 문법보다 사고방식의 전환이 더 중요해요. 핵심은 서버가 상태를 소유하고 서버가 HTML을 렌더링한다는 점이에요. 클라이언트 측 상태 관리는 서버 측 라우트 처리로 바뀌어요. 클라이언트 측 데이터 가져오기는 HTML 요소의 HTMX 속성으로 바뀌어요. 문법은 더 간단하지만, 클라이언트가 렌더링을 담당한다는 SPA의 전제를 버리고 새로운 사고방식에 익숙해져야 해요.


변경 이력

날짜 변경 사항 출처
2026-08-16 Starlette 1.3.1 → 1.6.0, 그리고 변경 사항 중 하나가 이 가이드의 GZip 스니펫을 조용히 바꿉니다. 동작 변경: Starlette 1.5.0(8월 8일)은 DEFAULT_EXCLUDED_CONTENT_TYPEStext/event-stream을 훨씬 넘어 gzip/zip 아카이브, PNG, JPEG, WebP, GIF, AVIF, audio/*, video/*, WOFF/WOFF2 폰트까지 포함하도록 확장했습니다. 따라서 이 항목들은 더 이상 기본적으로 압축되지 않습니다. image/*는 의도적으로 제외하지 않으므로 image/svg+xml은 압축할 수 있습니다. 새로운 키워드 전용 exclude_content_types가 이 목록을 재정의하고, 대소문자를 구분하지 않으며, 런타임에 모듈 상수를 다시 할당해도 더 이상 적용되지 않습니다. FastAPI의 런타임 고정 조건은 상한 없는 starlette>=0.46.0이므로, 새로 설치하면 1.6.0을 가져오고 독자가 별도로 조치하지 않아도 이 가이드의 스니펫에 변경 사항이 반영됩니다. 두 GZip 구절을 모두 수정했습니다. 1.5.0은 상태 206의 부분 응답도 건너뛰고 스트리밍되는 청크마다 플러시합니다. 1.4.0(8월 5일)은 128 KiB 이상의 thread_minimum_size gzip 청크를 워커 스레드로 옮겨 큰 압축 작업이 이벤트 루프를 막지 않도록 했습니다. 새 기능: 1.6.0(8월 8일)은 Starlette/Router/Mount/Routemax_body_sizeRequestBodyLimitMiddleware를 추가했습니다. 요청 본문 제한은 이 가이드에 전혀 없었으므로 새로운 Security 하위 섹션에서 다룹니다. 추가로 encode/starlette는 이제 Uvicorn 이전과 마찬가지로 Kludex/starlette로 리디렉션됩니다. 변경 이력 전용: Uvicorn 0.52.0–0.52.3(릴리스 자체에서 프로덕션 트래픽 앞에 두지 말라고 밝힌 실험적 Zig 기반 zttp HTTP/1.1 구현이므로, 가이드의 --http httptools 권장 사항은 유지됩니다), Alpine.js 3.16.0/3.16.1, SQLAlchemy 2.0.52(Python 3.15 지원, ORM UPDATE synchronize_session="fetch" 결과 열 정렬 불일치 수정). 변경 없음을 확인함: FastAPI 0.141.1, HTMX 최신 2.0.10 / 다음 4.0.0-beta6, Bootstrap 5.3.8, Jinja2 3.1.6, Pydantic 안정 버전 2.13.4. 해당 기간에 9개 종속성 전체에서 새 보안 권고 사항은 없었습니다. 28
2026-07-29 FastAPI 0.141.0 + 0.141.1(둘 다 7월 29일). 0.141.0은 app.frontend(check_dir="auto")를 추가하여 프런트엔드 빌드가 실행되기 전에 서버를 시작하는 일반적인 경우처럼 빌드 디렉터리가 없을 때 fastapi dev가 더 이상 실패하지 않도록 합니다. 몇 시간 뒤 나온 0.141.1은 app.frontend()의 종속성이 백그라운드 작업과 응답 헤더를 버리던 문제를 수정합니다. 쿠키를 설정하거나 BackgroundTask를 예약하는 종속성은 API 경로에서는 올바르게 동작했지만 프런트엔드 마운트에서는 해당 작업이 버려졌으므로, 이는 0.139.0에 추가된 종속성 지원의 실제 공백을 메웁니다. 이 가이드의 서버 렌더링 방식은 dist/ 디렉터리를 마운트하지 않으므로, 둘 다 새 섹션이 아니라 기존 app.frontend() 설명에 반영했습니다. 0.141.1은 FastAPI CLI 가이드에서 FASTAPI_ENV도 문서화합니다(문서 전용이며 본문 변경은 없습니다). 29
2026-07-27 FastAPI는 7월 27일 5시간 반 동안 0.140.1부터 0.140.7까지 출시했습니다 — 총 7개 릴리스이며 모두 0.140.0에서 시작한 종속성 처리 방식을 리팩터링한 것입니다. 두 흐름이 있습니다. FastAPI가 생성하고 유지하던 평탄화된 종속성 그래프 복사본은 이제 사라졌고(0.140.2), 이를 다시 만들던 모든 나머지 지점도 함께 제거되었습니다. OpenAPI 생성(0.140.3, 0.140.7), 본문 필드(0.140.5), 요청 매개변수(0.140.6)이며, 0.140.4는 읽히지 않는 반복 추적 장부를 제거합니다. 사용자에게 보이는 임계값 변경은 하나입니다. 0.140.1에서 fastapi/dependencies/models.py의 호출 가능 항목 분류 도우미에 있는 lru_cache가 1,024개에서 4,096개 항목으로 늘어납니다(_CALLABLE_CLASSIFICATION_CACHE_SIZE라는 이름). 1,024개를 넘는 고유 종속성을 쓰는 앱에서 캐시가 과도하게 교체된다는 보고가 있은 뒤의 변경입니다. API 변경은 없습니다. 종속성 메모리 단락의 권장 버전을 0.140.0에서 0.140.7 이상으로 옮겼고, 이 계열은 여전히 바뀌고 있으며 OpenAPI 종속성 벤치마크(PR #16075)는 계열의 마지막 릴리스에야 반영되었다는 메모를 추가했습니다. 30
2026-07-25 FastAPI 0.140.0(7월 24일 21:16 UTC)은 0.121.0(2025년 11월 3일)부터 존재하던 종속성 시스템 메모리 회귀를 수정합니다. PR #16049는 Dependant에서 functools.cached_property 속성 10개를 제거하고 모듈 수준 도우미로 옮기며 클래스를 @dataclass(slots=True)로 만듭니다. 병합된 PR의 공식 CodSpeed 실행 결과에 따르면 test_dependency_graph 메모리 벤치마크는 17.5 MB → 1.1 MB(×16)이며, 원래 보고서는 0.120.4가 약 400 MB 미만을 유지한 반면 0.121.3에서는 프로덕션 OOM이 발생했다고 설명합니다. Async Patterns에 0.140.0 관련 구절을 추가하고 Uvicorn Production Configuration에 워커 메모리 항목을 넣었습니다. 기존 오류도 수정했습니다: 가이드는 0.137.0이 “Starlette를 1.x 계열에 고정한다”고 주장했지만 사실이 아닙니다. FastAPI의 런타임 요구 사항은 상한 없는 starlette>=0.46.0이며(Starlette 0.4x도 계속 충족), 0.136.3, 0.137.0, 0.138.0, 0.139.2, 0.140.0 전반에서 동일합니다. 0.137.0 노트의 1.x 숫자는 저장소 테스트 잠금 파일에 대한 dependabot 업데이트이며(PR #15722는 uv.lock만 변경), 본문 주장과 24를 모두 수정했습니다. 변경하지 않은 두 항목도 표시했습니다. Dependant 내부는 이제 도구에 대해 호환성이 깨집니다(oauth_scopes, cache_key, _uses_scopes, _is_security_scheme가 속성에서 제거되고 _get_oauth_scopes() / _get_cache_key() / _uses_scopes() 모듈 함수로 대체되며 slots=True가 인스턴스 monkey-patching을 막음). 이는 이 가이드에서 언급하지 않는 문서화되지 않은 내부 API이며 0.137.0의 router.routes 변경과 같은 범주입니다. 또한 FastAPI의 공식 문서는 README, index.md, virtual-environments.md, Docker/deployment 페이지를 포함한 30개 파일에서 이제 pip/venv 대신 기본적으로 uv 프로젝트를 사용합니다(PR #16032, 7월 21일 병합). 문서 변경은 코드에는 외형적인 변화지만 이 가이드는 전반적으로 pip install -r requirements.txt를 가르치므로 이제 업스트림의 첫 진입점과 달라집니다. 이는 향후 편집 결정으로 남겨 두었으며 이번 패스에서는 의도적으로 결정하지 않았습니다. 27
2026-07-24 htmx 4.0.0-beta6이 npm next 태그에서 beta5를 대체했습니다(2026년 7월 23일 게시, GitHub도 같은 날 릴리스). 주요 베타 항목은 새로운 hx-multipart 확장(파트별 HX-* 동작 헤더가 있는 스트리밍 multipart/mixed/multipart/parallel 응답), Navigation API을 통한 히스토리 스크롤 복원과 Firefox 대체 처리, 베타 내부 이벤트 이름 변경 htmx:swap:finallyhtmx:finally:swap, 이제 스왑 후 발생하는 HX-Trigger 응답 헤더 이벤트, 사용자 지정 요청 메서드, protocols 전달을 포함한 hx-ws 재작성입니다. 권장 사항은 변함없습니다. 프로덕션에서는 4.0 GA 전까지 HTMX 2.x(latest = 2.0.10)를 유지합니다. 이름 변경은 4.0 베타 계열 내에서만 호환성이 깨집니다. 베타 추적 메모와 21를 업데이트했습니다. FastAPI 0.139.2, Uvicorn 0.51.0, Alpine.js 3.15.12, Starlette 1.3.1, Jinja2 3.1.6은 모두 변경 없음을 확인했으며 해당 기간에 보안 권고 사항은 없었습니다.
2026-07-17 FastAPI 0.139.1 + 0.139.2(7월 16일): app.frontend() 대체 처리의 점이 포함된 경로 수정(/users/john.doe, PR #16011) 및 병렬 스레드 테스트를 위한 스레드 안전 라우터 경로 생성(PR #16013) — 앱에 영향을 주는 API 변경은 없습니다. Uvicorn 0.49.0 → 0.51.0: 기존 websockets 구현은 사용 중단 예정이며 auto는 이제 기본적으로 websockets-sansio를 사용합니다(0.50.0). 기본 구현은 websockets>=13.0을 요구하고(0.50.2), 0.51.0(7월 8일)은 거의 무중단으로 다시 로드할 수 있도록 겹침 구간이 있는 SIGHUP 워커 재시작을 추가했습니다. 저장소는 이제 Kludex/uvicorn에 있습니다. HTMX(2.0.10 / 4.0.0-beta5 next), Alpine.js 3.15.12, Starlette 1.3.1, Pydantic 2.13.4, SQLAlchemy 2.0.51, Bootstrap 5.3.8은 모두 변경 없음을 확인했으며 해당 기간에 보안 권고 사항은 없었습니다.
2026-07-07 htmx 4.0.0-beta5가 이제 npm next 태그입니다(2026년 6월 26일 게시). beta4를 대체했으며 HTMX 4.0 베타 추적 메모와 [^22]를 이에 맞게 업데이트했습니다. 권장 사항은 변함없습니다. 프로덕션 작업은 4.0 GA 전까지 HTMX 2.x(latest = 2.0.10)를 유지합니다. htmx.org npm dist-tags를 기준으로 확인했습니다.
2026-07-02 FastAPI 0.139.0(7월 1일). app.frontend()가 이제 dependencies를 지원합니다. 예를 들어 제공되는 프런트엔드에 대한 자동 쿠키 인증(PR #15908)을 지원하며, 0.138.0의 정적 프런트엔드 마운트를 표준 Depends() 방식으로 확장합니다. 여전히 이 가이드의 서버 렌더링 주제와는 별개이므로 같은 대비 단락에 언급했습니다. 그 밖의 스택 변경은 없습니다. HTMX 2.0.10, Alpine.js 3.15.12, Bootstrap 5.3.8, SQLAlchemy 2.0.51은 변경되지 않았습니다. 26
2026-06-22 FastAPI 0.138.0 + 0.137.2. 0.138.0(6월 20일)은 빌드된 정적 프런트엔드(SPA dist/ 출력)를 제공하기 위한 app.frontend("/", directory="dist") / router.frontend(...)를 추가했습니다. 이는 이 가이드의 빌드 없는 서버 렌더링 주제와는 별개이므로 Async Patterns 섹션에서 대비해 언급했습니다. 0.137.2(6월 18일)는 router.routes가 내부 항목이 된 후(0.137.0부터) 경로를 열거하는 지원 방식으로 iter_route_contexts()를 추가했습니다. 둘 다 기능 추가이며 호환성이 깨지는 변경은 없습니다. Starlette(1.3.1), Pydantic(2.13.4), HTMX(2.0.10), Alpine.js(3.15.12), Bootstrap(5.3.8), SQLAlchemy(2.0.51)는 모두 변경되지 않았습니다. 25
2026-06-16 FastAPI 0.137.0/0.137.1 + Starlette 1.0→1.3.1. FastAPI 0.137.0(6월 14일)은 라우터 내부를 리팩터링합니다. router.routes는 이제 평탄한 APIRoute 목록이 아닌 내부 트리입니다(이를 순회하는 모든 항목과 호환성이 깨짐). 동시에 include_router() 후에 추가된 경로와 새로운 APIRouter.matches()/.handle() hook을 지원합니다. 0.137.1(6월 15일)은 APIRoute 타입 지정과 빈 경로의 접두사 없는 라우터를 수정했습니다. Starlette는 첫 안정 버전 1.0을 3월 22일에 출시했고 현재 1.3.1(6월 12일)이며, 사용 중단된 on_event/on_startup/on_shutdown hook과 @app.route()/@app.websocket_route() decorator를 제거했습니다. 이제 lifespanRoute/WebSocketRoute만 사용할 수 있습니다. (이 항목은 원래 FastAPI 0.137.0이 Starlette 1.3.1을 고정한다고 적었습니다. 2026-07-25 수정: 그렇지 않으며 런타임 요구 사항은 상한 없는 starlette>=0.46.0입니다.) Async Patterns 섹션에 lifespan/router 메모를 추가했습니다. SQLAlchemy 2.0.51(6월 15일)은 버그 수정만 포함합니다. 24
2026-06-08 SQLAlchemy 2.0.50 async 설치 변경. SQLAlchemy 2.0.50부터 async 스택의 greenlet 종속성은 더 이상 기본 설치되지 않습니다. sqlalchemy[asyncio] extra를 설치해야 하며(그렇지 않으면 엔진에 대한 첫 await가 missing-greenlet 오류로 실패함), 2.0.50은 Python 3.10+도 요구합니다(3.7–3.9 지원 중단). 또한 free-threaded 3.13t wheel을 추가합니다. SQLAlchemy 2.0 Async 섹션에 설치 메모를 추가했습니다. 나머지 스택의 본문 변경은 없습니다. FastAPI 최신은 여전히 0.136.3(2026-05-23, 6월 릴리스 없음)이고, htmx 안정 버전은 2.0.10입니다(4.0.0-beta4 “The Fetchening”은 베타이며 안정 버전 목표는 대략 2027년 초이므로 아직 프로덕션 권장 버전이 아닙니다). Alpine.js는 3.15.12이며 Bootstrap 5.3.x도 변경되지 않았습니다. 프로덕션 권장 사항은 변함없습니다. 4.0 안정 버전 전까지 HTMX 2.x를 사용하세요.23
2026-05-24 유지 관리 확인: 로컬 콘텐츠 인벤토리는 여전히 블로그 게시물 210개, 핵심 가이드 11개, 디자인 스터디 48개, 영어를 포함한 지원 로케일 10개를 보여 줍니다. FastAPI 최신은 0.136.3(2026-05-23)입니다. 릴리스 노트에서 언급된 유일한 앱 영향 리팩터링은 convert_underscores=True일 때 더 엄격해진 밑줄 헤더 처리이며, 0.136.2는 손상된 이벤트 데이터를 방지하기 위해 Server-Sent Event 필드를 검증합니다. htmx 안정 버전은 2.0.10으로 유지되며 npm next와 4.0 문서는 이제 4.0.0-beta4를 가리킵니다. SQLAlchemy 2.0 최신은 2.0.50이고 Pydantic 최신은 2.13.4로 유지됩니다. 프로덕션 권장 사항은 변함없습니다. 4.0이 안정화될 때까지 HTMX 2.x를 사용하세요.122
2026-05-18 사이트 인벤토리 갱신: 로컬 콘텐츠 인벤토리는 이제 블로그 게시물 210개, 핵심 가이드 11개, 디자인 스터디 48개, 영어를 포함한 지원 로케일 10개를 보여 줍니다. FastAPI 최신은 0.136.1로 유지됩니다. htmx 안정 버전은 2.0.10으로 유지되고 npm next는 4.0.0-beta3입니다. Alpine.js npm 최신은 3.15.12로 유지됩니다. 프로덕션 권장 사항은 변함없습니다. 4.0이 안정화될 때까지 HTMX 2.x를 사용하세요.12021
2026-05-15 유지 관리 확인: FastAPI 최신은 0.136.1로 유지됩니다. 이 로컬 사이트 환경은 FastAPI 0.128.0과 Starlette 0.50.0을 import합니다. htmx 안정 버전은 2.0.10으로 유지되고 npm next는 이제 4.0.0-beta3입니다. Alpine.js npm 최신은 3.15.12이고 Bootstrap 최신은 5.3.8, SQLAlchemy 2.0 최신은 2.0.49, Pydantic 최신은 2.13.4입니다. 프로덕션 권장 사항은 변함없습니다. 4.0이 안정화될 때까지 HTMX 2.x를 사용하세요.2021
2026-05-09 htmx 4.0.0-beta3 추적(2026년 5월 8일): htmx 4.0.0-beta3는 npm next 태그와 4.0 문서에서 사용할 수 있으며 npm latest는 2.0.10으로 유지됩니다. GA 전에 추적할 만한 주요 내용은 새로운 hx-live 확장(DOM 반응형 표현식), 새로운 hx-nonce 확장(htmx 속성을 위한 CSP nonce 보호), 설정, 히스토리, 이벤트, 핵심 JavaScript 도우미에 대한 마이그레이션 가이드 변경입니다. 프로덕션 권장 사항은 변함없습니다. htmx 2.x는 최신 npm 태그이며 4.0 GA까지 권장 버전입니다.21
2026-05-07 유지 관리 확인: FastAPI 최신은 0.136.1로 유지됩니다. htmx 안정 버전은 2.0.10이고 v4는 2026년 여름 목표의 베타 상태입니다. Alpine.js npm 최신은 3.15.12, Bootstrap 최신은 5.3.8, SQLAlchemy 2.0 최신은 2.0.49, Pydantic 최신은 2.13.4입니다. 사이트 로컬 지표는 블로그 게시물 182개, 가이드 11개, 지원 로케일 10개, Python 요구 사항 17개로 갱신했습니다. 마이그레이션 지침은 변함없습니다. 4.0이 안정화될 때까지 프로덕션에는 HTMX 2.x를 사용하세요.20
2026-04-25 FastAPI 0.136.1(2026년 4월 23일): Pydantic v2 사용 중단 정리(앱 코드의 동작 변경 없음). HTMX 4.0 타임라인 추적: htmx 4.0.0-beta1(4월 6일)과 4.0.0-beta2(4월 14일)가 출시되었습니다. 마이그레이션 지침은 변함없습니다 — htmx 2.x는 4.0이 안정화될 때까지 최신 npm 태그를 유지합니다. 보안 수정은 계속 제공되며 업그레이드 압박은 없습니다. 지금부터 설계 시 고려할 만한 주요 4.0 변경 사항은 다음과 같습니다. (1) 핵심 ajax 인프라가 XMLHttpRequest에서 fetch()로 바뀝니다. (2) 속성 상속은 기본적으로 명시적이 됩니다. (3) 히스토리 지원은 복원된 콘텐츠를 위해 네트워크 요청을 수행합니다(로컬 DOM 스냅샷 없음). FastAPI 0.135.4(4월 16일)는 0.135.3에 추가된 April Fool’s @app.vibe() decorator를 제거했습니다.
2026-04-16 HTMX 4.0-beta 인지 기능(미래 참조)을 추가했습니다. FastAPI 0.136.0의 Python 3.14t free-threaded 빌드 지원을 언급했습니다. Pydantic 2.13.x 기능(검증된 모델 데이터 접근이 가능한 private-attribute 기본 factory, 3.14 지원이 포함된 pydantic.v1 namespace 1.10.26). Alpine.js 3.15.11 수정 사항: x-anchor.noflip modifier, x-for 다중 루트 요소 경고, $refs morph 회귀 수정.
2026-03-24 최초 게시

참고 자료


이 가이드는 blakecrosley.com을 구축하는 데 사용한 전체 시스템을 다룹니다. No-Build Manifesto는 철학적 논거를 제공합니다. Lighthouse Perfect Score 게시물은 성능 최적화 과정을 기록합니다. Vibe Coding vs. Engineering 게시물은 AI 지원 개발이 이 워크플로에서 어디에 적합한지 살펴봅니다.


  1. 2026년 5월 18일 기준 blakecrosley.com 프로덕션 지표입니다. 이 사이트에는 블로그 게시물 210개, 인터랙티브 JavaScript 구성 요소, 핵심 가이드 11개, 디자인 스터디 48개, 영어와 번역된 9개 로케일, 최소한의 Python 의존성, 빌드 도구 0개가 있습니다. 로컬 콘텐츠 인벤토리, app/i18n/config.py, requirements.txt에서 확인했습니다. 

  2. Google PageSpeed Insights(pagespeed.web.dev)는 모든 공개 URL을 대상으로 Lighthouse 감사를 실행합니다. blakecrosley.com은 2026년 3월 기준 Performance, Accessibility, Best Practices, SEO에서 100/100/100/100점을 받았습니다. 결과는 공개적으로 확인할 수 있습니다. 전체 최적화 과정은 From 76 to 100: Achieving a Perfect Lighthouse Score를 참고하세요. 

  3. npx create-next-app@latest(Next.js 15, 2026년 2월 테스트)는 총 187 MB 크기의 node_modules/에 패키지 311개를 설치합니다. 추가 의존성이 있는 프로덕션 프로젝트는 대체로 더 커집니다. 개별 프로젝트마다 차이가 있습니다. 출처: 작성자의 테스트이며, The No-Build Manifesto에 기록되어 있습니다. 

  4. Vercel의 Next.js 성능 문서는 90점 이상의 점수를 달성하기 위해 특정 최적화(이미지 최적화, 폰트 로딩, 코드 분할)를 권장합니다. nextjs.org/docs/app/building-your-application/optimizing를 참고하세요. 70~90점 범위는 이러한 최적화를 적용하기 전 기본 설정을 반영합니다. 

  5. 전체 의존성 목록은 2026년 5월 기준 blakecrosley.com의 requirements.txt에서 확인했습니다. 이 파일에는 현재 Python 요구 사항 항목 17개가 있으며 빌드 도구, 컴파일러, 번들러는 없습니다. 

  6. Next.js 프로젝트를 유지 관리한 작성자의 경험(2021~2024)에 따르면, 활성 프로젝트에서 JavaScript 생태계는 한 달에 Dependabot PR 15~25개를 생성하며, 대부분은 개발자가 직접 가져온 적 없는 전이 의존성을 업데이트합니다. 

  7. Tim Berners-Lee는 웹 설계 원칙으로 하위 호환성을 제시했습니다. “a browser should be backwards-compatible.” 1996년의 페이지도 Chrome 2026에서 렌더링됩니다. w3.org/DesignIssues/Principles를 참고하세요. 

  8. OWASP는 공격 표면을 줄이기 위해 프로덕션에서 API 문서 엔드포인트를 비활성화할 것을 권장합니다. /openapi.json 엔드포인트는 모든 라우트 정의, 매개변수, 응답 모델을 노출합니다. 

  9. async와 sync 핸들러에 대한 FastAPI 문서: fastapi.tiangolo.com/async/. async 함수에서 await와 블로킹 호출을 함께 사용하면 이벤트 루프가 굶주리게 됩니다. 

  10. nh3는 Bleach 라이브러리의 후속 제품인 Rust 기반 HTML sanitizer입니다. PyO3 프로젝트에서 유지 관리하며 allowlist 기반 HTML sanitization을 제공합니다. github.com/messense/nh3를 참고하세요. 

  11. Vary 헤더는 RFC 9110 섹션 12.5.5에 정의되어 있습니다. 지정된 요청 헤더 값에 따라 별도의 응답을 저장하도록 캐시에 지시합니다. Vary: HX-Request가 없으면 CDN이 HTMX fragment를 전체 페이지 응답으로 제공할 수 있습니다. httpwg.org/specs/rfc9110.html#field.vary를 참고하세요. 

  12. CSS Custom Properties(CSS Variables)는 전 세계 브라우저의 97% 이상에서 지원됩니다. 런타임에 cascade, inherit, media query 반응이 가능하며, 이는 preprocessor variables에는 없는 기능입니다. 출처: caniuse.com/css-variables

  13. Google의 hreflang 문서: developers.google.com/search/docs/specialty/international/localized-versions. x-default 값은 언어가 hreflang 목록에 없는 사용자를 위한 대체 페이지를 지정합니다. 

  14. Alpine.js는 표현식 평가 엔진을 위해 Content Security Policy에 'unsafe-eval'이 필요합니다. CSP 호환 빌드(@alpinejs/csp)는 이 요구 사항을 피하지만 제한 사항이 있습니다. alpinejs.dev/advanced/csp를 참고하세요. 

  15. HMAC 기반 CSRF 토큰은 OWASP CSRF Prevention Cheat Sheet에 설명된 “Signed Double-Submit Cookie” 패턴을 따릅니다. hmac.compare_digest는 타이밍 사이드 채널 공격을 방지하기 위해 상수 시간 비교를 사용합니다. cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html를 참고하세요. 

  16. WebP는 동등한 시각 품질에서 JPEG보다 파일 크기가 25~35% 더 작습니다. Google의 WebP 연구: developers.google.com/speed/webp/docs/webp_study

  17. 103 Early Hints를 사용하면 서버 또는 CDN은 최종 응답이 준비되기 전에 preload 힌트가 포함된 예비 응답을 보낼 수 있습니다. Cloudflare는 rel=preload가 있는 Link 헤더의 Early Hints를 지원합니다. developer.chrome.com/blog/early-hints를 참고하세요. 

  18. React 18 + ReactDOM의 크기는 minified + gzipped 기준 약 42 KB입니다. router, state management library, build framework runtime을 포함하면 일반적인 React 애플리케이션은 framework JavaScript를 100~300 KB 전송합니다. 출처: bundlephobia.com/package/react-dom@18.2.0

  19. HTMX 버전 관리 정책과 하위 호환성 약속은 htmx.org/migration-guide-htmx-1/에 문서화되어 있습니다. Carson Gross는 Gross, Stepinski, Cotter가 쓴 Hypermedia Systems(2023)에서 하위 호환성 원칙을 설명했습니다. hypermedia.systems를 참고하세요. 

  20. 2026년 5월 15일 유지 관리 점검입니다. FastAPI PyPIrelease notes에는 0.136.1이 표시됩니다. 로컬 import 확인에서는 이 사이트 환경에서 FastAPI 0.128.0과 Starlette 0.50.0이 반환되었습니다. htmx.org의 quick start에는 2.0.10이 표시됩니다. npm view htmx.org version dist-tagslatest=2.0.10next=4.0.0-beta3을 반환했습니다. npm view alpinejs versionnpm view @alpinejs/csp version3.15.12를 반환했습니다. Bootstrap official blog와 npm package metadata에는 5.3.8이 표시됩니다. SQLAlchemy PyPI와 docs에는 2.0.49가 표시되고, Pydantic PyPI에는 2.13.4가 표시됩니다. 

  21. htmx 4.0.0-beta6는 현재 npm next 태그입니다(2026년 7월 23일 게시됨. beta 라인은 2026년 5월 8일 beta3에서 beta4 → beta5 → beta6으로 진행되었습니다). npm latest는 계속 2.0.10입니다. four.htmx.org의 4.0 문서는 next 빌드를 추적하며, 4.0 extensions index에는 hx-livehx-nonce가 나열되어 있고, 4.0 migration guide에는 프로덕션 앱을 2.x에서 이전하기 전에 검토할 마이그레이션 변경 사항이 문서화되어 있습니다. 2026년 7월 24일 htmx.org npm dist-tags를 기준으로 확인했습니다. 

  22. 2026년 5월 24일 유지 관리 점검입니다. 로컬 인벤토리 명령은 Markdown 블로그 게시물 210개, 최상위 가이드 파일 11개, 디자인 스터디 파일 48개를 반환했습니다. FastAPI release notes에는 2026-05-23의 0.136.3이 나열되어 있으며, convert_underscores=True일 때 더 엄격한 underscore-header 처리를 적용합니다. 0.136.2는 Server-Sent Event 필드를 검증합니다. python3 -m pip index versions fastapi는 최신 0.136.3을 반환했고, python3 -m pip index versions sqlalchemy는 최신 2.0.50을 반환했으며, python3 -m pip index versions pydantic는 최신 2.13.4를 반환했습니다. npm view htmx.org dist-tags version time.modified --jsonlatest=2.0.10, next=4.0.0-beta4, time.modified=2026-05-22T15:56:21.948Z를 반환했습니다. four.htmx.org installation docs에는 htmx.org@4.0.0-beta4가 표시됩니다. 

  23. 2026-05-24에 릴리스된 SQLAlchemy 2.0.50 changelogrelease blog입니다. asyncio greenlet 의존성은 더 이상 기본으로 설치되지 않으며, 이를 설치하려면 이제 sqlalchemy[asyncio] 설치 대상을 사용해야 합니다. 2.0.50은 Python 3.7/3.8/3.9 지원도 중단하고(이제 3.10 이상), free-threaded Python wheels를 추가하며, over(..., exclude=...) window-frame 매개변수를 추가합니다. 2026-06-08 기준 PyPI에서 최신 버전임을 확인했습니다. htmx 4.0.0-beta4(“The Fetchening”, 2026-05-22)는 2027년 초 stable을 목표로 하는 beta 상태입니다. 이 기간에는 FastAPI 0.136.3(2026-05-23), Alpine.js 3.15.12, Bootstrap 5.3.x가 변경되지 않았습니다. 

  24. FastAPI release notes: 0.137.0(2026-06-14)은 router internals를 리팩터링하여 router.routes가 더 이상 APIRoute 객체의 flat list가 아니라 intermediate objects의 tree가 되도록 변경했습니다(internal로 취급하세요). 또한 include_router() 이후에 routes를 추가할 수 있고, routes가 정의되기 전에 sub-router를 포함할 수 있으며, routes 복사를 피하고, APIRouter.matches()/.handle()을 추가합니다. Starlette를 1.x로 pin하지는 않습니다. FastAPI의 runtime requirement는 starlette>=0.46.0이며, 상한이 없는 하한입니다. 이는 0.136.3, 0.137.0, 0.138.0, 0.139.2, 0.140.0에서 동일하며, 2026-07-25에 PyPI JSON APIrequires_dist metadata로 확인했습니다. 0.137.0 notes의 Internal 아래에 있는 “bump starlette from 1.1.0 to 1.2.1” 줄(PR #15722)은 repository의 uv.lock test lockfile만 건드리는 dependabot update입니다. (이전에는 상한이 존재했습니다. 0.120.4와 0.121.0은 starlette<0.50.0,>=0.40.0을 제공했지만 0.136.3에서 제거되었습니다.) 2026-07-25에 정정했습니다. 이 각주와 본문에 있던 이전 표현은 잘못되었습니다. 0.137.1(2026-06-15)은 APIRoute typing과 prefixless router의 빈 path를 수정합니다. Starlette release notes: 약 8년 만의 첫 stable release인 1.0.0(2026-03-22)은 on_startup/on_shutdown/on_event()@app.route()/@app.websocket_route() decorators를 제거했습니다(lifespanRoute/WebSocketRoute 사용). 최신 버전은 1.3.1(2026-06-12)입니다. SQLAlchemy 2.0.51(changelog, 2026-06-15)은 async나 install에 영향을 주지 않는 bug-fixes 전용 릴리스입니다. 2026-06-16에 PyPI와 공식 release notes를 통해 확인했습니다. 

  25. FastAPI release notes: 0.138.0(2026-06-20)은 빌드된 static frontend를 제공하기 위한 app.frontend("/", directory="dist")router.frontend("/", directory="dist")를 추가했습니다(PR #15800; Frontend docs). 이는 server-rendered pattern이 아닌 static dist/ SPA-serving 기능이며, breaking change는 없습니다. 0.137.2(2026-06-18)는 이전에 router.routes를 순회하던 advanced use를 위한 iter_route_contexts()를 추가했습니다(0.137.0부터 internal). breaking change는 없습니다. 2026-06-22 기준 0.138.0보다 새로운 릴리스는 없습니다. Starlette 1.3.1, Pydantic 2.13.4, Uvicorn 0.49.0, SQLAlchemy 2.0.51, HTMX 2.0.10, Alpine.js 3.15.12, Bootstrap 5.3.8은 모두 변경되지 않았습니다. 2026-06-22에 PyPI와 공식 release notes를 통해 확인했습니다. 

  26. FastAPI 0.139.0 release notes, 2026년 7월 1일: “Support dependencies in app.frontend(), e.g. for automatic cookie authentication for the frontend”(PR #15908). 나머지 릴리스는 번역, docs, dependency bumps로 구성되며 breaking changes는 없습니다. 현재 세션에서 2026년 7월 2일(PST)에 확인한 결과, 0.139.0은 GitHub releases page의 최신 릴리스입니다. 

  27. FastAPI 0.140.0 release notes는 2026년 7월 24일 21:16 UTC에 게시되었습니다(PyPI upload_time_iso_8601 2026-07-24T21:16:42Z). 유일한 refactor 항목은 “⚡️ Reduce memory usage in dependencies. PR #16049”입니다(2026-07-24T21:07:52Z 병합). 회귀는 2025-11-03에 병합되어 같은 날 0.121.0에 포함된 PR #14262에서 도입되었습니다. 이 PR은 Dependant.cache_keyfunctools.cached_property를 추가했고, 0.139.2에서 이 클래스는 @cached_property 정의 10개를 포함했습니다. 0.140.0에서 fastapi/dependencies/models.py@dataclass(slots=True) class Dependant를 선언하고 로직을 module-level _get_cache_key(), _get_oauth_scopes(), _uses_scopes(), _is_security_scheme()으로 옮겼습니다. 소스는 tag 0.140.0에서 확인했습니다. 병합된 PR의 CodSpeed bot은 test_dependency_graph memory benchmark가 17.5 MB(base)에서 1.1 MB(head)로 바뀌어 “improve performance by ×16”이라고 보고합니다. 0.140.0은 재발 방지를 위해 CI memory benchmark(PR #16046)도 추가했습니다. 원래 보고서는 discussion #14742이며, 여기서 0.120.4는 약 400 MB 미만을 유지했지만 0.121.3은 프로덕션에서 OOM이 발생했습니다. 도구 작성자를 위한 참고 사항: Dependant.oauth_scopes, .cache_key, ._uses_scopes, ._is_security_scheme은 더 이상 attributes로 존재하지 않으며, slots=True는 instances의 monkey-patching을 막습니다. 이는 이 가이드에서 사용하지 않는 문서화되지 않은 internal API이며, 0.137.0의 router.routes 변경과 같은 범주입니다. 모든 사실은 2026-07-25에 PyPI, GitHub API, tagged source를 기준으로 재확인했습니다. 

  28. Starlette 릴리스 1.4.0(2026-08-05), 1.5.0(2026-08-08), 1.6.0(2026-08-08)입니다. 1.5.0의 제목은 “This release is all about giving GZipMiddleware some love”이며, “Add exclude_content_types parameter to GZipMiddleware”, “Flush GZip output for each streamed chunk”, “Skip compression of partial responses in GZipMiddleware”, “Expand default excluded content types in GZipMiddleware”를 나열합니다. exclusion tuple과 signature는 starlette/middleware/gzip.py에서 직접 읽었습니다. DEFAULT_EXCLUDED_CONTENT_TYPES = application/gzip, application/x-gzip, application/zip, audio/, font/woff, font/woff2, image/avif, image/gif, image/jpeg, image/png, image/webp, text/event-stream, video/이며, def __init__(self, app, minimum_size=500, compresslevel=9, thread_minimum_size=128*1024, *, exclude_content_types=DEFAULT_EXCLUDED_CONTENT_TYPES)입니다. 모두 2026-08-16에 가져와 확인했습니다. 

  29. FastAPI 0.141.0(2026-07-29, 14:47 UTC)은 fastapi dev를 사용하는 로컬 개발용 app.frontend(check_dir="auto")를 추가했습니다(PR #16102). FastAPI 0.141.1(2026-07-29, 17:17 UTC)은 app.frontend()에서 dependencies의 background tasks와 headers 지원을 수정했고(PR #16105), FastAPI CLI guide에 FASTAPI_ENV를 문서화했습니다(PR #16104). 둘 다 @tiangolo가 작업했습니다. PyPI 최신 버전은 2026-07-29에 0.141.1로 확인되었습니다. 

  30. FastAPI 릴리스 0.140.1부터 0.140.7까지는 모두 2026-07-27 12:07~17:34 UTC에 게시되었습니다(PyPI upload_time_iso_8601: 0.140.1 12:07:51Z, 0.140.2 14:15:38Z, 0.140.3 15:30:52Z, 0.140.4 15:46:49Z, 0.140.5 16:02:53Z, 0.140.6 16:31:48Z, 0.140.7 17:34:47Z). 각 릴리스 본문에는 Refactors 항목 하나가 있습니다. 0.140.1 “Update the lru_cache limit for dependencies to account for large apps”(PR #16062), 0.140.2 “Stop retaining flat dependency trees”(PR #16065), 0.140.3 “Avoid repeated dependency flattening in OpenAPI”(PR #16067), 0.140.4 “Skip unused dependency repeat bookkeeping”(PR #16069), 0.140.5 “Avoid flattening dependencies for body fields”(PR #16071), 0.140.6 “Avoid flattening dependencies for request parameters, mainly for OpenAPI”(PR #16073), 0.140.7 “Avoid flattening dependencies for OpenAPI”(PR #16076)입니다. cache figure는 #16062 diff에서 가져왔습니다. 이 diff는 fastapi/dependencies/models.py@lru_cache(maxsize=1024) decorators 3개를 @lru_cache(maxsize=_CALLABLE_CLASSIFICATION_CACHE_SIZE)로 바꾸고, tests/test_dependency_models.py를 업데이트하여 cache_info.maxsize == 4096을 검증합니다. PR 본문에는 “Some users reported a number of dependencies larger than 1024, this should account for larger apps.”라고 적혀 있습니다. 0.140.2는 memory benchmark(PR #16064)도 추가했고, 0.140.7은 OpenAPI dependency benchmarks(PR #16075)를 추가했습니다. 따라서 benchmark coverage는 시리즈 대부분 이후에 추가되었습니다. 2026-07-27에 GitHub releases API, PR diffs, PyPI를 기준으로 확인했으며, 작성 당시 0.140.7이 최신 릴리스였습니다. 

NORMAL fastapi-htmx.md EOF