인터페이스 기초
이 챕터에서 다루는 것
Go의 인터페이스는 메서드 목록이다. 문법은 열 줄이면 다 설명되고, 나머지 전부는 "그래서 어떻게 쓰는가"다. 여기서는 암묵적 구현이 왜 중요한지, 왜 인터페이스가 작아야 하는지, 그리고 인터페이스를 어느 쪽 패키지에서 정의해야 하는지를 다룬다.
문제 — 함수가 구체 타입에 묶이면
파일에 리포트를 쓰는 함수를 생각해 보자.
func report(f *os.File, items []string) error
이 함수는 파일에만 쓸 수 있다. 테스트에서 결과를 검사하려면 임시 파일을 만들었다가 다시 읽어야 하고, HTTP 응답에 쓰려면 같은 함수를 하나 더 만들어야 하고, 로그와 파일에 동시에 쓰려면 세 번째를 만들어야 한다.
report가 *os.File에서 실제로 쓰는 것은 Write 메서드 하나뿐이다. 그 하나만
요구하면 된다.
func report(w io.Writer, items []string) error
이제 파일, 메모리 버퍼, 네트워크 연결, 표준 출력, 압축 스트림, 그리고 아직 존재하지도 않는 타입이 전부 들어갈 수 있다. 인터페이스는 "내가 필요한 최소한"을 적는 자리다.
암묵적 구현
Go에서는 implements를 적지 않는다. 메서드가 다 있으면 그냥 구현한 것이다.
package main
import (
"errors"
"fmt"
"strings"
)
// Validator는 메서드 하나짜리 인터페이스다.
// 이 파일 어디에도 "구현한다"고 선언하는 곳이 없다.
type Validator interface {
Validate() error
}
type User struct {
Name string
Email string
}
func (u User) Validate() error {
if u.Name == "" {
return errors.New("이름이 비어 있다")
}
if !strings.Contains(u.Email, "@") {
return fmt.Errorf("이메일 형식이 아니다: %q", u.Email)
}
return nil
}
type Port int
func (p Port) Validate() error {
if p < 1 || p > 65535 {
return fmt.Errorf("포트 범위를 벗어남: %d", int(p))
}
return nil
}
// String을 붙이면 fmt.Stringer를 만족한다. 그렇다고 선언한 적은 없다.
func (p Port) String() string { return fmt.Sprintf(":%d", int(p)) }
// 컴파일 시점 검사 관용구. 구현이 깨지면 여기서 잡힌다.
var (
_ Validator = User{}
_ Validator = Port(0)
_ fmt.Stringer = Port(0)
)
// validateAll은 무엇이 들어올지 모른다. Validate만 있으면 된다.
func validateAll(vs ...Validator) []error {
var errs []error
for _, v := range vs {
if err := v.Validate(); err != nil {
errs = append(errs, fmt.Errorf("%T: %w", v, err))
}
}
return errs
}
func main() {
errs := validateAll(
User{Name: "김개발", Email: "dev@example.com"},
User{Name: "", Email: "nope"},
Port(8080),
Port(70000),
)
if len(errs) == 0 {
fmt.Println("전부 통과")
return
}
for _, err := range errs {
fmt.Println("실패:", err)
}
// Port는 Stringer이기도 하므로 fmt가 String()을 쓴다.
fmt.Println("포트 출력:", Port(8080))
}
go run ./03-implicit
실패: main.User: 이름이 비어 있다
실패: main.Port: 포트 범위를 벗어남: 70000
포트 출력: :8080
이 설계가 사는 이유
1. 의존 방향을 뒤집는다. Java에서 implements Comparable을 적으려면 그 인터페이스를
import해야 한다. 구현체가 인터페이스를 안다. Go에서는 구현체가 인터페이스의 존재를
모른다. 덕분에 내가 만들지 않은 타입, 내가 고칠 수 없는 타입도 내 인터페이스를 만족시킨다.
time.Time은 fmt.Stringer를 만족하는데, time 패키지가 fmt를 import하지 않는다.
2. 인터페이스를 나중에 추출할 수 있다. 구체 타입을 먼저 쓰고, 두 번째 구현이 필요해졌을 때 인터페이스를 뽑아내면 된다. 기존 코드를 하나도 고치지 않는다. Go에서 "인터페이스를 미리 만들지 마라"는 조언이 성립하는 것도 이 때문이다 (4-5).
3. 우연한 구현도 유효하다. Port.String()은 그냥 예쁘게 출력하려고 만든 것인데
fmt.Stringer가 됐다. 이것을 위험하다고 볼 수도 있지만, 실제로는 String() string,
Error() string, Read([]byte) (int, error) 같은 이름과 시그니처가 사실상 예약어처럼
쓰이면서 문제가 거의 없다.
컴파일 시점 검사 관용구
암묵적 구현의 유일한 불편은 구현이 깨진 것을 늦게 안다는 것이다. 메서드 이름의 오타나 시그니처 변경이 "그 타입을 인터페이스에 대입하는 자리"에서야 에러가 된다.
var _ Validator = User{}
var _ Validator = (*Server)(nil) // 포인터 리시버라면 이 형태
_에 대입하므로 변수가 생기지 않고 런타임 비용도 없다. "이 타입은 이 인터페이스를
구현할 의도다" 를 컴파일러와 사람 모두에게 알리는 표준 관용구다. 공개 타입이
공개 인터페이스를 구현할 때는 써 두는 편이 좋다.
(*Server)(nil)은 *Server 타입의 nil을 만드는 변환 표현식이다. 값을 만들지 않고
타입만 필요할 때 쓴다.
작은 인터페이스를 선호한다
Go 커뮤니티의 유명한 격언이 있다.
The bigger the interface, the weaker the abstraction. — Rob Pike
표준 라이브러리가 이 원칙을 그대로 따른다.
| 인터페이스 | 메서드 수 |
|---|---|
io.Writer, io.Reader, io.Closer | 1 |
fmt.Stringer, error | 1 |
sort.Interface | 3 |
io.ReadWriteCloser | 3 (조합) |
메서드가 하나면 구현하기 쉽고, 대체하기 쉽고, 조합하기 쉽다. 그리고 4-2에서 본 대로 필요하면 임베딩으로 합칠 수 있다. 반대 방향 — 큰 인터페이스를 쪼개기 — 은 이미 그것을 구현한 모든 코드를 건드려야 한다.
io.Writer — 실제 사례
말로 설명하는 것보다 하나를 제대로 보는 편이 낫다.
type Writer interface {
Write(p []byte) (n int, err error)
}
이 세 줄이 Go 표준 라이브러리에서 가장 많이 쓰이는 인터페이스다.
package main
import (
"errors"
"fmt"
"io"
"os"
"strings"
)
// report는 어디에 쓸지 모른다. io.Writer 하나만 받는다.
func report(w io.Writer, items []string) error {
for i, item := range items {
if _, err := fmt.Fprintf(w, "%d. %s\n", i+1, item); err != nil {
return fmt.Errorf("report: %w", err)
}
}
return nil
}
// countingWriter는 통과한 바이트 수를 세면서 다음 Writer로 넘긴다.
type countingWriter struct {
w io.Writer
n int64
}
func (c *countingWriter) Write(p []byte) (int, error) {
n, err := c.w.Write(p)
c.n += int64(n)
return n, err
}
// failingWriter는 limit 바이트를 넘기면 실패한다.
type failingWriter struct {
limit int
written int
}
var errQuotaExceeded = errors.New("쓰기 한도 초과")
func (f *failingWriter) Write(p []byte) (int, error) {
if f.written+len(p) > f.limit {
allowed := f.limit - f.written
f.written = f.limit
return allowed, errQuotaExceeded
}
f.written += len(p)
return len(p), nil
}
func main() {
items := []string{"슬라이스", "맵", "구조체"}
fmt.Println("--- os.Stdout")
if err := report(os.Stdout, items); err != nil {
fmt.Fprintln(os.Stderr, err)
}
// strings.Builder도 io.Writer다.
var sb strings.Builder
if err := report(&sb, items); err != nil {
fmt.Fprintln(os.Stderr, err)
}
fmt.Printf("--- strings.Builder: %d바이트\n", sb.Len())
// 감싸서 세기
counter := &countingWriter{w: io.Discard}
if err := report(counter, items); err != nil {
fmt.Fprintln(os.Stderr, err)
}
fmt.Println("--- countingWriter:", counter.n, "바이트")
// 여러 곳에 동시에 쓰기
var a, b strings.Builder
if err := report(io.MultiWriter(&a, &b), items); err != nil {
fmt.Fprintln(os.Stderr, err)
}
fmt.Println("--- MultiWriter 두 벌 같은가:", a.String() == b.String())
// 쓰기 실패도 그냥 에러다
fw := &failingWriter{limit: 20}
err := report(fw, items)
fmt.Println("--- failingWriter:", err)
fmt.Println("errors.Is(err, errQuotaExceeded):", errors.Is(err, errQuotaExceeded))
}
go run ./03-io-writer
--- os.Stdout
1. 슬라이스
2. 맵
3. 구조체
--- strings.Builder: 36바이트
--- countingWriter: 36 바이트
--- MultiWriter 두 벌 같은가: true
--- failingWriter: report: 쓰기 한도 초과
errors.Is(err, errQuotaExceeded): true
출력에서 읽어야 할 것
36바이트인 이유. 한글은 UTF-8에서 글자당 3바이트다
(2-3). 1. 슬라이스\n은
1, ., 공백, 한글 4자(12바이트), 개행으로 16바이트다. 세 줄을 합치면 36이다.
strings.Builder와 countingWriter가 같은 값을 낸 것은 같은 바이트열이 흘렀다는
뜻이다.
countingWriter가 조합의 본보기다. io.Writer를 받아 io.Writer가 된다.
report는 자기가 계수기에 쓰고 있다는 것을 모르고, 계수기는 최종 목적지가 무엇인지
모른다. 이 성질 때문에 압축, 암호화, 버퍼링, 속도 제한이 전부 같은 방식으로 끼워진다.
io.MultiWriter, io.Discard도 그냥 io.Writer다. 표준 라이브러리가 미리
만들어 둔 조합 부품이다.
Write가 에러를 돌려줄 수 있다는 점이 인터페이스에 박혀 있다. 메모리에 쓸 때는
실패할 일이 없지만, 네트워크나 디스크는 실패한다. 그래서 시그니처가 가장 어려운 경우에
맞춰져 있고, fmt.Fprintf의 반환값을 _로 버리지 않는 습관이 필요하다.
4-6에서 다시 본다.
:::info Write의 계약
io.Writer 문서는 구현자에게 두 가지를 요구한다. len(p)보다 적게 썼으면 반드시
nil이 아닌 에러를 돌려줄 것, 그리고 p를 수정하지 말 것(잠깐이라도).
failingWriter가 allowed와 errQuotaExceeded를 함께 돌려주는 것이 이 계약을
지키는 형태다. 인터페이스는 시그니처만이 아니라 문서에 적힌 계약까지가 인터페이스다.
:::
인터페이스는 소비자 쪽에서 정의한다
Go에서 가장 자주 어겨지는 원칙이고, 다른 언어에서 온 사람일수록 어긴다.
인터페이스는 그것을 쓰는 쪽 패키지에 둔다. 구현하는 쪽이 아니라.
package main
import (
"fmt"
"maps"
"slices"
)
// ---- 제공자 쪽: 인터페이스를 정의하지 않는다. 구체 타입만 내놓는다. ----
type UserStore struct {
byID map[string]string
}
func newUserStore() *UserStore {
return &UserStore{byID: make(map[string]string)}
}
func (s *UserStore) Put(id, name string) { s.byID[id] = name }
func (s *UserStore) Name(id string) (string, bool) {
name, ok := s.byID[id]
return name, ok
}
func (s *UserStore) Delete(id string) { delete(s.byID, id) }
func (s *UserStore) IDs() []string { return slices.Sorted(maps.Keys(s.byID)) }
// ---- 소비자 쪽: 자기가 실제로 쓰는 메서드만 인터페이스로 정의한다. ----
// greeter는 Name만 필요하다. Put도 Delete도 IDs도 요구하지 않는다.
type nameLookup interface {
Name(id string) (string, bool)
}
func greet(l nameLookup, id string) string {
name, ok := l.Name(id)
if !ok {
return "안녕하세요, 손님"
}
return "안녕하세요, " + name + "님"
}
// 소비자가 정의한 인터페이스라서 테스트 대역도 두 줄이면 된다.
type fixedLookup string
func (f fixedLookup) Name(string) (string, bool) { return string(f), true }
func main() {
store := newUserStore()
store.Put("u1", "김개발")
store.Put("u2", "이배포")
fmt.Println(greet(store, "u1"))
fmt.Println(greet(store, "없는ID"))
// 진짜 저장소 없이도 greet를 검증할 수 있다.
fmt.Println(greet(fixedLookup("테스터"), "무엇이든"))
fmt.Println("등록된 ID:", store.IDs())
}
go run ./03-consumer-side
안녕하세요, 김개발님
안녕하세요, 손님
안녕하세요, 테스터님
등록된 ID: [u1 u2]
왜 소비자 쪽인가
Java나 C#의 관례는 반대다. UserStore를 만들면 IUserStore 인터페이스를 같이
만들고, 구현체가 그것을 implements한다. Go에서 그렇게 하면 세 가지를 잃는다.
1. 인터페이스가 필요 이상으로 커진다. 제공자는 자기가 가진 메서드를 다 넣는다.
Put, Name, Delete, IDs. 그런데 greet가 쓰는 것은 Name 하나다.
테스트 대역을 만들려면 안 쓰는 세 개까지 구현해야 한다.
2. 소비자가 제공자 패키지에 의존하게 된다. 인터페이스가 제공자 쪽에 있으면
소비자는 그 패키지를 import해야 한다. 소비자 쪽에 두면 소비자는 아무것도 import하지
않는다. 위 예제의 greet는 UserStore의 존재를 모른다.
3. 인터페이스를 언제 만들지 판단할 근거가 사라진다. 소비자가 없으면 어떤 메서드가 필요한지 알 수 없다. 제공자 쪽에서 미리 만드는 인터페이스는 대부분 추측이다.
:::tip 판별법 인터페이스를 정의하려 할 때 스스로 물어 보자. "이 인터페이스를 인자로 받는 함수가 지금 있는가?" 없다면 아직 만들 때가 아니다. 있다면 그 함수가 있는 패키지가 인터페이스의 집이다. :::
예외도 있다. 구현이 여러 개일 것이 처음부터 확정적이고 그것이 패키지의 존재
이유라면 제공자 쪽에 둔다. io.Writer, database/sql/driver, net.Conn,
hash.Hash가 그렇다. 이런 인터페이스는 표준(standard) 이지 추상화가 아니다.
구분 기준은 "구현자가 여럿이고 서로 모르는가"다.
any — 메서드가 없는 인터페이스
type any = interface{}
any는 Go 1.18에서 들어온 별칭(alias) 이다. 새 타입이 아니라 interface{}의
다른 이름이다. 메서드를 하나도 요구하지 않으므로 모든 타입이 만족한다.
package main
import (
"fmt"
"maps"
"slices"
)
// any는 interface{}의 별칭이다. 메서드가 하나도 없는 인터페이스이므로 모든 타입이 만족한다.
type Empty = interface{}
// describe는 무엇이든 받는다. 대신 받은 뒤에는 타입 정보를 다시 얻어내야 한다.
func describe(v any) string {
return fmt.Sprintf("%T = %v", v, v)
}
// sum은 any를 쓰지 않는다. 필요한 것이 정해져 있으면 구체 타입이 낫다.
func sum(nums []int) int {
total := 0
for _, n := range nums {
total += n
}
return total
}
func main() {
var a any = 42
var e Empty = 42
fmt.Println("any와 interface{}는 같은 타입:", a == e)
for _, v := range []any{42, "문자열", 3.14, true, []int{1, 2}, nil} {
fmt.Println(describe(v))
}
// 설정처럼 구조가 열려 있는 데이터에는 any가 적절하다.
config := map[string]any{
"port": 8080,
"host": "localhost",
"debug": true,
"ratio": 0.75,
"targets": []string{"a", "b"},
}
for _, k := range slices.Sorted(maps.Keys(config)) {
fmt.Printf("%-8s %s\n", k, describe(config[k]))
}
// any에 담으면 원래 타입으로 연산할 수 없다.
// total := a + 1 // 컴파일 에러: invalid operation
fmt.Println("합계:", sum([]int{1, 2, 3}))
}
go run ./03-any
any와 interface{}는 같은 타입: true
int = 42
string = 문자열
float64 = 3.14
bool = true
[]int = [1 2]
<nil> = <nil>
debug bool = true
host string = localhost
port int = 8080
ratio float64 = 0.75
targets []string = [a b]
합계: 6
any를 써도 되는 자리
1. 값을 들여다보지 않고 통과시키기만 할 때. fmt.Println(a ...any),
log.Printf가 그렇다. 사실 fmt는 들여다보긴 하지만, 그것이 그 패키지의 일이다.
2. 구조가 열려 있는 데이터. JSON, 설정 파일, 사용자 정의 메타데이터.
map[string]any가 대표적이다(Part 9).
3. 그 외에는 거의 없다. "여러 타입을 받고 싶다"는 이유라면 지금은 제네릭이
답이다(Part 5). any는 타입 정보를 버리고, 버린 것을 4-4의
타입 단언으로 되찾아야 하며, 그 과정에서 런타임 실패 가능성이 생긴다.
:::note interface{}는 옛날 코드다
Go 1.18 이전 코드는 전부 interface{}로 적혀 있다. 의미는 완전히 같다. 새로 쓰는
코드에는 any를 쓰고, 옛 코드를 읽을 때 당황하지 않으면 된다. gofmt는 자동으로
바꾸지 않지만, go fix의 모더나이저가 처리해 준다.
:::
흔히 하는 실수
1. 인터페이스를 미리 만든다
구현이 하나뿐인데 인터페이스를 정의하는 것. 파일이 두 개가 되고, 이동(go to definition)이 한 단계 늘고, 얻는 것은 없다. 구현이 둘이 되거나 테스트에서 대역이 필요해질 때 만들어도 늦지 않다. 암묵적 구현 덕에 나중에 만들어도 기존 코드를 안 고친다.
2. 인터페이스를 반환한다
func NewStore() Store // 대체로 나쁘다
func NewStore() *UserStore // 이쪽
"인터페이스를 받고 구조체를 반환한다" 가 Go의 관용구다. 4-5의 주제이므로 거기서 자세히 본다.
3. 인터페이스 이름에 I 접두사를 붙인다
IWriter, IUserStore는 Go 코드가 아니다. 관례는 메서드 이름 + -er 다.
Reader, Writer, Formatter, Validator. 메서드가 여럿이면 역할 이름을 쓴다
(sort.Interface, net.Conn).
4. 큰 인터페이스 하나로 모든 것을 받는다
type Storage interface {
Get(string) ([]byte, error)
Put(string, []byte) error
Delete(string) error
List(string) ([]string, error)
Stat(string) (os.FileInfo, error)
}
Get만 쓰는 함수도 이걸 받게 된다. 그 함수의 테스트는 다섯 개를 구현해야 한다.
함수마다 필요한 만큼만 받는다.
func serve(g interface{ Get(string) ([]byte, error) }, key string) error
인라인 인터페이스도 문법적으로 가능하다. 메서드가 하나뿐이면 이름을 안 붙이는 것도 합리적인 선택이다.
5. 값과 포인터를 헷갈려 does not implement를 만난다
4-1의 메서드 집합 이야기다. 포인터 리시버 메서드가 있으면
&T{}를 넘겨야 한다. 이 에러의 90%는 &를 빼먹은 것이다.
정리
- Go의 인터페이스는 메서드 목록이고, 구현은 암묵적이다.
implements가 없다. - 암묵적 구현 덕에 내가 못 고치는 타입도 내 인터페이스를 만족하고, 인터페이스를 나중에 추출할 수 있다.
var _ Iface = (*T)(nil)로 컴파일 시점에 구현을 확인한다.- 작은 인터페이스가 강한 추상화다. 표준 라이브러리의 핵심 인터페이스는 대부분 메서드 하나다.
io.Writer는 세 줄짜리 인터페이스로 파일·메모리·네트워크·계수기·다중 출력을 전부 같은 함수에 넣는다.- 인터페이스는 소비자 쪽 패키지에 정의한다. 그래야 작아지고, 의존이 안 생기고, 만들 시점이 분명해진다.
any는interface{}의 별칭이다. 통과시키기만 할 때와 열린 데이터에만 쓰고, "여러 타입을 받고 싶다"면 제네릭(Part 5)을 본다.
연습문제
-
report를 파일에 쓰도록 바꿔 보자.os.Create로 얻은*os.File을 그대로 넘기면 된다. 그다음defer f.Close()의 에러를 어떻게 처리할지 생각해 보자. 힌트:Close의 에러를 버리면 안 되는 이유는 쓰기 버퍼가 그때 비워지기 때문이다. 2-7의 명명된 반환값이 답의 일부다. -
countingWriter처럼io.Writer를 감싸는upperWriter를 써 보자. 통과하는 바이트를 대문자로 바꿔 다음 Writer에 넘긴다. UTF-8 멀티바이트 문자가Write호출 경계에서 잘리면 어떻게 되는가? 이 문제가 왜io.Writer래퍼 전반의 함정인지 생각해 보자. -
nameLookup을greet함수 시그니처 안에 인라인으로 적어 보자 (func greet(l interface{ Name(string) (string, bool) }, id string) string). 읽기에 어느 쪽이 나은가? 인터페이스에 이름을 붙이는 것의 값은 무엇인가?