들어가며
Go를 쓰다 보면 “인터페이스가 뭔가 다른 언어와 다르게 신기하게 쓰이는 구나” 하는 순간이 종종 있다. 특히 제네릭이 없던 시절의 Go 코드를 보면 아래처럼 interface{}를 마치 만능 컨테이너처럼 쓰는 코드가 흔했다.
var values []interface{}
values = append(values, 10)
values = append(values, "hello")
values = append(values, []int{1, 2, 3})
그리고 나중에 이런 식으로 타입에 따라 분기한다.
func Print(v interface{}) {
switch x := v.(type) {
case int:
fmt.Println("int:", x)
case string:
fmt.Println("string:", x)
case []int:
fmt.Println("slice:", x)
default:
fmt.Printf("unknown type: %T\n", x)
}
}
정적 타입 언어에서 이렇게 “무한정 아무 타입이나 넣고 꺼내는" 구조가 되는 건 꽤 놀랍다. 이게 어떻게 가능할까? 이 질문의 진짜 핵심은 Go 인터페이스의 내부 구조에 있다.
eface / iface
Go 언어는 인터페이스를 두 가지 형태로 나누어 구현하고 있다.
- eface: 빈 인터페이스 (interface{})
- iface: 메서드가 있는 인터페이스
이 두 구조를 알면 Go 인터페이스를 이해하는데 큰 도움이 된다.
eface — 빈 인터페이스 (interface{})
Go 런타임에 정의된 eface 구조체는 정말 단순하다.
type eface struct {
typ *_type // dynamic type metadata
data unsafe.Pointer // pointer to actual data
}
interface 값은 정적 타입이 interface이지만, 내부에는 “구체 타입 + 값”이 살아 있다.
예를 들어:
var x interface{} = 10
런타임 구조는 다음과 같다:
x = (type=*int, data=&10)
이 사실 하나만 이해해도 왜 여러 타입을 한 slice에 넣을 수 있는지 왜 type assertion이 가능한지 왜 JSON 언마샬 결과가 map[string]interface{} 인지 왜 reflect가 interface 기반으로 동작하는지 전부 설명된다.
iface — 메서드가 있는 인터페이스
예를 들어:
type Reader interface {
Read([]byte) (int, error)
}
이런 인터페이스는 메서드 테이블(itab)을 포함하는 iface 구조를 사용한다.
type iface struct {
tab *itab
data unsafe.Pointer
}
itab 안에는 아래와 같은 정보들이 존재한다.
- 인터페이스 정보
- 구체 타입 정보
- 인터페이스가 요구하는 메서드들의 함수 포인터(jump table)
var r io.Reader = bytes.NewBuffer([]byte("hi"))
r.Read(buf)
그래서 위와 같은 코드를 실행시켜보면 런타임에서 실제로는
itab.funcs[0](data, buf)
이런 식으로 메서드가 호출된다.
nil interface trap
이제 구조를 봤으니 이 trap도 제대로 이해할 수 있다.
var x interface{} = (*User)(nil)
fmt.Println(x == nil) // false
왜 false일까? 인터페이스가 “진짜 nil”이 되려면 typ == nil && data == nil 이 되어야 한다. 그러나 위 코드는 x = (typ = *User, data = nil) 이다. 즉, type 포인터는 살아있고 data는 nil 이기 때문에 interface 전체는 nil이 아니다.
실제 메모리 구조를 직접 확인
package main
import (
"fmt"
"unsafe"
)
type eface struct {
typ uintptr
data uintptr
}
func dump(label string, v interface{}) {
p := (*eface)(unsafe.Pointer(&v))
fmt.Printf("[%s]\n", label)
fmt.Printf(" interface address : %p\n", &v)
fmt.Printf(" type pointer : 0x%x\n", p.typ)
fmt.Printf(" data pointer : 0x%x\n", p.data)
// data를 실제 타입으로 역참조할 수 있는 경우 출력
if p.data != 0 {
fmt.Printf(" data as int? : %d\n", *(*int)(unsafe.Pointer(p.data)))
}
fmt.Println()
}
func main() {
dump("int", 10)
dump("string", "hello")
dump("slice", []int{1, 2, 3})
var u *int = nil
dump("nil pointer in interface", u)
var x interface{}
dump("true nil interface", x)
}
실행 결과
[int]
interface address : 0x140000a4010
type pointer : 0x102a924c0
data pointer : 0x102a83538
data as int? : 10
[string]
interface address : 0x140000a4030
type pointer : 0x102a92280
data pointer : 0x102aad068
data as int? : 4339381145
[slice]
interface address : 0x140000a4050
type pointer : 0x102a912e0
data pointer : 0x140000aa000
data as int? : 1374390214680
[nil pointer in interface]
interface address : 0x140000a4070
type pointer : 0x102a8f260
data pointer : 0x0
[true nil interface]
interface address : 0x140000a4090
type pointer : 0x0
data pointer : 0x0
nil pointer in interface 의 경우 type pointer 값이 존재하므로 interface 자체는 nil이 아니다. true nil interface 의 경우 type/data 둘 다 nil일 때만 진짜 nil 이다.
자주보이는 interface pattern들
여러 타입을 한 컨테이너에 담기 ([]interface{})
eface 구조 덕분에 어떤 타입이든 담을 수 있다.
list := []interface{}{1, "hello", []int{1,2,3}}
dynamic type이 살아있으니 type switch로 잘 처리된다.
type switch로 다형성 처리
switch v := x.(type) {
case int, string:
...
}
interface 내부 type pointer 비교만 하므로 성능도 꽤 빠른 편이다.
JSON / YAML 동적 구조 (map[string]interface{})
Go의 정적 타입 구조상, JSON 같은 문서 기반 구조를 표현하려면 value를 interface로 받을 수밖에 없다. 그리고 자연스럽게 type switch로 핸들링한다.
error 인터페이스 기반 다형성
type error interface {
Error() string
}
iface 구조 덕분에 다양한 에러 타입을 단일 인터페이스로 다루고, wrapping/unwrapping도 dynamic type 정보 기반으로 자연스럽게 구현된다.
그렇다면 문제는 없을까?
얼핏 보기에 정적 타입 내부에서 꽤나 자유롭게 Interface 를 이용할 수 있어 보인다. 하지만 escape analysis 에 의한 interface 타입의 heap allocation 이 발생할 수 있다. 아마 rust 를 공부했던 사람들은 익숙할 수 있는데 이 heap allocation 을 이해하기 위해 아래 예시를 함께 보자.
func f() interface{} {
x := 10
return x
}
위 함수를 보면 x 에 정수(int) 값 10을 할당하고 return type 으로 interface{} 타입을 반환한다. 우리의 상식으로는 int 는 정수값으로 원래 스택 변수이기 때문에 heap allocation 이 발생하지 않을 것 같지만, 실제로는 heap allocation 이 발생한다. 이는 escape analysis 에 의한 interface 타입의 heap allocation 이 발생하기 때문이다.
이유는 무엇일까? 예를 들어 a = f() 라는 부분이 외부에 있다고 해보자. 그런데 x 의 값이 사라지면 interface 의 data pointer 가 dangling pointer 가 되어버린다. 따라서 컴파일러는 이를 최대한 안전하게 처리하기 위해 heap 에 올려버린다.
x escapes to heap because it’s stored in interface
실제 예시와 go build 에서 flag 를 통해 확인해보자
package main
func aa() interface{} {
x := 100
return x
}
func main() {
a := aa()
_ = a
}
❯ go build -gcflags="-m" interface_prac/main.go
# command-line-arguments
interface_prac/main.go:3:6: can inline aa
interface_prac/main.go:8:6: can inline main
interface_prac/main.go:9:13: inlining call to aa
interface_prac/main.go:5:9: 100 escapes to heap
interface_prac/main.go:9:13: 100 does not escape
위와 같이 100 이 heap 으로 escape1 되는 것을 확인할 수 있다. 만약 int 타입으로 리턴됬다면 어떨까?
package main
func aa() int {
x := 100
return x
}
func main() {
a := aa()
_ = a
}
❯ go build -gcflags="-m" interface_prac/main.go
# command-line-arguments
interface_prac/main.go:3:6: can inline aa
interface_prac/main.go:8:6: can inline main
interface_prac/main.go:9:9: inlining call to aa
int 를 리턴하는 경우에는 위 예시처럼 escape 가 일어나지 않음을 확인할 수 있다. inlining call to escape2 는 escape analysis 와 무관하다.
-
“100 escapes to heap”은 x 변수가 escape했다는 뜻이 아니다. literal 100 자체가 interface wrapping 과정에서 힙으로 복사된 것이다. 함수의 반환 타입이 interface이기 때문에 literal은 임시 메모리에 둘 수 없어서 힙으로 올라가는 것이다. ↩
-
여기서 “inlining call to aa”는 escape analysis와 무관하다. 단순히 컴파일러가 aa() 함수를 main 함수 안으로 인라인 최적화 한 것뿐이다. escape 여부는 '100 escapes to heap' 같은 별도의 메시지에서 판별된다. ↩
💬 댓글 0