2025-12-31
Go 언어로 HTTP 서버를 구축한 뒤 자연스럽게 만나는 다음 단계가 동적 HTML 생성입니다. Go 표준 라이브러리의 html/template 패키지는 별도 외부 라이브러리 없이 안전하고 강력한 템플릿 렌더링을 제공합니다.
이 글에서는 html/template의 기본 문법부터, text/template과의 핵심 차이인 XSS 자동 방어 메커니즘, 레이아웃 상속을 위한 block/define 패턴, 실무에서 필수인 FuncMap 커스텀 함수, 그리고 서버 시작 시 일괄 파싱 캐시 패턴까지 체계적으로 다룹니다.
🛠️ 필자의 실무 경험
필자는 Go로 사내 관리 도구 웹 사이트를 구축하면서
html/template을 처음 접했습니다. 처음에는 Django나 Jinja2 같은 풍부한 템플릿 엔진에 비해 기능이 부족하다고 느꼈지만, 이후 Gin 프레임워크와 연동하면서 Go 템플릿의 단순함이 오히려 강점임을 깨달았습니다. 특히 자동 XSS 이스케이핑은 보안 설정 없이도 안전한 출력을 보장해 주어 매우 유용했습니다.
Go에는 두 가지 템플릿 패키지가 있습니다.
| 패키지 | 용도 | XSS 방어 | 출력 대상 |
|---|---|---|---|
text/template | 일반 텍스트 생성 | 없음 | 파일, 로그, 설정 등 |
html/template | HTML 생성 | 자동 이스케이핑 | 웹 브라우저 |
웹 응답에는 반드시 html/template을 사용해야 합니다. text/template을 사용하면 <script>alert('xss')</script> 같은 사용자 입력이 그대로 HTML에 삽입되어 XSS 공격에 노출됩니다.
import (
"html/template" // 웹 응답용 — 항상 이것을 사용
// "text/template" // 텍스트 파일/이메일 생성 등 비웹 용도에만
)
package main
import (
"html/template"
"os"
)
func main() {
tmpl := template.Must(template.New("greeting").Parse(`
<h1>안녕하세요, {{.Name}}님!</h1>
<p>역할: {{.Role}}</p>
<p>가입일: {{.JoinDate}}</p>
`))
data := struct {
Name string
Role string
JoinDate string
}{
Name: "홍길동",
Role: "백엔드 개발자",
JoinDate: "2025-01-15",
}
tmpl.Execute(os.Stdout, data)
}
{{.FieldName}} — 현재 데이터(.)의 필드에 접근합니다.{{.}} — 현재 데이터 자체를 출력합니다.template.Must() — 파싱 에러 시 즉시 panic. 초기화 코드에 적합합니다.// 템플릿 내 변수 선언 및 사용
{{$greeting := "안녕하세요"}}
<p>{{$greeting}}, {{.Name}}님!</p>
// 변수 재할당
{{$count := 0}}
{{range .Items}}
{{$count = add $count 1}}
<li>{{$count}}. {{.}}</li>
{{end}}
tmplStr := `
{{if .IsAdmin}}
<span class="badge-admin">관리자</span>
{{else if .IsModerator}}
<span class="badge-mod">운영자</span>
{{else}}
<span class="badge-user">일반 사용자</span>
{{end}}
{{/* 비교 함수: eq, ne, lt, le, gt, ge */}}
{{if gt .Score 90}}
<p>우수 등급</p>
{{else if ge .Score 70}}
<p>보통 등급</p>
{{else}}
<p>미달</p>
{{end}}
{{/* not, and, or 논리 연산 */}}
{{if and .IsLoggedIn (not .IsBanned)}}
<a href="/dashboard">대시보드</a>
{{end}}
`
tmplStr := `
{{/* 슬라이스 순회 */}}
<ul>
{{range .Items}}
<li>{{.}}</li>
{{end}}
</ul>
{{/* 인덱스와 값 동시 접근 */}}
<ol>
{{range $i, $item := .Products}}
<li>{{add $i 1}}. {{$item.Name}} — {{$item.Price}}원</li>
{{end}}
</ol>
{{/* 맵 순회 */}}
<dl>
{{range $key, $val := .Config}}
<dt>{{$key}}</dt>
<dd>{{$val}}</dd>
{{end}}
</dl>
{{/* 빈 슬라이스 대체 메시지 — else 활용 */}}
{{range .Notifications}}
<div class="notification">{{.}}</div>
{{else}}
<p>새로운 알림이 없습니다.</p>
{{end}}
`
tmplStr := `
{{/* .Profile이 nil이 아닐 때만 블록 실행, 내부에서 .는 Profile로 변경됨 */}}
{{with .Profile}}
<img src="{{.AvatarURL}}" alt="프로필 이미지">
<p>{{.Bio}}</p>
{{else}}
<p>프로필 정보가 없습니다.</p>
{{end}}
`
with는 값이 존재할 때 데이터 컨텍스트(.)를 해당 값으로 교체합니다. 중첩 데이터를 다룰 때 코드가 간결해집니다.
html/template의 핵심 기능입니다. 사용자 입력을 템플릿에 출력할 때 자동으로 HTML 엔티티로 변환합니다.
func xssDemo(w http.ResponseWriter, r *http.Request) {
tmpl := template.Must(template.New("xss").Parse(`
<p>사용자 입력: {{.UserInput}}</p>
<p>신뢰할 수 있는 HTML: {{.SafeHTML}}</p>
<a href="{{.URL}}">링크</a>
<button onclick="{{.JSCode}}">버튼</button>
`))
data := struct {
UserInput string
SafeHTML template.HTML // 이스케이프 없이 그대로 출력
URL template.URL // URL 안전성 검증 후 출력
JSCode template.JS // JavaScript 컨텍스트에서 안전하게 출력
}{
UserInput: `<script>alert('XSS 공격!')</script>`,
SafeHTML: template.HTML(`<em>신뢰할 수 있는 <strong>HTML</strong></em>`),
URL: template.URL("https://example.com/path?q=안전한파라미터"),
JSCode: template.JS(`console.log('안전한 JS')`),
}
tmpl.Execute(w, data)
}
출력 결과:
UserInput → <script>alert('XSS 공격!')</script> (무력화)SafeHTML → <em>신뢰할 수 있는 <strong>HTML</strong></em> (그대로 렌더링)🛠️ template.HTML은 신중하게 사용
template.HTML,template.URL,template.JS로 타입 변환하면 이스케이핑이 적용되지 않습니다. 반드시 신뢰할 수 있는 값에만 사용하고, 사용자 입력에는 절대 사용하지 마세요.
html/template은 출력 컨텍스트(HTML 속성, URL, JavaScript, CSS)를 자동으로 파악하여 각 컨텍스트에 맞는 이스케이핑을 적용합니다. 이를 컨텍스트 인식 이스케이핑(Contextual Autoescaping)이라고 합니다.
실무에서는 HTML 파일을 별도로 관리합니다.
프로젝트 구조:
├── main.go
└── templates/
├── layout.html
├── home.html
└── article.html
// 단일 파일 파싱
tmpl, err := template.ParseFiles("templates/home.html")
// 와일드카드로 일괄 파싱
tmpl, err := template.ParseGlob("templates/*.html")
// 여러 파일 명시적 파싱
tmpl, err := template.ParseFiles(
"templates/layout.html",
"templates/home.html",
)
파일 기반 템플릿에서 Execute() 대신 ExecuteTemplate()으로 특정 템플릿을 지정해 실행합니다.
// "layout.html"이라는 이름의 템플릿을 실행
err = tmpl.ExecuteTemplate(w, "layout.html", data)
대규모 웹 앱에서는 헤더, 네비게이션, 푸터 등 공통 레이아웃을 재사용해야 합니다. block과 define으로 이를 구현합니다.
templates/layout.html — 기본 레이아웃:
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{block "title" .}}기본 제목{{end}}</title>
{{block "head" .}}{{end}}
</head>
<body>
<header>
<nav>
<a href="/">홈</a>
<a href="/about">소개</a>
{{if .User}}<a href="/dashboard">{{.User.Name}}</a>{{end}}
</nav>
</header>
<main>
{{block "content" .}}
<p>콘텐츠가 없습니다.</p>
{{end}}
</main>
<footer>
{{block "footer" .}}
<p>© 2025 My App</p>
{{end}}
</footer>
</body>
</html>
templates/home.html — 홈 페이지:
{{define "title"}}홈 — {{.SiteName}}{{end}}
{{define "head"}}
<link rel="stylesheet" href="/static/home.css">
{{end}}
{{define "content"}}
<h1>{{.WelcomeMessage}}</h1>
<section class="recent-posts">
<h2>최신 글</h2>
{{range .Posts}}
<article>
<h3><a href="/posts/{{.Slug}}">{{.Title}}</a></h3>
<time>{{.PublishedAt | formatDate}}</time>
<p>{{.Summary}}</p>
</article>
{{else}}
<p>아직 게시글이 없습니다.</p>
{{end}}
</section>
{{end}}
func homeHandler(w http.ResponseWriter, r *http.Request) {
tmpl, err := template.ParseFiles(
"templates/layout.html",
"templates/home.html",
)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
data := HomePageData{
SiteName: "Go 블로그",
WelcomeMessage: "Go 언어 기술 블로그에 오신 것을 환영합니다!",
User: getCurrentUser(r),
Posts: getRecentPosts(),
}
// layout.html 기준으로 렌더링, home.html의 define이 block을 채움
if err := tmpl.ExecuteTemplate(w, "layout.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
html/template의 내장 함수만으로는 부족한 경우 FuncMap으로 커스텀 함수를 등록합니다.
import (
"html/template"
"strings"
"time"
"unicode/utf8"
)
var tmplFuncMap = template.FuncMap{
// 문자열 처리
"upper": strings.ToUpper,
"lower": strings.ToLower,
"trim": strings.TrimSpace,
"contains": strings.Contains,
"replace": strings.ReplaceAll,
// 날짜 포맷
"formatDate": func(t time.Time) string { return t.Format("2006년 01월 02일") },
"formatDateTime": func(t time.Time) string { return t.Format("2006-01-02 15:04") },
"timeAgo": timeAgo,
// 수학
"add": func(a, b int) int { return a + b },
"sub": func(a, b int) int { return a - b },
"mul": func(a, b int) int { return a * b },
"div": func(a, b int) int {
if b == 0 { return 0 }
return a / b
},
"mod": func(a, b int) int { return a % b },
// 유틸리티
"truncate": func(s string, n int) string {
if utf8.RuneCountInString(s) <= n {
return s
}
runes := []rune(s)
return string(runes[:n]) + "..."
},
"default": func(def, val any) any {
if val == nil || val == "" || val == 0 {
return def
}
return val
},
"seq": func(n int) []int {
s := make([]int, n)
for i := range s { s[i] = i + 1 }
return s
},
}
func timeAgo(t time.Time) string {
diff := time.Since(t)
switch {
case diff < time.Minute:
return "방금 전"
case diff < time.Hour:
return fmt.Sprintf("%d분 전", int(diff.Minutes()))
case diff < 24*time.Hour:
return fmt.Sprintf("%d시간 전", int(diff.Hours()))
default:
return fmt.Sprintf("%d일 전", int(diff.Hours()/24))
}
}
FuncMap을 적용한 템플릿 사용:
// 반드시 Funcs()를 Parse() 전에 호출
tmpl := template.Must(
template.New("").Funcs(tmplFuncMap).ParseGlob("templates/*.html"),
)
템플릿에서 사용:
<h1>{{.Title | upper}}</h1>
<p>{{.Content | truncate 200}}</p>
<time>{{.CreatedAt | timeAgo}}</time>
<p>총 {{.Count}}개 중 {{add .Page 1}} 페이지</p>
{{/* 페이지네이션 */}}
{{range seq .TotalPages}}
<a href="?page={{.}}" {{if eq . $.CurrentPage}}class="active"{{end}}>{{.}}</a>
{{end}}
요청마다 template.ParseFiles()를 호출하면 디스크 I/O와 파싱 비용이 반복됩니다. 서버 시작 시 한 번만 파싱해 메모리에 캐시하는 것이 실무 표준입니다.
package main
import (
"html/template"
"log"
"net/http"
"os"
"sync"
)
// 템플릿 캐시 (서버 시작 시 초기화)
var (
tmplCache *template.Template
tmplOnce sync.Once
)
func templates() *template.Template {
tmplOnce.Do(func() {
var err error
tmplCache = template.Must(
template.New("").Funcs(tmplFuncMap).ParseGlob("templates/*.html"),
)
if err != nil {
log.Fatalf("템플릿 파싱 실패: %v", err)
}
log.Println("템플릿 파싱 완료")
})
return tmplCache
}
// 개발 모드 — 요청마다 재파싱 (핫 리로드)
var devMode = os.Getenv("APP_ENV") == "development"
func render(w http.ResponseWriter, name string, data any) {
var tmpl *template.Template
if devMode {
// 개발 환경: 파일 수정 즉시 반영
var err error
tmpl, err = template.New("").Funcs(tmplFuncMap).ParseGlob("templates/*.html")
if err != nil {
http.Error(w, "템플릿 오류: "+err.Error(), http.StatusInternalServerError)
return
}
} else {
// 프로덕션: 캐시 사용
tmpl = templates()
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.ExecuteTemplate(w, name, data); err != nil {
log.Printf("템플릿 실행 오류 [%s]: %v", name, err)
http.Error(w, "렌더링 오류", http.StatusInternalServerError)
}
}
// 사용 예
func homeHandler(w http.ResponseWriter, r *http.Request) {
render(w, "layout.html", HomeData{...})
}
🛠️ 개발/프로덕션 템플릿 전략
- 개발 환경(
APP_ENV=development): 요청마다 파일을 다시 읽어 템플릿 수정이 즉시 반영됩니다.- 프로덕션:
sync.Once로 시작 시 한 번만 파싱. 성능 최적화와 에러 조기 감지 모두 달성.- 시작 시 panic이 발생하면 템플릿 문법 오류가 있다는 신호입니다. CI/CD에서 서버 시작 실패로 배포를 차단합니다.
Go 1.16부터는 //go:embed 지시어로 템플릿 파일을 바이너리에 포함시킬 수 있습니다. 배포 시 파일 시스템 없이 단일 바이너리로 동작합니다.
import (
"embed"
"html/template"
"io/fs"
)
//go:embed templates
var templateFiles embed.FS
func initTemplates() *template.Template {
// embed.FS에서 템플릿 파싱
tmpl := template.Must(
template.New("").Funcs(tmplFuncMap).ParseFS(templateFiles, "templates/*.html"),
)
return tmpl
}
Go 단일 바이너리 배포의 핵심 장점인 의존성 없는 배포와 함께, 템플릿까지 바이너리에 포함되어 완전한 자급자족 서버가 됩니다.
앞서 배운 내용을 모두 종합한 완성형 예제입니다.
type Post struct {
ID int
Title string
Slug string
Summary string
Author string
Tags []string
PublishedAt time.Time
ViewCount int
}
type ListPageData struct {
PageTitle string
Posts []Post
CurrentPage int
TotalPages int
User *User
}
func postListHandler(w http.ResponseWriter, r *http.Request) {
page := parsePageParam(r)
posts, total := fetchPosts(page, 10)
render(w, "layout.html", ListPageData{
PageTitle: "게시글 목록",
Posts: posts,
CurrentPage: page,
TotalPages: (total + 9) / 10,
User: getSessionUser(r),
})
}
templates/post-list.html:
{{define "title"}}{{.PageTitle}} — Go 블로그{{end}}
{{define "content"}}
<h1>{{.PageTitle}}</h1>
<div class="post-list">
{{range .Posts}}
<article class="post-card">
<h2><a href="/posts/{{.Slug}}">{{.Title}}</a></h2>
<div class="meta">
<span>{{.Author}}</span>
<time datetime="{{.PublishedAt.Format "2006-01-02"}}">
{{.PublishedAt | timeAgo}}
</time>
<span>조회 {{.ViewCount | printf "%,d"}}회</span>
</div>
<p>{{.Summary | truncate 150}}</p>
<div class="tags">
{{range .Tags}}
<a href="/tags/{{.}}" class="tag">{{.}}</a>
{{end}}
</div>
</article>
{{else}}
<p class="empty">아직 게시글이 없습니다.</p>
{{end}}
</div>
{{/* 페이지네이션 */}}
{{if gt .TotalPages 1}}
<nav class="pagination">
{{range seq .TotalPages}}
<a href="?page={{.}}"
class="page-btn {{if eq . $.CurrentPage}}active{{end}}">
{{.}}
</a>
{{end}}
</nav>
{{end}}
{{end}}
Go의 html/template은 간결하지만 실무 웹 개발에 필요한 모든 기능을 갖추고 있습니다. Gin, Echo 같은 웹 프레임워크에서도 내부적으로 이 패키지를 사용하므로, 지금 익혀두면 어떤 Go 웹 스택에서도 통합니다.
오늘 배운 핵심을 정리하면:
다음 심화편 글에서는 context 패키지를 실제 HTTP 서버 미들웨어와 데이터베이스 쿼리에 연동하는 실전 패턴을 다룹니다. 요청 ID 추적, 인증 정보 전파, DB 타임아웃 제어까지 — context가 실무에서 어떻게 활용되는지 구체적인 예제로 살펴보겠습니다.