Search

[Golang] template 패키지 이해하기 (+HTML layout composition)

Last update: @9/14/2023

서론

Go로 웹서버를 만들고 있다. 단순히 템플릿 레이아웃 구조를 쓰고 싶었는데, 만만하게 봤던 template을 이해하는데 상당히 Go된 시간을 보냈다. 공식 문서도 생각보다 친절하지 않았다. 단순한 사용법을 다루는 글은 많기 때문에, template 패키지를 다루는 데 알아두면 좋은 부분만 짚어보겠다.

template 패키지

바꾸고 싶은 부분에 구멍을 뚫어놓고 여러개 복사해서 쓰는 것을 템플릿이라고 부른다. Go에서 템플릿은 구멍이 숭숭 뚫린 문자열을 말한다. 이 구멍에 이런저런 문자열을 바꿔가며 쓸 수 있게 해준다. 아래 두 패키지가 있다.
text/template
html/template
text/template에 XSS 공격 방지를 위한 스크립팅 등의 보안 기능을 추가한 게 html/template이다. 사용법은 똑같기 때문에 배울 때는 구분할 필요가 없다. HTML 문서를 렌더링할 때 html/template을 써야하는 부분만 주의하면 된다.

용어 정리

공식 문서의 예제를 들고 왔다.
package main import ( "log" "os" "text/template" ) type Inventory struct { Material string Count int } func main() { sweater := Inventory{ Material: "wool", Count: 17, } tmpl, err := template.New("test").Parse("{{.Count}} items are made of {{.Material}}") if err != nil { log.Panic(err) } err = tmpl.Execute(os.Stdout, sweater) if err != nil { log.Panic(err) } }
Go
복사
{{.Count}} items are made of {{.Material}}를 보면 문자열에 {{ }} 처럼 구멍이 뚫려있는 것을 볼 수 있다. 이런 구멍을 가리켜 액션(Action)이라고 부르고, 아래 두 가지가 있다.
data evaluation - 데이터 그 자체 또는 연산 결과
control structures - 제어 구조(반복문 등)
위와 같은 문자열에서 고정된 문자 부분과 액션 부분을 구분해 실행을 준비하는 것을 파싱(parse)한다고 한다.
이렇게 파싱하여 액션을 채워넣을 준비가 된 템플릿에 데이터 구조(data structure)를 전달하여 액션을 채워넣는 것을 템플릿을 실행(execute)한다고 한다.
tmpl.Execute(os.Stdout, sweater)는 템플릿을 sweater라는 데이터 구조에 적용해 찍어내는 것이다.
첫번째는 io.Writer가 들어오는데, 템플릿으로 찍어낸 문자열을 내보낼 곳을 말한다. os.Stdout은 기본적으로 콘솔을 가리키고, HTML을 찍어낸다면 보통 http.ResponseWriter를 넣는다.
Actions 내부에서 데이터 구조의 특정 위치를 가리키는 것이 .인데, 닷(dot)이라고 부르고, 가리킨다는 의미에서 커서(cursor)라고 부른다.
{{.}}의 닷(.)은 데이터 구조의 root를 뜻하고, {{.Count}}는 데이터 구조의 root(sweater)에서 Count라는 부분을 가리킨다. .Count를 묶어서 annotation이라고도 부르는데, 잘 쓰이지는 않는 용어같다. 보통 Action 또는 파이프라인(Pipeline)이라고 부른다.
Actions 내에서 연산이 끝난 결과물을 파이프라인(pipeline)이라고 부른다.
모든 용어를 다 알 필요는 없고 {{ }} 이 부분에 필요한 것을 채워넣는다는 것만 알면 된다.

template.Template은 템플릿의 컬렉션이다

처음엔 이 부분이 굉장히 헷갈린다. 일단 아래처럼 템플릿을 만든다고 하면
tmpl := template.New("main")
Go
복사
이 템플릿은 main이라는 이름을 가지게 되고, 현재 template body가 없는 텅 빈 undefined 상태가 된다. 이 상태에서 아래처럼 텍스트를 파싱해주면
tmpl, _ = tmpl.Parse(`{{.}} World`)
Go
복사
main은 하나의 액션({{.}})이 있는 template body를 가지고 있는 상태가 된다. 이 상태에서 아래처럼 tmpl.execute()를 해주면 템플릿을 사용할 수 있다.
_ = tmpl.Execute(os.Stdout, "Hello") // Hello World
Go
복사
사실 main 템플릿이 들어있는 tmpl 객체는 여러 템플릿을 모아놓은 컬렉션(collection)이다. 그리고 대표 템플릿이 main인 것 뿐이다.
tmpl.Execute() 메서드는 tmpl 컬렉션의 대표 템플릿을 실행하기 때문에 자동으로 main 컬렉션이 실행된다. tmpl.ExecuteTemplate()은 템플릿의 이름을 지정해서 실행한다. 따라서 아래처럼 직접 대표 컬렉션의 이름을 지정해서 호출해도 결과는 같다.
_ = tmpl.ExecuteTemplate(os.Stdout, "main", "Hello") // Hello World
Go
복사
따라서 main과 같은 컬렉션에 다른 템플릿을 추가하고 싶으면 이 tmpl 객체에 대고 New()를 호출해주면 된다. 정리하면,
새로운 템플릿 및 해당 템플릿이 담길 컬렉션을 만들 때는 template.New()
기존 컬렉션에 템플릿을 추가할 때는 tmpl.New()
를 해주는 것이다. 이 부분이 메서드 이름에는 전혀 티가 나지 않기 때문에 혼동을 일으킨다. 엄밀히 말하면 컬렉션이 아니라 연관 템플릿(associated template)을 추가한다는 개념이긴 하지만, 그럼에도 영 별로다.
template.Template이 알고 보면 컬렉션이라는 사실을 이해하면 새로운 템플릿을 추가하고 실행하는게 너무나 쉽게 느껴진다. 그냥 컬렉션에 템플릿 하나 더 추가하는 거니까.
tmpl := template.New("main") tmpl, _ = tmpl.Parse(`{{.}} World`) tmpl, _ = tmpl.New("sub").Parse(`{{.}} sub-World`) _ = tmpl.ExecuteTemplate(os.Stdout, "main", "Hello") // Hello World _ = tmpl.ExecuteTemplate(os.Stdout, "sub", "Hello") // Hello sub-World
Go
복사
여기서 의문점이 생기는데, tmpl이 컬렉션이라면 Parse() 메서드는 어떤 템플릿에 파싱한 내용을 넣어줄까? Parse()는 가장 마지막에 컬렉션에 추가된 메서드에 template body를 준다. 따라서 아래처럼 하면 main 템플릿은 텅 빈 상태가 된다.
tmpl := template.New("main") // New 이후 Parse()를 호출하지 않음 -> 빈 템플릿 tmpl, _ = tmpl.New("sub").Parse(`{{.}} sub-World`) _ = tmpl.ExecuteTemplate(os.Stdout, "main", "Hello") // 결과물 없음
Go
복사
하지만 아래처럼 다시 main 템플릿을 만들고 Parse()를 해주면 기존 텅 빈 main 템플릿을 덮어씌우게 된다(override).
tmpl := template.New("main") // 빈 템플릿 tmpl, _ = tmpl.New("sub").Parse(`{{.}} sub-World`) _ = tmpl.ExecuteTemplate(os.Stdout, "main", "Hello") // 결과물 없음 tmpl, _ = tmpl.New("main").Parse(`{{.}} World`) // main 템플릿 override _ = tmpl.ExecuteTemplate(os.Stdout, "main", "Hello") // Hello World
Go
복사
컬렉션에 템플릿을 추가하는 방법은 tmpl.New() 말고도 한 가지가 더 있다. 바로 텍스트 내부에서 템플릿을 정의하는 것이다.
tmpl := template.New("main") tmpl, _ = tmpl.Parse(`{{.}} World{{define "sub"}}{{.}} Sub-World{{end}}`) _ = tmpl.ExecuteTemplate(os.Stdout, "sub", "Hello") // Hello sub-World
Go
복사
위를 보면 main 템플릿으로 파싱하는 문자열에 {{define "sub"}}{{.}} Sub-World{{end}} 부분이 추가된 것을 볼 수 있다. 이렇게 하면 sub라는 이름으로 {{.}} Sub-World라는 바디를 가진 템플릿이 생긴다.
이때, main 템플릿은 해당 템플릿 {{define}} 액션 부분이 떨어져 나간 {{.}} World 부분만 바디로 가진다. 즉, 템플릿은 New()로도 만들 수 있고, {{define}}으로도 만들 수 있다. {{block}}도 가능한데, 뒤 레이아웃 부분에서 다루겠다.
{{define}}을 사용하면 가장 최근에 New()로 컬렉션에 추가된 템플릿이 무엇인지와 상관 없이 템플릿 이름을 지정해 해당 내용물을 갈아치울 수 있다.
한 템플릿이 execute될 때 같은 컬렉션 내의 다른 템플릿의 내용을 끼워넣고 싶으면 아래처럼 하면 된다.
{{template "T3" 넘길데이터}}
Go
복사
위 예제에 적용하면
tmpl, _ := template.New("main").Parse(`{{.}} World`) tmpl, _ = tmpl.New("sub").Parse(`{{.}} Sub-World`) tmpl, _ = tmpl.New("subsub").Parse(`{{template "main" .}} {{template "sub" .}}`) _ = tmpl.ExecuteTemplate(os.Stdout, "subsub", "Hello") // Hello World Hello Sub-World
Go
복사
위 코드 3번째 라인에서 다른 템플릿으로 넘길 데이터로 .(root)을 쓴 것을 주의깊게 봐야 한다. subsub 템플릿은 실행될 시점에 본인이 가지고 있는 데이터(”hello”)를 main, sub 템플릿으로 넘겨준 후 mainsub를 먼저 실행한 후 자신에게 가져오는 것을 알 수 있다.

레이아웃 구조 만들기 (layout composition)

이제 template을 이용해서 layout 구조로 HTML 페이지를 렌더링할 수 있다. layout composition이라고도 부르는데, 아래와 같은 기본 구조를 공통으로 쓰고, 개별 페이지 별로 달라지는 title, content 등의 일부만 갈아끼워 쓰고싶을 때 사용하는 방법이다.
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>{{block "title" .}}기본 제목{{end}}</title> </head> <body> {{block "content" .}}기본 내용{{end}} </body> </html>
Go
복사
layouy.html
여기서 쓰인 {{block}} 액션은 {{define}}{{template}} 액션을 합친 버전으로, 파싱 시 해당 이름의 템플릿이 생성되고, {{template}} 액션으로 바뀐다. 따라서 추후에 같은 이름의 템플릿으로 덮어씌우는 부분이 된다. 혹시 {{template}} 액션이 실행될 때 해당 이름의 템플릿이 없더라도 초깃값을 넣고싶을 때 쓰인다. 초깃값이 필요 없으면 그냥 {{template}}을 쓰면 된다.
구구절절하게 설명하는 것보단 실 사용 예시를 보는 게 빠르다. 프로젝트 구조가 아래와 같다고 하면
webserver \_ views | \_ bar | \_ B.html | \_ foo | \_ A.html \_ webserber.go main.go
Plain Text
복사
각각의 파일 내용을 아래처럼 하면 된다.
layout.html
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>{{block "title" .}}{{end}}</title> </head> <body> {{block "content" .}}{{end}} </body> </html>
Go
복사
A.html
{{template "layout.html" .}} {{define "title"}} Page A {{end}} {{define "content"}} <h1>A content</h1> {{end}}
Go
복사
위와 같이 하면 파싱 시 layout.html 템플릿을 상속(inheritance) 받는 효과가 있다. layout template이 먼저 들어오고, layout template 내부의 title과 content 템플릿이 생성된 이후에 다시 같은 이름의 title과 content 템플릿이 생성된다. 이때 layout의 기본 구조는 이어받으면서 내용물을 교체할 title, content 템플릿은 덮어씌우게(override) 되는 것이다.
B.html
{{template "layout.html" .}} {{define "title"}} Page B {{end}} {{define "content"}} <h1>B content</h1> {{end}}
Go
복사
A.html과 같은 형식이다,
webserver.go
package webserver import ( "embed" "html/template" "io/fs" "os" "path/filepath" "strings" ) var ( // HTML 템플릿을 모아둔 컬렉션 tmpl *template.Template //tmplFS - HTML 템플릿 파일들을 바이너리 형태로 메모리에 올린 파일시스템 구현체(읽기 전용) //go:embed는 메모리로 퍼올릴 파일시스템 경로를 지정하는 컴파일러 지시어. 현재 webserver.go 파일 상대경로로 디렉터리 지정 //go:embed views tmplFS embed.FS ) func InitWebserver() { // 새로운 템플릿 컬렉션 생성 tmpl = template.New("") // views 디렉터리 아래 모든 .html 파일을 템플릿 컬렉션에 추가 err := filepath.WalkDir("webserver/views/", func(path string, d fs.DirEntry, err error) error { if err != nil { panic(err) return err } // 파일에 대해서만 템플릿 생성 if d.IsDir() { return nil } // .html 파일에 대해서만 템플릿 생성 if !strings.Contains(path, ".html") { return nil } // webserver/views 디렉터리 기준 .html 파일의 상대경로 획득 // filepath.Rel의 첫 번째 파라미터로 프로젝트 루트 기준 상대경로를 넣어줘야 함 relativePath, err := filepath.Rel("webserver/views", path) if err != nil { panic(err) return err } // os가 windows인 경우 path 구분자를 백슬래시(\)에서 슬래시(/)로 변경 relativePath = strings.Replace(relativePath, "\\", "/", -1) // embed.FS에서 파일 내용 byte 배열로 읽어오기 // tmplFS.ReadFile의 첫 번째 파라미터로 현재 webserver.go 파일 기준 상대경로를 넣어줘야 함 data, err := tmplFS.ReadFile("views/" + relativePath) if err != nil { panic(err) return err } // views 기준 상대경로를 이름으로 가지는 템플릿을 생성해서 컬렉션에 추가 // layout.html // bar/B.html // foo/A.html tmpl, err = tmpl.New(relativePath).Parse(string(data)) if err != nil { panic(err) return err } return nil }) if err != nil { panic(err) } } func Render(htmlFilePath string) { clonedTmpl, err := tmpl.Clone() if err != nil { panic(err) } clonedTmpl, err = clonedTmpl.ParseFS(tmplFS, "views/"+htmlFilePath) if err != nil { panic(err) } err = clonedTmpl.ExecuteTemplate(os.Stdout, htmlFilePath, nil) if err != nil { panic(err) } }
Go
복사
embed.FS를 사용해서 파일 시스템을 메모리로 퍼올린다. 메모리 용량을 고려해서 그냥 파일시스템을 그대로 써도 된다.
filepath.WalkDir() 메서드를 통해 .html 템플릿 파일들을 모두 순회하면서 하나의 템플릿 컬렉션에 파싱한다. 이때 A.htmlB.html은 모두 title, content처럼 똑같은 이름의 템플릿을 가지고 있으므로 최종적으로는 하나의 title 템플릿과 하나의 content 템플릿이 있는 상태가 된다.
물론 실무에서는 header, footer 등의 템플릿이 추가로 들어갈 수도 있다.
Render() 메서드에서는 위의 템플릿 컬렉션을 Clone() 메서드로 복제하여 요청받은 템플릿을 한 번 더 파싱하여 title과 content 템플릿이 원하는 템플릿이 되도록 덮어쓰기한 후 실행한다.
예제이기 때문에 os.Stdout을 사용했지만 실제 웹서버에서는 아래와 같은 형태가 된다.
func render(w http.ResponseWriter, htmlFilePath string, data any) { clonedTmpl, err := tmpl.Clone() if err != nil { log.Error(err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } clonedTmpl, err = clonedTmpl.ParseFS(tmplFS, "views/"+htmlFilePath) if err != nil { log.Error(err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } err = clonedTmpl.ExecuteTemplate(w, htmlFilePath, data) if err != nil { log.Error(err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } }
Go
복사
main.go
package main import ( "go-prac-template/webserver" ) func main() { webserver.InitWebserver() webserver.Render("foo/A.html") }
Go
복사
위 앱을 실행하면 콘솔에 아래같은 결과가 뜬다.
foo/A.htmlbar/B.html로 바꿔서 실행해보면
잘 바뀌어 출력되는 것을 볼 수 있다.

기타

템플릿에 사용되는 파일을 .html 대신 .tmpl 확장자로 쓰기도 한다. 확장자가 프로그램 자체에 영향을 미치는 것은 없고, 정적 HTML 파일과 구분하기 위한 관행이다. 나는 개인적으로 IDE의 문법 강조 및 자동 완성을 위해 .html 확장자를 그대로 쓴다.

References