Skip to content

template

공식 문서: template package - text/template - Go Packages

평소 우리는 자주 fmt.Sprintf 함수를 사용하여 문자열을 포맷팅하지만, 이는 작은 문자열 처리에만 적합하며 포맷팅 동사를 사용하여 유형을 지정해야 합니다. 매개변수 명명도 지원하지 않고 복잡한 상황의 처리도 불가능합니다. 이것이 바로 템플릿 엔진이 해결하려는 문제입니다. 예를 들어 백엔드 정적 HTML 페이지를 직접 표시할 때 템플릿 엔진이 필요합니다. 커뮤니티에는 pongo2, sprig, jet 과 같은 많은 우수한 서드파티 템플릿 엔진 라이브러리가 있지만, 본 문서에서 설명할 주인공은 Go 내장 템플릿 엔진 라이브러리 text/template 입니다. 실제 개발에서는 일반적으로 html/template 을 사용하며, 후자는 전자를 기반으로 하며 HTML 에 대한 많은 안전 처리를 수행했습니다. 일반적인 경우에는 전자를 사용하면 되지만, HTML 템플릿 처리와 관련된 경우에는 후자를 사용하는 것이 더 안전합니다.

빠른 시작

다음은 템플릿 엔진 사용에 대한 간단한 예시입니다.

go
package main

import (
  "fmt"
  "os"
  "text/template"
)

func main() {
  tmpl := `This is the first template string, {{ .message }}`

  te, err := template.New("texTmpl").Parse(tmpl)
  if err != nil {
    fmt.Println(err)
    return
  }

  data := map[string]any{
    "message": "hello world!",
  }
  execErr := te.Execute(os.Stdout, data)
  if execErr != nil {
    fmt.Println(err)
  }
}

위 코드의 출력은 다음과 같습니다.

This is the first template string, hello world!

사례 코드에서 tmpl 은 템플릿 문자열이며, 문자열 내의 {{ .message }} 는 템플릿 엔진의 템플릿 매개변수입니다. 먼저 *Template.Parse 메서드를 통해 템플릿 문자열을 파싱합니다.

go
func (t *Template) Parse(text string) (*Template, error)

파싱 성공 후 *Template.Execute 메서드를 통해 data 데이터를 템플릿에 적용하고 마지막으로 전달된 Writeros.Stdout 에 출력합니다.

go
func (t *Template) Execute(wr io.Writer, data any) error

이후 템플릿 엔진 사용에서는 기본적으로 다음 세 단계로 이루어집니다.

  1. 템플릿 가져오기
  2. 템플릿 파싱
  3. 데이터를 템플릿에 적용

템플릿 엔진 사용은 매우 간단하며, 조금 더 복잡한 것은 템플릿 엔진의 템플릿 문법입니다. 이것이 본 문서에서 주로 설명할 내용입니다.

템플릿 문법

매개변수

Go 는 한 쌍의 중괄호 {{ }} 를 사용하여 템플릿에서 템플릿 매개변수를 나타내며, . 을 사용하여 루트 객체를 나타냅니다. 루트 객체는 전달된 data 입니다. 마치 유형의 멤버 변수에 접근하는 것처럼 . 기호를 사용하여 변수명을 연결하면 템플릿에서 해당 값에 접근할 수 있습니다. 예를 들어

{{ .data }}

동일한 이름의 멤버 변수가 존재해야 하며, 그렇지 않으면 오류가 발생합니다. 전달된 data 는 일반적으로 구조체 또는 map 이며, 기본 유형일 수도 있습니다. 예를 들어 숫자나 문자열일 수 있으며, 이때 . 이 나타내는 루트 객체는 그 자체입니다. 중괄호 내에서는 반드시 루트 객체에 접근하여 값을 가져올 필요 없으며, 기본 유형의 리터럴일 수도 있습니다. 예를 들어

{{ 1 }}
{{ 3.14 }}
{{ "jack" }}

어떤 유형이든 최종적으로 fmt.Sprintf("%s", val) 을 통해 문자열 표현 형태를 가져옵니다. 아래 예시를 보십시오.

go
func main() {
  out := os.Stdout

  tmpl := "data-> {{ . }}\n"

  datas := []any{
    "hello world!",
    6379,
    3.1415926,
    []any{1, "2*2", 3.6},
    map[string]any{"data": "hello world!"},
    struct {
      Data string
    }{Data: "hello world!"},
  }

  for _, data := range datas {
    err := ExecTmpl(out, tmpl, data)
    if err != nil {
      panic(err)
    }
  }
}

func ExecTmpl(writer io.Writer, tmpl string, data any) error {
  parsedTmpl, err := template.New("template").Parse(tmpl)
  if err != nil {
    return err
  }
  return parsedTmpl.Execute(writer, data)
}

출력은 다음과 같습니다.

data-> hello world!
data-> 6379
data-> 3.1415926
data-> [1 2*2 3.6]
data-> map[data:hello world!]
data-> {hello world!}

출력 형태가 fmt.Sprintf 를 직접 사용하는 것과 동일함을 알 수 있습니다. 구조체와 map 의 경우 필드명을 통해 값에 접근할 수 있습니다. 아래 예시를 보십시오.

go
func main() {
  out := os.Stdout

  tmpl := "data-> {{ .Data }}\n"

  datas := []any{
    map[string]any{"Data": "hello world!"},
    struct {
      Data string
    }{Data: "hello world!"},
  }

  for _, data := range datas {
    err := ExecTmpl(out, tmpl, data)
    if err != nil {
      panic(err)
    }
  }
}

출력은 다음과 같습니다.

data-> hello world!
data-> hello world!

슬라이스와 map 의 경우 특정 인덱스의 값에 접근하기 위한 특정 문법을 제공하지는 않지만, 함수 호출 방식을 통해 구현할 수 있습니다. 아래 예시를 보십시오.

go
func main() {
  out := os.Stdout

  tmpl := "data-> {{ index . 1}}\n"

  datas := []any{
    []any{"first", "second"},
    map[int]any{1: "first"},
  }

  for _, data := range datas {
    err := ExecTmpl(out, tmpl, data)
    if err != nil {
      panic(err)
    }
  }
}

출력

data-> second
data-> first

다차원 슬라이스의 경우 다음 방식을 통해 해당 인덱스의 값에 접근할 수 있으며, s[i][j][k] 와 동일합니다.

{{ index . i j k }}

중첩된 구조체나 map 의 경우 .k1.k2.k3 방식을 사용하여 접근할 수 있습니다. 예를 들어

{{ .person.father.name }}

템플릿 매개변수를 사용할 때 매개변수 앞뒤에 - 기호를 추가하여 앞뒤 공백을 제거할 수 있습니다. 예시를 보십시오.

go
func main() {
  out := os.Stdout

  tmpl := `{{ .x }} {{ - .op - }} {{ .y }}`

  datas := []any{
    map[string]any{"x": "10", "op": ">", "y": "2"},
  }

  for _, data := range datas {
    err := ExecTmpl(out, tmpl, data)
    if err != nil {
      panic(err)
    }
  }
}

정상적으로 출력 결과는 10 > 2 여야 하지만, op 매개변수 앞뒤에 - 기호를 추가했으므로 앞뒤 공백이 모두 제거되어 실제 출력은 다음과 같습니다.

10>2

중괄호 내에서 - 기호와 매개변수는 공백 하나를 사이에 두어야 한다는 점에 유의해야 합니다. 즉, {{- . -}} 형식이어야 합니다. 예시에서 양쪽에 공백을 추가하여 {{ - . - }} 형식으로 작성한 것은 단순히 개인적으로 보기에 좋기 때문이며, 실제로는 이러한 문법 제한이 없습니다.

주석

템플릿 문법은 주석을 지원하며, 주석은 최종 템플릿에서 생성되지 않습니다. 문법은 다음과 같습니다.

{{/* this is a comment */}}

주석 기호 /**/ 는 중괄호와 인접해야 하며, 사이에 다른 문자가 있으면 정상적으로 파싱되지 않습니다. 예외적인 경우는 공백을 제거할 때뿐입니다.

{{- /* this is a comment */ -}}

변수

템플릿에서도 변수를 선언할 수 있으며, $ 기호를 사용하여 변수임을 나타내고 := 를 통해 할당합니다. Go 코드와 동일합니다. 예시는 다음과 같습니다.

{{ $name := .Name }}
{{ $val := index . 1 }}
{{ $val := index .dict key }}
// 정수 할당
{{ $numer := 1 }}
// 부동소수점 할당
{{ $float := 1.234}}
// 문자열 할당
{{ $name := "jack" }}

이후 사용 시 $ 를 변수명 앞에 붙여 해당 변수의 값에 접근합니다. 예를 들어

go
func main() {
  out := os.Stdout

  tmpl := `{{ $name := .name }} {{- $name }}`

  datas := []any{
    map[string]any{"name": "jack"},
  }

  for _, data := range datas {
    err := ExecTmpl(out, tmpl, data)
    if err != nil {
      panic(err)
    }
  }
}

출력

jack

변수는 사용하기 전에 반드시 선언해야 하며, 그렇지 않으면 undefined variable 오류가 발생합니다. 또한 작용 범위 내에서만 사용할 수 있습니다.

함수

템플릿 자체의 문법은 실제로 많지 않으며, 대부분의 기능은 함수를 통해 구현됩니다. 함수 호출 형식은 함수명 뒤에 매개변수 목록을 공백을 구분 기호로 하여 연결하는 것입니다. 예를 들어

{{ funcname arg1 arg2 arg3 ... }}

이전에 사용한 index 함수

{{ index .s 1 }}

같음을 비교하는 eq 함수

{{ eq 1 2 }}

*Template 은 함수 매핑을 기록하는 FuncsMap 을 가지고 있습니다.

go
type FuncMap map[string]any

템플릿 생성 시 text/template.builtins 에서 기본 함수 매핑 테이블을 가져옵니다. 다음은 내장된 모든 함수입니다.

함수명역할예시
andAND 연산{{ and true false }}
orOR 연산{{ or true false }}
notNOT 연산{{ not true }}
eq같은지 여부{{ eq 1 2 }}
ne다른지 여부{{ ne 1 2 }}
lt작음{{ lt 1 2 }}
le작거나 같음{{ le 1 2 }}
gt{{ gt 1 2 }}
ge크거나 같음{{ ge 1 2 }}
len길이 반환{{ len .slice }}
index대상의 지정 인덱스 요소{{ index . 0 }}
slice슬라이스, s[1:2:3] 과 동일{{ slice . 1 2 3 }}
htmlHTML 이스케이프{{ html .name }}
jsJS 이스케이프{{ js .name }}
printfmt.Sprint{{ print . }}
printffmt.Sprintf{{ printf "%s" .}}
printlnfmt.Sprintln{{ println . }}
urlqueryurl query 이스케이프{{ urlquery .query }}

이 외에도 비교적 특별한 내장 함수 call 이 있으며, 이는 Execute 시 전달된 data 의 함수를 직접 호출하는 데 사용됩니다. 예를 들어 다음 템플릿

{{ call .string 1024 }}

전달된 데이터는 다음과 같습니다.

go
map[string]any{
    "string": func(val any) string { return fmt.Sprintf("%v: 2048", val) },
}

템플릿에서 생성되는 결과는 다음과 같습니다.

1024: 2048

이것은 사용자 정의 함수를 만드는 경로 중 하나이지만, 일반적으로 *Template.Funcs 메서드를 사용하여 사용자 정의 함수를 추가하는 것을 권장합니다. 후자는 전역적으로 작용하며 루트 객체에 바인딩할 필요가 없기 때문입니다.

go
func (t *Template) Funcs(funcMap FuncMap) *Template

사용자 정의 함수의 반환 값은 일반적으로 두 개입니다. 첫 번째는 사용되는 반환 값이고 두 번째는 error 입니다. 예를 들어 다음과 같은 사용자 정의 함수가 있습니다.

go
template.FuncMap{
    "add": func(val any) (string, error) { return fmt.Sprintf("%v+1", val), nil },
}

템플릿에서 직접 사용합니다.

{{ add 1024 }}

결과는 다음과 같습니다.

1024 + 1

파이프

이 파이프는 chan 과는 다른 것입니다. 공식 문서에서는 이를 pipeline 이라고 부르며, 데이터를 생성할 수 있는 모든 작업을 pipeline 이라고 합니다. 다음 템플릿 작업은 모두 파이프 작업에 속합니다.

{{ 1 }}
{{ eq 1 2 }}
{{ $name }}
{{ .name }}

리눅스에 익숙한 분들은 파이프 연산자 | 를 알고 있을 것입니다. 템플릿에서도 이러한 형식을 지원합니다. 파이프 작업은 템플릿에서 자주 사용됩니다. 예를 들어

{{ $name := 1 }}{{ $name | print | printf "%s+1=?" }}

결과는 다음과 같습니다.

1+1=?

이후 with, if, range 에서도 자주 사용됩니다.

with

with 문을 통해 변수와 루트 객체의 작용 범위를 제어할 수 있습니다. 형식은 다음과 같습니다.

{{ with pipeline }}
  text
{{ end }}

with 는 파이프 연산이 반환한 값을 확인하며, 값이 비어 있으면 중간 text 템플릿이 생성되지 않습니다. 빈 경우를 처리하려면 with else 를 사용할 수 있습니다. 형식은 다음과 같습니다.

{{ with pipeline }}
  text1
{{ else }}
  text2
{{ end }}

파이프 연산이 반환한 값이 비어 있으면 else 블록의 로직이 실행됩니다. with 문에서 선언된 변수의 작용 범위는 with 문 내로 제한됩니다. 아래 예시를 보십시오.

{{ $name := "mike" }}
{{ with $name := "jack"  }}
  {{- $name -}}
{{ end }}
{{- $name -}}

출력은 다음과 같습니다. 작용 범위가 다르기 때문에 두 개의 다른 변수임을 알 수 있습니다.

jackmike

with 문을 통해 작용 범위 내에서 루트 객체를 재작성할 수도 있습니다.

{{ with .name }}
  name: {{- .second }}-{{ .first -}}
{{ end }}
age: {{ .age }}
address: {{ .address }}

다음 데이터를 전달합니다.

go
map[string]any{
    "name": map[string]any{
        "first":  "jack",
        "second": "bob",
    },
    "age":     1,
    "address": "usa",
}

출력

name:bob-jack
age: 1
address: usa

with 문 내부에서 루트 객체 ..name 으로 변경되었음을 알 수 있습니다.

조건

조건 문의 형식은 다음과 같습니다.

{{ if pipeline }}
  text1
{{ else if pipeline }}
  text2
{{ else }}
  text3
{{ end }}

일반 코드를 작성하는 것과 동일하게 매우 이해하기 쉽습니다. 아래에 몇 가지 간단한 예시를 보십시오.

{{ if eq .lang "en" }}
  {{- .content.en -}}
{{ else if eq .lang "zh" }}
  {{- .content.zh -}}
{{ else }}
  {{- .content.fallback -}}
{{ end }}

전달된 데이터

go
map[string]any{
    "lang": "zh",
    "content": map[string]any{
        "en":       "hello, world!",
        "zh":       "你好,世界!",
        "fallback": "hello, world!",
    },
}

예시의 템플릿은 전달된 언어 lang 에 따라 어떤 방식으로 내용을 표시할지 결정합니다. 출력 결과

你好,世界!

반복

반복 문의 형식은 다음과 같습니다. range 가 지원하는 pipeline 은 배열, 슬라이스, map, 그리고 channel 이어야 합니다.

{{ range pipeline }}
  loop body
{{ end }}

else 와 함께 사용하여 길이가 0 일 때 else 블록 내용을 실행합니다.

{{ range pipeline }}
  loop body
{{ else }}
  fallback
{{ end }}

이 외에도 break, continue 와 같은 작업도 지원합니다. 예를 들어

{{ range pipeline }}
  {{ if pipeline }}
    {{ break }}
  {{ end }}
  {{ if pipeline }}
    {{ continue }}
  {{ end }}
  loop body
{{ end }}

아래에 반복 예시를 보십시오.

{{ range $index, $val := . }}
  {{- if eq $index 0 }}
    {{- continue -}}
  {{ end -}}
  {{- $index}}: {{ $val }}
{{ end }}

전달된 데이터

go
[]any{1, "2", 3.14},

출력

1: 2
2: 3.14

map 을 반복하는 것도 동일합니다.

중첩

하나의 템플릿에는 여러 개의 템플릿을 정의할 수 있습니다. 예를 들어

{{ define "t1" }} t1 {{ end }}
{{ define "t2" }} t2 {{ end }}

이렇게 정의된 템플릿은 최종 템플릿에 생성되지 않으며, 로드 시 이름을 지정하거나 template 문을 통해 수동으로 지정하지 않는 한 생성되지 않습니다.

func (t *Template) ExecuteTemplate(wr io.Writer, name string, data any) error

아래 예시와 같습니다.

{{ define "t1" }}
    {{- with .t1 }}
      {{- .data -}}
    {{ end -}}
{{ end }}
{{ define "t2" }}
    {{- with .t2 }}
      {{- .data -}}
    {{ end}}
{{ end -}}

다음 데이터를 전달합니다.

go
map[string]any{
    "t1": map[string]any{"data": "template body 1"},
    "t2": map[string]any{"data": "template body 2"},
}

코드

go
func main() {
  out := os.Stdout

  tmpl :=
    `{{ define "t1" }}
    {{- with .t1 }}
      {{- .data -}}
    {{ end -}}
{{ end }}
{{ define "t2" }}
    {{- with .t2 }}
      {{- .data -}}
    {{ end}}
{{ end -}}`

  datas := []any{
    map[string]any{
      "t1": map[string]any{"data": "template body 1"},
      "t2": map[string]any{"data": "template body 2"},
    },
  }

  name := "t1"

  for _, data := range datas {
    err := ExecTmpl(out, tmpl, name, data)
    if err != nil {
      panic(err)
    }
  }
}

func ExecTmpl(writer io.Writer, tmpl string, name string, data any) error {
  t := template.New("template")
  parsedTmpl, err := t.Parse(tmpl)
  if err != nil {
    return err
  }
  return parsedTmpl.ExecuteTemplate(writer, name, data)
}

출력

template body 1

또는 템플릿을 수동으로 지정할 수도 있습니다.

{{ define "t1" }}
    {{- with .t1 }}
      {{- .data -}}
    {{ end -}}
{{ end }}
{{ define "t2" }}
    {{- with .t2 }}
      {{- .data -}}
    {{ end}}
{{ end -}}
{{ template "t2" .}}

파싱 시 템플릿 이름을 지정했는지 여부와 관계없이 t2 가 로드됩니다.

연결

서브 템플릿은 하나의 템플릿 내부에서 여러 개의 명명된 템플릿을 선언하는 것이며, 연결은 외부의 여러 명명된 *Template 을 연결하는 것입니다. 그런 다음 template 문을 통해 지정된 템플릿을 참조합니다.

{{ template "templateName" pipeline}}

pipeline 은 연결된 템플릿의 루트 객체를 필요에 따라 지정할 수 있으며, 현재 템플릿의 루트 객체를 직접 전달할 수도 있습니다. 아래 코드 예시를 보십시오.

go
func main() {
  tmpl1 := `name: {{ .name }}`

  tmpl2 := `age: {{ .age }}`

  tmpl3 := `Person Info
{{template "t1" .}}
{{template "t2" .}}`

  t1, err := template.New("t1").Parse(tmpl1)
  if err != nil {
    panic(err)
  }

  t2, err := template.New("t2").Parse(tmpl2)
  if err != nil {
    panic(err)
  }

  t3, err := template.New("t3").Parse(tmpl3)
  if err != nil {
    panic(err)
  }

  if err := associate(t3, t1, t2); err != nil {
    panic(err)
  }

  err = t3.Execute(os.Stdout, map[string]any{
    "name": "jack",
    "age":  18,
  })
  if err != nil {
    panic(err)
  }
}

func associate(t *template.Template, ts ...*template.Template) error {
  for _, tt := range ts {
    _, err := t.AddParseTree(tt.Name(), tt.Tree)
    if err != nil {
      return err
    }
  }
  return nil
}

위 코드에서 t3 는 t1 과 t2 를 연결하며, *Template.AddParseTree 메서드를 사용하여 연결합니다.

go
func (t *Template) AddParseTree(name string, tree *parse.Tree) (*Template, error)

최종 템플릿 생성 결과는 다음과 같습니다.

Person Info
name: jack
age: 18

슬롯

block 문을 통해 vue 의 슬롯과 유사한 효과를 구현할 수 있으며, 이는 특정 템플릿을 재사용하기 위한 것입니다. 사용 사례를 보면 어떻게 사용하는지 알 수 있습니다. t1 템플릿에서 슬롯을 정의합니다.

Basic Person Info
name: {{ .name }}
age: {{ .age }}
address: {{ .address }}
{{ block "slot" . }} default content body {{ end }}

block 문은 슬롯의 기본 내용을 정의할 수 있으며, 이후 다른 템플릿에서 슬롯을 사용할 때 기본 내용을 덮어씁니다. t2 템플릿에서 t1 템플릿을 참조하고 define 을 사용하여 삽입할 내용을 정의합니다.

{{ template "person.txt" . }}
{{ define "slot" }}
school: {{ .school }}
{{ end }}

두 템플릿을 연결한 후 다음 데이터를 전달합니다.

go
map[string]any{
    "name":    "jack",
    "age":     18,
    "address": "usa",
    "company": "google",
    "school":  "mit",
}

최종 출력 결과는 다음과 같습니다.

Basic Person Info
name: jack
age: 18
address: usa

school: mit

템플릿 파일

템플릿 문법 사례에서는 모두 문자열 리터럴을 템플릿으로 사용했지만, 실제 사용 상황에서는 대부분 템플릿을 파일에 저장합니다.

go
func ParseFS(fsys fs.FS, patterns ...string) (*Template, error)

예를 들어 template.ParseFs 는 지정된 파일 시스템에서 pattern 과 일치하는 템플릿을 로드합니다. 아래 예시는 embed.FS 를 파일 시스템으로 사용하며, 세 개의 파일을 준비합니다.

txt
# person.txt
Basic Person Info
name: {{ .name }}
age: {{ .age }}
address: {{ .address }}
{{ block "slot" . }} {{ end }}

# student.txt
{{ template "person.txt" . }}
{{ define "slot" }}
school: {{ .school }}
{{ end }}

# employee.txt
{{ template "person.txt" . }}
{{ define "slot" }}
company: {{ .company }}
{{ end }}

코드는 다음과 같습니다.

go
import (
  "embed"
  "os"
  "text/template"
)

//go:embed *.txt
var fs embed.FS

func main() {
  data := map[string]any{
    "name":    "jack",
    "age":     18,
    "address": "usa",
    "company": "google",
    "school":  "mit",
  }

  t1, err := template.ParseFS(fs, "person.txt", "student.txt")
  if err != nil {
    panic(err)
  }

  t1.Execute(os.Stdout, data)

  t2, err := template.ParseFS(fs, "person.txt", "employee.txt")
  if err != nil {
    panic(err)
  }
  t2.Execute(os.Stdout, data)
}

출력은 다음과 같습니다.

Basic Person Info
name: jack
age: 18
address: usa

school: mit
Basic Person Info
name: jack
age: 18
address: usa

company: google

이는 매우 간단한 템플릿 파일 사용 사례로, person.txt 는 슬롯 파일로 사용되며 다른 두 파일은 내용을 재사용하고 사용자 정의된 새 내용을 삽입합니다. 다음 두 함수를 사용할 수도 있습니다.

go
func ParseGlob(pattern string) (*Template, error)

func ParseFiles(filenames ...string) (*Template, error)

ParseGlob 은 와일드카드 매칭을 기반으로 하며, ParseFiles 는 파일 이름을 기반으로 합니다. 둘 다 로컬 파일 시스템을 사용합니다. 프론트엔드에 표시할 html 파일인 경우 html/template 패키지를 사용하는 것을 권장합니다. 해당 패키지는 text/template 과 완전히 동일한 API 를 제공하지만, html, css, js 에 대한 안전 처리를 수행합니다.

Golang by www.golangdev.cn edit