인증과 관련된 포스트 중 마지막으로 알아볼 것은 쿠키(Cookie)와 세션(Sesisons)이다. 어플리케이션 레벨에서 인증하는 JWT(JSON Web Token), OAuth, Session 을 끝으로 인증 쪽은 마무리 지을 예정이며 프로토콜 레벨의 인증인 HTTP Basic, HTTP Digest 인증은 생략하기로 하자. 언젠간 이야기할 지도 모르겠지만, 지금 이 시점은 아니다.
세션(Sessions)
세션(Sessions)은 웹 어플리케이션을 개발할 때 기본적으로 쿠키(Cookie)*와 함께 배우는 개념이다. 둘 다 데이터라는 점에서는 동일하지만 쿠키는 클라이언트(웹 브라우저)에, 세션은 서버(파일, RDBMS, Redis)에 저장된다는 것이 가장 큰 차이점이다.
쿠키*는 비로그인 상태에서 장바구니, 사용자의 사용 패턴에 따른 맞춤형 페이지를 제공, 추적, 추천 등을하기 위해 사용하는 경우가 많다. 쿠키는 클라이언트에서 값을 바꾸는 등 조작이 가능하기 때문에 중요한 정보는 저장해서는 안 된다.
인증 토큰을 사용하지 않는다면, 인증된 유저에 대한 정보는 서버에 저장하는 데이터인 세션을 사용하여 저장하게 된다. HTTP 는 기본적으로 무상태이며, HTTP 2.0 이전 버전이라면 요청/응답의 한 사이클이 끝나면 연결을 해제하기 때문에 클라이언트의 상태를 유지하기 위한 수단이 필요한데, 세션을 사용하면 웹 어플리케이션이 상태를 유지할 수 있다. 따라서 어떤 라우트에서 세션에 데이터를 넣어두면 다른 라우트에서도 세션을 얻어와 사용할 수 있게된다.
클라이언트에 따른 세션을 식별하고 상태유지를 위해 클라이언트/서버간의 소통은 기본적으로 쿠키를 사용하며 서버에 저장되는 세션은 파일 또는 RDBMS, 그리고 Redis 에 저장되는 경우가 많다. 이 포스트에서 살펴볼 것은 기본적으로 쿠키를 저장소로 사용하는 세션인데, 저장소 사용의 유무와 상관없이 쿠키는 반드시 암호화(Encrypt)를 거쳐야 안전하게 보관된다. 쿠키를 세션 데이터 저장소로 사용하지 않는다면 타 저장소에서의 조회를 위해 쿠키에는 Session ID 를 저장하기 때문이다.
유저를 인증하고 완료되면 세션을 설정하면 세션 쿠키가 설정되는데, 그 이름은 GO_SESSION
라고 가정해보자. 대략적인 순서는 아래와 같다.
/login
에서 유저에 대한 인증이 완료되면 서버에 세션과 클라이언트에 세션에 해당하는 쿠키GO_SESSION
을 생성하고 세션에 유저의 이메일을 저장,/
로 리다이렉트 하되,Set-Cookie
헤더를 포함하여 클라이언트에 암호화된 세션 쿠키를 생성하도록 지시한다.- 로그인이 완료된 이후
/
에서 클라이언트의 세션 쿠키인GO_SESSION
을 얻어와 복호화하고 저장된 정보를 가져온다.
세션에 저장되는 정보는 인증에 대한 정보인 경우가 많긴 하지만, 꼭 그래야 할 필요는 없다. 임시적으로 저장되는 데이터도 세션을 사용하여 할 수 있다.
* 아래 섹션부터는 구현 영역이다. 구현에는 관심없다면 마치며로 넘어가자.
구현
세션을 어플리케이션에 사용하는 것은 아주 단순한 일이며 세션 드라이버를 직접 구현하는 것이 아닌 이상은 그렇게 큰 노력을 들일 필요는 없다. 일단, 일반적으로 사용하는 RDBMS 나 Redis 에 세션을 저장하는 것은 언어마다 잘 구현된 써드파티 라이브러리가 많고 사용법도 간단하다. 공통 인터페이스를 따라 세션을 만들고, 값을 저장하고 얻어오는 방법만 익히면 어떤 세션 드라이버를 사용하든 상관없이 같은 방법으로 사용할 수 있을 것이다.
만약 특별한 세션 드라이버를 구현하고 싶다면 언어마다 존재하는 SessionHandlerInterface 를 따르는 드라이버를 구현해야 한다. 이 포스트에서 세션을 사용하기 위해 라이브러리는 https://github.com/gorilla/sessions 를 사용했다. 늘 그랬듯이 언어는 Go 다.
먼저, 쿠키 저장소를 만드는 일을 해보자. 저장소를 만들때 쿠키 암호화를 위한 키도 생성한다. 이 키는 외부에 노출되어서는 안 된다. 쿠키를 세션 저장소로 사용하게 되면 쿠키에 데이터도 함께 저장하게 되므로 위에서 이야기한 '서버에 저장하는' 세션과는 거리가 있지만, 이해하기에는 부족하지 않다.
randomBytes := make([]byte, 32)
// crypto/rand
if _, err := rand.Read(randomBytes); err != nil {
panic(err)
}
// 쿠키 기반 세션 스토어 생성
store := sessions.NewCookieStore(randomBytes)
/login
여기에서는 유저를 인증하고 GO_SESSION
을 만든 뒤, 유저 정보를 저장하고 /
로 리다이렉트 한다. 인증과정은 주제에 벗어나기 때문에 생략하자. 만약이 세션 쿠키가 존재하지 않으면 새로 생성하게 된다.
http.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
session, _ := store.Get(r, "GO_SESSION")
// 인증...
// 세션 스토어에 데이터 저장
session.Values["email"] = "pronist@naver.com"
// 세션 저장
if err := session.Save(r, w); err != nil {
panic(err)
}
http.Redirect(w, r, "/", 302)
})
위와 같이 라우트를 만들고 접속해보면 아래와 같이 세션 쿠키가 생성된 것을 볼 수 있다. 이 세션 쿠키는 이후 요청 할 때 Header 에 포함될 것이며 그 값은 암호화되어 있다.
CookieStore.Save(*http.Request, http.ResponseWriter) error
라이브러리를 그저 사용만 하고 넘어가기에는 아쉬우니 라이브러리 세부 구현을 잠시나마 살펴보자면, 세션을 저장할 때 쿠키에 대해 securecookie.EncodeMulti()
를 통해 키를 통해 암호화하는 모습을 볼 수 있다. 우리가 설정한 키는 s.Codecs
라고 보면 된다.
// https://github.com/gorilla/sessions/blob/0a84f353f00601bbc2fcf99e8da368ef368b4e51/store.go#L101
// Save adds a single session to the response.
func (s *CookieStore) Save(r *http.Request, w http.ResponseWriter,
session *Session) error {
encoded, err := securecookie.EncodeMulti(session.Name(), session.Values,
s.Codecs...)
if err != nil {
return err
}
http.SetCookie(w, NewCookie(session.Name(), encoded, session.Options))
return nil
}
쿠키 저장소이기에 세션이 가지고 있어야 할 값인 session.Values
마저도 쿠키에 포함하여 암호화하는 것이다. 파일이나 데이터베이스 기반이라면 쿠키에는 session.ID
를 저장하고 ID 를 키로 하여 저장소에서 세션 값을 얻어오고 조회한다. 그에 해당하는 코드는 https://github.com/gorilla/sessions/blob/0a84f353f00601bbc2fcf99e8da368ef368b4e51/store.go#L231 에서 살펴볼 수 있다. 이 예제에서는 쿠키에 정보를 저장했으므로 서버에 저장되는 데이터라는 세션의 의미가 많이 퇴색되었다. 실무에서는 파일이나 데이터베이스에 세션을 저장하는게 일반적이다.
/
여기에서는 /login
으로부터 리다이렉트 이후 세션을 얻어와서 값을 출력하는 단순한 일을 한다. 물론 여기서 로그인을 거치지 않고 바로 접속하면 올바르게 실행되지 않는다. 편의상 세션이 존재하지 않는 경우의 예외처리는 하지 않았기 때문이다.
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
session, _ := store.Get(r, "GO_SESSION")
// 세션에서 값 얻어오기
user := session.Values["email"].(string) // pronist@naver.com
if _, err := w.Write([]byte(user)); err != nil {
panic(err)
}
})
요청 할 때 Cookie
헤더에 GO_SESSION
쿠키를 설정하고 요청하고 있음을 주목할 필요가 있다. 이 덕에 서버에서는 세션 쿠키를 조회할 수 있다.
CookieStore.New(*http.Request, string) (*Session, error)
이미 존재하는 세션을 얻어올 때는 내부적으로 암호화된 쿠키를 복호화하여 얻어오게 되며 라이브러리 세부 구현부분에서 CookieStore.New()
메서드를 통해 살펴볼 수 있고, 핵심은 securecookie.DecodeMulti()
에서 쿠키를 복호화하여 얻어온 세션을 반환하는 부분이다. 라우트에서는 세션을 얻어오기 위해 CookieStore.Get()
을 호출했지만, 내부적으로 CookieStore.New()
이 호출된다.
// https://github.com/gorilla/sessions/blob/0a84f353f00601bbc2fcf99e8da368ef368b4e51/store.go#L84
// New returns a session for the given name without adding it to the registry.
//
// The difference between New() and Get() is that calling New() twice will
// decode the session data twice, while Get() registers and reuses the same
// decoded session after the first call.
func (s *CookieStore) New(r *http.Request, name string) (*Session, error) {
session := NewSession(s, name)
opts := *s.Options
session.Options = &opts
session.IsNew = true
var err error
if c, errCookie := r.Cookie(name); errCookie == nil {
err = securecookie.DecodeMulti(name, c.Value, &session.Values,
s.Codecs...)
if err == nil {
session.IsNew = false
}
}
return session, err
}
마치며
세션을 사용하게되면 웹 어플리케이션에서 상태를 유지할 수 있게 되지만 JWT(JSON Web Token)와 같은 토큰 기반인증과는 다르게 쿠키를 기반으로 사용하므로 클라이언트가 웹 기반이 아닌 디바이스라면 대응하기 어려울 수 있다. 그러나 웹 개발을 한다면 세션은 쿠키와 함께 인증에 있어서는 가장 먼저 알아야하는 개념이므로 반드시 숙지해둘 필요가 있다.