HTMX와 AlpineJS를 쓰던 워크플로에서 Datastar로 전환하며, 더 간결한 API와 서버 주도 업데이트, SSE 기반 실시간 기능을 통해 코드량을 크게 줄이고 성능과 개발자/사용자 경험을 높인 이유와 사례를 공유합니다.
2022년, David Guillot는 영감 넘치는 DjangoCon Europe 발표에서 React 앱만큼이나 역동적으로 보이고 느껴지는 웹 앱을 선보였습니다. 하지만 그는 과감한 선택을 했습니다. 바로 그 앱을 React에서 HTMX로 전환해 코드베이스를 거의 70% 줄이면서도 기능은 크게 향상시킨 것이죠.
그 이후로 많은 팀이 같은 사실을 발견했습니다. 싱글 페이지 앱을 멀티 페이지 하이퍼미디어 앱으로 바꾸면, 코드 줄 수가 60% 이상 줄어드는 동시에 개발자 경험과 사용자 경험이 모두 개선되는 경우가 흔하다는 것입니다.
저도 제 프로젝트들을 HTMX에서 Datastar로 옮기며 비슷한 결과를 보았습니다. 특히 WebSocket이나 복잡한 프런트엔드 상태 관리 없이 실시간 멀티 사용자 애플리케이션을 만들면서 코드를 줄일 수 있다는 점이 무척 흥미로웠습니다.
FlaskCon 2025 발표 준비를 하던 중 벽에 부딪혔습니다. UI의 여러 부분을 동기화하기 위해 HTMX와 AlpineJS를 함께 사용했는데, 둘이 서로 보조를 맞추지 못해 컴포넌트가 갱신되지 않는 이유를 디버깅하느라 몇 시간을 허비하곤 했습니다. 두 라이브러리는 서로 소통하지 않습니다. 서로 다른 개발자가 만든 다른 라이브러리이기 때문에, 둘이 함께 동작하도록 만드는 책임은 결국 우리에게 있습니다.
컴포넌트를 시점마다 초기화하고 이벤트를 오케스트레이션하는 "댄스"를 관리하다 보니, 원치 않았던 코드가 늘어나고, 업무를 끝내기 위해 쓸 수 있는 시간도 점점 잠식되었습니다.
Datastar가 두 라이브러리의 역량을 더 작은 다운로드 크기로 제공한다는 걸 알고, 한 번 시도해 보기로 했습니다. Datastar는 식은 죽 먹기처럼 처리했고, 결과물의 코드는 훨씬 이해하기 쉬웠습니다.
다운로드하고 유지보수할 코드가 줄어든 것도 마음에 듭니다. 11KB도 안 되는 라이브러리 하나가 이 모든 것을 처리해 주니, 특히 모바일 사용자를 위해 페이지 로드 성능을 크게 개선할 수 있습니다. 다운로드할 것이 적을수록 더 좋습니다.
하지만 이건 시작에 불과합니다.
업무 프로젝트에 Datastar를 도입하면서, Datastar의 API가 정말 마음에 든다는 걸 깨달았습니다. HTMX보다 체감상 훨씬 가볍습니다. 원하는 결과를 얻기 위해 추가해야 하는 속성이 더 적습니다.
예를 들어, HTMX로 대부분의 상호작용을 구현할 때는 요청을 보낼 URL, 응답을 렌더링할 대상 요소, 그리고 HTMX의 동작을 커스터마이즈하기 위한 추가 속성들을 정의해야 하는 경우가 많습니다. 예를 들면 아래와 같습니다:
<a hx-target="#rebuild-bundle-status-button"
hx-select="#rebuild-bundle-status-button"
hx-swap="outerHTML"
hx-trigger="click"
hx-get="/rebuild/status-button"></a>
항상 이 모든 속성이 필요한 것은 아니지만, 매번 두세 개의 속성은 흔히 쓰게 됩니다. 게다가 상위 조상 요소 중에 동작을 바꿔 놓는 속성이 있는지 역추적해야 할 때도 있습니다. 그럴 때 생기는 버그는 정말 혼란스럽습니다!
Datastar에서는 보통 속성 하나면 충분합니다. 예를 들면 이런 식이죠:
<a data-on-click="@get('/rebuild/status-button')"></a>
몇 달 뒤에 이 코드를 다시 보더라도 어떻게 동작하는지 기억해 내기가 훨씬 쉽습니다.
HTMX와 Datastar의 가장 큰 차이는, HTMX는 HTML 명세를 진전시키는 프런트엔드 라이브러리인 반면 Datastar는 서버 주도로 고성능의 웹 네이티브, 라이브 업데이트 웹 애플리케이션을 지향한다는 점입니다.
HTMX에서는 요청을 "트리거"하는 요소에 속성을 추가해 동작을 서술합니다. 페이지의 아주 먼 곳을 업데이트하더라도요. 강력하긴 하지만, 그 결과 로직이 여러 층위에 흩어집니다. Datastar는 이를 뒤집습니다. 무엇이 바뀔지는 서버가 결정하고, 업데이트 로직을 한곳(서버)으로 모읍니다.
HTMX 문서의 예시를 인용해 보겠습니다:
<div>
<div id="alert"></div>
<button hx-get="/info"
hx-select="#info-details"
hx-swap="outerHTML"
hx-select-oob="#alert">
Get Info!
</button>
</div>
버튼을 누르면 /info로 GET 요청을 보내고, 응답에서 ID가 'info-details'인 요소로 버튼을 교체합니다. 그리고 응답에서 ID가 'alert'인 요소를 가져와 페이지의 같은 ID 요소를 대체합니다.
버튼 하나가 알아야 할 것이 너무 많습니다. 이 코드를 작성하려면, HTML을 편집하는 맥락 밖(서버)에서 무엇을 반환할지까지 알고 있어야 합니다. 제가 좋아하는 "동작의 지역성(locality of behavior)"을 이 지점에서 HTMX는 잃습니다.
반면 Datastar는 서버가 동작을 정의하는 것을 전제로 하며, 이 방식이 더 잘 맞습니다.
위 동작을 Datastar로 복제하는 방법은 여러 가지가 있습니다. 첫 번째 방법은 HTML을 기존과 유사하게 유지하는 것입니다:
<div>
<div id="alert"></div>
<button id="info-details"
data-on-click="@get('/info')">
Get Info!
</button>
</div>
이 경우 서버는 갱신 대상과 같은 ID를 가진 두 개의 루트 요소를 포함한 HTML 문자열을 반환할 수 있습니다:
<p id="info-details">찾고 계시던 상세 정보입니다…</p>
<div id="alert">알림! 이건 테스트입니다.</div>
저는 이 방법이 단순하고 성능도 좋아서 특히 마음에 듭니다.
더 나은 방법은 이 HTML을 하나의 컴포넌트로 다루도록 바꾸는 것입니다.
이 컴포넌트는 무엇일까요? 특정 항목에 대한 추가 정보를 사용자에게 보여주는 수단처럼 보입니다.
사용자가 버튼을 클릭하면 무엇이 일어날까요? 정보가 나타나거나, 나타낼 정보가 없어 에러를 렌더링할 것입니다. 어느 쪽이든 컴포넌트는 정적인 상태로 전환됩니다.
그렇다면 컴포넌트를 상태별로 쪼갤 수 있습니다. 먼저 플레이스홀더입니다:
<!-- info-component-placeholder.html -->
<div id="info-component">
<button data-on-click="@get('/product/{{product.id}}/info')">
Get Info!
</button>
</div>
그리고 서버는 사용자가 요청한 정보를 렌더링할 수 있습니다…
<!-- info-component-get.html -->
<div id="info-component">
{% if alert %}<div id="alert">{{ alert }}</div>{% endif %}
<p>{{product.additional_information}}</p>
</div>
…그러면 Datastar가 페이지를 갱신해 변경 사항을 반영합니다.
이 예시는 조금 어색할 수 있지만, 의도는 전달되리라 믿습니다. 컴포넌트 단위로 생각하면 잘못된 상태에 빠지거나 사용자의 상태를 놓치는 일을 예방할 수 있습니다.
David Guillot의 발표에서 놀라웠던 것 중 하나는, 즐겨찾기 개수를 보여주는 요소가 변경을 일으킨 컴포넌트와 아주 멀리 떨어져 있어도 카운트가 갱신되었다는 점입니다.
David의 팀은 HTMX가 JavaScript 이벤트를 트리거하고, 그 이벤트가 원격 컴포넌트가 최신 카운트를 받기 위해 GET 요청을 보내도록 유발하는 방식으로 이를 이뤄냈습니다.
Datastar에서는 동기 함수 안에서도 여러 컴포넌트를 한 번에 업데이트할 수 있습니다.
예를 들어, 장바구니에 항목을 담을 수 있는 컴포넌트가 있다고 합시다:
<form id="purchase-item"
data-on-submit="@post('/add-item', {contentType: 'form'})">"
>
<input type=hidden name="cart-id" value="{{cart.id}}">
<input type=hidden name="item-id" value="{{item.id}}">
<fieldset>
<button data-on-click="$quantity -= 1">-</button>
<label>Quantity
<input name=quantity type=number data-bind-quantity value=1>
</label>
<button data-on-click="$quantity += 1">+</button>
</fieldset>
<button type=submit>Add to cart</button>
{% if msg %}
<p class=message>{{msg}}</p>
{% endif %}
</form>
그리고 장바구니의 현재 항목 수를 보여주는 컴포넌트가 하나 더 있습니다:
<div id="cart-count">
<svg viewBox="0 0 10 10" xmlns="http://www.w3.org/2000/svg">
<use href="#shoppingCart">
</svg>
{{count}}
</div>
그러면 개발자는 같은 요청에서 둘 다 업데이트할 수 있습니다. Django에서는 대략 이렇게 보일 수 있습니다:
from datastar_py.consts import ElementPatchMode
from datastar_py.django import (
DatastarResponse,
ServerSentEventGenerator as SSE,
)
def add_item(request):
# skipping all the important state updates
return DatastarResponse([
SSE.patch_elements(
render_to_string('purchase-item.html', context=dict(cart=cart, item=item, msg='Item added!'))
),
SSE.patch_elements(
render_to_string('cart-count.html', context=dict(count=item_count))
),
])
Datastar 디스코드에 있으면서, Datastar가 단순한 헬퍼 스크립트가 아니라는 걸 더 잘 이해하게 되었습니다. Datastar는 웹의 고유한 프리미티브로 앱을 만드는 철학이며, 브라우저와 서버가 잘하는 일을 그대로 살려 주는 접근입니다.
HTMX가 HTML 스펙을 앞으로 밀어붙이는 데 초점을 둔다면, Datastar는 CSS 뷰 전환, Server-Sent Events, 웹 컴포넌트처럼 웹 네이티브 기능의 채택을 적재적소에 촉진하는 데 더 관심이 있습니다.
저는 예전부터 이런 기술을 활용하고자 했기에, 그 이점을 직접 체감하게 된 것이 정말 큰 깨달음이었습니다.
Datastar로 얻은 가장 큰 성과 중 하나는, 복잡한 AlpineJS 컴포넌트를 리팩터링해 간단한 웹 컴포넌트로 추출하고 이를 여러 곳에서 재사용하도록 만든 것이었습니다. 이 이야기는 조만간 별도로 더 자세히 다뤄 보겠습니다.
특히 좋았던 점은, 어떤 일은 JavaScript를 사용하는 편이 최선일 때가 분명히 있다는 사실입니다. 그렇다고 해서 React 같은 툴을 반드시 써야 하는 건 아닙니다. 커스텀 HTML 요소를 만드는 패턴은 동작의 지역성을 높게 유지하면서 앱 전반에 재사용할 수 있는 훌륭한 방법입니다.
게다가 Datastar는 이보다 더 많은 역량을 제공합니다.
협업을 일급 기능으로 삼는 앱은 두드러집니다. Datastar는 그 도전에 충분히 응할 수 있습니다.
이를 위해 대부분의 HTMX 개발자는 몇 초마다 폴링하여 서버에서 정보를 "끌어오거나", 복잡성을 키우는 커스텀 WebSocket 코드를 작성하곤 합니다.
Datastar는 Server-Sent Events(SSE)라는 단순한 웹 기술을 사용하여, 서버가 연결된 클라이언트로 업데이트를 "푸시"할 수 있게 합니다. 누군가 댓글을 추가하거나 상태가 바뀌는 등의 변화가 생기면, 서버가 최소한의 코드로 즉시 브라우저들을 업데이트할 수 있습니다.
이제 별도의 커스텀 JavaScript를 만들지 않고도 라이브 대시보드, 관리자 패널, 협업 도구를 만들 수 있습니다. 모든 것이 서버에서 HTML을 통해 흐릅니다.
또한 클라이언트의 연결이 끊어지면, 브라우저가 추가 코드 없이 자동으로 재연결을 시도하고, 심지어 서버에 "내가 마지막으로 받은 이벤트는 이것이야"라고 알려줄 수도 있습니다. 정말 훌륭합니다.
디스코드의 Datastar 커뮤니티에 있으면서, 그들이 웹 앱을 만드는 비전을 더 잘 이해하게 되었습니다. Datastar는 푸시 기반 UI 업데이트, 복잡성 축소, 그리고 복잡한 상황은 웹 컴포넌트 같은 도구로 로컬하게 처리하자는 방향을 지향합니다. 커뮤니티에서는 종종 신입 사용자들이 과도하게 복잡하게 만들고 있음을 깨닫도록 도와주곤 합니다.
제가 배운 몇 가지 팁은 이렇습니다:
컴포넌트 전체를 다시 렌더링해 내려보내는 걸 두려워하지 마세요. 더 쉽고, 성능에도 큰 영향이 없을 가능성이 높으며, 압축 효율도 좋아지고, 브라우저가 HTML 문자열을 파싱하는 속도는 믿을 수 없을 만큼 빠릅니다.
서버는 진실의 원천(source of truth)이자 브라우저보다 강력합니다. 상태의 대부분은 서버가 다루게 하세요. 생각만큼 반응형 시그널이 많이 필요하지 않을 가능성이 큽니다.
웹 컴포넌트는 동작의 지역성이 높은 커스텀 요소로 로직을 캡슐화하기에 아주 좋습니다. 좋은 예로 Datastar 웹사이트 헤더의 별자리 애니메이션이 있습니다. <ds-starfield> 요소는 별자리 애니메이션을 위한 모든 코드를 캡슐화하고, 내부 상태를 바꾸는 세 가지 속성을 노출합니다. Datastar는 범위 입력이 바뀌거나 요소 위에서 마우스가 움직일 때마다 이 속성들을 구동합니다.
제가 가장 기대하는 것은 Datastar가 열어 주는 가능성들입니다. 커뮤니티에서는 다른 도구를 쓰는 개발자들이 겪는 한계를 훨씬 넘어서는 프로젝트가 꾸준히 등장하고 있습니다.
예제 페이지에는 자바스크립트 컨퍼런스에서 소개된 데모의 속도와 메모리 사용량을 하이퍼미디어로 크게 개선한 데이터베이스 모니터링 데모가 있습니다.
백만 개 체크박스 실험은 애초에 올라간 서버에게 과부하였습니다. Anders Murphy는 값싼 서버에서 Datastar를 사용해 10억 개 체크박스를 만들어 냈습니다.
하지만 저를 가장 크게 자극한 것은 미국 전역의 모든 레이더 스테이션 데이터를 표시하는 웹 앱이었습니다. 레이더의 점 하나가 바뀌면, UI의 해당 점도 100밀리초 이내에 바뀝니다. 이는 곧 초당 80만 개가 넘는 포인트가 업데이트되고 있다는 뜻입니다. 게다가 사용자는 최대 한 시간 전까지 시간을 스크럽해 볼 수 있었고(지연 700밀리초 미만), 이 모든 것이 가능했습니다. 이걸 하이퍼미디어 앱으로 상상해 보실 수 있나요? Datastar가 바로 이런 일을 가능하게 합니다.
저는 아직 Datastar의 탐색 단계에 있다고 생각합니다. UI를 AJAX로 갱신하는 표준 HTMX 기능을 대체하는 일은 빠르고 쉬웠습니다. 이제는 다른 패턴들을 배우고 실험하면서 Datastar로 더 많은 것을 해내는 방법을 익히는 중입니다.
수십 년 동안 실시간 업데이트로 더 나은 사용자 경험을 제공하는 방법에 관심이 있었고, Datastar 덕분에 동기 코드만으로도 푸시 기반 업데이트를 구현할 수 있다는 점이 정말 마음에 듭니다.
HTMX를 처음 쓸 때 느꼈던 기쁨은 엄청났습니다. 하지만 Datastar로 전환한 뒤 잃은 것은 없다고 느낍니다. 오히려 더 많은 것을 얻었다고 느낍니다.
HTMX 사용의 기쁨을 한 번이라도 느껴 보셨다면, Datastar에서도 같은 도약을 다시 느끼실 거라 믿습니다. 마치 웹이 애초에 하도록 만들어진 일을 발견하는 느낌과 같습니다.