let's go를 배워보자 9일차 - stateful HTTP

0

lets-go

목록 보기
9/15

stateful HTTP

UX를 향상시키기 위해서 user가 새로운 snippet를 추가하면 confirmation message를 보여주는 것이 조다.

confirmation message은 snippet을 방금 만든 유저에게만 한 번 보여야 한다. 즉, 다른 유저들은 해당 메시지를 보지 못하도록 해야한다. 마치 Django나 Rails 같은 경우 flash message를 보여주는 기능과 같이 말이다.

이를 위해 우리는 같은 user 간에 HTTP reuqest 사이에 공유 데이터(또는 state)가 필요하다. 가장 흔한 방법은 유저에 대한 session을 구현하는 것이다.

1. Installing a Session Manager

session에 있어서는 수많은 보안 사항이 있고, 직접 구현하기에는 매우 귀찮은 일이다. 그러니 잘 만들어진 third-party package를 사용해보도록 하자.

router들과는 달리 framwork에 specific하지 않은 좋은 session 관리 패키지들이 있다.

  1. gorilla/sessions: 가장 많이 사용되며, 가장 유명한 package이다. 간단하며 사용하기 쉬운 API를 제공하며 MySQL, PostgreSQL, Redis등을 포함한 다양한 third-party session store을 제공한다. 그러나 중요하게도 여기에는 memory leak 이슈가 있는데 session token들을 새롭게 갱신하는 메커니즘이 제공되지 않기 때문이다. 이것은 만약 개발자가 third-party server-side session store를 사용하면 session fixation 고정에 매우 취약하다는 단점이 있다. 두 가지 문제점들은 패키지의 version 2에서 다뤄지고 있다. 그러나 19년도 기준으로 이 문제가 해결되지 않았다.

  2. golangcollege/sessions: cookie-based session store를 제공하여 encrypted되고 인증된 쿠키들을 사용한다. 이는 매우 가볍고 간단한 API를 사용하며 middleware를 통해서 자동으로 session data를 저장하고 로딩하는 것을 제공한다. 해당 패키지가 cookies를 session data를 저장하는 데 사용하고 있기 때문에 이는 매우 성능이 좋고 셋업이 편하다. 그러나 여기에는 몇가지 downside가 있는데, 저장할 수 있는 데이터 양이 4KB까지로 제한된다는 것과 서버 측 저장소를 사용하는 것과 같은 방식으로 session을 해지할 수가 없다.

일반적으로 만약 cookie-based session을 사용하기를 원한다면 golangcolleage/session 패키지를 사용하기를 원한다.

우리는 작은 프로그램이므로 cookie-based session인 golangcolleage/session을 사용하도록 하자.

go get github.com/golangcollege/sessions@v1

2. Setting Up the Session Manager

먼저 해야할 것은 session managermain.go 파일에 설정하는 것이다. 그리고 application 구조체에 의존성을 주입하여 모든 handler에서 사용할 수 있도록 하는 것이다.

session manager은 우리의 session들에 대한 configuration 설정들을 가지고 있고 일부 middleware와 session data를 저장하고 로딩 처리를 하는 helper 메서드를 제공한다.

우리에게 필요한 것은 32byte짜리 secret key이다. 이 키로 session cookies를 암호화를 하고 인증 처리를 해준다. 우리는 command line flag를 통해서 key를 받도록 application을 변경하도록 하자.

  • cmd/web/main.go

// Add a new session field to the application struct.
type application struct {
	errorLog      *log.Logger
	infoLog       *log.Logger
	session       *sessions.Session
	snippets      *mysql.SnippetModel
	templateCache map[string]*template.Template
}

func main() {
	addr := flag.String("addr", ":4000", "HTTP network address")
	dsn := flag.String("dsn", "web:pass@/snippetbox?parseTime=true", "MySQL data source name")
	// Define a new command-line flag for the session secret (a random key which
	// will be used to encrypt and authenticate session cookies). It should be 32
	// will be used to encrypt and authenticate session cookies). It should be 32
	// bytes long.
	secret := flag.String("secret", "s6Ndh+pPbnzHbS*+9Pk8qGWhTzbpa@ge", "Secret key")
	flag.Parse()

	infoLog := log.New(os.Stdout, "INFO\t", log.Ldate|log.Ltime)
	errorLog := log.New(os.Stderr, "ERROR\t", log.Ldate|log.Ltime|log.Lshortfile)

	db, err := openDB(*dsn)
	if err != nil {
		errorLog.Fatal(err)
	}
	defer db.Close()

	templateCache, err := newTemplateCache("./ui/html/")
	if err != nil {
		errorLog.Fatal(err)
	}
	// Use the sessions.New() function to initialize a new session manager,
	// passing in the secret key as the parameter. Then we configure it so
	// sessions always expires after 12 hours.
	session := sessions.New([]byte(*secret))
	session.Lifetime = 12 * time.Hour
	// And add the session manager to our application dependencies.
	app := &application{
		errorLog:      errorLog,
		infoLog:       infoLog,
		session:       session,
		snippets:      &mysql.SnippetModel{DB: db},
		templateCache: templateCache,
	}

	srv := &http.Server{
		Addr:     *addr,
		ErrorLog: errorLog,
		Handler:  app.routes(),
	}

	infoLog.Printf("Starting server on %s", *addr)
	err = srv.ListenAndServe()
	errorLog.Fatal(err)
}

application*sessions.Session을 추가하여 handler에서 접근할 수 있도록 한다. secretflag로 얻도록 하며, 이를 이용하여 session := sessions.New([]byte(*secret))을 만들어주면 된다. 이제 해당 key로 암호화와 인증 처리를 해줄 것이다. session.Lifetime = 12 * time.Hour로 설정하게 되면 cookie의 유효 기간을 설정할 수 있다. 유효 기간을 12시간으로 설정하였기 때문에 12시간 후면 session에서 데이터가 expires된다.

sessions.New()함수는 session 설정을 위한 셋팅 값들을 가지고 있는 Session 구조체 반환한다.

session이 동작하기 위해서 우리는 routes에 middleware인 Session.Enable() 메서드를 추가할 것이다. 이 middleware은 session data를 모든 http request와 response에 있는 session cookie로 부터 session data를 얻거나 저장할 수 있다.

우리는 모든 routes에 session manager가 동작하게 할 필요가 없다. 특히 ,/static route같은 경우는 필요가 없다.

그래서 session middleware를 standardMiddleware chain에 추가하는 것은 좋은 행동이 아니다.

대신에 dynamic application route에 middleware가 적절히 작용되는 dynamicMiddleware chain을 만들도록 하자. routes.go 파일을 열어서 다음과 같이 코드를 넣도록 하자.

  • /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)
	// Create a new middleware chain containing the middleware specific to
	// our dynamic application routes. For now, this chain will only contain
	// the session middleware but we'll add more to it later.
	dynamicMiddleware := alice.New(app.session.Enable)

	mux := pat.New()
	// Update these routes to use the new dynamic middleware chain followed
	// by the appropriate handler function.
	mux.Get("/", dynamicMiddleware.ThenFunc(app.home))
	mux.Get("/snippet/create", dynamicMiddleware.ThenFunc(app.createSnippetForm))
	mux.Post("/snippet/create", dynamicMiddleware.ThenFunc(app.createSnippet))
	mux.Get("/snippet/:id", dynamicMiddleware.ThenFunc(app.showSnippet))
	// Update these routes to use the new dynamic middleware chain followed
	// by the appropriate handler function.
	fileServer := http.FileServer(http.Dir("./ui/static/"))
	mux.Get("/static/", http.StripPrefix("/static", fileServer))
	return standardMiddleware.Then(mux)
}

application을 구동시켜 compile이 완료된다면 성공한 것이다.

3. Working with Session Data

이제 우리가 앞서 말했던 유저를 위한 confirmation message를 session data에 추가하기 위하여, *Session.Put() 메서드를 사용해보도록 하자. 여기의 두 번째 parameter는 data의 key이다. 이는 session에서 데이터를 저장하고 불러올 때 사용하는 key를 말하는 것이다.

app.session.Put(r, "flash", "Snippet successfully created!")

session으로 부터 데이터를 가져오기 위해서 우리는 두 가지 방법이 있다. 하나는 *Session.Get 메서드를 사용하여 interface{}타입의 데이터를 가져온 뒤 string으로 type assert를 하는 방법이 있다.

flash, ok := app.session.Get(r,"falsh").(string)
if !ok {
    app.serverError(w, errors.New("type assertion to string failed"))
}

또는 대안으로 type conversion을 해주는 *Session.GetString() 메서드를 사용할 수 있다. 만약 session data에 매칭되는 key가 없다면 또는 변환하려는 데이터가 string으로 변환이 안된다면 메서드는 ""와 같은 empty string을 반환한다.

flash := app.session.GetString(r, "flash")

string말고도 []byte, bool, int, time.Time 타입으로 변환해서 반환해주는 기능도 존재한다.

우리의 confirmation message은 한 번만 표시될 것이기 때문에 이를 사용하고 나면 remove해주어여 한다.

*Session.Remove() 메서드를 사용할 수 있다. 그러나 더 좋은 옵션은 *Session.PopString() 메서드로 이는 주어진 key에 해당하는 string 값을 가져와 주고 이를 session data로 부터 삭제해준다.

flash := app.session.PopString(r, "flash")

이제 session data를 사용해보도록 하자. createSnippet 핸들러를 수정하여 현재 user의 session data에 confirmation message를 추가하도록 하자.

  • cmd/web/handlers.go
func (app *application) createSnippet(w http.ResponseWriter, r *http.Request) {
	err := r.ParseForm()
	if err != nil {
		app.clientError(w, http.StatusBadRequest)
		return
	}

	form := forms.New(r.PostForm)
	form.Required("title", "content", "expires")
	form.MaxLength("title", 100)
	form.PermittedValues("expires", "365", "7", "1")

	if !form.Valid() {
		app.render(w, r, "create.page.tmpl", &templateData{Form: form})
		return
	}

	id, err := app.snippets.Insert(form.Get("title"), form.Get("content"), form.Get("expires"))
	if err != nil {
		app.serverError(w, err)
		return
	}
	// Use the Put() method to add a string value ("Your snippet was saved
	// successfully!") and the corresponding key ("flash") to the session
	// data. Note that if there's no existing session for the current user
	// (or their session has expired) then a new, empty, session for them
	// will automatically be created by the session middleware.
	app.session.Put(r, "flash", "Snippet successfully created!")

	http.Redirect(w, r, fmt.Sprintf("/snippet/%d", id), http.StatusSeeOther)
}

app.session.Put(r, "flash", "Snippet successfully created!")을 추가하여 "flash"라는 key값에 메시지를 저장하였다.

다음으로 confirmation message를 가져와주는 showSnippet 핸들러를 수정하여 template에 flash 메시지를 보여주려고 한다. showSnippet 핸들러를 변경하기 이전에 template에 flash 메시지를 넣기위해 templateData 구조체에 Flash 필드를 추가하도록 하자.

  • cmd/web/handlers.go
type templateData struct {
	CurrentYear int
	Flash       string
	Form        *forms.Form
	Snippet     *models.Snippet
	Snippets    []*models.Snippet
}

이제 showSnippet에서 flash 메시지를 받아서 templateData 구조체에 넣어서 넘겨주도록 하자.

func (app *application) showSnippet(w http.ResponseWriter, r *http.Request) {
	id, err := strconv.Atoi(r.URL.Query().Get(":id"))
	if err != nil || id < 1 {
		app.notFound(w)
		return
	}

	s, err := app.snippets.Get(id)
	if err != nil {
		if errors.Is(err, models.ErrNoRecord) {
			app.notFound(w)
		} else {
			app.serverError(w, err)
		}
		return
	}
	// Use the PopString() method to retrieve the value for the "flash" key.
	// PopString() also deletes the key and value from the session data, so it
	// acts like a one-time fetch. If there is no matching key in the session
	// data this will return the empty string.
	flash := app.session.PopString(r, "flash")
	// Pass the flash message to the template.
	app.render(w, r, "show.page.tmpl", &templateData{
		Flash:   flash,
		Snippet: s,
	})
}

session을 사용하여 flash메시지를 가져왔고, 이를 templateData 구조체에 넣어주었다. 이제 rendering하기 위해 base.layout.tmpl 파일에 해당 flash message를 렌더링해보자.

  • ui/html/base.layout.tmpl
{{define "base"}}
<!doctype html>
<html lang='en'>
    <head>
        <meta charset='utf-8'>
        <title>{{template "title" .}} - Snippetbox</title>
        <!-- Link to the CSS stylesheet and favicon -->
        <link rel='stylesheet' href='/static/css/main.css'>
        <link rel='shortcut icon' href='/static/img/favicon.ico' type='image/x-icon'>
        <!-- Also link to some fonts hosted by Google -->
        <link rel='stylesheet' href='https://fonts.googleapis.com/css?family=Ubuntu+Mono:400,700'>
    </head>
    <body>
        <header>
            <h1><a href='/'>Snippetbox</a></h1>
        </header>
        <nav>
            <a href='/'>Home</a>
            <a href='/snippet/create'>Create snippet</a>
        </nav>
        <main>
            {{with .Flash}}
            <div class='flash'>{{.}}</div>
            {{end}}
            {{template "main" .}}
        </main>
        {{template "footer" .}}
        <script src="/static/js/main.js" type="text/javascript"></script>
    </body>
</html>
{{end}}

이제 application을 켜서 새로운 snippet을 만들어보고 이를 실행해보도록 하자.

남색의 flash message box가 생겼다면 성공한 것이다. 새로고침을 하고 나면 메시지 박스가 사라질 텐데, 이는 session에 더 이상 해당 cookie가 없다는 것을 의미한다.

더 나아가서 showSnippet 핸들러에 굳이 flash session을 가져오는 로직을 추가히지 않아도 된다. 왜냐하면 helpers.go에서 자동으로 templateData를 만들 때 기본값에 넣어주면 되기 때문이다. 다음과 같이 말이다.

  • cmd/web/handlers.go
...
func (app *application) addDefaultData(td *templateData, r *http.Request) *templateData {
	if td == nil {
		td = &templateData{}
	}
	td.CurrentYear = time.Now().Year()
	td.Flash = app.session.PopString(r, "flash")
	return td
}
...

이렇게 만드는 것은 더 이상 flash message를 showSnippet 핸들러 안에서 처리하지 않도록 만든다. 그럼 showSnippet 핸들러의 코드를 다음과 같이 변경할 수 있다.

  • cmd/web/handlers.go
...
func (app *application) showSnippet(w http.ResponseWriter, r *http.Request) {
	id, err := strconv.Atoi(r.URL.Query().Get(":id"))
	if err != nil || id < 1 {
		app.notFound(w)
		return
	}

	s, err := app.snippets.Get(id)
	if err != nil {
		if errors.Is(err, models.ErrNoRecord) {
			app.notFound(w)
		} else {
			app.serverError(w, err)
		}
		return
	}

	app.render(w, r, "show.page.tmpl", &templateData{
		Snippet: s,
	})
}
...

추가한 코드를 없앤 것 뿐이다. 즉, session을 추가하기 이전 코드와 동일하다.

잘 적용되었는 지를 확인하기 위해 서버를 껏다가 다시 켜보도록 하자.

0개의 댓글