인터페이스와 타입 시스템
Go 인터페이스의 핵심: 암묵적 구현
Go 인터페이스의 가장 중요한 특징은 implements 키워드가 없다는 점이다. 어떤 타입이 인터페이스에 정의된 메서드를 모두 갖고 있으면, 그 타입은 해당 인터페이스를 자동으로 구현한다. 이를 덕 타이핑(duck typing) 또는 구조적 서브타이핑(structural subtyping)이라 한다.
// 인터페이스 정의
type Animal interface {
Sound() string
Name() string
}
// 선언 없이 자동으로 Animal을 구현
type Dog struct{ name string }
func (d Dog) Sound() string { return "왈!" }
func (d Dog) Name() string { return d.name }
type Cat struct{ name string }
func (c Cat) Sound() string { return "야옹" }
func (c Cat) Name() string { return c.name }
// Animal 인터페이스를 파라미터로 받음
func introduce(a Animal) {
fmt.Printf("%s이(가) 말합니다: %s\n", a.Name(), a.Sound())
}
func main() {
introduce(Dog{name: "바둑이"}) // Dog, Cat 모두 전달 가능
introduce(Cat{name: "나비"})
}이 설계는 강력한 분리(decoupling) 를 만든다. Dog는 Animal의 존재를 몰라도 된다. 나중에 만든 타입이 기존 인터페이스를 자동으로 만족할 수 있다.
인터페이스 내부 구조: iface와 itab
Go 런타임에서 인터페이스 값은 두 워드(two words) 로 표현된다.
itab의 메서드 테이블은 vtable과 유사하다. 메서드 순서가 고정되어 있어 룩업이 빠르다. 이 구조 덕분에 Go의 인터페이스 메서드 디스패치는 C++의 가상 함수 테이블과 비슷한 성능을 낸다.
인터페이스 nil 비교 함정: 인터페이스 변수는 타입 정보와 값 정보 둘 다 nil이어야 nil이다.
var err error // 완전한 nil — 두 워드 모두 0
var p *MyError = nil
err = p // 위험! _type은 *MyError로 설정됨 → err != nil
fmt.Println(err == nil) // false — 예상과 다름표준 라이브러리의 핵심 인터페이스
Go 표준 라이브러리는 작고 명확한 인터페이스를 중심으로 설계되어 있다. 가장 많이 쓰이는 것들:
| 인터페이스 | 패키지 | 메서드 |
|---|---|---|
io.Reader | io | Read(p []byte) (n int, err error) |
io.Writer | io | Write(p []byte) (n int, err error) |
io.Closer | io | Close() error |
fmt.Stringer | fmt | String() string |
error | builtin | Error() string |
sort.Interface | sort | Len() int; Less(i,j int) bool; Swap(i,j int) |
io.Reader와 io.Writer가 이렇게 중요한 이유는 파일, 네트워크 연결, HTTP 바디, 압축 스트림, 암호화 스트림 모두가 이 두 인터페이스를 구현하기 때문이다. 하나의 함수가 이 인터페이스를 받으면 어떤 종류의 스트림이든 처리할 수 있다.
// io.Writer를 받으니 파일에도, 네트워크에도, 버퍼에도 쓸 수 있다
func writeJSON(w io.Writer, v any) error {
return json.NewEncoder(w).Encode(v)
}인터페이스 합성
인터페이스는 다른 인터페이스를 포함(임베딩)할 수 있다.
type ReadWriter interface {
io.Reader
io.Writer
}
type ReadWriteCloser interface {
io.Reader
io.Writer
io.Closer
}작은 인터페이스를 합성해 더 큰 계약을 만드는 것이 Go의 관용구다. Rob Pike의 조언: "인터페이스를 크게 만들지 마라. 단일 메서드 인터페이스가 가장 유연하다."
타입 어서션(Type Assertion)
인터페이스에서 구체 타입을 꺼내야 할 때 타입 어서션을 사용한다.
var w io.Writer = os.Stdout
// 단일 값: 타입이 맞지 않으면 panic
f := w.(*os.File)
// comma-ok 관용구: 안전한 어서션
f, ok := w.(*os.File)
if ok {
fmt.Println("파일:", f.Name())
}
// 인터페이스 → 인터페이스 어서션도 가능
rw, ok := w.(io.ReadWriter)panic을 피하려면 항상 comma-ok 형태를 사용한다. 타입 어서션은 런타임에 iface의 타입 정보를 검사한다.
타입 스위치(Type Switch)
여러 가능한 타입을 분기해야 할 때는 타입 스위치가 더 깔끔하다.
func describe(i any) string {
switch v := i.(type) {
case int:
return fmt.Sprintf("정수: %d", v)
case string:
return fmt.Sprintf("문자열: %q (길이: %d)", v, len(v))
case bool:
return fmt.Sprintf("불린: %t", v)
case []int:
return fmt.Sprintf("int 슬라이스, 길이: %d", len(v))
case error:
return fmt.Sprintf("에러: %v", v)
case nil:
return "nil"
default:
return fmt.Sprintf("알 수 없는 타입: %T", v)
}
}타입 스위치의 v는 각 케이스 안에서 해당 구체 타입을 갖는다. %T 포맷 동사는 런타임 타입 이름을 출력한다.
empty interface와 any
interface{} (Go 1.18+에서 any로 별칭)는 모든 타입을 받을 수 있다.
// any는 interface{}의 타입 별칭
var x any = 42
x = "이제 문자열"
x = []int{1, 2, 3}any를 남용하면 타입 안전성이 사라지고, 컴파일 타임 오류가 런타임 panic으로 바뀐다. 실제로 어떤 타입이든 받아야 하는 경우(JSON 파싱, 로거, 일반 컨테이너)에만 써야 한다.
제네릭(Generics): 타입 파라미터
Go 1.18에서 제네릭이 추가되었다. 인터페이스가 "이 메서드를 갖는 어떤 타입"을 표현한다면, 타입 파라미터(type parameter) 는 "이 제약을 만족하는 어떤 타입"을 컴파일 타임에 특수화한다.
// any를 쓰면 런타임 에러 위험
func MaxAny(a, b any) any { ... } // 타입 안전하지 않음
// 제네릭: 컴파일 타임 타입 체크
func Max[T cmp.Ordered](a, b T) T {
if a > b {
return a
}
return b
}
fmt.Println(Max(3, 5)) // 5 (int)
fmt.Println(Max(3.14, 2.71)) // 3.14 (float64)
fmt.Println(Max("b", "a")) // "b" (string)[T cmp.Ordered]는 타입 제약(type constraint)이다. cmp.Ordered는 <, > 연산을 지원하는 모든 숫자와 문자열 타입을 포함한다.
커스텀 제약
제약도 인터페이스로 정의한다. 이것이 Go 1.18에서 인터페이스의 의미가 확장된 핵심이다: 인터페이스는 이제 타입 집합(type set) 을 정의한다.
// 메서드 기반 제약
type Stringer interface {
String() string
}
// 타입 집합 기반 제약 (타입 파라미터에서만 사용 가능)
type Integer interface {
~int | ~int8 | ~int16 | ~int32 | ~int64
}
func Sum[T Integer](nums []T) T {
var total T
for _, n := range nums {
total += n
}
return total
}~int는 "기저 타입이 int인 모든 타입"을 의미한다. type MyInt int도 포함된다.
comparable과 맵 키
comparable은 ==와 !=를 지원하는 타입들의 제약이다. 맵 키나 동등 비교가 필요한 제네릭 컨테이너에 쓴다.
func Contains[T comparable](slice []T, item T) bool {
for _, v := range slice {
if v == item {
return true
}
}
return false
}
fmt.Println(Contains([]int{1, 2, 3}, 2)) // true
fmt.Println(Contains([]string{"a", "b"}, "c")) // false인터페이스 설계 원칙
1. 작은 인터페이스를 선호하라. 단일 메서드 인터페이스(io.Reader, error, fmt.Stringer)가 가장 넓게 적용된다.
2. 인터페이스는 사용하는 쪽에서 정의하라. Go의 관용구는 구현 패키지가 아닌, 인터페이스를 필요로 하는 패키지가 인터페이스를 선언하는 것이다. 이렇게 하면 의존성이 역전된다.
3. 구체 타입을 반환하고 인터페이스를 받아라. "Accept interfaces, return structs"는 Go에서 자주 인용되는 원칙이다. 함수가 인터페이스를 반환하면 사용자가 더 구체적인 타입을 쓸 기회를 잃는다.
// 좋음: 인터페이스를 파라미터로 받아 유연성 확보
func process(r io.Reader) error { ... }
// 피해야 함: 굳이 인터페이스를 반환할 필요 없음
func newBuffer() io.ReadWriter { ... } // 대신
func newBuffer() *bytes.Buffer { ... } // 이게 낫다References
- How to Properly Implement Interfaces in Go — OneUptime Blog
- Understanding Type Assertion and Type Switch in Go — Leapcell
- Type assertion vs type switches in Go — Redowan's Reflections
- Go Interfaces Explained: Why, How, and Wow! — DEV Community
- Go's secret weapon: the standard library interfaces — Fredrik Averpil
- The Interface Table (itab): The Engine of Dynamic Dispatch — Medium
- Go Generics in 2026: What Finally Works and What Still Doesn't — DEV Community
- The joy of (type) sets: interfaces and generics in Go — Bitfield Consulting
- All your comparable types — The Go Blog
- Generic interfaces — The Go Blog