인터페이스의 내부와 함정
이 챕터에서 다루는 것
인터페이스 값이 무엇으로 이루어져 있는지를 알면 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
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는 넘겨받은 Reader가 WriteTo를 가지고 있는지 확인해서, 있으면 그쪽으로
최적화한다.
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.EOF가error에 걸리고fmt.Stringer까지 안 가는 이유다. 인터페이스case가 여럿이면 순서가 곧 우선순위다. case에 타입을 여러 개 쓰면(case int, int64:)x는 원래 인터페이스 타입 그대로다. 하나만 쓸 때만 그 타입으로 좁혀진다.
:::warning 타입 스위치가 길어지면 설계를 의심한다
타입 스위치는 닫힌 집합을 다루는 도구다. case를 추가하려면 그 함수를 고쳐야 하고,
누가 새 타입을 만들어도 자동으로 처리되지 않는다. 인터페이스의 목적과 정반대다.
정당한 자리는 좁다. any를 해석할 때(JSON 디코딩 결과), 에러 종류를 가를 때
(4-7), 선택적 인터페이스를 확인할 때. 그 밖에서 타입 스위치가
자라고 있다면 그 분기들이 메서드가 되어야 한다는 신호다.
:::
함정 1 — nil 포인터를 담은 인터페이스는 nil이 아니다
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 != nil이 true다. 이 두 줄이 나란히 있는 것이
이 함정의 잔인함이다.
return e에서 *ValidationError 값이 error 인터페이스로 변환된다. 그 순간
인터페이스는 이렇게 된다.
┌─────────────────────┬──────┐
│ *main.ValidationError │ nil │ ← 타입 칸이 비어 있지 않다
└─────────────────────┴──────┘
동적 값은 nil이지만 동적 타입은 *main.ValidationError 다. 두 칸이 모두 비어야
인터페이스가 nil인데, 한 칸이 차 있다. 그래서 err != nil이 true다.
%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 }.
규칙 세 가지
error를 반환하는 함수 안에서 구체 에러 타입의 변수를 만들지 않는다.var err error로 두거나, 성공 경로에서return nil을 직접 쓴다.- 성공 경로는
return nil로 끝낸다. 변수를 거치지 않는다. - 인터페이스를 반환하는 함수에서 구체 타입 값을 그대로 반환하지 않는다.
error뿐 아니라 모든 인터페이스에 해당한다.
:::danger go vet은 이것을 잡지 못한다
Go 1.26 기준으로 표준 go vet에는 이 패턴을 잡는 분석기가 없다.
staticcheck의 SA4023, nilness 분석기, golangci-lint의 nilnil 등이 일부
경우를 잡는다. 하지만 근본적으로는 error 반환 함수 안에 구체 에러 타입 변수를
두지 않는 습관이 방어선이다.
:::
함정 2 — 인터페이스 비교가 패닉을 낸다
인터페이스끼리 ==를 하면 동적 타입과 동적 값을 모두 비교한다. 동적 타입이
비교 불가능한 타입이면 런타임 패닉이다.
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"] == 8080은 false다.
[]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포인터를 담은 인터페이스는 아니다.- 비용은 간접 호출, 힙 할당, 타입 정보 상실. 대체로 무시할 만하지만 공짜는 아니다.
연습문제
-
buggy를 고치지 말고, 호출부에서 이 문제를 방어할 수 있는지 시도해 보자.err != nil만으로는 안 된다. 타입 단언으로 안쪽 포인터를 확인하는 코드를 써 보고, 왜 이것이 실용적인 해법이 아닌지 설명해 보자. -
compare함수를 고쳐서 패닉 대신 "비교 불가능"을 돌려주게 해 보자. 힌트:reflect.TypeOf(a).Comparable()이 있다.reflect는 Part 5에서 다루지만 이 메서드 하나는 지금 써도 된다.a가nil일 때를 조심하자. -
표준 라이브러리에서 선택적 인터페이스 패턴을 찾아보자.
go doc io.Copy,go doc io.WriteString의 소스를 읽어 보면 된다 (go doc -src io.WriteString). 이 패턴이 없었다면io패키지의 인터페이스가 어떻게 생겼어야 할지 생각해 보자.