Mover
는 움직이는 것을 나타내기 위해 다음과 같이 정의합니다.
type Mover interface {
Move()
}
Locker
는 잠그고(locking) 해제(unlocking)할 수 있는 것을 나타냅니다.
type Locker interface {
Lock()
Unlock()
}
MoveLocker
는 움직이거나 잠글 수 있는 것을 나타냅니다.
type MoveLocker interface {
Mover
Locker
}
구체적인 예시를 위해 bike
라는 타입을 정의합니다.
type bike struct {}
Move
는 bike
를 움직입니다.
func (bike) Move() {
fmt.Println("Moving the bike")
}
Lock
은 bike
가 움직이지 못하게 합니다.
func (bike) Lock() {
fmt.Println("Locking the bike")
}
Unlock
을 하면 bike
는 다시 움직일 수 있습니다.
func (bike) Unlock() {
fmt.Println("Unlocking the bike")
}
func main() {
MoverLocker
와 Mover
인터페이스 타입의 변수를 선언합니다. zero value로 초기화 됩니다.
var ml MoveLocker
var m Mover
bike
값을 생성하여 MoveLocker
인터페이스 타입 변수에 대입합니다.
ml = bike{}
MoveLocker
인터페이스 타입 변수는 Mover
인터페이스 타입 변수로 변환할 수 있습니다. 둘 모두 move
라는 메쏘드를 정의했기 때문입니다.
m = ml
하지만, 아래와 같이 반대로는 불가능합니다.
ml = m
컴파일을 하면 다음과 같은 에러가 발생합니다.
cannot use m (type Mover) as type MoveLocker in assignment:
Mover does not implement MoveLocker (missing Lock method).
인터페이스 타입 Mover
는 lock
과 unlock
메서드를 정의하고 있지 않다. 따라서, 컴파일러는 인터페이스 Mover
타입의 변수를 MoveLocker
타입의 변수로 암묵적으로 변환할 수 없다. Mover
인터페이스 변수의 실제 값이 MoveLocker
인터페이스를 구현한 bike
타입의 값이라 해도 변환하지 않는다. 런타임때 타입 단언을 사용하여 명시적으로 변환할 수는 있다.
아래와 같이 Mover
인터페이스의 값을 타입 단언을 사용해 bike
타입의 값으로 변환 후 복사한다. 복사된 값을 MoveLocker
인터페이스 변수에 배정한다. 아래 코드가 타입 단언의 문법이다. 인터페이스 값에 인터페이스값.(bike)
처럼 점(.)에 파라미터로 bike
값을 전달한다. m
이 nil
이 아닌 bike
타입의 값이 들어있을 경우, 포인터가 아닌 값을 넘겨받았기(value semantics) 때문에 m
을 복사한 값을 얻게 된다. 그렇지 않을 경우 panic
이 발생하게 된다. 아래 예시에서 b
는 bike
의 복사된 값을 가지고 있다.
b := m.(bike)
타입 단언의 성공 여부를 나타내는 boolean
값을 받아 panic
을 예방 할 수도 있다.
b, ok := m.(bike)
fmt.Println("Does m has value of bike?:", ok)
ml = b
Does m has value of bike?: true
타입 단언의 문법을 통해 인터페이스 변수에 실제 저장된 값의 타입이 무엇인지 알 수 있다. 캐스팅을 사용하는 다른 언어에 비해 가독성 관점에서 큰 장점이라고 할 수 있다.
package main
import (
"fmt"
"math/rand"
"time"
)
car
는 무엇가 운전할 수 있는 것을 의미한다.
type car struct{}
String
은 fmt.Stringer
인터페이스를 구현한다.
func (car) String() string {
return "Vroom!"
}
cloud
는 정보를 저장해 둘 장소를 의미한다.
type cloud struct{}
String
은 마찬가지로 fmt.Stringer
인터페이스를 구현한다.
func (cloud) String() string {
return "Big Data!"
}
랜덤 함수에 사용될 Seed
값을 정한다.
func main() {
rand.Seed(time.Now().UnixNano())
Stringer
인터페이스를 가지는 슬라이스를 생성한다.
mvs := []fmt.Stringer{
car{},
cloud{},
}
아래와 같은 코드를 10번 반복해보자.
for i := 0; i < 10; i++ {
rn := rand.Intn(2)
아래와 같이 랜덤으로 생성된 숫자를 통해 cloud
에 대한 타입 단언을 실행한다. 아래 예시는 타입 단언이 컴파일 때가 아닌 런타임때 실행된다는 것을 알 수 있다.
if v, ok := mvs[rn].(cloud); ok {
fmt.Println("Got Lucky:", v)
continue
}
x
라는 변수가 있으면 x.(T)
를 통해 T
타입으로 단언 될 수 있는지 확인해줘야 한다. 아니면 무결성 등의 이유로 panic
하길 원할 경우라면 ok
변수를 사용하지 않을 것이다. panic
으로부터 회복할 수 없으면 프로그램은 종료될 것이고 재시작해야 한다.
프로그램이 종료된다는 의미는 스택 트레이스가 출력되는 log.Fatal
, os.exit
혹은 panic
함수를 호출했다는 것이다. 타입 단언을 사용할 때는, 요청하는 타입의 값이 들어있지 않아도 괜찮은지 확인해야 한다.
타입 단언을 사용해 구체적인 타입의 값을 꺼낼 경우, 주의해서 사용한다. 디커플링의 레벨을 유지하기 위해 인터페이스를 사용했는데 타입 단언을 사용해 다시 이전으로 돌아가기 때문이다.
구체적인 타입을 사용할 경우 연관 있는 많은 코드를 동시에 리팩토링을 해야 될 수도 있다는 것을 알아야 한다. 반대로 인터페이스를 사용할 경우 내부 구현이 변해도 그로 인해 발생하는 변경점들은 최소화 할 수 있다.
fmt.Println("Got Unlucky")
}
}
Got Unlucky
Got Unlucky
Got Lucky: Big Data!
Got Unlucky
Got Lucky: Big Data!
Got Lucky: Big Data!
Got Unlucky
Got Lucky: Big Data!
Got Unlucky
Got Unlucky
소프트웨어를 설계할 때, 구체적인 타입이 아닌 인터페이스부터 설계한다. 인터페이스를 사용하는 이유는 무엇일까?
미신 #1: 인터페이스를 사용해야하기 때문에 인터페이스를 사용하고 있다.
답: 아니오. 인터페이스를 사용할 필요가 없다. 합리적이고 실용적일 때 인터페이스를 사용해야 한다.
인터페이스를 사용하는 데는 비용이 든다. 구체적인 타입을 인터페이스 타입으로 사용 될때 잠재적 할당 비용과 추상화 비용이 그것이다. 디커플링에 그만한 비용의 가치가 없다면 인터페이스를 사용해서는 안된다.
미신 #2: 코드를 테스트 하기 위해 인터페이스를 사용해야 한다.
답: 아니오. 테스트가 아니라 개발자를 우선하여 애플리케이션에 사용할 수 있는 API를 설계해야한다.
다음은 필요하지 않은 인터페이스를 사용하여 인터페이스 오염을 생성하는 예이다.
Server
는 TCP 서버에 대한 계약을 정의한다. 이것은 약간의 코드 악취에 해당하는데 이것은 사용자에게 노출 될 API이고 하나의 인터페이스에 넣기에 많은 동작이다.
type Server interface {
Start() error
Stop() error
Wait() error
}
server
는 Server
인터페이스를 구현한다. 이름이 일치하지만 꼭 나쁘다고 할 수 는 없다.
type server struct {
host string
}
NewServer
는 인터페이스 Server
타입을 리턴하는 팩토리 함수이다. 인터페이스를 반환함으로 코드스멜로 볼 수 있다.
함수나 인터페이스가 꼭 인터페이스 값을 반환하지 못하는 건 아니다. 반환해도 된다. 하지만, 보통은 주의해야 한다. 구체적인 타입이 동작을 가지고 있는 데이터이며 인터페이스는 그런 데이터를 받는 인풋으로써 사용되어야 한다.
코드 악취 - Export 되지 않은 타입 포인터를 인터페이스에 저장함
func NewServer(host string) Server {
return &server{host}
}
Start
는 서버를 시작해 요청을 받기 시작한다. 여기서는 실제 구현이 되있다고 가정한다.
func (s *server) Start() error {
return nil
}
Stop
은 서버를 멈춥니다.
func (s *server) Stop() error {
return nil
}
Wait
은 서버가 새로운 연결을 받지 않고 대기하도록 한다.
func (s *server) Wait() error {
return nil
}
func main() {
새로운 Server
를 생성한다.
srv := NewServer("localhost")
API를 사용한다.
srv.Start()
srv.Stop()
srv.Wait()
}
위 코드에서 srv
가 인터페이스가 아닌 구체적인 타입이었다면 아무 문제도 없을 것이다. 여기서 인터페이스는 디커플링 같은 어떠한 이점도 가져다 주지 않는다. 그저 추상화 수준을 높여 코드를 복잡하게 만들 뿐이다.
위 코드는 문제가 있는데 왜냐하면:
- 패키지가 구체적인 타입의 모든 API를 가지는 인터페이스를 선언하고 있다.
- 인터페이스는 export 되지만 구체적인 타입은 그렇지 않다.
- 팩토리 함수가 export 되지 않은 타입을 가지고 있는 인터페이스를 반환한다.
- 인터페이스를 없더라도 API에서 달라지는 점이 없다.
- 인터페이스가 API 변화에 잘 대응할 수 있도록 디커플링하고 있지 않다.
이전에 나온 예시에서 잘못된 인터페이스 사용을 고쳐보도록 하겠다.
Server
의 구현이다.
type Server struct {
host string
}
NewServer
는 Server
의 포인터를 반환한다.
func NewServer(host string) *Server {
return &Server{host}
}
Start
가 호출되면 서버가 리퀘스트를 받기 시작한다.
func (s *Server) Start() error {
return nil
}
Stop
는 서버를 멈춘다.
func (s *Server) Stop() error {
return nil
}
Wait
는 새로운 연결이 생성되는것을 막는다.
func (s *Server) Wait() error {
return nil
}
새로운 Server
를 생성한다.
func main() {
srv := NewServer("localhost")
API를 사용한다.
srv.Start()
srv.Stop()
srv.Wait()
}
인터페이스 오염을 피하기 위한 가이드라인
인터페이스를 다음과 같은 상황에서 사용한다:
- 유저가 API의 실제 구현 디테일을 작성한다.
- API가 유지보수가 필요한 다양한 구현을 가지고 있다.
- API의 일부분이 변화할 수 있고 디커플링을 필요로 할때 사용한다.
다음과 같은 상황에서 인터페이스를 사용할지 다시 한번 생각해본다:
- 오직 테스트를 위해서만 사용한다.
- 변화로부터 쉽게 대응할 수 없다.
- 인터페이스가 코드를 더 좋게 만들어주지 않는다.
Mocking은 중요하다. 네트워크에서 발생하는 대부분의 것들은 mock할 수 있다. 하지만, 데이터베이스를 mocking하는 것은 매우 복잡하기에 mock하기가 어렵다. 하지만 Docker를 사용하면 테스트를 위한 데이터베이스를 깔끔하게 생성할 수 있다.
모든 API는 테스트에만 집중하여야 한다. 더이상 애플리케이션 유저에 관해 걱정하지 않아도 된다. 이전에는 인터페이스가 없으면 유저 입장에서 테스트를 작성할 수 없었지만 이제는 아니다. 아래 예시가 그 이유를 보여준다.
Go를 사용하기로 결정한 회사에서 일한다고 가정해보자. 사내에는 모든 애플리케이션이 사용하는 pubsub 시스템을 가지고 있다. 아마도 이벤트소싱을 사용 하고 있고 pubsub 플랫폼은 대체되지 않을 것이다. 이런 이벤트소스에 연결하여 서비스를 만들기위해 Go 용 pubsub API가 필요하다.
우선 무엇이 변할 수 있는가? 이벤트소스가 변할 수 있을까?
만약 답이 '아니오'라면, 인터페이스를 사용할 이유가 없다. 그렇다면 모든 API를 구체적인 타입으로 작성할 것이다. 그리고 테스트를 작성하여 정상적으로 작동하는지 확인할 것이다.
며칠이 지난 후, 사용자들에게 문제가 발생하였다. 테스트를 작성해야 하는데 pubsub 시스템을 직접 호출할 수 없어서 mock을 사용해야 한다는 것이다. 그래서 사용자들은 인터페이스를 제공해주기를 원한다. 하지만, 현재 API는 인터페이스를 필요로 하고 있지 않다. 사용자들이 필요한 것이고 우리가 필요한 것이 아니다. 우리가 아닌 사용자가 pubsub 시스템을 분리시켜야 한다.
Go를 사용하기 때문에 이러한 분리가 가능하다. 다음 파일은 사용자 애플리케이션을 예시로 보여준다. pubsub 패키지는 pubsub 서비스를 시뮬레이션하는 패키지이다.
package main
import (
"fmt"
)
PubSub
는 큐(queue) 시스템에 접근할 수 있게 한다.
type PubSub struct {
host string
}
New
는 pubsub을 사용하기 위한 값을 반환한다.
func New(host string) *PubSub {
ps := PubSub{
host: host,
}
return &ps
}
Publish
는 특정 키에 데이터를 전송한다.
func (ps *PubSub) Publish(key string, v interface{}) error {
fmt.Println("Actual PubSub: Publish")
return nil
}
Subscribe
는 특정 키값으로부터 메시지를 수신한다.
func (ps *PubSub) Subscribe(key string) error {
fmt.Println("Actual PubSub: Subscribe")
return nil
}
아래는 패키지나 테스트를 위해 mock 객체를 어떻게 생성하는지 보여준다.
package main
import (
"fmt"
)
publisher
인터페이스로 pubsub
패키지를 mock을 가능케 한다. 애플리케이션을 작성할 때 필요한 모든 API를 정의하는 인터페이스를 선언한다. 이전 파일에 나온 구체적인 타입들이 이 인터페이스를 이미 구현하고 있다. 이제 여기서 구체적인 구현 없이 mocking을 통하여 애플리케이션 전체를 작성할 수 있다.
type publisher interface {
Publish(key string, v interface{}) error
Subscribe(key string) error
}
mock
은 pubsub
패키지를 mocking 하기 위한 구체적인 타입이다.
type mock struct{}
Publish
메쏘드는 publisher
인터페이스를 구현한다.
func (m *mock) Publish(key string, v interface{}) error {
// ADD YOUR MOCK FOR THE PUBLISH CALL.
fmt.Println("Mock PubSub: Publish")
return nil
}
Subscribe
메쏘드는 publisher
인터페이스를 구현한다.
func (m *mock) Subscribe(key string) error {
// ADD YOUR MOCK FOR THE SUBSCRIBE CALL.
fmt.Println("Mock PubSub: Subscribe")
return nil
}
publisher
인터페이스 슬라이스를 생성한다. 그리고 pubsub
의 주소를 부여한다. mock
의 주소값도 추가한다.
func main() {
pubs := []publisher{
New("localhost"),
&mock{},
}
인터페이스 슬라이스를 순회하면서 publisher
인터페이스가 어떻게 디커플링(decoupling)을 하는지 볼 수 있다. pubsub
패키지가 인터페이스를 제공할 필요가 없는 것을 볼 수 있다.
for _, p := range pubs {
p.Publish("key", "value")
p.Subscribe("key")
}
}