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 속성 하나로 처리됩니다.
<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이 아닙니다.
# 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})<!-- 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를 함께 반환하는 구조입니다.
<!-- 초기 페이지: <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)를 함께 반환합니다. 재귀적으로 계속 동작하는 구조라, 이 패턴을 처음 봤을 때 "이게 이렇게 깔끔할 수가 있나"라는 생각이 바로 들었습니다.
<!-- 서버 응답: 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를 처음 봤을 때 가장 인상적이었던 패턴입니다. 클라이언트 상태 없이 인라인 편집이 구현됩니다. "어? 이게 되네?"라는 반응이 딱 나왔습니다.
<!-- 읽기 모드 -->
<span
hx-get="/items/1/edit"
hx-trigger="dblclick"
hx-target="this"
hx-swap="outerHTML">
편집할 텍스트입니다
</span>더블클릭하면 서버가 <form> HTML을 반환하고, 그 폼이 <span> 자리를 대체합니다.
<!-- 서버가 반환하는 편집 폼 -->
<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)를 조합하는 패턴이 실무에서 가장 많이 쓰입니다.
<!-- 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로 응답 이벤트를 구독해두면 실무에서 훨씬 안정적입니다.
<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>실무에서 가장 흔한 실수
-
JSON API 서버에 그냥 붙이려는 시도: HTMX의 핵심은 서버가 HTML을 반환하는 것입니다. 기존 REST API 서버에 바로 붙이면 서버가 JSON을 돌려줘서 아무것도 동작하지 않습니다. HTML 렌더링 엔드포인트를 별도로 만들어야 하는 구조입니다.
-
hx-trigger디바운스 없이 검색 구현:hx-get="/search" hx-trigger="keyup"처럼 작성하면 키를 누를 때마다 요청이 발생합니다.delay:300ms와changed수식어를 함께 쓰면 값이 실제로 바뀌었을 때만, 300ms 이후에 요청이 한 번만 나갑니다. -
전체 페이지 레이아웃을 fragment로 반환:
hx-target으로 지정한 영역에 맞는 HTML 조각만 반환해야 합니다. 서버 뷰에서 전체 페이지 템플릿을 렌더링해서 반환하면 레이아웃이 중첩됩니다.django-htmx, Rails의request.xhr?조건처럼 HTMX 요청을 감지해 부분 템플릿만 렌더링하는 유틸리티를 활용하면 이 실수를 피할 수 있습니다.
마치며
HTMX는 "JavaScript를 없애는" 기술이 아니라, 서버 렌더링의 가치를 다시 웹의 중심으로 가져오는 아키텍처 선택입니다. 관리자 패널, CMS, 내부 도구, CRUD 대시보드처럼 "데이터를 보여주고 수정하는" 앱에서는 React 대비 코드량이 30~50% 줄어드는 사례가 실제로 보고되고 있습니다. 반면 오프라인 지원, 실시간 협업, 드래그앤드롭 같은 복잡한 클라이언트 인터랙션이 핵심인 앱이라면 React가 여전히 적합한 도구입니다.
지금 바로 시작해볼 수 있는 3단계:
-
CDN 한 줄 추가 —
<script src="https://unpkg.com/htmx.org@2" defer></script>를<head>에 넣고, 기존 프로젝트의 가장 단순한 검색 기능에hx-get과hx-trigger를 붙여봅니다. 빌드 환경은 전혀 건드릴 필요가 없습니다. -
HTML fragment 엔드포인트 하나 추가 —
/search에서<li>목록만 반환하는 뷰를 만들고,django-htmx나 Rails의request.xhr?조건으로 HTMX 요청을 감지해 부분 템플릿을 렌더링하는 패턴을 붙여봅니다. -
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