Go에서 HTMX를 활용하는 방법
요약
Go의 `html/template` 기반 서버 렌더링과 HTMX를 결합하여 동적 상호작용을 구현하는 방법을 설명합니다. 이 구조는 Go의 안전성을 유지하면서 적은 JavaScript로 풍부한 사용자 경험을 제공하며, 공유 템플릿 집합 및 전용 핸들러를 통해 배포와 관리가 용이해집니다.
핵심 포인트
- Go 서버 측 렌더링과 HTMX 결합으로 안정성과 동적 상호작용 확보
- 공유 템플릿(`base.tmpl`, `partials`)을 활용하여 구조화된 개발 가능
- HTMX 요청 처리를 위해 `HX-Request` 헤더와 전용 응답 코드를 사용해야 함
- 정적 파일로 HTMX를 직접 제공하는 것이 구성 단순화에 유리함
- 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뿐 아니라
Unpoly와 Hotwire 같은 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",
)
- 공유 기본 템플릿을 유지하면서 경로별 레이아웃과 페이지 콘텐츠를 조합할 수 있어, 관리자 영역처럼 별도 구조가 필요한 화면도 같은 렌더링 패턴으로 확장 가능함
AI 자동 생성 콘텐츠
본 콘텐츠는 GeekNews의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기