htmx로 JavaScript 없이 동적 웹페이지 만들기

웹 개발을 하다 보면 한 가지 의문이 드는 순간이 있습니다. 버튼 하나 클릭했을 때 서버에서 데이터 받아와서 화면 일부만 바꾸고 싶은데, 꼭 React나 Vue 같은 프레임워크를 써야 할까? 간단한 검색 폼이나 좋아요 버튼 하나 만드는데 JavaScript를 수십 줄씩 작성하는 게 과연 맞는 걸까?

이런 고민은 사실 꽤 오래전부터 있었어요. SPA(Single Page Application)가 대세가 되면서 JavaScript 코드는 점점 늘어났고 빌드 도구도 복잡해졌고 번들 크기도 커졌죠. 그런데 정작 만들고 싶었던 건 “클릭하면 서버에서 HTML 받아와서 여기에 넣어줘” 정도였을 수 있거든요.

htmx는 바로 이 지점을 파고든 라이브러리입니다. HTML 속성 몇 개만 추가하면 JavaScript를 직접 작성하지 않고도 AJAX 요청을 보내고 응답으로 받은 HTML을 페이지에 끼워 넣을 수 있어요. 이 글에서는 htmx의 핵심 개념부터 실전 활용법까지 차근차근 살펴보겠습니다.

htmx란?

htmx는 HTML 속성을 통해 AJAX 요청, CSS 트랜지션, WebSocket, Server-Sent Events 등에 접근할 수 있게 해주는 라이브러리입니다. 2020년에 intercooler.js의 후속작으로 등장했는데, 크기가 약 14KB(gzip 기준)밖에 안 돼서 상당히 가볍습니다.

htmx가 내세우는 철학은 간단해요. HTML이 이미 하이퍼미디어(hypermedia)인데, 왜 <a> 태그와 <form> 태그만 HTTP 요청을 보낼 수 있을까? 왜 GET과 POST만 가능할까? 왜 항상 전체 페이지를 교체해야 할까? htmx는 이런 HTML의 제약을 풀어주는 역할을 합니다.

기존 방식과 비교해보면 차이가 확연한데요. fetch() 함수로 API를 호출할 때는 JavaScript로 요청을 보내고 응답 JSON을 파싱하고 DOM을 직접 조작해서 화면을 갱신해야 했습니다. React 같은 프레임워크를 쓰면 상태 관리까지 신경 써야 하고요. htmx는 이 과정을 HTML 속성 하나로 줄여줍니다.

설치하기

htmx를 쓰는 가장 간단한 방법은 CDN에서 스크립트를 불러오는 겁니다.

<script src="https://unpkg.com/htmx.org@2.0.4"></script>

npm으로 설치할 수도 있습니다.

bun add htmx.org
npm install htmx.org

npm으로 설치했다면 JavaScript에서 직접 import하면 돼요.

import "htmx.org";

참고로 htmx는 의존성이 전혀 없어서 설치가 깔끔합니다. jQuery나 다른 라이브러리 없이 단독으로 동작해요.

기본 원리

htmx가 어떻게 동작하는지 한 문장으로 요약하면 이렇습니다. “사용자가 어떤 이벤트를 발생시키면 서버에 HTTP 요청을 보내고, 서버가 응답한 HTML 조각으로 페이지의 특정 부분을 교체한다.”

여기서 중요한 건 서버가 JSON이 아니라 HTML을 응답한다는 점이에요. 기존 SPA 방식에서는 서버가 JSON API를 제공하고 클라이언트가 그걸 받아서 화면을 그렸는데, htmx에서는 서버가 이미 렌더링된 HTML 조각을 보내줍니다.

간단한 예를 하나 볼까요?

<button hx-get="/api/greeting" hx-target="#result">
  인사말 불러오기
</button>
<div id="result"></div>

이 버튼을 클릭하면 htmx가 /api/greeting으로 GET 요청을 보내고, 응답으로 받은 HTML을 #result 요소 안에 넣어줍니다. JavaScript 코드가 한 줄도 없는데 동적으로 콘텐츠를 불러올 수 있는 거죠.

서버 쪽에서는 전체 HTML 페이지가 아니라 필요한 부분만 응답하면 됩니다. Express.js로 작성한다면 이런 식이에요.

app.get("/api/greeting", (req, res) => {
  res.send("<p>안녕하세요! 반갑습니다 👋</p>");
});

핵심 속성

htmx에서 가장 많이 쓰이는 속성 네 가지를 살펴볼까요?

HTTP 요청 속성

htmx는 모든 HTML 요소에서 HTTP 요청을 보낼 수 있게 해줍니다. hx-get, hx-post, hx-put, hx-patch, hx-delete 속성을 쓰면 됩니다.

<!-- GET 요청 -->
<button hx-get="/api/posts">글 목록 보기</button>

<!-- POST 요청 -->
<button hx-post="/api/posts" hx-vals='{"title": "새 글"}'>
  글 작성하기
</button>

<!-- DELETE 요청 -->
<button hx-delete="/api/posts/1">삭제</button>

기존 HTML에서는 <a> 태그로 GET, <form> 태그로 GET/POST만 보낼 수 있었는데, htmx를 쓰면 어떤 요소에서든 어떤 HTTP 메서드든 쓸 수 있습니다.

hx-target

기본적으로 htmx는 요청을 보낸 요소 자체를 응답 HTML로 교체합니다. 그런데 다른 요소를 교체하고 싶을 때가 많잖아요. 이때 hx-target 속성을 씁니다.

<input type="text"
       name="query"
       hx-get="/api/search"
       hx-trigger="keyup changed delay:300ms"
       hx-target="#search-results" />

<div id="search-results">
  <!-- 검색 결과가 여기에 들어갑니다 -->
</div>

CSS 선택자를 값으로 받기 때문에 #id, .class 등 익숙한 문법을 그대로 쓸 수 있고요. this를 넣으면 요청을 보낸 요소 자체가 대상이 되고 closest tr 같은 상대적 선택자도 지원합니다.

hx-trigger

어떤 이벤트에 반응해서 요청을 보낼지 지정하는 속성입니다. 기본값은 요소 종류에 따라 다른데요. <input>, <textarea>, <select>change 이벤트, <form>submit 이벤트, 나머지는 click 이벤트가 기본입니다.

<!-- 마우스를 올렸을 때 -->
<div hx-get="/api/preview" hx-trigger="mouseenter">
  여기에 마우스를 올려보세요
</div>

<!-- 키를 입력하고 300ms 후에 (디바운싱) -->
<input hx-get="/api/search"
       hx-trigger="keyup changed delay:300ms"
       hx-target="#results"
       name="q" />

<!-- 화면에 보일 때 한 번만 -->
<div hx-get="/api/lazy-content" hx-trigger="revealed once">
  로딩 중...
</div>

<!-- 2초마다 반복 -->
<div hx-get="/api/notifications" hx-trigger="every 2s">
  알림 영역
</div>

delay:300ms로 디바운싱을 걸거나 once로 한 번만 실행하거나 every 2s로 폴링을 설정하는 것까지 전부 HTML 속성으로 처리할 수 있어요.

hx-swap

응답 HTML을 대상 요소에 어떻게 넣을지 결정하는 속성입니다. 기본값은 innerHTML이고 다양한 옵션이 있어요.

<!-- 내부 HTML 교체 (기본값) -->
<div hx-get="/content" hx-swap="innerHTML">...</div>

<!-- 요소 자체를 교체 -->
<div hx-get="/content" hx-swap="outerHTML">...</div>

<!-- 요소 끝에 추가 -->
<div hx-get="/new-item" hx-swap="beforeend">...</div>

<!-- 요소 앞에 추가 -->
<div hx-get="/new-item" hx-swap="afterbegin">...</div>

<!-- 교체 후 스크롤 처리 -->
<div hx-get="/messages" hx-swap="beforeend scroll:bottom">...</div>

beforeend는 기존 콘텐츠 뒤에 추가하는 옵션이라 채팅 메시지 목록이나 무한 스크롤 같은 패턴에 딱 맞습니다. scroll:bottom을 붙이면 새 콘텐츠가 추가된 후 자동으로 스크롤까지 내려주고요.

실전 예제: 검색 자동완성

이론만 봐서는 감이 잘 안 올 수 있으니 좀 더 현실적인 예제를 만들어볼까요? 입력할 때마다 실시간으로 검색 결과가 나타나는 자동완성 기능입니다.

index.html
<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8">
  <title>htmx 검색 예제</title>
  <script src="https://unpkg.com/htmx.org@2.0.4"></script>
  <style>
    .search-box { padding: 8px 12px; font-size: 16px; width: 300px; }
    .result-item { padding: 8px; border-bottom: 1px solid #eee; }
    .htmx-indicator { display: none; }
    .htmx-request .htmx-indicator { display: inline; }
  </style>
</head>
<body>
  <h1>도시 검색</h1>
  <input class="search-box"
         type="text"
         name="q"
         placeholder="도시 이름을 입력하세요..."
         hx-get="/api/search"
         hx-trigger="input changed delay:300ms"
         hx-target="#results"
         hx-indicator="#spinner" />
  <span id="spinner" class="htmx-indicator">검색 중...</span>
  <div id="results"></div>
</body>
</html>

여기서 눈여겨볼 부분이 몇 가지 있는데요. 우선 hx-trigger="input changed delay:300ms"는 입력값이 변경되고 300밀리초 동안 추가 입력이 없을 때 요청을 보냅니다. 매 키 입력마다 서버에 요청을 보내면 부담이 크니까 디바운싱을 건 거죠.

hx-indicator="#spinner"는 요청이 진행되는 동안 지정된 요소를 보여주는 속성이에요. htmx는 요청 중인 요소에 htmx-request CSS 클래스를 자동으로 추가해주기 때문에 CSS만으로 로딩 표시를 제어할 수 있습니다.

서버 쪽 코드는 Express.js로 이렇게 구현하면 됩니다.

server.js
import express from "express";

const app = express();

const cities = [
  "서울", "부산", "대구", "인천", "광주",
  "대전", "울산", "세종", "수원", "성남",
  "고양", "용인", "창원", "청주", "전주",
  "천안", "남양주", "화성", "제주",
];

app.get("/api/search", (req, res) => {
  const query = req.query.q || "";
  const results = cities.filter((city) => city.includes(query));

  const html = results
    .map((city) => `<div class="result-item">${city}</div>`)
    .join("");

  res.send(html || "<div class='result-item'>검색 결과가 없습니다</div>");
});

app.use(express.static("."));

app.listen(3000, () => {
  console.log("서버가 http://localhost:3000에서 실행 중입니다");
});

서버가 JSON이 아니라 HTML을 직접 응답하는 부분을 주목해주세요. 프론트엔드에서 JSON을 파싱하고 DOM을 만들 필요 없이 서버에서 바로 보여줄 HTML을 만들어서 보내는 겁니다.

같은 기능을 fetch() 함수로 구현한다면 이벤트 리스너 등록부터 디바운싱 로직, fetch 호출, JSON 파싱, DOM 조작 코드까지 전부 JavaScript로 작성해야 해요. htmx를 쓰면 HTML 속성 네 개로 끝나니 차이가 꽤 크죠.

실전 예제: 인라인 편집

목록에서 항목을 클릭하면 바로 편집 모드로 전환되는 인라인 편집 패턴입니다. 관리자 페이지에서 자주 보이는 기능이죠.

먼저 일반 표시 모드의 HTML을 볼까요?

<div id="user-1" class="user-row">
  <span>홍길동</span>
  <span>hong@example.com</span>
  <button hx-get="/users/1/edit" hx-target="#user-1" hx-swap="outerHTML">
    수정
  </button>
</div>

수정 버튼을 클릭하면 서버가 편집 폼을 응답합니다.

<form id="user-1" class="user-row"
      hx-put="/users/1"
      hx-target="this"
      hx-swap="outerHTML">
  <input name="name" value="홍길동" />
  <input name="email" value="hong@example.com" />
  <button type="submit">저장</button>
  <button hx-get="/users/1" hx-target="#user-1" hx-swap="outerHTML">
    취소
  </button>
</form>

편집 폼에서 저장을 누르면 hx-put으로 PUT 요청이 나가고, 서버는 업데이트된 표시 모드 HTML을 다시 응답해요. 취소를 누르면 원래 HTML을 다시 불러옵니다. 전체 페이지 새로고침 없이 해당 행만 교체되는 거죠.

이 패턴의 핵심은 hx-swap="outerHTML"입니다. 요소 내부만 바꾸는 게 아니라 요소 자체를 통째로 교체하기 때문에 표시 모드와 편집 모드를 자연스럽게 전환할 수 있어요.

실전 예제: 무한 스크롤

페이지 하단에 도달하면 자동으로 다음 콘텐츠를 불러오는 무한 스크롤도 htmx로 간단하게 만들 수 있습니다.

<div id="post-list">
  <div class="post">첫 번째 글</div>
  <div class="post">두 번째 글</div>
  <div class="post">세 번째 글</div>
  <!-- ... -->
  <div hx-get="/api/posts?page=2"
       hx-trigger="revealed"
       hx-swap="outerHTML">
    더 불러오는 중...
  </div>
</div>

마지막에 있는 div가 화면에 보이는 순간(revealed 이벤트) 다음 페이지를 요청합니다. 서버는 새 포스트 HTML과 함께 다음 페이지를 불러올 트리거 요소까지 응답해요.

<div class="post">네 번째 글</div>
<div class="post">다섯 번째 글</div>
<div class="post">여섯 번째 글</div>
<div hx-get="/api/posts?page=3"
     hx-trigger="revealed"
     hx-swap="outerHTML">
  더 불러오는 중...
</div>

hx-swap="outerHTML"로 트리거 요소 자체를 교체하기 때문에 새 콘텐츠 끝에 다음 트리거가 자연스럽게 붙습니다. 마지막 페이지에서는 트리거 요소 없이 콘텐츠만 응답하면 알아서 멈추고요.

Intersection Observer API를 직접 다루는 것보다 훨씬 간결하죠?

htmx 요청 헤더

htmx는 요청을 보낼 때 유용한 HTTP 헤더를 자동으로 추가해줍니다. 서버 쪽에서 이 헤더를 활용하면 같은 URL로 일반 요청과 htmx 요청을 구분해서 처리할 수 있어요.

app.get("/posts", (req, res) => {
  const posts = getPostsFromDB();

  if (req.headers["hx-request"]) {
    // htmx 요청이면 HTML 조각만 응답
    res.send(renderPostList(posts));
  } else {
    // 일반 요청이면 전체 페이지 응답
    res.send(renderFullPage(posts));
  }
});

주요 요청 헤더를 정리하면 다음과 같습니다.

  • HX-Request — 항상 true로 설정돼요. htmx 요청인지 구분할 때 써요.
  • HX-Target — 대상 요소의 id 값이에요.
  • HX-Trigger — 요청을 발생시킨 요소의 id 값이에요.
  • HX-Current-URL — 브라우저의 현재 URL이에요.
  • HX-Boosted — boost된 요소에서 발생한 요청인지 알려줘요.

hx-boost로 기존 사이트 개선하기

이미 서버 사이드 렌더링으로 만들어진 웹사이트가 있다면 hx-boost 속성 하나로 SPA처럼 동작하게 만들 수 있습니다.

<nav hx-boost="true">
  <a href="/about">소개</a>
  <a href="/posts">글 목록</a>
  <a href="/contact">연락처</a>
</nav>

hx-boost="true"가 설정된 영역 안의 링크와 폼은 전체 페이지 이동 대신 AJAX로 처리돼요. 응답받은 HTML에서 <body> 부분만 추출해서 현재 페이지의 <body>를 교체하고 <head> 태그도 병합해줍니다. 브라우저 주소창 URL도 자동으로 바뀌고 뒤로 가기도 잘 동작해요.

기존 멀티 페이지 앱에 한 줄만 추가하면 페이지 전환이 부드러워지니까 가성비가 아주 좋은 기능입니다. Django, Rails, Spring 같은 서버 사이드 프레임워크와 특히 궁합이 잘 맞고요.

htmx와 폼 처리

HTML 폼은 원래 GETPOST 두 가지 메서드만 지원하고, 제출하면 항상 페이지가 새로고침됩니다. htmx를 쓰면 이 제약에서 벗어날 수 있어요.

<form hx-post="/api/contacts"
      hx-target="#contact-list"
      hx-swap="beforeend"
      hx-on::after-request="this.reset()">
  <input name="name" placeholder="이름" required />
  <input name="email" type="email" placeholder="이메일" required />
  <button type="submit">추가</button>
</form>

<div id="contact-list">
  <!-- 연락처가 여기에 추가됩니다 -->
</div>

폼을 제출하면 페이지 이동 없이 POST 요청이 전송되고, 응답 HTML이 목록 끝에 추가됩니다. hx-on::after-request="this.reset()"은 요청 완료 후 폼을 초기화하는 코드예요.

htmx는 폼 데이터를 자동으로 수집해서 요청에 포함시켜줍니다. <input>, <select>, <textarea> 등 표준 폼 요소의 값을 알아서 가져가기 때문에 따로 데이터를 조합할 필요가 없어요.

htmx 확인 대화상자

데이터 삭제처럼 되돌릴 수 없는 작업에는 확인 대화상자를 띄우는 게 좋겠죠? hx-confirm 속성으로 간단하게 처리할 수 있습니다.

<button hx-delete="/api/posts/1"
        hx-target="closest .post-item"
        hx-swap="outerHTML"
        hx-confirm="정말 삭제하시겠습니까?">
  삭제
</button>

브라우저 기본 확인 대화상자가 나타나고, 확인을 눌러야만 요청이 전송돼요.

CSS 트랜지션

htmx는 DOM을 교체할 때 CSS 트랜지션도 자동으로 처리해줍니다. 새 콘텐츠가 기존 콘텐츠와 같은 id를 가지면 htmx가 view transition이나 swap 과정에서 클래스를 토글해주기 때문에 CSS만으로 애니메이션 효과를 줄 수 있어요.

.fade-me-in.htmx-added {
  opacity: 0;
}
.fade-me-in {
  opacity: 1;
  transition: opacity 300ms ease-in;
}
<button hx-get="/api/content"
        hx-target="#container"
        hx-swap="innerHTML transition:true">
  콘텐츠 불러오기
</button>

htmx가 새 요소를 DOM에 삽입할 때 htmx-added 클래스를 잠깐 붙였다 떼거든요. 위 CSS와 조합하면 콘텐츠가 부드럽게 페이드인됩니다. 별도 애니메이션 라이브러리 없이도 기본적인 트랜지션을 구현할 수 있는 거죠.

언제 htmx를 써야 할까?

htmx가 모든 상황에 맞는 건 아닙니다. 어떤 경우에 htmx가 빛을 발하고 어떤 경우에는 다른 도구가 나은지 정리해볼게요.

htmx가 잘 맞는 경우부터 살펴보면, 서버 사이드 렌더링 기반 앱에 부분적인 동적 기능을 추가할 때 좋습니다. CRUD 중심의 관리자 페이지나 대시보드를 만들 때도 적합하고요. Django, Rails, Spring Boot 같은 서버 사이드 프레임워크와 함께 쓸 때 특히 빛을 발합니다. 프론트엔드 전문 인력이 없는 팀에서도 고려해볼 만해요.

반면에 다른 도구가 나을 때도 있습니다. 복잡한 클라이언트 상태 관리가 필요한 앱(예: Figma, Google Docs)이나, 오프라인에서도 동작해야 하는 PWA, 실시간 데이터 시각화나 인터랙티브 차트가 핵심인 앱에는 맞지 않아요. 이미 React/Vue 생태계가 잘 갖춰진 프로젝트라면 굳이 htmx로 전환할 이유도 없고요.

핵심은 “서버가 HTML을 응답하는 구조가 자연스러운가?”입니다. 그렇다면 htmx가 아주 좋은 선택지가 될 수 있어요.

마치며

htmx는 웹 개발의 복잡도를 크게 줄여주는 도구입니다. HTML 속성만으로 AJAX 요청을 보내고 페이지를 부분 갱신할 수 있다는 건, 프론트엔드 빌드 파이프라인이나 상태 관리 라이브러리 없이도 충분히 동적인 웹페이지를 만들 수 있다는 뜻이기도 하죠.

물론 htmx가 React나 Vue를 완전히 대체하는 건 아닙니다. 하지만 “이 정도 기능에 이만큼의 복잡도가 필요한가?”라는 질문을 던져보면 생각보다 많은 프로젝트에서 htmx만으로도 충분할 수 있어요. 특히 서버 사이드 렌더링과 조합하면 개발 속도가 확 올라가는 걸 경험할 수 있을 겁니다.

htmx에 대해 더 깊이 알고 싶다면 공식 문서를 살펴보시길 권합니다. 예제와 패턴이 잘 정리되어 있어서 실무에 바로 적용할 수 있어요.

This work is licensed under CC BY 4.0 CC BY

Discord