Go 언어 html/template 패키지 렌더링과 레이아웃 상속 가이드

Go 언어 html/template: 렌더링, 레이아웃 상속, FuncMap, XSS 방어 완전 정복

Go 언어로 HTTP 서버를 구축한 뒤 자연스럽게 만나는 다음 단계가 동적 HTML 생성입니다. Go 표준 라이브러리의 html/template 패키지는 별도 외부 라이브러리 없이 안전하고 강력한 템플릿 렌더링을 제공합니다.

이 글에서는 html/template의 기본 문법부터, text/template과의 핵심 차이인 XSS 자동 방어 메커니즘, 레이아웃 상속을 위한 block/define 패턴, 실무에서 필수인 FuncMap 커스텀 함수, 그리고 서버 시작 시 일괄 파싱 캐시 패턴까지 체계적으로 다룹니다.

🛠️ 필자의 실무 경험

필자는 Go로 사내 관리 도구 웹 사이트를 구축하면서 html/template을 처음 접했습니다. 처음에는 Django나 Jinja2 같은 풍부한 템플릿 엔진에 비해 기능이 부족하다고 느꼈지만, 이후 Gin 프레임워크와 연동하면서 Go 템플릿의 단순함이 오히려 강점임을 깨달았습니다. 특히 자동 XSS 이스케이핑은 보안 설정 없이도 안전한 출력을 보장해 주어 매우 유용했습니다.


1. html/template vs text/template

Go에는 두 가지 템플릿 패키지가 있습니다.

패키지용도XSS 방어출력 대상
text/template일반 텍스트 생성없음파일, 로그, 설정 등
html/templateHTML 생성자동 이스케이핑웹 브라우저

웹 응답에는 반드시 html/template을 사용해야 합니다. text/template을 사용하면 <script>alert('xss')</script> 같은 사용자 입력이 그대로 HTML에 삽입되어 XSS 공격에 노출됩니다.

import (
    "html/template" // 웹 응답용 — 항상 이것을 사용
    // "text/template" // 텍스트 파일/이메일 생성 등 비웹 용도에만
)

2. 템플릿 기본 문법

2-1. 데이터 접근

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)
}

2-2. 내장 변수

// 템플릿 내 변수 선언 및 사용
{{$greeting := "안녕하세요"}}
<p>{{$greeting}}, {{.Name}}님!</p>

// 변수 재할당
{{$count := 0}}
{{range .Items}}
    {{$count = add $count 1}}
    <li>{{$count}}. {{.}}</li>
{{end}}

3. 제어 구조

3-1. 조건문 — if / else if / else

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}}
`

3-2. 반복문 — range

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}}
`

3-3. with — nil/빈값 분기

tmplStr := `
{{/* .Profile이 nil이 아닐 때만 블록 실행, 내부에서 .는 Profile로 변경됨 */}}
{{with .Profile}}
    <img src="{{.AvatarURL}}" alt="프로필 이미지">
    <p>{{.Bio}}</p>
{{else}}
    <p>프로필 정보가 없습니다.</p>
{{end}}
`

with는 값이 존재할 때 데이터 컨텍스트(.)를 해당 값으로 교체합니다. 중첩 데이터를 다룰 때 코드가 간결해집니다.


4. XSS 자동 방어 메커니즘

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)
}

출력 결과:

🛠️ template.HTML은 신중하게 사용

template.HTML, template.URL, template.JS로 타입 변환하면 이스케이핑이 적용되지 않습니다. 반드시 신뢰할 수 있는 값에만 사용하고, 사용자 입력에는 절대 사용하지 마세요.

html/template은 출력 컨텍스트(HTML 속성, URL, JavaScript, CSS)를 자동으로 파악하여 각 컨텍스트에 맞는 이스케이핑을 적용합니다. 이를 컨텍스트 인식 이스케이핑(Contextual Autoescaping)이라고 합니다.


5. 파일 기반 템플릿

실무에서는 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)

6. 레이아웃 상속 — block과 define

대규모 웹 앱에서는 헤더, 네비게이션, 푸터 등 공통 레이아웃을 재사용해야 합니다. blockdefine으로 이를 구현합니다.

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>&copy; 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)
    }
}

7. FuncMap — 사용자 정의 함수

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}}

8. 서버 시작 시 일괄 파싱 — 실무 필수 패턴

요청마다 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{...})
}

🛠️ 개발/프로덕션 템플릿 전략


9. embed.FS로 템플릿 바이너리 내장 (Go 1.16+)

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 단일 바이너리 배포의 핵심 장점인 의존성 없는 배포와 함께, 템플릿까지 바이너리에 포함되어 완전한 자급자족 서버가 됩니다.


10. 실전 예제 — 블로그 게시글 목록 페이지

앞서 배운 내용을 모두 종합한 완성형 예제입니다.

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}}

마치며: html/template 핵심 정리

Go의 html/template은 간결하지만 실무 웹 개발에 필요한 모든 기능을 갖추고 있습니다. Gin, Echo 같은 웹 프레임워크에서도 내부적으로 이 패키지를 사용하므로, 지금 익혀두면 어떤 Go 웹 스택에서도 통합니다.

오늘 배운 핵심을 정리하면:

  1. html/template은 text/template과 달리 XSS를 자동으로 방어한다. 웹 응답에는 반드시 html/template을 사용하라.
  2. 컨텍스트 인식 이스케이핑으로 HTML/URL/JS 각 컨텍스트에 맞는 안전한 출력을 보장한다.
  3. block/define으로 레이아웃을 상속하여 공통 UI를 재사용한다.
  4. FuncMap으로 커스텀 함수를 등록하여 날짜 포맷, 문자열 처리, 페이지네이션 등을 템플릿에서 직접 처리한다.
  5. 서버 시작 시 일괄 파싱 + sync.Once 캐시 패턴으로 성능을 최적화한다.
  6. embed.FS로 템플릿을 바이너리에 포함시켜 단일 파일 배포를 달성한다.

다음 심화편 글에서는 context 패키지를 실제 HTTP 서버 미들웨어와 데이터베이스 쿼리에 연동하는 실전 패턴을 다룹니다. 요청 ID 추적, 인증 정보 전파, DB 타임아웃 제어까지 — context가 실무에서 어떻게 활용되는지 구체적인 예제로 살펴보겠습니다.