viper 모듈의 코드를 좀 살펴보다가 주석으로 링크된 게시글을 발견해서 읽게 되었다. 글의 내용이 상당히 좋다는 생각이 들어, 번역까진 아니더라도 내용을 정리해보고자 한다. 원문은 링크를 클릭해주시길 바란다.
이 글을 쓰신 분은 대부분의 Gopher라면 다 알고 있을 그사람, 바로 Go
의 창시자 중 한 명인 Rob Pike 님이다.
내용을 정리하기에 앞서 느낀 점을 말해보자면, 언어를 만든 것과 활용하는 것은 별개의 문제라는 것을 다시 한 번 깨닫게 되었다. 물론, 게임 세계에서는 개발자들이 생각지 못한 것들을 유저들이 해내는 것을 보면서 어렴풋이 알긴 했지만 그래도 역시 놀랍다.
아무튼, 내용 정리를 시작해보자.
원문을 읽어보면 알겠지만, Rob Pike
님은 어플리케이션에서 필수로 사용하는 Config
를 우아하게 해결하는 방법을 찾다가 끝내 발견한 이 방법이 가장 만족스럽다고 하셨다.
Config
에 관한 고민은 또 다른 링크인 이 글을 읽어보면 간접경험을 할 수 있을 것이다.
Go
에서 내가 좋아하는 특징 중 하나가 바로 매우 엄격한 타입 시스템이다. Go
는 타입을 매우 엄격하게 따지는데, 알다시피 배열의 크기 마저 하나의 타입이 된다. 즉, 길이가 1인 배열과 2인 배열은 각자 다른 타입이 된다. 만약 매개변수로 [1]int
타입을 받는다면, 이 함수에는 [2]int
타입의 변수를 매개변수로 넘길 수 없다! 그래서 Go
에서는 정적 길이를 가진 배열보단 Python
을 써본 사람이라면(그 외 다른 언어도 물론) 익숙한 Slice
로 배열을 사용한다.
아무튼, Go
는 타입 시스템이 매우 엄격한 언어인데, 타입 시스템이 엄격한 만큼 상당히 많은 타입을 개발자가 정의해서 사용할 수 있다. 이는 B
언어 창시자이자 Go
의 공동 창시자인 Ken Thompson의 영향을 받은 건지 자세히는 모르겠지만 Go
에서는 C
를 사용해 본 사람이라면 익숙한 typedef
를 비슷하게 이용하여 새로운 타입(구조체)를 선언해서 사용할 수 있다.
type MyType struct {
someInt int
someStr string
}
이 뿐만 아니라 Type Alias
도 할 수 있다.
type MyType int
중요한 건, MyType
이 실제로는 int
라도 MyType
과 int
는 다르므로 반드시 타입 캐스팅을 해줘야 된다는 것이다.
// MyType을 매개변수로 받는 함수
func Func(t MyType) { /* ... */ }
// int형 변수 선언
var notMyType int
Func(notMyType) // X
Func(MyType(notMyType)) // O
위에서 배열의 길이조차 타입이라고 했는데, 함수 역시 그 자체가 타입이 될 수 있다. 개인적으로 http패키지를 봤을 때 가장 신기했던 타입 중 하나가 바로 http.HandlerFunc였다.
이 타입은 아래와 같이 정의되어있다.
type HandlerFunc func(ResponseWriter, *Request)
함수가 HandlerFunc
라는 타입이다. 게다가 이 타입은 아래의 메서드를 구현하고 있다.
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request)
함수가 메서드를 구현하고 있는 셈인데, 함수이기 이전에 타입이기 때문이다. http
패키지에서는 요청에 대한 응답을 Handler
인터페이스를 구현하는 타입이 하게 되는데, HandlerFunc
타입이 Handler
인터페이스를 만족한다. 참고로 Handler
인터페이스는 아래와 같다.
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
내가 가장 Go
에서 좋아하는 특징인데, Go
에서는 다른 언어에서와 달리 인터페이스가 Duck Typing의 특징을 갖고 있다.
즉, 어떤 타입이든 다른 언어에서 인터페이스를 구현할 때처럼 명시적으로 표기를 할 필요가 없이 해당 인터페이스를 만족하는 메서드만 구현하면 그 인터페이스를 만족한다고 본다. 참으로 아름다운 기능이 아닐 수 없다!
일반적인 클래스로 객체를 구현하는 언어의 경우, 인터페이스를 구현하는 방법은 아래와 같을 것이다. 예를 들어, PHP
의 경우
interface Handler
{
function serveHttp(ResponseWriter $w, Request $r);
}
class MyHandler implements Handler // Handler를 구현한다고 명시해야만 함
{
public function serveHttp(ResponseWriter $w, Request $r)
{
// ...
위처럼 implements
키워드로 클래스가 인터페이스를 구현함을 명시해야만 한다. 하지만 Go
는 그럴 필요가 없다! 그냥 인터페이스를 충족하는 메서드만 구현하면, 해당 타입은 인터페이스를 구현한다고 본다. 이는 강한 타입언어임에도 불구하고 동적 타입의 스크립트 언어와 같은 유연함을 보장한다. 심지어 컴파일시에 타입 오류를 체크할 수도 있다!👏👏👏
Go
를 알고 있는 사람들이라면 다 알고 있는 내용이겠지만, 혹시 모를 독자를 위해 한 번 정리를 해보았다.
왜 갑자기 이 내용을 정리했냐면, 이 내용을 알고 있어야 앞으로의 내용을 이해하기 수월하기 때문이다.
Rob Pike
님이 생각한 방법은 Config
에 대한 확장성과 사용성을 고려한 방법인데, 함수 타입을 이용하는 방법이다. 시작은 Option
타입의 함수를 정의하면서 시작한다.
type Option func(*Ctx)
매개변수는 우리가 설정을 적용할 컨텍스트 객체를 받을 것이다.
그 다음, 컨텍스트 객체에 메서드를 하나 정의하자. 이 메서드는 옵션을 적용하는 메서드이다.
func (c *Ctx) Option(opts ...Option) {
for _, opt := range opts {
opt(c)
}
}
간단하다. 매개변수로 받는 Option
함수를 Ctx
에 적용한다. Ctx
는 하나의 구조체일 것이므로, 각종 프로퍼티들이 있을 것이다. 설정이라는 것은 결국 이 Ctx
의 프로퍼티를 설정하는 일이 될 것이다.
구현 개발자가 클라이언트 개발자들을 위해 설정을 위한 함수들을 opt
라는 패키지 안에 구현한다고 보자. (Option
의 패키지는 생략하도록 하겠다.)
package opt
// ...
func Verbosity(v int) Option {
return func(c *Ctx) {
c.verbosity = v
}
}
하는 일이 매우 단순하다. Verbosity
함수는 설정할 값 v
를 매개변수로 받고, Ctx
의 프로퍼티를 설정한다.
일반적으로 어플리케이션이 성장하면서 Ctx
의 몸집 역시 비대해질 것이고, 수많은 상태값들이 추가될 것이다. 초기부터 이 패키지를 사용해왔던 클라이언트 개발자나 이 패키지를 구현한 개발자라면 Ctx
에 대한 지식이 충분할 것이므로 크게 문제가 되지 않을 수 있다. 하지만 뒤늦게 참여(사용)하는 개발자라면, 이 Ctx
를 전부 이해하는 것을 기대하기가 힘들 것이다.
어차피 패키지 구현자는 Ctx
에 대해 그 누구보다도 잘 알고 있을 것이고, 클라이언트 개발자들을 위해 옵션 함수를 구현하는 것쯤은 누워서 떡먹기일 것이다.
사용은 아래와 같을 것이다.
c.Option(opt.Verbosity(10))
Verbosity
함수는 클로저를 리턴하는데, 그 클로저가 Option
을 만족하므로 c
는 리턴된 클로저를 호출해서 설정을 적용하게 된다.
여기까지만 해도 설정을 우아하게 해결하는데 충분하다.
이러한 방식의 장점은 여러가지가 있을 것이다. 내가 생각하는 장점을 꼽자면
Config
타입의 문서를 보는 것보다 메서드 목록을 보는게 더 보기 편하다.Go
는 문서화하기가 편한데, 이미 공식적으로 문서화툴을 제공해주기 때문이다. 이 툴의 결과물을 봤을 때, type
에 대한 문서는 아래와 같이 나온다.
반면, 메서드 목록은 하나하나 구체적으로 나온다.
위의 이미지에서 볼 수 있듯이, 설정 하나하나에 대해 자세한 설명을 기대할 수 있다. type
의 경우, struct
코드 자체에 주석만 문서로 포함되기 때문에 읽기가 조금 불편한 감이 없잖아 있다.
또, 각 함수마다 테스트 코드로 예제를 작성했다면, 예제도 볼 수 있다(이 얼마나 아름다운 문서화 툴의 힘인가!).
그리고 개발하다보면 느끼겠지만, 함수는 언제나 편하다. 우리에게 추가적인 자유를 부여해주기 때문이다.
opt
에 함수를 구현하는 방법에 따라 자주 사용되는 옵션을 모을 수 있다.이에 대한 설명은 따로 하지 않겠다.
Config
구조체로 구현되어있는 경우, 설정 후 사이드 이펙트를 걱정해야한다.만약 이런 경우가 있으면 어떻게 될까?
cfg := Config{ /* ... */ }
c := new Ctx(cfg)
cfg.Port = 1000
Go
는 기본적으로 패키지 레벨의 visibility
만 존재한다(이를 visibility
로 표현하는게 맞나 싶긴 하지만). 기본적으로 private
의 개념이 없다. 단지 패키지에서 export
를 할 것이냐 말 것이냐를 결정할 수 있을 뿐이다.
일반적인 클래스 기반 OOP
언어에서는 위와 같은 상황을 원천적으로 막을 수 있지만, Go
에서는 그럴 수 없다.
옵션 함수들을 구현하면 마치 자바스크립트에서 private
을 클로저로 구현하듯, 비슷한 효과를 얻을 수 있다.
개인적으로 함수가 그냥 맘에 든다. 취향이니 존중해주길 바란다.
대부분의 경우, 위와 같은 방법으로 충분히 목적을 달성할 수 있다. 하지만 더 나아가 함수(클로저)를 이용해서 얻을 수 있는 파워풀한 기능을 알아보자.
Memoization
이라는 개념이 있다. 이전에 계산된 값을 갖고 있다가, 필요할 때 바로 리턴해주는 일종의 캐시 개념인데, 이와 비슷하게 기존 옵션에 새로운 옵션을 설정할 때 이전의 값을 쉽게 가져올 수 있다면 좋을 것 같다.
예를 들어, 아래와 같이 Getter
코드 없이 말이다.
// 이 두 코드 라인을 하나로 합칠 수 있도록 하자.
prevConfig := c.verbosity
c.Option(opt.Verbosity(3))
우리의 Option
타입이 이전 설정값을 리턴하게 만들면 된다.
type Option func(*Ctx) interface{}
Option
의 타입이 변경되었으니 Verbosity
가 리턴하는 클로저 역시 수정하자.
func Verbosity(v int) Option {
return func(c *Ctx) (prev interface{}) {
prev = c.verbosity
f.verbosity = v
return
}
}
이제 Ctx
의 Option
메서드를 수정하자.
func (c *Ctx) Option(opts ...Option) (prev interface{}) {
for _, opt := range opts {
prev = opt(c)
}
return
}
이제 아래와 같이 쓸 수 있다.
prevVerbosity := c.Option(opt.Verbosity(3))
c.DoSomeDebugging()
c.Option(opt.Verbosity(prevVerbosity.(int))
interface{}
를 리턴하기 때문에 type assertion
이 필요한 부분이 지저분해 보인다. 좀 더 아름답게 바꿔보자. interface{}
가 아닌, Option
을 또 리턴하게 만들면 어떨까?
type Option func(*Ctx) Option
func Verbosity(v int) Option {
return func(c *Ctx) Option {
prev := c.verbosity
c.verbosity = v
return Verbosity(prev)
}
}
func (c *Ctx) Option(opts ...Option) (prev Option) {
for _, opt := range opts {
prev = opt(c)
}
return
}
이제 Verbosity
는, 이전 옵션상태로 되돌리는 클로저를 리턴한다! Option
의 구조는 마치 일종의 State Machine을 연상케 하기도 한다.
이제 아래와 같이 사용할 수 있다.
prevVerbosity := c.Option(opt.Verbosity(3))
c.DoSomeDebugging()
c.Option(prevVerbosity)
defer
와 같이 사용하기도 좋다.
func DoSomethingVerbosely(c *Ctx, verbosity int) {
prev := c.Option(opt.Verbosity(verbosity))
defer c.Option(prev)
// 무언가 작업...
}
잠깐 다른 설정을 이용해서 무언가 작업을 하고, 다시 원래 설정으로 쉽게 복귀할 수 있다. 리소스를 닫는 일반적인 defer io.Close()
와 같은 방식이다. 좀 더 버그를 줄일 수 있게 되었다!
추가적인 기능이 필요하다면, 적절히 생각해서 기능을 추가하면 될 것이다. 함수는 개발자에게 자유를 주니까 말이다.
이 모든게 과도하다고 생각될 지도 모른다. 하지만 클라이언트 개발자에게도 좋고, 사실 코드를 이해하는게 크게 어렵지도 않아서 사용하기도 간편하다. 과도한 옵션을 클라이언트 개발자에게 직접 제안하는 것보다, 옵션 함수 API를 이용해 설정할 수 있게 해주면 클라이언트 개발자도 매우 편할 것이다.
또, Ctx
에 대한 구조를 비교적 마음껏 쉽게 바꿀 수 있는 점 역시 구현 개발자에게 장점일 것이다. 이렇게 구현함으로써 우리는 Ctx
를 아름답게 캡슐화 했다고 볼 수 있다.
Go
의 아름다운 타입시스템, 아름다운 인터페이스로 아름다운 구현을 할 수 있다. 아무리 생각해도 Go
는 정말 아름다운 언어라고 생각한다.
정말 핵심은 이러한 기능을 사용한다기 보다는, http.HandlerFunc
와 같이 Go
의 강력한 타입, 인터페이스 시스템을 보여주는 좋은 예제라고 생각한다.
오직 (내가 알기로는)Go
만이 할 수 있는 우아한 방법을 알게 됨으로써 좀 더 다양한 생각을 할 수 있게 되어 기쁘다고 생각한다.