Go에서 HTMX를 활용하는 방법

3 hours ago 1
  • Go의 html/template 기반 서버 측 렌더링을 유지하면서 HTMX로 동적 상호작용을 더하고, 하나의 렌더러에서 전체 페이지와 HTML 조각을 선택적으로 반환함
  • base.tmpl, 페이지 템플릿, 재사용 조각으로 마크업을 나누고 Go 1.16의 embed.FS와 공유 템플릿 집합을 활용해 배포와 렌더링 구조를 단순화함
  • HX-Request에 따라 검색 결과를 전체 페이지 또는 테이블 행으로 반환하며, Vary: HX-Request 와 historyRestoreAsHxRequest: false로 캐시 및 뒤로 가기 복원 오류를 방지함
  • HTMX 요청을 다른 페이지로 이동시킬 때는 일반 3xx 대신 HX-Redirect와 2xx 응답을 사용하고, 204·422·4xx·5xx별 responseHandling으로 교체 범위와 오류 표시를 제어함
  • 로컬 저장소 캐시와 속성 상속을 끄고 요청 제한 시간을 설정하는 구성을 출발점으로 삼으며, 규모가 커지면 중간 레이아웃 템플릿을 추가해 관리자 영역 같은 별도 화면 구조까지 지원할 수 있음

Go의 서버 렌더링과 HTMX를 결합하는 이유

  • HTMX는 적은 JavaScript만으로 동적 상호작용을 추가하면서 Go html/template의 서버 측 HTML 렌더링이 주는 일관성과 안전성을 유지하게 해줌
  • 구현에서 다루는 축은 세 가지임
    • 전체 페이지와 부분 HTML 응답을 함께 지원하는 템플릿 구조
    • HTMX 환경의 리다이렉트와 오류 처리
    • HTMX 기본값을 변경하는 구성과 그 이유
  • 버튼을 이미지로 교체하는 기본 기능에서 시작해, 사용자 이름이나 이메일을 입력하는 동안 목록을 필터링하는 검색 화면으로 확장함
  • 같은 템플릿 패턴은 HTMX뿐 아니라 UnpolyHotwire 같은 HTML-over-the-wire 도구에도 적용 가능함
  • HTMX의 기본 사용법을 모른다면 먼저 공식 문서를 살펴보는 편이 적합함

프로젝트 디렉터리와 정적 파일 구성

  • 프로젝트는 역할에 따라 다음 영역으로 나뉨
    • assets/html/base.tmpl: 모든 페이지가 공유하는 HTML 골격
    • assets/html/pages/: 개별 페이지 콘텐츠
    • assets/html/partials/: 여러 위치에서 재사용하는 HTML 조각
    • assets/static/css, assets/static/img, assets/static/js: 정적 자산
    • cmd/web/: 서버 시작, 핸들러, HTML 렌더러 코드
  • 기본 골격은 다음 명령으로 생성함
go mod init example.com/htmx mkdir -p assets/static/css assets/static/img assets/static/js assets/html/partials assets/html/pages cmd/web touch assets/efs.go assets/html/base.tmpl assets/html/partials/images.tmpl assets/html/pages/home.tmpl cmd/web/main.go cmd/web/handlers.go cmd/web/html.go
  • HTMX는 CDN이나 NPM으로 설치할 수도 있지만, 복사본을 내려받아 애플리케이션의 정적 파일로 직접 제공하는 방식을 주로 사용함
    • 구성이 단순하며 CDN 사용에 따른 단점을 피할 수 있음
    • htmx.org@2.0.10과 클래스 없는 CSS 프레임워크 bamboo.css@1.4.0을 사용함
wget -P assets/static/js https://cdn.jsdelivr.net/npm/htmx.org@2.0.10/dist/htmx.min.js wget -P assets/static/css https://cdn.jsdelivr.net/npm/bamboo.css@1.4.0/dist/bamboo.min.css

기본·페이지·부분 템플릿 분리

  • base.tmpl은 <head>, 공통 제목, <main> 등 전체 문서 골격을 정의하고 page:title과 page:content를 삽입함
  • Bamboo CSS와 HTMX 스크립트는 기본 템플릿에서 불러오며, HTMX 스크립트에는 defer 속성을 지정함
    • 브라우저가 HTML을 파싱하는 동안 스크립트를 병렬로 가져옴
    • HTML 파싱과 DOM 구성이 끝난 뒤 스크립트를 실행함
  • 모든 템플릿은 파일명에 의존하지 않고 {{define}}...{{end}}로 명시적인 이름을 부여함
    • Go 코드는 정의된 이름만으로 템플릿을 일관되게 참조할 수 있음
    • page:title의 :는 임의의 구분자이며 page_title, page-title, pageTitle, title 같은 이름도 가능함
  • 홈페이지 버튼에는 두 가지 동작을 지정함
    • hx-get="/gopher": 클릭할 때 GET /gopher 요청을 보냄
    • hx-swap="outerHTML": 응답 HTML로 버튼 자체를 교체함
  • partial:image:gopher는 재사용 가능한 이미지 조각이며, width="{{.}}"를 통해 호출 시점에 이미지 너비를 전달받음
  • 특정 페이지에서만 사용하는 HTML 조각은 공용 partials 디렉터리가 아니라 해당 페이지 파일에 둘 수 있음
    • 여러 페이지에서 쓰는 조각은 공용 디렉터리에 두고, 페이지 전용 조각은 관련 콘텐츠 옆에 배치하는 구분임

Go 바이너리에 HTML과 정적 자산 포함

  • Go 1.16에 파일 임베딩이 추가된 뒤에는 런타임에 디스크에서 읽는 대신 HTML과 정적 자산을 Go 바이너리에 포함할 수 있음
  • //go:embed "html" "static" 지시어로 두 디렉터리를 embed.FS에 넣고, fs.Sub()를 사용해 두 개의 하위 파일 시스템으로 분리함
    • HTMLFiles: html을 루트로 사용하는 템플릿 파일 시스템
    • StaticFiles: static을 루트로 사용하는 정적 파일 시스템
  • 분리해서 얻는 이점은 두 가지임
    • HTML 코드와 정적 파일 코드가 서로의 파일에 불필요하게 접근하지 않음
    • 파일을 열 때 html/ 또는 static/ 접두사를 붙일 필요가 없음
  • sub()에서 panic()을 사용하지 않으려면 오류를 반환하고 main()에서 두 변수를 초기화할 수도 있음
  • 현재 구현에서 fs.Sub()가 실패하려면 디렉터리 값이 유효한 경로가 아니어야 하지만, 고정 문자열 "html"과 "static"은 이 검사를 통과하므로 런타임 패닉 위험이 매우 낮음

공유 템플릿 집합과 htmlRenderer

  • htmlRenderer는 렌더링 흐름을 다음처럼 관리함
    • 시작할 때 공유 템플릿 집합을 한 번 파싱함
    • 렌더링할 때마다 공유 집합을 복제함
    • 요청에 필요한 페이지 템플릿을 추가로 파싱함
    • 지정한 이름의 템플릿을 실행해 HTTP 응답으로 전송함
  • newHTMLRenderer()는 template.FuncMap을 등록한 뒤 공통 템플릿을 파싱함
    • now를 time.Now에 연결함
    • 다른 사용자 정의 템플릿 함수도 같은 위치에 추가할 수 있음
  • render()는 sharedTemplates.Clone()으로 요청별 템플릿 집합을 만들고, additionalTemplateFiles가 있으면 ParseFS()로 확장함
  • 템플릿은 먼저 bytes.Buffer에 실행한 뒤 상태 코드를 기록하고 응답 본문으로 전송함
  • 서버 초기화 시 "base.tmpl"과 "partials/*.tmpl"을 공유 집합으로 파싱함
    • 기본 템플릿과 모든 공용 조각을 렌더러에서 항상 사용할 수 있음
    • 페이지별 템플릿만 핸들러 호출 시 추가함
  • 정적 파일은 http.FileServerFS(assets.StaticFiles)와 /static/ 경로로 제공하며, 서버는 :5051에서 실행됨

전체 페이지와 HTML 조각 렌더링

  • 홈페이지 핸들러는 다음 호출로 전체 문서를 반환함
app.html.render(w, 200, nil, "base", "pages/home.tmpl")
  • 공유 집합에 pages/home.tmpl을 추가하고 base 템플릿을 200 OK로 실행하는 호출임
  • /gopher 핸들러는 추가 파일을 파싱하지 않고, 공유 집합에 이미 포함된 partial:image:gopher 만 실행함
width := 100 app.html.render(w, http.StatusOK, width, "partial:image:gopher")
  • HTMX는 /gopher에서 받은 이미지를 hx-swap="outerHTML" 설정에 따라 버튼과 교체함
  • 이 구조는 다음 이점을 제공함
    • 템플릿과 정적 자산이 바이너리에 포함돼 배포가 쉬워짐
    • 동일한 render()로 전체 HTML 페이지와 특정 HTML 조각을 모두 반환할 수 있음
    • 기본 템플릿과 부분 템플릿으로 마크업 중복을 줄임
    • 부분 템플릿을 기본 템플릿, 페이지 콘텐츠, 다른 부분 템플릿에서도 재사용 가능함

입력과 동시에 갱신되는 사용자 검색

  • 검색 기능은 두 경로로 나뉨
    • GET /users: 모든 사용자 정보를 담은 전체 HTML 페이지
    • GET /users/search: 이름이나 이메일이 검색어와 일치하는 사용자의 테이블 행만 반환하는 HTML 조각
  • 검색 입력창은 다음 HTMX 속성을 조합함
    • hx-get="/users/search"로 검색 요청을 보냄
    • hx-trigger="input changed delay:500ms, keyup[key=='Enter']"로 입력 변경 후 500ms가 지나거나 Enter를 누르면 요청함
    • 검색어는 GET /users/search?query=foo 형태의 쿼리 문자열로 전달됨
    • hx-target="#search-results"로 응답을 <tbody id="search-results"> 내부에 넣음
    • hx-push-url="true"로 요청마다 주소 표시줄을 갱신하고 브라우저 기록에 항목을 추가함
  • users:rows는 사용자 행만 렌더링하는 별도 템플릿임
    • 각 행에는 이름과 이메일이 표시됨
    • IsGopher가 참이면 partial:image:gopher를 너비 24로 렌더링함
  • listUsers와 searchUsers는 모두 pages/users.tmpl을 공유 집합에 추가하되 실행하는 템플릿은 다름
    • listUsers: base를 실행해 전체 페이지를 반환함
    • searchUsers: users:rows를 실행해 일치하는 행만 돌려줌
  • 검색어가 비어 있으면 전체 사용자 목록을 사용하고, 값이 있으면 strings.Contains()로 이름과 이메일을 검사함
  • 실제 애플리케이션에서는 다음 보완을 고려할 수 있음
    • 목록과 검색 핸들러를 하나로 합침
    • 오류 처리와 로깅 도우미를 중앙화함
    • 미들웨어로 Content Security Policy 헤더와 패닉 복구를 추가함
    • 서버 제한 시간을 적절히 설정함

HX-Request로 전체 페이지와 부분 응답 구분

  • 사용자가 /users/search?query=leo를 브라우저에서 직접 열거나 공유받은 링크로 방문하면, 테이블 행에 해당하는 부분 HTML만 보일 수 있음
  • HTMX 요청에는 항상 HX-Request: true 헤더가 포함되므로 이를 기준으로 응답 형태를 결정함
func isHTMXRequest(r *http.Request) bool { return r.Header.Get("HX-Request") == "true" }
  • 검색 핸들러의 기본 실행 템플릿을 base로 두고, HTMX 요청일 때만 users:rows로 변경함
    • 직접 방문하면 검색 결과가 포함된 전체 페이지를 받음
    • HTMX 요청에는 <tbody>에 넣을 행 조각만 반환함

Vary 헤더와 뒤로 가기 복원

  • 같은 URL이 HX-Request 값에 따라 서로 다른 응답을 반환하므로 캐시에도 이 차이를 알려야 함
  • 모든 렌더링 응답에 Vary: HX-Request 를 추가함
    • 개별 핸들러에서 필요한 경우에만 설정하는 편이 엄밀히는 낭비가 적음
    • 렌더러에서 일괄 설정하면 누락으로 발생할 수 있는 버그를 피할 수 있음
  • hx-push-url이나 hx-boost가 브라우저 기록을 추가하면 HTMX는 완성된 페이지 HTML을 브라우저 로컬 저장소에 캐시함
    • 기본 캐시는 최대 10페이지
    • 캐시에 없는 지점까지 뒤로 이동하면 HTMX가 서버에 해당 URL을 다시 요청함
  • 기본 복원 요청에는 HX-Request: true가 포함되므로 서버가 전체 페이지 대신 부분 HTML을 반환할 수 있음
  • HX-Request로 응답 종류를 선택한다면 historyRestoreAsHxRequest: false 를 설정함
    • 캐시 미스 복원 요청에서 HX-Request: true를 제외함
    • 서버가 해당 요청에 전체 HTML 페이지를 반환하게 됨
  • HTMX 구성은 base.tmpl의 htmx-config 메타 태그에 넣음

HTMX 요청을 다른 페이지로 리다이렉트

  • HTMX는 성공 메시지 같은 HTML 조각을 즉시 교체할 수 있어 일반적인 Post/Redirect/Get 흐름이 필요한 경우를 줄여줌
  • 로그인 성공 후 프로필로 이동하는 것처럼 완전히 다른 페이지로 보내야 할 때는 일반 3xx 응답만으로 처리할 수 없음
    • 브라우저가 HTMX보다 먼저 3xx 응답을 따라감
    • HTMX는 리다이렉트 뒤의 최종 응답만 확인하고 그 콘텐츠를 기존 대상에 교체함
  • HTMX 요청에는 2xx 응답과 HX-Redirect 헤더를 함께 사용함
    • HX-Redirect: /foo/bar는 브라우저를 해당 URL로 이동시켜 전체 페이지를 다시 불러옴
    • 이동 대상 요청에는 HX-Request: true가 포함되지 않음
  • JavaScript가 꺼졌거나 HTMX가 로드되지 않은 환경도 지원하려면, HTMX가 아닌 요청에는 일반 3xx 응답을 유지해야 함
func redirect(w http.ResponseWriter, r *http.Request, url string, code int) { if isHTMXRequest(r) { w.Header().Set("HX-Redirect", url) w.WriteHeader(http.StatusNoContent) return } http.Redirect(w, r, url, code) }
  • 로그인 뒤 /profile로 이동할 때는 다음처럼 호출할 수 있음
redirect(w, r, "/profile", http.StatusSeeOther)

HX-Redirect와 HX-Location의 차이

  • HX-Location은 전체 페이지를 다시 불러오지 않고 리다이렉트와 유사한 동작을 수행함
    • 대상 URL의 HTML을 가져옴
    • 응답을 <body>에 교체함
    • 브라우저 기록에 새 항목을 추가함
  • 대상 URL을 요청할 때는 HX-Request: true 가 포함됨
  • 대상 경로가 HX-Request에 따라 부분 HTML을 반환한다면 문제가 생길 수 있음
    • 핸들러는 HX-Location을 따라온 요청과 일반 HTMX 요청을 구분할 수 없음
    • 전자에는 전체 페이지가 필요하지만 후자에는 부분 응답이 필요함
  • 히스토리 복원과 달리 HX-Location 이동에서 HX-Request: true를 끄는 설정은 없음
  • 대부분은 전체 페이지 재로딩을 감수하고 HX-Redirect를 사용하는 편이 더 쉽고 안전함
  • 이동 대상 경로가 항상 전체 HTML만 반환하도록 구성됐다면 HX-Location도 사용할 수 있음

204·422·4xx·5xx 응답 처리

  • HTMX는 기본적으로 4xx 또는 5xx 응답을 DOM에 교체하지 않음
    • 현재 화면을 그대로 유지함
    • 오류는 개발자 도구 콘솔에 기록됨
  • 존재하지 않는 템플릿 이름 때문에 서버가 500 Internal Server Error를 반환하더라도, 기본 설정에서는 사용자가 화면에서 오류를 확인하지 못함
  • 오류 메시지를 사용자에게 보여주기 위해 4xx와 5xx 응답은 <body> 전체에 교체하도록 설정함
  • 예외는 422 Unprocessable Content임
    • 폼 검증 오류가 포함된 폼을 반환할 때 사용함
    • 기존 HTMX 대상에 응답이 정상적으로 교체돼야 함
  • responseHandling은 상태 코드에 따라 다음처럼 구성함
    • 204 No Content: DOM을 변경하지 않음
    • 422 Unprocessable Content: 기존 대상에 정상 교체함
    • 나머지 4xx와 5xx: 응답을 <body>에 교체함
    • 그 밖의 응답: 기존 대상에 정상 교체함
"responseHandling":[ {"code":"204", "swap": false}, {"code":"422", "swap": true}, {"code":"[45]..", "swap": true, "target": "body"}, {"code":"...", "swap": true} ]
  • 특정 상호작용에서 오류를 <body>가 아닌 다른 대상에 넣어야 한다면 response target 확장 기능으로 기본 설정을 덮어쓸 수 있음

브라우저 주소 표시줄의 현재 URL 확인

  • HTMX는 hx-boost, hx-push-url, hx-replace-url을 사용하지 않으면 AJAX 요청과 응답 교체 과정에서 브라우저 URL을 변경하지 않음
  • 이 때문에 Go 핸들러의 r.URL과 사용자가 주소 표시줄에서 보는 URL이 다를 수 있음
  • HTMX는 각 요청의 HX-Current-URL 헤더에 브라우저가 현재 표시하는 URL을 전달함
  • 도우미 함수는 이 헤더를 url.URL로 파싱하고, 헤더가 없으면 r.URL을 반환함
func browserURL(r *http.Request) (*url.URL, error) { cu := r.Header.Get("HX-Current-URL") if cu != "" { return url.Parse(cu) } return r.URL, nil }

HTMX 기본값을 변경하는 구성

  • historyCacheSize: 0으로 HTMX의 로컬 저장소 페이지 캐시를 완전히 끔
    • 로컬 저장소 캐시는 버그와 보안 문제의 원천이 될 수 있음
    • 뒤로 가기를 누르면 캐시 대신 서버에서 HTML을 다시 가져옴
    • 향후 HTMX 버전에서는 같은 이유로 로컬 저장소 캐싱이 기본 비활성화될 예정임
  • disableInheritance: true로 HTMX 속성 상속을 비활성화함
    • 속성을 항상 명시적으로 선언하는 편이 명확함
    • 버그나 의도하지 않은 동작의 위험을 낮춤
    • 향후 HTMX 버전에서는 속성 상속도 기본 비활성화될 예정임
  • includeIndicatorStyles: false로 HTMX가 표시기 스타일을 주입하지 않게 함
    • 표시기 스타일을 다른 CSS 규칙과 함께 직접 정의해 일관성을 유지함
  • HTMX 요청은 기본적으로 제한 시간이 없어 서버가 응답할 때까지 기다림
    • Go 애플리케이션의 제한 시간과 마감 시각 처리 방식에 따라 timeout을 밀리초 단위로 지정할 수 있음
    • 출발점으로 timeout: 5000 을 사용함
  • 기본 구성에는 historyRestoreAsHxRequest: false와 상태 코드별 responseHandling도 포함됨
<meta name="htmx-config" content='{ "includeIndicatorStyles": false, "historyCacheSize": 0, "historyRestoreAsHxRequest": false, "responseHandling":[ {"code":"204", "swap": false}, {"code":"422", "swap": true}, {"code":"[45]..", "swap": true, "target": "body"}, {"code":"...", "swap": true} ], "timeout": 5000 }' >

큰 애플리케이션을 위한 페이지별 레이아웃

  • 기본 템플릿과 페이지별 템플릿만으로 부족하다면 두 계층 사이에 레이아웃 템플릿을 추가할 수 있음
  • 관리자 페이지처럼 일반 페이지와 다른 제목, 탐색 메뉴, 본문 구조가 필요한 영역에 적합함
  • base는 page:content를 직접 호출하는 대신 layout을 호출함
<body> {{template "layout" .}} </body>
  • layouts/admin.tmpl은 관리자 영역의 공통 구조를 정의하고 내부에서 page:content를 호출함
    • 관리자 영역 제목
    • 사용자와 주문으로 이동하는 탐색 메뉴
    • 페이지별 콘텐츠가 들어갈 <main>
  • 핸들러는 render()에 사용할 레이아웃과 페이지 템플릿을 함께 전달함
app.html.render( w, 200, nil, "base", "layouts/admin.tmpl", "pages/admin-orders.tmpl", )
  • 공유 기본 템플릿을 유지하면서 경로별 레이아웃과 페이지 콘텐츠를 조합할 수 있어, 관리자 영역처럼 별도 구조가 필요한 화면도 같은 렌더링 패턴으로 확장 가능함
Read Entire Article