본문으로 건너뛰기

인터페이스 설계 판단

이 챕터에서 다루는 것

문법은 4-3에서 끝났다. 남은 것은 판단이다. 언제 인터페이스를 만들고, 언제 만들지 않는가. Go에서 이 판단이 다른 언어와 크게 다르기 때문에 챕터 하나를 따로 쓴다.

문제 — 인터페이스는 공짜가 아니다

다른 언어에서 온 사람이 Go에서 가장 자주 만드는 코드가 이것이다.

// store/store.go
type Store interface {
Get(id string) (*User, error)
Put(u *User) error
Delete(id string) error
List() ([]*User, error)
}

// store/postgres.go
type PostgresStore struct{ ... }
func NewPostgresStore(...) Store { ... }

구현은 하나다. 그런데 이 코드는 이미 네 가지를 지불했다.

  1. 파일이 하나 늘고, 정의로 이동하면 인터페이스에 닿는다. 실제 구현을 보려면 한 단계를 더 가야 한다.
  2. 메서드를 추가하려면 두 곳을 고쳐야 한다.
  3. NewPostgresStore가 인터페이스를 반환하므로 호출자는 PostgresStore에만 있는 것에 닿을 수 없다.
  4. 얻은 것은 없다. "나중에 다른 DB로 바꿀 수 있다"는 그날이 오면 그때 인터페이스를 뽑으면 된다. 암묵적 구현 덕에 기존 코드를 하나도 안 고친다.

Go 커뮤니티는 이 상황을 인터페이스 오염(interface pollution) 이라고 부른다.

원칙 1 — 구현이 하나면 인터페이스를 만들지 않는다

Don't design with interfaces, discover them. — Rob Pike

인터페이스는 발명하는 것이 아니라 발견하는 것이다. 구체 타입으로 먼저 짜고, 같은 자리에 다른 타입이 들어와야 할 이유가 생겼을 때 그 자리에서 인터페이스가 드러난다.

인터페이스를 만들어도 되는 신호는 다음 중 하나다.

  • 실제 구현이 둘 이상이다. 미래형이 아니라 지금.
  • 테스트에서 대역이 필요하다. 그것도 진짜 필요할 때. 인메모리 구현으로 충분한 경우가 많다.
  • 의존성 방향을 끊어야 한다. 상위 계층이 하위 패키지를 import하면 안 되는 경우.
  • 호출자가 구현을 주입해야 한다. 콜백, 미들웨어, 플러그인.

:::warning "테스트를 위해"가 남용된다 "모킹하려면 인터페이스가 필요하다"는 말은 절반만 맞다. 진짜 인메모리 구현이 더 나은 경우가 많다. map으로 만든 저장소는 목(mock)보다 짧고, 실제 동작을 하고, 동작이 바뀌면 같이 깨진다.

목이 정당한 자리는 바깥 세계에 나가는 경계다. 네트워크, 시계, 파일 시스템, 난수. 그 안쪽은 대체로 진짜 구현으로 테스트하는 편이 낫다. Part 8에서 다시 본다. :::

원칙 2 — 인터페이스를 받고, 구조체를 반환한다

Go에서 가장 자주 인용되는 설계 격언이다.

Accept interfaces, return structs.

examples/04-interfaces-and-errors/05-accept-return/main.go
package main

import "fmt"

// ---- 구체 타입 ----

type memStore struct {
data map[string]string
hits int
}

func (s *memStore) Get(key string) (string, bool) {
v, ok := s.data[key]
if ok {
s.hits++
}
return v, ok
}

func (s *memStore) Put(key, value string) { s.data[key] = value }

// Hits는 나중에 추가된 메서드다.
func (s *memStore) Hits() int { return s.hits }

// ---- 반환 타입을 인터페이스로 둔 버전 ----

type Store interface {
Get(key string) (string, bool)
Put(key, value string)
}

func newStoreAsInterface() Store {
return &memStore{data: make(map[string]string)}
}

// nil을 돌려주려다 밟는 함정. 반환 타입이 인터페이스라 (타입, nil)이 나간다.
func newStoreMaybeNil(enabled bool) Store {
var s *memStore
if enabled {
s = &memStore{data: make(map[string]string)}
}
return s
}

// ---- 구조체를 반환하는 버전 ----

func newStore() *memStore {
return &memStore{data: make(map[string]string)}
}

// ---- 소비자: 필요한 것만 받는다 ----

type getter interface {
Get(key string) (string, bool)
}

func lookup(g getter, key string) string {
if v, ok := g.Get(key); ok {
return v
}
return "(없음)"
}

func main() {
// 구조체를 반환하면 모든 메서드를 쓸 수 있다.
s := newStore()
s.Put("a", "사과")
fmt.Println("lookup:", lookup(s, "a"))
fmt.Println("lookup:", lookup(s, "b"))
fmt.Println("Hits:", s.Hits())

// 인터페이스를 반환하면 인터페이스에 없는 메서드에 닿지 못한다.
i := newStoreAsInterface()
i.Put("a", "사과")
// i.Hits() // 컴파일 에러: i.Hits undefined (type Store has no field or method Hits)
if h, ok := i.(interface{ Hits() int }); ok {
fmt.Println("단언해서 겨우 꺼낸 Hits:", h.Hits())
}

// 인터페이스 반환이 nil 함정을 부른다.
disabled := newStoreMaybeNil(false)
fmt.Println("disabled == nil:", disabled == nil)
fmt.Printf("실제 내용: 타입=%T 값=%v\n", disabled, disabled)

func() {
defer func() {
if rec := recover(); rec != nil {
fmt.Println("사용하려는 순간:", rec)
}
}()
disabled.Put("x", "y")
}()
}
go run ./05-accept-return
lookup: 사과
lookup: (없음)
Hits: 1
단언해서 겨우 꺼낸 Hits: 0
disabled == nil: false
실제 내용: 타입=*main.memStore 값=<nil>
사용하려는 순간: runtime error: invalid memory address or nil pointer dereference

왜 구조체를 반환하는가

1. 호출자가 정보를 잃지 않는다. Hits()가 그 예다. 인터페이스로 반환하면 나중에 추가되는 모든 메서드가 호출자에게 안 보인다. 꺼내려면 타입 단언을 해야 하고, 그건 인터페이스를 쓴 이유를 스스로 무너뜨리는 일이다.

2. 호출자가 자기에게 맞는 인터페이스를 고를 수 있다. lookupgetter만 요구한다. 반환 타입이 *memStore여도 아무 문제 없이 들어간다. 인터페이스는 받는 쪽에서 좁히는 것이지 주는 쪽에서 정하는 것이 아니다.

3. nil 함정을 피한다. newStoreMaybeNil4-4의 함정 그대로다. 반환 타입이 *memStore였다면 disabled == niltrue였을 것이다. 인터페이스를 반환하는 순간 nil을 정직하게 돌려주기가 어려워진다.

4. 문서가 구체적이 된다. go doc에 실제 타입과 그 모든 메서드가 나온다.

예외

error는 예외다. 표준 라이브러리 전체가 error 인터페이스를 반환한다. 이유는 4-6에서 본다.

그 밖에 정당한 예외는 팩토리가 여러 구현 중 하나를 고를 때다.

func NewCompressor(algo string) (Compressor, error) // gzip이냐 zstd냐를 런타임에 고른다

이때조차 각 구현의 생성자(NewGzip() *GzipCompressor)를 따로 노출해 두는 편이 좋다.

원칙 3 — 함수 타입도 인터페이스를 구현할 수 있다

메서드 하나짜리 인터페이스라면, 구조체를 만들지 않고 함수에 메서드를 붙이는 방법이 있다.

examples/04-interfaces-and-errors/05-handler-func/main.go
package main

import (
"fmt"
"strings"
)

// Handler는 메시지 하나를 처리한다.
type Handler interface {
Handle(msg string) error
}

// HandlerFunc는 함수 타입이다. 여기에 메서드를 붙여 Handler로 만든다.
// net/http.HandlerFunc가 정확히 이 형태다.
type HandlerFunc func(msg string) error

func (f HandlerFunc) Handle(msg string) error { return f(msg) }

// 구조체 구현 — 상태가 필요할 때
type recorder struct {
seen []string
}

func (r *recorder) Handle(msg string) error {
r.seen = append(r.seen, msg)
return nil
}

// 미들웨어: Handler를 받아 Handler를 돌려준다.
func withUpper(next Handler) Handler {
return HandlerFunc(func(msg string) error {
return next.Handle(strings.ToUpper(msg))
})
}

func withReject(next Handler, banned string) Handler {
return HandlerFunc(func(msg string) error {
if strings.Contains(msg, banned) {
return fmt.Errorf("거부된 메시지: %q", msg)
}
return next.Handle(msg)
})
}

func main() {
rec := &recorder{}

// 함수 하나를 Handler로 쓴다. 구조체를 만들 필요가 없다.
printer := HandlerFunc(func(msg string) error {
fmt.Println("출력:", msg)
return nil
})

for _, h := range []Handler{
printer,
withUpper(printer),
withReject(printer, "spam"),
rec,
} {
for _, msg := range []string{"hello", "spam ad"} {
if err := h.Handle(msg); err != nil {
fmt.Println("에러:", err)
}
}
}

fmt.Println("recorder가 본 것:", rec.seen)
}
go run ./05-handler-func
출력: hello
출력: spam ad
출력: HELLO
출력: SPAM AD
출력: hello
에러: 거부된 메시지: "spam ad"
recorder가 본 것: [hello spam ad]

이 패턴이 왜 중요한가

net/http가 이 구조 그 자체다.

type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}

type HandlerFunc func(ResponseWriter, *Request)

func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
f(w, r)
}

세 줄짜리 어댑터 하나로 "핸들러는 인터페이스""핸들러는 그냥 함수" 를 동시에 만족시킨다. 상태가 필요하면 구조체로 만들고, 필요 없으면 함수로 쓴다. 미들웨어는 Handler를 받아 Handler를 돌려주는 함수가 된다. Part 9와 Part 10의 모든 웹 코드가 이 모양이다.

메서드 하나짜리 인터페이스를 정의할 때는 XxxFunc 어댑터를 같이 둘지 항상 고려해 볼 만하다. 표준 라이브러리에는 http.HandlerFunc, sort.SliceStable의 비교 함수, io/fs.WalkDirFunc 같은 형태로 흩어져 있다.

표준 라이브러리 설계 읽기

sort.Interface — 큰 인터페이스가 남긴 교훈

type Interface interface {
Len() int
Less(i, j int) bool
Swap(i, j int)
}

메서드 세 개다. 슬라이스를 정렬하려면 타입을 하나 정의하고 세 메서드를 써야 했다. 너무 번거로워서 Go 1.8에 sort.Slice(x any, less func(i, j int) bool)가 추가됐고, Go 1.21에 제네릭 기반 slices.Sort, slices.SortFunc이 들어와 지금은 그쪽이 기본이다.

slices.SortFunc(people, func(a, b Person) int { return cmp.Compare(a.Age, b.Age) })

교훈은 "세 개도 많았다" 는 것이다. 인터페이스가 커질수록 구현 부담이 커지고, 결국 우회로가 생긴다. sort.Interface는 여전히 유효하지만, 새 코드에서 쓸 일은 거의 없다(Part 5).

io.Reader / io.Writer — 하나가 옳았던 경우

메서드 하나. 그래서 수백 개의 타입이 구현하고, 수십 개의 조합 부품 (io.MultiWriter, io.LimitReader, bufio.Reader, gzip.Writer)이 존재한다. 4-3에서 본 그대로다.

error — 하나짜리 인터페이스의 극단

type error interface {
Error() string
}

언어에 내장된 유일한 인터페이스다. 다음 챕터의 주제다.

net.Conn — 크지만 정당한 경우

type Conn interface {
Read(b []byte) (n int, err error)
Write(b []byte) (n int, err error)
Close() error
LocalAddr() Addr
RemoteAddr() Addr
SetDeadline(t time.Time) error
SetReadDeadline(t time.Time) error
SetWriteDeadline(t time.Time) error
}

메서드 여덟 개. 작은 인터페이스 원칙에 어긋나 보이지만 여기서는 옳다. "네트워크 연결"이라는 개념 자체가 이 여덟 가지를 다 포함하기 때문이고, 구현자가 TCP·UDP·Unix 소켓·TLS 등 여럿이며 서로를 모르기 때문이다. 4-3에서 말한 "추상화가 아니라 표준" 인 경우다.

정리하면 기준은 이렇다. 인터페이스가 커도 되는 경우는 구현자가 여럿이고, 그 목록이 열려 있고, 그 인터페이스가 개념 하나를 통째로 표현할 때다. 그 외에는 소비자가 필요한 만큼만 잘라 쓴다.

인터페이스를 쓰지 말아야 할 때

정리하면 이렇게 된다.

상황판단
구현이 하나뿐이다만들지 않는다
"나중에 바꿀지도"만들지 않는다. 그때 만들어도 안 늦다
생성자의 반환 타입구조체. error만 예외
자료를 담기만 하는 타입 (DTO, 설정)만들지 않는다. 메서드가 없다
핫 루프 안재고. 간접 호출과 할당 비용이 측정되는 유일한 자리
함수 인자인터페이스. 필요한 메서드만
구현이 둘 이상이거나 주입이 필요인터페이스
여러 타입을 같은 코드로 처리 (동작이 아니라 타입만 다름)제네릭 (Part 5)

마지막 줄이 최근에 바뀐 부분이다. 제네릭이 들어오기 전에는 "여러 타입을 받는" 문제를 interface{}나 인터페이스로 풀 수밖에 없었다. 지금은 동작이 다르면 인터페이스, 타입만 다르면 제네릭이 기준이다.

흔히 하는 실수

1. 패키지마다 Service 인터페이스를 만든다

UserService, OrderService, PaymentService가 각각 인터페이스와 구현 한 쌍씩. 구현은 전부 하나다. 인터페이스 오염의 전형이다.

2. 인터페이스와 구현을 같은 패키지에 나란히 둔다

store/
store.go // type Store interface
memory.go // type MemStore struct

인터페이스가 제공자 쪽에 있다는 신호다. 소비자 쪽으로 옮기거나, 아예 없애는 것을 먼저 검토한다(4-3).

3. 인터페이스를 반환한다

nil 함정을 부르고, 호출자에게서 정보를 뺏는다. error 외에는 구조체를 반환한다.

4. 인터페이스에 필드가 필요하다고 느낀다

Go의 인터페이스에는 메서드만 있다. 필드가 필요하면 Name() string 같은 접근자 메서드를 두게 되는데, 그것이 어색하게 느껴진다면 인터페이스가 아니라 구조체를 받아야 하는 상황일 가능성이 높다.

5. 모든 것을 인터페이스 뒤에 숨기고 테스트를 목으로 채운다

목이 늘수록 테스트는 구현의 호출 순서를 검증하게 되고, 리팩터링할 때마다 깨진다. 실제 동작을 검증하는 테스트가 목 없는 테스트인 경우가 많다.

정리

  • 인터페이스는 발명이 아니라 발견이다. 구현이 하나면 만들지 않는다.
  • 나중에 필요해지면 그때 뽑는다. 암묵적 구현 덕에 기존 코드를 안 고친다.
  • 인터페이스를 받고 구조체를 반환한다. error만 예외다.
  • 인터페이스를 반환하면 정보를 잃고 nil 함정을 부른다.
  • 함수 타입에 메서드를 붙여 메서드 하나짜리 인터페이스를 구현할 수 있다. http.HandlerFunc이 그 형태이고, 미들웨어 패턴의 바탕이다.
  • 큰 인터페이스가 정당한 경우는 구현자가 여럿이고 개념 하나를 통째로 표현할 때다 (net.Conn).
  • 동작이 다르면 인터페이스, 타입만 다르면 제네릭.

연습문제

  1. 지금까지 본 예제 중 인터페이스가 없어도 되는 것을 찾아보자. 4-3Validator는 어떤가? nameLookup은? 각각 없앴을 때 무엇을 잃는지 적어 보자.

  2. HandlerFunc 예제에 withRetry(next Handler, n int) Handler 미들웨어를 추가해 보자. 실패하면 최대 n번 다시 시도한다. 미들웨어를 여러 개 겹칠 때 순서가 결과를 바꾸는가? withUpper(withReject(h, "SPAM"))withReject(withUpper(h), "SPAM")을 비교해 보자.

  3. newStoreAsInterface가 반환하는 값에서 Hits()를 꺼낼 때 쓴 i.(interface{ Hits() int })를 보자. 이 코드가 동작하는데도 나쁜 이유를 세 가지 이상 대 보자. 힌트: 컴파일러가 무엇을 검사해 주지 못하는가?