Go는 정적 타입 언어이지만, reflect 패키지를 통해 실행 시점에 타입 정보를 탐색하고 값을 동적으로 다룰 수 있다. 이 글에서는 런타임 타입 분석의 핵심 개념과 실무 활용법을 살펴본다.
언제 타입 조사가 필요한가
함수가 다양한 타입을 통일된 방식으로 처리해야 할 때, 인터페이스만으로는 부족한 경우가 있다. 예를 들어 아직 정의되지 않은 사용자 정의 타입이나, 공통 인터페이스를 구현하지 않은 서로 다른 값들을 다뤄야 할 때가 그렇다. 또한 JSON 직렬화나 ORM 같은 메타프로그래밍 영역에서도 이 기법은 필수적이다.
package main
import (
"encoding/json"
"fmt"
)
type Product struct {
Code string `json:"code"`
Price float64 `json:"price"`
Stock int `json:"stock"`
}
func main() {
p := Product{
Code: "P-992",
Price: 29900.0,
Stock: 45,
}
bytes, _ := json.Marshal(p)
fmt.Println(string(bytes))
}
reflect 패키지의 핵심 구성 요소
Go의 모든 인터페이스 값은 구체 타입과 해당 타입의 값으로 이루어진다. reflect 패키지는 이를 reflect.Type과 reflect.Value 두 가지 관점으로 조사할 수 있게 한다.
| 함수 | 반환 타입 | 설명 |
|---|---|---|
reflect.TypeOf(x) | reflect.Type | 값의 타입 정보 획득 |
reflect.ValueOf(x) | reflect.Value | 값의 실제 데이터 획득 |
타입 정보 탐색
TypeOf를 통해 얻은 타입 객체는 이름, 패키지 경로, 기반 타입(Kind) 등 다양한 메타데이터를 제공한다. 특히 사용자 정의 타입과 내장 타입을 구분할 때 Name()과 Kind()의 차이를 이해하는 것이 중요하다.
package main
import (
"fmt"
"reflect"
)
func inspectType(v interface{}) {
t := reflect.TypeOf(v)
fmt.Printf("타입: %v, 이름: %v, 종류: %v\n", t, t.Name(), t.Kind())
}
type category int64
type Item struct {
Title string
Done bool
}
func main() {
var ptr *float64
var ctg category
var r rune
var itm Item
var nums []int
inspectType(ptr) // 타입: *float64, 이름: , 종류: ptr
inspectType(ctg) // 타입: main.category, 이름: category, 종류: int64
inspectType(r) // 타입: int32, 이름: int32, 종류: int32
inspectType(itm) // 타입: main.Item, 이름: Item, 종류: struct
inspectType(nums) // 타입: []int, 이름: , 종류: slice
}
값 추출과 변환
reflect.Value는 원본 값을 다양한 형태로 환원시킬 수 있다. 단, 잘못된 타입으로 변환하면 패닉이 발생하므로 Kind()를 통한 사전 검증이 필요하다.
package main
import (
"fmt"
"reflect"
)
func extractValue(input interface{}) {
val := reflect.ValueOf(input)
switch val.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
fmt.Printf("정수: %d\n", val.Int())
case reflect.Float32, reflect.Float64:
fmt.Printf("실수: %f\n", val.Float())
case reflect.String:
fmt.Printf("문자열: %s\n", val.String())
case reflect.Bool:
fmt.Printf("불리언: %t\n", val.Bool())
}
}
func main() {
extractValue(int64(42))
extractValue(3.14159)
extractValue("hello")
}
값의 수정
함수 인자는 값 복사로 전달되므로, 수정하려면 반드시 주소를 전달해야 한다. 포인터를 통해 얻은 reflect.Value는 Elem()으로 실제 값에 접근한 후 SetXxx 메서드로 변경할 수 있다.
package main
import (
"fmt"
"reflect"
)
func updateDirectly(v interface{}) {
// 값 전달 시 수정 불가 - 패닉 유발
reflect.ValueOf(v).SetInt(999)
}
func updateViaPointer(v interface{}) {
val := reflect.ValueOf(v)
if val.Kind() != reflect.Ptr {
fmt.Println("포인터가 아님")
return
}
elem := val.Elem()
if elem.Kind() == reflect.Int64 {
elem.SetInt(999)
}
}
func main() {
var num int64 = 100
// updateDirectly(num) // 패닉!
updateViaPointer(&num)
fmt.Println(num) // 999
}
구조체 동적 분석
구조체는 필드 수, 각 필드의 이름·타입·태그, 그리고 메서드까지 실행 시점에 탐색할 수 있다. 이는 데이터 검증, 자동 매핑, 코드 생성 등에 활용된다.
필드와 태그 조회
package main
import (
"fmt"
"reflect"
)
type Employee struct {
ID uint64 `db:"emp_id" json:"id"`
Name string `db:"emp_name" json:"name"`
Salary int `db:"emp_salary" json:"salary"`
}
func exploreStruct(s interface{}) {
t := reflect.TypeOf(s)
if t.Kind() == reflect.Ptr {
t = t.Elem()
}
if t.Kind() != reflect.Struct {
return
}
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
fmt.Printf("필드명: %s, 타입: %s, DB태그: %s, JSON태그: %s\n",
f.Name, f.Type, f.Tag.Get("db"), f.Tag.Get("json"))
}
}
func main() {
e := Employee{ID: 1, Name: "김철수", Salary: 5000000}
exploreStruct(e)
}
메서드 호출과 동적 실행
package main
import (
"fmt"
"reflect"
)
type Calculator struct {
Result int
}
func (c *Calculator) Add(a, b int) {
c.Result = a + b
}
func (c Calculator) GetResult() int {
return c.Result
}
func invokeMethods(obj interface{}) {
val := reflect.ValueOf(obj)
// 값 수신 메서드 호출
getResult := val.MethodByName("GetResult")
res := getResult.Call(nil)
fmt.Printf("결과: %v\n", res[0])
// 포인터 메서드 호출을 위한 재설정
val = reflect.ValueOf(obj).Elem().Addr()
add := val.MethodByName("Add")
args := []reflect.Value{
reflect.ValueOf(30),
reflect.ValueOf(12),
}
add.Call(args)
fmt.Printf("합산 후: %d\n", val.Elem().FieldByName("Result").Int())
}
func main() {
calc := Calculator{Result: 0}
// GetResult는 값 수신자 메서드
fmt.Println(calc.GetResult())
// 동적 호출은 포인터로 전달
invokeMethods(&calc)
}
구조체 필드 값 변경
구조체 포인터를 받아 Elem()으로 해체한 뒤, FieldByName으로 특정 필드를 찾아 수정할 수 있다.
package main
import (
"fmt"
"reflect"
)
type Config struct {
Host string `env:"HOST"`
Port int `env:"PORT"`
Timeout int `env:"TIMEOUT"`
}
func modifyConfig(cfg interface{}) {
val := reflect.ValueOf(cfg)
if val.Kind() != reflect.Ptr || val.Elem().Kind() != reflect.Struct {
return
}
elem := val.Elem()
h := elem.FieldByName("Host")
if h.CanSet() {
h.SetString("localhost")
}
p := elem.FieldByName("Port")
if p.CanSet() {
p.SetInt(8080)
}
t := elem.FieldByName("Timeout")
if t.CanSet() {
t.SetInt(30)
}
}
func main() {
c := Config{Host: "0.0.0.0", Port: 3000, Timeout: 10}
modifyConfig(&c)
fmt.Printf("%+v\n", c)
}
주의사항과 설계 원칙
타입 조사는 강력하지만 다음과 같은 단점이 있다. 적절한 상황에서만 제한적으로 사용해야 한다.
- 안정성 저하: 타입 불일치가 런타임에 발견되어 예측하지 못한 패닉을 유발할 수 있다.
- 가독성 저하: 동적 타입 처리는 정적 타입 검사의 이점을 포기하므로 코드 의도를 파악하기 어려워진다.
- 성능 오버헤드: 타입 정보 조회와 메서드 동적 호출은 정적 호출보다 느리다.
대부분의 경우 인터페이스와 제네릭으로 해결 가능하므로, 타입 조사는 메타프로그래밍, 직렬화, 프레임워크 개발 등 명확한 목적이 있을 때만 도입하도록 한다.