고 중급 #1 인터페이스 — 암묵적 구현의 의미

6 분 소요

Go 중급 시리즈의 첫 글입니다. 기초 7편을 마쳤다면 작은 도구는 자신 있게 짤 수 있을 텐데, 중급은 그 위에 — Go의 진짜 강점들을 얹는 단계입니다.

총 7편으로 구성됩니다.

  • #1 인터페이스 ← 이번 글
  • #2 에러 처리 패턴
  • #3 고루틴과 채널 입문
  • #4 select와 타임아웃
  • #5 context.Context 깊이
  • #6 테스팅
  • #7 표준 라이브러리 투어

이번 글은 — Go의 가장 두드러진 디자인 결정 중 하나인 인터페이스를 다룹니다.

인터페이스 정의 #

기본 인터페이스
type Speaker interface {
	Speak() string
}

type 이름 interface { 메서드들 } — 이 모양 자체는 다른 언어와 비슷합니다. 차이는 어떻게 구현하느냐에 있습니다.

암묵적 구현 — Go의 시그니처 #

Go의 인터페이스는 implements 키워드가 없습니다.

암묵적 구현
type Speaker interface {
	Speak() string
}

type Dog struct{}

func (d Dog) Speak() string {
	return "멍멍"
}

func main() {
	var s Speaker = Dog{}    // 자동으로 구현됨
	fmt.Println(s.Speak())
}

DogSpeak() string 메서드를 가지고 있으면 — 자동으로 Speaker 인터페이스를 만족합니다. 어디에도 “Dog implements Speaker” 같은 선언이 없습니다.

이걸 구조적 타이핑(structural typing) 또는 duck typing의 컴파일 시점 버전이라 부릅니다. 타입스크립트의 인터페이스도 같은 방식입니다. Java 같은 명시적 인터페이스 시스템과는 정반대.

왜 이 디자인인가? #

이 디자인의 큰 이점:

  1. 인터페이스를 사용처에서 정의 가능 — 라이브러리가 인터페이스를 노출하지 않아도, 사용하는 쪽에서 필요한 메서드 셋을 인터페이스로 정의해 받을 수 있습니다.
  2. 느슨한 결합 — 라이브러리 A의 타입과 라이브러리 B의 타입이 같은 인터페이스를 만족할 수 있음 (서로 모르는 사이에도)
  3. 점진적 추상화 — 처음에는 구체 타입으로 짜다가 나중에 인터페이스로 추출 가능

인터페이스의 핵심 — 사용처에서 정의 #

전통적인 OOP가 “라이브러리가 인터페이스를 정의하고, 구현체가 implements 한다"라면, Go는 **“사용자가 자기에게 필요한 인터페이스를 정의한다”**가 자연스럽습니다.

사용처에서 인터페이스 정의
package mylogger

// 우리 로거가 필요한 것 — Writer 인터페이스
type Writer interface {
	Write(p []byte) (n int, err error)
}

func WriteLog(w Writer, msg string) {
	w.Write([]byte(msg))
}
호출
import (
	"os"
	"bytes"
	"mylogger"
)

func main() {
	mylogger.WriteLog(os.Stdout, "stdout으로")        // os.File이 Writer만족
	mylogger.WriteLog(&bytes.Buffer{}, "buffer로")   // bytes.Buffer도 Writer만족
}

os.Filebytes.Buffermylogger.Writer 인터페이스를 미리 알 수 없습니다. 우리가 우리 코드에 필요한 모양을 인터페이스로 정의했고, 두 타입 모두 우연히 그 모양을 만족하니 자동으로 호환됩니다.

표준 라이브러리의 핵심 인터페이스 — io.Reader, io.Writer #

가장 자주 쓰이는 인터페이스 둘. Go의 I/O 시스템 전체가 이 위에 서 있습니다.

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

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

메서드가 단 하나 인 매우 작은 인터페이스. 그래서:

  • os.File은 둘 다 만족 (파일 읽기/쓰기)
  • net.Conn은 둘 다 만족 (네트워크 송수신)
  • bytes.Buffer는 둘 다 만족 (메모리 버퍼)
  • strings.Reader는 Reader만족
  • gzip.Writer는 Writer만족 (압축 + 다른 Writer로 전달)

같은 함수를 — 파일이든 네트워크 소켓이든 메모리 버퍼든 똑같이 다룰 수 있습니다. 엄청나게 강력한 추상화입니다.

동일한 함수, 다양한 출처
func processData(r io.Reader) error {
	// ...
}

processData(file)         // 파일
processData(httpResponse.Body)   // 네트워크
processData(strings.NewReader("hello"))   // 문자열
processData(&bytes.Buffer{})    // 메모리

작은 인터페이스 가이드 #

Go 커뮤니티의 강한 컨벤션:

인터페이스는 작을수록 좋다. 메서드 한두 개가 이상적.

이 가이드의 이유 — 작은 인터페이스가 더 많은 타입에 의해 자동 만족됩니다. 메서드가 많아질수록 그 모양을 만족하는 타입이 줄어들어 활용도가 떨어집니다.

io.Reader, io.Writer, error, fmt.Stringer 모두 메서드 1개입니다. 큰 인터페이스가 필요하면 여러 작은 인터페이스의 합성으로 만드세요.

인터페이스 합성
type ReadWriter interface {
	Reader
	Writer
}

io.ReadWriter가 정확히 이 정의입니다.

빈 인터페이스 — interface{} (= any) #

메서드가 0개인 인터페이스는 모든 타입이 만족합니다.

빈 인터페이스
type Empty interface{}      // 옛 표기

// Go 1.18+ 부터 any 별칭이 표준
var x any = 42
var y any = "hello"
var z any = []int{1, 2, 3}

anyinterface{}의 별칭 입니다 (Go 1.18+). 새 코드는 거의 모두 any를 씁니다.

자바스크립트의 any, 타입스크립트의 unknown과 비슷합니다. 모든 타입을 받지만 — 사용하기 전에 좁혀야 합니다.

타입 단언 (Type Assertion) #

any가 안에 어떤 타입을 들고 있는지 꺼내려면 — 타입 단언.

단언 — 단순
var v any = "hello"

s := v.(string)
fmt.Println(s)         // hello

n := v.(int)            // ✗ panic: interface conversion: ...

타입이 맞으면 OK, 틀리면 panic. 위험합니다.

안전한 단언 — comma-ok #

comma-ok 단언
var v any = "hello"

if s, ok := v.(string); ok {
	fmt.Println("string:", s)
} else {
	fmt.Println("string 아님")
}

두 번째 값 ok가 false 면 잘못된 타입. panic 안 일어납니다. 거의 항상 이 형태가 안전합니다.

타입 switch — 여러 타입 분기 #

any가 여러 타입 중 하나일 때.

타입 switch
func describe(i any) {
	switch v := i.(type) {
	case int:
		fmt.Printf("int: %d\n", v)
	case string:
		fmt.Printf("string: %s\n", v)
	case bool:
		fmt.Printf("bool: %t\n", v)
	case []int:
		fmt.Printf("[]int: %v\n", v)
	default:
		fmt.Printf("unknown type %T\n", v)
	}
}

switch v := i.(type) 라는 특수 문법. 각 case 안에서 v가 그 타입으로 좁혀집니다. JS의 typeof switch와 비슷한 역할입니다.

인터페이스 변수의 nil 함정 #

가장 헷갈리는 부분입니다.

nil 함정
type MyError struct{ msg string }

func (e *MyError) Error() string {
	return e.msg
}

func doSomething() error {
	var err *MyError = nil
	return err   // *MyError가 nil 인 채로 error 인터페이스에 담김
}

func main() {
	err := doSomething()
	if err != nil {
		fmt.Println("에러 있음")   // ✗ 출력됨!
	}
}

err != nil이 true가 됩니다. 왜냐면 — 인터페이스 값은 (타입, 값) 쌍이라, 안의 값이 nil이라도 타입이 nil 아닌 한 인터페이스 자체는 nil이 아닙니다.

해결: 함수에서 명시적으로 nil을 반환하거나, 반환 타입을 인터페이스로 직접 두세요.

안전한 패턴
func doSomething() error {
	var err *MyError = nil
	if err == nil {
		return nil   // 인터페이스 nil 직접 반환
	}
	return err
}

이 함정은 처음 보면 매우 의외이고, 실무에서도 종종 만납니다. errors.Is 등 표준 도구를 써서 비교하면 더 안전한 방법도 있습니다(다음 글).

인터페이스를 만족하는지 컴파일 시점 검증 #

코드가 어떤 타입이 어떤 인터페이스를 만족해야 한다는 의도를 명시하고 싶으면.

컴파일 시점 검증
var _ Speaker = (*Dog)(nil)

이 한 줄은 — “Dog가 Speaker를 만족하지 않으면 컴파일 에러” 입니다. var _는 변수를 안 쓰겠다는 표시이고, (*Dog)(nil)은 nil 인 *Dog 값. 만약 Dog가 Speak() 메서드를 빠뜨리면 컴파일이 막힙니다.

라이브러리가 자기 타입이 외부 인터페이스를 정확히 구현하는지 확인하는 표준 패턴입니다.

인터페이스 vs 구체 타입 — 어디서 추상화하나 #

가이드:

  1. 함수 매개변수 → 보통 인터페이스 (가능한 한 작게)
  2. 함수 반환값 → 보통 구체 타입

이걸 “Accept interfaces, return concrete types” 라고 줄여 부릅니다.

권장 패턴
// Good
func ReadConfig(r io.Reader) (*Config, error) {
	// ...
}

// 이상한 패턴 — 인터페이스를 반환하는 경우는 흔하지 않음
func ReadConfig(r io.Reader) (interface{}, error) {
	// ...
}

매개변수를 인터페이스로 받으면 — 호출자가 더 다양한 타입을 줄 수 있고, 테스트도 mock으로 쉽게 됩니다. 반환은 구체 타입이라 호출자가 모든 메서드를 사용할 수 있습니다.

자주 쓰는 표준 인터페이스들 #

자주 만나는 인터페이스
// 문자열 표현
type Stringer interface {
	String() string
}

// 에러
type error interface {
	Error() string
}

// 비교 가능
type Comparable[T any] interface {
	Compare(T) int
}

// 정렬
type Sort interface {
	Len() int
	Less(i, j int) bool
	Swap(i, j int)
}

fmt.Println이 객체를 출력할 때 — 그 객체가 Stringer를 구현했으면 자동으로 String()의 결과를 씁니다. 자기 타입의 출력 모양을 정의하는 표준 방법입니다.

마무리 #

이번 글에서 정리한 내용:

  • 인터페이스는 암묵적 구현 — implements 키워드 없음
  • 사용처에서 인터페이스 정의 가능 — 느슨한 결합
  • io.Reader, io.Writer 같은 작은 인터페이스가 강력
  • 인터페이스는 작을수록 좋다 — 메서드 1~2개
  • 빈 인터페이스 = any (Go 1.18+)
  • comma-ok 타입 단언과 타입 switch
  • 인터페이스 변수의 nil 함정 (타입은 있고 값만 nil)
  • “Accept interfaces, return concrete types”
  • Stringer, error 같은 표준 인터페이스

다음 글(#2 에러 처리 패턴)에서는 #4 기초에서 본 에러를 더 깊이 — wrapping, errors.Is/As, 커스텀 에러 타입 패턴까지 정리합니다.

X