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

FastAPI + HTMX: 빌드 없는 풀스택

# FastAPI + HTMX: 빌드 없는 풀스택

words: 5432 read_time: 28m updated: 2026-03-25 08:23
$ less fastapi-htmx.md

TL;DR: FastAPI + HTMX + Alpine.js + Bootstrap 5 + Jinja2 + 순수 CSS만으로 빌드 도구 없이, node_modules/ 없이, Lighthouse 만점을 달성하는 프로덕션 웹 애플리케이션을 만들 수 있어요. 이 가이드에서는 아키텍처부터 배포까지 전체 시스템을 다루며, 번들러, 컴파일러, 트랜스파일러 단 하나 없이 37개의 블로그 포스트, 20개의 인터랙티브 JavaScript 컴포넌트, 20개의 가이드, 10개 언어 번역을 제공하는 blakecrosley.com을 프로덕션 레퍼런스로 활용해요.1

현대 웹 개발 스택은 React, webpack, TypeScript, 그리고 빌드 파이프라인이 필요하다고 전제해요. 하지만 콘텐츠 중심 사이트, 내부 도구, CRUD 애플리케이션, 포트폴리오 사이트, 문서 플랫폼 등 상당히 넓은 범주의 애플리케이션에서 그 전제는 틀렸어요. 이 가이드에서 설명하는 스택은 프론트엔드 빌드 툴체인 전체를 제거하면서도 Lighthouse 100/100/100/100 점수를 달성하는 사이트를 만들어요.2

이건 주장이 아니에요. 측정 결과예요. 여기서 설명하는 아키텍처는 실제 프로덕션에서 운영되고 있고, 10개 언어로 실제 사용자에게 서비스하고 있으며, 수치는 검증 가능해요.


핵심 요약

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

이 가이드 활용법

이 문서는 포괄적인 레퍼런스예요. 본인의 경험 수준에 맞는 곳부터 시작하세요:

경험 수준 시작 지점 추가 탐색
Python 개발자, HTMX 입문 노빌드 테제아키텍처 개요HTMX 심화 Alpine.js 패턴, 보안
React/Vue 개발자, 대안 평가 중 노빌드 테제의사결정 프레임워크 아키텍처 개요, 성능
FastAPI 개발자, 인터랙티브 기능 추가 HTMX 심화Alpine.js 패턴 i18n과 로컬라이제이션, 배포
풀스택 개발자, 처음부터 구축 아키텍처 개요부터 순서대로 읽기 지속적으로 참고할 빠른 레퍼런스 카드

Ctrl+F / Cmd+F로 특정 패턴이나 속성을 검색하세요. 문서 끝의 빠른 레퍼런스 카드에서 한눈에 볼 수 있는 요약을 제공해요.


노빌드 테제

이 테제는 좁고 구체적이에요: 솔로 개발자나 소규모 팀이 운영하는 콘텐츠 중심 사이트에서는, 빌드 도구가 존재하지 않는 문제를 해결하면서 실제 문제를 만들어내요.

blakecrosley.com의 실제 수치를 확인해 보세요:

지표 blakecrosley.com (노빌드) 일반적인 Next.js 프로젝트3
의존성 Python 패키지 15개 npm 패키지 311개 이상
빌드 설정 파일 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 성능 100 명시적 최적화 없이 70-904

15개의 Python 패키지에는 FastAPI, Jinja2, Pydantic, uvicorn, nh3 및 기타 10개가 포함돼요. 빌드 도구는 하나도 없어요. 컴파일러도 없고, 번들러도 없어요.5

포기해야 하는 것들

정직하게 실제 비용을 나열해야 해요:

TypeScript가 없어요. 모든 .js 파일은 바닐라 JavaScript예요. 타입 오류는 컴파일러가 아닌 테스트와 코드 분석으로 잡아요. 솔로 개발자에게는 충분하지만, 컴포넌트 인터페이스를 공유하는 10명의 팀에서는 적합하지 않아요.

Hot Module Replacement가 없어요. CSS 변경 시 브라우저를 수동으로 새로고침해야 해요. HTMX의 hx-boost가 내비게이션을 충분히 빠르게 만들어 전체 새로고침이 견딜 만하지만, 빠른 시각적 반복 작업에서는 HMR이 시간을 절약해 줘요.

트리 셰이킹이 없어요. 작성한 모든 JavaScript 바이트가 브라우저에 전송돼요. 이 제약이 규율을 강제해요: 큰 유틸리티 모듈 대신 작고 집중된 파일을 작성하게 돼요.

npm 컴포넌트 라이브러리가 없어요. Radix, shadcn/ui, Headless UI를 사용할 수 없어요. 모든 인터랙티브 요소는 직접 만들거나 Bootstrap 5의 내장 컴포넌트를 사용해요.

npm의 디자인 시스템 토큰이 없어요. 디자인 시스템은 CSS 커스텀 속성에 존재해요. 다른 프로젝트에서 패키지로 가져올 수 없어요.

이러한 트레이드오프는 1~3명의 개발자가 운영하는 콘텐츠 중심 사이트에서는 수용할 만해요. 15명의 엔지니어링 팀이 개발하는 SaaS 제품에서는 수용할 수 없을 거예요. 15장에서 의사결정 프레임워크를 제공해요.

얻는 것들

빌드 실패가 없어요. 피어 의존성 충돌로 npm install이 실패하는 일도, 건드리지 않은 파일의 TypeScript 오류로 next build가 실패하는 일도 없어요.6

소스 보기로 디버깅할 수 있어요. 브라우저에서 실행되는 JavaScript가 본인이 작성한 JavaScript 그 자체예요. 소스 맵이 필요 없어요.

로컬 서버가 즉시 시작돼요. uvicorn app.main:app --reload가 2초 이내에 시작돼요.

명확한 요청 워터폴. 첫 방문 시 로드되는 항목: HTML 문서 1개 (gzip 압축 시 약 15KB), CSS 파일 1개 (약 8KB), HTMX (약 14KB, 캐시됨), Alpine.js (약 14KB, 캐시됨), 그리고 페이지의 인터랙티브 JS (약 4-8KB). 첫 방문 총합: 45-60KB.1

미래에도 안전한 프론트엔드. 클라이언트 측 코드는 HTML, CSS, JavaScript를 사용해요 — 30년간 하위 호환성을 유지해 온 웹 표준이에요.7 Webpack 4 → 5 마이그레이션도, Create React App 지원 중단도, Next.js App Router 마이그레이션도 없어요.


아키텍처 개요

요청 흐름

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

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% 줄여줘요.

라우팅

라우트는 두 가지 카테고리로 나뉘어요: 페이지 라우트는 전체 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에 의존하고, 이것이 다시 request에 의존할 수 있어요. 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 클래스는 시작 시 타입을 검증하므로 필수 필드가 누락되면 런타임이 아닌 시작 시점에 즉시 실패해요.

비동기 패턴

FastAPI 라우트는 기본적으로 비동기예요. I/O 바운드 작업(데이터베이스 쿼리, HTTP 요청, 파일 읽기)에서 async는 이벤트 루프 블로킹을 방지해요:

@app.on_event("startup")
async def startup_load_translations():
    """Load translations from D1 into memory at startup."""
    client = init_d1_client(
        worker_url=settings.D1_WORKER_URL,
        auth_secret=settings.D1_AUTH_SECRET,
    )
    if not client.is_configured:
        logger.warning("i18n: D1 not configured, translations use defaults")
        return
    cache = await load_translations(client)
    logger.info(f"i18n: Loaded {cache.stats['key_count']} keys")

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>, 헤더, 푸터, 스크립트 태그 등 나머지는 모두 기본 템플릿에서 가져와요. 이건 구성(construction)이 아닌 빼기(subtraction)를 통한 합성이에요.

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을 단일 응답으로 보내요.

blakecrosley.com에서는 연락처 폼에 이 패턴이 적용되어 있어요: 폼을 제출하면 폼 본문이 성공 메시지로 교체되고, 동시에 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> 콘텐츠만 변경돼요. blakecrosley.com은 메인 내비게이션에 부스트 링크를 사용하고 있어서, 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이 사용 가능할 때 경험을 향상시켜요.

패턴 5: 로딩 상태

<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를 사용해요.


Sass 없이 Bootstrap 5 사용하기

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)에 저장되고 시작 시 인메모리 캐시에 로드돼요:

@app.on_event("startup")
async def startup_load_translations():
    client = init_d1_client(worker_url=settings.D1_WORKER_URL,
                            auth_secret=settings.D1_AUTH_SECRET)
    cache = await load_translations(client)
    logger.info(f"i18n: Loaded {cache.stats['key_count']} keys")

메모리 캐시는 매 페이지 렌더링마다 데이터베이스를 조회하지 않아도 되게 해줘요. 번역 업데이트 시 캐시 갱신이 필요한데, 관리자 엔드포인트나 배포를 통해 트리거돼요. 이 아키텍처는 최신성보다 성능을 우선시해요 — 번역은 자주 변경되지 않지만, 페이지 렌더링은 모든 요청마다 발생하기 때문이에요.

상태 모니터링

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시간 동안 캐시돼요.


데이터베이스 패턴

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

데이터베이스 세션을 위한 의존성 주입

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(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 의존성은 세션 생명주기를 관리해요: 세션을 열고, 라우트 핸들러에 yield하고, 성공 시 커밋하고, 예외 발생 시 롤백해요. 모든 데이터베이스 작업은 파라미터화된 쿼리를 사용하며, 절대 문자열 보간을 사용하지 않아요.

Pydantic 통합

Pydantic 모델은 API 경계에서 입력을 검증하고 템플릿용 출력을 직렬화해요:

from pydantic import BaseModel, EmailStr

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

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

Pydantic은 라우트 핸들러가 실행되기 전에 타입, 형식(이메일, URL), 제약 조건(최소/최대 길이)을 검증해요. 유효하지 않은 입력은 자동으로 422 응답을 반환해요. 이 방식이 클라이언트 측 폼 검증 라이브러리를 대체하며, 서버가 검증하고 HTMX이 성공 메시지나 오류 피드백을 스왑해요.

Alembic을 활용한 마이그레이션

Alembic은 데이터베이스 스키마 변경을 관리해요:

# 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 모델을 현재 데이터베이스 스키마와 비교해서 마이그레이션 스크립트를 생성해요. 이 스크립트들은 저장소에 포함되는 버전 관리된 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")

마이그레이션은 배포 시(애플리케이션 시작 전에) 실행돼요. 이를 통해 데이터베이스 스키마가 애플리케이션 코드와 일치하도록 보장해요. blakecrosley.com의 경우 대부분의 데이터는 Cloudflare D1(HTTP를 통해 접근)에 저장되므로, Alembic 마이그레이션은 세션 데이터와 분석에 사용되는 로컬 SQLite 또는 PostgreSQL 데이터베이스에 적용돼요.

Cloudflare D1 패턴

blakecrosley.com은 Cloudflare Worker 프록시를 통해 접근하는 원격 데이터베이스로 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의 엣지에서 동작하는 SQLite이며, HTTP를 통해 접근해요. Worker 프록시가 인증과 속도 제한을 처리해요. 트레이드오프는 지연 시간이에요: 모든 쿼리가 HTTP 요청(~50-100ms)인 반면, 로컬 데이터베이스 연결은 ~1-5ms예요. 시작 시 메모리 내 캐시가 번역과 같은 읽기 중심 워크로드에서 이 문제를 완화해요.


보안

보안 헤더 미들웨어

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에 'unsafe-inline''unsafe-eval'이 포함되어 있는 이유는 Alpine.js의 표현식 평가에 필요하기 때문이에요. 대안은 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의 네 가지 카테고리(성능, 접근성, 모범 사례, SEO)에서 모두 100점을 기록하고 있어요. PageSpeed Insights에서 직접 확인할 수 있어요.2

핵심 최적화 항목은 다음과 같아요:

크리티컬 CSS

크리티컬(스크롤 없이 보이는 영역) CSS를 추출해서 <head>에 인라인으로 삽입해요. 전체 스타일시트는 비동기로 로드돼요:

<!-- Critical CSS inlined for instant first paint -->
<style>{% include "components/_critical.css" %}</style>

<!-- Full CSS loads async — doesn't block render -->
<link rel="stylesheet" href="/static/css/styles.css"
      media="print" onload="this.media='all'">
<noscript>
  <link rel="stylesheet" href="/static/css/styles.css">
</noscript>

media="print" 기법은 브라우저에게 이 스타일시트가 화면 렌더링에 필요하지 않다고 알려줘서 첫 번째 페인트를 차단하지 않아요. onload 핸들러가 로딩 완료 후 media="all"로 전환해요. <noscript> 폴백은 JavaScript가 없는 환경에서도 스타일시트가 로드되도록 보장해요.16

GZip 압축

app.add_middleware(GZipMiddleware, minimum_size=500)

500바이트를 초과하는 응답은 압축돼요. HTML는 70-80% 압축되므로 15KB 문서가 3-4KB로 줄어들어요.

불변 정적 에셋 캐싱

# 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 파싱과 병렬로 다운로드하되, 문서 파싱이 완료된 후에 실행해요. 비동기 로딩과 실행 순서 관리의 복잡성 없이 렌더링 차단을 방지해요.

이미지 최적화

이미지는 반응형 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% 작은 파일 크기를 제공해요.17

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를 미리 가져올 수 있게 해요.18

최소한의 JavaScript

전체 JavaScript 용량:

라이브러리 크기 (minified + gzipped)
HTMX ~14 KB
Alpine.js ~14 KB
페이지별 JS 4-8 KB
합계 32-36 KB

일반적인 React 애플리케이션은 애플리케이션 코드 이전에 프레임워크 JavaScript만 100-300 KB를 전송해요.19 노빌드 접근 방식은 전송할 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 프로젝트를 감지하고 의존성을 설치한 뒤 시작 명령을 실행해요. Docker파일이 필요 없어요. 헬스 체크 엔드포인트는 트래픽을 수신하기 전에 애플리케이션이 응답 가능한 상태인지 확인해요:

@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는 네 개의 워커 프로세스를 실행해요 (일반적인 규칙: CPU 코어 수 * 2 + 1)
  • --loop uvloop는 더 빠른 uvloop 이벤트 루프를 사용해요 (asyncio의 드롭인 대체)
  • --http httptools는 더 빠른 httptools HTTP 파서를 사용해요

개발 시에는 --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을 적용해요.


결정 프레임워크

빌드 도구가 필요한가요?

네 가지 질문에 답해 보세요:

1. 다섯 명 이상의 개발자가 JavaScript 인터페이스를 공유하나요? 그렇다면, TypeScript의 컴파일 타임 타입 체크가 런타임 테스트로는 너무 늦게 발견되는 통합 버그를 방지해줘요. 빌드 단계를 추가하세요.

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

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

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

네 가지 답이 모두 “아니오”라면 노빌드 접근 방식이 가능해요. 하나라도 “예”라면 빌드 도구가 실제 문제를 해결해 주는 거예요. 실수는 네 가지 답이 모두 “아니오”인데 빌드 도구를 추가하는 것이에요 — 없는 문제를 해결하면서 의존성 관리 오버헤드만 만드는 셈이죠.1

스택 비교

카테고리 노빌드 (이 가이드) React + 빌드 도구
적합한 용도 콘텐츠 사이트, 포트폴리오, 내부 도구, CRUD 앱 SaaS 제품, 복잡한 SPA, 디자인 시스템 소비자
팀 규모 1-5명 5-50명 이상
상태 관리 서버 (HTMX) + 클라이언트 (Alpine.js) 클라이언트 (React state, Redux, Zustand)
타입 안전성 런타임 (Pydantic 서버 사이드) 컴파일 타임 (TypeScript)
컴포넌트 재사용 Jinja2 includes + macros npm 패키지, 공유 라이브러리
SEO 기본적으로 서버 렌더링 SSR/SSG 설정 필요
성능 하한선 높음 (최소한의 JS, 서버 렌더링) 다양함 (프레임워크 오버헤드)
복잡성 상한선 낮음 (오프라인, 리치 클라이언트 상태 불가) 높음 (모든 클라이언트 인터랙션 가능)
의존성 15개 Python 패키지 300개 이상 npm 패키지
빌드 시간 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

FAQ

HTMX는 실제 웹 애플리케이션에 프로덕션 환경에서 사용할 수 있나요?

네. HTMX는 2020년부터 안정적으로 유지되고 있으며, 여러 산업 분야에서 프로덕션 환경으로 사용되고 있어요. 제작자인 Carson Gross는 하위 호환성을 핵심 설계 원칙으로 유지하고 있으며, HTMX 문서에는 메이저 버전 내에서 기존 애플리케이션을 깨뜨리지 않겠다고 명시되어 있어요.20 이 라이브러리는 minified + gzip 기준 14KB이고, 의존성이 전혀 없으며, 시맨틱 버전 관리를 따르고 있어요. blakecrosley.com은 3년간 프로덕션에서 HTMX를 운영하면서 HTMX 관련 버그가 단 한 건도 없었어요.

빌드 단계 없이 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는 총 30-40KB의 JavaScript를 전송하는 반면, Next.js 애플리케이션은 100-300KB를 전송해요.19

이 스택에서 폼 유효성 검사는 어떻게 하나요?

서버 사이드에서 처리해요. 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 확장(hx-ws)이 있어서 엘리먼트를 WebSocket 엔드포인트에 연결할 수 있어요:

<div hx-ws="connect:/ws/notifications">
  <div id="notifications" hx-ws="send"></div>
</div>

서버에서 보낸 메시지는 HTTP 응답과 동일한 타겟팅 및 스왑 메커니즘을 사용해서 DOM에 삽입돼요. 서버가 WebSocket를 통해 HTML 프래그먼트를 전송하면 HTMX가 이를 삽입해요.

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

서버 렌더링된 HTML는 크롤러가 JavaScript를 실행하지 않고도 전체 페이지 콘텐츠를 받을 수 있기 때문에 본질적으로 SEO 친화적이에요. blakecrosley.com은 여러 SEO 레이어를 추가하고 있어요:

  • JSON-LD 구조화 데이터를 모든 페이지의 <head>에 포함 (Person, Article, WebSite, FAQPage 스키마)
  • 동적 사이트맵에 10개 로케일 전체의 hreflang 대체 태그 포함
  • /blog/feed.xmlRSS 피드
  • 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-03-24 최초 발행

참고 문헌


이 가이드는 blakecrosley.com을 구축하는 데 사용된 전체 시스템을 다루고 있어요. No-Build Manifesto에서 철학적 근거를 확인할 수 있고, Lighthouse Perfect Score 포스트에서 성능 최적화 과정을 확인할 수 있어요. Vibe Coding vs. Engineering 포스트에서는 AI 지원 개발이 이 워크플로에 어떻게 적용되는지 살펴볼 수 있어요.


  1. 2026년 3월 기준 blakecrosley.com 프로덕션 지표. 이 사이트는 37개의 블로그 포스트, 20개의 인터랙티브 JavaScript 컴포넌트, 20개의 가이드 섹션, 10개 언어 번역을 15개의 Python 패키지와 빌드 도구 없이 제공하고 있어요. 전체 의존성 목록: fastapi, uvicorn, starlette, pydantic, pydantic-settings, jinja2, markdown, pygments, beautifulsoup4, lxml, nh3, resend, python-multipart, httpx, analytics-941. requirements.txt에서 확인했어요. 

  2. Google PageSpeed Insights(pagespeed.web.dev)는 공개 URL에 대해 Lighthouse 감사를 실행해요. blakecrosley.com은 2026년 3월 기준 100/100/100/100(Performance, Accessibility, Best Practices, SEO) 점수를 기록하고 있어요. 결과는 누구나 직접 확인할 수 있어요. 전체 최적화 과정은 From 76 to 100: Achieving a Perfect Lighthouse Score에서 확인하세요. 

  3. npx create-next-app@latest(Next.js 15, 2026년 2월 테스트)를 새로 실행하면 node_modules/에 311개 패키지가 설치되며 총 187 MB를 차지해요. 추가 의존성이 있는 프로덕션 프로젝트는 이보다 더 커지는 경향이 있어요. 프로젝트마다 차이가 있을 수 있어요. 출처: 저자 테스트, The No-Build Manifesto에 문서화. 

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

  5. 2026년 3월 기준 blakecrosley.com의 requirements.txt에서 확인한 전체 의존성 목록이에요. 빌드 도구, 컴파일러, 번들러는 하나도 없어요. 

  6. 저자가 Next.js 프로젝트를 유지보수한 경험(2021-2024)에 따르면, JavaScript 생태계에서 활발한 프로젝트는 월 15-25개의 Dependabot PR이 생성되며, 대부분은 개발자가 직접 설치한 적 없는 전이적 의존성의 업데이트예요. 

  7. Tim Berners-Lee는 하위 호환성을 웹 설계 원칙으로 명시했어요: “브라우저는 하위 호환성이 있어야 한다.” 1996년에 만든 페이지가 2026년 Chrome에서도 렌더링돼요. w3.org/DesignIssues/Principles를 참고하세요. 

  8. OWASP는 공격 표면을 줄이기 위해 프로덕션 환경에서 API 문서 엔드포인트를 비활성화할 것을 권장해요. /openapi.json 엔드포인트는 모든 라우트 정의, 파라미터, 응답 모델을 노출해요. 

  9. FastAPI async vs sync 핸들러 문서: fastapi.tiangolo.com/async/. async 함수에서 await와 블로킹 호출을 혼합하면 이벤트 루프가 고갈돼요. 

  10. nh3는 Rust 기반 HTML 새니타이저로, Bleach 라이브러리의 후속작이에요. PyO3 프로젝트에서 유지보수하며 허용 목록 기반 HTML 새니타이제이션을 제공해요. github.com/messense/nh3를 참고하세요. 

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

  12. CSS Custom Properties(CSS Variables)는 전 세계 브라우저의 97% 이상에서 지원돼요. 캐스케이드, 상속, 미디어 쿼리에 대한 런타임 반응 등 프리프로세서 변수에는 없는 기능을 제공해요. 출처: 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. media="print" 비동기 CSS 로딩 기법은 web.dev 팀이 문서화했어요. 브라우저는 스타일시트가 인쇄 미디어로 선언되어 있어 렌더링을 차단하지 않는 것으로 처리해요. onload 핸들러가 다운로드 완료 후 all 미디어로 업그레이드해요. web.dev/articles/defer-non-critical-css를 참고하세요. 

  17. WebP는 동등한 시각적 품질에서 JPEG보다 25-35% 더 작은 파일을 제공해요. Google의 WebP 연구: developers.google.com/speed/webp/docs/webp_study

  18. 103 Early Hints를 사용하면 서버(또는 CDN)가 최종 응답이 준비되기 전에 프리로드 힌트가 포함된 예비 응답을 보낼 수 있어요. Cloudflare는 rel=preload가 포함된 Link 헤더에 대해 Early Hints를 지원해요. developer.chrome.com/blog/early-hints를 참고하세요. 

  19. React 18 + ReactDOM은 minified + gzipped 기준 약 42 KB예요. 라우터, 상태 관리 라이브러리, 빌드 프레임워크 런타임을 포함하면 일반적인 React 애플리케이션은 100-300 KB의 프레임워크 JavaScript를 전송해요. 출처: bundlephobia.com/package/[email protected]

  20. HTMX 버전 관리 정책과 하위 호환성 약속은 htmx.org/migration-guide-htmx-1/에 문서화되어 있어요. Carson Gross는 Hypermedia Systems(2023, Gross, Stepinski, Cotter 공저)에서 하위 호환성 원칙을 밝힌 바 있어요: hypermedia.systems

NORMAL fastapi-htmx.md EOF