let's go를 배워보자 12일차 - Using Request Context

0

lets-go

목록 보기
13/15

Using Request Context

우리는 간단하게 유저가 인증되었는 지 아닌 지를 authenticatedUserID가 있는 지 없는 지로 체크하였다.

func (app *application) isAuthenticated(r *http.Request) bool {
return app.session.Exists(r, "authenticatedUserID")
}

여기에 추가하여 우리는 authenticatedUserID를 database에서 조회하여 실제로 존재하는 데이터인지 아닌 지 확인해야한다. 또한, 해당 유저가 active한 유저인 지 아닌지도 확인하여야 한다.

그러나 여기에는 추가적으로 database check를 해야하는 약간의 문제가 있다. 우리의 isAuthenticated() helper는 매 요청 사이클마다 계속해서 불린다. 이러는 와중에 계속해서 database에서 확인 요청을 한다면 overhead가 너무 크다.

대안으로 특정 middleware에서 현재의 요청이 인증되었는 지 또는 active인 상태인지를 확인한다음, 하위의 핸들러 chain에게 넘겨주어야 한다.

즉, request context를 설정하고, request context에서 인증 정보를 확인한 다음 handler에게 정보를 전달하면 된다.

1. How Request Context Works

handler를 처리하는 모든 http.Requestcontext.Context 객체를 포함하고 있다. 이는 request의 과정 동안에 정보를 저장할 수 있도록 하는 객체이다.

web application에서 context.Context 객체를 사용하는 대표적인 use-case는 middleware와 다른 handler들 사이에 정보(또는 데이터)를 전달하는 데 사용하는 것이다.

우리는 context.Context를 사용하여 만약 user가 특정 middleware를 통해 인증되었고 active 상태인지 아닌지를 확인하고, 만약 그렇다면 인증되었다는 정보를 우리의 모든 middleware와 handler들에게 전달하여 사용할 수 있도록 한다.

2. The Request Context Syntax

요청의 context에 information을 추가하는 기본적인 코드는 다음과 같다.

ctx := r.Context()
ctx = context.WithValue(ctx, "isAuthenticated", true)
r = r.WithContext(ctx)

하나하나 확인해보도록 하자.
1. r.Context() 메서드는 request에 있는 context를 불러오고 ctx 변수에 할당해준다.
2. context.WithValue() 메서드를 사용하여 request에 있는 context의 새로운 copy를 만들고, key값으로 isAuthenticatedtrue값을 넘겨준다.
3. 그런다음 마지막으로 r.WithContext() 메서드로 우리의 새로운 context를 포함한 request의 copy를 만든다.

직접적으로 request에 있는 context를 변경하지 않았다. 새로운 context를 만들고 http.Request객체의 새로운 copy를 만들었다.

위의 code를 좀 더 간단하게 만들면 다음과 같다.

ctx = context.WithValue(r.Context(), "isAuthenticated", true)
r = r.WithContext(ctx)

이제 request context에 데이터를 넣는 방법을 알게되었다. 그럼 어떻게 데이터를 가져올 수 있을까?

request context 값들은 interface{}타입으로 저장된다. 이는 context로 부터 값을 가져오면 기존의 타입을 단언해주어야 한다는 것이다.

데이터를 가져오기 위해서는 r.Context().Value() 메서드를 사용하면 된다.

isAuthenticated, ok := r.Context().Value("isAuthenticated").(bool)
if !ok {
    return errors.New("could not convert value to bool")
}

3. Avoiding Key Collisions

그런데 만약 contextisAuthenticated 키를 누군가 사용하고 있다면 어떻게될까? third-party application에서 isAuthenticated을 사용하고 있으면 문제가 될 것이다. 때문에 이러한 충돌을 막기 위해 key값에 해당하는 새로운 type을 만드는 것이 좋다.

type contextKey string

const contextKeyIsAuthenticated = contextKey("isAuthenticated")

...

ctx := r.Context()
ctx = context.WithValue(ctx, contextKeyIsAuthenticated, true)
r = r.WithContext(ctx)

...

isAuthenticated, ok := r.Context().Value(contextKeyIsAuthenticated).(bool)
if !ok {
    return errors.New("could not convert value to bool")
}

4. Request Context for Authentication/Authorization

이제 request context 기능을 우리의 application에 추가하도록 하자.

pkg/models/mysql/users.go 파일에 가서, UserModel.Get() 메서드를 수정하도록 하여 database에서 특정 user를 가져오도록 하자.

  • pkg/models/mysql/users.go
...
func (m *UserModel) Get(id int) (*models.User, error) {
	u := &models.User{}

	stmt := `SELECT id, name, email, created, active FROM users WHERE id = ?`
	err := m.DB.QueryRow(stmt, id).Scan(&u.ID, &u.Name, &u.Email, &u.Created, &u.Active)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return nil, models.ErrNoRecord
		} else {
			return nil, err
		}
	}
	return u, nil
}
...

이제 id를 통해서 user 데이터를 가져올 수 있으므로, cmd/web/main.go 파일에 가서 우리의 custom contextKey 타입을 정의하고 contextKeyIsAuthenticated 변수를 만들자. 이를 통해 우리는 request context로 부터 unique한 값을 불러오고 저장할 수 있다.

  • cmd/web/main.go
type contextKey string

const contextKeyisAuthenticated = contextKey("isAuthenticated")

type application struct {
	errorLog      *log.Logger
	infoLog       *log.Logger
	session       *sessions.Session
	snippets      *mysql.SnippetModel
	templateCache map[string]*template.Template
	users         *mysql.UserModel
}

이제 새로운 함수인 authenticate() middleware를 만들어 user id를 session data로부터 fetch하도록 한다음 database로 부터 id가 valid하고 user가 active한 지를 확인하자. 이후 request context에 해당 데이터를 넣어주자.

  • cmd/web/middleware.go
...
func (app *application) authenticate(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// Check if a authenticatedUserID value exists in the session. If this *isn't
		// present* then call the next handler in the chain as normal.
		exists := app.session.Exists(r, "authenticatedUserID")
		if !exists {
			next.ServeHTTP(w, r)
			return
		}
		// Fetch the details of the current user from the database. If no matching
		// record is found, remove the (invalid) authenticatedUserID value from their
		// session and call the next handler in the chain as normal.
		user, err := app.users.Get(app.session.GetInt(r, "authenticatedUserID"))
		if errors.Is(err, models.ErrNoRecord) {
			app.session.Remove(r, "authenticatedUserID")
			next.ServeHTTP(w, r)
			return
		} else if err != nil {
			app.serverError(w, err)
			return
		}
		// Likewise, if the the current user is has been deactivated remove the
		// authenticatedUserID value from their session and call the next handler in
		// the chain as normal.
		if !user.Active {
			app.session.Remove(r, "authenticatedUserID")
			next.ServeHTTP(w, r)
			return
		}
		// Otherwise, we know that the request is coming from a active, authenticated,
		// user. We create a new copy of the request, with a true boolean value
		// added to the request context to indicate this, and call the next handler
		// in the chain *using this new copy of the request*.
		ctx := context.WithValue(r.Context(), contextKeyisAuthenticated, true)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}
...

다음의 코드를 분석해보면, 만약 우리는 인증된 또는 active user를 찾지못하면 원래 즉, 변화가 없는 *http.Request를 다음 next handler chain에 전달한다.

만약 인증되고 active한 user를 찾으면 우리는 contextKeyIsAuthenticated key에 인증되었다는 정보인 true를 넣고 이전 request의 복사본에 넣어준다. 그리고 *http.Requst의 복사본을 다음 handler에 전달한다.

이제 middleware를 적용하기위해서 cmd/web/routes.go 파일에 가서 authenticate() 미들웨어를 dynamic middleware 체인에 넣어주도록 하자.

  • cmd/web/routes.go
package main

import (
	"net/http"

	"github.com/bmizerany/pat"
	"github.com/justinas/alice"
)

func (app *application) routes() http.Handler {
	standardMiddleware := alice.New(app.recoverPanic, app.logRequest, secureHeaders)
	// Add the authenticate() middleware to the chain.
	dynamicMiddleware := alice.New(app.session.Enable, noSurf, app.authenticate)
	authenticatedMiddleware := dynamicMiddleware.Append(app.requireAuthentication)

	mux := pat.New()
	mux.Get("/", dynamicMiddleware.ThenFunc(app.home))
	mux.Get("/snippet/create", authenticatedMiddleware.ThenFunc(app.createSnippetForm))
	mux.Post("/snippet/create", authenticatedMiddleware.ThenFunc(app.createSnippet))
	mux.Get("/snippet/:id", dynamicMiddleware.ThenFunc(app.showSnippet))
	// Add the five new routes.
	mux.Get("/user/signup", dynamicMiddleware.ThenFunc(app.signupUserForm))
	mux.Post("/user/signup", dynamicMiddleware.ThenFunc(app.signupUser))
	mux.Get("/user/login", dynamicMiddleware.ThenFunc(app.loginUserForm))
	mux.Post("/user/login", dynamicMiddleware.ThenFunc(app.loginUser))
	mux.Post("/user/logout", authenticatedMiddleware.ThenFunc(app.logoutUser))

	fileServer := http.FileServer(http.Dir("./ui/static/"))
	mux.Get("/static/", http.StripPrefix("/static", fileServer))
	return standardMiddleware.Then(mux)
}

이제 middleware를 넣어주었으니 마지막으로 해줘야할 것은 isAuthenticated() helper 함수를 수정하여야 한다. request context를 통해서 해당 데이터가 인증되었는 지 아닌 지를 확인하기만 하면 된다.

  • /cmd/web/helpers.go
...
func (app *application) isAuthenticated(r *http.Request) bool {
	isAuthenticated, ok := r.Context().Value(contextKeyisAuthenticated).(bool)
	if !ok {
		return false
	}
	return isAuthenticated
}

contextcontextKeyIsAuthenticated key로 value가 없거나, value가 bool타입이 아닌 지에 대해서 처리해주어야 한다. 이는 인증에 실패한 것이므로 false라고 처리해주어야 한다.

추가적으로 request에 있는 context는 request chain process 이외에 다른 부분에 사용해서는 안된다. 다음은 go docs에서 context.Context에 대해 경고한 것이다.

Use context Values only for reuqest-scoped data that trainsits processes and APIs.

즉, context를 request 생애주기 이외의 다른 객체의 dependencies로 넘기지말라는 것이다. 즉, 우리가 cache나 logger, connection pool을 dependencies로 관리하였지만 context는 그렇게 관리하지 말라는 것이다.

이는 context가 request chain에서 벗어나게되면 코드의 디버깅이 어려워지고 clarity가 무너지기 때문이다. 즉, request에 국한되지 않은 context는 의존성으로 관리되어 다른 request가 또 다른 request에 영향을 주는 문제가 있기 때문이다.

0개의 댓글