Go 언어 - 성급한 추상화

검프·2021년 5월 1일
3

Go 언어 학습

목록 보기
8/9
post-thumbnail

The Ultimate Go Study Guide의 내용을 참고하여 작성했습니다.

소프트웨어를 설계할 때 구체적인 타입Concrete type을 이용한 높은 결합도Coupling^{Coupling} 대신 인터페이스를 이용한 느슨한 결합을 추구해야 합니다. 서브 타이핑을 이용한 다형성 제공은 부모 자식 타입 간의 결합도가 높아서 다양한 문제를 일으킬 수 있습니다. 반면 인터페이스를 이용한 다형성 제공은 결합도가 낮아져 변경에 보다 더 유연하게 대처할 수 있게 해줍니다.

그렇다면 모든 것을 인터페이스로 정의하여 구현하는 것은 바람직한 설계일까요? 여기서는 잘못된 인터페이스 사용 예제를 설명합니다.

인터페이스를 이용한 성급한 추상화

인터페이스를 잘못 사용 방법 중 하나가 성급한 추상화입니다. 인터페이스를 이용해서 설계를 했을때 디커플링Decoupling^{Decoupling}을 통해 얻은 이득이 확실하지 않다면 인터페이스를 사용하지 말아야합니다. 예제를 통해서 알아보겠습니다.

<1>에서 Server 인터페이스를 정의하고 있습니다. 인터페이스는 코드 사용자 입장에서 API가되는데 너무 많은 동작이 정의되어 있습니다. <2>에서 serverServer 인터페이스를 구현하고 있습니다.

package server

// <1>
type Server interface {
	Start() error
	Stop() error
	Wait() error
}

// <2>
type server struct {
	host string
}
...

<3>에서 Server 타입을 리턴하는 NewServer()라는 팩토리 함수를 정의합니다. 내보내기하지 않은 server 타입의 포인터를 인터페이스에 저장하여 리턴하는 냄새나는 코드입니다. 또한 Go 언어에서는 인터페이스를 리턴하도록 만드는 것을 냄새나는 코드로 취급하기도 합니다.

근거는 "인터페이스를 받아들이고 구조체를 리턴하라"Accept interfaces, return structs^{Accept\ interfaces,\ return\ structs} 라는 가이드가 있습니다. 함수 호출 시 인터페이스를 반환하는 이유는 사용자가 반환 값을 가지고 할 수 있는 행위를 표현하기 위해서입니다.

Go 언어에서는 암시적 인터페이스 구현을 지원하고 있습니다. 이 때문에 미리 추상화할 필요 없이 사후에 추상화 필요성이 생길 경우 우아한 방법으로 추상화하는 것이 가능합니다.

...
// <3>
func NewServer(host string) Server {
	return &server{host}
}

func (s *server) Start() error {
	return nil
}

func (s *server) Stop() error {
	return nil
}

func (s *server) Wait() error {
	return nil
}

<4>에서 srv가 인터페이스가 아닌 구체적인 타입이어도 아무런 문제가 없습니다. 이런 방시의 인터페이스 사용은 디커플링 같은 이점도 주지 않고 있습니다. 그냥 단순히 추상화 수준만 높여서 코드를 복잡하게 보이게 만들 뿐입니다.

func main() {
	// <4>
	srv := server.NewServer("localhost")
	srv.Start()
	srv.Stop()
	srv.Wait()
}

위 코드의 문제를 정리해보면,

  • 패키지는 server 구조체의 전체 기능과 동일한 기능을 정의한 Server 인터페이스를 정의합니다.
  • Server 인터페이스는 내보내기 하지만, server 구조체는 내보내기 하지 않습니다.
  • NewServer() 팩토리 함수는 반환값으로 내보내기 하지 않은 server 타입을 지정한 Server 인터페이스를 반환합니다.
  • Server 인터페이스를 제거해도 사용자 API에 변경 사항이 없습니다.
  • Server 인터페이스가 API 변경에 대해서 디커플링하고 있지 않습니다.

성급한 추상화 바로잡기

이제 위에서 살펴본 예제에서 인터페이스를 오용한 부분을 바로잡아 보겠습니다.

<1>에서 인터페이스를 사용하던 부분을 제거하고 Server 구조체를 내보내기 합니다. <2>에서 NewServer() 함수는 Server 구조체의 포인터를 반환합니다. 이후 나머지 코드에서는 Server 구조체를 바로 사용하는 것으로 인터페이스 오용을 바로잡았습니다.

package server

// <1>
type Server struct {
	host string
}

// <2>
func NewServer(host string) *Server {
	return &Server{host}
}

func (s *Server) Start() error {
	return nil
}

func (s *Server) Stop() error {
	return nil
}

func (s *Server) Wait() error {
	return nil
}
func main() {
	srv := server.NewServer("localhost")
	srv.Start()
	srv.Stop()
	srv.Wait()
}

참고 자료에서는 아래와 같은 경우에 인터페이스를 사용하라고 가이드하고 있습니다.

인터페이스를 사용해야 하는 상황

  • API 사용자가 API의 상세한 구현 요구사항을 제공하는 경우
  • API에 유지보수해할 여러 구현 내용이 포함된 경우
  • API에서 변경 가능한 부분이 분리되어 표현되어야 하는 경우

인터페이스를 사용할지 다시 고민해야 하는 경우

  • 테스트만을 위해서 인터페이스를 설계하는 경우
  • API가 변경되지 않도록 인터페이스가 설계되지 않은 경우
  • 인터페이스를 사용하므로인해 얻어지는 코드 개선 이점이 뚜렷하지 않은 경우

개인적으로는 인터페이스를 이용해서 더 적극적으로 추상화를 해야한다고 생각하는 경우가 더 있습니다.

  • 사용하고 있는 프레임워크 등에서 제공하는 관례가 뚜렷한 경우 추상화 이점이 크지 않더라도 인터페이스를 사용하는 것이 커뮤니케이션을 용이하게하여 결국 유지보수하기 쉬운 코드가된다.
  • 개발하고자하는 도메인에 대한 이해와 통찰이 깊은 상태라면 개발 초기부터 추상화에 노력을 기울여도 좋다.

익스트림 프로그래밍XP의 원칙 중 필요하다고 간주할 때까지 기능을 추가하지 않는 것이 좋다YAGNI(You aren't gonna need it)는 원칙이 있습니다. 추상화에도 아래와 같이 인용해 볼 수 있겠네요.

실제로 필요할 때 추상화하고, 필요하다고 예상될 때는 절대로 추상화하지 마세요.


참고자료

profile
권구혁

1개의 댓글

comment-user-thumbnail
2021년 5월 1일

발표를 들으며 항상 배웁니다. 좋은 발표 감사합니다 ❤️

답글 달기