개인정보처리방침© 2026 DEV BAK - 기술블로그. All rights reserved.
DEV BAK - 기술블로그
frontend

HTMX 4.0 서버 렌더링 패턴: 클라이언트 상태 없이 인터랙티브 웹을 만드는 아키텍처 선택

React를 처음 배울 때, 저는 솔직히 이 질문을 머릿속에 묻어뒀습니다. "버튼 클릭 하나에 이렇게 많은 코드가 필요한 게 맞는 건가?" 상태 관리, 빌드 도구, hydration 오류... 분명 웹은 HTML과 폼으로 동작하던 시절이 있었는데, 어느 순간 그 단순함을 잃어버렸다는 느낌이 들었습니다. React 경험이 없는 백엔드 개발자분들도 걱정 없습니다. "지금까지 서버에서 전체 페이지를 렌더링하던 방식"이 익숙하다면, HTMX는 오히려 더 자연스럽게 느껴질 수 있습니다.

HTMX는 그 질문에 정면으로 답합니다. 14KB짜리 라이브러리 한 줄이면, npm도 빌드 도구도 번들러도 없이 hx-get, hx-post 같은 HTML 속성 몇 개만으로 AJAX 요청, 무한 스크롤, 인라인 편집까지 구현됩니다. 현재 안정 버전은 2.x이고, 2025년 하반기부터 알파가 공개된 4.0은 내부적으로 XMLHttpRequest에서 현대적인 fetch() API로 전환하며 DOM Morphing과 View Transitions API 지원을 추가하는 큰 업데이트를 준비 중입니다. 이 글에서는 지금 당장 쓸 수 있는 2.x 문법을 기준으로 설명합니다.

이 글은 HTMX의 핵심 인터랙션 패턴을 실제 코드와 함께 살펴보고, 어떤 프로젝트에서 진지하게 고려해볼 만한 선택지인지 판단할 수 있는 기준을 정리합니다. 처음부터 따라하는 설치 가이드라기보다, 이미 서버사이드 개발 경험이 있는 분이 "내 프로젝트에 써도 되나?"를 판단하는 데 초점을 맞췄습니다. Django 중심의 예시를 주로 쓰지만, Rails라면 render partial:, Go라면 templ 컴포넌트로 동일하게 적용할 수 있습니다.


핵심 개념

HTMX가 다르게 동작하는 이유 — HDA 패턴

HTMX는 단순한 AJAX 헬퍼가 아닙니다. 그 철학은 HDA(Hypermedia-Driven Application) 패턴에 뿌리를 두고 있습니다. REST의 HATEOAS 원칙을 실제로 구현하는 개념인데, 말이 좀 어렵게 들리지만 실제로는 아주 단순합니다.

HATEOAS(Hypermedia as the Engine of Application State): 서버가 HTML 링크와 폼을 통해 "다음에 할 수 있는 것"을 알려주는 방식. 클라이언트는 서버가 준 HTML 그대로를 보여주기만 합니다.

SPA에서는 서버가 JSON을 내려주고, 클라이언트가 그 데이터를 받아서 상태를 직접 관리하고 화면을 그립니다. 전통적인 서버 렌더링에서는 서버가 완성된 HTML 페이지 전체를 내려주지만 매번 전체 페이지가 새로고침됩니다. HTMX는 그 중간 어딘가에 있습니다. 서버가 **완성된 HTML 조각(fragment)**을 반환하고, 브라우저는 그 HTML을 DOM의 지정된 위치에 끼워 넣기만 합니다. 전체 페이지를 다시 로드하지 않으면서도, 클라이언트에 JavaScript 상태가 없습니다.

핵심 속성 한눈에 보기

HTMX의 모든 인터랙션은 hx-* 속성 조합으로 표현됩니다. 저도 처음엔 어떤 속성이 뭘 하는지 헷갈렸는데, 역할을 세 가지로 나눠서 보면 훨씬 명확해집니다.

역할 속성 설명
요청 hx-get / hx-post / hx-delete 어떤 HTTP 요청을 어디로 보낼지
배치 hx-target / hx-swap 응답 HTML을 어디에, 어떻게 넣을지
트리거 hx-trigger 언제 요청을 보낼지
URL 관리 hx-push-url 브라우저 히스토리 URL 갱신
점진적 향상 hx-boost 기존 <a>, <form>을 HTMX 방식으로 자동 업그레이드. 전체 페이지 이동 대신 콘텐츠 영역만 교체하도록 기존 링크를 일괄 변환할 때 편리합니다

hx-swap의 주요 옵션을 살펴보면, innerHTML(기본값: 대상 요소의 내부를 교체), outerHTML(대상 요소 자체를 교체), beforeend(대상 요소 끝에 추가) 같은 방식으로 DOM 업데이트 위치를 세밀하게 제어할 수 있습니다.

이 글에서 다루는 패턴:

  • 예시 1: 라이브 검색
  • 예시 2: 무한 스크롤
  • 예시 3: 인라인 편집
  • 예시 4: Alpine.js 조합

실전 적용

예시 1: 라이브 검색 — 타이핑할 때마다 결과 업데이트

실무에서 자주 맞닥뜨리는 상황인데, React였다면 useState + useEffect + debounce 훅 조합이 필요했을 겁니다. HTMX에서는 HTML 속성 하나로 처리됩니다.

html
<input
  hx-get="/search"
  hx-trigger="keyup changed delay:300ms"
  hx-target="#results"
  name="q"
  placeholder="검색어 입력..."
/>
<div id="results"></div>
속성 역할
hx-get="/search" 키 입력마다 /search?q=...로 GET 요청
hx-trigger="keyup changed delay:300ms" 값이 실제로 변경됐을 때만, 300ms 디바운스 적용
hx-target="#results" 응답 HTML을 #results 안에 교체

서버(Django 기준)에서는 HTML 조각만 반환합니다. JSON이 아닙니다.

python
# Django view
def search(request):
    q = request.GET.get("q", "")
    items = Item.objects.filter(name__icontains=q)
    return render(request, "partials/search_results.html", {"items": items})
html
<!-- partials/search_results.html -->
{% for item in items %}
  <li>{{ item.name }}</li>
{% endfor %}

Rails라면 render partial: "search_results", locals: { items: @items }, Go라면 templ 컴포넌트로 동일한 구조입니다.

핵심 포인트: useEffect, debounce 유틸리티, 상태 관리 라이브러리가 전혀 없습니다. 서버가 HTML을 돌려주고, 브라우저가 그걸 갖다 놓습니다.

한 가지 꼭 짚고 싶은 게 있습니다. 서버가 HTML fragment를 반환할 때 XSS를 조심해야 합니다. Django의 {{ item.name }}은 자동 이스케이프되므로 안전하지만, mark_safe()나 |safe 필터를 남용하면 위험합니다. Rails의 <%= %>, Go의 html/template도 기본 이스케이프가 켜져 있습니다. 어떤 프레임워크를 쓰든 자동 이스케이프를 기본으로 유지하는 것이 중요한데, HTMX를 처음 쓰는 분들이 이 부분을 가볍게 넘어가는 경우를 가끔 봐서 일부러 강조해둡니다.


예시 2: 무한 스크롤 — 뷰포트 진입 감지

페이지네이션 버튼 없이 스크롤만으로 다음 페이지를 로드하는 패턴입니다. Intersection Observer API를 직접 다뤄봤다면 얼마나 코드가 줄어드는지 체감하실 겁니다.

처음 페이지가 렌더링될 때 <ul> 안에 첫 항목들과 함께 로딩 트리거 div를 포함시키고, 이후 페이지 fragment는 <li> 항목들과 다음 로딩 div를 함께 반환하는 구조입니다.

html
<!-- 초기 페이지: <ul> 안에 첫 페이지 항목 + 로딩 div -->
<ul id="post-list">
  <li>게시물 1</li>
  <li>게시물 2</li>
  <!-- ... 10개 -->
 
  <div
    hx-get="/posts?page=2"
    hx-trigger="revealed"
    hx-swap="outerHTML"
    hx-target="this">
    <span>로딩 중...</span>
  </div>
</ul>
속성 역할
hx-trigger="revealed" 이 요소가 뷰포트에 들어오는 순간 요청 발동
hx-swap="outerHTML" 로딩 div 자체를 응답 HTML로 통째 교체

서버는 다음 페이지 <li> 항목들과 또 다른 로딩 div(page=3)를 함께 반환합니다. 재귀적으로 계속 동작하는 구조라, 이 패턴을 처음 봤을 때 "이게 이렇게 깔끔할 수가 있나"라는 생각이 바로 들었습니다.

html
<!-- 서버 응답: page=2 fragment -->
<li>게시물 11</li>
<li>게시물 12</li>
<!-- ... -->
 
<!-- 다음 페이지를 위한 새 로딩 div -->
<div
  hx-get="/posts?page=3"
  hx-trigger="revealed"
  hx-swap="outerHTML"
  hx-target="this">
  <span>로딩 중...</span>
</div>

핵심 포인트: 로딩 div가 자기 자신을 다음 페이지 fragment로 교체하는 재귀 구조입니다. 마지막 페이지에서는 서버가 새 로딩 div 없이 <li>만 반환하면 자연스럽게 무한 스크롤이 종료됩니다.


예시 3: 인라인 편집 — 클릭하면 폼, 저장하면 다시 텍스트

제가 HTMX를 처음 봤을 때 가장 인상적이었던 패턴입니다. 클라이언트 상태 없이 인라인 편집이 구현됩니다. "어? 이게 되네?"라는 반응이 딱 나왔습니다.

html
<!-- 읽기 모드 -->
<span
  hx-get="/items/1/edit"
  hx-trigger="dblclick"
  hx-target="this"
  hx-swap="outerHTML">
  편집할 텍스트입니다
</span>

더블클릭하면 서버가 <form> HTML을 반환하고, 그 폼이 <span> 자리를 대체합니다.

html
<!-- 서버가 반환하는 편집 폼 -->
<form hx-put="/items/1" hx-target="this" hx-swap="outerHTML">
  <input type="text" name="text" value="편집할 텍스트입니다">
  <button type="submit">저장</button>
  <button hx-get="/items/1" hx-target="this" hx-swap="outerHTML">취소</button>
</form>

저장하면 서버가 업데이트된 <span>을 돌려줘서 다시 읽기 모드로 전환됩니다. 취소 버튼도 원래 <span>을 요청해서 폼을 없앱니다. 클라이언트에 "현재 편집 중인 아이템" 같은 상태가 전혀 없습니다.

핵심 포인트: 읽기 모드 → 편집 모드 → 저장/취소 → 읽기 모드 전이가 모두 서버가 반환하는 HTML 조각으로 이루어집니다. 클라이언트 상태 변수가 하나도 없는 구조입니다.


예시 4: Alpine.js와 함께 — 클라이언트 인터랙션 보완

HTMX만으로는 서버 왕복 없이 처리해야 하는 순수 클라이언트 인터랙션(드롭다운 열기/닫기, 아코디언, 탭 전환 등)이 어색합니다. 이때 Alpine.js(~15KB)를 조합하는 패턴이 실무에서 가장 많이 쓰입니다.

html
<!-- Alpine.js로 드롭다운 토글, HTMX로 데이터 로드 -->
<div x-data="{ open: false }">
  <button @click="open = !open">필터 열기</button>
 
  <div x-show="open">
    <select
      hx-get="/items"
      hx-trigger="change"
      hx-target="#item-list"
      hx-include="[name='search']"
      name="category">
      <option value="">전체</option>
      <option value="frontend">프론트엔드</option>
      <option value="backend">백엔드</option>
    </select>
  </div>
</div>
 
<input type="text" name="search" placeholder="검색어..." />
<div id="item-list">
  <!-- HTMX가 서버에서 가져온 목록 -->
</div>

hx-include="[name='search']" 부분이 중요합니다. <select>가 폼 태그 바깥에 있는 다른 필터(여기서는 search 인풋)를 함께 전송하려면 hx-include로 명시적으로 포함시켜야 합니다. 실무에서 이 부분을 빠뜨려서 "왜 다른 필터 값이 안 넘어가지?"라고 한참 고민했던 기억이 있습니다.

역할 분담 원칙: Alpine.js는 "서버 요청 없이 처리 가능한 UI 인터랙션", HTMX는 "서버 데이터가 필요한 모든 것". 이 구분만 잘 지켜도 코드가 명확해집니다.

핵심 포인트: 여러 필터가 폼 태그 바깥에 흩어져 있을 때는 hx-include로 포함할 셀렉터를 명시하거나, hx-vals로 동적 값을 전달하는 방식을 활용할 수 있습니다.


장단점 분석

장점

항목 내용
극소 번들 사이즈 14KB(gzip). React 단독으로도 약 45KB(gzip)에서 시작하고, React Router + 상태 관리 라이브러리를 더하면 100KB 이상이 되는 것과 비교하면 모바일 환경에서 체감 차이가 납니다
Hydration 없음 서버에서 완성된 HTML이 내려오므로 클라이언트 hydration 단계가 없습니다. HTML 파싱 즉시 인터랙티브 상태가 됩니다
진입 장벽 최소화 <script src> 한 줄로 설치. npm, 빌드 도구, 번들러가 불필요합니다
백엔드 단일화 별도 REST/GraphQL API 레이어 없이 기존 MVC 서버 코드를 그대로 활용할 수 있습니다
SEO 친화적 콘텐츠가 서버에서 완전히 렌더링되어 크롤러가 JS 실행 없이 색인합니다

Hydration이란: SSR로 렌더링된 HTML에 클라이언트 JS가 "붙어" 이벤트 리스너를 등록하는 과정. React SSR에서 종종 "hydration mismatch" 오류가 발생하는 바로 그 단계입니다. HTMX는 서버가 완성된 HTML을 내려주고 클라이언트가 그대로 쓰기 때문에 이 단계 자체가 없습니다.

단점 및 주의사항

항목 내용 실제 상황
JSON API 비호환 HTMX는 HTML 응답만 처리합니다. 기존 JSON API 서버를 그대로 쓸 수 없습니다 HTML 렌더링 레이어를 서버에 별도로 추가해야 합니다. 기존 프로젝트 전환보다 신규 프로젝트에 더 자연스럽게 맞는 선택입니다
복잡한 클라이언트 상태 한계 오프라인 모드, 실시간 협업, 리치 에디터, 고급 드래그앤드롭은 React가 여전히 유리합니다 Alpine.js 조합으로 보완 가능한 범위가 생각보다 넓지만, 이 영역에서는 HTMX가 맞지 않는다고 솔직히 인정하는 게 낫습니다
컴포넌트 생태계 부재 React/Vue처럼 재사용 가능한 UI 컴포넌트 라이브러리가 빈약합니다 Tailwind CSS 유틸리티 클래스로 직접 구성하는 방식이 일반적입니다
타입 안전성 부족 hx-* 속성은 문자열 기반이라 IDE 자동완성·타입 검사가 제한적입니다 VSCode HTMX 확장이나 백엔드 템플릿 타입 도구로 부분 보완 가능합니다
네트워크 의존성 모든 인터랙션이 서버 왕복을 필요로 합니다 hx-indicator로 로딩 상태를 보여주고, 응답 실패 시 htmx:responseError 이벤트를 구독해서 처리하는 패턴을 함께 쓰는 것이 좋습니다

에러 처리를 빠뜨리면 서버 500 응답이나 네트워크 실패 때 화면이 조용히 아무것도 안 하는 상황이 생깁니다. 아래처럼 hx-on::after-request로 응답 이벤트를 구독해두면 실무에서 훨씬 안정적입니다.

html
<input
  hx-get="/search"
  hx-trigger="keyup changed delay:300ms"
  hx-target="#results"
  hx-on::after-request="if(event.detail.failed) document.getElementById('error-msg').classList.remove('hidden')"
  name="q"
/>
<p id="error-msg" class="hidden text-red-500">요청 중 문제가 발생했습니다.</p>

실무에서 가장 흔한 실수

  1. JSON API 서버에 그냥 붙이려는 시도: HTMX의 핵심은 서버가 HTML을 반환하는 것입니다. 기존 REST API 서버에 바로 붙이면 서버가 JSON을 돌려줘서 아무것도 동작하지 않습니다. HTML 렌더링 엔드포인트를 별도로 만들어야 하는 구조입니다.

  2. hx-trigger 디바운스 없이 검색 구현: hx-get="/search" hx-trigger="keyup"처럼 작성하면 키를 누를 때마다 요청이 발생합니다. delay:300ms와 changed 수식어를 함께 쓰면 값이 실제로 바뀌었을 때만, 300ms 이후에 요청이 한 번만 나갑니다.

  3. 전체 페이지 레이아웃을 fragment로 반환: hx-target으로 지정한 영역에 맞는 HTML 조각만 반환해야 합니다. 서버 뷰에서 전체 페이지 템플릿을 렌더링해서 반환하면 레이아웃이 중첩됩니다. django-htmx, Rails의 request.xhr? 조건처럼 HTMX 요청을 감지해 부분 템플릿만 렌더링하는 유틸리티를 활용하면 이 실수를 피할 수 있습니다.


마치며

HTMX는 "JavaScript를 없애는" 기술이 아니라, 서버 렌더링의 가치를 다시 웹의 중심으로 가져오는 아키텍처 선택입니다. 관리자 패널, CMS, 내부 도구, CRUD 대시보드처럼 "데이터를 보여주고 수정하는" 앱에서는 React 대비 코드량이 30~50% 줄어드는 사례가 실제로 보고되고 있습니다. 반면 오프라인 지원, 실시간 협업, 드래그앤드롭 같은 복잡한 클라이언트 인터랙션이 핵심인 앱이라면 React가 여전히 적합한 도구입니다.

지금 바로 시작해볼 수 있는 3단계:

  1. CDN 한 줄 추가 — <script src="https://unpkg.com/htmx.org@2" defer></script>를 <head>에 넣고, 기존 프로젝트의 가장 단순한 검색 기능에 hx-get과 hx-trigger를 붙여봅니다. 빌드 환경은 전혀 건드릴 필요가 없습니다.

  2. HTML fragment 엔드포인트 하나 추가 — /search에서 <li> 목록만 반환하는 뷰를 만들고, django-htmx나 Rails의 request.xhr? 조건으로 HTMX 요청을 감지해 부분 템플릿을 렌더링하는 패턴을 붙여봅니다.

  3. Alpine.js와의 역할 경계 합의 — 서버 요청이 필요 없는 순수 UI 상태(드롭다운, 모달 열기/닫기)는 Alpine.js, 서버 데이터가 필요한 모든 것은 HTMX. 이 구분을 팀 안에서 미리 정해두면 나중에 코드가 섞이는 상황을 막을 수 있습니다.


참고 자료

  • htmx.org 공식 문서
  • Hypermedia-Driven Applications — htmx 공식 에세이
  • When Should You Use Hypermedia? — htmx 공식 에세이
  • htmx 4.0 — The Fetchening 공식 에세이
  • HTMX 4.0: Hypermedia finds a new gear | InfoWorld
  • htmx in 2026: When You Don't Need React | DEV Community
  • HTMX and Alpine.js 조합 가이드 | InfoWorld
  • Another Real World React → HTMX Port | htmx 공식
  • Pros and Cons for a HTMX Beginner | Labcodes
  • SE Radio 671: Carson Gross on HTMX | Software Engineering Radio
#HTMX#서버사이드렌더링#HDA#HATEOAS#Alpine.js#Django#REST-API#DOM-Morphing#무한스크롤#인라인편집
공유하기

목차

핵심 개념HTMX가 다르게 동작하는 이유 — HDA 패턴핵심 속성 한눈에 보기실전 적용예시 1: 라이브 검색 — 타이핑할 때마다 결과 업데이트예시 2: 무한 스크롤 — 뷰포트 진입 감지예시 3: 인라인 편집 — 클릭하면 폼, 저장하면 다시 텍스트예시 4: Alpine.js와 함께 — 클라이언트 인터랙션 보완장단점 분석장점단점 및 주의사항실무에서 가장 흔한 실수마치며참고 자료

추천 포스트

View Transitions API — 2025 Baseline 달성 이후, 라이브러리 없이 구현하는 프로덕션 전환 애니메이션
frontend

View Transitions API — 2025 Baseline 달성 이후, 라이브러리 없이 구현하는 프로덕션 전환 애니메이션

솔직히 처음 이 API를 봤을 때 "이게 된다고?" 싶었습니다. Framer Motion이나 GSAP 없이, CSS 몇 줄과 JavaScript 메서드 하나만으로 앱 같은 화면 전환을 구현한다는 게 너무 좋아 보였거든요. 그런데 실제로 써보니 정말 됩니다. 브라우저가 화면 전환 전후 스...

2026년 06월 07일읽는 데 20분
Webpack을 Rsbuild로 옮겼더니 프로덕션 빌드가 74% 빨라졌다 — 마이그레이션 현실과 Rspack 함정
frontend

Webpack을 Rsbuild로 옮겼더니 프로덕션 빌드가 74% 빨라졌다 — 마이그레이션 현실과 Rspack 함정

솔직히 저도 처음엔 "또 새로운 빌드 도구야?"라는 반응이었습니다. Vite가 나왔을 때도 그랬고, Turbopack 발표 때도 그랬죠. 그런데 Alan 핀테크 팀의 사례를 보고 나서는 그냥 지나칠 수가 없었습니다. 가장 복잡한 프로젝트에서 프로덕션 빌드 시간이 2분 10초에서 34초로...

2026년 06월 23일읽는 데 19분
CSS @layer로 마이크로 프론트엔드 스타일 충돌 잡기 | Cascade Layers 격리 전략
frontend

CSS @layer로 마이크로 프론트엔드 스타일 충돌 잡기 | Cascade Layers 격리 전략

마이크로 프론트엔드(MFE, Micro Frontend) 프로젝트를 처음 진행했을 때, 팀A가 배포한 버튼 스타일이 팀B의 화면에서 멋대로 바뀌어 있는 걸 보고 꽤 당황했던 기억이 있습니다. 그 시절엔 CSS Modules로 클래스명 해시를 만들거나, 선택자 앞에 팀 prefix를 붙이...

2026년 06월 23일읽는 데 22분
Next.js RSC + Streaming으로 TTFB를 350ms에서 60ms로 단축하는 방법
frontend

Next.js RSC + Streaming으로 TTFB를 350ms에서 60ms로 단축하는 방법

운영 중인 서비스의 Core Web Vitals를 들여다보다가 TTFB가 400ms를 훌쩍 넘는 페이지를 발견한 적이 있습니다. DB 쿼리는 각각 빠른데 왜 이렇게 느리지 싶어서 살펴봤더니, 세 개의 독립적인 쿼리가 직렬로 대기하고 있었습니다. 로 병렬화하면 해결되겠구나 싶었는데, 막상...

2026년 06월 07일읽는 데 19분
Next.js Turbopack 빌드 캐시로 `next build`를 10배 빠르게 — 실험적 플래그의 함정과 CI 연동 전략
frontend

Next.js Turbopack 빌드 캐시로 `next build`를 10배 빠르게 — 실험적 플래그의 함정과 CI 연동 전략

CI에서 가 3분을 넘어가기 시작하면 슬슬 스트레스가 쌓입니다. PR 하나 올릴 때마다 배포 파이프라인 앞에서 커피 한 잔을 다 마셔야 하는 그 시간. 저도 e-커머스 프로젝트에서 webpack 빌드가 180초를 넘기면서 "이걸 어떻게 줄여보나" 하고 한참 고민했습니다. 로컬에서 스테이...

2026년 05월 30일읽는 데 16분
Zustand persist migrate: persistedState unknown 타입을 TypeScript로 안전하게 좁히는 4가지 방법
frontend

Zustand persist migrate: persistedState unknown 타입을 TypeScript로 안전하게 좁히는 4가지 방법

Zustand로 상태를 localStorage에 영속화하다 보면, 어느 순간 스키마를 바꿔야 하는 상황이 옵니다. 필드 이름을 바꾸거나, 새 필드를 추가하거나, 중첩 구조를 평탄화하거나. 그럴 때 의 옵션을 꺼내 드는데, 처음 마주치면 꽤 당혹스러운 타입이 하나 있습니다. 바로 . ...

2026년 05월 30일읽는 데 19분