Go의 런타임 타입 조사와 값 조작 기법

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.Typereflect.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.ValueElem()으로 실제 값에 접근한 후 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)
}

주의사항과 설계 원칙

타입 조사는 강력하지만 다음과 같은 단점이 있다. 적절한 상황에서만 제한적으로 사용해야 한다.

  • 안정성 저하: 타입 불일치가 런타임에 발견되어 예측하지 못한 패닉을 유발할 수 있다.
  • 가독성 저하: 동적 타입 처리는 정적 타입 검사의 이점을 포기하므로 코드 의도를 파악하기 어려워진다.
  • 성능 오버헤드: 타입 정보 조회와 메서드 동적 호출은 정적 호출보다 느리다.

대부분의 경우 인터페이스와 제네릭으로 해결 가능하므로, 타입 조사는 메타프로그래밍, 직렬화, 프레임워크 개발 등 명확한 목적이 있을 때만 도입하도록 한다.

태그: go Reflect reflection 메타프로그래밍 런타임 타입 분석

7월 10일 01:36에 게시됨