5 min read

[aniflux 4/5] htmx로 SPA처럼 — deep link, fragment, push-url 삽질기

도입

htmx를 처음 접했을 때 "이거면 React 안 써도 되겠다"라고 생각했습니다.
실제로 간단한 CRUD라면 그 말이 맞습니다.
그런데 SPA 수준의 UX를 구현하려고 하면, 생각보다 고민할 게 많더라고요.

aniflux는 애니 구독 관리, 에피소드 목록, 자막 상태, 설정 페이지 등 여러 화면이 있습니다.
탭 전환이 부드러워야 하고, URL을 공유하면 해당 화면이 바로 열려야 하고, 새로고침해도 깨지면 안 됩니다.
이 정도를 htmx로 하려면 꽤 의도적인 설계가 필요했습니다.

배경

프론트엔드 프레임워크를 뭘 쓸지 꽤 고민했습니다.
React나 Vue를 붙이면 빌드 파이프라인이 생기고, 번들 결과물을 Go 바이너리에 임베드해야 하고, API를 따로 만들어야 합니다.
aniflux의 목표는 단일 바이너리였거든요.
Go template으로 HTML을 렌더링하고, htmx로 동적 부분만 교체하면 빌드 도구 없이 갈 수 있습니다.

htmx 2.0을 선택한 건 그래서입니다.
서버가 HTML을 내려주고, 클라이언트는 DOM 일부만 교체하는 구조.
JSON API도 필요 없고, 상태 관리 라이브러리도 필요 없습니다.

과정

같은 URL에 대해 두 가지 응답을 내려주는 겁니다.
htmx 요청이면 fragment만, 일반 요청이면 full page를 반환합니다.

func renderPage(w http.ResponseWriter, r *http.Request, page string, data any) {
    if r.Header.Get("HX-Request") == "true" {
        tmpl.ExecuteTemplate(w, page+"-fragment", data)
        return
    }
    tmpl.ExecuteTemplate(w, "layout", data)
}

htmx에서 hx-push-url="true"를 쓰면 브라우저 주소창이 바뀝니다.
사용자가 탭을 클릭하면 fragment만 교체되면서 URL도 변경되니까, SPA처럼 보이는 거죠.

실시간 업데이트는 SSE로 처리했습니다.
자막 backfill이 완료되면 서버가 이벤트를 보내고, htmx의 sse-swap으로 해당 행만 갱신합니다.
WebSocket까지 갈 필요는 없었거든요.
단방향 알림이면 SSE로 충분합니다.

삽질

가장 오래 걸린 문제는 새로고침이었습니다.

htmx로 탭을 이동하면 fragment만 교체되니까 잘 됩니다.
그런데 그 URL에서 F5를 누르면 서버가 fragment만 반환해버리는 겁니다.
CSS도 없고 레이아웃도 없는 HTML 조각만 덩그러니 나옵니다.

처음엔 모든 핸들러에서 HX-Request 분기를 넣었는데, 빠뜨리는 곳이 계속 생기더라고요.
결국 미들웨어에서 처리하는 방식으로 바꿨습니다.
HX-Request 헤더가 없으면 무조건 layout을 감싸서 반환합니다.
템플릿 구조를 layout > content-area > fragment 형태로 통일하니까 해결됐습니다.

두 번째 문제는 폼 partial update였습니다.
설정 페이지에서 하나의 필드만 PUT으로 업데이트하면, 서버가 해당 섹션의 fragment를 반환합니다.
그런데 같은 폼 안에 있는 다른 필드들이 초기값으로 돌아가버리는 문제가 있었습니다.
htmx가 응답으로 받은 HTML로 해당 영역을 통째로 교체하니까 당연한 거였습니다.

해결 방법은 swap 대상을 좁히는 것이었습니다.
hx-target을 폼 전체가 아니라 변경된 필드의 wrapper로 잡고, hx-swap="outerHTML"을 씁니다.
아니면 아예 hx-swap="none"으로 두고 서버에서 HX-Trigger 응답 헤더로 toast만 보여주는 방식도 썼습니다.

<button hx-put="/api/settings/path"
        hx-target="#path-field"
        hx-swap="outerHTML">
  저장
</button>

결과

지금 aniflux UI는 URL 공유가 됩니다.
/subscriptions/3 링크를 브라우저에 붙여넣으면 해당 작품 에피소드 목록이 바로 뜹니다.
새로고침해도 깨지지 않고, 탭 전환은 fragment 교체라 빠릅니다.

솔직히 React로 했으면 이런 고민 자체가 없었을 겁니다.
라우터가 다 해주니까요.
하지만 빌드 도구, node_modules, API 레이어, 상태 관리 — 이 비용을 생각하면 htmx 쪽이 aniflux 규모에는 맞았습니다.

트레이드오프는 있습니다.
복잡한 인터랙션(드래그 앤 드롭 정렬, 인라인 에디팅)은 htmx만으로 어렵습니다.
다만 aniflux의 WebUI는 구독 설정과 상태 모니터링이 전부입니다.
근본은 백엔드 스케줄러가 알아서 돌아가는 거고, UI는 확인/조정 용도에 가깝습니다.
그 정도라면 htmx로 충분하고, React를 붙여서 얻는 이점보다 바이너리 크기와 빌드 복잡도를 줄이는 게 더 중요했습니다.

htmx가 간단하다는 건 시작이 간단하다는 뜻이지, SPA 수준을 만들려면 나름의 아키텍처가 필요합니다.
그래도 이 서비스의 중심은 30분마다 돌아가는 백엔드 스케줄러지, UI가 아닙니다.
Go template + htmx + SSE 조합으로 빌드 파이프라인 없이 단일 바이너리에 UI까지 넣은 건 이 맥락에서 합리적인 결정이었습니다.

참고