Go 100가지 실수를 배워보자 1일차

0

go 100 mistake

목록 보기
1/1

Golang은 어렵다

go언어에서는 일반적으로 다른 언어들이 지원하는 기능들을 제공하지 않는 경우들이 있다. 가령 exception, macro, 삼항 연산자, lazy evaluation 등이 있다. go에서는 이러한 기능을 제공하지 않는 가장 큰 이유를 다음과 같이 답변했다.

원하는 기능을 go에서 제공하지 않는다면, 그 기능은 go언어에 어울리지 않거나 컴파일 속도나 설계의 명료함을 떨어뜨리거나, 기반 시스템 모델을 필요 이상으로 복잡하게 만들기 때문이다.

이 부분만 봐도 느껴지는 go의 철학 중 하나는, 기능의 가지 수가 전부가 아니라는 것이다. 'go의 단점'을 검색하면 'exception이 없다', '삼항 연산자가 없다', '상속이 없다' 등을 단점으로 나열하는데, 사실 이는 go를 정말 잘못 이해하고 쓰는 사람들이 쓴 글이다. go를 쓰면서 go스러운 code를 만들어야하고 사고를 가져야하는데, go에는 없지만 java나 python에 있는 방식으로 코딩하려고 하니까 code가 엉망이 되어버리는 것이다.

go에서는 몇 가지 핵심 특성을 최대한 활용하여 광범위하게 적용하는 것을 추구한다. 이를 다음과 같은 속성으로 표현할 수 있다.

  1. 안정성(stability): go는 개선 및 보안 패치에 대해서 업데이트가 꽤 빈번하지만, 안정성을 유지하고 있다.
  2. 표현력(expressivity): go는 적은 키워드와 제한된 방법으로 주요 문제를 해결하는 능력을 볼 때 대규모 코드 베이스에서도 표현력이 뛰어나다.
  3. 컴파일(compilation): go는 한결같이 빠른 컴파일 시간을 지향하고 있다. 이는 프로그래밍에 있어서 컴파일 시간의 향상이 생산성 향상으로 이어진다고 생각하기 때문이다.
  4. 안정성(safety): go언어는 정적 타입 기반의 강력한 언어이다. 그래서 엄격한 컴파일 규칙에 따라 타입에 안전한 코드를 생성하도록 보장한다.

go언어는 simple함을 추구하고 있다. 적은 키워드를 제공하고 사용에 있어서 편리함을 추구하기 때문에 처음 go를 접한 사람도 며칠 안에 go의 주요 기능들을 익힐 수 있고, REST API 서버 등 app을 만들 수 있다.

그러나, 대다수가 이렇게 go를 얍잡아보고 건드렸다가, 프로젝트가 과중해지거나 복잡해지면 go에 대해서 욕을 하고 돌아선다. 필자는 이러한 현상에 대해 나름 분석했는데, 대부분 go에 대해서 '공부'를 안해서 발생하는 문제였다.

왜 다들 go는 공부하지 않을까? 그 이유가 바로 go의 가장 큰 특징인 simple함에 있다. 문법이 simple하고 어렵지 않으니까, 자신이 go에 대해서 전부 알고 있다고 생각하고, go에 대해서는 잘 공부하지 않는다. 또한, 다른 언어와 같이 특정 프로그래밍 패러다임 이론에 기반하여 만들어진 것이 아니기 때문에 표준과 같은 방식이 없는 것도 한 몫한다.

다른 언어의 경우는 전해져 내려오는 바이블 같은 것들이 있다. 그 바이블 대로 코딩하는 것이 정답이고, 최적화된 방법이라는 것이다. 그러나, go에서는 그러한 바이블이 딱히 없다. 그렇기 때문에 go는 simple한 문법을 가지지만, 굉장히 어려운 언어이다. 개발자의 사고 과정이 그대로 코드에 적히게 되고, 이는 그 동안 바이블로 가려진 자신의 실력이 여실히 드러나게 된다.

1~2년 간 go로 project를 했는데, 그 결과가 이상하거나 단점 투성이라면 go가 문제가 아니라, 자신의 순수한 프로그래밍 실력을 의심해보면 된다.

'Simple doesn't mean easy'

'간단하다고, 쉽다는 것은 아니다' 이 말은 go언어의 다양한 영역에 적용할 수 있다.

그렇기 때문에 우리는 실수를 통해서 go언에 대해 깊이 배워나가야한다. 그렇다면 '실수'라는 것은 무엇을 의미하는 것일까??

  1. 버그: 버그는 소프트웨어 버그로, race condition, memory leak, logic error 등 여러가지 버그들을 말한다.
  2. 과도한 복잡도: 너무 복잡한 경우이다. 이러한 이유의 대부분은 가상의 미래를 상상하여 코드를 주어진 문제를 해결하는 방법으로 만들기 보다 너무 미래 지향적으로 만들어서 코드가 복잡해지는 경우이다. 대표적으로 interface와 generic의 남용인데, interface로 만들었지만, 실상은 다형성이 필요없는 경우였거나, generic이 필요없는 데 남용하는 경우가 있다.
  3. 낮은 가독성: 클린 코드에서는 읽는 데 드는 시간이 쓰는 데 드는 시간보다 10배 이상이 걸린다고 한다. go에서는 중첩된 코드, 데이터 타입 표현, 갤과 매개변수의 이름 등 가독성을 해치는 경우들이 많다.
  4. 최적이 아니거나, 관례에 어긋난 구성: 프로젝트를 최적화하지 않거나 관례에 따르지 않는 것인데, 대부분 잘못된 습관으로부터 비롯된다. 가령 개발자가 이전에 자신이 배운 java나 python, c에서의 관례를 go에 대입해놓고 'go가 문제'라고 하는 경우들이 있다. 오죽하면 그런 사람들을 위한 책들이 존재할 정도이다.
  5. API 편의성 부족: client는 고려도 안한 API의 경우 편리성이 떨어져 표현력이 떨어지고 이해하기가 어려워지고 에러가 쉽게 발생한다. 가령 any 타입을 남용하거나 옵션을 처리하는데 잘못된 생성 패턴을 적용하거나, 객체 지향 프로그래밍의 표준 관계를 맹목적으로 찬양하여 API 사용성에 문제가 생기는 경우들이 있다.
  6. 최적화되지 않은 코드: go에 대한 기능을 제대로 이해하지 못하거나, 기초가 부족하기 때문일 수도 있다.
  7. 생산성 부족: 작성한 코드가 제대로 작동하는 지 효율적으로 확인하는 테스트 코드 작성 방법, 표준 라이브러리를 효과적으로 사용하는 방법 등이 있다.

이제 100가지 실수들에 대해서 배워보도록 하자.

1. 의도하지 않은 variable shadowing에 조심하자

변수의 스코프(scope)란 변수를 참조할 수 잇는 위치를 말한다. go에서는 블록안에 선언된 변수를 하위 블록에서 다시 선언할 수 있다. 이를 변수 가림(variable shadowing)이라고 한다.

아래의 예제를 보도록 하자.

package main

import (
	"fmt"
	"net/http"
)

func main() {
	makeHttpClient()
}

func makeHttpClient() error {
	var client *http.Client
	tracing := true

	if tracing {
		client, err := createClientWithTracing()
		if err != nil {
			return err
		}
		client.Timeout = 100
	} else {
		client, err := createDefaultClient()
		if err != nil {
			return err
		}
		client.Timeout = 100
	}

	fmt.Println(client)

	return nil
}

func createClientWithTracing() (*http.Client, error) {
	return http.DefaultClient, nil
}

func createDefaultClient() (*http.Client, error) {
	return http.DefaultClient, nil
}

clietn 변수를 다른 함수로 부터 받는 코드인데, 실행하면 아래와 같은 결과가 발생한다.

<nil>

즉, 함수로부터 http.Client`를 못 받은 것이다. 왜 이러한 문제가 생기는 것일까??

문제가 되는 코드를 보도록 하자.

func makeHttpClient() error {
	var client *http.Client
	tracing := true

	if tracing {
		client, err := createClientWithTracing()
		if err != nil {
			return err
		}
		client.Timeout = 100
	} else {
		client, err := createDefaultClient()
		if err != nil {
			return err
		}
		client.Timeout = 100
	}

	fmt.Println(client)

	return nil
}

잘보면 makeHttpClient block안에 하위 block으로 :=을 실행하여 client를 생성하고 있다. 이는 상위 block인 client가 아니라 하위 block에 새로운 변수인 client를 생성한다는 의미인 것이다.

즉, 상위 block에 있는 client가 하위 block에서 생성한 client에 의해서 가려지는 현상으로 이를 variable shadowing이라고 하는 것이다.

따라서, 바깥 block에 있는 변수는 계속 nil상태로 남는다.

이 문제를 해결하려면 두 가지 방법이 있다.

첫번째 방법은 내부 블록에 다음과 같이 임시 변수를 사용하는 것이다.

func makeHttpClient() error {
	var client *http.Client
	tracing := true

	if tracing {
		c, err := createClientWithTracing()
		if err != nil {
			return err
		}
		c.Timeout = 100
		client = c
	} else {
		c, err := createDefaultClient()
		if err != nil {
			return err
		}
		c.Timeout = 100
		client = c
	}

	fmt.Println(client)

	return nil
}

상위 block의 client를 하위 block의 c로 할당해주는 것이다. 이렇게 두면 variable shadowing이 생기지 않는다.

두번째 방법은 :=로 생성, 대입 연산자를 사용하지 않고 대입 연산자 =만 사용하는 방법이다.

func makeHttpClient() error {
	var client *http.Client
	var err error
	tracing := true

	if tracing {
		client, err = createClientWithTracing()
		if err != nil {
			return err
		}
		client.Timeout = 100
	} else {
		client, err = createDefaultClient()
		if err != nil {
			return err
		}
		client.Timeout = 100
	}

	fmt.Println(client)

	return nil
}

하위 block에서는 생성한 변수가 없으므로 상위 block의 clienterr에 그 결과가 대입된다.

변수 shadowing 문제가 가장 잘 발생하는 경우가 바로 ㅈerror을 반환받아 처리할 때이다. 가끔식 shadowing되어 처리되기 때문에 변수가 먹어들어가게 된다.

2. 필요 이상으로 코드를 중첩하지 마라

소프트웨어에서 mental model이란 system 동작을 머릿속에 표현한 것을 말한다. 코드 가독성이 높을 수록 mental model을 관리하는 과정에서 코드를 이해하는 인지 노력(cognitive effort)가 적어진다. 그래서 코드를 파악하고 유지 보수하기가 더 쉬운 것이다.

중첩 수준은 가독성을 평가하는 데 중요한 요소이다. 가령, 프로젝트를 새로 시작할 때 다음 join함수를 파악해야한다고 해보자.

func main() {
	s1 := "hello"
	s2 := "world"
	ret, err := join(s1, s2, 7)
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println(ret)
}

func concatenate(s1, s2 string) (string, error) {
	return s1 + s2, nil
}

func join(s1, s2 string, max int) (string, error) {
	if s1 == "" {
		return "", errors.New("s1 is empty")
	} else {
		if s2 == "" {
			return "", errors.New("s2 is empry")
		} else {
			concat, err := concatenate(s1, s2)
			if err != nil {
				return "", err
			} else {
				if len(concat) > max {
					return concat[:max], nil
				} else {
					return concat, nil
				}
			}
		}
	}
}

기능에 에러는 없지만, 중첩 단계가 너무 많아 mental model에 좋지 않다. 이는 곧 가독성이 좋지 않다는 것을 의미한다.

이번에는 같은 기능을 좀 다르게 작성해보자.

func join(s1, s2 string, max int) (string, error) {
	if s1 == "" {
		return "", errors.New("s1 is empty")
	}
	if s2 == "" {
		return "", errors.New("s2 is empry")
	}

	concat, err := concatenate(s1, s2)
	if err != nil {
		return "", err
	}

	if len(concat) > max {
		return concat[:max], nil
	}

	return concat, nil
}

이 코드는 기능은 이전과 같지만 mental model 유지에 드는 인지 노력이 훨씬 적다. 중첩 수준이 두 단계뿐이기 때문이다. mat ryer는 다음과 같이 말했다.

정상 경로(happy path)는 왼쪽으로 정렬한다. 그러면 첫 번째 열만 살짝 훑어봐도 예상대로 실행되는 지를 쉽게 파악할 수 있다.

위의 예제에서 happy path가 바로 if문을 통과하지 않는 경우이고, 예외에 대한 처리는 if문을 통과한 경우들이다.

정리하면 함수 구현 코드에서 중찹 단계가 많을수록 이해하기 어려워진다. 따라서 최대한 지워주는 것이 좋다.

if블록에 반환문이 있다면, else 블록 전체를 생략한다.

이는 다음을 말하는 것이다.

if s1 == "" {
    return "", errors.New("s1 is empty")
} else {
    return s1, nil
}

이라면 else를 지울 수 있따.

if s1 == "" {
    return "", errors.New("s1 is empty")
} 

return s1, nil

이렇게 가독성을 좋게 만들어 mental model을 높이는 것이 좋다.

3. init 함수를 잘못 사용하지 마라

init 함수는 application 상태를 초기화하는 데 사용한다. 인수를 받지 않고 결과를 리턴하지도 않는다. 패키지를 초기화할 때, 그 안에 선언된 상수와 변수를 모두 평가(evaluation)하고 나서 init함수가 호출된다. 가령 다음과 같이 main 패키지를 초기화하는 과정을 살펴보자.

  • log/log.go
package log

import "fmt"

func init() {
	fmt.Println("log start")
}

func Hello() {
	fmt.Println("hello")
}
  • main.go
package main

import (
	"fmt"
	"gyu/log"
)

func init() {
	fmt.Println("init")
}

var a = func() int {
	fmt.Println("var")
	return 0
}()

func main() {
	log.Hello()
	fmt.Println("main")
}

결과는 다음과 같다.

log start
var
init
hello
main

이를 통해서 알 수 있는 init 함수의 실행 순서는 다음과 같다.

  1. package import을 먼저 실행하여 package init 함수 실행
  2. 전역 변수, 상수에 대해서 먼저 초기화
  3. init 함수 실행
  4. main 함수 실행

만약 한 패키지에 init 함수를 여러 개 정의할 수도 있는데, 그러면 패키지에 있는 init 함수들은 source 파일의 alphabet 순서대로 실행된다. 가령, log 패키지에 있는 a.gob.go 모두 init 함수가 있다면 a.goinit함수가 먼저 실행되고 b.goinit함수가 실행된다.

따라서, 패키지 안에 init함수를 여러 개 작성할 때는 각각이 실행되는 순서에 영향을 받지 않게 작성해야한다. 소스 파일 이름은 언제든지 바뀔 수 있고, 그러면 실행 순서도 달라지기 때문이다.

한 소스 파일 안에 init 함수를 여러 개 정의할 수도 있다. 이 경우는 순서대로 실행된다.

init 함수를 이용하여 부수 효과(side effect)를 얻을 수도 있는데 database를 연동하려고 하는 경우 많이들 썼었던 패턴일 것이다.

  • db/data.go
package db

import "fmt"

func init() {
	fmt.Println("DB On")
}
  • main.go
package main

import (
	"fmt"
	_ "gyu/db"
)

func main() {
	fmt.Println("main")
}

결과는 다음과 같다.

DB On
main

_ "gyu/db"와 같은 패턴은 db package에 있는 함수나 구조체를 참조하진 않지만, init만은 실행하고 싶을 때 사용한다.

init을 사용할 때는 매우 조심해야하는데, 잘못된 참조로 의존 관계가 꼬일 수도 있으며 code의 mental model이 매우 복잡해질 수 있기 때문이다. 다음의 잘못된 예를 보도록 하자.

var db *sql.DB

func init() {
	dsn := os.Getenv("MYSQL_DATA_SOURCE_NAME")
	d, err := sql.Open("mysql", dsn)

	if err != nil {
		fmt.Println(err)
	}

	err = d.Ping()
	if err != nil {
		log.Panic(err)
	}

	db = d
}

위 예제는 데이터베이스를 오픈하고 ping을 할 수 있는지 확인한 뒤, DB connection을 글로벌 변수에 저장한다. 이 코드에 대해서 3가지 문제를 지적할 수 있다.

  1. init 함수는 에러 관리 기능이 부족하다. init 함수는 에러를 반환하지 않기 때문에 에러 발생을 알리는 유일한 방법은 panic뿐이다. 즉, database 연결에 실패하면 app을 다운시켜야한다는 것이다.
  2. test하기가 어렵다. init함수가 매번 실행되어 test에 있어서 test 타겟보다도 test 준비시간이 오랜 시간 걸릴 수 있따.
  3. database connection pool을 글로벌 변수에 저장해야 한다는 것이다. 글로벌 변수는 다음과 같은 단점이 있다.
    1. 패키지 안에 있는 함수라면 누구나 글로벌 변수 값을 바꿀 수 있다.
    2. 글로벌 변수에 의존하는 함수를 격리시킬 수 없기 때문에 단위 테스트를 작성하기 까다로워 진다.

변수는 글로벌로 만들기 보다는 캡슐화하는 것이 대체로 좋다. 따라서, 다음과 같이 일반 함수로 빼내서 처리하는 것이 좋다.

func createClient(dsn string) (*sql.DB, error) {
	d, err := sql.Open("mysql", dsn)

	if err != nil {
		fmt.Println(err)
	}

	err = d.Ping()
	if err != nil {
		log.Panic(err)
	}

	return d, nil
}

이렇게 함수로 빼내어버리면 3가지 단점을 해결할 수 있다.

  1. 에러 처리의 책임을 호출자에게 줄 수 있따.
  2. 이 함수가 제대로 작동하는지 확인하는 통합 test를 작성할 수 있다.
  3. connection pool이 함수 안에 캡슐화되어 있다.

그렇다면 init 함수를 무조건 제거해야할까?? 꼭 그런 것은 아니다. init 함수가 유용한 경우도 꽤 있다. 가령 go 공식 블로그 에서는 정적 HTTP 설정을 init 함수에서 처리하도록 한다.

func init() {
	redirect := func(w http.ResponseWriter, r *http.Request) {
		http.Redirect(w, r, "/", http.StatusFound)
	}

	http.HandleFunc("/blog", redirect)
	http.HandleFunc("/blog/", redirect)

	static := http.FileServer(http.Dir("static"))
	http.Handle("/favicon.io", static)
	http.Handle("/font.css", static)
	http.Handle("/fonts/", static)
	http.Handle("/lib/godoc", http.StripPrefix("/lib/godoc/", http.HandlerFunc(staticHandler)))
}

이 예제에서는 init 함수에서 에러가 발생할 일이 없다. 또한, 글로벌 변수가 필요없고 그래서 단위 테스트에도 영향을 주지 않는다.

지금까지 살펴본 내용을 정리하면 init함수는 다음과 같은 한계가 있다.
1. 에러 관리 능력이 부족하다.
2. 테스트 코드를 작성하기 어렵다.(단위 테스트 스코프와는 직접 관련이 없는 외부 의존성이 발생할 수 있다.)
3. 초기화 과정에서 상태를 결정해야 할 경우 글로벌 변수로 처리해야 한다.

정리하자면 대 부분의 초기화 작업은 별도의 함수를 만드는 것이 좋다.

4. Getter와 Setter를 남용하지 말자

go에서는 다른 언어처럼 getter, setter 함수를 따로 만들어주지 않는다. getter와 setter를 쓰지 않고도 구조체 필드에 접근할 수 있는데, 가령 표준 라이브러리 중에서 time.Timer 구조체 처럼 필에 직접 접근하도록 구현한 것도 있다.

timer := time.NewTimer(time.Second)
<-timer.C

C<-chan Time field로 timer 구조체의 field이다. 이처럼 구조체에 접근하는데 별다른 setter와 getter없이 접근이 가능하다는 것을 볼 수 있다. go에서는 field를 수정하면 안되는 부분에서조차 getter와 setter를 사용하지 않고 접근할 수 있다.

반면, getter나 setter를 사용하면 다음과 같은 장점이 있다.
1. field값을 가져오거나 설정하는 데 관련한 동작을 캡슐화할 수 있다. 그래서 나중에 코드 변경이 수월하다.
2. 내부 표현을 숨겨주기 때문에 어느 부분을 드러낼지 유연하게 정할 수 있다.
3. 속성이 변하는 시점에 디버깅 개입 지점을 제공하기 때문에 실행 시간에 디버깅이 쉽다.
4. 상위 호환상(forward compatibility)를 보장하는 과정에서 위의 3가지 경우가 발생할 것 같다면 getter와 setter를 사용하는 것이 유리하다.

getter와 setter는 field 이름이 balance라면 다음과 같은 규칙을 갖는다.

  1. getter method는 GetBalance가 아니라 Balance로 짓는다.
  2. setter method는 SetBalance로 짓는다.

가령 다음과 같다.

currentBalance := customer.Balance()
if currentBalance < 0 {
	customer.SetBalance(0)
}

정리하자면, 특별한 장점이 없다면 굳이 getter, setter를 매번 만들 필요가 없다. 실용적인 관점으로 접근해 효율성과 관례 준수 사이의 균현을 잘 맞추는 것이 좋다.

getter, setter가 필요하거나 앞서 언급한 경우처럼 상위 호환성을 보장하는 동시에 getter, setter를 사용해야할 상황이 조만간 발생할 것 같다면 얼마든지 사용해도 된다.

5. interface 오염을 조심하자

interface pollution은 불필요한 추상화로 코드가 복잡해져서 이해하기 힘들게 되는 현상을 말한다.

interface는 object의 동작을 표현하는 수단을 제공한다. interface는 여러 오브젝트가 공통적으로 구현할 추상화를 정의한다.

go에서의 interface에 대한 강력함을 보여주는 사례로는 io.Readerio.Writer가 있다. io 패키지는 입출력 관련 기본 연산을 추상화한다. io.Reader는 데이터 소스로부터 읽는 기능을 표현하고 io.Writer는 target에 데이터를 쓰는 동작을 표현한다.

io.Reader는 다음과 같은 Read 메서드르 하나를 정의한다.

type Reader interface {
	Read(p []byte) (n int, err error)
}

io.Reader interface를 구현하려면 바이트 슬라이드를 받아서 그 안을 데이터로 채운 뒤 바이트 수나 에러를 리턴하게 만들어야 한다.

반면에 io.WriterWrite라는 메서드 하나를 정의한다.

type Writer interface {
	Write(p []byte) (n int, err error)
}

io.Writer interface를 직접 구현하려면 슬라이스에 담긴 데이터를 target으로 쓰고, 다 쓴 바이트 수나 에러를 반환하게 만든다. 두 interface 모두 다음과 같은 기본적인 추상화를 제공한다.

  • io.Reader: 데이터 소스에서 데이터를 읽는다.
  • io.Writer: 타깃에 데이터를 쓴다.

왜 go에서 두 가지 인터페이스를 제공하고 있을까?? 이렇게 추상화하는 목적이 무엇일까?

func copySourceToDest(source io.Reader, dest io.Writer) error {
	//
}

이렇게 작성한 함수는 *os.File 처럼 io.Readerio.Writer를 구현한 타입으로 매개변수를 받는 함수에서도 그대로 사용할 수 있다. 예를 들어 io.Writer가 데이터베이스에 쓰도록 커스터마이즈하더라도 위 코드는 수정 없이 그대로 사용할 수 있다. 이 처럼 interface가 함수의 범용성을 높이기 때문에 재사용성이 높아진다.

뿐만 아니라, 이 함수에 대한 단위 테스트를 작성하기도 더 쉬워진다. 파일을 직접 다룰 필요없이 stringsbytes 처럼 io.Readerio.Writer interface를 구현한 패키지 중에서 적절한 기능을 제공하는 것을 활용하면 되기 때문이다.

interface를 설계할 때 granularity(interface에서 제공하는 메서드의 갯수)도 반드시 고려해야한다. go언어에서 널리 알려진 표현 중에 다음이 있따.

interface는 클수록 추상화가 약해진다. (롭 파이크)

실제로 interface는 몸집이 커지면 커질 수록 추상화가 약해진다. 따라서, interface는 최대한 메서드들을 쪼개고 이들을 합치는 방식을 사용하는 것이 좋다. 가령, io.Readerio.Writer interface를 모두 만족하는 interface를 만들고 싶다면 다음과 같이 하면 된다.

type ReadWriter interface {
	io.Reader
	io.Writer
}

물론 granularity로 적절한 값을 찾기란 쉽지 않을 수 있다. 그러나 확실한 것은 비대한 granularity는 그렇게 좋은 코드가 아니라는 것이다.

이제 interface를 사용하기 바람직한 경우들을 보도록 하자.

  1. 공통 동작(common behavior)
  2. 결합 분리(decoupling)
  3. 동작 제한(restricting behavior)

공통 동작

하나의 공통 동작을 구현하는 타입이 여러 개 잇는 겨웅이다. 이럴 때는 interface 내부에서 공통 동작을 추출한다. 가령 collection을 정렬하는 작업을 다음과 같이 3가지 메서드로 추출할 수 있다.

  • collection에 담긴 원소 개수 알아내기
  • 어느 한 원소가 다른 원소보다 앞에 나와야 하는 경우 알려주기
  • 두 원소 위치 바꾸기(swap)

따라서, sort 패키지에 다음과 같은 인터페이스가 추가되어있다.

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

이 interface는 재사용할 가능성이 매우 높다. index 기반 collection이라면 어떠한 것이든 정렬하는 공통 동작을 담고 있기 때문이다.

동작을 추출하기에 적합한 추상화만 찾아내면 여러 장점이 따라온다. 가령 sort 패키지는 sort.Interface에 의존하는 유틸리티 함수를 제공한다. 주어진 collection이 정렬된 상태인지 검사하는 함수를 예로 들 수 있다.

func IsSorted(data Interface) bool {
	n := data.Len()
	for i := n - 1; i > 0; i-- {
		if data.Less(i, i-1) {
			return false
		}
	}
	return true
}

sort.Interface에서 제공하는 추상화 수준이 적절하기 때문에 굉장히 유용하다.

결합 분리

또 다른 중요 유스케이스로 코드와 구현을 분리하는 결합 분리(decoupling)이 있다. 구체적인 구현 대신 추상화된 코드를 작성하면 나중에 구현이 바뀌더라도 앞서 작성한 코드를 고치지 않아도 된다. 이것이 바로 리스코프 치환 원칙(LSP)이다.

결합 분리의 장점 중 하나는 단위 테스트와 연계할 수 있다는 것이다. 가령 고객을 새로 생성해서 저장하는 CreateNewCustomer 메서드를 구현하는 경우를 생각해보자. 구체적인 구현인 mysql.Store 구조체를 직접 호출하도록 하면 다음과 같다.

type CustomerService struct {
	store mysql.store
}

func (cs CustomerService) CreateCustomer(id string) error {
	customer := Customer{id: id}
	return cs.store.StoreCustomer(customer)
}

이렇게 만들면 CustomerService는 특정 구현인 mysql.store에 종속된다.

테스트를 진행하려면 실제 구현에 종속되어 있기 때문에 통합 테스트를 진행할 수 밖에 없다. 그러기 위해서는 MySql 인스턴스 하나를 실제로 띄워야 하거나, go-sqlmock과 같이 mocking 기법을 수반해야한다. 통합 테스트가 좋긴하지만 목적에 딱 맞는다고 볼 수 없다. 좀 더 유연하게 처리하려면 CustomerService`를 특정 구현과 분리해야한다. 그러기 위해서 다음과 같이 interface를 사용하는 것이다.

type customerStorer interface {
	StoreCustomer(Customer) error
}

type CustomerService struct {
	store customerStorer
}

func (cs CustomerService) CreateCustomer(id string) error {
	customer := Customer{id: id}
	return cs.store.StoreCustomer(customer)
}

CustomerService의 기능을 interface로 추상화하여 CustomerService와 특정 구현을 분리한다. 이렇게하면 고객을 저장하는 기능이 interface를 사용하기 때문에 메서드를 훨씬 유연하게 테스트할 수 있다. 가령 다음과 같다.

  • 통합 테스트에서 특정 구현을 사용하게 만들 수 있다.
  • 단위 테스트에서 mock 또는 테스트용 구현체를 사용할 수 있다.

동작 제한

특정 구현체가 제공하는 기능들이 있는데, 이 기능 중 일부만 사용자에게 제공하고 싶은 경우가 있다. 가령 GetSet 기능을 제공하는 IntConfig라는 구조체가 있다고 하자.

type IntConfig struct {
}

func (c *IntConfig) Get() int {

}

func (c *IntConfig) Set(value int) {

}

사용자에게 IntConfig를 제공하려고 하는데, Get 기능만 제공하고 Set은 제공하고 싶지 않다. 즉, readonly로 제공하고 싶은 것이다. IntConfig를 수정하지 않고도 읽기 전용이라는 것을 강제하려면 어떻게 해야할까? 다음과 같이 Get만 허용하는 interface를 만들면 된다.

type intConfigGetter interface {
	Get() int
}

그러고 나서, 작성할 코드에서는 다음과 같이 특정 구현이 아닌 intConfigGetter에만 종속되도록 작성한다.

type Client struct {
}

func (c *Client) GetValue(intConfig intConfigGetter) {
	v := intConfig.Get()
	...
}

이처럼 interface는 기능을 강제하는 등 여러 이유에 따라 타입에 대한 동작을 제한하는 데 사용할 수 있다.

지금까지 interface를 쓰기 좋은 3 가지 경우를 보았다. 마지막으로 Interface Pollution(인터페이스 오염)에 대해서 알아보자.

interface 오염

go언어로 진행한 project에서 interface를 남용하는 사례를 흔히 볼 수 있다. 대부분이 이전 언어(java, c#)에 익숙한 인터페이스 사용 방법을 go에서 사용해서 그렇다.

중요한 것은 interface는 추상화를 정의하기 위해서 사용하는 것이다. 즉, 프로그래밍에서 추상화는 발견하는 것이지 창조하는 것이 아니다

이 사실을 명심해두야한다. 즉, 특별한 이유없이 추상화 코드를 새로 만들고, 혹시 필요할지도 모른다는 생각으로 인터페이스를 설계하면 안된다. 다르게 말하자면 인터페이스는 당장 필요할 때 만다는 것이다. 당장 쓸 일이 없는데 나중에 필요 할 것이라는 생각으로 미리 만들면 안된다.

interface 남용으로 발생하는 가장 대표적인 문제는 코드 흐름이 매우 복잡해진다는 것이다. 간접 호출 단계(level of indirection)이 쓸데없이 늘어난다. 의미 없는 추상화는 코드를 이해하기 힘들게 할 뿐이다. interface를 추가할 이유가 특별히 없고, 코드가 어떻게 나아지는지 불분명하다면 그 interface의 존재 자체를 다시 생각해야한다. 그냥 구현을 직접 호출하는 것이 좋을 수 있다.

interface로 설계하지 말고 찾아내라 (롭 파이크)

추상적으로 문제를 풀지 말고 당장 해결할 문제를 푼다. 마지막으로 앞서 설명한 사항만큼은 중요한 사실을 전한다. interface로 코드를 향상시키는 효과가 불분명하다면 그냥 제거해서 코드를 간결하게 만드는게 낫다.

0개의 댓글