htmx 실전: HTML 속성만으로 동적 웹 만들기와 React·Vue와의 선택 기준
이 글의 핵심
간단한 관리 화면 하나에도 SPA 프레임워크와 빌드 파이프라인을 들여야 하는지 의문에서 출발합니다. 서버가 HTML 조각을 돌려주고 htmx가 그 조각을 끼워 넣는 방식의 장점과, 복잡한 클라이언트 상태가 필요한 화면에서는 오히려 불리해지는 한계를 비교해 도입 여부를 판단할 수 있게 합니다.
이 글의 핵심
htmx는 직접 JavaScript를 작성하지 않고 HTML 속성만으로 서버 요청과 DOM 교체를 선언하는 라이브러리입니다. 서버가 JSON 대신 HTML 조각을 돌려주고, htmx가 그 조각을 지정한 위치에 끼워 넣는 것이 기본 동작입니다. 확장 기능을 더하면 Server-Sent Events·WebSocket도 같은 방식으로 다룰 수 있습니다.
htmx란?
htmx는 2020년 Carson Gross가 개발한 하이퍼미디어 기반 웹 라이브러리입니다.
🚀 핵심 철학
htmx의 출발점은 “왜 링크와 폼만 HTTP 요청을 보낼 수 있고, 왜 GET·POST만 쓸 수 있으며, 왜 응답이 오면 페이지 전체가 바뀌어야 하는가”라는 질문입니다. 이 제약을 풀면 HTML 자체가 하이퍼미디어로서 훨씬 많은 일을 할 수 있다는 것이 htmx의 주장입니다.
- HTML이 모든 HTTP 메서드를 사용할 수 있어야 한다
- 모든 HTML 요소가 AJAX 요청을 보낼 수 있어야 한다
- 서버가 HTML을 응답하면 클라이언트가 교체한다
💡 왜 htmx인가?
Before (React/Vue)
// 복잡한 JavaScript 코드
const [data, setData] = useState([]);
useEffect(() => {
fetch('/api/users')
.then(res => res.json())
.then(data => setData(data));
}, []);
return <div>{data.map(user => <div>{user.name}</div>)}</div>;
두 코드가 비교하는 대상이 정확히 같지 않다는 점을 짚고 넘어가야 공정합니다. React 예제는 클라이언트 상태(data)를 유지하면서 재렌더링하는 반면, htmx 예제는 상태를 서버에 위임하고 응답으로 받은 HTML 조각을 그대로 DOM에 꽂아 넣는 방식입니다. 즉 htmx가 “코드가 짧다”기보다는 애초에 상태 관리 자체를 서버로 옮겨버린 것이 핵심입니다. 이 트레이드오프가 잘 맞는 경우(서버가 이미 진실의 원천인 CRUD 앱)와 안 맞는 경우(클라이언트에서만 존재해야 하는 복잡한 UI 상태, 예: 드래그 중인 좌표)를 구분하는 게 htmx를 제대로 쓰는 핵심입니다.
After (htmx)
<!-- 단순한 HTML -->
<button hx-get="/users" hx-target="#list">
Load Users
</button>
<div id="list"></div>
htmx 시작하기
설치
CDN 사용 (권장)
<!DOCTYPE html>
<html>
<head>
<script src="https://unpkg.com/[email protected]"></script>
</head>
<body>
<!-- htmx 사용 가능 -->
</body>
</html>
npm으로 설치
npm install htmx.org
// app.js
import 'htmx.org';
이 글의 예제는 htmx 1.9 계열(unpkg.com/[email protected]) 기준입니다. htmx 2.0이 나오면서 IE11 지원 제거, 일부 확장 기능의 코어 통합 등 변화가 있었지만, hx-get/hx-post/hx-swap 같은 핵심 속성 문법은 그대로 유지되므로 이 글의 예제는 2.x에서도 대부분 그대로 동작합니다. 새 프로젝트를 시작한다면 버전 고정 시 최신 메이저 버전을 확인하는 습관을 들이는 게 좋습니다 — CDN 링크에 버전을 명시하지 않으면 배포 후 예고 없이 동작이 바뀌는 사고로 이어질 수 있습니다.
핵심 개념
1️⃣ hx-get: AJAX GET 요청
<!-- 버튼 클릭 시 /users 요청 후 응답을 #result에 삽입 -->
<button hx-get="/users" hx-target="#result">
Load Users
</button>
<div id="result"></div>
서버 응답 예시 (Node.js/Express)
app.get('/users', (req, res) => {
res.send(`
<ul>
<li>Alice</li>
<li>Bob</li>
<li>Charlie</li>
</ul>
`);
});
2️⃣ hx-post: AJAX POST 요청
<!-- 폼 제출 시 /submit으로 POST 요청 -->
<form hx-post="/submit" hx-target="#message">
<input name="username" type="text" placeholder="Username" />
<button type="submit">Submit</button>
</form>
<div id="message"></div>
서버 응답
app.post('/submit', (req, res) => {
const { username } = req.body;
res.send(`<p>Welcome, ${username}!</p>`);
});
3️⃣ hx-swap: 응답 삽입 방식
<!-- innerHTML (기본값) -->
<button hx-get="/content" hx-target="#box" hx-swap="innerHTML">
Replace Content
</button>
<!-- outerHTML: 타겟 자체를 교체 -->
<button hx-get="/content" hx-target="#box" hx-swap="outerHTML">
Replace Element
</button>
<!-- beforeend: 타겟 내부 끝에 추가 -->
<button hx-get="/content" hx-target="#list" hx-swap="beforeend">
Append
</button>
<!-- afterbegin: 타겟 내부 시작에 추가 -->
<button hx-get="/content" hx-target="#list" hx-swap="afterbegin">
Prepend
</button>
4️⃣ hx-trigger: 이벤트 트리거
<!-- 클릭 시 (기본값) -->
<button hx-get="/data" hx-trigger="click">Click Me</button>
<!-- 입력할 때마다 -->
<input hx-get="/search" hx-trigger="keyup" />
<!-- 입력 후 500ms 지연 (디바운스) -->
<input hx-get="/search" hx-trigger="keyup delay:500ms" />
<!-- 마우스 호버 시 -->
<div hx-get="/info" hx-trigger="mouseenter">Hover Me</div>
<!-- 로드 시 자동 실행 -->
<div hx-get="/initial-data" hx-trigger="load">Loading...</div>
<!-- 폴링 (2초마다 자동 요청) -->
<div hx-get="/live-data" hx-trigger="every 2s">Live Data</div>
5️⃣ hx-target: 응답 삽입 위치
<!-- ID로 타겟 지정 -->
<button hx-get="/data" hx-target="#result">Load</button>
<div id="result"></div>
<!-- 가장 가까운 부모 찾기 -->
<div id="container">
<button hx-get="/data" hx-target="closest div">Load</button>
</div>
<!-- 현재 요소 자체 -->
<button hx-get="/data" hx-target="this">Load</button>
hx-target을 안 쓰면 htmx는 요청을 보낸 요소 자신을 타겟으로 삼고, 기본 hx-swap이 innerHTML이므로 그 요소의 안쪽 내용을 응답으로 바꿉니다. “버튼을 눌렀더니 버튼 글자 자리에 사용자 목록이 들어갔다”는 초보자 버그의 원인이 대부분 이것입니다. hx-swap/hx-target 조합을 명시적으로 쓰는 습관을 들이면, “이 요청의 결과가 정확히 어디에 어떻게 반영되는가”가 마크업만 보고도 파악되는 것이 htmx의 진짜 장점 중 하나입니다 — JavaScript 콜백 어딘가에 숨어 있는 게 아니라 HTML 속성에 그대로 드러나 있으니까요.
실전 예제
📝 예제 1: 실시간 검색
<!DOCTYPE html>
<html>
<head>
<script src="https://unpkg.com/[email protected]"></script>
</head>
<body>
<h1>실시간 검색</h1>
<!-- 입력 시 500ms 후 서버에 요청 -->
<input
type="text"
name="q"
hx-get="/search"
hx-trigger="keyup changed delay:500ms"
hx-target="#results"
placeholder="검색어 입력..."
/>
<div id="results"></div>
</body>
</html>
서버 (Express)
app.get('/search', (req, res) => {
const query = req.query.q || '';
const results = ['Apple', 'Banana', 'Cherry']
.filter(item => item.toLowerCase().includes(query.toLowerCase()));
const html = results.length > 0
? `<ul>${results.map(r => `<li>${r}</li>`).join('')}</ul>`
: '<p>검색 결과 없음</p>';
res.send(html);
});
hx-trigger="keyup changed delay:500ms"의 changed는 값이 실제로 바뀌었을 때만 요청하게 하므로, 화살표 키나 Shift처럼 값을 바꾸지 않는 키 입력에는 요청이 가지 않습니다. delay:500ms는 마지막 입력 후 500ms 동안 추가 입력이 없을 때 요청하는 디바운스입니다. 그래도 느린 서버에서는 앞 요청의 응답이 뒤 요청보다 늦게 도착해 옛 검색어의 결과가 최신 결과를 덮어쓰는 경쟁이 생길 수 있습니다. hx-sync="this:replace"를 붙이면 새 요청을 보낼 때 진행 중인 이전 요청을 취소해 이 문제를 막을 수 있습니다. 또 이 예제의 <input>은 <form> 안에 있지 않지만, htmx는 요청을 보낸 요소가 입력 요소이면 그 name과 값을 요청에 포함하므로 서버에서 req.query.q로 받을 수 있습니다.
여기서 반드시 짚어야 할 보안 문제가 있습니다. 위 예제들처럼 서버에서 문자열 템플릿으로 HTML을 직접 조립해서 응답하는 패턴은, React의 JSX나 템플릿 엔진이 자동으로 해주던 이스케이프 처리를 htmx 방식에서는 직접 챙겨야 한다는 뜻입니다. <p>Welcome, ${username}!</p>처럼 사용자 입력을 그대로 문자열에 꽂아 넣으면, 사용자가 username에 <script>...</script>를 넣었을 때 그대로 브라우저에서 실행되는 반사형 XSS가 됩니다. 실무에서는 escape-html 같은 라이브러리로 사용자 입력을 이스케이프하거나, EJS·Pug 같은 서버 템플릿 엔진(기본적으로 auto-escape)을 통해 HTML 조각을 생성하는 것이 안전합니다 — “그냥 문자열 리터럴이니까 괜찮겠지”라는 생각이 htmx 초보자들이 가장 흔히 저지르는 실수입니다.
보안에서 하나 더 챙길 것은 CSRF입니다. htmx의 hx-post·hx-delete는 브라우저가 쿠키를 자동으로 싣는 일반 요청이므로, 세션 쿠키로 인증하는 앱이라면 일반 폼과 똑같이 CSRF 토큰이 필요합니다. <body hx-headers='{"X-CSRF-Token": "..."}'>처럼 상위 요소에 헤더를 선언하면 하위의 모든 htmx 요청에 토큰이 붙습니다. 또 Express에서 hx-post 요청의 req.body가 undefined로 나온다면, htmx가 기본적으로 폼 데이터를 application/x-www-form-urlencoded로 보내는데 서버에 express.urlencoded({ extended: true }) 미들웨어가 없기 때문입니다. express.json()만 등록해 둔 API 서버에 htmx를 붙일 때 자주 만나는 문제입니다.
📋 예제 2: 무한 스크롤
<div id="posts">
<div class="post">Post 1</div>
<div class="post">Post 2</div>
<div class="post">Post 3</div>
<!-- 뷰포트에 보일 때 자동 로드 -->
<div
hx-get="/posts?page=2"
hx-trigger="revealed"
hx-swap="outerHTML"
>
Loading more...
</div>
</div>
서버
app.get('/posts', (req, res) => {
const page = parseInt(req.query.page) || 1;
const posts = generatePosts(page);
const html = `
${posts.map(p => `<div class="post">${p.title}</div>`).join('')}
<div
hx-get="/posts?page=${page + 1}"
hx-trigger="revealed"
hx-swap="outerHTML"
>
Loading more...
</div>
`;
res.send(html);
});
🗑️ 예제 3: 삭제 버튼
<ul id="todo-list">
<li>
Task 1
<button
hx-delete="/todos/1"
hx-target="closest li"
hx-swap="outerHTML swap:1s"
>
Delete
</button>
</li>
<li>
Task 2
<button
hx-delete="/todos/2"
hx-target="closest li"
hx-swap="outerHTML swap:1s"
>
Delete
</button>
</li>
</ul>
서버
app.delete('/todos/:id', (req, res) => {
const id = req.params.id;
// DB에서 삭제...
// 빈 응답 (타겟이 사라짐)
res.send('');
});
삭제 응답의 상태 코드에도 함정이 있습니다. REST 관례대로 res.status(204).end()를 돌려주면, htmx는 204 No Content 응답에서는 교체를 하지 않으므로 서버에서는 지워졌는데 화면의 항목은 그대로 남습니다. 위 예제처럼 200과 빈 본문을 돌려줘야 outerHTML 교체로 항목이 사라집니다.
hx-swap="outerHTML swap:1s"의 swap:1s 부분은 단순 애니메이션 딜레이가 아니라, 실제로 DOM 교체가 일어나기 전 1초의 유예 시간을 만들어 CSS 트랜지션(예: fade-out)이 끝날 시간을 벌어주는 용도입니다. 이걸 빼고 배포했다가 “삭제 버튼을 누르면 애니메이션 없이 항목이 뚝 끊기듯 사라진다”는 리뷰를 받은 적이 있는데, 원인은 서버 응답이 오자마자 htmx가 즉시 DOM을 교체해버려서 CSS transition이 적용될 시간 자체가 없었기 때문이었습니다. swap: 타이밍 수식어는 애니메이션이 들어간 UI에서는 사실상 필수로 챙겨야 하는 디테일입니다.
📱 예제 4: 폼 검증
<form hx-post="/register" hx-target="#result">
<input
name="email"
type="email"
required
hx-post="/validate-email"
hx-trigger="blur"
hx-target="#email-error"
/>
<div id="email-error"></div>
<input name="password" type="password" required />
<button type="submit">Register</button>
</form>
<div id="result"></div>
서버
// 이메일 검증
app.post('/validate-email', (req, res) => {
const { email } = req.body;
const exists = checkEmailExists(email);
if (exists) {
res.send('<p style="color:red">이미 사용 중인 이메일입니다.</p>');
} else {
res.send('<p style="color:green">사용 가능한 이메일입니다.</p>');
}
});
// 회원가입
app.post('/register', (req, res) => {
const { email, password } = req.body;
// DB에 저장...
res.send('<p>회원가입 성공!</p>');
});
폼 검증 예제에서 가장 자주 부딪히는 문제는 오류 응답이 화면에 나타나지 않는 것입니다. 서버가 검증 실패를 REST 관례대로 res.status(422).send('<p>...</p>')로 돌려주면, htmx는 기본 설정에서 4xx·5xx 응답을 교체하지 않고 htmx:responseError 이벤트만 발생시킵니다. 네트워크 탭에는 응답이 분명히 보이는데 화면은 그대로라 원인을 찾기 어렵습니다. 방법은 두 가지입니다. 검증 실패도 200으로 응답하면서 오류 메시지 HTML을 돌려주거나, htmx:beforeSwap 이벤트에서 evt.detail.xhr.status === 422일 때 evt.detail.shouldSwap = true로 바꿔 교체를 허용합니다(htmx 2.x에서는 htmx.config.responseHandling 설정으로도 조정할 수 있습니다). 또 서버가 오류일 때 다른 위치에 메시지를 보여 주고 싶다면 응답 헤더 HX-Retarget과 HX-Reswap으로 타겟과 교체 방식을 응답 쪽에서 바꿀 수 있습니다.
고급 기능
🔄 hx-boost: 전체 페이지 AJAX화
<!-- 모든 링크와 폼을 자동으로 AJAX로 전환 -->
<body hx-boost="true">
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
<a href="/contact">Contact</a>
</nav>
<main>
<!-- 페이지 전환 시 이 부분만 교체됨 -->
</main>
</body>
hx-boost는 기존 서버 렌더링 사이트에 htmx를 점진적으로 도입할 때 가장 강력한 진입점입니다. 링크와 폼 제출을 가로채 전체 페이지 새로고침 대신 AJAX로 처리하되, URL은 그대로 바뀌고 뒤로가기도 정상 작동합니다 — 자바스크립트 코드를 한 줄도 새로 안 짜고 “SPA 같은 느낌”만 얻고 싶을 때 쓰는 기능입니다. 서버는 평소처럼 전체 페이지를 응답하면 되고, htmx가 그 응답의 <body> 내용을 가져와 현재 <body>에 교체하며 <title>도 바꿔 줍니다. 요청에는 HX-Request: true와 HX-Boosted: true 헤더가 붙으므로, 서버가 원하면 이 헤더를 보고 레이아웃을 생략한 가벼운 응답을 보낼 수도 있습니다(이때는 캐시가 두 응답을 섞지 않도록 Vary: HX-Request 헤더를 함께 둡니다).
hx-boost를 켰을 때 가장 흔한 문제는 페이지별 스크립트가 다시 실행되지 않는 것입니다. 전체 페이지를 새로 불러오는 것이 아니므로 <head>에 넣은 스크립트나 DOMContentLoaded에 묶어 둔 초기화 코드는 첫 방문에서만 실행되고, 부스트된 이동 이후에는 차트나 서드파티 위젯이 초기화되지 않은 채로 남습니다. 초기화 코드는 htmx.onLoad(fn)으로 옮겨 새로 들어온 내용에도 적용되게 하고, 부스트가 맞지 않는 링크(파일 다운로드, 외부 도메인, 다른 레이아웃을 쓰는 페이지)에는 hx-boost="false"를 붙여 제외합니다.
📡 Server-Sent Events (SSE)
<!-- 서버에서 실시간 업데이트 수신 -->
<div
hx-ext="sse"
sse-connect="/live-updates"
sse-swap="message"
>
Waiting for updates...
</div>
서버 (Node.js)
app.get('/live-updates', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
const interval = setInterval(() => {
const data = `<p>Update at ${new Date().toLocaleTimeString()}</p>`;
res.write(`data: ${data}\n\n`);
}, 2000);
// ⚠️ 클라이언트 연결 종료 시 interval을 정리하지 않으면, 탭을 닫아도
// 서버 프로세스에는 setInterval이 영원히 남아 메모리/커넥션 누수가
// 됩니다. 접속자가 늘수록 조용히 서버가 무거워지는 흔한 원인입니다.
req.on('close', () => clearInterval(interval));
});
SSE와 WebSocket은 htmx 코어가 아니라 확장 기능입니다. 확장 스크립트를 따로 불러오지 않으면 hx-ext="sse"를 써도 아무 일도 일어나지 않고 콘솔 오류도 없어서 원인을 찾기 어렵습니다. htmx 1.x에서는 htmx.org/dist/ext/sse.js를, 2.x에서는 별도 패키지인 htmx-ext-sse를 불러와야 하며, 사용하는 htmx 메이저 버전과 확장 버전을 맞춰야 합니다. 서버 쪽에서는 sse-swap="message"가 이벤트 이름이 없는 기본 메시지를 받는다는 점, 그리고 data: 줄 안의 HTML에 줄바꿈이 있으면 SSE 형식이 깨지므로 한 줄로 보내거나 줄마다 data:를 붙여야 한다는 점을 기억해야 합니다. Nginx 같은 프록시 뒤에서는 응답 버퍼링 때문에 이벤트가 모였다가 한꺼번에 도착할 수 있어, X-Accel-Buffering: no 헤더나 프록시의 버퍼링 설정을 꺼야 합니다.
🔌 WebSocket
<div
hx-ext="ws"
ws-connect="/chat"
>
<form ws-send>
<input name="message" />
<button type="submit">Send</button>
</form>
<div id="messages"></div>
</div>
SSE와 WebSocket 중 무엇을 쓸지는 데이터 흐름이 단방향인지 양방향인지로 갈립니다. 서버 → 클라이언트로만 실시간 업데이트를 밀어주면 되는 대시보드·알림 같은 경우엔 SSE가 프로토콜도 단순하고 HTTP/1.1 위에서 그냥 동작해서 인프라 설정 부담이 적습니다. 반대로 채팅처럼 클라이언트도 서버로 계속 메시지를 보내야 한다면 WebSocket이 필요합니다 — SSE로 억지로 구현하려면 별도의 POST 엔드포인트를 두는 식으로 우회해야 해서 오히려 복잡해집니다.
htmx vs React/Vue
| 기능 | htmx | React/Vue |
|---|---|---|
| 번들 크기 | 약 14KB (gzip) | React+ReactDOM 약 40KB대 (gzip) + 앱 코드 |
| JavaScript | 최소 | 많음 |
| 서버 렌더링 | ✅ 기본 | ⚠️ 복잡 (Next.js/Nuxt) |
| SEO | ✅ 완벽 | ⚠️ SSR 필요 |
| 초기 로딩 | ⚡ 빠름 | 🐢 느림 |
| 복잡한 UI | ⚠️ 제한적 | ✅ 강력 |
| 학습 곡선 | 🟢 쉬움 | 🔴 어려움 |
이 표를 볼 때 “복잡한 UI”와 “학습 곡선” 행이 사실 같은 이야기의 양면이라는 걸 알아두면 좋습니다. htmx가 배우기 쉬운 이유는 상태 관리·리렌더링·가상 DOM 같은 개념 자체가 없기 때문인데, 바로 그 개념들이 빠져 있기 때문에 클라이언트 쪽에서 복잡한 상호작용(드래그 앤 드롭, 실시간 협업 커서, 복잡한 폼 위저드)을 구현하려면 결국 별도의 JavaScript를 직접 짜야 합니다. htmx는 “React를 대체”하기보다 “React가 필요 없는 페이지에서 React를 안 쓰게 해주는” 도구에 가깝습니다.
언제 htmx를 사용할까?
✅ htmx가 적합한 경우
- CRUD 애플리케이션: 게시판, 관리자 패널
- 서버 렌더링 중심: Django, Rails, Laravel, Express
- 단순한 동적 기능: 폼 제출, 페이지 부분 업데이트
- SEO 중요: 콘텐츠 중심 사이트
- 빠른 개발: 프로토타입, MVP
❌ htmx가 부적합한 경우
- 복잡한 클라이언트 상태: 실시간 협업 도구
- 오프라인 우선: PWA, 로컬 캐싱
- 고도로 인터랙티브한 UI: 그래프 편집기, 게임
- 대규모 SPA: Gmail, Figma 같은 앱
서버 템플릿을 갱신 단위로 나누기
htmx를 도입할 때 제 경험상 가장 큰 변화는 코드가 아니라 서버 템플릿의 구조였습니다. 버튼 하나가 목록의 일부만 다시 그리려면 그 일부를 따로 렌더링할 수 있는 부분 템플릿이 있어야 하고, 같은 조각을 전체 페이지와 htmx 응답에서 함께 쓰도록 나눠 두지 않으면 같은 HTML을 두 곳에서 관리하게 됩니다. 새 프로젝트라면 처음부터 “이 화면의 어떤 부분이 따로 갱신되는가”를 기준으로 템플릿을 쪼개 두는 것이 htmx를 편하게 쓰는 가장 확실한 방법입니다. 새로 시작한다면 1.9가 아닌 최신 2.x 버전을 명시해 불러오세요.