Recommanded Free YOUTUBE Lecture: <% selectedImage[1] %>

Contents

Context

Go의 context패키지는 프로세스 혹은 API 간에, 값, 시그널, 취소(cancelation), 데드라인(deadline)등을 전달하기 위해서 사용한다.

REST API 서버를 만든다고 가정해보자. 서버는 요청 받으면, 이에 대한 컨텍스트를 만들 수 있다. 그리고 이 요청을 처리하기 위한 함수를 호출 할 수 있다. 이러한 함수의 호출 체인에 컨텍스트를 전파하면, 호출 체인을 효과적으로 관리 할 수 있다.

예를 들어 A() 함수가 B()함수를 호출했다고 가정해보자. B() 함수는 바깥에 있는 인증 API를 호출하는데, 정해진 시간내에 처리를 해야 한다. 정해진 시간이 지났다면, B() 프로세스를 종료해야 할 것이다. Context를 이용하면 A() 함수는 WithCancle, WithDeadline, Withtimeout 등의 context를 전달 할 수 있다. 이제 B() 함수에서 타임아웃등이 발생하면, B() 함수는 컨텍스트를 반환하고 함수를 종료할 수 있게 된다.

개발자는 context를 이용해서 패키지 전체에서 함수 인터페이스를 일관되게 유지 할 수 있다. 이를 위해서는 아래의 규칙을 따라야 한다. 컨텍스트를 구조체 안에 저장하지 않아야 한다. 대신 명시적으로 전달을 해야 한다. 보통은 ctx 라는 이름으로 함수의 첫번째 매개 변수로 전달 한다.
func DoSomething(ctx context.Context, arg Arg) error {
    // ... use ctx ...
}

Concurrency 와 Context

Go 언어는 고루틴을 이용해서 동시성을 지원한다. 예를들어 REST 서버인 경우 매 요청마다 핸들러를 실행하는데, 이들 핸들러는 다시 데이터베이스와 RPC 백앤드에 접근하기 위해서 새로운 고루틴을 실행하기도 한다.

이들 고루틴은 사용자의 ID, 인증 토큰, 요청 만료시간, 요청 값들을 필요로 한다. 만약 요청이 취소되거나 시간이 만료되면 고루틴을 종료하고 자원을 회수 할 수 있어야 한다.

즉 동시에 실행되는 고루틴을 위해서
  • 요청 값
  • 취소 신호(canceled)
  • 고루틴의 작업 마감시간(타임아웃 혹은 데드라인)
등의 데이터를 API의 경계를 넘어서 전달 할 수 있어야 한다. 이러한 작업을 쉽게 하기 위해서 구글은 context패키지를 개발해서 제공하고 있다.

Context 패키지

context 패키지의 핵심은 Context 타입이다.

Done 메서드는 모종의 이유로 컨텍스트가 완료(그게 실패든, 시간초과든)되면 채널을 반환한다. 채널이 닫히면 함수는 해당 작업을 포기하고 반환한다. 개발자는 Err메서드를 이용해서 컨텍스트가 취소된 이유를 확인 할 수 있다.

Deadline 메서드를 이용하면, 함수가 작업을 시작해야 할지를 결정할 수 있다. 만약 남은 시간이 너무 적다면, 함수의 실행을 포기해야 할 수도 있다. 혹은 I/O 작업에 대한 제한 시간을 설정하기 위해서 Deadline을 사용 할 수도 있다.

파생되는 contexts

Context 패키지는 이미 존재하는 컨텍스트로 부터 새로운 컨텍스트를 파생하는 함수를 제공한다. 부모로 부터 자식을 만드는 느낌인데, 따라서 이러한 값들은 트리를 형성하게 된다. 만약 컨텍스트가 취소되면 해당 컨텍스트로 부터 파생된 모든 컨텍스트도 취소된다.

Background 컨텍스트는 트리의 루트다. 이 컨텍스트는 절대로 취소 할 수 없다.
// Background는 비어있는 컨텍스트를 반환한다.
// 이것은 취소될 수 없으며, 데드라인을 설정 할 수도 있다.
// Background는 main, init 등 최상의 요청을 받는 곳에서 사용한다.
func Background() Context

Context를 이용한 session 데이터 전달

유저 인증 후, 그 정보를 세션으로 관리하는 웹 애플리케이션 서버가 있다. 그 구성은 대략 아래와 같다.

 Session 관리 구성
  1. Application server는 인증서버에서 넘어온 Token을 검사한 다음, session-db에 세션을 만든다.
  2. session-db에 저장한 세션을 redis에 캐시한다. 세션 읽기는 redis에서만 이루어진다. redis에 저장된 세션은 TTL이 적용된다. TTL 동안 읽지 않은 세션은 삭제가 된다. 따라서 session-db에는 세션이 있지만, (유저가 접속을 하지 않아서) 세션이 삭제되는 경우가 있는데, 이때는 session-db에 세션이 있는지 확인하는 절차를 거친다.
세션에는 애플리케이션 서버가 유저 요청을 처리하기 위해서 필요한 여러 정보를 저장한다. 내가 만들려는 서비스는 세션에 아래와 같은 정보를 저장하고 있다.
type SessionInfo struct {
    UserID    string    // 유저 ID
    LoginTime string    // 로그인 시간
    Gender    string    // 성
    Age       int       // 나이
}

이 웹 서비스 애플리케이션은 미들웨어(middleware)를 가지고 있다. 미들웨어는 API 핸들러로 요청을 보내기 전에, 세션을 가지고 있는지, 올바른 요청인지를 판단하고 필요에 따라서 다른 URL로 리다이렉트 하거나 요청 헤더를 조작하는 등의 일을 한다. 아래와 같은 구성이다.

 Middleware

Go 언어에서 미들웨어는 대략 아래와 코드 형태를 가진다.
func MiddleWare(handle http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        sessionid := r.Header.Get("x-myapp-session")
        // Redis에서 sessionid를 찾아서 값을 가져온다. 
        sess, err := GetSession(sessionid)
        if err != nil {
            // 세션을 찾지 못했다면, 권한 에러일거다.
            // 403 에러를 리턴하거나, 로그인 페이지 등으로 리다이렉트한다.
        }
        // session 정보를 context로 넘긴다.
        // 각 핸들러들은 context로 넘어온 세션 정보를 읽어서 처리하면 된다.
        ctx := context.WithValue(r.Context(), "session", sess)
        handle.ServeHTTP(w, r.WithContext(ctx))
    })
}
핸들러는 아래와 같은 모습일테다.
func MyHandler(w http.ResponseWriter, r *http.Request) {
    sess, ok := r.Context().Value("session").(SessionInfo)
    // 세션에 포함된 정보를 이용해서 다양한 작업을 할 수 있다. 
}

Context를 이용한 애플리케이션 로깅 패턴

모든 웹 애플리케이션은 HTTP 요청에 대한 로깅을 해야 한다. 일반적으로 사용하는 로그포맷은 아래와 같다.
127.0.0.1 - - [29/Jul/2017:19:08:30 +0900] "DELETE /my/api HTTP/1.1" 200 31 "" "Go-http-client/1.1"
이 로그는 유저의 요청과 그 결과를 빠르게 수집하고 분석 할 수 있지만, 문제가 발생 했을 때 상세 내용을 확인 할 수 없다. 자세한 정보를 담고 있는 에러로그를 따로 남겨야 한다. 이 정보에는 애플리케이션 로그 뿐만 아니라, 세션 정보들도 함께 남겨야 할 것이다. 개발자와 관리자는 이 정보를 이용해서 유저의 어떤 API 요청에서, 에러가 발생했는지를 확인 할수 있다.

에러 포맷은 ELK 스택으로 보냈을 때 처리하기 쉽게 하기 위해서 JSON으로 설정했다. 처리할 정보는 다음과 같다.
  • 에러가 발생한 파일
  • 에러가 발생한 함수
  • 에러가 발생한 줄수
  • 에러를 로깅한 시간
  • 에러가 발생한 API
  • 유저의 세션
핸들러는 요청 처리 결과를 HTTP Status 함께 클라이언트에게 넘길 것이다. 이 응답 함수는 핸들러에서 따로 호출 할 건데, 이 함수에서 "HTTP Staus가 200이 아닐 때, 에러로그를 남기도록"하면 된다. 코드는 대략 아래와 같을 것이다.
func MyHandler(w http.ResponseWriter, r *http.Request) {
    // 이런 저런 처리 하다가 에러가 발생했다면
    // Response 함수를 호출한다. 
    if err != nil {
        Response(w, r, HandlerStatus{
            Status: http.StatusBadRequest,
            Code: 87, 
            Result: err.Error()
       })
    }
}

// 컨텍스트로 부터 세션정보를 읽어와서, 에러로그를 만들어서 저장한다.
// 에러로그를 저장 후, Client에 HTTP Response를 전송한다.
func Response(w http.ResponseWriter, r *http.Request, status HandlerStatus) {
    if resp.Status != http.StatusOK {
        sessInfo, ok := r.Context().Value("session").(SessionInfo)
        if ok {
            Logger.PrintJson(r.Method,
                r.URL.Path,
                resp.Status,
                resp.Code,
                resp.Result,
                sessInfo)
        } else {
            Logger.PrintJson(r.Method,
                r.URL.Path,
                resp.Status,
                resp.Code,
                resp.Result,
                SessionInfo{})
        }
    }
    message, _ := json.Marshal(resp)
    w.WriteHeader(resp.Status)
    w.Write(w, string(message))
} 
Logger.PrintJson은 로그를 파일에 저장하는 함수다. 매개변수도 좀 쓸데 없이 많기도 하고 썩 좋은 코드는 아니니, 대략 참고만하자.
func (l *Logger) PrintJson(method string, api string, statusCode int, appCode int, msg string, log interface{}) error {
    if l == nil {
        return StatusLoggerFailure
    }
    // 함수이름, 라인, 파일등의 정보를 가져온다.
    pc, _, _, ok := runtime.Caller(1)
    details := runtime.FuncForPC(pc)
    funcName := ""
    fileLine := 0
    fileName := ""
    if ok && details != nil {
        var fullFileName string
        funcName = details.Name()
        fullFileName, fileLine = details.FileLine(pc)
        fileNames := strings.Split(fullFileName, "/")
        fileName = fileNames[len(fileNames)-1]
    }
    t := time.Now()
    date := t.Format("2006-01-02T15:04:05.000Z")

    // 로깅할 아이템을 설정하고
    // json으로 바꾼다음에, 파일에 저장한다.
    item := LogItem{
        Date:       date,
        FuncName:   funcName,
        FileName:   fileName,
        FileLine:   fileLine,
        Method:     method,
        API:        api,
        StatusCode: statusCode,
        AppCode:    appCode,
        Message:    msg,
        Item:       log,
    }
    jsonLog, _ := json.Marshal(item)

    l.logger.Printf("%s", string(jsonLog))
    return nil
}