[번역] Interfaces in Go

🦦 hoonyland
15 min readDec 1, 2019

인터페이스는 매우 훌륭한 개념이자 Go에서 다형성을 구현하는 유일한 방법입니다.

이 글은 Uday HiwaraleInterfaces in Go를 번역한 글입니다. Go와 더불어 OOP도 공부하는 중이기 때문에 개념이나 표현이 잘못 번역된 부분이 있을 수 있습니다. 언제나 댓글 환영합니다!

(source: pexels.com)

☛ 인터페이스란?
우리는 구조체메소드 강의에서 객체행위에 대해 이야기를 나눴고 구조체(비구조체 포함)에서 메소드를 구현하는 방법을 살펴보았다. 인터페이스는 객체가 구현할 수 있는 메소드 시그니처 집합이라 볼 수 있다.

예를 들어, 개는 걷고 짖는다. 인터페이스가 걷고 짖는 행위에 대해 메소드 시그니처를 선언하고, 개(Dog) 객체에서 걷고(walk) 짖는(bark) 메소드를 구현하면, 개(Dog)는 해당 인터페이스를 구현했다고 말할 수 있다.

인터페이스의 기본 역할은 메소드명, 매개변수 입력값 & 반환값의 타입을 제공하는 일이다. 즉, 메소드를 선언하고 그것을 구현하는 것은 오직 타입(구조체 타입 등)을 따른다.

OOP 프로그래밍에 익숙하다면 인터페이스를 구현하는 과정에서 implement 키워드를 종종 보았을 것이다. 하지만 Go에서는 해당 타입이 인터페이스를 구현하더라도 명시적으로 implement 등의 키워드로 선언하지 않는다. 특정 타입이 인터페이스에서 정의된 메소드를 구현했다면, 해당 타입은 인터페이스를 구현했다고 본다. 바꿔 말하면, 오리처럼 걷고, 오리처럼 수영을 하고, 오리처럼 꽥꽥거린다면, 그것을 오리이다.

☛ 인터페이스 선언하기
struct와 동일한 방식으로 interface 키워드로 인터페이스 선언을 하고 타입 별명(type alias)을 사용해 활용한다.

type Shape interface {
Area() float64
Perimeter() float64
}

인터페이스 이름 규칙은 약간 혼란스러운 부분이 있는데, 이 글에서 자세히 살펴볼 수 있다.

아래 그림을 보면, 매개변수는 없고 float64타입을 반환하는 AreaPerimeter 두 메소드를 가진 Shape 인터페이스를 선언했다. 이 두 메소드를 구현한 모든 타입에 대해서는 Shape 인터페이스를 구현했다고 할 수 있다.

interface도 하나의 타입이기 때문에 이 타입에 대한 변수를 선언할 수도 있다.아래 그림에서는 Shape 인터페이스 타입의 변수 s를 선언했다.

https://play.golang.org/p/oGRDKbrEJYb

위 그림에서 나온 결과를 분석하기 전에 몇가지 설명할 것이 있다. 인터페이스 는 두가지 유형의 타입을 갖는다. 첫번째로 인터페이스의 정적 타입은 인터페이스 자신이다. 위 그림에서는 Shape를 말한다. 인터페이스는 정적 값을 갖지 않고 동적 값을 가리키기만 한다. 인터페이스 타입에 대한 변수는 인터페이스를 구현한 타입의 변수를 들고 있는 개념이다. 해당 타입 은 인터페이스의 동적 타입 이 되고 타입 의 값은 인터페이스의 동적 값 이 된다.

위 그림의 결과를 보자. 타입과 값 모두 nil 임을 알 수 있다. 구현된 코드까지만 봐서는 Shape 인터페이스는 자신을 구현한 어떤 타입이 올 지 알 수 없기 때문이다. fmt패키지에서 구현된 Prinln 함수는 매개변수로 인터페이스를 받고 있는데, 이 때 매개변수는 인터페이스의 동적 값을 가리키고 %T 문법을 사용하는 Printf 함수는 인터페이스의 동적 타입을 가리키는 것이다. 하지만 실제 인터페이스 s의 타입은 Shape이다 .

☛ 인터페이스 구현하기
Shape 인터페이스에서 시그니처로 제공하는 AreaPerimeter 메소드를 구현해보자. 더불어 Rect 구조체를 Shape 인터페이스를 구현하도록 만들어보자.

https://play.golang.org/p/Hb__pA7Xp5V

위 프로그램을 보자. Shape 인터페이스를 생성했고, 정사각형을 의미하는 구조체 타입 Rect를 만들었다. 이 후 Rect를 리시버 타입으로 하는 메소드 AreaPerimeter를 정의했다. Rect는 두 메소드를 구현했다라고 할 수 있다. 이 두 메소드는 Shape 인터페이스에서 정의되어있다. 때문에 RectShape 인터페이스를 구현했다고 할 수 있다.

인터페이스 Shape를 띄고 nil을 값으로 갖는 변수 s를 생성했고 여기에 구조체 Rect를 담았다. Rect는 Shape를 구현했기 때문에, 이 과정은 완벽하게 동작한다. 위 그림에서 결과창을 보자. 이제 s의 동적 타입은 Rect이고 동적 값은 구조체 Rect의 값 {5 4}임을 확인할 수 있다. 인터페이스 Shape를 구현한 또 다른 구조체 타입을 담는 것도 열려있기 때문에 이것을 동적이라 하는 것이다.

종종 인터페이스의 동적 타입을 콘크리트 타입이라 부르기도 한다. 인터페이스 타입에 접근할 때, 현재 갖고 있는 동적 값의 타입을 반환하고 정적 타입은 숨겨놓기 때문이다.

이제 우리는 s에서 Area 메소드를 호출할 수 있다. s의 콘크리트 타입은 Rect이고 RectArea 메소드를 구현했기 때문이다. 구조체 Rect로 생성된 sr의 비교도 가능하다. 두 변수 모두 Rect를 콘크리트 타입으로 하고 동일한 값을 갖고 있기 때문이다.

자, 이제 s동적 타입과 동적 값 을 바꿔보자.

https://play.golang.org/p/K8mBGpfApjJ

구조체메소드 강의글을 읽었다면, 위 프로그램이 쉽게 이해될 것이다. 새로운 구조체 타입 CircleShape 인터페이스를 구현하고 있기 때문에 sCircle 타입의 구조체도 할당할 수 있다.

이 프로그램을 보고서 아마도 여러분은 타입 이 왜 동적인지를 이해했을 것이다. 슬라이스 강의글에서 슬라이스는 실제 값이 담겨있는 배열 을 가리키는 포인터를 가지고 있다는 것을 배웠을 것이다. 인터페이스도 마찬가지이다. 이 인터페이스를 구현한 타입의 포인터를 동적으로 들고있다고 생각하면 된다.

다음 프로그램을 실행하면 어떤 일이 일어날 지 생각해보라.

https://play.golang.org/p/pwhIwfHFzF9

위 프로그램에서 Perimeter 메소드를 구현한 코드를 지웠다. 지우고 나니 프로그램은 컴파일 되지 않고 에러를 반환했다.

program.go:22: cannot use Rect literal (type Rect) as type Shape in assignment:
Rect does not implement Shape (missing Perimeter method)

인터페이스를 구현하기 위한 조건을 이해했다면 위 에러가 쉽게 이해가 될 것이다. 인터페이스에서 선언된 모든 메소드를 구현해야한다.

☛ 빈 인터페이스
아무런 메소드도 갖고 있지 않은 인터페이스를 빈 인터페이스 라고 한다. 빈 인터페이스는 interface{} 로 표현한다. 빈 인터페이스는 아무 메소드도 선언되지 않았기 때문에 모든 타입이 빈 인터페이스를 구현했다고 볼 수 있다.

혹시 fmt 패키지에 구현된 Println 함수는 어떻게 그 때 그 때 다른 타입의 데이터를 매개변수로 받아 콘솔에 띄우는 지 생각해본 적 있는가? 이걸 가능케 하는 것이 바로 빈 인터페이스이다. 어떻게 동작하는지 살펴보자.

빈 인터페이스를 매개변수로 받아 동적 값과 타입 을 콘솔에 표시하는 explain 함수를 만들었다.

https://play.golang.org/p/NhvO6Qjw_zp

위 프로그램을 보면, 문자열 타입을 띄는 MyString 이라는 새로운 타입과 Rect 구조체 타입을 생성했다. explain 함수는 빈 인터페이스를 매개변수로 받기 때문에, MyString 타입과 Rect 타입 모두 매개변수로 넘길 수 있다. 모든 타입은 빈 인터페이스를 구현했다고 볼 수 있고 매개변수 i는 빈 인터페이스이기 때문이다.

☛ 다수의 인터페이스
한 타입은 여러 인터페이스를 구현하는 것이 가능하다. 아래 예시를 보자.

https://play.golang.org/p/YgW3NBxp8Fh

위 프로그램을 보면, Area 메소드를 선언한 Shape 인터페이스와 Volume 인터페이스를 선언한 Object 인터페이스를 생성했다. 구조체 Cube는 이 두 메소드 모두를 구현했기 때문에, 두 인터페이스를 모두 구현한 것이 된다. 그러므로 Cube 구조체 타입의 값은 Shape 타입의 변수 혹은 Object 타입의 변수 모두에 할당이 가능하다.

so 모두 c의 동적 값을 갖고 있을거라 예상할 수 있다. Shape 인터페이스 타입을 가지며 Area를 구현한 sArea 메소드를 사용했고, Object 인터페이스 타입을 가지며 Volume 메소드를 구현한 oVolume 메소드를 사용했다. 만약 s에서 Volume 메소드를 호출하고 o에서 Area 메소드를 호출하면 어떻게 될까?

위 프로그램을 조금 수정해서 어떤 일이 일어나는지 보자.

fmt.Println(“area of s of interface type Shape is”, s.Volume())
fmt.Println(“volume of o of interface type Object is”, o.Area())

위처럼 코드를 수정하면 다음 결과를 얻을 수 있다.

program.go:31: s.Volume undefined (type Shape has no field or method Volume)
program.go:32: o.Area undefined (type Object has no field or method Area)

컴파일에 실패한 것을 볼 수있다. s의 정적 타입은 Shape이고 o의 정적 타입은 Object이기 때문이다.
이 문제를 해결하기 위해서는 인터페이스에 가려진 값을 끄집어 낼 방법이 필요하다. 이 때 쓰는 방법이 타입 단언이다.

☛ 타입 단언
i.(Type) 문법을 사용하면 인터페이스 아래 숨겨진 동적 값을 찾아낼 수 있다. 이 때 i는 인터페이스이고 Type은 인터페이스 i를 구현한 타입이다. Go는 i의 동적 타입이 Type과 동일한지 확인한다.

자, 그럼 이전에 컴파일 에러가 났던 예제 코드를 다시 작성해서 인터페이스의 동적 값을 끄집어내 보자.

https://play.golang.org/p/0e1XTpjuXJ_e

드디어 우리는 구조체 Cube 타입인 변수 c에서 인터페이스 s에 숨겨진 값에 접근할 수 있게 되었다.

타입 단언(i.(Type))을 시도했을 때 Type이 인터페이스i를 구현하지 않았다면 Go 컴파일러는 에러를 반환할 것이다. 반대로 Type은 인터페이스 i를 구현했더라도 iType의 콘크리트 값을 갖고 있지 않는 경우, Go는 런타임 동안 패닉상태에 빠지게 된다. 다행히도 이것을 방지할 방법이 있다.

value, ok := i.(Type)

위 문법에서 ok 변수를 통해 Type이 인터페이스 i를 구현하였는지, i가 콘크리트 타입으로 Type을 갖는지 확인할 수 있다. 두 조건 모두 충족한다면 oktrue, 하나라도 충족하지 못한다면 okfalse가 되고 value는 구조체의 기본값으로 반환됩니다.

여기서 질문 하나. 인터페이스 아래 숨겨진 값이 또 다른 인터페이스를 구현했는지의 여부는 어떻게 알 수 있을까? 이것 역시 타입 단언으로 가능하다. 타입 단언 문법에서 Typeinterface라면, Go는 i의 동적 타입이 인터페이스 Type을 구현했는지 여부를 확인한다.

https://play.golang.org/p/Iu84WAzDEwx

Cube 구조체는 Skin 인터페이스를 구현하지 않았기 때문에, ok2false이고 value2nil이게 된다. 이 때 만약 조건을 충족하는지 판단하는 ok가 빠진 단축된 버전의 타입 단언(v := i.(type))을 사용했다면 프로그램은 에러와 함께 패닉 상태에 빠졌을 것이다.

panic: interface conversion: main.Cube is not main.Skin: missing method Color

⚠️ 알아두면 좋을 것: 인터페이스 타입을 띄는 구조체의 한 필드에 접근할 때도 타입 단언을 쓰는게 좋다. 인터페이스 타입에 선언되지 않은 필드에 접근을 시도하는 경우 런타입 패닉에 빠지게 되기 때문이다. 그러니 필요한 대부분의 경우에는 타입 단언을 사용하자.

☀️ 타입 단언은 인터페이스가 어떤 타입의 콘크리트 값을 갖는지 판단할 때 뿐만 아니라 한 인터페이스 타입을 띄는 변수를 다른 인터페이스 타입으로 변환할 때도 사용한다. 예를 들어, 한 구조체가 한 인터페이스를 구현했을 때 그 구조체는 타입 단언으로 해당 인터페이스 타입으로 변환이 가능하다. 여기서 예제를 확인해보자. 만약 인터페이스를 구현하지 않은 구조체를 타입 단언으로 넘길 경우 nil과 함께 okfalse가 된다.

☛ 타입 변환
이 글 초반에 빈 인터페이스와 그 용법을 확인했다. 아까 보았던 explain 함수를 예로 들어보자. explain 함수의 매개타입은 빈 인터페이스이기 때문에 함수에 어떤 것도 넘길 수 있다. 혹시 문자열이 함수에 넘어왔을 때에만 대문자로 변환하는 로직을 추가하고 싶다면 어떻게 할까? 물론 strings 패키지에서 제공하는 ToUpper 함수를 사용하면 되겠지만, explain 함수에 넘어온 매개타입 빈 인터페이스의 콘크리트 타입이 string임을 확인해야 한다.

이 때 타입 변환을 사용한다. 타입 변환 문법은 타입 단언 문법과 비슷한데, i.(type)에서 i는 인터페이스를, type에는 고정 키워드를 쓴다. 이를 사용해서 값 대신 인터페이스의 콘크리트 타입을 확인할 수 있다. 주의할 점은 타입 오직 switch 문에서만 동작한다는 것이다.

아래 예시를 보자.

https://play.golang.org/p/ItSSq3VDMbB

기존 explain 함수를 타입 변환을 사용하는 코드로 바꿔보았다. 어떤 타입이 들어오든 explain 함수가 호출 될 때 i값과 함께 동적 타입 을 받아온다. switch 조건문 안에서 string 타입이 들어온 경우, strings.ToUpper 함수를 사용했다. 오직 string 타입에만 적용해야 하기때문에, string 타입인 i 안에 숨겨진 값을 꺼내야 한다. 그래서 타입 변환을 사용했다.

nill 인터페이스는 nil 타입을 갖는다.

☛ 임베딩 인터페이스
Go에서는 한 인터페이스가 또 다른 인터페이스를 구현하거나 확장하는 것이 불가능하다. 하지만 2개 이상의 인터페이스를 합치는 새로운 인터페이스를 생성할 수 있다. Shape-Cube 프로그램을 수정해보자.

https://play.golang.org/p/s2U79IDaKqF

CubeAreaVolume 메소드를 구현했기 때문에 ShapeObject 인터페이스를 구현한 것이 된다. Meterial 인터페이스는 이 두 인터페이스를 합친 임베드 인터페이스이기 때문에, Cube는 마찬가지로 Meterial 인터페이스를 구현한 것이 된다. 이는 익명 중첩 구조체처럼 동작한다. 중첩된 인터페이스의 모든 메소드는 부모 인터페이스에 승급된다.

☛ 포인터 리시버 vs 값 리시버
지금까지 값 리시버를 구현한 메소드만 보았다. 당연히 인터페이스는 포인터 리시버를 받는 메소드를 선언하는 것도 가능하다.

https://play.golang.org/p/vEkRuYo1JKu

위 프로그램은 컴파일 에러를 반환한다.

program.go:27: cannot use Rect literal (type Rect) as type Shape in assignment: Rect does not implement Shape (Area method has pointer receiver)

무엇이 문제일까? 분명 구조체 타입 RectShape 인터페이스에 선언된 모든 메소드를 구현하였는데 왜 Rect does not implement Shape 에러가 반횐될까? 에러를 다시 한 번 주의깊게 읽어보자. Area method has pointer receiver. Area 메소드가 포인터 리시버를 받으면 뭐가 달라지는 걸까?

structs 강의글에서 보았듯이 포인터 리시버를 받는 메소드는 포인터와 값 모두에 잘 동작한다. 위 프로그램에서 단순하게 r.Area()를 호출했다면 문제없이 잘 동작했을 것이다.

하지만 인터페이스의 경우 메소드가 포인터 리시버를 받는다면 인터페이스는 동적 타입의 값이 아닌 동적 타입의 포인터를 갖게 된다. 그렇기 때문에 인터페이스 변수에 값을 할당하는 대신 포인터를 할당해야 한다. 이 개념에 따라 위 프로그램을 수정해보자.

https://play.golang.org/p/3OY4dBOSXdL

바뀐 부분은 25번째 줄 한군데 뿐이다. r의 값 대신 r의 포인터를 사용했다. s의 콘크리트 값은 이제 포인터이다. 잘 동작하는 것을 볼 수 있다!

☛ 인터페이스 사용하기
이번 글에서는 인터페이스와 다양한 활용법을 배웠다. 지금까지 배운 개념이 바로 다형성이다. 모든 타입의 변수를 매개변수로 받을 수 있는 Println 함수처럼 복수의 타입을 매개변수로 받아야 할 함수메소드가 필요할 때 인터페이스는 매우 유용하게 쓰인다. 바로

func Println(a …interface{}) (n int, err error)

이런 가변 길이 함수(variadic function)처럼.

복수의 타입이 동일한 인터페이스를 구현한다면 동일한 형태의 코드를 사용할 수 있게 되어 프로그램을 작성하는 일이 훨씬 쉬워진다. 그러니 인터페이스를 사용할 수만 있다면 무조건 사용하자.

--

--