Table of contents
인터페이스라는 약속
다른 언어에서 인터페이스를 써본 적 있다면, Go의 인터페이스를 처음 만났을 때 뭔가 빠진 느낌을 받을 수 있다. implements 키워드가 없기 때문이다. Java나 C#에서는 “이 타입은 이 인터페이스를 구현합니다”라고 명시적으로 선언해야 하지만, Go는 그런 선언 없이도 인터페이스가 작동한다.
인터페이스를 하나 정의해보자.
package main
import "fmt"
type Speaker interface {
Speak() string
}
Speaker는 Speak() 메서드를 가진 모든 타입과 호환된다. 이제 이 인터페이스를 “구현”하는 타입을 만들어 보겠다.
type Dog struct {
Name string
}
func (d Dog) Speak() string {
return d.Name + ": 멍멍!"
}
type Cat struct {
Name string
}
func (c Cat) Speak() string {
return c.Name + ": 야옹~"
}
func greet(s Speaker) {
fmt.Println(s.Speak())
}
func main() {
greet(Dog{Name: "초코"})
greet(Cat{Name: "나비"})
}
Dog도 Cat도 어디에서도 Speaker를 구현한다고 선언하지 않았다. 그저 Speak() string 메서드를 가지고 있을 뿐이고, 그것만으로 Speaker 인터페이스를 만족시킨다. 이걸 암묵적 구현(implicit implementation)이라고 부른다.
구조체와 인터페이스 사이의 암묵적 구현 관계를 그림으로 보면 더 직관적이다.
flowchart LR
subgraph Interface
Speaker["Speaker\n─────────\n+ Speak() string"]
end
subgraph Structs
Dog["Dog\n─────────\n+ Name string\n+ Speak() string"]
Cat["Cat\n─────────\n+ Name string\n+ Speak() string"]
end
Dog -. "암묵적 구현\n(implements 없이)" .-> Speaker
Cat -. "암묵적 구현\n(implements 없이)" .-> Speaker
greet["greet(s Speaker)"]
Speaker --> greet
왜 암묵적으로 만들었을까
이 설계에는 꽤 실용적인 이유가 있다. 내가 만든 타입이 다른 사람이 정의한 인터페이스를 자동으로 충족할 수 있기 때문이다. 라이브러리 A가 인터페이스를 정의하고, 라이브러리 B가 그 인터페이스를 모르는 채로 타입을 만들었어도, 메서드 시그니처만 맞으면 둘은 연결된다.
Java에서는 이런 게 불가능하다. 반드시 소스 코드에서 implements를 써야 하니까, 내가 수정할 수 없는 외부 라이브러리 타입에 인터페이스를 붙이려면 어댑터 패턴 같은 우회로가 필요하다. Go는 그런 우회가 필요 없다.
결합도를 낮추는 데도 효과적이다. 인터페이스를 정의하는 쪽과 구현하는 쪽이 서로를 전혀 모르는 상태에서도 협력할 수 있으니, 코드의 의존 방향이 자연스럽게 정리된다.
빈 인터페이스 — any
Go에는 메서드가 하나도 없는 인터페이스가 있다. 모든 타입이 이를 만족하므로, 어떤 값이든 담을 수 있는 그릇이 된다.
package main
import "fmt"
func printAnything(v any) {
fmt.Println(v)
}
func main() {
printAnything(42)
printAnything("hello")
printAnything(true)
printAnything([]int{1, 2, 3})
}
any는 Go 1.18에서 도입된 interface{}의 별칭이다. 예전 코드에서는 interface{}로 되어 있는 걸 자주 볼 수 있고, 둘은 완전히 같은 의미다.
편리하지만 남용하면 안 된다. any로 받은 값은 타입 정보가 사라지기 때문에, 꺼내 쓸 때 반드시 타입을 확인하는 과정이 필요해진다. 컴파일러의 도움을 받을 수 없으니 런타임 에러의 가능성이 열리는 셈이다.
타입 단언
any나 인터페이스 타입으로 받은 값의 실제 타입을 확인하고 꺼내는 방법이 타입 단언(type assertion)이다.
package main
import "fmt"
func describe(v any) {
// 기본 형태: 실패하면 panic
s := v.(string)
fmt.Println("문자열:", s)
}
func describeSafe(v any) {
// 안전한 형태: ok 패턴
s, ok := v.(string)
if ok {
fmt.Println("문자열:", s)
} else {
fmt.Println("문자열이 아님")
}
}
func main() {
describeSafe("hello")
describeSafe(42)
// describe(42) // panic: interface conversion 에러
}
v.(string)은 “v가 실제로 string이라면 그 값을 꺼내라”는 뜻이다. 한 가지 값만 받으면 실패 시 panic이 발생하고, 두 번째 반환값 ok를 함께 받으면 안전하게 처리할 수 있다.
실무에서는 거의 항상 ok 패턴을 쓴다. panic은 프로그램을 죽이기 때문에, 타입이 확실하지 않은 상황에서 단일 반환 형태를 쓰는 건 위험하다.
타입 스위치
타입 단언이 하나의 타입만 확인한다면, 타입 스위치는 여러 타입을 한꺼번에 분기할 수 있다.
package main
import "fmt"
func classify(v any) string {
switch val := v.(type) {
case int:
return fmt.Sprintf("정수: %d", val)
case string:
return fmt.Sprintf("문자열: %q (길이 %d)", val, len(val))
case bool:
if val {
return "참"
}
return "거짓"
case nil:
return "nil"
default:
return fmt.Sprintf("알 수 없는 타입: %T", val)
}
}
func main() {
fmt.Println(classify(42))
fmt.Println(classify("Go"))
fmt.Println(classify(true))
fmt.Println(classify(nil))
fmt.Println(classify(3.14))
}
v.(type)은 일반 타입 단언과 생김새가 비슷하지만, switch 문 안에서만 사용할 수 있는 특수한 문법이다. 각 case에서 val은 이미 해당 타입으로 변환된 상태이므로, 추가 단언 없이 바로 쓸 수 있다.
JSON 파싱 결과를 다룰 때 특히 유용한 패턴이다. json.Unmarshal로 map[string]any에 담긴 값을 꺼낼 때 타입 스위치가 자주 등장한다.
인터페이스 조합
Go의 인터페이스는 다른 인터페이스를 임베딩해서 조합할 수 있다. 작은 인터페이스를 합쳐서 더 큰 계약을 만드는 방식이다.
package main
import "fmt"
type Reader interface {
Read() string
}
type Writer interface {
Write(data string)
}
type ReadWriter interface {
Reader
Writer
}
type File struct {
content string
}
func (f *File) Read() string {
return f.content
}
func (f *File) Write(data string) {
f.content = data
}
func process(rw ReadWriter) {
rw.Write("hello, Go")
fmt.Println(rw.Read())
}
func main() {
f := &File{}
process(f)
}
ReadWriter는 Reader와 Writer를 모두 포함한다. 이 패턴은 Go 표준 라이브러리 곳곳에서 쓰인다. io.ReadWriter, io.ReadCloser, io.ReadWriteCloser 같은 타입이 전부 이런 조합으로 만들어진 것이다.
Go 커뮤니티에서는 “인터페이스는 작게 만들어라”라는 격언이 있다. 메서드 하나짜리 인터페이스가 가장 재사용성이 높고, 필요할 때 조합하면 되기 때문이다.
io.Reader와 io.Writer
Go 표준 라이브러리에서 가장 중요한 인터페이스 둘을 꼽으라면 단연 io.Reader와 io.Writer다.
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
메서드가 딱 하나씩이다. 이 단순한 인터페이스가 파일 입출력, 네트워크 통신, 압축, 암호화, HTTP 요청/응답을 전부 관통한다. 파일도 Reader이고, HTTP 응답 본문도 Reader이며, 압축 스트림도 Reader다.
직접 구현해보면 감이 온다.
package main
import (
"fmt"
"io"
"strings"
)
type UpperReader struct {
src io.Reader
}
func (u *UpperReader) Read(p []byte) (int, error) {
n, err := u.src.Read(p)
for i := 0; i < n; i++ {
if p[i] >= 'a' && p[i] <= 'z' {
p[i] -= 32 // 소문자 → 대문자
}
}
return n, err
}
func main() {
original := strings.NewReader("hello, go interfaces!")
upper := &UpperReader{src: original}
data, _ := io.ReadAll(upper)
fmt.Println(string(data)) // HELLO, GO INTERFACES!
}
UpperReader는 내부 Reader에서 읽은 데이터를 대문자로 변환한다. 기존 Reader를 감싸서 새로운 동작을 추가하는 이 패턴을 데코레이터 패턴이라고 부르는데, Go에서는 인터페이스 덕분에 이런 조합이 매우 자연스럽다.
strings.NewReader, bufio.NewReader, gzip.NewReader — 이름에 Reader가 들어간 타입은 전부 io.Reader를 구현하고 있으며, 서로 체이닝할 수 있다. 이 일관성이 Go의 강력한 장점 중 하나다.
인터페이스의 nil 함정
인터페이스와 관련해서 한 가지 주의할 점이 있다. 인터페이스 값은 내부적으로 (타입, 값) 쌍으로 저장되는데, 이 때문에 직관과 다른 결과가 나올 수 있다.
package main
import "fmt"
type MyError struct {
Message string
}
func (e *MyError) Error() string {
return e.Message
}
func mayFail(fail bool) error {
var err *MyError = nil
if fail {
err = &MyError{Message: "실패!"}
}
return err // 여기가 함정
}
func main() {
err := mayFail(false)
if err != nil {
fmt.Println("에러 발생:", err) // 이 줄이 실행된다!
} else {
fmt.Println("성공")
}
// 왜? 인터페이스 내부를 들여다보면:
fmt.Printf("타입: %T, 값: %v, nil?: %v\n", err, err, err == nil)
// 타입: *main.MyError, 값: <nil>, nil?: false
}
mayFail(false)는 *MyError 타입의 nil 포인터를 반환한다. 하지만 이 값이 error 인터페이스로 변환되면, 인터페이스 내부에는 (타입: *MyError, 값: nil)이 담긴다. 타입 정보가 있으니 인터페이스 자체는 nil이 아닌 것으로 판정되는 거다.
올바른 해결 방법은 인터페이스 타입으로 직접 nil을 반환하는 것이다.
func mayFailFixed(fail bool) error {
if fail {
return &MyError{Message: "실패!"}
}
return nil // error 인터페이스의 진짜 nil
}
이건 Go를 쓰다 보면 한 번쯤 반드시 만나는 함정이니, 미리 알아두면 디버깅 시간을 크게 줄일 수 있다.
인터페이스는 Go의 다형성을 지탱하는 핵심 장치다. 암묵적 구현 덕분에 코드 간 결합이 느슨해지고, 작은 인터페이스의 조합으로 복잡한 동작을 표현할 수 있다. 특히 io.Reader와 io.Writer 패턴은 Go 생태계 전반에 깊이 박혀 있으므로, 이 둘에 익숙해지면 표준 라이브러리를 다루는 게 한결 편해진다.
다음 편에서는 포인터를 다룬다. &와 *의 의미, 값 전달과 포인터 전달의 차이, 그리고 nil 포인터까지 살펴보자.
-> 8편: 포인터




Loading comments...