Language/Go

Go - Context

ocwokocw 2022. 2. 27. 23:02

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

- 출처: https://pkg.go.dev/context

- 출처: https://go.dev/blog/context

- 출처: https://faiface.github.io/post/context-should-go-away-go2/

- Context

Software를 구축하다보면 긴 생명주기를 갖거나 자원 집약적인 process들을 실행시켜야 하는 경우가 있다. 어떤 이유에 의해 이런 행위를 발생시킨 요청이 취소되거나 실패한 경우, application을 통해 일관된 방식으로 이런 프로세스들을 멈추도록 해야 한다.
 
이런 조치를 취하지 않으면 성능 문제를 디버깅 하느라 상당히 애를 먹을 수 있다. 이번 챕터에서는 context pakcage 를 사용해서 긴 생명주기를 갖는 프로세스들을 관리해본다.

- Fetch Example

이런 상황을 재연하기 위해 일단 데이터를 가져와서 응답하는데 오래 걸리는 웹서버를 구축한다. 사용자가 해당 웹서버로 요청을 날리고 응답을 받기 전에 요청을 취소해서 해당 프로세스가 멈추게 해본다.
 
우선 간단한 서버 코드로 시작해보자.
 
func Server(store Store) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprint(w, store.Fetch())
	}
}
 
Server 함수는 Store를 받아서 http.HandlerFunc를 반환한다. Store는 아래와 같이 정의한다.
 
type Store interface {
	Fetch() string
}
 
반환된 함수는 데이터를 얻기 위해 store의 Fetch 메소드를 호출하고 응답에 해당 데이터를 쓴다. test에 사용해야 할 Store stub을 아래와 같이 작성해준다.
 
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)
	}
}
 
위와 같이 정상적인 경우를 작성했으니 이제 사용자가 취소하기 전에 Fetch를 끝내지 못하는 Store 시나리오를 만들어보자.
 

- Spy Store

handler가 Store에게 취소하라고 요청할 수 있도록 interface를 갱신해주자.
 
type Store interface {
	Fetch() string
	Cancel()
}
 
data를 반환하는데 오래 걸리면서 취소 되었다는 것을 인지할 수 있는 Spy 객체를 작성해주자. 기존에는 Stub이었지만 기록을 해야하므로 이름도 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
}
 
100ms가 되기 전에 요청을 취소하고, 취소가 되었는지 확인하는 새로운 test를 작성해준다.
 
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.Error("store was not told to cancel")
	}
})
 
context package는 기존에 존재하던 Context로 부터 새로운 값의 Context를 파생하는 함수를 제공한다.(request.Context()로 부터 새로운 취소 context 파생) 이런 값들은 tree형태를 갖는데, Context가 취소되면 해당 Context로 부터 파생된 모든 Context들도 취소된다.
 
여기서 중요한 점은 취소요청이 주어진 요청의 호출 stack 전체에 파생될 수 있도록 context를 파생시켜야 한다는 점이다.
 
위의 코드에서는 cancel 함수를 반환하는 request로 부터 새로운 cancellingCtx 파생하는 작업을 하였다. 그 후 time.AfterFunc를 이용하여 해당 함수를 5ms에 호출될 수 있도록 하였다. 마지막으로 request.WithContext를 호출해서 요청내에서 새로운 Context를 사용하였다.
 
test를 수행하면 우리가 예상한대로 설정한 메시지와 함께 실패했다는 문구가 나올것이다. 이 test 코드가 성공하도록 Cancel을 호출해주도록 하자.
 
func Server(store Store) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		store.Cancel()
		fmt.Fprint(w, store.Fetch())
	}
}
 
위와 같이 코드를 작성하면 test는 통과하지만 모든 요청에서 fetch 하기전에 Store를 취소하는것을 원하는건 아니기 때문에 코드를 수정해줄 필요가 있다.
 
store에서 데이터를 성공적으로 반환하는 경우에는 취소되면 안된다는점을 test case에 추가해주도록 하자. 
 
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")
	}
})
 
2가지 test를 실행하면 성공적으로 데이터를 반환하는 test case는 실패해야 한다. 정상적으로 data가 반환되는 경우에는 성공하도록 구현 세부사항을 변경해주자.
 
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는 context가 완료되거나 취소될 때, 신호를 전송하는 channel을 반환하는 Done() 메소드를 갖고 있다. 이 신호를 기다리다가 송신받으면 store.Cancel을 호출한다. 하지만 만약 Store가 이런 상황이 생기기전에 데이터를 반환하면 해당 작업을 무시해야한다.
 
이를 구현하기 위해서 goroutine 에서 Fetch()를 실행하고 data channel에 결과를 쓴다. 그리고 select문을 이용하여 2개의 비동기 프로세스에 대한 race 상태를 기다리면서 response에 결과를 쓰거나 Cancel을 호출한다.
 
type SpyStore struct {
	response  string
	cancelled bool
	t         *testing.T
}

func (s *SpyStore) assertWasCancelled() {
	s.t.Helper()
	if !s.cancelled {
		s.t.Error("store was not told to cancel")
	}
}

func (s *SpyStore) assertWasNotCancelled() {
	s.t.Helper()
	if s.cancelled {
		s.t.Error("store was told to cancel")
	}
}
 
위와 같이 spy에 assertion 메소드를 추가해서 가독성을 높여주도록 하자. spy 멤버로 추가된 testing.T는 spy를 생성할 때 주입해줘야 한다.
 
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()
	})
}
 
 

- Context 전파

동작측면에서는 괜찮은 접근법이긴 하다. 하지만 괜찮은 코드인지 생각해보아야하는 점이있다.
 
웹서버가 수동으로 Store를 취소하는것과 연관되는게 맞을까? Store가 다른 느린 프로세스들에 의존하면 어떻게 될까? Store.Cancel이 정확하게 모든 연관된것들에게 취소요청을 전파하게 해야한다.
 
context에서 가장 중요한것중 하나는 취소를 제공하는 일관된 방법이라는것이다. 아래 문구는 https://pkg.go.dev/context 의 부분을 인용한것이다.
 
서버로 들어오는 요청들은 Context를 생성해야하고 나가는 호출들은 Context를 수락해야한다. 함수 호출 chain간의 반드시 Context를 전파해야하며, 선택적으로 WithCancel, WithDeadline, WithTimeout, WithValue를 사용하여 생성한 파생된 Context로 대체해야 한다. Context가 취소될 때, 해당 Context로 부터 파생된 모든 Context들 또한 취소되어야 한다.
 
아래는 https://go.dev/blog/context 에 나오는 문구이다.
 
Google에서는 Go 프로그래머들에게 유입되거나 나가는 요청들 사이의 호출 경로에 있는 모든 함수의 첫번째 인자로 Context 파라미터를 넘기도록 한다. 이렇게 하면 다른 팀들에 의해 개발된 Go code여도 상호작용이 잘된다. 또한 timeout이나 취소를 간단히 제어할 수 있고 보안 자격과 같은 중요한 값이 Go 프로그램으로 적절하게 전송되도록 해준다.
 
잠시 context내에서 보내야 하는 모든 기능의 파급효과를 생각해보자.
 
무슨말인지 모를 수도 있고, 아마 구체적으로 생각하는게 쉽지 않을것이다. 한번 직접 이 사상을 따라해보자. context를 통해 Store에게 전달하고 책임을 전가하자. 또한 이런식으로 하면 의존자들에게도 context를 전달할 수 있고 스스로가 멈출 책임을 갖게할수도 있다.
 
책임이 변경됨에 따라 test 코드도 수정해야 한다. 이제 handler가 갖는 유일한 책임은 context를 Store에게 전달하고, 취소되었을 때 Store로 부터 발생하는 에러를 다루는것이다.
 
책임이 변경되었으므로 Store 인터페이스를 갱신해야 한다.
 
type Store interface {
	Fetch(ctx context.Context) (string, error)
}
 
handler 내의 코드도 지워주자.
 
func Server(store Store) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
	}
}
 
SpyStore를 갱신하자.
 
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():
				log.Println("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
	}
}
 
우리의 spy가 context와 함께 동작하는 실제 메소드처럼 보이게 만들어야 한다.
 
위의 코드에서 goroutine 내부 부분을 살펴보자. 한 문자마다 sleep을 주고 결과 문자열에 추가해서 느린 프로세스를 시뮬레이팅하고 있다. goroutine이 작업을 끝내면 문자열을 data channel에 쓴다. goroutine은 ctx.Done 신호를 대기하고 있는데 만약 신호가 channel에 전송되면 작업을 멈춘다.
 
마지막으로 select문에서는 goroutine의 작업의 완료신호나 취소 신호를 대기하고 있다. 위의 과정은 이전의 접근법과 비슷한데, 어떤 것을 반환해야할 지 결정하기 위해 Go의 병렬성 primitive문법을 이용하여 2개의 비동기 프로세스들이 race 상태를 갖도록 만들었다.
 
마지막으로 test 코드를 갱신해준다. 취소 test를 주석처리해주고, 우선 정상인 경우에 대한 test 코드를 먼저 고쳐주도록 하자.
 
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)
	}
})
 
test를 실행해보면 빈값이 반환될것이므로 test가 실패할것이다. test가 통과할 수 있게 수정해보도록 하자.
 
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)
	}
}
 
위의 코드를 보면 server가 더이상 취소 동작에 대한 책임에서 벗어났고, context를 통해 취소가 발생할 수 있는 가능성을 위한 downstream 함수에 의존한다. 즉 코드를 수정하기 전에는 handler 내에서 단순하게 데이터를 얻기 위해 Fetch를 호출하고, context의 Done과 데이터의 반환을 select문으로 race condition 상태에서 대기했지만 이런 복잡한 처리를 Fetch로 위임하고 handler는 data반환이나 error반환에 따라서만 적절한 동작을 취하고 있다.

- context.Value

https://faiface.github.io/post/context-should-go-away-go2/ 에서는 다음과 같이 주장하고 있다.
 
만약 내 회사에서 ctx.Value를 쓴다면 당신은 해고이다.
 
일부 엔지니어들은 context를 통해 값을 전달하는것이 편하므로 이를 옹호하기도 한다. 하지만 편안함이라는건 때로는 나쁜 코드의 원인이 된다.
 
context.Value의 문제점은 형식이 없는 map과 같아서 형 안전성을 보장할 수 없고, 실제로 값이 없을 경우에 대한 처리도 해주어야 한다는점이다. 하나의 모듈에서 다른 모듈에 대해 map key들의 의존성이 생기므로 누군가 한 부분을 바꾼다면 난처한 상황이 생길 수 있다.
 
만약 함수가 어떤 값을 필요로한다면 context.Value로 부터 값을 추출하지 말고 형식화된 파라미터로 넘겨야 한다는것이다. 이렇게 해야 명시적으로 체크할 수 있고, 다른 사람들이 알 수 있다.
 
하지만 해당 기능이 언제나 나쁜것은 아니다. context내의 요청과 관련된 정보를 포함하는(ex - trace id)것과 같이 유용한 사용법도 있다. 
 
 
context.Value의 내용은 사용자를 위한것이 아니라 유지보수 담당자를 위한것이다. 해당값은 문서화나 예측된 결과를 위해서 필수값이 되면 안된다.