본문으로 건너뛰기

리플렉션과 구조체 태그

이 챕터에서 다루는 것

Go는 정적 타입 언어인데, json.Marshal은 임의의 구조체를 받아 필드 이름을 알아내 JSON을 만든다. 컴파일 시점에 그 구조체를 몰랐는데 어떻게 하는가. 답이 리플렉션이다.

reflect 패키지의 사용법, 구조체 태그의 정확한 문법, encoding/json이 태그로 무슨 일을 하는지, 그리고 왜 리플렉션을 되도록 피해야 하는지를 다룬다.

4-4 연습문제에서 "reflect.TypeOf(a).Comparable()은 Part 5에 가서 써도 된다"고 미뤄 둔 것도 여기서 갚는다.

문제 — 컴파일 시점에 모르는 타입을 다뤄야 할 때

지금까지 배운 도구로 "임의의 구조체를 쿼리 문자열로 바꾸는 함수"를 쓸 수 있는가.

  • 인터페이스(4-3)는 안 된다. 각 구조체가 ToQuery() 메서드를 직접 구현해야 하는데, 그러면 자동화가 아니다.
  • 제네릭(5-1)도 안 된다. T의 필드 목록에 접근하는 문법이 없다. 제약으로 "필드가 있음"을 요구할 수도 없다.
  • any 로 받아도 그 안을 들여다볼 수 없다. 타입 단언은 아는 타입에만 쓴다.

필드 목록을 실행 시점에 조회해야 한다. 그것을 하는 유일한 방법이 reflect다.

리플렉션은 4-4에서 본 (타입, 값) 쌍의 타입 쪽을 프로그램이 직접 열어 보는 기능이다. 인터페이스에 이미 타입 정보가 들어 있으므로 새 정보를 만드는 것이 아니라, 이미 있는 것을 읽는 API다.

reflect.Typereflect.Value

examples/05-generics-and-advanced/05-reflect-basics/main.go
package main

import (
"fmt"
"reflect"
)

type User struct {
ID int
Name string
Tags []string
Admin bool
note string // 비공개 필드
}

func (u User) Greet() string { return "안녕하세요, " + u.Name }

func main() {
u := User{ID: 7, Name: "김개발", Tags: []string{"go"}, Admin: true, note: "메모"}

t := reflect.TypeOf(u)
v := reflect.ValueOf(u)

// Type은 "무슨 타입인가", Kind는 "어떤 부류인가"다.
fmt.Println("Type:", t, "/ Kind:", t.Kind())
fmt.Println("이름:", t.Name(), "/ 패키지 경로:", t.PkgPath())

type Celsius float64
ct := reflect.TypeOf(Celsius(36.5))
fmt.Println("Celsius → Type:", ct, "Kind:", ct.Kind())

// 필드 순회. Go 1.26의 이터레이터 메서드를 쓰면 인덱스 루프가 필요 없다.
fmt.Println("--- 필드")
for sf, fv := range v.Fields() {
// 비공개 필드는 값을 읽을 수 없다. Interface()가 패닉이다.
if !sf.IsExported() {
fmt.Printf(" %-6s %-10v (비공개, 값 읽기 불가)\n", sf.Name, sf.Type)
continue
}
fmt.Printf(" %-6s %-10v = %v\n", sf.Name, sf.Type, fv.Interface())
}

fmt.Println("--- 메서드")
for m := range t.Methods() {
fmt.Printf(" %s %v\n", m.Name, m.Type)
}

// ---- 4-4에서 미뤄 둔 것: 비교 가능성을 런타임에 묻기 ----
fmt.Println("--- Comparable")
for _, x := range []any{42, "문자열", User{}, []int{1}, map[string]int{}, [2]int{}} {
fmt.Printf(" %-18T %t\n", x, reflect.TypeOf(x).Comparable())
}

// ---- 값을 바꾸려면 포인터를 거쳐야 한다 ----
fmt.Println("--- 설정 가능성")
rv := reflect.ValueOf(u)
fmt.Println("값에서 얻은 Value CanSet:", rv.Field(1).CanSet())

pv := reflect.ValueOf(&u).Elem() // Elem으로 포인터를 한 겹 벗긴다
fmt.Println("포인터에서 얻은 Value CanSet:", pv.Field(1).CanSet())
pv.Field(1).SetString("이배포")
fmt.Println("변경 후:", u.Name)

// 비공개 필드는 포인터를 거쳐도 못 바꾼다.
fmt.Println("비공개 필드 CanSet:", pv.FieldByName("note").CanSet())

// ---- Value를 원래 타입으로 되돌리기 ----
// Interface() + 타입 단언이 전통적인 방법이다.
back := v.Interface().(User)
fmt.Println("Interface() 단언:", back.ID)

// TypeAssert는 같은 일을 하면서 인터페이스 박싱 할당을 피한다.
if name, ok := reflect.TypeAssert[string](v.Field(1)); ok {
fmt.Println("TypeAssert[string]:", name)
}
if _, ok := reflect.TypeAssert[int](v.Field(1)); !ok {
fmt.Println("TypeAssert[int]는 실패한다")
}

// ---- 잘못 쓰면 런타임 패닉이다 ----
func() {
defer func() { fmt.Println("복구:", recover()) }()
_ = v.Field(0).String() // int에 String()을 부르면 패닉이 아니라 이상한 값이 나온다
v.Field(0).SetInt(1) // 이쪽이 패닉이다
}()

fmt.Println("int Field에 String():", v.Field(0).String())
}
go run ./05-reflect-basics
Type: main.User / Kind: struct
이름: User / 패키지 경로: main
Celsius → Type: main.Celsius Kind: float64
--- 필드
ID int = 7
Name string = 김개발
Tags []string = [go]
Admin bool = true
note string (비공개, 값 읽기 불가)
--- 메서드
Greet func(main.User) string
--- Comparable
int true
string true
main.User false
[]int false
map[string]int false
[2]int true
--- 설정 가능성
값에서 얻은 Value CanSet: false
포인터에서 얻은 Value CanSet: true
변경 후: 이배포
비공개 필드 CanSet: false
Interface() 단언: 7
TypeAssert[string]: 김개발
TypeAssert[int]는 실패한다
복구: reflect: reflect.Value.SetInt using unaddressable value
int Field에 String(): <int Value>

TypeKind는 다르다

답하는 질문
Type정확히 무슨 타입인가main.Celsius
Kind어떤 부류인가float64

Kind는 언어가 정한 스물 몇 가지 고정 목록(Int, String, Slice, Struct, Pointer, Interface, Func, ...)이다. 리플렉션 코드는 거의 항상 Kind로 분기한다. 사용자가 정의한 타입이 무엇인지는 미리 알 수 없지만, 그것이 정수 부류인지 슬라이스 부류인지는 알 수 있기 때문이다.

5-2~float64가 컴파일 시점의 근본 타입이라면, Kind는 그 런타임 버전이다.

Comparable() — 4-4의 숙제

main.User false
[2]int true

UserTags []string이 있어서 비교 불가능하다. [2]int는 원소가 비교 가능하므로 가능하다. 5-2에서 컴파일 시점에 comparable로 잡던 것과 같은 규칙을 런타임에 물어보는 방법이다.

comparable 제약이 있는데 왜 이것이 필요한가. 타입이 any로 들어올 때다. 5-2에서 본 대로 인터페이스 타입은 comparable을 만족하므로 컴파일러가 막아 주지 않는다. ==를 하기 전에 reflect.TypeOf(x).Comparable()로 확인하면 패닉 대신 분기할 수 있다. errors.Is가 내부에서 하는 일이 정확히 이것이다.

설정 가능성 — 리플렉션의 첫 관문

값에서 얻은 Value CanSet: false
포인터에서 얻은 Value CanSet: true

reflect.ValueOf(u)u복사본을 받는다. 복사본을 고쳐 봐야 원본이 안 바뀌므로, reflect는 아예 금지한다. 고치려면 포인터를 넘기고 Elem()으로 한 겹 벗긴다.

pv := reflect.ValueOf(&u).Elem()
pv.Field(1).SetString("이배포")

이것이 json.Unmarshal&v를 요구하는 이유다. 인자를 값으로 주면 reflect가 채울 수 없다.

비공개 필드는 포인터를 거쳐도 못 바꾼다. CanSet()false이고, Interface()를 부르면 패닉이다. 패키지 경계가 리플렉션으로 뚫리지 않는다는 뜻이며, Part 6의 export 규칙이 런타임에도 유지된다.

reflect.TypeAssert

func TypeAssert[T any](v Value) (T, bool)

v.Interface().(T)와 의미가 같지만 중간에 인터페이스 값을 만들지 않는다. Interface()는 값을 any에 담느라 힙 할당이 생길 수 있는데, 그 단계를 건너뛴다. 리플렉션 코드에서 값을 꺼내는 자리에는 이쪽을 쓴다.

잘못 쓰면 런타임 패닉이다

복구: reflect: reflect.Value.SetInt using unaddressable value
int Field에 String(): <int Value>

reflect의 거의 모든 메서드는 잘못된 Kind에 대해 패닉을 낸다. 여기서 더 나쁜 것은 String()이다. 이것만 패닉을 내지 않고 <int Value>라는 문자열을 돌려준다. v.String()으로 값을 꺼내려다 이 문자열이 로그에 찍히는 것이 흔한 버그다. 값을 문자열로 뽑으려면 fmt.Sprint(v.Interface())를 쓴다.

:::warning 컴파일러가 아무것도 도와주지 않는다 리플렉션 코드는 타입 검사를 통과한다. 틀린 것은 실행해 봐야 안다. 이 챕터의 핵심 메시지가 이것이고, 마지막 절에서 다시 다룬다. :::

구조체 태그

태그는 필드에 붙이는 문자열 메타데이터다. 언어는 그것을 문자열로 저장할 뿐이고, 의미는 읽는 쪽이 정한다.

type User struct {
Name string `json:"name" db:"user_name" validate:"required"`
}

문법 규칙은 딱 두 가지다.

  1. 필드 선언 뒤의 문자열 리터럴이면 태그다. 보통 백틱을 쓴다 — 안쪽에 큰따옴표가 들어가기 때문이다.
  2. reflect.StructTag의 관례는 공백으로 구분된 key:"value"이다. 이것은 언어 문법이 아니라 reflect가 정한 관례다.
examples/05-generics-and-advanced/05-struct-tags/main.go
package main

import (
"fmt"
"net/url"
"reflect"
"strconv"
)

// 태그는 필드 뒤에 붙는 문자열 리터럴이다. 백틱을 쓰는 이유는 안쪽에 큰따옴표가 들어가서다.
// 관례는 공백으로 구분된 key:"value" 쌍이며, 이 관례는 언어가 아니라 reflect가 강제한다.
type SearchQuery struct {
Keyword string `form:"q" doc:"검색어"`
Page int `form:"page"`
PerPage int `form:"per_page"`
SafeMode bool `form:"safe"`
Internal string `form:"-"`
NoTag string
}

// Encode는 form 태그를 읽어 쿼리 문자열을 만든다.
func Encode(v any) (string, error) {
rv := reflect.ValueOf(v)
if rv.Kind() == reflect.Pointer {
rv = rv.Elem()
}
if rv.Kind() != reflect.Struct {
return "", fmt.Errorf("구조체가 필요하다: %s", rv.Kind())
}

values := url.Values{}
for sf, fv := range rv.Fields() {
if !sf.IsExported() {
continue
}

// Lookup은 태그의 유무를 구분한다. Get은 없을 때도 ""를 준다.
tag, ok := sf.Tag.Lookup("form")
if !ok {
continue // 태그가 없는 필드는 건너뛴다
}
if tag == "-" {
continue // 명시적 제외
}

s, err := scalarString(fv)
if err != nil {
return "", fmt.Errorf("필드 %s: %w", sf.Name, err)
}
values.Set(tag, s)
}
return values.Encode(), nil
}

func scalarString(v reflect.Value) (string, error) {
switch v.Kind() {
case reflect.String:
return v.String(), nil
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return strconv.FormatInt(v.Int(), 10), nil
case reflect.Bool:
return strconv.FormatBool(v.Bool()), nil
default:
return "", fmt.Errorf("지원하지 않는 종류: %s", v.Kind())
}
}

func main() {
q := SearchQuery{
Keyword: "고 언어",
Page: 2,
PerPage: 50,
SafeMode: true,
Internal: "노출되면 안 됨",
NoTag: "무시됨",
}

// ---- 태그를 그대로 들여다보기 ----
t := reflect.TypeOf(q)
fmt.Println("--- 태그 원문")
for f := range t.Fields() {
form, hasForm := f.Tag.Lookup("form")
fmt.Printf(" %-9s 원문=%-22q form=%-8q 있음=%t doc=%q\n",
f.Name, string(f.Tag), form, hasForm, f.Tag.Get("doc"))
}

// ---- 태그로 동작을 바꾸기 ----
encoded, err := Encode(q)
if err != nil {
fmt.Println("Encode 실패:", err)
return
}
fmt.Println("--- 인코딩 결과")
fmt.Println(" ", encoded)

decoded, err := url.QueryUnescape(encoded)
if err != nil {
fmt.Println("Unescape 실패:", err)
return
}
fmt.Println(" (사람이 읽는 형태)", decoded)

// ---- 지원하지 않는 타입은 런타임에야 걸린다 ----
type Bad struct {
Ratios []float64 `form:"ratios"`
}
if _, err := Encode(Bad{}); err != nil {
fmt.Println("--- 런타임 실패:", err)
}

// ---- 구조체가 아니면 역시 런타임에 걸린다 ----
if _, err := Encode(42); err != nil {
fmt.Println("--- 런타임 실패:", err)
}

// ---- 태그 오타는 아무 경고 없이 무시된다 ----
// 구조체 필드에 직접 적으면 go vet이 잡으므로, StructTag 값을 손으로 만들어 확인한다.
fmt.Println("--- 잘못된 태그")
for _, raw := range []reflect.StructTag{
`form:"ok"`,
`form "콜론 없음"`,
`form:콜론은 있지만 따옴표 없음`,
`Form:"대소문자 불일치"`,
} {
v, ok := raw.Lookup("form")
fmt.Printf(" %-32q → %q (있음=%t)\n", string(raw), v, ok)
}
fmt.Println(" → 구조체 필드에 이렇게 적으면 go vet의 structtag 분석기가 잡는다")
}
go run ./05-struct-tags
--- 태그 원문
Keyword 원문="form:\"q\" doc:\"검색어\"" form="q" 있음=true doc="검색어"
Page 원문="form:\"page\"" form="page" 있음=true doc=""
PerPage 원문="form:\"per_page\"" form="per_page" 있음=true doc=""
SafeMode 원문="form:\"safe\"" form="safe" 있음=true doc=""
Internal 원문="form:\"-\"" form="-" 있음=true doc=""
NoTag 원문="" form="" 있음=false doc=""
--- 인코딩 결과
page=2&per_page=50&q=%EA%B3%A0+%EC%96%B8%EC%96%B4&safe=true
(사람이 읽는 형태) page=2&per_page=50&q=고 언어&safe=true
--- 런타임 실패: 필드 Ratios: 지원하지 않는 종류: slice
--- 런타임 실패: 구조체가 필요하다: int
--- 잘못된 태그
"form:\"ok\"" → "ok" (있음=true)
"form \"콜론 없음\"" → "" (있음=false)
"form:콜론은 있지만 따옴표 없음" → "" (있음=false)
"Form:\"대소문자 불일치\"" → "" (있음=false)
→ 구조체 필드에 이렇게 적으면 go vet의 structtag 분석기가 잡는다

GetLookup

func (tag StructTag) Get(key string) string
func (tag StructTag) Lookup(key string) (value string, ok bool)

Get은 없으면 ""를 준다. 그래서 "태그가 없다"와 "태그 값이 빈 문자열이다"를 구분하지 못한다. json:",omitempty"처럼 이름을 비우는 관례가 실재하므로, 이 구분이 필요할 때가 많다. 기본은 Lookup이다.

태그 오타는 조용히 무시된다

출력의 마지막 블록이 이 챕터에서 가장 중요한 줄일지도 모른다.

"form \"콜론 없음\"" → "" (있음=false)
"Form:\"대소문자 불일치\"" → "" (있음=false)

콜론을 빠뜨려도, 따옴표를 빠뜨려도, 대소문자를 틀려도 컴파일 에러가 없다. 그냥 태그가 없는 것처럼 동작한다. JSON 필드 하나가 조용히 사라지는 버그가 이렇게 생긴다.

:::tip go vetstructtag 분석기 다행히 구조체 필드에 직접 적은 잘못된 태그는 go vet이 잡는다.

struct field tag `form "oops"` not compatible with reflect.StructTag.Get: bad syntax for struct tag pair

같은 키가 중복된 것, JSON 이름이 겹치는 것도 잡아 준다. 태그를 쓰는 프로젝트에서 go vet은 선택이 아니다(1-6). 다만 대소문자 불일치나 존재하지 않는 키(jsonn:"...")는 못 잡는다 — 어떤 키가 유효한지는 vet이 알 수 없기 때문이다. :::

encoding/json이 실제로 하는 일

json.Marshal이 마법처럼 보이지만, 지금까지 본 것을 조합한 것이다. 축약판을 직접 만들어 진짜와 비교해 보자.

:::note encoding/json 자체는 Part 9의 주제다 여기서는 리플렉션과 태그가 어떻게 맞물리는지만 본다. 인코딩 규칙 전반, Marshaler 인터페이스, 디코딩, 성능은 Part 9에서 다룬다. :::

examples/05-generics-and-advanced/05-mini-json/main.go
package main

import (
"encoding/json"
"fmt"
"reflect"
"strconv"
"strings"
)

type Profile struct {
ID int `json:"id"`
Name string `json:"name"`
Nickname string `json:"nickname,omitempty"`
Tags []string `json:"tags"`
Password string `json:"-"`
Legacy string // 태그가 없으면 필드 이름을 그대로 쓴다
internal int // 비공개 필드는 아예 대상이 아니다
}

// Marshal은 encoding/json이 태그를 가지고 하는 일을 축약해 재현한다.
// 실제 구현은 훨씬 크고 캐싱과 최적화가 들어가 있다.
func Marshal(v any) (string, error) {
rv := reflect.ValueOf(v)
if rv.Kind() == reflect.Pointer {
if rv.IsNil() {
return "null", nil
}
rv = rv.Elem()
}
if rv.Kind() != reflect.Struct {
return "", fmt.Errorf("구조체가 필요하다: %s", rv.Kind())
}

var b strings.Builder
b.WriteByte('{')
first := true

for sf, fv := range rv.Fields() {
// 1. 비공개 필드는 건너뛴다. 이것이 대문자 export 규칙이 JSON에서 의미를 갖는 이유다.
if !sf.IsExported() {
continue
}

// 2. 태그를 이름과 옵션으로 나눈다.
name, opts := parseTag(sf)
if name == "-" {
continue
}

// 3. omitempty면 제로값을 건너뛴다.
if opts["omitempty"] && fv.IsZero() {
continue
}

enc, err := encodeValue(fv)
if err != nil {
return "", fmt.Errorf("필드 %s: %w", sf.Name, err)
}

if !first {
b.WriteByte(',')
}
first = false
b.WriteString(strconv.Quote(name))
b.WriteByte(':')
b.WriteString(enc)
}

b.WriteByte('}')
return b.String(), nil
}

// parseTag는 `json:"name,omitempty,string"` 형태를 이름과 옵션 집합으로 나눈다.
func parseTag(sf reflect.StructField) (string, map[string]bool) {
tag, ok := sf.Tag.Lookup("json")
if !ok {
return sf.Name, nil // 태그가 없으면 필드 이름
}
parts := strings.Split(tag, ",")
name := parts[0]
if name == "" {
name = sf.Name // `json:",omitempty"`처럼 이름을 비울 수 있다
}
opts := make(map[string]bool, len(parts)-1)
for _, o := range parts[1:] {
opts[o] = true
}
return name, opts
}

func encodeValue(v reflect.Value) (string, error) {
switch v.Kind() {
case reflect.String:
return strconv.Quote(v.String()), nil
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return strconv.FormatInt(v.Int(), 10), nil
case reflect.Bool:
return strconv.FormatBool(v.Bool()), nil
case reflect.Slice:
if v.IsNil() {
return "null", nil
}
var b strings.Builder
b.WriteByte('[')
for i := range v.Len() {
if i > 0 {
b.WriteByte(',')
}
s, err := encodeValue(v.Index(i))
if err != nil {
return "", err
}
b.WriteString(s)
}
b.WriteByte(']')
return b.String(), nil
default:
return "", fmt.Errorf("지원하지 않는 종류: %s", v.Kind())
}
}

func main() {
p := Profile{
ID: 7,
Name: "김개발",
Tags: []string{"go", "backend"},
Password: "비밀",
Legacy: "옛 필드",
internal: 99,
}

mine, err := Marshal(p)
if err != nil {
fmt.Println("Marshal 실패:", err)
return
}
std, err := json.Marshal(p)
if err != nil {
fmt.Println("json.Marshal 실패:", err)
return
}

fmt.Println("직접 만든 것:", mine)
fmt.Println("encoding/json:", string(std))
fmt.Println("같은가:", mine == string(std))

// Nickname을 채우면 omitempty가 더 이상 건너뛰지 않는다.
p.Nickname = "개발"
mine2, err := Marshal(p)
if err != nil {
fmt.Println("Marshal 실패:", err)
return
}
real2, err := json.Marshal(p)
if err != nil {
fmt.Println("json.Marshal 실패:", err)
return
}
fmt.Println("nickname 채운 뒤:", mine2)
fmt.Println("같은가:", mine2 == string(real2))

// nil 슬라이스는 []가 아니라 null이 된다. 자주 물리는 함정이다.
empty := Profile{ID: 1, Name: "이배포"}
e1, err := Marshal(empty)
if err != nil {
fmt.Println("Marshal 실패:", err)
return
}
e2, err := json.Marshal(empty)
if err != nil {
fmt.Println("json.Marshal 실패:", err)
return
}
fmt.Println("nil 슬라이스:", e1)
fmt.Println("같은가:", e1 == string(e2))
}
go run ./05-mini-json
직접 만든 것: {"id":7,"name":"김개발","tags":["go","backend"],"Legacy":"옛 필드"}
encoding/json: {"id":7,"name":"김개발","tags":["go","backend"],"Legacy":"옛 필드"}
같은가: true
nickname 채운 뒤: {"id":7,"name":"김개발","nickname":"개발","tags":["go","backend"],"Legacy":"옛 필드"}
같은가: true
nil 슬라이스: {"id":1,"name":"이배포","tags":null,"Legacy":""}
같은가: true

규칙 네 개면 재현된다

1. 비공개 필드는 대상이 아니다. internal이 출력에 없다. 이것이 Part 6의 대문자 export 규칙이 JSON 직렬화에서 갖는 의미다. JSON에 나와야 하는 필드는 대문자로 시작해야 한다. Go 초심자가 "필드가 안 나와요"로 가장 많이 막히는 지점이다.

2. -는 제외다. Password가 빠졌다.

3. 태그가 없으면 필드 이름 그대로. Legacy"Legacy"로 나왔다. 소문자로 바꿔 주지 않는다.

4. omitempty는 제로값을 건너뛴다. Nickname""일 때 사라졌다가 값이 채워지자 나타났다.

omitempty의 함정

omitempty가 판단하는 것은 제로값이다. 위 예제는 fv.IsZero()로 축약했지만, 실제 encoding/json은 "빈 값"을 조금 다르게 본다 — false, 0, nil 포인터, 길이 0인 문자열·배열·슬라이스·맵이다. 어느 쪽이든 결과는 같다.

문제는 의미다. Count int \json:"count,omitempty"`에서 0은 "값이 없음"인가 "값이 0임"인가. 후자라면 필드가 사라지면 안 된다. 답은 *int를 쓰는 것이고, Go 1.26의 new(expr)`이 그 코드를 짧게 만든다 (3-5). Part 9에서 다시 본다.

nil 슬라이스는 []가 아니라 null이다

nil 슬라이스: {"id":1,"name":"이배포","tags":null,"Legacy":""}

nil 슬라이스와 빈 슬라이스가 JSON에서 갈린다. API 소비자 쪽에서 for (const t of data.tags)가 터진다. []string{}으로 초기화하거나 omitempty를 붙여 아예 빼는 것이 답이다. 3-1의 "nil 슬라이스는 대부분 빈 슬라이스처럼 동작한다"에서 대부분의 예외가 여기다.

대가 — 그리고 코드 생성이라는 대안

examples/05-generics-and-advanced/05-reflect-cost/main.go
package main

import (
"fmt"
"reflect"
"strconv"
"strings"
"time"
)

type Event struct {
ID int `log:"id"`
Kind string `log:"kind"`
Actor string `log:"actor"`
Weight int `log:"weight"`
Ok bool `log:"ok"`
}

// ---- 방법 1: reflect. 타입이 몇 개든 함수 하나로 끝난다 ----

func FormatReflect(v any) string {
rv := reflect.ValueOf(v)
var b strings.Builder
first := true
for sf, fv := range rv.Fields() {
key, ok := sf.Tag.Lookup("log")
if !ok {
continue
}
if !first {
b.WriteByte(' ')
}
first = false
b.WriteString(key)
b.WriteByte('=')
switch fv.Kind() {
case reflect.String:
b.WriteString(fv.String())
case reflect.Int:
b.WriteString(strconv.Itoa(int(fv.Int())))
case reflect.Bool:
b.WriteString(strconv.FormatBool(fv.Bool()))
}
}
return b.String()
}

// ---- 방법 2: 손으로 쓴 코드. 코드 생성기가 뱉는 것도 이 모양이다 ----

func (e Event) Format() string {
var b strings.Builder
b.WriteString("id=")
b.WriteString(strconv.Itoa(e.ID))
b.WriteString(" kind=")
b.WriteString(e.Kind)
b.WriteString(" actor=")
b.WriteString(e.Actor)
b.WriteString(" weight=")
b.WriteString(strconv.Itoa(e.Weight))
b.WriteString(" ok=")
b.WriteString(strconv.FormatBool(e.Ok))
return b.String()
}

func main() {
e := Event{ID: 42, Kind: "deploy", Actor: "김개발", Weight: 3, Ok: true}

fmt.Println("reflect :", FormatReflect(e))
fmt.Println("직접 작성:", e.Format())
fmt.Println("같은 결과:", FormatReflect(e) == e.Format())

// 필드를 하나 지우면 reflect 버전은 알아서 따라오고, 손으로 쓴 버전은 안 따라온다.
// 반대로 태그 이름을 오타 내면 reflect 버전만 조용히 틀린다.

const n = 200_000

start := time.Now()
for range n {
_ = FormatReflect(e)
}
reflectDur := time.Since(start)

start = time.Now()
for range n {
_ = e.Format()
}
directDur := time.Since(start)

fmt.Printf("\n%d회 반복 (이 수치는 기계마다 다르다)\n", n)
fmt.Printf(" reflect : %v\n", reflectDur.Round(time.Millisecond))
fmt.Printf(" 직접 작성: %v\n", directDur.Round(time.Millisecond))
fmt.Printf(" 배수 : 약 %.1f배\n", float64(reflectDur)/float64(directDur))

// ---- reflect가 놓치는 것: 컴파일러의 도움 ----
// 태그를 고쳐도 컴파일은 통과한다. 틀린 것은 실행해 봐야 안다.
type Typo struct {
ID int `logg:"id"` // 오타
}
fmt.Println("\n태그 오타 결과:", strconv.Quote(FormatReflect(Typo{ID: 1})))

// 필드 이름을 바꾸는 리팩터링도 마찬가지다.
// 에디터의 리네임은 태그 문자열을 건드리지 않는다.
}
go run ./05-reflect-cost
reflect : id=42 kind=deploy actor=김개발 weight=3 ok=true
직접 작성: id=42 kind=deploy actor=김개발 weight=3 ok=true
같은 결과: true

200000회 반복 (이 수치는 기계마다 다르다)
reflect : 77ms
직접 작성: 11ms
배수 : 약 6.9배

태그 오타 결과: ""

:::warning 위 시간 수치는 이 기계에서 한 번 측정한 값이다 CPU, 부하, GC 타이밍에 따라 매번 다르다. 같은 기계에서 두 번 돌려도 배수가 5배대와 7배대를 오간다. 비율의 자릿수만 읽고 숫자 자체를 기억하지 않는다. 제대로 된 측정은 testing.B로 한다(Part 8, Part 12). :::

리플렉션이 지불하는 것

1. 컴파일러의 도움을 통째로 잃는다. 이것이 성능보다 훨씬 큰 비용이다.

  • 태그 오타(logg:"id")가 컴파일 에러가 아니라 빈 출력이 된다.
  • 필드 이름을 리네임해도 태그 문자열은 안 따라온다.
  • 지원하지 않는 타입을 넣으면 런타임 에러다.
  • IDE의 "사용처 찾기"가 태그를 통해 연결된 코드를 못 찾는다.

2. 속도. 위에서 5~7배가 나왔다. encoding/json은 타입별 인코더를 캐싱해서 이 격차를 크게 줄이지만, 손으로 쓴 코드를 이길 수는 없다.

3. 읽기 어렵다. reflect 코드는 Kind 분기와 패닉 조건으로 가득 찬다.

그래서 언제 쓰는가

상황
타입을 컴파일 시점에 안다쓰지 않는다. 그냥 코드를 쓴다
타입 종류가 유한하고 적다인터페이스나 타입 스위치
여러 타입에 같은 코드제네릭(5-1)
임의의 사용자 구조체를 다루는 라이브러리리플렉션 또는 코드 생성
위 + 성능이 중요코드 생성

리플렉션은 라이브러리 작성자의 도구다. 애플리케이션 코드에서 reflect를 import하고 있다면 거의 항상 다른 답이 있다.

코드 생성이라는 대안

리플렉션이 실행 시점에 하는 일을 빌드 시점에 해 두는 것이다. go:generate 주석을 달고, 도구가 .go 파일을 뱉고, 그것을 커밋한다.

//go:generate stringer -type=Status

산출물은 위 예제의 Event.Format() 같은, 손으로 쓴 것과 구별되지 않는 코드다.

리플렉션코드 생성
속도느리다손으로 쓴 것과 같다
타입 안전성런타임컴파일 시점
빌드 단계없음go generate 필요
산출물없음생성 파일을 커밋
디버깅어렵다그냥 Go 코드

실무에서 널리 쓰이는 생성기로 stringer(열거형의 String()), sqlc(SQL → 타입 있는 Go 코드), mockgen, protoc-gen-go가 있다. Part 11에서 GORM(리플렉션 기반)과 sqlc(코드 생성 기반)를 나란히 보면 이 트레이드오프가 구체적으로 드러난다.

제네릭이 들어오면서 리플렉션이 필요했던 자리의 상당수가 사라졌다. slicesmaps가 대표적이다 — 예전에는 sort.Slice처럼 reflect로 구현했는데, 지금은 slices.SortFunc이 리플렉션 없이 같은 일을 한다(5-3).

흔히 하는 실수

1. 애플리케이션 코드에서 reflect를 쓴다

거의 항상 인터페이스, 제네릭, 타입 스위치로 풀린다. reflect를 import하려는 순간 한 번 멈춰서 다시 생각한다.

2. 값을 넘기고 수정하려 한다

reflect.ValueOf(u)는 복사본이다. reflect.ValueOf(&u).Elem()이어야 CanSet()true다.

3. Value.String()으로 값을 꺼낸다

KindString이 아니면 패닉도 안 내고 <int Value> 같은 문자열을 준다. fmt.Sprint(v.Interface())를 쓴다.

4. Get으로 태그 유무를 판단한다

Get은 없어도 ""다. Lookup을 쓴다.

5. 태그 오타를 방치한다

go vet을 CI에 넣는다. 그래도 대소문자 불일치와 존재하지 않는 키는 못 잡으므로, 직렬화 결과를 실제로 확인하는 테스트가 필요하다(Part 8).

6. JSON에 안 나오는 필드를 소문자로 선언해 놓고 헤맨다

reflect가 비공개 필드를 읽을 수 없다는 규칙 때문이다. 이 규칙은 리플렉션으로 뚫리지 않는다.

7. 리플렉션 결과를 캐싱하지 않는다

reflect.TypeOf와 필드 순회를 호출마다 반복하면 비싸다. 라이브러리를 만든다면 타입별 계획을 map[reflect.Type]...에 캐싱하는 것이 표준 기법이다. encoding/json이 정확히 그렇게 한다.

정리

  • 리플렉션은 컴파일 시점에 타입을 모를 때만 쓰는 도구다. 인터페이스로도, 제네릭으로도 안 되는 자리에서만 쓴다.
  • reflect.Type은 "무슨 타입", Kind는 "어떤 부류"다. 리플렉션 코드는 거의 항상 Kind로 분기한다.
  • reflect.TypeOf(x).Comparable()런타임에 비교 가능성을 물을 수 있다. any가 들어오는 자리에서 == 패닉을 피하는 방법이다.
  • 값을 바꾸려면 포인터를 넘기고 Elem()을 거쳐야 한다. json.Unmarshal&v를 요구하는 이유다. 비공개 필드는 어떤 경우에도 못 바꾼다.
  • reflect.TypeAssert[T](v)v.Interface().(T)의 할당 없는 대체다. 타입만 필요하면 reflect.TypeOf(x) 대신 reflect.TypeFor[T]()를 쓴다 — 값 없이 타입 인자만으로 reflect.Type을 얻는다.
  • 구조체 태그는 문자열이다. key:"value" 관례는 reflect가 정한 것이고, 오타는 조용히 무시된다. go vetstructtag 분석기가 필수다.
  • 태그 조회는 Get이 아니라 Lookup 이 기본이다.
  • encoding/json의 태그 동작은 네 규칙이다 — 비공개 제외, - 제외, 태그 없으면 필드 이름, omitempty는 제로값 생략.
  • nil 슬라이스는 JSON에서 []가 아니라 null이다.
  • 리플렉션의 가장 큰 비용은 속도가 아니라 컴파일러의 도움을 잃는 것이다. 대안은 코드 생성이며, 제네릭이 들어오면서 리플렉션이 필요한 자리 자체가 줄었다.

연습문제

  1. Encode에 중첩 구조체 지원을 넣어 보자. 필드의 KindStruct면 재귀해서 부모.자식 형태의 키를 만든다. time.Time처럼 구조체이지만 통째로 하나의 값인 타입은 어떻게 구분할 것인가? 힌트: encoding/json은 이 문제를 json.Marshaler 인터페이스 확인으로 푼다 — 리플렉션 코드도 인터페이스로 탈출구를 만든다.

  2. Marshalomitempty가 아니라 omitzero를 구현해 보자. 둘 다 실제 encoding/json에 있는 옵션이고, 차이는 "길이 0인 슬라이스"를 어떻게 볼 것인가다. fv.IsZero()fv.Len() == 0이 각각 []string{}[]string(nil)에 대해 무엇을 주는지 확인하고, 어느 쪽이 덜 놀라운 동작인지 판단해 보자. 참고로 go fix에는 omitemptyomitzero로 바꾸라고 제안하는 omitzero 픽서가 있다.

  3. FormatReflect타입별 계획 캐싱을 넣어 보자. map[reflect.Type][]plan에 "필드 인덱스와 키 이름" 목록을 미리 만들어 두고, 두 번째 호출부터는 태그 파싱을 건너뛴다. 몇 배까지 좁혀지는가? 그리고 이 캐시를 여러 실행 흐름에서 동시에 쓰려면 무엇이 더 필요한가(Part 7에서 답을 얻는다)?