본문 바로가기
Language/Go

Go - select

by ocwokocw 2022. 1. 12.

- 출처: https://quii.gitbook.io/learn-go-with-tests/go-fundamentals/select

- 개요

http 요청 test를 작성하는법을 알아본다. goroutine 사용시 동기화를 위해 select를 이용해본다.

- WebsiteRace example

두 URL을 받아서 HTTP GET 요청을 날렸을 때 먼저 응답한 URL을 반환하는 WebsiteRace 함수를 작성해보자. 만약 두 URL 모두 10초내로 답변이 없으면 error를 반환한다.
 
우선 요구사항에 맞게 간단하게 test 코드를 작성해보자.
 
func TestRacer(t *testing.T) {
    slowURL := "http://www.facebook.com"
    fastURL := "http://www.quii.co.uk"

    want := fastURL
    got := Racer(slowURL, fastURL)

    if got != want {
        t.Errorf("got %q, want %q", got, want)
    }
}
 
아주 프로그래밍을 잘하지 않아도 위의 코드가 약간 어설프다는것을 곧바로 눈치 챘을 수도 있다. 하지만 중요한것은 처음부터 너무 완벽함에 집착하지 않고 점진적으로 프로그램을 개선해나가는것이다.
 
func Racer(a, b string) (winner string) {
    return
}

............

func Racer(a, b string) (winner string) {
    startA := time.Now()
    http.Get(a)
    aDuration := time.Since(startA)

    startB := time.Now()
    http.Get(b)
    bDuration := time.Since(startB)

    if aDuration < bDuration {
        return a
    }

    return b
}
 
우선 Compile만 통과하는 코드를 작성하고 실행하여 실패하는것을 확인하도록 하자. 그 후 test가 통과하도록 코드를 작성한다.
 
각 URL은 다음과 같은 과정을 거친다. 우선 time.Now() 로 URL 요청을 하기전에 현재 시간을 기록한다. 그 후 http.Get 을 이용하여 URL의 내용을 얻는다. http.Get은 http.Response와 error를 반환하지만 현재까지는 필요 없기 때문에 반환값을 변수로 할당하지는 않았다. time.Since 로 처음에 기록한 시간을 받아서 time.Duration을 반환한다.

- 문제점

우선 가장 큰 문제점은 test 결과가 일관성이 없다는 것이다. test 코드에서 우리의 로직을 test 하기 위해 실제 웹사이트에 요청을 하고 있기 때문이다.
 
Go에서는 표준 라이브러리를 지원하기 때문에 HTTP를 사용하는 test 코드를 작성하는것이 매우 일반적이다.
 
Mocking과 Dependency injection 챕터에서 test 코드에서 외부 서비스에 의존하는 연관성을 어떻게 끊을 수 있는지를 배웠었다. 외부 서비스와 의존성을 가지게되면 느리고 예외 케이스를 테스트하기가 힘든점 등 여러 가지 문제가 많다.
 
표준 라이브러리에서는 mock HTTP server를 쉽게 생성할 수 있는 net/http/httptest package를 지원한다.
 
mock을 사용하도록 test code를 변경해서 제어가 가능한 신뢰할 수 있는 서버를 구축해보자.
 
func TestRacer(t *testing.T) {

    slowServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        time.Sleep(20 * time.Millisecond)
        w.WriteHeader(http.StatusOK)
    }))

    fastServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
    }))

    slowURL := slowServer.URL
    fastURL := fastServer.URL

    want := fastURL
    got := Racer(slowURL, fastURL)

    if got != want {
        t.Errorf("got %q, want %q", got, want)
    }

    slowServer.Close()
    fastServer.Close()
}
 
문법이 약간 복잡해보일 수 있지만 시간을 갖고 천천히 살펴보면 크게 어렵지 않다.
 
httptest.NewServer는 익명함수를 취하는 http.HandlerFunc를 인자로 받고 있다. http.HandlerFunc 형은 type HandlerFunc func(ResponseWriter, *Request)이다. 결국에는 ResponseWriter와 Request를 받는 함수로 HTTP server를 구축한다는 의미가 된다.
 
복잡한 프레임워크 같은것을 사용해본적이 있다면 코드가 너무 직관적으로 느껴질 수 있다. 하지만 이런 직관적인 코드가 Go의 매력인데, 실제로 HTTP server를 구축할 때 해당 코드를 사용한다. 차이점은 httptest.NewServer로 감싸서 test 시 사용하기 더 쉽고, 요청을 수신할 port를 찾고, test가 끝나면 server를 닫을 수 있다는것뿐이다.
 
2 개의 서버중에 하나는 time.Sleep을 이용해 요청을 다른 서버보다 느리게 만들었다. 두 서버는 모두 OK 상태코드를 응답에 써서 호출자에게 돌려준다.
 
test를 다시 수행해보면 더 빠를뿐만 아니라 무조건 통과하는것을 확인할 수 있다. sleep을 조정하면 의도적으로 test를 실패하게 만들수도 있다.
 
이제 통과하는 test를 작성했으니 리팩토링을 할 차례이다. 배포용 코드와 test 코드의 중복을 없애보자.
 
func Racer(a, b string) (winner string) {
    aDuration := measureResponseTime(a)
    bDuration := measureResponseTime(b)

    if aDuration < bDuration {
        return a
    }

    return b
}

func measureResponseTime(url string) time.Duration {
    start := time.Now()
    http.Get(url)
    return time.Since(start)
}
 
응답속도 측정하는 부분을 메소드로 추출해서 가독성이 더 좋아졌다.
 
func TestRacer(t *testing.T) {

    slowServer := makeDelayedServer(20 * time.Millisecond)
    fastServer := makeDelayedServer(0 * time.Millisecond)

    defer slowServer.Close()
    defer fastServer.Close()

    slowURL := slowServer.URL
    fastURL := fastServer.URL

    want := fastURL
    got := Racer(slowURL, fastURL)

    if got != want {
        t.Errorf("got %q, want %q", got, want)
    }
}

func makeDelayedServer(delay time.Duration) *httptest.Server {
    return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        time.Sleep(delay)
        w.WriteHeader(http.StatusOK)
    }))
}
 
가짜 서버를 만드는 부분도 리팩토링을 하였다. 

- defer

바로 이전 코드에서 defer를 이용해서 함수를 호출하였는데, 이렇게 defer로 호출하면 포함하고 있는 함수가 종료될 때 해당 함수를 호출한다. 보통 자원을 정리할 때(file을 닫거나 server를 종료해서 더이상 요청을 받지 않도록) 사용한다. 
 
defer를 호출하는 의도는 함수가 종료되기전 마지막에 실행되어야 할 코드를 실행하는것이지만 server를 생성했던 코드 근처에 defer를 사용해서 서버를 닫아주는 코드를 작성하는것이 가독성이 더 좋을 수 있다.
 
지금까지 Go의 특징을 살린 리팩토링으로 좋은 해결책을 작성했지만 더 간단하게 해결할 수 있는 방법이 있다.

- select

Go가 병렬성을 잘 지원하고 있는데 왜 우리는 차례대로 웹사이트 속도를 측정하고 있을까? 동시에 웹사이트를 측정해보자. 우리는 요청들의 정확한 응답시간을 측정하고 있는게 아니라 어떤 요청에 대한 응답이 먼저 오는지를 측정하고 있다. 
 
이런 상황에서 사용할 수 있는 select 구문을 소개한다. select는 쉽고 명확하게 프로세스들을 동기화할 수 있게 도와준다.
 
func Racer(a, b string) (winner string) {
    select {
    case <-ping(a):
        return a
    case <-ping(b):
        return b
    }
}

func ping(url string) chan struct{} {
    ch := make(chan struct{})
    go func() {
        http.Get(url)
        close(ch)
    }()
    return ch
}
 
ping 함수에서 chan struct{}를 생성하여 반환하였다. 현재 상황에서는 어떤 형이 channel에 보내져야 하는지 전혀 신경쓸필요가 없기 때문에, 단순하게 끝났다는 신호를 보내고 channel만 닫아주면 된다.
 
왜 bool 같은 형이 아니라 struct{}를 사용했을까? 특별한 동작상의 이유보다는 chan struct{}는 메모리관점에서 가장 작은 데이터 형이기 때문이다.
 
또 한 가지 특이한점은 channel을 닫긴했지만 channel에 아무것도 전송하지 않았는데 왜 할당했는가? 우리가 시작한 goroutine은 http.Get(url) 동작이 끝나면 channel에 신호를 전송한다.
 
channel을 생성할때에는 var ch chane struct{}와 같이 var가 아니라 make를 사용해야 한다. var 변수를 이용하면 해당 형의 "zero"값으로 초기화된다. 예를 들어 string이라면 "", int형은 0값으로 초기화된다. 그런데 channel의 "zero"값은 nil이라서 만약 <- 연산자로 신호를 보내려고 하면 nil 채널로는 보낼 수 없기 때문에 <- 연산자 이후의 코드가 영원히 실행되지 않는다. (block된다.)
 
concurrency 챕터를 떠올려보면 myVar := <-ch 와 같이 사용하면 채널로 값이 전송되기까지 기다릴 수 있다는것을 기억하고 있을것이다. 값을 기다리고 있으므로 blocking 호출이라고 할 수 있다.
 
select를 이용하면 여러 개의 channel을 기다릴 수 있다. select 내의 case 중 값을 보내는 첫번째 case문에 해당하는 구문이 실행된다.
 
URL 각각을 위한 2개의 채널을 셋팅하기 위해 select 내에서 ping을 사용하였다. select에서 둘 중 channel에 먼저 쓰는 하나의 URL이 실행되고, 그 결과로 URL이 반환된다. select문을 이용함으로써 코드의 의도가 더 명확하고 구현이 더 간단해졌음을 알 수 있다.

- timeout

이제 마지막 요구사항인 10초를 넘겼을 경우에 대한 timeout을 구현해보자.
 
t.Run("returns an error if a server doesn't respond within 10s", func(t *testing.T) {
    serverA := makeDelayedServer(11 * time.Second)
    serverB := makeDelayedServer(12 * time.Second)

    defer serverA.Close()
    defer serverB.Close()

    _, err := Racer(serverA.URL, serverB.URL)

    if err == nil {
        t.Error("expected an error but didn't get one")
    }
})
 
위의 코드를 보면 알겠지만 test 서버들을 10초 이상 걸리게 만들어서 URL이 반환되지 않고, error가 반환되기를 기대하고 있다.
 
func Racer(a, b string) (winner string, error error) {
    select {
    case <-ping(a):
        return a, nil
    case <-ping(b):
        return b, nil
    }
}
 
우선 Racer의 시그니처를 수정해서 반환 형을 하나 추가해주어야 한다. 정상적인 경우에는 error가 없으므로 nil을 반환한다.
 
func Racer(a, b string) (winner string, error error) {
    select {
    case <-ping(a):
        return a, nil
    case <-ping(b):
        return b, nil
    case <-time.After(10 * time.Second):
        return "", fmt.Errorf("timed out waiting for %s and %s", a, b)
    }
}
 
time.After는 select문을 사용할 때 매우 편리한 기능을 제공한다. 사실 우리가 작성한 test 코드에서는 일어나지 않는 경우지만, 만약 channel 들이 값을 반환하지 않는다면 잠재적으로 영원히 block 될 가능성을 대비한 코드를 작성할 수 있다. time.After 는 chan을 반환하고, 정의한 시간이 지나면 신호를 전송한다.
 
우리 같은 상황에 아주 딱맞는 기능이다. 만약 a 또는 b 중 이긴 URL이 반환되더라도 10초가 지나면 time.After가 신호를 전송해서 error를 받을 수 있다.

- 문제점

요구사항은 모두 구현되었지만 한 가지 문제가 있다. DI, mocking을 떠올려본다면 짐작할 수 있을것이다. test가 수행되는데 너무 느리다는것이다.
 
timeout을 환경설정으로 분리하여 이 문제를 해결해보자. test 에서는 매우 짧은 시간을 사용하고, 실제 코드에서는 10초를 사용하면 될것이다.
 
func Racer(a, b string, timeout time.Duration) (winner string, error error) {
    select {
    case <-ping(a):
        return a, nil
    case <-ping(b):
        return b, nil
    case <-time.After(timeout):
        return "", fmt.Errorf("timed out waiting for %s and %s", a, b)
    }
}
 
아직 Racer로 timeout에 대한 값을 넘기지 않았기 때문에 아직 Compile은 되지 않는 상황이다. 이 값을 넘기기 전에 잠시 생각해보자. 일반적인 경우에 timeout 값에 대해 신경쓸필요가 있는지 여부와 요구사항에 timeout값 여부가 명확하게 명시되어있다는 점이다.
 
이 두 가지를 고려해서 test 코드와 user가 사용할 코드 모두에서 의미가 있도록 리팩토링을 해보자.
 
var tenSecondTimeout = 10 * time.Second

func Racer(a, b string) (winner string, error error) {
    return ConfigurableRacer(a, b, tenSecondTimeout)
}

func ConfigurableRacer(a, b string, timeout time.Duration) (winner string, error error) {
    select {
    case <-ping(a):
        return a, nil
    case <-ping(b):
        return b, nil
    case <-time.After(timeout):
        return "", fmt.Errorf("timed out waiting for %s and %s", a, b)
    }
}
 
사용자와 일반적인 경우의 test 함수는 Racer 함수를 사용하면 된다. 반면 응답이 오지 않는 상황에 대한 test는 ConfigurableRacer를 사용하면 된다.
 
func TestRacer(t *testing.T) {

    t.Run("compares speeds of servers, returning the url of the fastest one", func(t *testing.T) {
        slowServer := makeDelayedServer(20 * time.Millisecond)
        fastServer := makeDelayedServer(0 * time.Millisecond)

        defer slowServer.Close()
        defer fastServer.Close()

        slowURL := slowServer.URL
        fastURL := fastServer.URL

        want := fastURL
        got, err := Racer(slowURL, fastURL)

        if err != nil {
            t.Fatalf("did not expect an error but got one %v", err)
        }

        if got != want {
            t.Errorf("got %q, want %q", got, want)
        }
    })

    t.Run("returns an error if a server doesn't respond within the specified time", func(t *testing.T) {
        server := makeDelayedServer(25 * time.Millisecond)

        defer server.Close()

        _, err := ConfigurableRacer(server.URL, server.URL, 20*time.Millisecond)

        if err == nil {
            t.Error("expected an error but didn't get one")
        }
    })
}
 
 

'Language > Go' 카테고리의 다른 글

Go - Sync  (0) 2022.01.20
Go - reflection  (0) 2022.01.15
Go - Concurrency  (0) 2022.01.04
Go - mocking  (0) 2022.01.02
Go - dependency injection  (0) 2021.12.31

댓글