데이터를 전송하다보면 특정 request에 대하여 metadata를 설정하고 싶을 때가 있다. 보통 request를 전달-전달-전달하는 하나의 chain형식에서 해당 request에 대한 고유 속성을 부여해 특별한 처리를 해주고 싶을 때 사용한다.
metadata는 크게 두가지 카테고리로 구별할 수 있다. 하나는 request를 정확히 처리하기 위해서 필요한 metadata이고, 하나는 언제 request처리를 중단할 것인지를 위한 metadata이다.
가령, HTTP server가 microservice구조에서 일련의 request룰 식별하고 싶을 때 tracking ID
를 사용할 수 있다. 또한, request가 너무 길어지면 timer를 걸어서 request를 중단시킬 수 있다.
go는 request metadata문제를 context
라는 개념을 통해 해결하였다.
Context
는 새로운 키워드가 아니라 그저 Context
interface를 만족하는 단순한 객체이다. 또한, 사용 방법 또한 특별한 것이 있는 게 아니라, 자연스러운 golang의 사용방법과 같이 함수에 파라미터로 Context
객체를 넘겨주어 사용하면 된다.
단, Context
를 사용할 때는 하나의 규율이 있는데 반드시 함수의 맨 앞부분으로 파라미터 값을 정의한다는 것이다. 다음을 보도록 하자.
func logic(ctx context.Context, info string) (string, error) {
// do some interesting stuff here
return "", nil
}
위의 예시와 같이 context.Context
는 함수의 가장 첫번째 파라미터로 주는 것이 하나의 관례이다.
context
package는 Context
interface 뿐만 아니라 몇 가지 context객체를 제공해주는 함수를 포함한다. 만약, 어떠한 context를 들고 있지 않다면 context.Background
함수를 통해서 빈 초기 context객체를 생성할 수 있다. context.Background
함수는 context객체를 생성하되 Context
interface로 리턴값을 반환하기 때문에 그 내부를 볼수는 없다.
빈 context는 시작점으로 context에 metadata를 추가할 때마다 factory function을 호출하여 기존의 context를 wrapping한다.
참고로 context.TODO
도 초기에 빈 context객체를 제공해주지만, 오직 개발 환경에서만 사용하는 것이 좋다. context.TODO
는 어디서 context 객체가 오는 지 모르거나, 어떻게 쓰일지 모를 때 사용하는 것으로 production환경에서는 쓰지 않는 것이 좋다.
golang의 net/http
handler에서는 아쉽게도 context.Context
가 명시적으로 함수에 정의되어있지 않다. 이는 net/http
가 context.Context
보다 먼저 만들어졌기 때문이다.
따라서, golang은 http.Request
에 다음의 두 가지 함수를 제공하여 context
를 사용할 수 있도록 했다.
Context()
: 해당 request와 관련된 context.Context
를 반환한다.WithContext()
: context.Context
를 받아 새로운 http.Request
를 반환한다. 이때 이전의 request state는 제공된 context.Context
에 결합된다.일반적인 패턴은 다음과 같다.
func Middleware(handler http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
ctx := req.Context()
// wrap the context with stuff -- you'll see how soon!
req = req.WithContext(ctx)
handler.ServeHTTP(rw, req)
})
}
ctx := req.Context()
다음의 라인을 통해서 middleware의 handler가 request
에 관련된 context
객체를 뽑아내는 것을 볼 수 있다.
현재 req
에 관련된 metadata가 담긴 ctx
에 요리조리 원하는 로직을 추가하고, 추가한 metadata를 담은 ctx
를 req.WithContext
에 제공하면, 이전 request를 기반으로 metadata context를 채운 새로운 request를 반환해준다.
그리고 새롭게 만든 req
객체를 handler
의 ServeHTTP
로 넘겨준다. 이렇게되면 다음 handler에 넘어가게되어 실행되는 것이다.
일반적으로 http handler에서 context를 사용할 때는 Context()
함수를 호출하여 context
객체를 받아내고 이를 개발자의 business logic에 실행시키는 것이 보통의 경우이다.
func handler(rw http.ResponseWriter, req *http.Request) {
ctx := req.Context()
err := req.ParseForm()
if err != nil {
rw.WriteHeader(http.StatusInternalServerError)
rw.Write([]byte(err.Error()))
return
}
data := req.FormValue("data")
result, err := logic(ctx, data)
if err != nil {
rw.WriteHeader(http.StatusInternalServerError)
rw.Write([]byte(err.Error()))
return
}
rw.Write([]byte(result))
}
만약, 개발자의 application에서 외부로 요청을 보낼 때 Context
를 추가하고 싶다면, http.NewRequestWithContext
를 통해서 현재의 Context
정보를 담은 request를 만들 수 있다.
type ServiceCaller struct {
client *http.Client
}
func (sc ServiceCaller) callAnotherService(ctx context.Context, data string)
(string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
"http://example.com?data="+data, nil)
if err != nil {
return "", err
}
resp, err := sc.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("Unexpected status code %d",
resp.StatusCode)
}
// do the rest of the stuff to process the response
id, err := processResponse(resp.Body)
return id, err
}
이제 어떻게 context를 얻고 보내는 지 알 수 있게 되었으므로, Context
를 활용해보도록 하자.
golang은 explicit하게 data를 전달하는 방법을 좋아하지 implicit하게 data를 숨기고 알아서 처리하는 것을 좋아하지 않는다. 따라서, data를 함수에 전달해야하는 부분이 있다면 거의 대부분 함수의 파라미터로 넘기는 것이 좋다.
하지만 안타깝게도 그렇지 못하는 상황이 있다. 가령 HTTP request handler와 middleware 같은 경우가 있는데, 이미 함수의 파라미터가 정해져있어 data를 explicit하게 전달히지 못하는 경우이다.
이런 경우에 context
에 관련된 data를 전달하여 사용하는 것이다. 가령, http request에서 인가된 사람인지를 확인하기 위해서 JWT를 확인하거나 per-request GUID를 확인하는 경우가 있다.
이렇게 context
에 data를 넣기 위해서 사용하는 factory함수가 있는데 context.WithValue()
이다. 해당 함수는 3개의 파라미터를 받는데, 하나는 data를 추가할 context
객체이고 하나는 key, 하나는 value 그자체이다. 참고로 key
와 value
는 any
타입이므로 어떤 값이든 받을 수 있다.
context.WithValue
함수는 context를 반환하는데, 이 context
객체는 파라미터로 넣은 context
가 아니다. 대신에 child context인데, 파라미터로 넣은 parent context객체에 key-value pair를 포함하는 새로운 context인 것이다.
이렇게 기존의 context
객체를 기반으로 wrapping하여 새로운 context
객체를 만드는 이유는 context
자체가 하나의 immutable로 평가되기 때문이다. 이 덕분에 context
는 더욱 깊은 layer로 data를 전달할 수 있게 되는 것이다. 따라서 계속해서 wrapping하여 child context를 만들어내는 것이지 child context에서 parent context로 넘어가진 않는다.
context.Context
의 Value()
method는 context에 value가 존재하는 지 확인해준다. 이 method는 key를 받아서 value를 반환해주는데, 반환된 value의 type이 any
이므로 타입을 알고있어야 한다. 만약, key
에 해당하는 value
가 없었다면 nil
이 나온다.
ctx := context.Background()
if myVal, ok := ctx.Value(myKey).(int); !ok {
fmt.Println("no value")
} else {
fmt.Println("value:", myVal)
}
context객체의 chain에 있어서 Value()
method는 값을 찾아가는데 있어 linear search를 사용한다. value를 몇 가지만 사용한다면 성능에 있어서 큰 문제는 없지만, 다수의 value를 context에 저장하고 불러온다면 성능이 매우 떨어질 수 있다. 만약 context chain에 너무 많은 value들이 있다면 리팩토링이 필요하다.
context
객체와 map
을 사용할 때 중요한 것 중 하나는 key
값을 어떻게 선택할 것인가이다. 단순 string
이나 predefined된 int값들을 key로 쓰는 것은 좋은 생각이 아니다. 왜냐하면 다른 package에 import되어 사용될 때 충돌이 발생할 수 있거나, 다른 package에서 key를 마구잡이로 가져와 사용할 수도 있기 때문이다.
따라서, 다음의 pattern이 사용되는데, unexported int type의 user defined type을 하나 만들고, 이에 대해서 iota
로 enum값 key들을 만들어내는 것이다.
type userKey int
다음과 같이 userKey
type을 만들되 unexported타입으로 만드는 것이 핵심이다.
이제 unexported type
을 기반으로 enum값을 만들어내어 key로 사용하면 된다.
const (
_ userKey = iota
key
)
key
도 key
의 type도 모두 unexported이기 때문에 외부에서 개발자가 만든 package의 key와 동일한 key를 생성해낼 수 없다.
가령, 다음을 보도록 하자.
const (
_ userKey = iota
USER_NAME
USER_KEY
)
USER_KEY
는 2값을 가지지만 int
tpye의 2
와는 다르다. userKey
타입의 2
이다. 따라서, 이들은 서로 다르다. 생각해보면 단순한 이유인데, golang은 강타입 언어로 같은 값이라도 타입이 다르면 다르게 보기 때문이다. 따라서, comparable
한 key값으로 위와 같이 unexported type
과 해당 타입으로 만든 iota
key를 사용하는 것이 좋다.
그런데 만약 다른 패키지에서 해당 패키지의 context
객체의 value에 접근해야할 때는 어떻게해야할까?? type도 key도 unexported이기 때문에 접근할 방법이 없다. 따라서, getter, setter와 같은 것을 context에 제공해주는 것이 좋다.
여기에도 관례가 하나있는데, context
에 특정 value를 추가할 때는 ContextWith~
을 접두사로 붙이는 함수를 만들고, context
에서 value
를 가져올 때는 ~FromContext
라는 접두사를 붙여준다.
가령, key
라는 key값에 대하여 다음과 같이 만들 수 있다.
func ContextWithUser(ctx context.Context, user string) context.Context {
return context.WithValue(ctx, key, user)
}
func UserFromContext(ctx context.Context) (string, bool) {
user, ok := ctx.Value(key).(string)
return user, ok
}
이제 위의 방법을 통해서 middleware에서 cookie의 user ID를 가져오는 code를 만들어보도록 하자.
// a real implementation would be signed to make sure
// the user didn't spoof their identity
func extractUser(req *http.Request) (string, error) {
userCookie, err := req.Cookie("identity")
if err != nil {
return "", err
}
return userCookie.Value, nil
}
func Middleware(h http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
user, err := extractUser(req)
if err != nil {
rw.WriteHeader(http.StatusUnauthorized)
rw.Write([]byte("unauthorized"))
return
}
ctx := req.Context()
ctx = ContextWithUser(ctx, user)
req = req.WithContext(ctx)
h.ServeHTTP(rw, req)
})
}
cookie
에서 user에 대한 정보를 추출하고 request의 context에 user정보를 추가하는 코드이다. ContextWithUser
를 통해서 context에 user정보를 추가하고 있다.
다음으로 handler에서 우리가 추가한 user정보를 context
에서부터 뽑아와보도록 하자.
func (c Controller) DoLogic(rw http.ResponseWriter, req *http.Request) {
ctx := req.Context()
user, ok := identity.UserFromContext(ctx)
if !ok {
rw.WriteHeader(http.StatusInternalServerError)
return
}
data := req.URL.Query().Get("data")
result, err := c.Logic.BusinessLogic(ctx, user, data)
if err != nil {
rw.WriteHeader(http.StatusInternalServerError)
rw.Write([]byte(err.Error()))
return
}
rw.Write([]byte(result))
}
UserFromContext
으로 context객체에서 user정보를 가져오는 것을 볼 수 있다. handler에서 복잡하게 cookie에 접근할 필요도 없고, context에서 직접 data를 가져와 변환할 필요도 없다. 이렇게하면 handler에 business logic에 관련된 코드만 넣을 수 있어 좋다.
어떤 경우에는 context에 value를 유지하는 게 좋은 경우가 있다. GUID를 트랙킹하는 경우가 대표적이다. GUID값은 단순히 비지니스 로직을 위해 존재하는 값이 아니라, application관리를 위해 존재하는 값이다. GUID와 같은 값을 받기위해 explicit하게 metadata를 받는 함수를 만드는 것은 적절하지 않다. 해당 metadata는 비지니스 로직을 위해서라기 보다는 application 디버깅을 위해서이고, 외부 라이브러리에서도 또는 외부 서버에서도 자유롭게 받아들여 사용할 수 있어야하기 때문이다.
따라서 GUID값을 context에 넣어 비지니스 로직에 관계없이 log message를 쓰거나 또 다른 server에 견결할 때 쓰도록 한다.
다음은 GUID를 context와 함께 사용하여 service를 GUID로 tracking하도록 하는 것이다.
package tracker
import (
"context"
"fmt"
"net/http"
"github.com/google/uuid"
)
type guidKey int
const key guidKey = 1
func contextWithGUID(ctx context.Context, guid string) context.Context {
return context.WithValue(ctx, key, guid)
}
func guidFromContext(ctx context.Context) (string, bool) {
g, ok := ctx.Value(key).(string)
return g, ok
}
func Middleware(h http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
ctx := req.Context()
if guid := req.Header.Get("X-GUID"); guid != "" {
ctx = contextWithGUID(ctx, guid)
} else {
ctx = contextWithGUID(ctx, uuid.New().String())
}
req = req.WithContext(ctx)
h.ServeHTTP(rw, req)
})
}
type Logger struct{}
func (Logger) Log(ctx context.Context, message string) {
if guid, ok := guidFromContext(ctx); ok {
message = fmt.Sprintf("GUID: %s - %s", guid, message)
}
// do logging
fmt.Println(message)
}
func Request(req *http.Request) *http.Request {
ctx := req.Context()
if guid, ok := guidFromContext(ctx); ok {
req.Header.Add("X-GUID", guid)
}
return req
}
Middleware
함수는 들어온 request의 header로부터 GUID
를 가져오고 이를 context
에 넣어주거나 GUID
를 만들어 context
에 넣어준다. 그 다음 GUID
정보가 담긴 context를 다음 handler에 넘겨주도록 context정보를 담은 request를 만들어 전달해준다.
다음은 Logger
구조체의 Log
method를 통해서 어떻게 GUID
가 사용되고 있는 지를 보여주고 있다. context
에 GUID
가 있다면 log에 해당 기록을 설정해주도록 하는 것이다. Request
함수는 application이 또 다른 service를 call할 때 사용하는 함수로 *http.Request
를 파라미터로 받아서 context
를 가져와 GUID
를 header에 설정해주도록 한다.
이렇게 기능을 만들었다면 business logic은 GUID를 이용한 tracking 기능을 모른채로 자신의 로직을 동작시킬 수 있다. GUID를 tracking하는 기능에 대해서는 interface
민을 만들어 필요한 기능을 dependency injection시키도록 하자. 또한, function type을 만들어 request를 decorating해주고 이들을 business logic 구조체에 넣어 사용할 수 있도록 해주자.
type Logger interface {
Log(context.Context, string)
}
type RequestDecorator func(*http.Request) *http.Request
type LogicImpl struct {
RequestDecorator RequestDecorator
Logger Logger
Remote string
}
이제 우리의 business logic은 다음과 같아진다.
func (l LogicImpl) Process(ctx context.Context, data string) (string, error) {
l.Logger.Log(ctx, "starting Process with "+data)
req, err := http.NewRequestWithContext(ctx,
http.MethodGet, l.Remote+"/second?query="+data, nil)
if err != nil {
l.Logger.Log(ctx, "error building remote request:"+err.Error())
return "", err
}
req = l.RequestDecorator(req)
resp, err := http.DefaultClient.Do(req)
// process the response...
}
business logic은 이 안에서 GUID가 logger에 의해서 사용되고 RequestDecorator
에 의해 설정되고 잇는 지 모른다. 이는 program logic과 program management data를 분리한 것으로 오직 main
code이외에는 이 사실에 대해서 알 수 없다.
controller := Controller{
Logic: LogicImpl{
RequestDecorator: tracker.Request,
Logger: tracker.Logger{},
Remote: "http://localhost:4000",
},
}
context는 또한 application의 반응을 제어하고 현재 goroutine을 조정한다.
여러 goroutine이 서로 다른 HTTP service를 호출한다고 생각해보자. 만약 한 service가 error를 호출할 때, 다른 goroutine들이 진행 중인 HTTP service를 취고시킬 방법이 딱히 없다. go에서 이를 cancellation이라고 하며 context가 이에 대한 구체적인 방법을 제시해준다.
취소 가능한 context를 만들기 위해서는 context.WithCancel
function을 사용하여 context를 만들면 된다. context.WithCancel
은 context.Context
를 매개변수로 받고, 반환 값으로 context.Context
와 context.CancelFunc
을 제공한다.
반환된 context.Context
는 위의 다른 예제와 마찬가지로 매겨변수로 받은 context를 wrapping하여 만든 child context이다. context.CancelFunc
은 어떠한 파라미터도 받아들이지 않고 context를 cancel하는 기능만 가진다.
언제나 cancel function과 관련된 context를 만든다면, context 동작이 완료된 이후에 반드시 cancel function을 실행시켜주어야 한다. 만약 cancel function을 실행해주지 않으면 메모리 또는 goroutine과 같은 resource에 누수가 발생할 것이다. 이는 결국 program의 성능을 크게 저하시킨다. 참고로, cancel function은 첫번째 호출 이후로 두번째 호출을 해도 아무런 문제가 없고, 에러도 발생하지 않는다.
cancel function을 호출하도록 보장하는 가장 좋은 방법은 defer
이다.
ctx, cancelFunc := context.WithCancel(context.Background())
defer cancelFunc()
그런데 어떻게 cancellation을 감지할 수 있는가?? context.Context
인터페이스에는 Done
이라는 메서드를 가지고 있다. 이는 struct{}
channel을 반환한다. 왜냐하면 struct{}
는 일반적으로 비어있는 구조체이기 때문에 메모리를 차지하지 않기 때문이다. (단, 아주 미묘한 상황에서는 메모리를 갖지만, 얼마안된다.)
해당 channel은 cancel
함수가 호출되면 닫힌다. 명심하자, 닫힌 channel은 그 즉시 바로 zero value를 반환하다는 사실을 말이다. 따라서, 바로 해당 channel이 닫혔다는 것을 알 수 있다.
참고로 cancel기능이 없는 context
에서 Done
을 호출하면 nil
이 반환된다. 이전에 살펴보았듯이 nil
channel은 어떠한 것도 반환하지 않기 때문에 select-case
문에서 영원히 hang이 된다.
이제 예시로 확인해보도록 하자. 만약 여러 HTTP endpoint를 통해서 data를 가져오는 program을 만든다고 했을 때, 하나라도 실패하면 이 수행과정을 취소하기로 한다. Context
cancellation이 이를 딱 알맞는 상황인 것이다.
먼저 cancellable context를 하나 만들어주고 goroutine으로부터 data를 받을 channel을 만들어준다. 마지막으로 goroutine에서 모든 동작을 완료할 때까지 기다리는 sync.WaitGroup
을 만들어준다.
ctx, cancelFunc := context.WithCancel(context.Background())
defer cancelFunc()
ch := make(chan string)
var wg sync.WaitGroup
wg.Add(2)
다음으로 두개의 goroutine을 동작시키되 하나는 bad status를 랜덤하게 반환하는 URL로 요청을 보내고, 다른 하나는 delay후 JSON응답을 받도록 한다. 먼저 random status goroutine은 다음과 같다.
go func() {
defer wg.Done()
for {
// return one of these status code at random
resp, err := makeRequest(ctx,
"http://httpbin.org/status/200,200,200,500")
if err != nil {
fmt.Println("error in status goroutine:", err)
cancelFunc()
return
}
if resp.StatusCode == http.StatusInternalServerError {
fmt.Println("bad status, exiting")
cancelFunc()
return
}
select {
case ch <- "success from status":
}
time.Sleep(1 * time.Second)
}
}()
makeRequest
함수는 제공된 context와 URL을 사용해서 HTTP request를 전달하는 helper function이다. 만약 OK status을 받으면 channel에 data를 써주고 1초 정도 sleep한다. 만약 error응답을 받거나 에러가 발생한다면 cancelFunc
을 호출하고 goroutine을 나간다.
다음은 delay가 발생하는 goroutine이다.
go func() {
defer wg.Done()
for {
// return after a 1 second delay
resp, err := makeRequest(ctx, "http://httpbin.org/delay/1")
if err != nil {
fmt.Println("error in delay goroutine:", err)
cancelFunc()
return
}
select {
case ch <- "success from delay: " + resp.Header.Get("date"):
}
}
}()
이제 마지막으로 for-select
pattern을 사용하여 channel을 읽고 cancellation이 발생하기를 기다리도록 하자.
loop:
for {
select {
case s := <-ch:
fmt.Println("in main:", s)
case <-ctx.Done():
fmt.Println("in main: cancelled!")
break loop
}
}
wg.Wait()
위 select
문은 두 가지 경우를 커버하는데 하나는 channel로부터 data를 가져오는 것이고 하나는 done channel이 닫히기 전까지 기다리는 것이다. done channel이 닫히면 loop에서 벗어나고 goroutine이 끝나기까지 기다린다.
전체코드는 다음과 같다.
package main
import (
"context"
"fmt"
"net/http"
"sync"
"time"
)
func makeRequest(ctx context.Context, url string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
return http.DefaultClient.Do(req)
}
func main() {
ctx, cancelFunc := context.WithCancel(context.Background())
defer cancelFunc()
ch := make(chan string)
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
for {
resp, err := makeRequest(ctx, "http://httpbin.org/status/200,200,200,500")
if err != nil {
fmt.Println("error in status goroutine:", err)
cancelFunc()
return
}
if resp.StatusCode == http.StatusInternalServerError {
fmt.Println("bad status, exiting")
cancelFunc()
return
}
select {
case ch <- "success from status":
}
time.Sleep(1 * time.Second)
}
}()
go func() {
defer wg.Done()
for {
resp, err := makeRequest(ctx, "http://httpbin.org/delay/1")
if err != nil {
fmt.Println("error in delay goroutine:", err)
cancelFunc()
return
}
select {
case ch <- "success from delay: " + resp.Header.Get("date"):
}
}
}()
loop:
for {
select {
case s := <-ch:
fmt.Println("in main:", s)
case <-ctx.Done():
fmt.Println("in main: cancelled!")
break loop
}
}
wg.Wait()
}
전체 코드를 보면 알 수 있듯이 error날 때까지 계쏙 요청을 보내고, error가 발생하면 Done
channel로부터 응답을 받아 loop를 중지시키는 것이다.
실행시켜보면 다음의 결과를 얻게된다.
go run ./main.go
in main: success from status
in main: success from delay: Tue, 30 Jan 2024 15:17:40 GMT
in main: success from status
in main: success from delay: Tue, 30 Jan 2024 15:17:42 GMT
bad status, exiting
in main: cancelled!
error in delay goroutine: Get "http://httpbin.org/delay/1": context canceled
아주 재밌는 점이 있는데 첫번째는 cancelFunc
을 여러 번 부르고 있다는 것이다. 이전에 언급했던 것과 같이 여러번 호출해도 별 문제가 없다. 다음으로 cancellation이 발생한 이후로 delay goroutine으로부터 error msg를 받았다는 것이다. 이는 built-in HTTP golang client에서 cancellation을 확인하여 request를 취소시켰기 때문이다.
일반적으로 cancellation이 발생했다는 것은 어떠한 문제가 있다는 것이므로, 이 문제를 기록하고 싶을 것이다. 안타깝게도 WithCancel
에서 제공하는 cancel function은 입력값이 없어 error msg를 전송하지 못한다.
그러나 WithCancelCause
를 사용하면 error msg를 파라미터로 받은 cancel function을 생성해준다. context
package의 Cause
함수는 context에 관련된 cancellation function의 첫번째 호출에 전달된 error msg를 반환해준다.
Cause
는 context.Context
가 아닌 함수인데, cancellation에 의한 error반환은 go 1.20에 추가되었었기 때문이다. 만약 context.Context
interface에 추가되었다면 이는 수많은 라이브러리의 하위호환성을 깨뜨렸을 것이다. 따라서 Cause
라는 함수를 추가하는 것이 가장 간단한 해결방법이었던 것이다.
이제 cancellation에서 발생한 error msg를 받기 위해서 context 생성 부분을 변경해보도록 하자.
ctx, cancelFunc := context.WithCancelCause(context.Background())
defer cancelFunc(nil)
다음으로 우리의 두 goroutine을 약간 바꿔주도록 하자.
먼저 bad request를 받는 goroutine에 대해서는 select
문을 없애도록 하고 cancellation을 호출할 때 error msg를 주도록 한다.
resp, err := makeRequest(ctx, "http://httpbin.org/status/200,200,200,500")
if err != nil {
cancelFunc(fmt.Errorf("in status goroutine: %w", err))
return
}
if resp.StatusCode == http.StatusInternalServerError {
cancelFunc(errors.New("bad status"))
return
}
ch <- "success from status"
time.Sleep(1 * time.Second)
다음으로 delay goroutine역시도 마찬가지로 해주도록 하자.
resp, err := makeRequest(ctx, "http://httpbin.org/delay/1")
if err != nil {
fmt.Println("in delay goroutine:", err)
cancelFunc(fmt.Errorf("in delay goroutine: %w", err))
return
}
ch <- "success from delay: " + resp.Header.Get("date")
마지막으로 context.Cause
를 호출하여 error msg를 출력하도록 하자.
loop:
for {
select {
case s := <-ch:
fmt.Println("in main:", s)
case <-ctx.Done():
fmt.Println("in main: cancelled with error", context.Cause(ctx))
break loop
}
}
wg.Wait()
fmt.Println("context cause:", context.Cause(ctx))
전체 코드는 다음과 같다.
package main
import (
"context"
"errors"
"fmt"
"net/http"
"sync"
"time"
)
func makeRequest(ctx context.Context, url string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
return http.DefaultClient.Do(req)
}
func main() {
ctx, cancelFunc := context.WithCancelCause(context.Background())
defer cancelFunc(nil)
ch := make(chan string)
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
for {
resp, err := makeRequest(ctx, "http://httpbin.org/status/200,200,200,500")
if err != nil {
cancelFunc(fmt.Errorf("in status goroutine: %w", err))
return
}
if resp.StatusCode == http.StatusInternalServerError {
cancelFunc(errors.New("bad status"))
return
}
ch <- "success from status"
time.Sleep(1 * time.Second)
}
}()
go func() {
defer wg.Done()
for {
resp, err := makeRequest(ctx, "http://httpbin.org/delay/1")
if err != nil {
fmt.Println("in delay goroutine:", err)
cancelFunc(fmt.Errorf("in delay goroutine: %w", err))
return
}
ch <- "success from delay: " + resp.Header.Get("date")
}
}()
loop:
for {
select {
case s := <-ch:
fmt.Println("in main:", s)
case <-ctx.Done():
fmt.Println("in main: cancelled with error", context.Cause(ctx))
break loop
}
}
wg.Wait()
fmt.Println("context cause:", context.Cause(ctx))
}
해당 code를 동작시키면 다음의 결과를 얻게된다.
go run ./main.go
in main: success from status
in main: success from delay: Tue, 30 Jan 2024 15:44:17 GMT
in main: cancelled with error bad status
in delay goroutine: Get "http://httpbin.org/delay/1": context canceled
context cause: bad status
bad request를 발생시키는 첫번째 goroutine에 의해서 cancellation이 발생하는 것을 볼 수 있다. cancelFunc(errors.New("bad status"))
이 실행되는 것을 볼 수있다.
따라서 main code부분의 fmt.Println("in main: cancelled with error", context.Cause(ctx))
의 context.Cause(ctx)
로 bad status
가 오는 것이다.
재미난 것은 delay goroutine에서 뒤늦게 context가 취소된 것을 알고 cancelFunc(fmt.Errorf("in delay goroutine: %w", err))
을 호출하지만, 별도의 error도 발생하지 않고 cancelFunc
에 쓴 error msg가 main code에 전달도 안된 것을 알 수 있다.
main code의 마지막 줄인 fmt.Println("context cause:", context.Cause(ctx))
에서 또 해당 context의 cancellation msg를 출력해도 context cause: bad status
인 것이다. 즉, cancellation msg는 overwrite가 되지 않고, 처음에 쓴 cancellation msg가 그대로 남는 것을 알 수 있다.
이렇게 canellation을 business logic에 따라 손수 호출하는 것은 매우 효율적이다. 그러나 때떄로 특정 task가 너무 많은 시간이 걸릴 때 자동으로 task를 cancel시키고 싶을 때가 있다. 이때 사용하는 것이 timer이다.
server는 client에게 있어 하나의 공유 자원에 가깝다. 문제는 client들은 다른 client들을 생각하지 않기 때문에, server에 대한 자원을 독점으로 사용하는 경우가 있다. 가령, 너무 많은 시간동안 한 client가 connection을 차지하여 독점적으로 요청을 계속보내는 경우가 있다. 이 경우 다른 client는 connection을 얻지 못하므로 공정하지 못하게 server를 사용하지 못하게 된다.
일반적으로 server는 다음의 4가지를 관리해야한다.
1. 동시 requests에 대한 제한
2. 실행되기 기다리는 request를 queue에 넣는 최대 수를 결정
3. request가 동작하는데 걸리는 시간을 제한
4. request가 사용하는 자원들을 제한, 가령 CPU, memory, disk 등이 있다.
go는 위의 첫번째 3개는 제공해준다. 첫번째는 goroutine의 개수를 제한하면 되고, 두번째는 buffered channel을 통해서 해결하면 된다.
3번째는 context를 통해 구현할 수 있는데, context로 request를 얼마동안 동작시킬 것인지 결정할 수 있다. 몇 초, 몇 분 동안 request를 동작시킬 지 결정하는 것은 user가 불만족스러움을 느끼지 못할 때까지이다. 이는 하나의 가이드라인이 된다. 따라서 request가 동작하는 maximum time을 알고있다면 이를 context에 할당해주면 된다.
시간 제한이 있는 context를 만드는 두 개의 함수가 있는데, 첫번째는 context.WithTimeout
이다. 이는 두 개의 파라미터를 받는데 첫번째는 기존의 context이고, 두번째는 얼마동안 request를 실행할 것인지에 대한 시간인 time.Duration
이다. 이 함수의 반환값으로 context와 cancellation function을 반환한다. 해당 context는 정해진 duration이 지나면 cancellation을 발생시킨다.
두번째 함수는 context.WithDeadline
이다. 이 함수 역시도 두 개의 파라미터를 받는데, 첫번째는 기존의 context를 받고 두번째는 언제 자동으로 context를 취소시킬 지 결정하는 time.Time
을 받는다. context.WithTimeout
과 동일하게 특정 시간에 종료되는 context와 cancellation함수를 반환한다.
context.WithCalcel
과 context.WithDeadline
은 둘 다 cancellation 함수를 반환하기 때문에 반드시 cancellation 함수를 최소한 한번은 실행시켜주어야 resource leak가 발생하지 않는다.
만약, context
가 지정한 시간을 지나서 cancel되었는 지 확인하고 싶다면 Deadline
method를 context.Context
에 써주면 된다. 이는 time.Time
을 반환하는데, 언제 cancellation이 발생하는 지에 대한 시간과 bool
값으로 cancellation되었는지 나타낸다.
특정 service에 대한 request는 사실 하나로 끝나지 않을 수 있다. 하나의 request가 끝나면 다음 request로 넘어가는 하나의 chain을 이룰 수 있는데, 이 chain의 전체 timeout을 관할하고 싶을 수 있다. 즉, 얼마나 오랫동안 network call을 동작시킬 것인지 제한하고 싶을 것이다.
child context에 설정한 시간은 parent context의 시간 아래에 영향을 받는다. 만약, parent context가 2초 후에 timeout이 발생하고 child context는 3초후에 timeout이 발생한다면 parent context가 2초 후 timeout이 발생하면 child context도 timeout될 것이다.
다음의 간단한 예제를 보도록 하자.
ctx := context.Background()
parent, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
child, cancel2 := context.WithTimeout(parent, 3*time.Second)
defer cancel2()
start := time.Now()
<-child.Done()
end := time.Now()
fmt.Println(end.Sub(start).Truncate(time.Second))
위의 예제에서 parent context는 2초 timeout을 지정하였고, child context는 3초 timeout을 지정하였다. <-child.Done()
을 통해서 child context가 timeout되기를 기다리는데 흥미롭게도 실제로 걸린 시간은 3초가 아니라 2초이다.
2s
따라서, parent context는 항상 child context보다 더 오랜시간의 timeout값을 가져야 parent context timeout 내에서 child context가 timeout된다.
context는 cancel함수에 의해서 강제로 취소되거나 timeout에 의해서 취소될 수 있는데, 이를 알기 위해서 context의 Err
method를 사용할 수 있다. Err
method는 context가 여전히 동작하면 nil
을 반환하고 취소될 경우 두 가지 중 하나의 sentinel error를 반환한다. 하나는 context.Canceled
이고 context.DeadlineExceeded
이다. 첫번째는 cancellation 함수의 호출과 같이 명시적인 cancellation이후에 발생하고, 두번째는 timeout된 후에 cancel되어 발생한다.
다음의 예시를 보도록 하자.
ackage main
import (
"context"
"errors"
"fmt"
"net/http"
"sync"
"time"
)
func makeRequest(ctx context.Context, url string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
return http.DefaultClient.Do(req)
}
func main() {
ctx, cancelFuncParent := context.WithTimeout(context.Background(), 3*time.Second)
defer cancelFuncParent()
ctx, cancelFunc := context.WithCancelCause(ctx)
defer cancelFunc(nil)
ch := make(chan string)
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
for {
resp, err := makeRequest(ctx, "http://httpbin.org/status/200,200,200,500")
if err != nil {
cancelFunc(fmt.Errorf("in status goroutine: %w", err))
return
}
if resp.StatusCode == http.StatusInternalServerError {
cancelFunc(errors.New("bad status"))
return
}
ch <- "success from status"
time.Sleep(1 * time.Second)
}
}()
go func() {
defer wg.Done()
for {
resp, err := makeRequest(ctx, "http://httpbin.org/delay/1")
if err != nil {
fmt.Println("in delay goroutine:", err)
cancelFunc(fmt.Errorf("in delay goroutine: %w", err))
return
}
ch <- "success from delay: " + resp.Header.Get("date")
}
}()
loop:
for {
select {
case s := <-ch:
fmt.Println("in main:", s)
case <-ctx.Done():
fmt.Println("in main: cancelled with cause:", context.Cause(ctx), "err:", ctx.Err())
break loop
}
}
wg.Wait()
fmt.Println("context cause:", context.Cause(ctx))
}
위 code는 이전에 httpbin을 호출했던 code이다.
바뀐 부분은 아래와 같다.
ctx, cancelFuncParent := context.WithTimeout(context.Background(), 3*time.Second)
defer cancelFuncParent()
ctx, cancelFunc := context.WithCancelCause(ctx)
defer cancelFunc(nil)
만약 WithTimeout
나 WithDeadline
를 통해 만들어진 context에서 error의 cancellation cause를 얻고 싶다면 WithCancelCause
로 wrapping해주어야 한다.
resource leak를 막기위해서 cancel function에 대해서 반드시 defer
로 호출해주는 것이 좋다.
만약 context가 timeout이 발생했을 때 custom sentinel error를 반환하고 싶다면 context.WithTimeoutCause
또는 context.WithDeadlineCause
함수를 대신 호출하면 된다.
위 program은 500 status code를 받거나 3초 이내로 500 status를 받지 못하면 종료된다.
이때 error가 무엇인지 알기위해서 context에 Err
method를 호출할 수 있다.
fmt.Println("in main: cancelled with cause:", context.Cause(ctx),"err:", ctx.Err())
또한, context.Cause
도 호출하여 어떤 결과가 나올 지 확인해보도록 하자.
결과는 random이지만, timeout이 발생했을 때는 다음과 같았다.
go run ./main.go
in main: success from status
in main: success from delay: Wed, 31 Jan 2024 14:55:06 GMT
in main: success from status
in main: success from delay: Wed, 31 Jan 2024 14:55:07 GMT
in main: cancelled with cause: context deadline exceeded err: context deadline exceeded
in delay goroutine: Get "http://httpbin.org/delay/1": context deadline exceeded
context cause: context deadline exceeded
주목할 점은 context.Cause
와 Err
method의 결과가 context.DeadlineExceeded
로 동일하다는 것이다.
그러나, bad status err
가 발생할 때는 조금 다르다.
go run ./main.go
in main: cancelled with cause: bad status err: context canceled
in delay goroutine: Get "http://httpbin.org/delay/1": context canceled
context cause: bad status
context.Cause
는 cancellation function에 첫번째로 입력한 error값인 bad status
가 나오지만, Err
는 context.Canceled
error인 context canceled
를 반환한다.