인터페이스 - 선언 없이 만족하는 약속
이 장에서 배우는 것
앞 장에서는 구조체에 메서드를 붙이고, 값 리시버와 포인터 리시버가 어떻게 다른지 살펴보았다. 이 장에서는 그 메서드들을 묶어 "이런 일을 할 수 있는 값"이라고 부르는 방법인 인터페이스(interface)를 다룬다. Go의 인터페이스는 구현하는 쪽이 "나는 이 인터페이스를 따른다"고 적지 않는다. 메서드가 맞으면 저절로 만족한다. 이 성질 때문에 인터페이스는 아주 작게 만들고, 쓰는 쪽에서 필요한 만큼만 선언하는 방식이 자연스럽다.
- 인터페이스가 선언 없이 만족되는 규칙을 설명하고, 컴파일 시점에 확인하는 방법을 안다.
- 메서드가 한두 개인 작은 인터페이스를 설계하고 다른 인터페이스를 포함시킨다.
- 타입 단언과 타입 switch로 인터페이스 안의 실제 값을 꺼낸다.
fmt.Stringer를 구현해 출력 형태를 직접 정한다.- nil 인터페이스와 "nil 포인터를 담은 인터페이스"의 차이를 구분한다.
문제 상황
동네 대여소에는 일반 자전거와 전기 자전거가 있다. 요금 규칙이 다르다. 일반 자전거는 30분까지 1,000원이고 그 뒤로는 10분마다 200원이 붙는다. 전기 자전거는 분당 100원이다. 지금까지의 방식대로라면 정산 함수를 자전거 종류마다 하나씩 만들게 된다. settleCity, settleElectric 같은 함수가 생기고, 종류가 늘 때마다 함수도 늘어난다.
정산 함수가 실제로 알아야 하는 것은 자전거의 종류가 아니라 "이용 시간을 받아 요금을 돌려주는 능력" 하나뿐이다. 회원 할인도 마찬가지다. 정산 함수는 할인율을 알려 주는 무언가가 있는지만 알면 된다. 이 "능력"을 이름 붙여 타입으로 쓰게 해 주는 것이 인터페이스다.
한편 이런 상황도 생긴다. 자전거 목록을 훑다가 전기 자전거에만 있는 배터리 잔량을 알고 싶다. 목록의 원소 타입은 공통 인터페이스이므로 배터리 메서드가 보이지 않는다. 이때는 값 안을 들여다보는 타입 단언이 필요하다. 이 장은 이 두 가지 요구를 차례로 해결한다.
인터페이스는 선언 없이 만족된다
메서드 목록이 곧 계약이다
인터페이스는 메서드 이름과 시그니처(매개변수와 반환 타입)의 목록이다. 아래는 이 장의 예제에서 쓰는 가장 작은 인터페이스다.
type Pricer interface {
Price(minutes int) int
}
어떤 타입이 Price(minutes int) int 메서드를 가지고 있으면 그 타입은 Pricer를 만족한다. implements 같은 키워드는 없다. Pricer가 나중에 만들어졌더라도, 다른 패키지에서 작성된 타입이라도 메서드만 맞으면 된다. 아래 그림은 이 관계를 보여 준다.
이 방식에서는 의존 방향이 뒤집힌다. 구현하는 타입은 인터페이스를 모르고, 인터페이스를 필요로 하는 쪽이 자기에게 필요한 만큼만 선언한다. 정산 함수가 Pricer 하나만 받으면 자전거뿐 아니라 정액제 요금표나 시험용 가짜 값도 그대로 넣을 수 있다.
값 리시버와 포인터 리시버의 영향
어떤 타입이 어떤 메서드를 "가졌다"고 볼지는 리시버 종류에 따라 달라진다. 타입이 가진 메서드의 모음을 메서드 집합(method set)이라고 부르며, 규칙은 다음과 같다.
| 메서드 선언 | T 값이 만족 | *T 값이 만족 | 예 |
|---|---|---|---|
| func (t T) M() | 만족한다 | 만족한다 | CityBike의 Price |
| func (t *T) M() | 만족하지 못한다 | 만족한다 | MemberDiscount의 Rate |
포인터 리시버로 선언한 메서드는 값 자체가 아니라 그 값의 주소가 가진 메서드다. 그래서 MemberDiscount{Percent: 10}을 Discounter 자리에 바로 넣으면 컴파일 오류가 나고, &MemberDiscount{Percent: 10}은 통과한다.
작은 인터페이스와 포함
표준 라이브러리의 인터페이스는 대부분 메서드가 하나다. 작을수록 만족하는 타입이 많아지고 쓰임새가 넓어진다. 여러 능력이 함께 필요할 때는 작은 인터페이스를 다른 인터페이스 안에 적어 넣어 합친다.
type Bike interface {
fmt.Stringer
Pricer
}
Bike는 String() string과 Price(int) int를 모두 가진 타입을 뜻한다. 새 메서드를 다시 쓰지 않고 이름만 적으면 된다.
선언 없이 만족된다는 것은 실수도 조용히 넘어갈 수 있다는 뜻이기도 하다. 메서드 이름을 잘못 적어도 그 타입은 그냥 인터페이스를 만족하지 못할 뿐이고, 오류는 그 타입을 인터페이스 자리에 넣는 코드에서야 나타난다. 의도를 코드로 남기고 싶다면 패키지 수준에 다음과 같이 적는다.
var _ Bike = CityBike{}
밑줄 변수는 값을 버리므로 실행에는 영향이 없고, CityBike가 Bike를 만족하지 못하면 이 줄에서 바로 컴파일 오류가 난다.
인터페이스 안의 값 꺼내기
fmt.Stringer
fmt 패키지에는 String() string 메서드 하나로 이루어진 Stringer 인터페이스가 있다. fmt.Println이나 %v, %s는 값이 Stringer를 만족하면 그 메서드를 호출해 출력 문자열을 얻는다. 구조체를 그대로 출력하면 {2 80}처럼 필드 값만 나열되어 읽기 어렵다. String 메서드를 하나 붙이면 어디에서 출력하든 읽기 좋은 형태가 된다.
타입 단언
인터페이스 변수는 안에 실제 값을 하나 담고 있다. 그 값이 특정 타입인지 확인하고 꺼내는 문법이 타입 단언(type assertion)이다. 반드시 쉼표 ok 형태로 쓴다.
if r, ok := b.(BatteryReporter); ok {
fmt.Println(r.Battery())
}
단언 대상은 구체적인 타입일 수도 있고 인터페이스일 수도 있다. 위 코드는 인터페이스를 대상으로 삼아 "배터리 잔량을 알려 주는 메서드가 있는가"를 묻는다. 자전거 종류를 일일이 나열하지 않아도 되어 새 종류가 생겨도 이 코드는 바뀌지 않는다. ok 없이 b.(EBike)만 쓰면 타입이 맞지 않을 때 프로그램이 패닉(panic, 비정상 종료)한다.
타입 switch
여러 타입을 차례로 검사할 때는 타입 switch가 읽기 쉽다.
switch x := v.(type) {
case int:
// 여기서 x는 int
case fmt.Stringer:
// 여기서 x는 fmt.Stringer
default:
// 여기서 x는 v와 같은 타입
}
각 case 안에서 x는 그 case의 타입으로 바뀌어 있다. case nil은 인터페이스가 비어 있는 경우를 잡는다. case는 위에서부터 검사하고 처음 맞는 곳에서 멈춘다. 구체적인 타입을 인터페이스 case보다 위에 두어야 의도한 곳에서 걸린다.
타입 switch의 대상으로 자주 쓰는 any는 메서드가 하나도 없는 인터페이스이므로 어떤 값이든 담을 수 있다. 편리하지만 컴파일러가 타입을 검사해 주지 못하므로, 정말 필요한 곳에만 쓰는 편이 좋다.
nil 인터페이스 함정
인터페이스 값은 내부적으로 두 칸으로 이루어져 있다고 생각하면 된다. 하나는 담긴 값의 타입, 다른 하나는 값 자체다. 인터페이스가 nil과 같다고 판정되려면 두 칸이 모두 비어 있어야 한다.
다음 함수는 회원이 아니면 "할인 없음"을 돌려주려는 의도로 작성되었다.
func lookupBad(member bool) Discounter {
var d *MemberDiscount
if member {
d = &MemberDiscount{Percent: 10}
}
return d
}
회원이 아니면 d는 nil 포인터다. 그러나 이것을 Discounter로 반환하는 순간 타입 칸에는 *MemberDiscount가 들어간다. 호출한 쪽에서 d != nil은 참이 되고, 이어서 d.Rate()를 부르면 nil 포인터의 필드를 읽다가 패닉한다. 고치는 방법은 "없음"을 나타낼 때 포인터 변수를 거치지 않고 인터페이스 자리에 nil을 직접 돌려주는 것이다.
func lookupGood(member bool) Discounter {
if member {
return &MemberDiscount{Percent: 10}
}
return nil
}
완성 코드
아래 프로그램은 위의 내용을 한 파일에 모았다. 작업 디렉터리에 go mod init bike로 모듈을 만든 뒤 main.go로 저장한다.
main.go
package main
import (
"fmt"
"sort"
)
// Pricer는 이용 시간(분)으로 요금(원)을 계산한다.
type Pricer interface {
Price(minutes int) int
}
// BatteryReporter는 배터리 잔량(%)을 알려 준다.
type BatteryReporter interface {
Battery() int
}
// Bike는 출력할 수 있고 요금을 계산할 수 있는 자전거다.
type Bike interface {
fmt.Stringer
Pricer
}
type CityBike struct {
ID int
}
func (b CityBike) String() string {
return fmt.Sprintf("일반 자전거 #%d", b.ID)
}
func (b CityBike) Price(minutes int) int {
if minutes <= 30 {
return 1000
}
extra := (minutes - 30 + 9) / 10
return 1000 + extra*200
}
type EBike struct {
ID int
Charge int
}
func (b EBike) String() string {
return fmt.Sprintf("전기 자전거 #%d (배터리 %d%%)", b.ID, b.Charge)
}
func (b EBike) Price(minutes int) int {
return minutes * 100
}
func (b EBike) Battery() int {
return b.Charge
}
type Station string
func (s Station) String() string {
return "대여소 " + string(s)
}
// Discounter는 할인율(%)을 알려 준다.
type Discounter interface {
Rate() int
}
type MemberDiscount struct {
Percent int
}
func (m *MemberDiscount) Rate() int {
return m.Percent
}
var _ Bike = CityBike{}
var _ Bike = EBike{}
var _ Discounter = (*MemberDiscount)(nil)
func lookupBad(member bool) Discounter {
var d *MemberDiscount
if member {
d = &MemberDiscount{Percent: 10}
}
return d
}
func lookupGood(member bool) Discounter {
if member {
return &MemberDiscount{Percent: 10}
}
return nil
}
func settle(p Pricer, minutes int, d Discounter) int {
fee := p.Price(minutes)
if d != nil {
fee -= fee * d.Rate() / 100
}
return fee
}
func describe(v any) string {
switch x := v.(type) {
case nil:
return "값 없음"
case CityBike:
return fmt.Sprintf("일반 자전거 %d번", x.ID)
case EBike:
return fmt.Sprintf("전기 자전거 %d번, 배터리 %d%%", x.ID, x.Charge)
case int:
return fmt.Sprintf("정수 %d", x)
case fmt.Stringer:
return "Stringer: " + x.String()
default:
return fmt.Sprintf("알 수 없는 타입 %T", x)
}
}
func main() {
bikes := []Bike{CityBike{ID: 1}, EBike{ID: 2, Charge: 80}, CityBike{ID: 3}}
minutes := []int{25, 55, 70}
for i, b := range bikes {
fmt.Printf("%v: %d분 %d원\n", b, minutes[i], b.Price(minutes[i]))
}
for _, b := range bikes {
if r, ok := b.(BatteryReporter); ok {
fmt.Printf("%s 배터리 %d%%\n", b, r.Battery())
} else {
fmt.Printf("%s 배터리 정보 없음\n", b)
}
}
pricers := map[string]Pricer{"일반": CityBike{}, "전기": EBike{}}
names := make([]string, 0, len(pricers))
for name := range pricers {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
fmt.Printf("%s 40분 %d원\n", name, pricers[name].Price(40))
}
items := []any{CityBike{ID: 1}, EBike{ID: 2, Charge: 80}, 42, Station("망원"), 3.5, nil}
for _, item := range items {
fmt.Println(describe(item))
}
fmt.Println("회원 정산:", settle(bikes[2], 70, lookupGood(true)))
fmt.Println("비회원 정산:", settle(bikes[2], 70, lookupGood(false)))
bad := lookupBad(false)
fmt.Println("bad == nil:", bad == nil)
fmt.Printf("bad 타입: %T\n", bad)
good := lookupGood(false)
fmt.Println("good == nil:", good == nil)
}
줄별 해설
Pricer,BatteryReporter,Discounter는 모두 메서드가 하나인 인터페이스다.Bike는fmt.Stringer와Pricer를 포함해 두 능력을 합친 것이다.CityBike의Price에서(minutes - 30 + 9) / 10은 정수 나눗셈으로 올림을 계산하는 방법이다. 31분이면 (1+9)/10=1, 40분이면 (10+9)/10=1이므로 초과 10분 단위가 한 번 붙고, 70분이면 (40+9)/10=4이므로 네 번 붙는다.EBike만Battery메서드를 가진다.Bike에는Battery가 없으므로Bike변수로는 직접 부를 수 없고 단언이 필요하다.Station은 문자열 기반 타입에String만 붙인 예다. 자전거는 아니므로Bike는 아니지만fmt.Stringer는 만족한다.MemberDiscount의Rate는 포인터 리시버다. 그래서var _ Discounter = (*MemberDiscount)(nil)처럼 포인터 타입으로 확인한다. 값 타입으로 쓰면 컴파일되지 않는다.settle은Bike가 아니라Pricer를 받는다. 요금 계산만 필요하기 때문이다.bikes[2]는Bike이므로 그대로 넘길 수 있고,Bike는Pricer의 능력을 모두 갖고 있어서 자동으로 변환된다.d != nil검사가 있으므로 할인이 없을 때는nil을 넘기면 된다.describe의case순서에 주목한다.CityBike와EBike도String을 가졌으므로fmt.Stringercase를 위에 두면 그쪽에서 먼저 걸린다.Station은 구체case에 없어서fmt.Stringer에 걸리고,3.5는 어디에도 맞지 않아default로 간다.%T는 값의 타입 이름을 출력한다.main의 첫 반복문에서%v가Bike값을 받으면String이 호출된다. 서식 안의%%는 퍼센트 기호 한 글자를 뜻한다.- 두 번째 반복문의
b.(BatteryReporter)는 인터페이스를 대상으로 한 단언이다.EBike만 통과한다. - 맵
pricers의 값 타입이Pricer여서 서로 다른 타입을 한 맵에 담을 수 있다. 맵 순회 순서는 정해져 있지 않으므로 키를 모아sort.Strings로 정렬한 뒤 출력한다. - 마지막 부분에서
lookupBad(false)의 결과는nil과 같지 않고 타입이*main.MemberDiscount로 나온다.lookupGood(false)의 결과는nil과 같다. 예제에서는 함정을 눈으로 확인하기 위해bad를settle에 넘기지 않았다. 넘기면 패닉한다.
실행 결과
$ go run .
일반 자전거 #1: 25분 1000원
전기 자전거 #2 (배터리 80%): 55분 5500원
일반 자전거 #3: 70분 1800원
일반 자전거 #1 배터리 정보 없음
전기 자전거 #2 (배터리 80%) 배터리 80%
일반 자전거 #3 배터리 정보 없음
일반 40분 1200원
전기 40분 4000원
일반 자전거 1번
전기 자전거 2번, 배터리 80%
정수 42
Stringer: 대여소 망원
알 수 없는 타입 float64
값 없음
회원 정산: 1620
비회원 정산: 1800
bad == nil: false
bad 타입: *main.MemberDiscount
good == nil: true
실무에서 자주 틀리는 것
nil 포인터를 인터페이스로 그대로 반환한다
틀린 코드는 앞에서 본 lookupBad다. 반환 타입이 인터페이스인 함수에서 포인터 변수를 그대로 return하면 nil 포인터라도 "nil이 아닌 인터페이스"가 된다. 호출한 쪽의 nil 검사가 소용없어지고 이후 메서드 호출에서 패닉이 난다.
// 틀림
func find(ok bool) Discounter {
var d *MemberDiscount
if ok {
d = &MemberDiscount{Percent: 5}
}
return d
}
// 고침
func find(ok bool) Discounter {
if ok {
return &MemberDiscount{Percent: 5}
}
return nil
}
쉼표 ok 없이 타입 단언을 쓴다
타입이 맞지 않을 때 단언은 값을 돌려주는 대신 패닉한다. 실행해 보기 전에는 드러나지 않는 종류의 오류다.
// 틀림: bikes[0]은 CityBike라서 패닉한다
e := bikes[0].(EBike)
fmt.Println(e.Charge)
// 고침
if e, ok := bikes[0].(EBike); ok {
fmt.Println(e.Charge)
}
포인터 리시버 타입의 값을 인터페이스에 넣는다
메서드를 포인터 리시버로 선언해 놓고 값 자체를 넣으면 컴파일 오류가 난다. 오류 메시지는 대략 "method Rate has pointer receiver"라는 내용을 담고 있다.
// 틀림
var d Discounter = MemberDiscount{Percent: 10}
// 고침
var d Discounter = &MemberDiscount{Percent: 10}
인터페이스를 크게 만들어 놓고 쓴다
구현하는 쪽에서 "혹시 쓸지 모르니" 메서드를 잔뜩 넣은 인터페이스를 만들면, 시험용 가짜 값 하나를 만들 때도 메서드를 전부 구현해야 한다. 쓰는 쪽에서 필요한 메서드만 골라 선언하는 편이 낫다.
// 틀림: 정산에는 Price만 필요한데 모두 요구한다
type BikeService interface {
Price(minutes int) int
Battery() int
Lock()
Unlock()
Report() string
}
func settle(s BikeService, minutes int) int { return s.Price(minutes) }
// 고침
func settle(p Pricer, minutes int) int { return p.Price(minutes) }
한눈에 보기
| 주제 | 문법 | 핵심 규칙 | 주의 |
|---|---|---|---|
| 암묵적 구현 | type P interface { M() } | 메서드가 맞으면 만족한다 | 포인터 리시버는 *T만 만족한다 |
| 컴파일 확인 | var _ P = T{} | 만족하지 않으면 그 줄에서 오류 | 값 타입과 포인터 타입을 구분한다 |
| 포함 | 인터페이스 안에 이름만 적기 | 작은 것을 합쳐 큰 것을 만든다 | 가능하면 작게 유지한다 |
| 타입 단언 | r, ok := v.(T) | ok로 성공 여부를 확인한다 | ok 없이 쓰면 패닉한다 |
| 타입 switch | switch x := v.(type) | case마다 x의 타입이 바뀐다 | 구체 타입을 인터페이스보다 위에 둔다 |
| Stringer | func (t T) String() string | fmt 출력이 이 메서드를 쓴다 | 리시버 종류에 따라 적용 대상이 다르다 |
| nil 함정 | return nil | 타입 칸과 값 칸이 모두 비어야 nil이다 | nil 포인터 변수를 반환하지 않는다 |
| 상황 | 알맞은 도구 | 이유 | 예 |
|---|---|---|---|
| 타입 하나만 확인 | 쉼표 ok 단언 | if 한 줄로 끝난다 | 배터리 메서드 유무 |
| 타입이 여러 가지 | 타입 switch | 분기가 한눈에 보인다 | describe 함수 |
| 능력의 유무 | 인터페이스 대상 단언 | 새 타입에도 코드 수정이 없다 | BatteryReporter |
연습 문제
- 한 번 내면 이용 시간과 관계없이 요금이 같은 정액제 타입
FlatPricer를 만들어라. 필드는 요금Won int하나이며Pricer를 만족해야 한다. 완성 코드의pricers맵에 "정액"이라는 키로 넣으려면 어떤 줄을 추가해야 하는가? describe함수가bool값도 처리하도록 하려 한다.true이면 "참",false이면 "거짓"을 돌려주려면case를 어떻게 쓰는가?- 다음 함수는
nil검사를 통과하고도 패닉한다. 이유를 설명하고 고쳐라.func pick(name string) Pricer { var p *FlatPricer if name == "정액" { p = &FlatPricer{Won: 1500} } return p } EBike의String을func (b *EBike) String() string으로 바꾸고fmt.Println(EBike{ID: 2, Charge: 80})을 실행하면 무엇이 출력되는가? 그 이유는 무엇인가?
정답과 해설
- 메서드 하나만 추가하면 된다.
맵에는type FlatPricer struct { Won int } func (f FlatPricer) Price(minutes int) int { return f.Won }"정액": FlatPricer{Won: 1500}을 추가한다.FlatPricer가Pricer를 언급하지 않아도 메서드가 맞으므로 그대로 들어간다. 이름 정렬은sort.Strings가 하므로 출력 코드는 바꾸지 않아도 된다. 정렬 결과 "정액"은 "일반"과 "전기" 사이가 아니라 "전기" 뒤에 놓인다. 한글은 자음 순서로 "ㅇ", "ㅈ", "ㅈ" 다음 모음 순이며, "전"과 "정"을 비교하면 모음 "ㅓㄴ"과 "ㅓㅇ"에서 "ㄴ"이 앞서기 때문이다. - 다음과 같이
case를 추가한다.case bool: if x { return "참" } return "거짓"case bool에서x는bool이므로if x로 바로 쓸 수 있다. name이 "정액"이 아니면p는 nil 포인터이고, 반환하는 순간 타입 칸에*FlatPricer가 들어가 반환값은nil과 같지 않다. 호출한 쪽의nil검사를 통과하고Price를 부르면,Price가 값 리시버라서 nil 포인터를 값으로 옮기는 과정에서 패닉한다. 고치는 방법은 다음과 같다.func pick(name string) Pricer { if name == "정액" { return &FlatPricer{Won: 1500} } return nil }{2 80}이 출력된다.String이 포인터 리시버가 되면 그 메서드는*EBike의 메서드 집합에만 속하고,EBike값은fmt.Stringer를 만족하지 못한다.fmt.Println은Stringer를 만족하지 않는 구조체를 필드 값 나열 형태로 출력한다.&EBike{ID: 2, Charge: 80}을 넘기면String이 호출된다. 참고로 이 변경을 하면[]Bike{EBike{...}}처럼 값을Bike에 넣는 코드도 컴파일되지 않는다.