Server 함수는 Store 인수를 받은 뒤 http.HandlerFunc를 반환한다. Store는 다음과 같이 정의되어 있다:
type Store interface {
Fetch() string
}
반환된 함수는 store의 Fetch 메서드를 통해 데이터를 얻은 뒤 응답으로 해당 데이터를 출력한다.
아래는 이 테스트에 쓰인 Store의 구현부분이다.
type StubStore struct {
response string
}
func (s *StubStore) Fetch() string {
return s.response
}
func TestServer(t *testing.T) {
data := "hello, world"
svr := Server(&StubStore{data})
request := httptest.NewRequest(http.MethodGet, "/", nil)
response := httptest.NewRecorder()
svr.ServeHTTP(response, request)
if response.Body.String() != data {
t.Errorf(`got "%s", want "%s"`, response.Body.String(), data)
}
}
행복한 경로 코드가 준비되었으니 이제는 약간 더 현실성이 있는 경우를 가정해 볼 차례이다. 사용자가 요청을 취소하기 전까지 Store가 Fetch를 끝내지 못하는 경우를 생각해보자.
테스트부터 작성하기
핸들러에서 Store를 중단시킬 방법이 필요하므로 인터페이스를 알맞게 바꿔주자.
type Store interface {
Fetch() string
Cancel()
}
스파이를 수정하여 data를 가져오는 데에 시간이 걸리도록 하고, 취소 여부를 알 수 있도록 해보자. 또한 해당 객체가 어떻게 호출되는지 지켜볼 것이므로 이름을 SpyStore로 바꿔주자. 그리고 Store 인터페이스를 구현하도록 Cancel 메서드를 추가해주자.
type SpyStore struct {
response string
cancelled bool
}
func (s *SpyStore) Fetch() string {
time.Sleep(100 * time.Millisecond)
return s.response
}
func (s *SpyStore) Cancel() {
s.cancelled = true
}
100 밀리초가 지나기 전에 요청을 취소하는 테스트를 추가해보고, store가 취소되는지 확인해보자.
t.Run("tells store to cancel work if request is cancelled", func(t *testing.T) {
data := "hello, world"
store := &SpyStore{response: data}
svr := Server(store)
request := httptest.NewRequest(http.MethodGet, "/", nil)
cancellingCtx, cancel := context.WithCancel(request.Context())
time.AfterFunc(5 * time.Millisecond, cancel)
request = request.WithContext(cancellingCtx)
response := httptest.NewRecorder()
svr.ServeHTTP(response, request)
if !store.cancelled {
t.Errorf("store was not told to cancel")
}
})
컨텍스트 패키지는 기존 컨텍스트들로 부터 새 컨텍스트들을 파생시키는 함수를 제공하며, 이를 사용할 경우 해당 컨텍스트들은 트리를 형성하게 된다: 어떠한 컨텍스트가 취소될 경우, 그것으로 부터 파생된 모든 컨텍스트들 또한 취소된다.
이 점을 숙지하여 주어진 요청에 대한 취소가 해당 요청의 호출 스택을 따라 전파되어 모든 컨텍스트들이 취소될 수 있도록 컨텍스트를 파생시키는 것이 중요하다.
request에서 새로운 cancellingCtx를 파생시킴과 동시에 cancel 함수를 얻게 된다. 다음으로 time.AfterFunc를 통해 해당 함수가 5 밀리초 후에 호출되도록 설정해보자. 마지막으로 request.WithContext를 통해 새로 얻은 컨텍스트를 사용해보자.
테스트 실행해보기
예상대로 테스트는 실패할 것이다.
--- FAIL: TestServer (0.00s)
--- FAIL: TestServer/tells_store_to_cancel_work_if_request_is_cancelled (0.00s)
context_test.go:62: store was not told to cancel
테스트를 통과하는 최소한의 코드 작성하기
TDD를 숙지하며 테스트를 작성해보자. 최소한의 코드를 추가하여 테스트가 통과하도록 해보자.
위와 같이 수정함으로써 테스트를 통과하기는 하지만 기분이 그리 좋지만은 않다! 당연한 얘기이지만 모든 요청에 대하여 데이터를 가져오기도 전에 Store를 취소하여서는 안된다.
좋다! TDD를 숙지함으로써 테스트의 결점이 보이기 시작했다.
행복한 경로 테스트를 수정하여 Store가 취소되지 않았음을 assert 하도록 해보자.
t.Run("returns data from store", func(t *testing.T) {
data := "hello, world"
store := &SpyStore{response: data}
svr := Server(store)
request := httptest.NewRequest(http.MethodGet, "/", nil)
response := httptest.NewRecorder()
svr.ServeHTTP(response, request)
if response.Body.String() != data {
t.Errorf(`got "%s", want "%s"`, response.Body.String(), data)
}
if store.cancelled {
t.Error("it should not have cancelled the store")
}
})
두 테스트를 실행해보면 행복한 경로 테스트는 이제 실패할 것이다. 조금 더 합리적인 구현이 필요한 시점이다.
func Server(store Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
data := make(chan string, 1)
go func() {
data <- store.Fetch()
}()
select {
case d := <-data:
fmt.Fprint(w, d)
case <-ctx.Done():
store.Cancel()
}
}
}
여기서 우리는 무엇을 했나?
context 에게는 Done()이라는 메서드가 있으며 이는 컨텍스트가 "완료"되거나 "취소"될 경우 신호를 받는 채널을 반환한다. 우리는 해당 신호를 대기하여 해당 신호가 올 경우 store.Cancel을 호출하고 싶지만, 만약 Store가 그 전에 Fetch를 완료했을 경우에는 그 신호를 무시해주고 싶다.
이를 위해 고루틴에서 Fetch를 호출한 뒤 새로 만들어줄 채널인 data에 결과를 보내준다. 그리고 select 문을 사용하여 두 비동기 프로세스를 경합시킨 뒤 응답을 출력하거나 Cancel을 수행하자.
리팩터링 하기
스파이에 assertion 메서드들을 추가하여 테스트 코드를 리팩터링 해보자.
type SpyStore struct {
response string
cancelled bool
t *testing.T
}
func (s *SpyStore) assertWasCancelled() {
s.t.Helper()
if !s.cancelled {
s.t.Errorf("store was not told to cancel")
}
}
func (s *SpyStore) assertWasNotCancelled() {
s.t.Helper()
if s.cancelled {
s.t.Errorf("store was told to cancel")
}
}
스파이를 생성할 때 *testing.T를 잊지 말고 넘겨주도록 하자.
func TestServer(t *testing.T) {
data := "hello, world"
t.Run("returns data from store", func(t *testing.T) {
store := &SpyStore{response: data, t: t}
svr := Server(store)
request := httptest.NewRequest(http.MethodGet, "/", nil)
response := httptest.NewRecorder()
svr.ServeHTTP(response, request)
if response.Body.String() != data {
t.Errorf(`got "%s", want "%s"`, response.Body.String(), data)
}
store.assertWasNotCancelled()
})
t.Run("tells store to cancel work if request is cancelled", func(t *testing.T) {
store := &SpyStore{response: data, t: t}
svr := Server(store)
request := httptest.NewRequest(http.MethodGet, "/", nil)
cancellingCtx, cancel := context.WithCancel(request.Context())
time.AfterFunc(5*time.Millisecond, cancel)
request = request.WithContext(cancellingCtx)
response := httptest.NewRecorder()
svr.ServeHTTP(response, request)
store.assertWasCancelled()
})
}
위의 접근 방식은 작동하기는 하지만 자연스러운가?
웹 서버가 Store를 취소하는데에 직접 관여하는 것이 적절하다고 생각하나? Store가 다른 실행 속도가 느린 프로세스들에 의존하는 경우 어떻게 될까? Store.Cancel이 올바르게 파생 컨텍스트들에게 취소를 전파하도록 해야할 필요가 있다.
서버로 들어오는 요청들에 대해 컨텍스트를 생성하는 것이 좋고 내보내는 함수는 컨텍스트를 인수로 받는 것이 좋다. 또한 두 과정 사이의 함수들을 호출할 때 해당 컨텍스트를 반드시 전파하여야 하며, 선택적으로 해당 컨텍스트를 WithCancel, WithDeadline, WithTimeout, 혹은 WithValue를 이용해 파생시킨 컨텍스트를 사용할 수도 있다. 컨텍스트가 취소될 때 해당 컨텍스트를 상속한 모든 컨텍스트들 또한 취소된다.
구글에서는 고 프로그래머들로 하여금 모든 들어오는 요청과 나가는 요청 함수들의 첫번째 인수를 컨텍스트로 하도록 규정한다. 이는 여러 팀에서 개발된 고 코드들이 서로 잘 작동하도록 한다. 컨텍스트는 간단한 방법을 통해 시간 초과와 취소를 관리할 수 있도록 하며, 보안 자격 증명과 같은 중요한 값들이 고 프로그램내에서 올바르게 넘겨질 수 있도록 한다.
(잠시 시간을 내어 모든 함수가 컨텍스트를 넘겨줄 경우 가져올 영향과 그것을 인간공학적인 관점에서 생각해 보자.)
약간 불편하게 느껴지나? 좋다. 불편할지라도 해당 접근 방식을 따라하여 Store에 context를 넘겨줌으로써 관여하게 해보자. 이를 통해 Store는 해당 context를 그것에 의존하는 것들에 넘겨줄 수 있게 되고, 그 컨텍스트들은 그것들을 취소하는 데에 관여하게 된다.
테스트부터 작성하기
각 구성요소가 관여하는 부분이 바뀌었으므로 테스트 또한 수정해주자. 핸들러가 담당하는 부분은 이제 단지 컨텍스트를 Store를 통해 전파시키는 것과 Store가 취소될 경우 보내지는 오류를 처리하는 것이다.
Store 인터페이스를 수정하여 새로 관여하는 부분을 담당할 수 있도록 하자.
type Store interface {
Fetch(ctx context.Context) (string, error)
}
type SpyStore struct {
response string
t *testing.T
}
func (s *SpyStore) Fetch(ctx context.Context) (string, error) {
data := make(chan string, 1)
go func() {
var result string
for _, c := range s.response {
select {
case <-ctx.Done():
s.t.Log("spy store got cancelled")
return
default:
time.Sleep(10 * time.Millisecond)
result += string(c)
}
}
data <- result
}()
select {
case <-ctx.Done():
return "", ctx.Err()
case res := <-data:
return res, nil
}
}
스파이를 수정하여 context를 실제로 사용하는 메서드처럼 바꿔보자.
결과값의 문자열을 한글자씩 덧붙이는 느린 모의 프로세스 고루틴을 만들자. 고루틴이 완료됨과 동시에 data 채널에 결과값을 보내주고 고루틴에서 ctx.Done을 대기하여 값이 들어올 경우 작업을 중단하게 해보자.
마지막으로 추가적인 select 문을 사용하여 해당 고루틴이 완료되거나 취소되는 것을 기다리도록 하자.
이전의 접근 방식과 비슷하지만 이번에는 고에 내장된 동시성 기능을 사용하여 두개의 비동기 프로세스를 경합시켜 반환할 값을 정하게 된다.
context를 사용하는 함수들과 메서드들을 만들 경우 아래와 비슷한 접근 방식을 사용하게 되므로 꼭 작동 방식을 이해해보도록 하자.
이제 테스트들을 수정해줄 차례이다. 이전에 진행한 취소 테스트를 지워줌으로써 행복한 경로 테스트를 수정해 보자.
t.Run("returns data from store", func(t *testing.T) {
data := "hello, world"
store := &SpyStore{response: data, t: t}
svr := Server(store)
request := httptest.NewRequest(http.MethodGet, "/", nil)
response := httptest.NewRecorder()
svr.ServeHTTP(response, request)
if response.Body.String() != data {
t.Errorf(`got "%s", want "%s"`, response.Body.String(), data)
}
})
오류가 발생할 경우 응답으로 아무것도 출력되지 않는 것을 테스트해야 한다. 안타깝게도 httptest.ResponseRecorder는 이러한 기능을 제공하지 않으므로 스파이를 추가하여 이를 테스트 해보자.
type SpyResponseWriter struct {
written bool
}
func (s *SpyResponseWriter) Header() http.Header {
s.written = true
return nil
}
func (s *SpyResponseWriter) Write([]byte) (int, error) {
s.written = true
return 0, errors.New("not implemented")
}
func (s *SpyResponseWriter) WriteHeader(statusCode int) {
s.written = true
}
위의 SpyResponseWriter는 http.ResponseWriter 인터페이스를 구현하기에 테스트에 사용할 수 있다.
t.Run("tells store to cancel work if request is cancelled", func(t *testing.T) {
store := &SpyStore{response: data, t: t}
svr := Server(store)
request := httptest.NewRequest(http.MethodGet, "/", nil)
cancellingCtx, cancel := context.WithCancel(request.Context())
time.AfterFunc(5*time.Millisecond, cancel)
request = request.WithContext(cancellingCtx)
response := &SpyResponseWriter{}
svr.ServeHTTP(response, request)
if response.written {
t.Error("a response should not have been written")
}
})
테스트 실행해보기
=== RUN TestServer
=== RUN TestServer/tells_store_to_cancel_work_if_request_is_cancelled
--- FAIL: TestServer (0.01s)
--- FAIL: TestServer/tells_store_to_cancel_work_if_request_is_cancelled (0.01s)
context_test.go:47: a response should not have been written
테스트를 통과하는 최소한의 코드 작성하기
func Server(store Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
data, err := store.Fetch(r.Context())
if err != nil {
return // todo: log error however you like
}
fmt.Fprint(w, data)
}
}
이제 서버 코드가 매우 간단해진 것을 확인할 수 있다. 직접적으로 취소하는 데에 관여하지 않고 단순히 context를 넘겨주고 이후로 호출된 함수들에게 의존하기 때문이다.
정리
이 챕터에서 다룬 내용
클라이언트가 요청을 취소할 때의 HTTP 핸들러를 테스트 하는 방법
컨텍스트를 사용하여 취소를 관리하는 법
context를 인수로 받는 함수를 작성하고 고루틴, select 문, 채널을 이용하여 해당 컨텍스트를 취소하는 방법
구글의 가이드라인에 나와있는 데로 요청에 대한 호출 스택에 유효한 컨텍스트를 전파하여 취소를 관리하는 법
몇몇 엔지니어들은 context를 통해 값들을 전해주는 것이 편리하다는 이유로 옹호하곤 한다.
하지만 편의성은 종종 나쁜 코드를 만들어낸다.
context.Values는 단순히 타입이 지정되지 않은 맵이기 때문에 타입 안전성이 보장되지 않고 실제로는 가지고 있지 않은 값을 처리해줘야 하는 문제점을 가지고 있다. 한 모듈에서 다른 모듈로 보낼 경우 맵 키들의 대응 목록을 만들어줘야 하고, 누군가 코드를 수정하기 시작하면 문제가 발생하기 시작한다.
다시 말해, 함수에 값을 넘겨주려면 context.Value를 사용하지 말고 타입이 지정된 인수로 넘겨줘야 한다. 이는 정적으로 해당 부분이 검수되게 하고 모든 사람이 문서를 읽을 수 있도록 한다.
하지만...
트레이스 id와 같이 요청과 관련없는 정보를 이용할 때에는 도움이 될 수 있다. 호출 스택의 모든 함수에서 해당 정보를 필요로 할 가능성은 낮은 데다 이를 함수 인수로 포함할 경우 함수의 시그니쳐가 복잡해질 수 있기 때문이다.
context.Value의 내용은 사용자를 위한 것이 아니라 관리자를 위한 것이다. 기대되거나 문서화된 결과 값에 필요한 입력값이 되어서는 절대 안된다.
추가 자료
필자는 Michal Štrba의 Go 2에서는 컨텍스트가 없어져야 한다를 흥미롭게 읽었다. 그가 주장하는 바는 context를 모든 곳에서 넘겨줘야하는 것은 탐탁하지 않고 이는 곧 고 언어가 가진 취소를 관리하는 데에 있어서의 부족함을 드러낸다는 것이다. 그는 또한 이러한 문제점이 라이브러리 레벨이 아닌 언어적인 레벨에서 수정되기를 바란다. 이러한 문제점이 해결되기 전까지는 오래 실행되는 프로세스를 관리하는 데에 있어 context는 필요한 존재이다.