ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Spring] MVC HttpSession, Interceptor, Cookie 정리하기
    Server/Spring MVC 2021. 10. 20. 01:19
    728x90
    반응형

    Spring MVC : 세션, 인터셉터, 쿠키

    로그인을 구현하는 방법에는 크게 JWT, Session-Cookie 방식 두 가지가 존재합니다. 저는 지금까지 두 가지 중에 항상 JWT를 사용해서 로그인을 구현해왔는데요. 그래서 Session-Cookie를 사용했을 때 어떤 장단점이 있는지 직접 개발하면서 체감 해보진 못하고 이론적으로만 공부해보았습니다. JWT는 직접 개발하면서 느낀 결과 여러가지 장점이 있지만, 토큰이 탈취 당할 수 있다는 것이 가장 큰 단점이라고 많이 느꼈습니다.

     

     

    그래서 이번 글에서는 JWT 대신 세션에 대해서 알아보고 추가적으로 인터셉터, 쿠키에 대해서도 살~짝 정리해보겠습니다.



    HttpSession 사용하기

    Controller에서 HttpSession을 사용하려면 아래의 두 가지 방법 중 한 가지를 이용하면 됩니다.

    • 요청 매핑 애노테이션 적용 메소드에 HttpSession 파라미터를 추가한다.
    • 요청 매핑 애노테이션 적용 메소드에 HttpServletRequest 파라미터를 추가하고 HttpServletRequest를 이용해서 HttpSession을 구한다.

     

    @PostMapping("/signup")
    fun sign(@RequestBody signUpDTO: SignUpDTO, httpSession: HttpSession) {}

    위와 같이 사용하는 방식이 첫 번째 방식입니다. 이렇게 사용하게 되면 항상 HttpSession을 생성하게 됩니다.

     

    @PostMapping("/signup")
    fun sign(@RequestBody signUpDTO: SignUpDTO, req: HttpServletRequest) {
        val session = req.session
    }

    HttpServletRequest를 사용하는 것이 두 번째 방법입니다. 이렇게 사용하게 되면 필요한 시점에만 HttpSession을 생성할 수 있습니다.

     

    그리고 HttpSession에 대해서 좀 더 알아보기 위해서 공식문서를 보았는데요.

    Provides a way to identify a user across more than one page request or visit to a Web site and to store information about that user

    The servlet container uses this interface to create a session between an HTTP client and an HTTP server. The session persists for a specified time period, across more than one connection or page request from the user. A session usually corresponds to one user, who may visit a site many times. The server can maintain a session in many ways such as using cookies or rewriting URLs.

    Session information is scoped only to the current web application ( ServletContext), so information stored in one context will not be directly visible in another.

     

    위의 영어는 공식문서에서 내용을 가져왔습니다. 파파고의 힘을 빌려 해석해보면 아래와 같습니다.

     

    둘 이상의 페이지 요청에서 사용자를 식별하거나, 웹 사이트를 방문하고 해당 사용자에 대한 정보를 저장하는 방법을 제공한다.

    서블릿 컨테이너는 HttpSession 인터페이스를 사용하여 HTTP 클라이언트와 HTTP 서버 간의 세션을 작성합니다. 세션은 지정된 시간 동안 사용자의 둘 이상의 연결 또는 페이지 요청에 걸쳐 지속됩니다. 세션은 일반적으로 사이트를 여러 번 방문할 수 있는 한 사용자에 해당합니다. 서버는 쿠키를 사용하거나 URL을 다시 작성하는 등의 다양한 방법으로 세션을 유지관리할 수 있습니다.

    세션 정보는 현재 웹 응용프로그램(ServletContext)으로만 범위가 지정되므로, 한 컨텍스트에 저장된 정보는 다른 컨텍스트에서 직접 볼 수 없습니다.

     

    세션만 보더라도 Spring MVC 내부에서 엄청나게 복잡하고 많은 일들이 일어나고 있다는 것을 알 수 있습니다. 그래서 Spring에서 Session을 구현한다면 HttpSession을 사용하게 될 것입니다.

    httpSession.setAttribute("user", user)
    httpSession.getAttribute("user")
    httpSession.invalidate()       

    위와 같이 setAttribute를 사용해서 user를 세션에 저장하고 getAttribute를 사용해서 다시 user를 꺼내올 수 있습니다. 그리고 invalidate를 통해서 세션을 제거할 수 있습니다.

     

    그러면 여기서 잠깐!! 한 가지 궁금한 점이 생기는데요.

     

     

    궁금한 점

    HttpSession.getAttribute("user");
    
    사용자 A가 접속해도 "user" Key로 값을 가져오고,
    사용자 B가 접속해도 "user" Key로 가져옵니다. 
    같은 Key를 쓰는데 어떻게 유저 A와 B를 구분해서 값을 가져오나요?

    위와 같이 Key가 같은데 user를 구분해서 가져오는 것이 가능한 이유가 무엇일까요? 이것에 대해 이해하려면 먼저 JSESSIONID 라는 것을 먼저 알아야 합니다.



    JSESSIONID란?

    • 톰캣 컨테이너에서 세션을 유지하기 위해 발급하는 키
    • 상태를 저장하기 위해서 톰캣은 JSESSIONID 쿠키를 클라이언트(브라우저)에게 발급해주고 이 값을 통해 세션을 유지할 수 있도록 한다.

     

     

    JSESSIONID란에 대해서 간단하게 요약하면 위와 같이 정의할 수 있습니다.

     

    스크린샷 2021-10-19 오후 1 40 04

    The name to be used for all session cookies created for this context. If set, this overrides any name set by the web application. If not set, the value specified by the web application, if any, will be used, or the name JSESSIONID if the web application does not explicitly set one.

     

    그리고 잠깐 톰캣 공식 문서를 보면서 sessionCookieName에 대해서 살펴보겠습니다.

    간단하게 요약하자면 특별하게 이름을 지정하지 않으면 JSESSIONID 이름이 사용된다. 정도인 것 같습니다. 그러면 JSESSIONID 으로 어떻게 위의 질문에 대한 답을 어떻게 해결할 수 있는 것일까요?

     

    @PostMapping("/signup")
    fun sign(@RequestBody signUpDTO: SignUpDTO, httpSession: HttpSession) {
        httpSession.setAttribute("email", signUpDTO.email)
    }

    먼저 위의 코드는 email을 세션으로 등록하고 있는데요. 한번 Postman으로 위의 API를 호출해보겠습니다.

     

    스크린샷 2021-10-19 오후 1 54 39

     

    그러면 위의 API를 호출하면 Postman에서 볼 수 있듯이 Response CookieJSESSIONID가 포함되어서 응답으로 오는 것을 볼 수 있습니다. 즉, 세션이 서버에 등록되면 서버에서 응답 값으로 클라이언트(브라우저)에게 JSESSIONID을 Response Header에 담아서 보내주는 것을 볼 수 있습니다.

     

    @GetMapping
    fun test(httpSession: HttpSession): String {
        httpSession.setAttribute("key", "value")
        return "test"
    }

    이번에는 GET 방식의 세션을 저장하는 임시 코드를 작성하고 크롬 브라우저에서 요청한 후에 개발자 도구를 확인해보겠습니다.

     

    스크린샷 2021-10-19 오후 2 00 54

    그리고 Network 탭에서 확인을 해보면 포스트맨에서 본 것과 동일하게 Response HeadersSet-CookieJSESSIONID를 담아서 응답이 온 것을 확인할 수 있습니다. 그리고 한번 더 동일하게 크롬에서 요청을 보내보겠습니다.

     

    스크린샷 2021-10-19 오후 2 03 13

     

    한번 더 요청을 보내니 이번에는 Request HeadersCookieJSESSIONID를 서버로 보내는 것을 볼 수 있습니다. 즉, 전체적인 흐름을 정리하면 아래와 같습니다.

    1. 클라이언트(웹 브라우저의 사용자)가 처음으로 웹 어플리케이션을 방문하거나 request.getSession()을 통해 HttpSession을 처음으로 가져 오면 서블릿 컨테이너는 새로운 HttpSession 객체를 생성하고 길고 unique한 ID를 생성 후, 서버의 메모리에 저장합니다.
    2. 서블릿 컨테이너는 JSESSIONID란 이름을 key로, 생성한 session ID를 value로 하여 HTTP 응답의 Set-Cookie header에 cookie로 설정합니다
    3. 브라우저는 다음 요청부터 Request HeadersJSESSIONID를 담아서 서버로 요청을 보냅니다.
    4. 서블릿 컨테이너는 들어오는 모든 HTTP request의 cookie header에서 JSESSIONID라는 이름의 cookie가 있는지 확인하고 해당 값 (session ID)을 사용하여 서버의 메모리에 저장된 HttpSession을 가져옵니다.



     

    JSESSIONID가 존재할 경우

    image

     

     

    JSESSIONID 없을 경우

    image

    클라이언트 측에서는 웹브라우저 인스턴스가 실행되는 동안 session cookie가 활성화됩니다. 따라서 클라이언트가 웹 브라우저 인스턴스(모든 탭 / 창)를 닫으면 클라이언트 session이 삭제됩니다. 새 브라우저에서 session과 연관된 cookie는 존재하지 않으므로 더 이상 cookie는 전송되지 않습니다. 이로 인해 새로운 HTTP Session이 생성되고 새로운 session cookie가 사용됩니다.

    Reference : https://jojoldu.tistory.com/118

     

     

    위에서 클라이언트가 웹 브라우저 인스턴스(모든 탭 / 창)를 닫으면 클라이언트 session이 삭제됩니다. 라는 말이 나오는데요. 저도 많이 들어봤던 말이지만, 실제로 정말 그런지 한번 테스트를 해보겠습니다. (반드시 시크릿 모드로 브라우저를 키셔야 합니다.)

     

    스크린샷 2021-10-20 오후 10 41 56

    위에서 보았던 것처럼 첫 번째 요청은 Response HeadersJSESSIONID이 응답으로 오는 것을 볼 수 있습니다.

     

    스크린샷 2021-10-20 오후 10 45 03

    그리고 브라우저를 전부 다 닫은 후에 다시 시크릿 모드를 키고, 요청을 보내보면 세션 값이 바뀐 것을 볼 수 있는데요. 즉, 클라이언트가 웹 브라우저 인스턴스(모든 탭 / 창)를 닫으면 클라이언트 session이 삭제됩니다. 라는 말처럼 동작하는 것을 확인할 수 있습니다.

     

    그러면 지금까지 위에서 정리한 내용을 바탕으로 같은 Key를 쓰는데 어떻게 유저 A와 B를 구분해서 값을 가져오나요? 라는 질문은 요청이 올 때의 Request Header에 존재하는 JSESSIONID로 구분할 수 있다. 라고 답할 수 있을 것 같은데요. 이것 또한 정말로 그런지 한번 더 테스트를 해보겠습니다.

     

    스크린샷 2021-10-20 오후 10 59 35

    위와 같이 간단하게 코드를 기반으로 아래와 같이 진행해보겠습니다.

    1. 시크릿 모드 브라우저에서 http://localhost:8080/api/v1로 요청 보내기
    2. 시크릿 모드 브라우저 전부 다 끈 후에 시크릿 모드 브라우저 다시 키기
    3. http://localhost:8080/api/v1/test로 요청 보내기

     

    지금까지 계속 보았듯이 1번을 진행하면 서버에서 Response Header에 JESSIONID 값을 보내줄 것입니다. 그런데 2번을 진행하면 1번에서 받은 JSESSIONID가 사라지게 될 것인데요. 이러한 상태로 3번을 호출해서 getAttribute()를 하게 되면 어떤 결과를 출력하게 될까? 라는 것을 알아보는 테스트 입니다.

     

    스크린샷 2021-10-20 오후 11 05 06

    결과가 null이 찍히는 것을 볼 수 있습니다. 즉, setAttribute 할 때의 key가 중요한 것이 아니라 클라이언트(브라우저)에서 요청을 보낼 떄 Request HeaderJESSIONID가 있냐 없냐가 유저를 구분하는데 중요한 역할을 한다는 것을 다시 한번 알 수 있습니다. 정리하자면 2번 단계에서 브라우저를 전부 다 끈 후에 다시 켰기에 JESSIONID가 없는 상태로 getAttribute()를 하려 했기 때문에 null이 출력된 것입니다.



    읽어보면 좋은 내용

    ServletContext는 웹 애플리케이션이 살아있는 한 계속 살아있습니다. 그리고 그것은 모든 session에서 모든 request간에 공유됩니다. 클라이언트가 동일한 브라우저 인스턴스로 웹 어플리케이션과 상호 작용하고, session이 서버에서 time out되지 않은 한 HttpSession은 계속 유지됩니다. 같은 session은 모든 request간에 공유됩니다. HttpServletRequest와 HttpServletResponse는 서블릿이 클라이언트로부터 HTTP request을 받을 때부터 완성된 응답이 도착할 때까지(역자주: 웹 페이지가 랜더링되는 시점)살아있습니다. 그외 다른 곳에서는 공유되지 않습니다. 모든 Servlet, Filter 및 Listener 인스턴스는 웹 어플리케이션이 살아있는 한 계속 살아있습니다. ServletContext, HttpServletRequest 및 HttpSession에 정의 된 모든 attribute는 해당 객체가 살아있는 동안 지속됩니다.

    Reference : https://jojoldu.tistory.com/118



    Thread Safety

    서블릿과 필터는 모든 request에서 공유됩니다. 멀티 스레드와 다른 스레드 (HTTP request)는 동일한 인스턴스를 사용할 수 있습니다.그렇지 않으면 매 request마다 init() 및 destroy()를 다시 실행하기에는 너무 많은 비용이 듭니다.

    또한 request나 session 에서 사용하는 데이터를 서블릿이나 필터의 인스턴스 변수로 할당해서는 안됩니다. 다른 session의 모든 request간에 공유되어 스레드로부터 안전하지 않습니다.

    Reference : https://jojoldu.tistory.com/118

    class ExampleServlet : HttpServlet() {
    
        private lateinit var thisIsNOTThreadSafe: Any //쓰레드에 안전하지 않은 변수
    
        override fun doGet(req: HttpServletRequest?, resp: HttpServletResponse?) {
            val thisIsThreadSafe: Any  // 쓰레드에 안전한 지역변수
            thisIsNOTThreadSafe = req!!.getParameter("foo");   // BAD!! 모든 request가 공유합니다.
            thisIsThreadSafe = req.getParameter("foo");      // OK, 이건 쓰레드에 안전합니다.
        }
    }

    간단히 말해서부터 쓰레드 예제Jojoldu님 블로그 에서 가져왔습니다. HttpSession에 대해서 마지막 이 부분을 보면서 이해하면 좋을 거 같아서 가져왔습니다.



    인터셉터란(Interceptor)?

    image

    위의 그림을 보면 저번의 공부했던 AOP 앞에 Interceptor가 존재하는 것을 볼 수 있습니다. Interceptor를 사용하기 좋은 대표적인 예시가 로그인 여부를 확인하는 것인데요. 만약에 Interceptor를 사용하지 않고 로그인 여부를 확인해야 한다면 Controller에서 각 API마다 중복 코드가 발생할 것입니다.

     

    이렇게 다수의 컨트롤러에 대해 동일한 기능을 적용해야 할 때 사용하면 좋은 것이 HandlerInterceptor 입니다.

     

    HandlerInterceptor 인터페이스

    public interface HandlerInterceptor {
    
        default boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
                throws Exception {
    
            return true;
        }
    
        default void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
                @Nullable ModelAndView modelAndView) throws Exception {
        }
    
        default void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler,
                @Nullable Exception ex) throws Exception {
        }
    
    }

    대표적인 인터셉터는 HandlerInterceptor가 존재합니다. 내부 코드를 보면 preHandle, postHandle, afterCompletion 메소드가 존재합니다.

     

    image

    • preHandle: 컨트롤러 실행 전 ex) 로그인 하기 않은 경우 컨트롤러를 실행하지 않도록
    • postHandle: 컨트롤러 실행 후, 아직 뷰를 실행하기 전, 컨트롤러가 정상적으로 실행된 이후에 추가 기능을 구현할 때 사용 (컨트롤러가 Exception을 발생시키면 postHandle() 메소드는 실행되지 않음)
    • afterCompletion: 뷰를 실행한 이후, 메소드는 뷰가 클라이언트에 응답을 전송한 뒤에 실행됨 (컨트롤러 실행 이후에 예기치 않게 발생한 익셉션을 로그로 남긴다거나 실행 시간을 기록하는 등의 후처리를 하기 적합한 메소드)

     

    스크린샷 2021-10-18 오후 9 36 03

    먼저 위와 같이 HandlerInterceptor를 구현한 하겠습니다. 함수 안에는 단순히 println()을 통해서 출력하는 코드를 작성하였습니다. 처음에 저는 이렇게 구현만 하면 되는 줄 알았는데, 이렇게만 하고 실행하면 Interceptor가 실행되지 않습니다. 실행을 하기 위해서는 따로 Interceptor 설정을 해줘야 합니다.

     

    스크린샷 2021-10-18 오후 9 40 23

    WebMvcConfigurer 인터페이스의 addInterceptors 메소드는 인터셉터를 설정하는 메소드 입니다. 그리고 Controller를 호출한 후에 println()을 통해서 출력하는 것들이 예상했던 대로 출력되는지 실행해보겠습니다.

     

    스크린샷 2021-10-18 오후 9 52 54

    위처럼 예측했던 순서대로 결과가 출력된 것을 볼 수 있습니다.



    컨트롤러에서 쿠키 사용하기

    쿠키에 대해서 많이 들어보셨을 것이고, 어디에 사용되는지 대략적으로라도 알고 계실텐데요. 쿠키가 사용되는 대표적인 예시는 사용자 편의를 위해 아이디를 기억해두었다가 다음에 로그인할 때 아이디를 자동으로 넣어주는 사이트 입니다.

    @PostMapping("/signup")
    fun sign(@RequestBody signUpDTO: SignUpDTO, @CookieValue(value = "remember", required = false) cookie: Cookie) {
      userService.signup(signUpDTO)
    }

    스프링 MVC에서 쿠키를 사용하는 방법 중 하나는 @CookieValue 애노테이션을 사용하는 것입니다. @CookieValue 애노테이션은 value 속성은 쿠키의 이름을 지정합니다.

     

    val rememberCookie = Cookie("remember", signUpDTO.email)
    rememberCookie.path = "/"
    rememberCookie.maxAge = 60 * 60 * 24 * 30
    response.addCookie(rememberCookie)

    예제 코드이지만, 가볍게 Cookie는 이런식으로 값을 설정해서 사용하는구나의 느낌을 얻으면 좋을 거 같습니다. 참고로 실제 상용 코드에서는 위의 코드처럼 메일 주소를 직접 저장하는 것이 아니라 암호화해서 보안을 높여야 합니다.

    반응형

    댓글

Designed by Tistory.