Skip to main content

Command Palette

Search for a command to run...

Go Interface 파헤치기

Updated
5 min read

들어가며

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 으로 escape[^1] 되는 것을 확인할 수 있다. 만약 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 escape[^2] 는 escape analysis 와 무관하다.


[^1]: “100 escapes to heap”은 x 변수가 escape했다는 뜻이 아니다. literal 100 자체가 interface wrapping 과정에서 힙으로 복사된 것이다. 함수의 반환 타입이 interface이기 때문에 literal은 임시 메모리에 둘 수 없어서 힙으로 올라가는 것이다.

[^2]: 여기서 “inlining call to aa”는 escape analysis와 무관하다. 단순히 컴파일러가 aa() 함수를 main 함수 안으로 인라인 최적화 한 것뿐이다. escape 여부는 '100 escapes to heap' 같은 별도의 메시지에서 판별된다.

21 views

More from this blog

RDB 에서 큰 컬럼을 인덱스로 잡으면 안되는 이유

B-Tree 는 기본적으로 페이지 사이즈 와 저장할 수 있는 원소의 개수를 고정값으로 사용한다. 하지만 우리가 실제로 페이지에 저장하는 값은 가변적인 크기를 가지고 있기 때문에 필연적으로 물리적으로 저장해야할 개수가 다 차기도 전에 페이지가 넘치는 상황에 부딪히게 된다. 예를 들어 100KB 를 저장하는 페이지에 위와 같이 데이터를 저장한 상태이다. 여

Feb 26, 20262 min read49

Slotted Page

데이터베이스와 관련된 기술을 보다보면 어떻게 데이터를 관리하고 저장하지? 특히 단편화(Fragmentation) 이 일어나는 것을 어떻게 통제하고 관리할까? 혹은 정렬된 자료구조 내부에서 데이터의 순서를 보존하기 위해 어떠한 행위들을 할까? 궁금해집니다. 오늘은 조금 더 데이터베이스 내부에 쓰이는 자료구조를 들여다보며 연관된 행위를 공부해보려고 합니다. F

Feb 22, 20264 min read63
Slotted Page

MCP 를 통한 workflow 자동화

AI native 최근에 LinkedIn 이나 여러 소셜 플랫폼들의 글을 보면 AI native 회사 라는 워딩들이 많이 보입니다. IBM 의 정의에 따르면 AI native 를 아래와 같이 정의한다고 하는데요. “AI를 사고와 업무 방식에 끊임없이 내재화하는 상태” 그렇다면 팀원들이 계속해서 AI 를 사고와 업무 방식에 끊임 없이 내재화 하려면 어떻게 해야할까요? 개발자들은 이미 Claude code 나 Codex 등 여러 AI Tool...

Feb 14, 20263 min read100

파이썬 톺아보기 2화 - Ast 와 바이트코드

식(Expression) 과 문장(Statement) 프로그래밍을 공부하다보면 위 두 단어를 반드시 마주하게 된다. 가끔 헷갈려하는 경우가 많은데 오늘은 python 에서 기본 모듈인 ast 모듈을 공부하며 이를 알아보도록 하자. 식(Expression) 기본적으로 식(Expression) 이란 평가되면 값이 나오는 코드 조각을 뜻한다. 파이썬에서는 어떠한 부분들이 있을까? 노드 타입설명예시 BinOp이항 연산a + b, x * y...

Feb 6, 20267 min read30
D

dev_roach

41 posts