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...