Reflect, Unsafe, Cgo
본 글은 Golang을 공부하며 주요 내용이라 생각되는 것들을 기록해둔 자료이며, Ubuntu 22.04 LTS 기준으로 작성되었습니다.
Introduction
Go는 안전한 언어이다. 정적 타입의 변수를 통해 어떤 데이터를 사용하고 있는지 명확히 하며, 메모리를 관리하기 위해 가비지 컬렉터를 사용한다. 포인터를 사용하긴 하지만 C나 C++보다는 제한적인 범위에서만 사용할 수 있다. 따라서 고 런타임은 대부분 안전하게 실행된다고 할 수 있다.
하지만 타입을 명확히 정의할 수 없는 데이터를 다뤄야 하는 경우가 있을 수 있다.
이를테면 타입을 컴파일 타임에 결정할 수 없는 데이터가 있을 수 있다. 이 경우 reflect
패키지를 통해 데이터와 상호작용하거나 데이터를 구성한다.
또는 데이터 타입의 메모리 레이아웃을 사용해야 하는 경우, unsafe
패키지를 사용한다.
그리고 C로 작성된 라이브러리의 기능을 사용해야 하는 경우, cgo
패키지를 통해 C 코드를 호출할 수도 있다.
처음 Go언어를 배우는 사람들을 위한 책에서 나올 내용 치고는 다소 고난도의 개념인지라, 이러한 내용이 나오는 것에 대해 다소 의아할 수도 있다. 하지만 다음과 같은 두 가지 이유로 이 내용을 다룬다.
- 개발자들은 코드에서 문제를 발견하면 솔루션을 검색해보고 제대로 이해하지도 못한 채 코드에 복붙하는 경우가 많다. 이로 인해 여러 문제가 생길 수도 있다. 그러니 이러한 고급 기술에 대해 조금이라도 이해해두는 편이 문제를 일으킬 여지를 약간이나마 줄일 수 있을 것이다.
- 소개하는 내용이 일반 Go 코드에서는 할 수 없는 것들이라, 내용을 접하다 보면 흥미로움을 느낄 수도 있기 때문이다!
Reflection
Go의 장점 중 하나는 정적 타입 언어라는 것이다. Go에서 타입 및 변수, 함수를 선언하는 것이 매우 간단하며, 프로그램에서 사용될 데이터의 구조를 나타내기 위해 타입을 사용한다. 하지만 컴파일 타임에 정의되는 타입만으로 프로그램에서 사용될 데이터를 사용하기에는 부족할 때가 있다. 이를테면 프로그램을 작성할 때 존재하지 않았던 데이터가 런타임에 변수에 할당될 수도 있다. 가령, 파일에서 데이터를 읽어오거나 네트워크 요청을 변수에 저장하거나, 아니면 여러 타입에 대응되는 함수를 작성할 수도 있을 것이다.
이러한 상황에서는 Reflection을 사용한다. Reflection은 런타임에 타입을 확인할 수 있게 해주며, 런타임에 변수, 함수, 구조체를 검사하고, 수정하고, 생성하는 기능도 제공한다.
이러한 Reflection의 기능이 언제 필요한 것인가에 대한 질문이 있을 수 있다. 표준 라이브러리에서 Reflection이 어떻게 쓰이는지 알면 우리도 감을 잡을 수 있을 것이다.
database/sql
패키지에서는 데이터베이스에서 데이터를 읽거나 쓸 때 Reflection을 사용하여 레코드를 전송하고 읽어온 데이터를 반환함- Go에 내장된 템플릿 작성 라이브러리인
text/template
및html/template
에서는 reflection을 사용하여 템플릿으로 전달되는 값을 처리함 fmt
패키지의fmt.Println
등 대부분의 출력 함수는 들어온 파라미터의 타입에 맞춰 알잘딱깔센하게 출력해줘야 하기 때문에, Reflection을 엄청 많이 사용한다고 함sort
패키지는 Reflection을 사용하여 다양한 타입의 슬라이스를 정렬하는 기능을 구현함encoding
패키지에 정의된 다양한 데이터 타입과 함께, JSON이나 XML을 Marshaling 및 Unmarshaling할 때 Reflection이 사용됨. 구조체 tag는 Reflection를 통해 액세스되며, Reflection를 통해 구조체 필드를 읽고 씀.
이러한 예시들은 한 가지 공통점을 공유한다. Go 프로그램으로 가져오거나 외부로 내보내는 데이터에 액세스하고 형식을 지정한다는 것이다. 다시 말해, Reflection은 Go 프로그램과 외부 세계의 경계선에서 사용되는 것이다.
Types, Kinds
이제 Reflection이 무엇을 하며 언제 필요한지 알았으니, 어떻게 쓰는 것인지 알아보려 한다.
reflect
패키지는 표준 라이브러리에 포함되어 있으며, Reflection을 위한 여러 타입들과 함수들이 구현되어 있다.
Reflection의 핵심 개념은 types, kinds, values, 이렇게 세 가지이다.
먼저 types에 대해 알아보자. Reflection에서 타입은 말 그대로 변수의 속성, 변수가 포함할 수 있는 항목 및 변수가 상호 작용할 수 있는 방법을 정의한다. Reflection을 쓰면 코드를 작성하여 타입을 쿼리할 수 있다.
func main() {
x := 1
xt := reflect.TypeOf(x)
fmt.Println(xt.Name())
f := Foo{}
ft := reflect.TypeOf(f)
fmt.Println(ft.Name())
xpt := reflect.TypeOf(&x)
fmt.Println(xpt.Name())
tt := reflect.TypeOf(ft)
fmt.Println(tt.Name())
tnt := reflect.TypeOf(ft.Name())
fmt.Println(tnt.Name())
}
위 구문의 실행 결과는 다음과 같다.
int
Foo
string
이처럼 reflect
패키지의 TypeOf()
함수를 통해 변수의 타입을 확인할 수 있다.
TypeOf()
는 파라미터로 들어온 변수의 타입을 reflect.Type
라는 타입으로 나타낸다.
reflect.Type
에는 변수의 타입에 관련된 여러 메소드가 존재하지만 다는 다루지 못하고 몇 개만 다뤄보고자 한다.
Name()
메소드는 타입명을 문자열로 반환한다. 가장 마지막 출력 결과가 string임을 확인할 수 있다. 이처럼 Name()
은 int와 같은 원시 타입이나 위 예제의 Foo
와 같은 구조체의 이름을 반환한다.
그러나 slice나 포인터 등은 달리 이름이 없기 때문에 빈 문자열을 반환한다.
reflect.Type
의 Kind()
메소드는 reflect.Kind
타입의 값을 반환하며, reflect.Kind
는 해당 타입이 어떤 원시 타입으로 작성되었는지를 나타내는 상수값이다. 이를테면 slice, map, pointer, struct, interface, string, array, function, int 등, 어떠한 원시 타입인지 알려준다. reflect.Type
과 reflect.Kind
는 다소 헷갈릴 수 있는데, 만약 위 예제를 예로 든다면 Foo
의 타입은 "Foo"
가 될 것이고 종류는 reflect.Struct
가 될 것이다.
종류는 매우 중요하다. Reflection을 사용할 때 유의해야 할 것이 있는데, reflect
패키지는 개발자가 Reflection에 대해 잘 알고 있다고 가정한다. reflect.Type
등 reflect
의 타입들에 정의된 일부 메소드는 특정 종류에서만 말이 된다.
예를 들어 reflect.Type
의 NumIn()
이라는 메소드가 있다.
만약 reflect.Type
이 함수를 나타내고 있다면 NumIn()
은 입력 파라미터의 수를 반환하지만, 함수를 나타내고 있지 않다면 프로그램은 panic에 빠질 것이다.
따라서 종류를 사용하여 reflect.Type
이 어떤 메소드를 사용할 수 있고 어떤 메소드가 panic이 되는지 아는 게 중요하다.
아래 예제는 위 예제에서 Kind()
메소드를 통해 타입의 종류까지 출력하는 부분을 추가한 것이다.
func main() {
x := 1
xt := reflect.TypeOf(x)
fmt.Println(xt.Name(), xt.Kind())
f := Foo{}
ft := reflect.TypeOf(f)
fmt.Println(ft.Name(), ft.Kind())
xpt := reflect.TypeOf(&x)
fmt.Println(xpt.Name(), xpt.Kind())
tt := reflect.TypeOf(ft)
fmt.Println(tt.Name(), tt.Kind())
tnt := reflect.TypeOf(ft.Name())
fmt.Println(tnt.Name(), tt.Kind())
}
실행 결과는 다음과 같다.
int int
Foo struct
ptr
ptr
string ptr
reflect.Type
의 또 다른 중요한 메소드는 Elem()
이다.
Go에는 다른 타입을 참조하는 타입이 있으며, Elem()
은 참조되는 타입이 어떤 타입인지 밝혀낸다.
이를테면 위 예시에서 종류가 ptr
이었던 xpt
, tt
부분을 다음과 같이 수정할 것이다.
xpt := reflect.TypeOf(&x)
fmt.Println(xpt.Name(), xpt.Kind(), xpt.Elem().Name(), xpt.Elem().Kind())
tt := reflect.TypeOf(ft)
fmt.Println(tt.Name(), tt.Kind(), tt.Elem().Name(), tt.Elem().Kind())
실행 결과는 다음과 같다.
ptr int int
ptr rtype struct
만약 reflect.Type
이 포인터를 나타내고 있다면 Elem()
은 그 포인터가 가리키는 타입의 reflect.Type
을 반환한다.
위 예제로 미루어 보아 xpt
는 int를, tt
는 rtype
라는 이름의 구조체를 가리키는 포인터임을 알 수 있다.
Elem()
메소드는 reflect.Type
이 포인터뿐 아니라 slice, map, channel, array를 나타낼 때도 동작한다. 한편 int
등의 원시 타입을 나타낸다면 panic이 발생한다.
reflect.Type
이 구조체를 나타내고 있다면 NumField()
메소드를 사용하여 구조체에 존재하는 필드의 수를 알 수 있다.
또한 Field()
메소드를 통해 필드에 접근할 수 있다. Field()
는 reflect.StructField
라는 타입으로 표현된 각 필드의 구조를 반환하며, reflect.StructField
는 필드의 이름, 순서, 타입, 구조체 태그 정보를 가지고 있다.
다음의 예제를 살펴보자!
type Foo struct {
A int `myTag:"value"`
B string `myTag:"value2"`
}
func main() {
f := Foo{}
ft := reflect.TypeOf(f)
for i := 0; i < ft.NumField(); i++ {
curField := ft.Field(i)
fmt.Println(curField.Name, curField.Type.Name(), curField.Tag.Get("myTag"))
}
}
Foo
인스턴스인 f를 reflect.TypeOf()
의 파라미터로 보내서, 구조체를 표현하는 reflect.Type
을 ft
에 저장한다.
그 후 NumField()
로 필드 수 만큼 이터레이션을 돌며 Field()
메소드로 각 필드의 필드명, 타입, 태그 정보를 출력한다.
실행 결과는 다음과 같다.
A int value
B string value
마찬가지로, 구조체가 아닌 reflect.Type
에서 NumField()
나 Field()
메소드를 호출하면 panic이 발생한다.
reflect.Type
에는 다른 더 많은 메소드가 존재하지만, 거의 비슷한 패턴을 따른다. 추가적인 정보를 원한다면 Go의 표준 라이브러리 문서를 확인할 수 있다.
Values
Reflection을 통해 변수의 타입 뿐 아니라, 변수의 값도 읽어올 수 있다. reflect.ValueOf()
함수를 호출하면 변수의 값을 나타내는 reflect.Value
인스턴스를 생성할 수 있다.
vValue := reflect.ValueOf(v)
Go의 모든 변수는 타입이 존재하기 때문에 reflect.Value
에는 reflect.Type
을 반환하는 Type()
이란 메소드가 존재하며, reflect.Type
처럼 종류를 반환하는 Kind()
메소드가 존재한다.
reflect.Type
이 변수의 타입에 대한 정보를 찾는 메소드를 가지고 있는 것처럼, reflect.Value
은 변수의 값에 대한 정보를 찾는 메소드를 가지고 있다. 마찬가지로 이들 모두를 다루진 않겠지만, 변수의 값을 얻고자 할 때 reflect.Value
를 어떻게 사용하는지 확인해볼 것이다.
먼저 reflect.Value
에서 값을 다시 읽어오는 방법을 살펴볼 것이다. reflect.Value
의 Interface()
메소드는 변수의 값을 빈 인터페이스로 반환한다.
s := []string{"a", "b", "c"}
sv := reflect.ValueOf(s)
s2 := sv.Interface().([]string)
fmt.Println(s2)
Interface()
는 모든 종류의 값을 포함하는 reflect.Value
인스턴스에 대해 호출할 수 있다. 하지만 변수의 종류가 Bool, Complex, Int, Uint, Float, String과 같은 Built-in 원시 타입인 경우 사용할 수 있는 특수한 메소드가 있다.
Bytes()
메소드는 변수의 타입이 []bytes
이면 사용할 수 있다.
reflect.Type
에서처럼, reflect.Value
에서 변수의 타입과 맞지 않는 메소드를 호출한다면 프로그램이 panic에 빠진다.
Reflection을 통해 변수의 값을 설정할 수도 있지만, 다음과 같은 세 단계를 거쳐야 한다.
첫 번째로, 변수의 포인터를 reflect.ValueOf()
의 파라미터로 보낸다. 이렇게 하면 포인터를 나타내는 reflect.Value
를 반환한다.
i := 10
iv := reflect.ValueOf(&i)
다음으로, 설정해야 할 실제 값을 얻는다. reflect.Value
의 Elem()
메소드를 사용하여, 포인터가 가리키는 값을 얻을 수 있다. reflect.Type
의 Elem()
처럼, reflect.Value
의 Elem()
은 포인터가 가리키는 값을 reflect.Value
또는 인터페이스로 반환한다.
ivv := iv.Elem()
마지막으로 값을 수정한다.
앞서 언급하였듯 값의 종류가 원시 타입일 때만 쓸 수 있는 특수한 메소드가 존재하며, 그것이 값을 설정하는 SetBool
, SetInt
, SetFloat
, SetString
등이다.
예제에서는 SetInt()
를 사용하여 값을 수정하였으며, 이렇게 하면 i의 값이 변경된다.
ivv.SetInt(15)
fmt.Println(i)
다른 모든 타입들에 대해서는 reflect.Value
를 파라미터로 받는 Set()
을 호출해야 한다.
값을 변경하는 게 아니라 이 값을 읽는 것이기 때문에, 설정하고자 하는 값은 포인터일 필요가 없다.
또한 Interface()
를 사용하여 원시 타입을 읽을 수 있듯, Set()
을 호출하여 원시 타입을 설정할 수 있다.
값을 변경하기 위해 reflect.ValueOf()
의 파라미터로 포인터를 전달해야 하는 이유는 Go의 다른 함수와 동일하다.
다른 함수에서는 파라미터를 포인터 타입으로 지정하여 파라미터의 값을 변경할 수 있음을 나타내고, 변수의 값을 변경할 때 포인터에 dereference하여 값을 변경한다.
즉, 다음의 두 함수는 동일한 과정을 거친다고 볼 수 있다!
func changeInt(i *int) {
*i = 20
}
func changeIntReflect(i *int) {
reflect.ValueOf(i).Elem().SetInt(20)
}
만약 다음과 같이 포인터를 거치지 않는다면 변수의 값을 읽어오는 데에는 문제가 없겠지만, 값을 설정할 때 panic이 발생한다.
i3 := 10
reflect.ValueOf(i3).SetInt(1) // this calls will panic
Making New Values
Reflection을 사용하는 최선의 방법을 살펴보기에 앞서, 값을 생성하는 방법에 대해서도 알아보려 한다.
reflect.New()
함수는 new()
함수의 reflection 버전이라고 볼 수 있다.
reflect.New()
는 reflect.Type
을 파라미터로 받아 명시된 타입의 reflect.Value
를 가리키는 포인터 타입의 reflect.Value
를 반환한다.
반환값이 포인터이기 때문에, Interface()
메소드를 사용하여 수정한 후 변수에 할당할 수 있다.
reflect.New()
가 스칼라 타입의 포인터를 생성하는 것처럼, make()
함수에 대응되는 함수가 Reflection에도 존재한다.
func MakeChan(typ Type, buffer int) Value
func MakeMap(typ Type) Value
func MakeMapWithSize(typ Type, n int) Value
func MakeSlice(typ Type, len, cap int) Value
이러한 함수들은 포함된 타입이 아니라 복합 타입을 나타내는 reflect.Type
을 파라미터로 받는다.
reflect.Type
을 구성할 때는 항상 값에서 시작해야 하지만 마땅한 값이 없는 경우 reflect.Type
을 나타내는 변수를 만들 수 있는 방법이 있다.
var stringType = reflect.TypeOf((*string)(nil)).Elem()
var stringSliceType = reflect.TypeOf([]string(nil))
위 예제에서 변수 stringType
은 string
을 나타내는 reflect.Type
을 갖게 되며, stringSliceType
은 []string
을 타나내는 reflect.Type
을 갖게 된다.
stringType
의 경우 해석하기 좀 어려울 수 있지만 포인터 타입을 먼저 생성한 뒤 Elem()
을 호출하여 결국 string
을 나타내는 reflect.Type
을 얻는 것이다.
stringSliceType
의 경우는 []string
의 기본값 자체가 nil
이기 때문에 위처럼 위와 같은 절차를 거치면 []string
을 타나내는 reflect.Type
을 얻을 수 있다.
이렇게 얻은 stringType
와 stringSliceType
을 reflect.New()
와 reflect.MakeSlice()
의 파라미터로 보내 값을 생성할 수 있다.
func main() {
var stringType = reflect.TypeOf((*string)(nil)).Elem()
var stringSliceType = reflect.TypeOf([]string(nil))
sv := reflect.New(stringType).Elem()
sv.SetString("hello")
ssv := reflect.MakeSlice(stringSliceType, 0, 10)
ssv = reflect.Append(ssv, sv)
ss := ssv.Interface().([]string)
fmt.Println(ss)
}
실행하면 다음과 같은 결과를 확인할 수 있다.
[hello]
Use Reflection to Check If an Interface’s Value Is nil
interface는 두 가지 nil을 가질 수 있다. 첫 번째는 interface가 가리키는 타입이 nil인 경우(즉 아무 것도 가리키지 않는 상태. 이 경우를 nul 인터페이스라고 함)이고, 두 번째는 interface가 가리키는 타입은 nil이 아니지만 값이 nil인 경우이다.
만약 interface가 가리키는 값이 nil인지 확인하고 싶다면 reflection의 IsValid()
와 IsNil()
메소드를 활용해야 한다.
func hasNoValue(i interface{}) bool {
iv := reflect.ValueOf(i)
if !iv.IsValid() {
return true
}
switch iv.Kind() {
case reflect.Ptr, reflect.Slice, reflect.Map, reflect.Func, reflect.Interface:
return iv.IsNil()
default:
return false
}
}
func main() {
var s interface{} = "asd"
var p *string
fmt.Println(hasNoValue(s), hasNoValue(p), hasNoValue(nil))
}
IsValid()
는 reflect.Value
가 nil 인터페이스 이외의 다른 타입을 나타내는 경우 true
를 반환한다.
만일 reflect.Value
가 nil 인터페이스라서 IsValid()
의 값이 true
인 상태에서, 다른 메소드를 호출하면 panic에 빠지기 때문이다.
IsNil()
은 reflect.Value
의 값이 nil이라면 true를 반환한다. IsNil()
를 호출하기에 앞서 먼저 Kind()
로 값의 종류를 검사하는 것을 알 수 있다. 어느 정도 예상할 수 있듯, 변수의 Zero value nil이 아닌 타입(int 등)일 때 IsNil()
을 호출하면 panic이 발생하기 때문이다.
Use Reflection to Write a Data Marshaler
앞서 언급했듯 Replection은 marshaling과 unmarshaling을 구현하기 위해 사용된다. 우리는 데이터 marshaler를 직접 설계하여 어떻게 사용되는 지 알아보려 한다!
Go는 CSV 파일을 읽고 쓰기 위해 csv.NewReader
와 csv.NewWriter
함수를 제공하지만 데이터를 구조체로 매핑해주는 기능은 따로 없다. 이 기능을 직접 구현해볼 것이다.
먼저 데이터 타입을 정의한다. 구조체 태그를 달아서, 다른 marshaler처럼 데이터에 존재하는 필드가 구조체의 각 필드에 매핑될 수 있게끔 한다.
type MyData struct {
Name string `csv:"name"`
Age int `csv:"age"`
HasPet bool `csv:"has_pet"`
}
public API는 두 개의 함수로 구성된다.
func Unmarshal(data [][]string, v interface{}) error
func Marshal(v interface{}) ([][]string, error)
먼저 Marshal()
함수를 작성해 보자.
func Marshal(v interface{}) ([][]string, error) {
sliceVal := reflect.ValueOf(v)
if sliceVal.Kind() != reflect.Slice {
return nil, errors.New("must be a slice of structs")
}
structType := sliceVal.Type().Elem()
if structType.Kind() != reflect.Struct {
return nil, errors.New("must be a slice of structs")
}
var out [][]string
header := marshalHeader(structType)
out = append(out, header)
for i := 0; i < sliceVal.Len(); i++ {
row, err := marshalOne(sliceVal.Index(i))
if err != nil {
return nil, err
}
out = append(out, row)
}
return out, nil
}
어떤 종류의 구조체든 marsharling이 가능해야 하기 떄문에, interface{}
타입의 파라미터를 사용한다. 데이터를 수정하지 않고 읽기만 할 것이기 때문에 구조체의 slice에 대한 포인터가 아니다.
먼저, csv의 첫 줄은 각 열의 이름이 저장된 헤더가 될 것이므로, 구조체에 명시된 구조체 태그로부터 열 이름을 가져와 출력 데이터에 추가한다. slice의 reflect.Value
에서 Type()
메소드를 호출하여 reflect.Type
을 얻고, 다시 Elem()
을 호출하여 slice의 내부 타입 정보를 얻는다. 그리하여 얻은 타입 정보를 marshalHeader()
에 전달하여 반환된 값을 출력 데이터에 추가하는 것이다.
이후 구조체 slice의 이터레이션을 돌면서 각 인덱스의 reflect.Value
를 marshalOne()
의 파라미터로 보내서 반환된 값을 출력 데이터에 추가한다. 이터레이션이 끝나면 값을 반환한다.
marshalHeader()
는 다음과 같이 작성되었다.
func marshalHeader(vt reflect.Type) []string {
var row []string
for i := 0; i < vt.NumField(); i++ {
field := vt.Field(i)
if curTag, ok := field.Tag.Lookup("csv"); ok {
row = append(row, curTag)
}
}
return row
}
구조체를 나타내는 reflect.Type
의 각 필드에 대해 이터레이션을 돌며, csv
태그를 읽고 출력 slice에 추가하여 반환한다.
그리고 marshalOne()
은 다음과 같이 작성되었다.
func marshalOne(vv reflect.Value) ([]string, error) {
var row []string
vt := vv.Type()
for i := 0; i < vv.NumField(); i++ {
fieldVal := vv.Field(i)
if _, ok := vt.Field(i).Tag.Lookup("csv"); !ok {
continue
}
switch fieldVal.Kind() {
case reflect.Int:
row = append(row, strconv.FormatInt(fieldVal.Int(), 10))
case reflect.String:
row = append(row, fieldVal.String())
case reflect.Bool:
row = append(row, strconv.FormatBool(fieldVal.Bool()))
default:
return nil, fmt.Errorf("cannot handle field of kind %v", fieldVal.Kind())
}
}
return row, nil
}
구조체를 나타내는 vv reflect.Value
를 파라미터로 받고, Kind()
메소드로 각 필드의 종류를 보고 문자열로 적절히 변환해준 후 출력한다.
이제 Unmarshal()
함수를 작성해보자.
func Unmarshal(data [][]string, v interface{}) error {
sliceValPtr := reflect.ValueOf(v)
if sliceValPtr.Kind() != reflect.Ptr {
return errors.New("must be a pointer to a slice of structs")
}
sliceVal := sliceValPtr.Elem()
if sliceVal.Kind() != reflect.Slice {
return errors.New("must be a pointer to a slice of structs")
}
structType := sliceVal.Elem().Type()
if structType.Kind() != reflect.Struct {
return errors.New("muast be a pointer to")
}
header := data[0]
namePos := make(map[string]int, len(header))
for k, v := range header {
namePos[v] = k
}
for _, row := range data[1:] {
newVal := reflect.New(structType).Elem()
err := unmarshalOne(row, namePos, newVal)
if err != nil {
return err
}
sliceVal.Set(reflect.Append(sliceVal, newVal))
}
return nil
}
데이터를 복사할 것이기 때문에, Unmarshal()
에서 파라미터로 받는 데이터는 구조체 slice의 포인터여야 한다.
함수 최상단에 이를 검사하는 부분이 존재한다. 이후 차례로 포인터가 가리키는 slice, slice가 저장하는 구조체를 검사한다.
데이터의 첫째 줄은 각 열의 이름이 저장된 헤더이다. 이 정보를 통해 구조체의 csv
태그를 각 인덱스에 대응시키는 map을 생성할 수 있다.
이후 데이터의 각 행마다 이터레이션을 돌며 새로운 structType
타입의 reflect.Value
를 생성한다. 그리고 unmarshalOne()
을 호출하여 각 행의 데이터를reflect.Value
에 복사한 후 출력 데이터에 추가한다.
unmarshalOne()
은 다음과 같이 작성되었다.
func unmarshalOne(row []string, namePos map[string]int, vv reflect.Value) error {
vt := vv.Type()
for i := 0; i < vv.NumField(); i++ {
typeField := vt.Field(i)
pos, ok := namePos[typeField.Tag.Get("csv")]
if !ok {
continue
}
val := row[pos]
field := vv.Field(i)
switch field.Kind() {
case reflect.Int:
i, err := strconv.ParseInt(val, 10, 64)
if err != nil {
return err
}
field.SetInt(i)
case reflect.String:
field.SetString(val)
case reflect.Bool:
b, err := strconv.ParseBool(val)
if err != nil {
return err
}
field.SetBool(b)
default:
return fmt.Errorf("cannot handle field of kind %v", field.Kind())
}
}
return nil
}
이 함수는 새로 생성된 reflect.Value 타입의 각 필드에 대해 이터레이션을 돌며, csv
태그의 이름을 통해 namePos
맵에서 data
slice의 위치를 찾아 문자열로 변환된 값을 저장한다.
이제 작성한 marshaler와 unmarshaler를 통해 csv를 분석해보자!
data :=
`name,age,has_pet
Jon,"100",true
"Fred ""The Hammer"" Smith",42,false
Martha,37,"true"
`
r := csv.NewReader(strings.NewReader(data))
allData, err := r.ReadAll()
if err != nil {
panic(err)
}
var entries []MyData
Unmarshal(allData, &entries)
fmt.Println(entries)
out, err := Marshal(entries)
if err != nil {
panic(err)
}
sb := &strings.Builder{}
w := csv.NewWriter(sb)
w.WriteAll(out)
fmt.Println(sb)
전체 예제 코드는 https://go.dev/play/p/3kwe7ag1i1C에서 확인할 수 있다.
Build Functions with Reflection to Automate Repetitive Tasks
Reflection을 통해 반복적인 코드를 작성하지 않고, 기존 함수를 감싸 재사용하여 함수를 생성할 수도 있다. 다음의 예제는 들어온 파라미터로 함수에 시간을 재는 기능이 추가된 함수를 반환하는 Factory 함수이다.
func MakeTimedFunction(f interface{}) interface{} {
ft := reflect.TypeOf(f)
fv := reflect.ValueOf(f)
wrapperF := reflect.MakeFunc(ft, func(args []reflect.Value) []reflect.Value {
start := time.Now()
out := fv.Call(args)
end := time.Now()
fmt.Println(end.Sub(start))
return out
})
return wrapperF.Interface()
}
이 함수는 함수를 파라미터로 받아, 함수를 나타내는 reflect.Type
을 얻어 reflect.MakeFunc()
로 보낸다.
이후 reflect.MakeFunc()
로 시작 시각을 얻고, 기존 함수를 호출하고, 종료 시각을 얻어서 시간차를 출력하고, 기존 함수의 반환값을 반환 하는 함수를 작성한다.
이후 해당 함수를 가리키는 reflect.Value
를 Interface()
메소드를 통해 반환할 함수를 얻는다.
위 함수는 다음과 같이 사용할 수 있다.
func timeMe(a int) int {
var result int = 0
for i := 0; i < a*10000; i++ {
result += 1
}
return result
}
func main() {
timed := MakeTimedFunction(timeMe).(func(int) int)
fmt.Println(timed(100000))
}
이처럼 Reflection을 통해 함수를 생성할 수는 있지만 이 기능을 사용할 때는 주의해야 한다. 언제 함수를 생성할지, 어떤 기능을 추가할지에 대해 확실히 해 두는 편이 좋다. 그렇지 않으면 프로그램 내 데이터 흐름을 이해하기 더욱 어려워질 것이다.
뿐만 아니라 기본적으로 Reflection은 프로그램을 느리게 만든다. 네트워크 통신 같은 원래 느린 동작을 하는 게 아닌 이상, 함수를 생성하고 생성된 함수를 호출하는 것은 성능에 중대한 영향을 미칠 것이다. 다시 한번 언급하지만, Reflection은 프로그램과 외부 세계에서 오고가는 데이터를 매핑할 때 유용하다.
What you shouldn’t do with Reflection
reflect.StructOf()
함수는 reflect.StructField
의 slice를 파라미터로 받아 새로운 구조체 타입을 가리키는 reflect.Type
을 반환한다. 이렇게 생성된 구조체는 interface{}
를 통해서만 변수에 할당될 수 있으며, Reflection을 사용해야만 데이터를 읽고 쓸 수 있다.
근데 굳이 이렇게까지 할 필요가 없다. 이 기능은 학술적인 목적에서만 사용된다고 하며, 우리 같은 일반 사용자들은 그냥 구조체 선언해서 쓰자. 하지말라면 하지마!
또한 Reflection으로는 메소드를 만들 수도 없다. 함수나 구조체 타입은 만들 수 있긴 한데, 타입에 메소드를 추가할 수는 없다. 당연히 인터페이스를 만드는 것도 안된다.
Only Use Reflection If It’s Worthwhile
입이 닳도록 말하지만 Reflection은 프로그램과 외부 세계에서 오고가는 데이터를 변환할 때 필수적이다. 하지만 그 외의 상황에서는 사용에 주의를 기울여야 한다.
이를테면 여러 언어에 존재하는 Filter()
함수를 작성한다고 가정해보자. 만약 문자열에 대해 동작하는 Filter()
함수를 작성한다면 아래와 같이 작성할 것이다.
func Filter(slice []string, filter func(string) bool) []string {
res := []string{}
for _, s := range slice {
if filter(s) {
res = append(res, s)
}
}
return res
}
반면 단일 타입이 아니라 여러 타입에 대해 연산을 가능하게 한다고 가정할 때, 이런 함수는 Reflection을 통해서 작성할 수 있다.
func ReflectedFilter(slice interface{}, filter interface{}) interface{} {
sv := reflect.ValueOf(slice)
fv := reflect.ValueOf(filter)
sliceLen := sv.Len()
out := reflect.MakeSlice(sv.Type(), 0, sliceLen)
for i := 0; i < sliceLen; i++ {
curVal := sv.Index(i)
values := fv.Call([]reflect.Value{curVal})
if values[0].Bool() {
out = reflect.Append(out, curVal)
}
}
return out.Interface()
}
그리고 아래와 같이 호출하여 사용한다.
func main() {
names := []string{"Andrew", "Bob", "Clara", "Hortense"}
longNames := ReflectedFilter(names, func(s string) bool {
return len(s) > 3
}).([]string)
fmt.Println((longNames))
ages := []int{20, 50, 13}
adults := ReflectedFilter(ages, func(age int) bool {
return age >= 18
}).([]int)
fmt.Println(adults)
}
ReflectedFilter()
함수는 어렵지 않지만 Filter()
보다는 분명히 길다.
게다가 성능을 측정해보면 Reflection을 사용한 코드는 사용하지 않은 코드보다 30~70배 느린 것을 확인할 수 있다.
BenchmarkFilterReflectString-12 4123 336595 ns/op
BenchmarkFilterString-12 191110 8211 ns/op
BenchmarkFilterReflectInt-12 3427 359483 ns/op
BenchmarkFilterInt-12 497114 4233 ns/op
더 많은 메모리를 사용하고 수천 회의 메모리 할당이 일어나므로 가비지 컬렉터가 해야 할 일이 늘어나기 때문이다. 즉, 필요에 따라 Reflection을 사용할 수는 있지만 충분히 고민을 거쳐야 한다.
성능 이슈보다 중요한 문제는 파라미터로 잘못된 타입을 넘기는 것을 컴파일러가 잡아주지 못한다는 것이다. 특정 타입만 허용해주는 작업을 반복적으로 진행해야 하기 떄문에 에러를 찾기도 힘들며, 코드의 유지보수 난이도가 증가한다.
unsafe is Unsafe
reflect
패키지를 통해 타입, 값을 조작할 수 있듯, unsafe
패키지를 통해 메모리를 조작할 수 있다.
그러나 unsafe
패키지는 굉장히 특이한 3개의 함수와 하나의 타입을 정의하는데, 다른 패키지의 함수나 타입과는 완전히 다르다고 볼 수 있다.
함수 Sizeof()
는 특정 타입의 변수가 몇 바이트를 사용하고 있는지 반환한다.
함수 Offsetof()
는 구조체의 필드가 구조체의 시작점으로부터 몇 바이트 떨어져 있는지 반환한다.
함수 Alignof()
는 변수 또는 필드가 얼마만큼 byte alignment를 필요로 하는지 반환한다.
어떤 값이든 이 함수들의 파라미터로 보낼 수 있으며, 반환되는 값은 상수이므로 상수 표현식에 사용할 수 있다.
unsafe.Pointer
타입은 모든 유형의 포인터를 unsafe.Pointer
로 변환하거나, 또는 unsafe.Pointer
를 변환하여 포인터를 얻을 수 있는 특별한 타입이다.
또한 unsafe.Pointer
는 uintptr
이라고 하는 특별한 정수형 타입으로도 변환할 수 있으며, C/C++처럼 이 타입을 통해 수학적 연산도 가능하다.
이렇게 하면 특정 타입의 인스턴스로 들어가서 각각의 바이트를 추출할 수 있으며, 바이트를 조작하면 변수의 실제 값이 변경된다.
일반적으로 unsafe
패키지를 사용하는 두 가지 패턴이 있다.
첫 번째는 일반적으로 변환할 수 없는 두 타입간의 변수를 변환하는 것이다. unsafe.Pointer
를 가운데 두고 몇 단계의 타입 변환을 거쳐 수행할 수 있다.
두 번째는 변수를 unsafe.Pointer
로 변환하고 unsafe.Pointer
를 uintptr
로 변환한 다음, 바이트에 접근하여 복사하거나 조작하는 것이다.
Use unsafe to Convert External Binary Data
Go는 메모리 안정성을 중요시 하는 언어인데 왜 unsafe
같은 패키지가 존재하는가에 대해 의문이 생길 것이다.
Go 프로그램과 외부 세계의 사이에서 데이터를 변환하기 위해 reflect
를 사용하는 것처럼, 바이너리 데이터를 변환하기 위해 unsafe
를 사용한다.
실제로 unsafe
가 사용되는 목적을 살펴보면 주로 운영체제 또는 C로 작성된 코드와의 통합을 위해 사용되거나, 코드 최적화를 위해 사용된다.
특히 네트워크로부터 데이터를 읽어들이거나 전송할 때 unsafe
의 unsafe.Pointer
를 사용하여 데이터를 매핑하면 매우 빠르다.
가령 다음과 같은 구조의 데이터 프로토콜이 있다고 해보자.
- Value: 4 바이트, big endian, unsigned int
- Label: 10 바이트, 값에 대한 ASCII 이름
- Active: 1 바이트, 필드가 활성화되어 있는지 나타내기 위한 플래그
- Padding: 데이터를 16바이트로 맞춰주기 위한 Padding
네트워크상의 데이터는 대부분 Big endian 또는 Network Byte Order로, most significant byte가 가장 작은 주소에 할당된다. 반면 오늘날 대부분의 CPU에서는 least significant byte가 가장 작은 주소에 할당되는 little endian을 사용한다. 네트워크상의 데이터를 읽거나 쓸 때는 데이터의 처리에 주의해야 한다.
다음과 같은 구조체를 정의한다.
type Data struct {
Value uint32
Label [10]byte
Active bool
// padded 1 byte
}
그리고, 네트워크로부터 다음과 같은 바이트 열을 읽었다고 가정해보자.
[0 132 95 237 80 104 111 110 101 0 0 0 0 0 1 0]
이제 이 데이터를 길이가 16인 바이트로 읽은 후, Data
구조체로 변환할 것이다.
안전한 Go 코드에서는 다음과 같은 코드로 데이터 매핑을 할 수 있다.
func DataFromBytes(b [16]byte) Data {
d := Data{}
d.Value = binary.BigEndian.Uint32(b[:4])
copy(d.Label[:], b[4:14])
d.Active = b[14] != 0
return d
}
혹은 이렇게 unsafe.Pointer
를 사용할 수도 있다.
func DataFromBytesUnsafe(b [16]byte) Data {
data := *(*Data)(unsafe.Pointer(&b))
if isLE {
data.Value = bits.ReverseBytes32(data.Value)
}
return data
}
먼저 [16]byte
의 포인터를 unsafe.Pointer
로 변환한 후 이를 다시 *Data
로 변환한다.
구조체의 포인터가 아니라 값을 반환할 것이기 때문에 가장 바깥에서 *
를 통해 포인터를 derefernce해준다.
이때 little endian 플랫폼이라면 값을 뒤집어주는 작업을 거친 후 데이터를 반환해준다.
Go 프로그램이 little endian 플랫폼에서 돌아가고 있는지 다음과 같이 확인할 수 있다.
var isLE bool
func init() {
var x uint16 = 0xFF00
xb := *(*[2]byte)(unsafe.Pointer(&x))
isLE = (xb[0] == 0x00)
}
init()
함수의 사용은 최대한 피해야 하지만, 지금의 경우는 실질적으로 변하지 않는 패키지 변수를 초기화하는 경우이므로 init()
을 사용하여 isLE
를 초기화한다.
프로그램 런타임 중 프로세서의 endian이 변할 일은 없기 때문에 적절한 용례라고 볼 수 있다.
litten endian 플랫폼에서는 x
의 바이트 열이 [00 FF]로 저장되지만 big endian 플랫폼에서는 [FF 00]으로 저장된다.
따라서 unsafe.Pointer
로 정수를 바이트 열로 변환한 후, 첫 번째 바이트의 값을 확인하여 isLE
를 결정할 수 있다.
마찬가지로 네트워크상에 데이터를 전송하는 경우, 안전한 Go 코드는 다음과 같다.
func BytesFromData(d Data) [16]byte {
out := [16]byte{}
binary.BigEndian.PutUint32(out[:4], d.Value)
copy(out[4:14], d.Label[:])
if d.Active {
out[14] = 1
}
return out
}
그리고 unsafe
를 사용하면 다음과 같다.
func BytesFromDataUnsafe(d Data) [16]byte {
if isLE {
d.Value = bits.ReverseBytes32(d.Value)
}
b := *(*[16]byte)(unsafe.Pointer(&d))
return b
}
실제로 성능을 비교해보면, unsafe
를 사용한 코드가 두 배 가량 빠르다고 한다.
BenchmarkBytesFromData-12 127067046 9.444 ns/op
BenchmarkBytesFromDataUnsafe-12 187575825 6.395 ns/op
BenchmarkDataFromBytes-12 127086181 9.590 ns/op
BenchmarkDataFromBytesUnsafe-12 135862089 8.999 ns/op
..나는 두 배가 아닌데..?
어쨌든 이러한 로우레벨 기술을 활용하여 퍼포먼스를 끌어올려야 하는 프로그램에서는 유용하게 사용할 수 있을 것이다. 다만 대부분의 프로그램에서는 이렇게까지 할 필요는 없고 그냥 안전한 코드를 쓰자.
unsafe Strings and Slices
unsafe
에서도 slice나 string을 사용할 수 있다.
우선 string
은 기본적으로 byte 열의 포인터와 길이로 이루어 있다.
reflect
패키지에는 이러한 구조를 가지고 있는 reflect.StringHeader
라는 타입이 존재하며, 이를 통해 string
의 내부적 구조에 접근하여 조작할 수 있다.
아래 예제는 길이 정보에 접근하는 예제이다.
s := "hello"
sHdr := (*reflect.StringHeader)(unsafe.Pointer(&s))
fmt.Println(sHdr.Len)
또한 sHdr
의 Data
필드에 접근하여 “pointer arithmetic”을 통해 문자열 내부의 바이트를 읽을 수 있다. 이때 피연산자의 타입은 uintptr
여야 한다. C/C++에서 보던 것과 유사한 접근이다!
for i := 0; i < sHdr.Len; i++ {
bp := *(*byte)(unsafe.Pointer(sHdr.Data + uintptr(i)))
fmt.Println(string(bp))
}
fmt.Println()
runtime.KeepAlive(s)
reflect.StringHeader
의 Data
필드는 uintptr
유형이다.
그런데 가비지 컬렉터의 존재로 인해 uintptr
을 통해 유효한 메모리를 단일 구문 이상 참조할 수는 없다.
이때 runtime.KeepAlive(s)
를 함수의 마지막에 추가하면, Go 런타임의 가비지 컬렉터가 KeepAlive()
를 호출할 때까지 s
를 수집하지 않게 된다.
unsafe
패키지를 통해 string으로부터 reflect.StringHeader
을 얻을 수 있듯, slice로부터 reflect.SliceHeader
를 얻을 수 있다.
reflect.SliceHeader
에는 각각 Len
, Cap
, Data
의 세 가지 필드가 있으며 각각 길이, 용량, 그리고 slice의 데이터를 가리키는 포인터이다.
s := []int{10, 20, 30}
sHdr := (*reflect.SliceHeader)(unsafe.Pointer(&s))
fmt.Println(sHdr.Len)
fmt.Println(sHdr.Cap)
string에서와 유사하게 []int
포인터를 unsafe.Pointer
로 변환한 후, 이를 다시 reflect.SliceHeader
의 포인터로 변환한다. 이후 Len
, Cap
필드에 접근할 수 있다.
slice 또한 각 원소에 대해 이터레이션이 가능하다.
intByteSize := unsafe.Sizeof(s[0])
fmt.Println(intByteSize)
for i := 0; i < sHdr.Len; i++ {
intVal := *(*int)(unsafe.Pointer(sHdr.Data + intByteSize*uintptr(i)))
fmt.Println(intVal)
}
runtime.KeepAlive(s)
int
의 크기는 32비트 또는 64비트이기 때문에, 반드시 unsafe.Sizeof()
함수로 Data
필드의 각 값이 몇 바이트인지 확인해야 한다.
이후 그 크기만큼 uintptr
로 변환한 i
를 곱하면 Data
필드의 각 데이터의 접근할 수 있다.
마지막으로 int
포인터로 변환해주고 dereference하면 값을 얻을 수 있다.
unsafe Tools
실행 명령어에 -gcflags=-d=checkptr
플래그를 붙이면 잘못 사용된 unsafe.Pointer
나 uintptr
를 찾을 수 있다.
이 플래그는 Race Checker처럼 모든 unsafe
문제를 찾을 수 있는 것은 아니고 프로그램의 속도를 느리게 만들지만, 테스트 단계에서는 붙이는 것이 좋다.
unsafe는 유용한 로우 레벨 패키지이지만, 지금 무엇을 필요로 하는지 잘 모르거나 성능 향상이 필요한 게 아니라면 사용하지 않는 것이 좋다.
unsafe에 대해 더 알고싶다면 패키지 문서를 참조해 보자.
Cgo
reflection이나 unsafe
와 마찬가지로, cgo
는 Go 프로그램과 외부 세계의 경계, 그 중 C 라이브러리와의 integration을 할 때 가장 유용하다.
C는 매우 오래 된 언어임에도 불구하고, 프로그래밍 언어의 lingua franca로 자리잡고 있다. 거의 운영체제가 C나 C++로 작성되며, 이에 포함된 라이브러리들도 C로 작성되었다.
따라서 거의 모든 언어가 C 라이브러리와 통합하는 방법을 제공한다. Go에서는 C에 대한 Foreign Function Interface(FFI)를 cgo
라고 이름붙였다.
Go는 명시적인 것을 지향하는 언어이지만, cgo
는 뭔가 자동으로 알아서 해주는 느낌이 강하다. 직접 예제를 보자.
package main
import "fmt"
/*
#cgo LDFLAGS: -lm
#include <stdio.h>
#include <math.h>
#include "mylib.h"
int add(int a, int b) {
int sum = a + b;
printf("a: %d, b: %d, sum %d\n", a, b, sum);
return sum;
}
*/
import "C"
func main() {
sum := C.add(3, 2)
fmt.Println(sum)
fmt.Println(C.sqrt(100))
fmt.Println(C.multiply(10, 20))
}
일단, /**/
로 묶인 주석이 반드시 필요하며, 주석과 import "C"
사이에는 빈 줄이 없어야 한다. 이 빈 줄때문에 컴파일이 안돼서 한참 찾았다..
위 main.go
파일과 같은 폴더 안에 각각 mylib.h
, mylib.c
을 생성하여 다음과 같이 작성해준다.
int multiply(int a, int b);
#include "mylib.h"
int multiply(int a, int b) {
return a * b;
}
그러고 나서 go build
명령어로 컴파일하여 생성된 파일을 실행하면 다음과 같은 결과를 얻을 수 있을 것이다.
$ ./cgo
a: 3, b: 2, sum 5
5
10
200
우선 C
라는 패키지는 실존하는 표준 라이브러리가 아니다!
C
는 자동으로 생성되는 패키지이며, import "C"
위에 명시된 주석에 내장된 C 코드에서 대부분의 식별자가 정의된다.
예제의 주석에서는 add()
함수를 선언하였고, C.add()
로 그 함수를 불러와서 호출하였다.
또한 주석 블록 안에서 import된 다른 함수나 전역변수 등도 불러올 수 있다. 이를테면 math.h
에서 불러온 C.sqr()
나 mylib.h
에서 불러온 C.multiply()
처럼 말이다.
이뿐 아니라 C
패키지에서는 C 내장 타입을 나타내기 위한 C.int
나 C.char
타입 등도 정의하고 있으며, Go의 문자열을 C의 문자열로 변환하기 위한 C.Cstring
함수도 있다.
더 신기한 일도 가능하다. 이번에는 Go의 함수를 C로 export할 건데, Go의 함수 앞에 //export
주석을 붙이면 할 수 있다. 먼저 main.go
의 소스코드를 작성한다.
package main
/*
extern int add(int a, int b);
*/
import "C"
import "fmt"
//export doubler
func doubler(i int) int {
return i * 2
}
func main() {
fmt.Println(C.add(1, 2))
}
이제 main.go
와 동일한 디렉토리에 .c 파일을 생성하여 작성한다. 이 때 특수 헤더인 "_cgo_export.h"
를 include해야 한다.
#include "_cgo_export.h"
int add(int a, int b) {
int doubleA = doubler(a);
int sum = doubleA + b;
return sum;
}
위 예제를 실행하면 다음과 같은 결과를 얻는다.
$ ./export
4
이렇듯 cgo
로 Go와 C의 함수를 상호 호출하는 것은 생각보다 간단하다.
하지만 Go는 가비지 컬렉터가 존재하지만 C에는 없다는 점 때문에, 대수롭지 않은 Go 코드라도 C와 통합되기가 생각보다 쉽지 않다. 그래서 포인터를 C 코드로 보낼 수는 있지만, 포인터를 포함하는 무언가를 보낼 수는 없다. 이러한 점 때문에 문자열, 슬라이스, 함수 등 포인터로 구현된 것들은 C로 보낼 수 없다.
또한 C 함수가 반환되어도 Go 포인터의 복사본을 가지고 있으면 안된다. Go가 가비지 컬렉팅을 하고 나면 포인터가 이상한 메모리 주소를 가리키게 되기 때문에, 프로그램이 컴파일은 되더라도 런타임에 죽거나 이상하게 동작할 수도 있다.
다른 제약도 있다. printf()
와 같이 가변적인 함수를 호출하기 위해 cgo
를 사용하면 안된다.
C의 Union 타입은 바이트 어레이로 변환되며 C의 함수 포인터를 Go에서 호출할 수는 없지만, 변수에 저장해 두었다가 다시 C 함수로 전달할 수는 있다.
이러한 규칙들 때문에 cgo
를 사용하기가 쉽지 않다.
만약 Python이나 Ruby에 익숙하다면 cgo
같은 접근이 성능 측면에서 굉장한 이점이 있을 것임을 알 것이다. 실제로 Python의 NumPy는 C로 작성되기도 했다.
하지만 Go는 파이썬이나 루비보다 훨씬 빠르다. 알고리즘을 로우 레벨에서 재작성할 필요가 크게 없다. 그리고 cgo
를 쓰더라도 성능 향상을 기대하기는 어렵다. C와 Go의 메모리 모델과 프로세싱 방법이 다르기 때문인데, Go에서 C 코드를 호출할 때 C에서 C 코드를 호출하는 것보다 대략 29배 느리다고 한다.
cgo
가 빠르지도 않고 사용하기도 쉽지 않지만, 사용하는 이유는 명확하다.
반드시 사용해야 하는 C 라이브러리가 있지만 대체할 Go 라이브러리는 없는 경우일 때이다.
만약 대체할 Go 라이브러리가 있다면, 웬만하면 그걸 쓰도록 하자.
References
[
](https://learning.oreilly.com/library/view/learning-go/9781492077206/)[Jon Bodner, 『Learning Go』, O'Reilly Media, Inc.](https://learning.oreilly.com/library/view/learning-go/9781492077206/)