본문으로 건너뛰기

인터페이스의 내부와 함정

이 챕터에서 다루는 것

인터페이스 값이 무엇으로 이루어져 있는지를 알면 Go에서 가장 악명 높은 함정 하나가 당연한 결과로 보인다. 여기서는 (타입, 값) 쌍이라는 구조, 거기서 값을 꺼내는 타입 단언과 타입 스위치, 그리고 그 구조 때문에 생기는 세 가지 함정을 다룬다.

인터페이스 값의 구조

인터페이스 값은 두 개의 워드다.

┌──────────────┬──────────────┐
│ 동적 타입 │ 동적 값 │
│ (type) │ (value) │
└──────────────┴──────────────┘
  • 동적 타입: 안에 들어 있는 값의 실제 타입. *os.File, int, main.User 같은 것.
  • 동적 값: 그 값 자체(또는 그 값에 대한 포인터).
var w io.Writer // (nil, nil)
w = os.Stdout // (*os.File, os.Stdout의 주소)
w = &bytes.Buffer{} // (*bytes.Buffer, 그 버퍼의 주소)

인터페이스가 nil이라는 것은 두 칸이 모두 비었다는 뜻이다. 이 한 문장이 이 챕터 후반부의 함정을 전부 설명한다.

값을 담을 때 값이 복사된다는 점도 기억해 두자. w = someStruct는 구조체를 복사해서 넣는다. 그래서 4-1에서 본 대로 인터페이스에 담긴 값은 주소를 얻을 수 없고, 포인터 리시버 메서드를 부를 수 없다.

타입 단언

인터페이스에서 구체 타입을 다시 꺼내는 것이 타입 단언(type assertion) 이다.

v := x.(T) // 실패하면 패닉
v, ok := x.(T) // 실패하면 v는 T의 제로값, ok는 false
examples/04-interfaces-and-errors/04-type-assertion/main.go
package main

import (
"bytes"
"fmt"
"io"
"os"
"strings"
)

// describe는 타입 스위치로 분기한다.
func describe(v any) string {
switch x := v.(type) {
case nil:
return "nil"
case int:
return fmt.Sprintf("int %d (두 배 %d)", x, x*2)
case string:
return fmt.Sprintf("string %q (길이 %d)", x, len(x))
case []int:
return fmt.Sprintf("[]int 요소 %d개", len(x))
case error:
// 인터페이스도 case에 쓸 수 있다. 위쪽 case가 먼저 걸리면 여기까지 오지 않는다.
return "error: " + x.Error()
case fmt.Stringer:
return "Stringer: " + x.String()
default:
return fmt.Sprintf("모르는 타입 %T", x)
}
}

type temperature float64

func (t temperature) String() string { return fmt.Sprintf("%.1f도", float64(t)) }

func main() {
var w io.Writer = os.Stdout

// 인터페이스에 담긴 동적 타입과 값
fmt.Printf("동적 타입: %T\n", w)

// 구체 타입으로 단언 — 성공
f := w.(*os.File)
fmt.Println("파일 이름:", f.Name())

// comma-ok 형태 — 실패해도 패닉이 아니다
if buf, ok := w.(*bytes.Buffer); ok {
fmt.Println("버퍼:", buf.Len())
} else {
fmt.Println("*bytes.Buffer가 아니다")
}

// 인터페이스에서 인터페이스로도 단언할 수 있다
if c, ok := w.(io.Closer); ok {
fmt.Printf("Closer이기도 하다: %T\n", c)
}

var w2 io.Writer = &strings.Builder{}
if _, ok := w2.(io.Closer); !ok {
fmt.Println("strings.Builder는 Closer가 아니다")
}

// comma-ok 없이 실패하면 패닉이다
func() {
defer func() {
if rec := recover(); rec != nil {
fmt.Println("단언 실패 패닉:", rec)
}
}()
_ = w.(*bytes.Buffer)
}()

fmt.Println("---")
for _, v := range []any{
nil,
7,
"안녕",
[]int{1, 2, 3},
io.EOF,
temperature(36.5),
3.14,
} {
fmt.Println(describe(v))
}
}
go run ./04-type-assertion
동적 타입: *os.File
파일 이름: /dev/stdout
*bytes.Buffer가 아니다
Closer이기도 하다: *os.File
strings.Builder는 Closer가 아니다
단언 실패 패닉: interface conversion: io.Writer is *os.File, not *bytes.Buffer
---
nil
int 7 (두 배 14)
string "안녕" (길이 6)
[]int 요소 3개
error: EOF
Stringer: 36.5도
모르는 타입 float64

읽어야 할 것

comma-ok가 기본형이다. 3-3의 맵 조회와 같은 형태다. 단언이 실패할 수 있다면 반드시 이쪽을 쓴다. 한 형태를 쓰는 유일한 경우는 실패할 리가 없고, 실패했다면 프로그램 논리가 깨진 것일 때다.

인터페이스에서 인터페이스로도 단언할 수 있다. w.(io.Closer)가 그것이다. 이것을 선택적 인터페이스(optional interface) 패턴이라 하고, 표준 라이브러리가 즐겨 쓴다. io.Copy는 넘겨받은 ReaderWriteTo를 가지고 있는지 확인해서, 있으면 그쪽으로 최적화한다.

if wt, ok := src.(io.WriterTo); ok {
return wt.WriteTo(dst)
}

"필요하면 쓰고 없으면 기본 경로" 라는 확장 방식이다. 인터페이스를 깨지 않고 능력을 추가할 수 있다.

%T가 동적 타입을 보여 준다. 디버깅에서 가장 먼저 찍어 볼 것이다.

타입 스위치

switch x := v.(type)는 여러 타입을 한 번에 가른다.

  • case nil인터페이스 자체가 nil 일 때 걸린다.
  • case에 인터페이스도 쓸 수 있다. case error, case fmt.Stringer.
  • 위에서부터 순서대로 검사한다. io.EOFerror에 걸리고 fmt.Stringer까지 안 가는 이유다. 인터페이스 case가 여럿이면 순서가 곧 우선순위다.
  • case에 타입을 여러 개 쓰면(case int, int64:) x는 원래 인터페이스 타입 그대로다. 하나만 쓸 때만 그 타입으로 좁혀진다.

:::warning 타입 스위치가 길어지면 설계를 의심한다 타입 스위치는 닫힌 집합을 다루는 도구다. case를 추가하려면 그 함수를 고쳐야 하고, 누가 새 타입을 만들어도 자동으로 처리되지 않는다. 인터페이스의 목적과 정반대다.

정당한 자리는 좁다. any를 해석할 때(JSON 디코딩 결과), 에러 종류를 가를 때 (4-7), 선택적 인터페이스를 확인할 때. 그 밖에서 타입 스위치가 자라고 있다면 그 분기들이 메서드가 되어야 한다는 신호다. :::

함정 1 — nil 포인터를 담은 인터페이스는 nil이 아니다

Go에서 가장 유명한 함정이다. 그리고 구조를 알면 당연한 결과다.

examples/04-interfaces-and-errors/04-nil-interface/main.go
package main

import "fmt"

type ValidationError struct {
Field string
}

func (e *ValidationError) Error() string {
return "잘못된 필드: " + e.Field
}

// buggy는 흔한 버그다. 반환 타입이 error인데 *ValidationError 변수를 그대로 돌려준다.
func buggy(field string) error {
var e *ValidationError // nil 포인터
if field == "" {
e = &ValidationError{Field: "(빈 이름)"}
}
return e // field가 비어 있지 않아도 nil이 아닌 error가 나간다
}

// fixed는 성공 경로에서 명시적으로 nil을 돌려준다.
func fixed(field string) error {
if field == "" {
return &ValidationError{Field: "(빈 이름)"}
}
return nil
}

// alsoFixed는 애초에 구체 타입 변수를 두지 않는다.
func alsoFixed(field string) error {
var err error
if field == "" {
err = &ValidationError{Field: "(빈 이름)"}
}
return err
}

func main() {
// 인터페이스는 (타입, 값) 쌍이다. 둘 다 nil이어야 인터페이스가 nil이다.
var iface any
fmt.Printf("빈 인터페이스: 값=%v 타입=%T nil인가=%t\n", iface, iface, iface == nil)

var p *ValidationError
iface = p
fmt.Printf("nil 포인터를 담음: 값=%v 타입=%T nil인가=%t\n", iface, iface, iface == nil)

fmt.Println("---")

err := buggy("이름 있음")
fmt.Printf("buggy: err=%v err != nil = %t\n", err, err != nil)

err = fixed("이름 있음")
fmt.Printf("fixed: err=%v err != nil = %t\n", err, err != nil)

err = alsoFixed("이름 있음")
fmt.Printf("alsoFixed: err=%v err != nil = %t\n", err, err != nil)

fmt.Println("---")

// 호출부에서는 이렇게 보인다.
if err := buggy("정상"); err != nil {
fmt.Println("정상 입력인데 실패로 처리됨:", err)
}
if err := fixed("정상"); err != nil {
fmt.Println("여기는 실행되지 않는다:", err)
} else {
fmt.Println("fixed는 제대로 성공 처리된다")
}

// 안쪽 포인터가 진짜 nil인지 보려면 타입 단언으로 꺼내야 한다.
bad := buggy("정상")
if ve, ok := bad.(*ValidationError); ok {
fmt.Println("꺼낸 포인터가 nil인가:", ve == nil)
}
}
go run ./04-nil-interface
빈 인터페이스: 값=<nil> 타입=<nil> nil인가=true
nil 포인터를 담음: 값=<nil> 타입=*main.ValidationError nil인가=false
---
buggy: err=<nil> err != nil = true
fixed: err=<nil> err != nil = false
alsoFixed: err=<nil> err != nil = false
---
정상 입력인데 실패로 처리됨: <nil>
fixed는 제대로 성공 처리된다
꺼낸 포인터가 nil인가: true

무슨 일이 일어났는가

buggy: err=<nil> err != nil = true

출력이 <nil>인데 err != niltrue다. 이 두 줄이 나란히 있는 것이 이 함정의 잔인함이다.

return e에서 *ValidationError 값이 error 인터페이스로 변환된다. 그 순간 인터페이스는 이렇게 된다.

┌─────────────────────┬──────┐
│ *main.ValidationError │ nil │ ← 타입 칸이 비어 있지 않다
└─────────────────────┴──────┘

동적 값은 nil이지만 동적 타입은 *main.ValidationError 다. 두 칸이 모두 비어야 인터페이스가 nil인데, 한 칸이 차 있다. 그래서 err != niltrue다.

%v<nil>을 출력하는 것은 fmt가 값이 nil 포인터임을 알아채고 그렇게 처리하기 때문이다. 사람이 보는 것과 ==가 보는 것이 다르다.

실무에서 이 버그가 나는 자리

1. 위 buggy처럼 구체 타입 변수를 error로 반환할 때.

func doWork() error {
var e *MyError
// ... e를 채울 수도 있고 아닐 수도 있는 로직 ...
return e // 함정
}

2. 명명된 반환값을 구체 타입으로 둘 때.

func parse() (err *ParseError) { ... } // 호출부에서 error로 받으면 같은 문제

3. 에러를 모아서 하나로 만들 때.

func validate(u User) error {
errs := &MultiError{}
// ... 아무것도 추가되지 않았어도 ...
return errs // 항상 non-nil
}

이건 좀 다른 형태다. errs가 진짜 non-nil 포인터라 err != nil이 정당하게 true가 된다. 해결은 if len(errs.list) == 0 { return nil }.

규칙 세 가지

  1. error를 반환하는 함수 안에서 구체 에러 타입의 변수를 만들지 않는다. var err error로 두거나, 성공 경로에서 return nil을 직접 쓴다.
  2. 성공 경로는 return nil로 끝낸다. 변수를 거치지 않는다.
  3. 인터페이스를 반환하는 함수에서 구체 타입 값을 그대로 반환하지 않는다. error뿐 아니라 모든 인터페이스에 해당한다.

:::danger go vet은 이것을 잡지 못한다 Go 1.26 기준으로 표준 go vet에는 이 패턴을 잡는 분석기가 없다. staticcheckSA4023, nilness 분석기, golangci-lintnilnil 등이 일부 경우를 잡는다. 하지만 근본적으로는 error 반환 함수 안에 구체 에러 타입 변수를 두지 않는 습관이 방어선이다. :::

함정 2 — 인터페이스 비교가 패닉을 낸다

인터페이스끼리 ==를 하면 동적 타입과 동적 값을 모두 비교한다. 동적 타입이 비교 불가능한 타입이면 런타임 패닉이다.

examples/04-interfaces-and-errors/04-comparison/main.go
package main

import "fmt"

type Point struct{ X, Y int }

type Box struct{ Items []string } // 슬라이스 필드가 있으므로 비교 불가능

func compare(a, b any) (result string) {
// 비교 불가능한 동적 타입이면 런타임 패닉이다.
defer func() {
if rec := recover(); rec != nil {
result = fmt.Sprintf("패닉: %v", rec)
}
}()
return fmt.Sprintf("%t", a == b)
}

func main() {
// 동적 타입과 동적 값이 모두 같아야 같다.
fmt.Println("1 == 1: ", compare(1, 1))
fmt.Println("1 == int64(1): ", compare(1, int64(1)))
fmt.Println("Point == Point: ", compare(Point{1, 2}, Point{1, 2}))
fmt.Println("\"a\" == \"a\": ", compare("a", "a"))
fmt.Println("nil == nil: ", compare(nil, nil))

// 슬라이스, 맵, 함수는 비교할 수 없다. 인터페이스에 담겨도 마찬가지다.
fmt.Println("[]int == []int: ", compare([]int{1}, []int{1}))
fmt.Println("map == map: ", compare(map[string]int{}, map[string]int{}))
fmt.Println("Box == Box: ", compare(Box{}, Box{}))

// 포인터 비교는 대상의 동일성이다.
p1, p2 := &Point{1, 2}, &Point{1, 2}
fmt.Println("&Point == &Point: ", compare(p1, p2))
fmt.Println("같은 포인터: ", compare(p1, p1))

// any를 맵 키로 쓰면 같은 함정이 대입 시점에 터진다.
m := map[any]string{}
m[Point{1, 2}] = "가능"
fmt.Println("맵 키로 Point:", m[Point{1, 2}])

func() {
defer func() {
if rec := recover(); rec != nil {
fmt.Println("맵 키로 슬라이스:", rec)
}
}()
m[[]int{1}] = "불가능"
}()
}
go run ./04-comparison
1 == 1: true
1 == int64(1): false
Point == Point: true
"a" == "a": true
nil == nil: true
[]int == []int: 패닉: runtime error: comparing uncomparable type []int
map == map: 패닉: runtime error: comparing uncomparable type map[string]int
Box == Box: 패닉: runtime error: comparing uncomparable type main.Box
&Point == &Point: false
같은 포인터: true
맵 키로 Point: 가능
맵 키로 슬라이스: runtime error: hash of unhashable type []int

읽어야 할 것

1 == int64(1)false다. 값이 같아도 동적 타입이 다르면 다르다. 이것이 JSON 디코딩 결과를 비교할 때 사람을 괴롭힌다 — encoding/json은 모든 숫자를 float64로 만들므로, config["port"] == 8080false다.

[]int, map, func은 비교할 수 없다. 3-4에서 본 구조체 비교 가능성 규칙이 그대로 인터페이스로 옮겨 온 것이다. 다만 컴파일러가 잡아 주던 것이 런타임 패닉으로 바뀐다. []int{1} == []int{1}은 컴파일 에러지만, any에 담긴 순간 컴파일러가 알 수 없다.

map[any]V는 특히 위험하다. 키를 넣는 순간 해시를 계산해야 하므로 hash of unhashable type으로 죽는다. map[string]V로 두는 편이 거의 항상 낫다.

이 함정이 실제로 무는 자리는 에러 비교다.

if err == ErrNotFound { ... }

err의 동적 타입이 비교 불가능하면 패닉이다. 그래서 4-7에서 errors.Is를 쓴다 — errors.Is는 내부적으로 비교 가능성을 확인한다.

함정 3 — nil 인터페이스에 메서드를 부른다

동적 타입이 없으면 부를 메서드도 없다.

var w io.Writer
w.Write([]byte("x")) // panic: nil pointer dereference

4-1에서 본 "nil 리시버는 괜찮다"와 헷갈리기 쉽다. 정리하면 이렇다.

상황결과
var p *T = nil; p.Method() (포인터 리시버)호출된다. 안에서 역참조하면 패닉
var i I = nil; i.Method()패닉. 부를 것 자체가 없다
var i I = (*T)(nil); i.Method() (포인터 리시버)호출된다. 첫 번째와 같다

세 번째가 4-2에서 본 인터페이스 임베딩의 nil 함정과 이어진다.

인터페이스의 비용

인터페이스는 공짜가 아니다. 세 가지 비용이 있다.

1. 간접 호출. 메서드 호출이 테이블 조회를 거친다. 인라인 최적화도 대부분 막힌다. 다만 요즘 컴파일러는 동적 타입이 하나로 확정되면 탈가상화(devirtualization) 를 하기도 한다.

2. 힙 할당. 인터페이스에 값을 담으면 그 값이 힙으로 탈출하는 경우가 많다. 3-6에서 본 이스케이프 분석의 대표적인 원인이 fmt.Println(x)인 이유다. go build -gcflags=-m이 이것을 x escapes to heap으로 알려 준다.

3. 타입 정보 상실. 꺼내려면 타입 단언이 필요하고, 그건 런타임 검사다.

그렇다고 인터페이스를 피하라는 뜻은 아니다. 핫 루프 안이 아니면 이 비용은 대체로 측정되지 않는다. 다만 "인터페이스를 하나 더 끼우자"가 공짜가 아니라는 감각은 4-5의 판단에 필요하다.

흔히 하는 실수

1. err != nil인데 에러 내용이 <nil>이다

함정 1이다. 이 증상을 보면 바로 "구체 에러 타입 변수를 반환했구나"를 떠올린다.

2. x.(T)를 comma-ok 없이 쓴다

패닉이 나는 자리를 만든다. 실패가 불가능하다고 증명할 수 있을 때만 한 형태를 쓴다.

3. 타입 스위치의 default를 비워 둔다

모르는 타입이 조용히 무시된다. 최소한 로그를 남기거나 에러를 돌려준다.

default:
return fmt.Errorf("처리할 수 없는 타입: %T", x)

4. map[any]V를 쓴다

키 타입을 좁힐 수 있으면 좁힌다. any 키는 런타임 패닉과 미묘한 타입 불일치 (1 vs int64(1))를 동시에 들여온다.

5. 인터페이스에 담긴 구조체를 고치려 한다

var s fmt.Stringer = myStruct{}
// s는 복사본을 들고 있다. 원본을 고칠 방법이 없다.

포인터를 담아야 한다. &myStruct{}.

정리

  • 인터페이스 값은 (동적 타입, 동적 값) 쌍이다. 둘 다 비어야 nil 이다.
  • 타입 단언은 v, ok := x.(T)가 기본형이다. 한 형태는 실패가 불가능할 때만.
  • 인터페이스에서 인터페이스로도 단언할 수 있다. 선택적 인터페이스 패턴의 바탕이다.
  • 타입 스위치의 case는 위에서부터 검사하고, 인터페이스도 쓸 수 있다. 길어지면 설계를 의심한다.
  • nil 포인터를 담은 인터페이스는 nil이 아니다. error를 반환하는 함수 안에 구체 에러 타입 변수를 두지 않는다.
  • 인터페이스 비교는 동적 타입이 비교 불가능하면 런타임 패닉이다. 에러는 ==가 아니라 errors.Is로 비교한다.
  • nil 인터페이스의 메서드 호출은 패닉이다. nil 포인터를 담은 인터페이스는 아니다.
  • 비용은 간접 호출, 힙 할당, 타입 정보 상실. 대체로 무시할 만하지만 공짜는 아니다.

연습문제

  1. buggy를 고치지 말고, 호출부에서 이 문제를 방어할 수 있는지 시도해 보자. err != nil만으로는 안 된다. 타입 단언으로 안쪽 포인터를 확인하는 코드를 써 보고, 왜 이것이 실용적인 해법이 아닌지 설명해 보자.

  2. compare 함수를 고쳐서 패닉 대신 "비교 불가능"을 돌려주게 해 보자. 힌트: reflect.TypeOf(a).Comparable()이 있다. reflect는 Part 5에서 다루지만 이 메서드 하나는 지금 써도 된다. anil일 때를 조심하자.

  3. 표준 라이브러리에서 선택적 인터페이스 패턴을 찾아보자. go doc io.Copy, go doc io.WriteString의 소스를 읽어 보면 된다 (go doc -src io.WriteString). 이 패턴이 없었다면 io 패키지의 인터페이스가 어떻게 생겼어야 할지 생각해 보자.