[Go] Self-referential functions and the design of options

undefcat·2020년 4월 1일
0
post-thumbnail

Self-referential functions and the design of options

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라도 MyTypeint는 다르므로 반드시 타입 캐스팅을 해줘야 된다는 것이다.

// 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를 알고 있는 사람들이라면 다 알고 있는 내용이겠지만, 혹시 모를 독자를 위해 한 번 정리를 해보았다.

왜 갑자기 이 내용을 정리했냐면, 이 내용을 알고 있어야 앞으로의 내용을 이해하기 수월하기 때문이다.

Option Type

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

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
    }
}

이제 CtxOption 메서드를 수정하자.

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만이 할 수 있는 우아한 방법을 알게 됨으로써 좀 더 다양한 생각을 할 수 있게 되어 기쁘다고 생각한다.

profile
초보개발자니뮤ㅠ

0개의 댓글