TL;DR

  • 인터페이스는 “무엇을 할 수 있는지”만 규정하는 contract, Python 덕 타이핑·typing.Protocol과 같은 발상
  • Go 인터페이스는 암묵적 구현이라 메서드만 갖추면 자동 만족 — 디커플링의 핵심
  • 관용구 “accept interfaces, return structs”와 빈 인터페이스 any의 타입 단언·타입 스위치 활용

AI-assisted


1. 먼저: 사용자 정의 타입(type)

인터페이스를 이해하려면 먼저 Go에서 “새 타입을 만든다”는 게 무슨 뜻인지 알아야 한다. Go는 type 키워드로 기존 타입에 이름을 붙여 사용자 정의 타입을 만든다.

가장 흔한 건 구조체다.

type Person struct {
    Name string
    Age  int
}

여기서 끝이 아니다. 구조체뿐 아니라 기본 타입이나 함수 타입에도 이름을 붙일 수 있다.

type Score int                      // int에 Score라는 이름을 붙임
type Converter func(string) Score   // 함수 시그니처에도 이름을 붙임

type Score int은 “정수인데, 의미상 점수”라는 새 타입을 만든 것이다. 내부 표현은 int과 같지만 Go는 이 둘을 다른 타입으로 취급한다. type Converter func(string) Score는 “문자열을 받아 Score를 돌려주는 함수”라는 타입에 이름을 붙인 것이다.

Python에 비유하면 class Person:으로 클래스를 정의하는 것, 혹은 Score = int처럼 타입 별칭을 만드는 것과 비슷한 자리에 있다. 다만 Python의 단순 별칭(Score = int)은 int과 같은 타입으로 취급되는 반면, Go의 type Score int은 진짜 별개의 타입이다. Python에서 이렇게 구분되는 별개 타입을 만들려면 NewType을 써야 하는데, 자세한 건 Python typing 노트에 정리돼 있다.

왜 굳이 새 타입을 만드나

int을 그냥 쓰면 될 것 같은데 type Score int을 만드는 이유는, 의미가 다른 값을 컴파일러가 구분해주길 바라기 때문이다. Score와 일반 int을 실수로 섞어 쓰면 컴파일 에러가 난다. 더 중요한 건, 이렇게 만든 타입에 메서드를 붙일 수 있다는 점이다. 다음 절의 주제다.


2. 메서드(method) — 타입에 붙는 함수

메서드는 특정 타입에 묶인 함수다. 일반 함수와 거의 같지만, 함수 이름 앞에 **리시버(receiver)**가 하나 붙는다.

type Person struct {
    Name string
    Age  int
}
 
// (p Person) 부분이 리시버
func (p Person) Greet() string {
    return "안녕하세요, " + p.Name + "입니다"
}
 
func main() {
    p := Person{Name: "Alice", Age: 30}
    fmt.Println(p.Greet())  // 안녕하세요, Alice입니다
}

func (p Person) Greet()에서 (p Person)이 리시버다. “이 함수는 Person 타입에 붙는다”는 선언이고, 함수 안에서 p로 그 인스턴스에 접근한다. Python의 self와 정확히 같은 역할이다.

# Python
class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age
 
    def greet(self):
        return f"안녕하세요, {self.name}입니다"

차이라면 Python은 self를 메서드 안에서 첫 파라미터로 받지만, Go는 함수 이름 앞에 리시버를 따로 둔다는 점이다. 관례상 리시버 변수명은 타입명 첫 글자를 딴 약어를 쓴다(Person → p). 그리고 메서드는 연관된 타입과 같은 패키지 안에 정의해야 한다.

값 리시버 vs 포인터 리시버

리시버에는 두 종류가 있다. 값 리시버 (p Person)와 포인터 리시버 (p *Person)다. 어느 쪽을 쓸지는 규칙이 단순하다.

리시버 선택 규칙

  • 리시버를 수정해야 하면 포인터 리시버 (*Person)
  • nil 처리가 필요하면 포인터 리시버
  • 수정하지 않으면 값 리시버 (Person)

call by value에서 보았듯이, Go는 값을 복사해서 넘기므로 값 리시버는 인스턴스의 복사본을 받는다. 복사본을 수정해봤자 원본은 안 바뀐다. 원본을 바꾸려면 주소를 받아야 하고, 그게 포인터 리시버다.

// 값 리시버 — 복사본을 받으므로 원본이 안 바뀜
func (p Person) GrowUpWrong() {
    p.Age++   // 복사본의 Age만 증가. 원본 그대로
}
 
// 포인터 리시버 — 주소를 받으므로 원본이 바뀜
func (p *Person) GrowUp() {
    p.Age++   // 원본의 Age가 증가
}
 
func main() {
    p := Person{Name: "Alice", Age: 30}
 
    p.GrowUpWrong()
    fmt.Println(p.Age)  // 30 — 안 바뀜
 
    p.GrowUp()
    fmt.Println(p.Age)  // 31 — 바뀜
}

p.GrowUp()을 보면 p는 포인터가 아닌 값인데도 포인터 리시버 메서드가 호출된다. Go가 (&p).GrowUp()으로 알아서 주소를 잡아주기 때문이다. 반대로 포인터 인스턴스(*Person)는 값 리시버 메서드와 포인터 리시버 메서드를 둘 다 호출할 수 있다.

String() 메서드 — 가장 자주 쓰는 예

타입에 String() string 메서드를 붙이면, fmt.Println으로 출력할 때 그 메서드의 결과가 쓰인다. Python의 __str__과 같은 역할이다.

type Score int
 
func (s Score) String() string {
    return fmt.Sprintf("%d점", int(s))
}
 
func main() {
    var s Score = 95
    fmt.Println(s)  // 95점 — 기본 출력 대신 String()의 결과
}
# Python의 __str__
class Score:
    def __init__(self, value):
        self.value = value
 
    def __str__(self):
        return f"{self.value}점"
 
print(Score(95))  # 95점

이 String()이 바로 다음에 나올 인터페이스 이야기로 이어진다. 사실 위 코드는 Go의 fmt.Stringer라는 인터페이스를 자기도 모르게 만족시킨 것이다. 이제 그게 무슨 뜻인지 풀어보자.


3. 인터페이스란 무엇인가 — 구체 타입 vs 추상 타입

인터페이스를 이해하는 핵심은 타입을 두 종류로 나눠 보는 것이다. **구체 타입(concrete type)**과 **추상 타입(abstract type)**이다.

구체 타입은 메모리에서 어떻게 표현되는지, 어떤 데이터를 담는지, 어떤 메서드를 가지는지가 완전히 정해진 타입이다. “실체가 있는” 타입이라고 보면 된다. int, string, 구조체, 슬라이스, 맵, 포인터가 전부 구체 타입이다. 정해진 게 다 있으니 바로 값을 만들 수 있다.

p := Person{Name: "Alice", Age: 30}  // 구체 타입 — 바로 값 생성 가능
n := 42                              // int도 구체 타입

추상 타입은 반대다. “무엇인지(데이터 구조)“는 말하지 않고, “무엇을 할 수 있는지(메서드 집합)“만 규정한다. Go에서 유일한 추상 타입이 바로 인터페이스다. 인터페이스 선언은 메서드 목록만 나열한다.

type Stringer interface {
    String() string
}

이 Stringer는 “String() string 메서드를 가진 무언가”라는 contract일 뿐, 그 자체로는 데이터가 없다. 그래서 Stringer{}처럼 값을 직접 만들 수 없다. 인터페이스는 구체 타입을 담는 그릇이지, 스스로 실체가 되지는 못한다.

실생활 비유: USB 포트

USB 포트를 떠올려보자. 포트는 “이런 모양과 규격을 갖추면 꽂힌다”는 약속만 정의한다. 마우스든 키보드든 외장하드든, 그 규격에 맞는 모양만 갖추면 어떤 기기든 꽂힌다. 포트 입장에서는 꽂힌 게 정확히 무슨 기기인지 몰라도 된다. 규격만 맞으면 그만이다.

인터페이스가 정확히 이렇다. “이 메서드들을 갖춰라”가 USB 규격이고, 그 메서드들을 갖춘 타입이면 어떤 구체 타입이든 그 인터페이스 자리에 꽂힌다. 받는 쪽은 꽂힌 게 Person인지 Score인지 몰라도, 약속된 메서드만 호출하면 된다.

인터페이스 값 안에는 항상 구체 타입이 들어있다

인터페이스는 스스로 값을 못 만든다고 했지만, 인터페이스 변수는 만들 수 있다. 단, 그 변수 안에는 런타임에 항상 어떤 구체 타입의 값이 들어가 있다.

var s Stringer        // 인터페이스 변수. 지금은 nil
s = Score(95)         // 안에 구체 타입 Score(95)가 담김
fmt.Println(s.String())  // 95점

s는 Stringer 타입이지만, 실제로 안에 든 건 Score(95)라는 구체 타입 값이다. 이 “안에 든 구체 타입”을 다시 꺼내는 방법(타입 단언)은 7번에서 다룬다.

Python·C++로 비유

이 구도는 다른 언어에도 똑같이 있다.

구체 타입 (인스턴스화 가능)추상 타입 (인스턴스화 불가)
Pythonlist, dict, 사용자 classabc.ABC, typing.Protocol, collections.abc.Sequence
C++구체 클래스순수 가상함수를 가진 추상 클래스
Goint, 구조체, 슬라이스, 맵, 포인터인터페이스

Python에서 Sequence를 직접 인스턴스화할 수 없고 list만 만들 수 있는 것, C++에서 순수 가상함수를 가진 추상 클래스로는 객체를 못 만드는 것과 정확히 같은 구분이다. 나중에 나올 관용구 “accept interfaces, return structs”도 결국 이 구체/추상 구분에서 나온 말이다.


4. Go 인터페이스의 진짜 특징: 암묵적 구현

여기가 Go 인터페이스에서 가장 중요한 지점이다. Go는 암묵적(implicit) 구현을 쓴다. 정해진 메서드를 다 갖추기만 하면, 아무 선언 없이도 자동으로 그 인터페이스를 만족한다.

Java를 떠올려보면 차이가 분명하다. Java는 명시적으로 선언해야 한다.

// Java — implements를 명시해야 한다
class Score implements Stringer {
    public String toString() { ... }
}

Go는 implements 같은 키워드가 없다. 그냥 메서드만 맞으면 끝이다.

type Stringer interface {
    String() string
}
 
type Score int
 
// "implements Stringer" 같은 선언이 전혀 없다
func (s Score) String() string {
    return fmt.Sprintf("%d점", int(s))
}
 
// 그런데도 Score는 Stringer를 자동으로 만족한다
var x Stringer = Score(95)  // 문제없이 컴파일됨

Score는 어디에도 “나는 Stringer다”라고 쓰지 않았다. 그저 String() string 메서드를 가졌을 뿐이다. 그것만으로 Stringer가 요구하는 동작을 충족하니, Go는 자동으로 Score를 Stringer로 받아들인다.

Python 덕 타이핑과 같은 발상

Python을 써왔다면 이게 낯설지 않다. Python의 덕 타이핑이 정확히 이 발상이다. “오리처럼 걷고 오리처럼 울면 그건 오리다.” 무슨 타입인지 미리 선언하지 않고, 필요한 메서드만 있으면 그냥 쓴다.

# Python 덕 타이핑 — 상속 선언 없이 그냥 메서드만 있으면 동작
class Score:
    def __str__(self):
        return f"{self.value}점"
 
# print는 Score가 무슨 타입인지 안 따진다. __str__만 있으면 쓴다

Python 3.8부터는 이걸 좀 더 정적으로 표현하는 `typing.Protocol`이 생겼다. 명시적 상속 없이 “이런 메서드를 가진 무엇이든” 받는다고 타입 힌트로 적을 수 있다.

from typing import Protocol
 
class Stringer(Protocol):
    def __str__(self) -> str: ...
 
def show(x: Stringer) -> None:
    print(x)   # __str__만 있으면 어떤 타입이든 받음

typing.Protocol은 사실상 Go 인터페이스의 Python 버전이다. Go가 처음부터 언어 차원에서 했던 걸, Python은 타입 힌트로 나중에 들여온 셈이다.

왜 이게 디커플링의 핵심인가

암묵적 구현이 중요한 이유는 결합도를 낮추기 때문이다. Score를 정의하는 사람은 Stringer 인터페이스의 존재 자체를 몰라도 된다. 나중에 누군가 Stringer를 받는 함수를 만들면, Score는 코드를 전혀 안 고쳐도 거기에 그냥 들어간다.

여기서 Go 인터페이스의 발상 전환이 나온다. 인터페이스는 구현하는 쪽이 아니라 사용하는 쪽(호출자)이 정의한다. Java에서는 보통 라이브러리 제공자가 인터페이스를 주고 사용자가 구현하지만, Go에서는 반대가 자연스럽다. 어떤 함수를 짜는데 입력이 “Read 메서드만 있으면 되겠다” 싶으면, 호출자가 자기에게 필요한 동작만 담은 작은 인터페이스를 그 자리에서 정의한다. 구현체는 그 인터페이스를 몰라도 메서드만 맞으면 알아서 들어맞는다.

이게 “안정적인 덕 타이핑”이라 불리는 이유다. Python의 덕 타이핑은 런타임에 메서드가 없으면 터지지만, Go는 컴파일 타임에 메서드가 맞는지 검사한다. 유연함은 유지하되 안전성을 얹은 것이다.


5. 표준 라이브러리 인터페이스

실제로 매일 쓰는 표준 인터페이스를 보자. Go 표준 라이브러리는 작은 인터페이스로 짜여 있고, 이게 코드 재사용의 토대가 된다.

fmt.Stringer

앞에서 본 그 인터페이스다. 표준 라이브러리에 이렇게 정의돼 있다.

type Stringer interface {
    String() string
}

타입에 String() string만 붙이면 fmt.Println이 자동으로 그걸 쓴다. 2번에서 Score에 String()을 붙였을 때 출력이 95점으로 바뀐 게 바로 이 인터페이스를 만족했기 때문이다.

io.Reader와 io.Writer

Go에서 가장 널리 쓰이는 두 인터페이스다. 각각 메서드가 딱 하나뿐이다.

type Reader interface {
    Read(p []byte) (n int, err error)
}
 
type Writer interface {
    Write(p []byte) (n int, err error)
}

Reader는 “어딘가에서 바이트를 읽어올 수 있다”, Writer는 “어딘가로 바이트를 쓸 수 있다”는 contract다. 파일, 네트워크 연결, 메모리 버퍼, 압축 스트림이 전부 이 인터페이스를 만족한다. 그래서 데이터의 출처가 무엇이든 같은 코드로 다룰 수 있다.

// process는 데이터가 어디서 오는지 모른다. io.Reader면 다 받는다
func process(r io.Reader) error {
    buf := make([]byte, 1024)
    for {
        n, err := r.Read(buf)
        if n > 0 {
            // buf[:n] 처리
        }
        if err == io.EOF {
            return nil
        }
        if err != nil {
            return err
        }
    }
}
 
func main() {
    // 파일을 열어서 그대로 넘긴다. os.File이 io.Reader를 만족한다
    f, _ := os.Open("data.txt")
    defer f.Close()
    process(f)
 
    // 문자열도 strings.NewReader로 io.Reader가 된다
    process(strings.NewReader("hello"))
}

process는 파일인지 문자열인지 신경 쓰지 않는다. io.Reader라는 contract만 보고 일한다. 이게 인터페이스로 얻는 유연함의 실전 모습이다.

인터페이스 임베딩 — 작은 것을 합쳐 큰 것을

인터페이스는 다른 인터페이스를 임베딩해서 조합할 수 있다. 표준 라이브러리의 io.ReadCloser가 대표적이다.

type Reader interface {
    Read(p []byte) (n int, err error)
}
 
type Closer interface {
    Close() error
}
 
// Reader와 Closer를 합친 것
type ReadCloser interface {
    Reader
    Closer
}

ReadCloser는 Read와 Close를 둘 다 가진 무언가를 뜻한다. 작은 인터페이스를 레고 블록처럼 합쳐 더 큰 계약을 만든다. 큰 인터페이스를 처음부터 통으로 정의하는 대신, 필요한 작은 조각들을 조립하는 식이다.

작은 인터페이스가 좋은 인터페이스

Go에서는 메서드가 적은 인터페이스를 선호한다. io.Reader처럼 메서드 하나짜리 인터페이스가 가장 재사용하기 좋다. 만족시키기 쉽고, 조합하기도 쉽기 때문이다. “이 인터페이스에 메서드를 더 넣어야 하나?” 싶으면, 대개는 인터페이스를 쪼개는 게 낫다.


6. “인터페이스를 받고 구조체를 반환하라”

Go 코드를 읽다 보면 자주 보이는 관용구가 있다. accept interfaces, return structs — 인터페이스를 받고 구조체를 반환하라.

뜻은 단순하다.

  • 입력(파라미터)은 인터페이스로 받아서 유연하게 — 호출자가 어떤 구체 타입을 넘기든 메서드만 맞으면 받는다.
  • 출력(반환값)은 구체 타입(구조체)으로 돌려줘서 명확하게 — 받는 쪽이 정확히 무엇을 받았는지 안다.
// 입력은 io.Reader 인터페이스 — 파일이든 문자열이든 다 받음
// 출력은 *bytes.Buffer 구체 타입 — 받는 쪽이 정확히 뭘 받는지 안다
func ReadAll(r io.Reader) *bytes.Buffer {
    var buf bytes.Buffer
    io.Copy(&buf, r)
    return &buf
}

왜 반환은 구체 타입인가

두 가지 이유가 있는데, 더 직접적인 건 정보 보존이다.

첫째, 정보가 보존된다. 인터페이스를 반환하면 받는 쪽은 그 인터페이스에 선언된 메서드만 쓸 수 있다. 구체 타입을 반환하면 그 타입의 모든 메서드와 필드를 다 쓸 수 있다. 핵심은 이 방향이 한쪽으로만 열려 있다는 점이다. 구체 타입을 받은 호출자는 필요하면 자기가 알아서 인터페이스로 좁힐 수 있다. 하지만 인터페이스로 받아 이미 잃어버린 정보는 호출자가 되살릴 수 없다. 그러니 넓게(구체 타입으로) 주고, 좁히는 건 받는 쪽에 맡기는 게 낫다.

둘째, 반환값을 나중에 비파괴적으로 확장할 수 있다. 이건 버저닝과 얽힌 이야기인데, 정확히 짚을 필요가 있다. “인터페이스에 메서드를 추가하면 구현체가 다 깨진다”는 사실 자체는 반환 전용 문제가 아니라 인터페이스 일반의 성질이다. 오히려 인터페이스를 파라미터로 받을 때 호출자가 구현을 강제당하므로 더 직접적이다. 반환에 특화된 진짜 포인트는 따로 있다. 내가 반환한 값의 기능을 앞으로 늘릴 자유가 있느냐다.

구체 타입을 반환하면 메서드를 그냥 더하면 된다. 덧셈이라 기존 호출자가 안 깨진다.

func New() *Storage { ... }
 
// v2: 메서드를 추가해도 기존 호출자는 그대로 동작한다
func (s *Storage) Backup() error { ... }

반대로 인터페이스를 반환하면, 반환값에 새 기능을 주는 길이 둘 다 깨는 변경뿐이다.

type Storage interface {
    Get(key string) string
}
func New() Storage { ... }
 
// v2에서 Backup 기능을 주고 싶다면?
//   방법 1: 인터페이스에 Backup() 추가 → 이 인터페이스의 모든 구현체(내 것 + 외부 mock)가 깨짐
//   방법 2: 반환 타입 자체를 바꿈 → 함수 시그니처가 깨짐
// 어느 쪽이든 깨는 변경이다

즉 인터페이스를 반환하는 순간 반환값의 API를 비파괴적으로 키울 길이 막힌다. 구체 타입을 반환하면 그 길이 열려 있다. 그래서 출력은 구체 타입으로 두는 게 안전하다.

정리하면

  • 정보 보존(주 근거) — 구체 타입을 받으면 호출자가 필요할 때 직접 인터페이스로 좁힐 수 있다. 인터페이스로 받으면 잃은 정보를 되살릴 수 없다.
  • 확장 자유(반환 특화) — 구체 타입은 메서드를 더해도 안 깨지지만, 인터페이스는 손대는 순간 구현체가 깨진다.
  • “메서드 추가가 구현체를 깬다”는 것 자체는 인터페이스 일반의 주의점이지, 반환만의 문제는 아니다.

예외: 오류(error)

이 규칙에는 잘 알려진 예외가 하나 있다. error는 인터페이스인데도 반환값으로 쓴다. Go에서 함수가 오류를 돌려줄 때는 거의 항상 error 인터페이스 타입으로 반환한다. 이건 Go의 오류 처리 관례가 워낙 확고해서 생긴 의도적 예외다.


7. 빈 인터페이스(any)와 타입 단언·타입 스위치

마지막으로 특수한 인터페이스 하나를 보자. 메서드가 0개인 인터페이스다.

interface{}   // 메서드가 하나도 없는 인터페이스
any           // Go 1.18부터 추가된 별칭. interface{}와 완전히 같다

메서드를 0개 요구한다는 건, 모든 타입이 이 계약을 만족한다는 뜻이다. 어떤 타입이든 “메서드 0개 가지기”는 자동으로 충족하기 때문이다. 그래서 any에는 어떤 값이든 담을 수 있다.

var x any
x = 42           // ok
x = "hello"      // ok
x = Person{}     // ok — 무엇이든 들어간다

Python의 동적 타입과 비슷한 자리다. Python 변수는 원래 무엇이든 담으므로 any가 기본 상태인 셈이다. Go는 정적 타입 언어라 일부러 any를 써야 그 느슨함을 얻는다. Python에도 똑같이 “타입 검사를 끄는” typing.Any가 있고, 둘 다 “꼭 필요할 때만, 남용 금지”라는 조언이 따라붙는다.

any는 언제 쓰고, 왜 남용하면 안 되나

any의 정당한 쓰임은 스키마가 미리 정해지지 않은 외부 데이터를 다룰 때다. 대표적으로 구조를 모르는 JSON을 받을 때다.

// 어떤 모양일지 모르는 JSON을 일단 받아두는 placeholder
var data map[string]any
json.Unmarshal(raw, &data)

:=로 자동으로 안 되나?

“Go는 :=로 타입을 알아서 달아주는데 왜 굳이 any냐”고 생각하기 쉽다.

하지만 :=의 타입 추론은 이전에도 설명했듯이 컴파일 타임에 우변 표현식의 타입을 보고 못 박는 것이라, 런타임에야 정해지는 값에는 쓸 수 없다.
JSON이 객체일지 배열일지 숫자일지는 바이트를 까봐야 아는 런타임 사실이라 컴파일러는 미리 알 수 없다. 게다가 json.Unmarshal은 값을 반환하지 않고 받을 변수에 포인터로 써넣으므로(Unmarshal(data, &v)), :=가 추론할 우변 표현식조차 없다.
그래서 받을 변수에 정적으로 줄 수 있는 타입은 “무엇이든”을 뜻하는 any뿐이다.

설령 파싱 함수가 값을 반환하더라도 모양을 모르면 그 반환 타입이 any라, v := decode(raw)의 v도 결국 any다. :=는 우변이 주는 것보다 더 구체적인 타입을 만들어내지 못한다.

하지만 남용은 피해야 한다. any에 값을 담는 순간 타입 정보가 사라져서, 컴파일러의 타입 검사라는 Go의 가장 큰 장점을 스스로 버리는 꼴이 된다.

타입 단언 (type assertion) — 담긴 구체 타입을 꺼내기

3번에서 “인터페이스 값 안에는 항상 구체 타입이 들어있다”고 했다. 그 구체 타입을 다시 꺼내는 게 타입 단언(type assertion)이다.

var i any = 42
 
n := i.(int)      // "i 안에 든 게 int이다"라고 단언하고 꺼냄
fmt.Println(n)    // 42

문제는 단언이 틀리면 패닉이 난다는 점이다. i에 든 게 int이 아닌데 i.(int)을 하면 프로그램이 죽는다. 그래서 comma-ok 형태를 쓰는 게 안전하다. 성공 여부를 ok로 받아 패닉을 피한다.

var i any = 42
 
n, ok := i.(string)   // i는 int인데 string으로 단언
if ok {
    fmt.Println("문자열:", n)
} else {
    fmt.Println("문자열이 아님")  // 이쪽이 실행됨. 패닉 없음
}

Python의 isinstance로 먼저 확인하고 쓰는 패턴과 발상이 같다.

# Python
if isinstance(i, str):
    print("문자열:", i)
else:
    print("문자열이 아님")

타입 스위치 (type switch) — 여러 타입을 분기

확인할 타입이 여러 개면 if 단언을 줄줄이 쓰는 대신 타입 스위치(type switch)를 쓴다.

func describe(i any) string {
    switch v := i.(type) {
    case int:
        return fmt.Sprintf("정수 %d", v)
    case string:
        return fmt.Sprintf("문자열 %q", v)
    case Person:
        return fmt.Sprintf("Person %s", v.Name)
    default:
        return "알 수 없는 타입"
    }
}

i.(type)은 타입 스위치 안에서만 쓰는 특수 문법이다. 각 case에서 v는 그 타입으로 확정돼, 별도 단언 없이 바로 쓸 수 있다. Python에서 isinstance를 연달아 확인하는 코드를 깔끔하게 대체한다.

nil 포인터에도 메서드를 호출할 수 있다

Python에서는 None.foo()가 즉시 에러지만, Go에서는 nil 포인터 리시버로도 메서드를 호출할 수 있다. 메서드 안에서 리시버가 nil인지 직접 검사해 처리하도록 짤 수 있기 때문이다. 단, 메서드 안에서 그 nil 리시버를 역참조하면 그때는 패닉이 난다.