고 고급 #4 reflect 패키지 — 런타임에 타입 다루기

5 분 소요

#3 제네릭 다음, 이번에는 다른 결의 동적 도구. reflect.

reflect는 — 런타임에 타입을 들여다보고 조작하는 도구입니다. 직접 쓸 일은 많지 않지만 — encoding/json, text/template, ORM 같은 라이브러리 안쪽에서 항상 보입니다. 어떻게 동작하는지 알면 그 라이브러리들이 자연스러워집니다.

기본 두 개념 — Type과 Value #

reflect 시작
import "reflect"

func main() {
	x := 42

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

	fmt.Println(t)         // int
	fmt.Println(v)         // 42
	fmt.Println(t.Kind())  // int
}
  • reflect.Type — 타입의 메타데이터 (이름, kind, 필드, 메서드 등)
  • reflect.Value — 값 자체와 타입 정보 둘 다

Kind — 타입의 큰 분류 #

v := reflect.ValueOf(struct{ X int }{42})
fmt.Println(v.Kind())    // struct

Kind는 — 더 큰 분류(int, string, struct, slice, map, ptr 등). Type은 더 구체적(예: mypkg.User 라는 정의된 타입까지).

struct의 필드 들여다보기 #

struct 필드 순회
type User struct {
	Name  string
	Age   int
	Email string
}

u := User{"이도경", 30, "x@y.z"}
v := reflect.ValueOf(u)
t := v.Type()

for i := 0; i < t.NumField(); i++ {
	field := t.Field(i)
	value := v.Field(i)
	fmt.Printf("%s %v = %v\n", field.Name, field.Type, value)
}
// Name string = 이도경
// Age int = 30
// Email string = x@y.z

t.NumField(), t.Field(i), v.Field(i) — JSON 인코더가 내부에서 하는 일과 정확히 같습니다.

struct 태그 읽기 #

struct 태그
type User struct {
	Name  string `json:"name"`
	Email string `json:"email,omitempty"`
}

t := reflect.TypeOf(User{})
field := t.Field(0)
fmt.Println(field.Tag.Get("json"))    // name

Field(i).Tag.Get("json")json:"..." 부분만 꺼냅니다. encoding/json이 이 정보로 어떻게 직렬화할지 결정합니다.

이 메커니즘이 — Go 라이브러리들이 struct만 보고 다양한 동작을 하게 만드는 핵심 원리입니다.

값 수정 — Value가 settable이어야 #

값 수정
x := 42
v := reflect.ValueOf(&x).Elem()    // 포인터 → 가리키는 값
v.SetInt(100)
fmt.Println(x)                      // 100

핵심 두 가지:

  • 포인터로 ValueOf 해서 — Elem()으로 가리키는 값 얻기
  • 그 값이 settable 이어야 SetInt, SetString 등 가능
settable 검증
x := 42
v := reflect.ValueOf(x)         // 값 복사
v.CanSet()                       // false — 복사본이라 수정 불가

v = reflect.ValueOf(&x).Elem()
v.CanSet()                       // true

동적 함수 호출 #

reflect로 함수 호출
func add(a, b int) int { return a + b }

f := reflect.ValueOf(add)
results := f.Call([]reflect.Value{
	reflect.ValueOf(1),
	reflect.ValueOf(2),
})
fmt.Println(results[0].Int())    // 3

Call이 — 런타임에 함수를 부를 수 있게 해 줍니다. RPC 라이브러리, 핸들러 디스패치 같은 경우에 활용.

인터페이스 변환 — Type assertion의 동적 버전 #

reflect와 interface
var i interface{} = "hello"

v := reflect.ValueOf(i)
fmt.Println(v.Kind())     // string
fmt.Println(v.String())   // hello

interface{} 안에 무엇이 들어 있는지 — 타입 단언 없이 검사할 수 있습니다.

전형적인 사용 예 — JSON 인코딩 미니 버전 #

이해하면 reflect의 위치가 명확해집니다. 몇 줄로 만들어 봅니다.

간단 직렬화
func toMap(v any) map[string]any {
	rv := reflect.ValueOf(v)
	rt := rv.Type()

	m := map[string]any{}
	for i := 0; i < rt.NumField(); i++ {
		field := rt.Field(i)
		key := field.Tag.Get("json")
		if key == "" {
			key = field.Name
		}
		m[key] = rv.Field(i).Interface()
	}
	return m
}

type User struct {
	Name string `json:"name"`
	Age  int
}

toMap(User{"이도경", 30})
// map[name:이도경 Age:30]

encoding/json도 본질적으로 같은 일을 — 더 정교하게 합니다(zero value 처리, omitempty, 중첩 구조체, 슬라이스 등).

자주 만나는 경우 — 환경 변수 자동 매핑 #

env 자동 매핑
type Config struct {
	Port int    `env:"PORT"`
	Host string `env:"HOST"`
}

func loadEnv(cfg any) {
	v := reflect.ValueOf(cfg).Elem()
	t := v.Type()

	for i := 0; i < t.NumField(); i++ {
		field := t.Field(i)
		key := field.Tag.Get("env")
		val := os.Getenv(key)
		if val == "" {
			continue
		}

		fv := v.Field(i)
		switch fv.Kind() {
		case reflect.String:
			fv.SetString(val)
		case reflect.Int:
			n, _ := strconv.Atoi(val)
			fv.SetInt(int64(n))
		}
	}
}

이 패턴이 — caarlos0/env, kelseyhightower/envconfig 같은 라이브러리의 핵심 구현 방식입니다.

함정 — reflect는 느리다 #

reflect는 일반 코드보다 — 수십 배 느립니다. 이유:

  • 타입 검사를 런타임에 함
  • 메모리 할당이 더 많음
  • 컴파일러 최적화 못 함
benchmark 예
BenchmarkDirect-8       1000000000      0.3 ns/op
BenchmarkReflect-8        50000000       30 ns/op

핫 패스에서는 — reflect 대신 코드 생성(다음 #7)을 검토하세요. 또는 interface + type switch가 reflect보다 빠른 경우도 많습니다.

함정 — 컴파일러 도움이 사라짐 #

v.SetString("hi")    // 만약 v가 int 면 → 런타임 panic

reflect는 정적 타입 체크를 우회합니다. 잘못 쓰면 컴파일 시점이 아니라 — 런타임 panic. 항상 Kind()로 검사하고 들어가야 안전.

함정 — interface{} 의 특수한 nil #

자주 만나는 함정
var p *User = nil
var i interface{} = p

i == nil    // false — interface는 (타입, 값) 쌍
v := reflect.ValueOf(i)
v.IsNil()   // true — Value의 IsNil은 안쪽 값을 봄

interface가 nil이려면 타입과 값이 모두 nil 이어야 합니다. reflect.Value.IsNil()이 — 안쪽 포인터의 nil 여부를 알려 줍니다(Kind에 따라).

unsafe.Pointer와의 관계 #

reflect는 — 내부적으로 unsafe.Pointer를 활용합니다. 그래서 안전하지만 — 잘못 쓰면 같은 위험을 만들 수 있습니다. 보통 라이브러리 작성자만 깊게 들어가고, 일반 사용자는 표면 API만 씁니다.

#5 unsafe와 cgo에서 unsafe를 다룹니다.

언제 써야 하나? — 회피 가능하면 회피 #

reflect의 원칙:

  • 컴파일 타임 정보로 충분하면 → 제네릭, 인터페이스, 코드 생성
  • 타입을 모르고도 동작해야 하는 라이브러리 → reflect가 적절

대부분의 비즈니스 로직은 — 첫 번째에 해당. reflect를 직접 쓸 일은 라이브러리/프레임워크 코드에서 더 흔합니다.

“Reflection is never clear.” — Go proverbs

실전 — fmt.Println도 reflect의 사용자 #

fmt.Printf("%v", x)가 — 임의의 타입을 받아 출력 가능한 이유는 reflect. 표준 라이브러리에서 대표적인 사용 사례입니다.

fmt.Sprintf("%v", User{"이도경", 30})    // {이도경 30}

마무리 #

이번 글에서 정리한 내용:

  • reflect.Type — 타입 메타데이터, reflect.Value — 값 + 타입
  • Kind — 타입의 큰 분류 (int, struct, ptr 등)
  • struct 필드/태그 순회NumField, Field, Tag.Get
  • 값 수정 — 포인터 → Elem() → settable 인지 확인 → Set*
  • 동적 호출reflect.Value.Call
  • 느림 — 핫 패스 회피, 핫하면 코드 생성으로 대체
  • 정적 타입 체크 우회 — 런타임 panic 위험
  • 가능하면 제네릭/인터페이스 우선

다음 글(#5 unsafe와 cgo)에서는 — Go의 안전 영역 밖으로 한 걸음 나가는 두 도구를 정리합니다. 거의 안 쓰지만, 어떤 경우가 그 도구를 부르는지는 알아 둘 가치가 있습니다.

X