Skip to content
ioob.dev
Go back

Go 입문 7편 — 인터페이스

· 6분 읽기

Table of contents

인터페이스라는 약속

다른 언어에서 인터페이스를 써본 적 있다면, Go의 인터페이스를 처음 만났을 때 뭔가 빠진 느낌을 받을 수 있다. implements 키워드가 없기 때문이다. Java나 C#에서는 “이 타입은 이 인터페이스를 구현합니다”라고 명시적으로 선언해야 하지만, Go는 그런 선언 없이도 인터페이스가 작동한다.

인터페이스를 하나 정의해보자.

package main

import "fmt"

type Speaker interface {
    Speak() string
}

SpeakerSpeak() 메서드를 가진 모든 타입과 호환된다. 이제 이 인터페이스를 “구현”하는 타입을 만들어 보겠다.

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: "나비"})
}

DogCat도 어디에서도 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.Unmarshalmap[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)
}

ReadWriterReaderWriter를 모두 포함한다. 이 패턴은 Go 표준 라이브러리 곳곳에서 쓰인다. io.ReadWriter, io.ReadCloser, io.ReadWriteCloser 같은 타입이 전부 이런 조합으로 만들어진 것이다.

Go 커뮤니티에서는 “인터페이스는 작게 만들어라”라는 격언이 있다. 메서드 하나짜리 인터페이스가 가장 재사용성이 높고, 필요할 때 조합하면 되기 때문이다.

io.Reader와 io.Writer

Go 표준 라이브러리에서 가장 중요한 인터페이스 둘을 꼽으라면 단연 io.Readerio.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.Readerio.Writer 패턴은 Go 생태계 전반에 깊이 박혀 있으므로, 이 둘에 익숙해지면 표준 라이브러리를 다루는 게 한결 편해진다.

다음 편에서는 포인터를 다룬다. &*의 의미, 값 전달과 포인터 전달의 차이, 그리고 nil 포인터까지 살펴보자.

-> 8편: 포인터


Share this post on:

Comments

Loading comments...


Previous Post
Go 입문 8편 — 포인터
Next Post
Go 입문 6편 — 구조체와 메서드