Spring MVC/01.WebProgramming

07. cookie와 session

은서파 2024. 9. 8. 23:30

이번 시간에는 Cookie, Session등 웹 애플리케이션에서 상태를 유지하기 위한 방법들에 대해 살펴보자.

 

Http의 Stateless 한 특성

 

기본적으로 HTTP는 stateless 한 특성을 지닌다(반대말은 stateful 이다). 즉 한번 request를 보내고 서버로부터 response를 받으면 서로 남남으로 이전 요청의 상태를 기억하지 않는다. 그래서 로그인 후 글쓰기를 하려해도 다시 로그인 하라고 이야기 하게 된다.

기본적으로 클라이언트의 상태를 기억하지 않는다.

왜 HTTP는 stateless 할까?

더보기
  • 확장성: 서버가 상태를 저장하지 않기 때문에 새로운 서버를 추가(scale out)하거나 기존 서버를 제거하는 것이 쉽다. 이를 통해 대규모 트래픽에 대응하기가 용이하다.
  • 단순성: 들어오는 요청만 처리하면 되기 때문에 시스템의 구성이 간단해지고 구현이 쉬워진다. 상태 없이 들어오는 요청만으로 동작하므로 동일한 요청에는 동일한 결과가 오고 이를 통해 캐싱이 용이해진다.
  • 자원의 효율성: 서버가 상태 정보를 저장할 필요가 없기 때문에 메모리와 자원을 절약할 수 있다.
  • 신뢰성: 요청들이 독립적이기 때문에 네트워크 오류, 서버 문제 발생시 다른 요청에 영향을 주지 않는다.


그럼 클라이언트의 상태를 저장하기 위해서는 어떤 장치를 이용해야 할까?

 

Cookie

 

Cookie란?

쿠키란 웹 서버에서 정보를 생성해서 클라이언트 즉 브라우저에 보관하는 데이터를 말한다. 

다음은 쿠키의 동작 메커니즘을 보여준다.

쿠키의 동작

  1. 처음에 클라이언트에는 쿠키가 없는 상태에서 서버로 요청을 날리게 된다.
  2. 서버는 필요에 따라 쿠키를 생성한다.
  3. 생성된 쿠키는 response를 통해 브라우저에 전달된다.
  4. 브라우저는 도메인별로 쿠키를 관리하는데 쿠키에는 path 정보가 포함된다.
  5. 다시 해당 도메인의 path로 request를 날리게 되면 저장되었던 쿠키가 서버로 전달되게 된다.

 

Cookie의 구성 요소

쿠키는 다음의 구성 요소를 갖는다.

구성  요소설명
이름 (Name) 쿠키의 고유 식별자로 unique 해야 함. 쿠키를 저장할 때 지정하는 키.
알파벳, 숫자, 특수문자(하이픈, 언더스코어, 점, 틸드)로 구성되며 나머지는 문자는 인코딩 필요
값 (Value) 쿠키와 연결된 데이터로 문자열로 기본적으로 Name과 동일한 규칙이 적용
도메인 (Domain) 쿠키가 유효한 도메인. 해당 도메인에서만 쿠키에 접근 가능.
경로 (Path) 쿠키가 유효한 URL 경로로 지정된 경로 이하에서만 쿠키 사용 가능. default는 쿠키가 생성된 Path.
최대 수명 (Max-Age) 쿠키의 최대 생존 시간(초 단위). 만료일 대신 사용할 수 있음. 
음수 또는 지정하지 않을 경우 웹 브라우저 종료 시 삭제 - Session과 동일. default -1
0이면 브라우저에서 즉시 삭제
보안 (Secure) HTTPS 연결에서만 쿠키 전송 여부. 설정하면 HTTPS에서만 전송됨. default false
HttpOnly JavaScript에서 접근할 수 없도록 설정. XSS 공격 방지에 도움. default false


쿠키를 생성할 때는 Cookie 생성자를 사용하면 되고 나머지 내용은 setter를 통해 설정한다. 생성한 쿠키는 HttpServletResponse를 통해 내려보내야 의미가 있으며 서버에서 쿠키를 확인하기 위해서는 HttpServletRequest를 사용한다.

Cookie cookie = new Cookie(name, value);
cookie.setMaxAge(maxAge);
cookie.setPath(path);
// 쿠키는 response를 통해서 전송해야 의미가 있다.
response.addCookie(cookie);

// 쿠키를 확인할 때는 request를 활용한다.
Cookie [] cookies = request.getCookies();

쿠키를 수정할 때는 동일한 이름의 쿠키를 만들어서 내려보내면 된다. 이름은 중복되지 않기 때문이다.

쿠키를 명시적으로 삭제하는 메서드는 제공되지 않으며 삭제하려는 key로 쿠키를 만들고 만료일을 0으로 설정해주면 유효기간이 0인 쿠키가 브라우저로 내려가고 기존 쿠키를 덮어쓰면서 삭제하게 된다.

 

쿠키 작성 및 동작 확인

다음과 같이 쿠키를 만들어보고 쿠키의 동작에 관련된 질문에 대해 답해보자.

쿠키의 동작 확인을 위한 서버 구성

  1. /cookie/cookiemaker를 호출했을 때 forward로 호출된 cookieconsumer.jsp에서 확인되는 cookie의 종류와 원인은?
  2. /cookie/cookieconsumer를 호출했을 때 cookieconsumer.jsp에서 확인되는 cookie의 종류와 원인은?
  3. 브라우저를 즉시 종료 후 다시 호출한 cookieconsumer.jsp에서 확인되는 cookie의 종류는?
  4. 1분 후 다시 호출한 cookieconsumer.jsp에서 확인되는 cookie의 종류는?

 

더보기
// FrontServlet.java
private String cookieMaker(HttpServletRequest req, HttpServletResponse resp) {
  resp.addCookie(makeCookie("some", "value", 60*1, req.getContextPath()+"/gugu"));
  resp.addCookie(makeCookie("name", "홍길동", 60*1, req.getContextPath()+"/cookie"));
  resp.addCookie(makeCookie("addr", URLEncoder.encode("대한민국 서울", "UTF-8"), -1));  // 공백처리
  resp.addCookie(makeCookie("phone", "010-1234", 0));
  return "/cookieconsumer.jsp";
}

private Cookie makeCookie(String key, String value, int maxAge, String path){
  Cookie cookie = makeCookie(key, value, maxAge);
  cookie.setPath(path);
  return cookie;
}

private Cookie makeCookie(String key, String value, int maxAge){
  Cookie cookie = new Cookie(key, value);
  cookie.setMaxAge(maxAge);
  return cookie;
}
<!-- cookieconsumer.jsp -->
<%@ page import="java.net.URLDecoder" %>
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
    <title>cookie 확인</title>
</head>
<body>
<%
  Cookie [] cookies = request.getCookies();
  if(cookies!=null){
    for(Cookie c:cookies){
      out.println(c.getName()+" : "+ URLDecoder.decode(c.getValue(), "UTF-8")+"<br>");
    }
  }
%>
</body>
</html>

 

쿠키의 확인

쿠키를 확인하기 위해서는 개발자도구의 [네트워크]-[쿠키 탭] 또는 [애플리케이션]-[저장용량]-[쿠키] 기능을 사용할 수 있다. [네트워크]에서는 요청/응답 쿠키를 모두 확인 가능한데 읽기 전용이고 [애플리케이션]에서는 쿠키를 삭제할 수 있다.

다음은 cookie/cookiemaker를 요청했을 때 [네트워크] 탭에서 살펴본 cookie 정보이다.

쿠키의 정보 확인

그런데 요청 쿠키에는 우리가 생성하지 않은 JSESSIONID라는 이름의 쿠키도 존재한다. 이것의 정체는 무엇일까?

쿠키 활용 연습

addResult.jsp에 기존의 더하기 이력을 쿠키를 통해서 표현해보자.

지금 요청해서 가져온 결과와 이전 결과를 함께 보이기

 

상태 저장 영역의 활용

 

4가지 상태 저장 영역

앞서 이야기했듯이 HTTP 프로토콜은 기본적으로는 클라이언트의 상태를 기록하지 않는다. 생각해보면 수백만의 사람이 웹 페이지를 사용한다고 했을 때 이들의 모든 정보를 유지한다면 서버의 부담은 엄청날 것이다.  그래서 꼭 필요한 일부 정보들만 상황에 따라 선별적으로 특정 영역을 이용해서 저장하기로 했다. 이를 위해 page, request, session, application의 4가지 영역을 사용한다.

상태 저장을 위한 4가지 영역

영역 설명 활용 예
page(PageContext) .jsp에서 특정 페이지에서만 유효한 데이터 저장. 거의 사용될 일이 없음 계산 결과, 사용자의 입력 값
request(HttpServletRequest) 요청이 발생하고 응답이 이뤄지기 전까지 유지할 데이터 저장. 주로 forward를 통해서 두 번째 웹 컴포넌트에 정보를 전달하기 위함
하나의 요청이 끝날 때까지만 존재
요청 정보, 생산한 값 등
session(HttpSession) 사용자의 세션이 유지되는 동안 유지해야 할 정보로 여러 번의 response 과정에서 사용자의 상태를 저장하기 위해 사용
브라우저를 닫는 등 세션이 종료될 때까지 존재
로그인 정보, 장바구니 정보 등
application(ServletContext) 여러 개의 session간 공유할 데이터 즉 애플리케이션 전체에서 공유되는 데이터를 저장하는 영역
애플리케이션이 종료될 때까지 존재
애플리케이션 공통 객체(DB 연결 정보, 사용자 권한 목록)

각 영역은 page < request < session < application의 순으로 정보가 유지되는 범위가 넓어진다.

 

각 영역의 사용

각 영역에 해당하는 객체를 어떻게 획득해서 사용할 수 있는지 살펴보자.

영역 JSP - 내장 객체 사용 Servlet - HttpServletRequest에서 획득
page(PageContext) pageContext JSP 전용
request(HttpServletRequest) request HttpServletRequest req;
session(HttpSession) session HttpSession session = req.getSession();
application(ServletContext) application ServletContext ctx = req.getServletContext();

일단 요소들을 얻었다면 사용 가능한 메서드를 알아봐야 하는데 객체가 4개나 되서 부담스럽긴 하지만 다들 attribute를 사용하기 때문에 사용하는 메서드는 동일하다. attribute의 활용은 redirect와 forward에서 살펴본 내용이다.

public void setAttribute(String name, Object value)  // name으로 value를 저장한다.
public Object getAttribute(String name)              // name으로 저장된 value를 반환한다.
public void removeAttribute(String name)             // name으로 저장된 attribute를 삭제한다.
Enumeration getAttributeNames()                      // 영역에 저장된 attribute 목록을 반환한다.

 

여러 페이지를 거치는 동안의 데이터 상태 확인

다음과 같은 흐름에서 second.jsp와 ThirdServlet에서 확인되는 application, session, request에 저장된 데이터를 추측해보자.

각 상황에서 데이터 상태는?

  1. setattr에서 forward/redirect로 getattr.jsp를 호출할 때 application, session, request에서 확인되는 attribute들은?
  2. 전혀 다른 브라우저에서 getattr.jsp를 호출할 때 확인되는 attribute들은?
  3. 브라우저를 종료 후 다시 getattr.jsp를 호출할 때 확인되는 attribute들은?
  4. 서버 재 시작 후   다시 getattr.jsp를 호출할 때 확인되는 attribute들은?
더보기
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) {
  ...
  else if(path.equals("/session/setattr")){
    target = checkAttribute(req, resp);
  } else if(path.equals("/session/getattr")){
    target = "/session/getattr.jsp";
  }
  . . .
}

private String checkAttribute(HttpServletRequest req, HttpServletResponse resp) {
  String type = req.getParameter("type");
  req.setAttribute("reqAttr", "reqAttr: "+type);
  HttpSession session = req.getSession();
  session.setAttribute("sesAttr", "sesAttr: "+type)  ;
  ServletContext ctx = req.getServletContext();
  ctx.setAttribute("ctxAttr", "ctxAttr: "+type);
  if(type.equals("forward")){
    return "/session/getattr.jsp";
  }else{
    return "redirect:/session/getattr.jsp";
  }
}
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
    <title>attribute check</title>
</head>
<body>
<%
    String root = request.getContextPath();
%>
<ul>
    <li>request param: <%=request.getParameter("type")%></li>
    <li>request attr: <%=request.getAttribute("reqAttr")%></li>
    <li>session attr: <%=session.getAttribute("sesAttr")%></li>
    <li>context attr: <%=application.getAttribute("ctxAttr")%></li>
</ul>
<a href="<%=root%>/session/setattr?type=forward">forward</a> |
<a href="<%=root%>/session/setattr?type=redirect">redirect</a>
</body>
</html>

 

HttpSession

 

세션의 동작 방식

4가지 저장 영역에 대한 이해를 바탕으로 세션에 대해 좀 더 살펴보자. 개인의 정보를 request를 넘는 범위까지 저장하기 위해서는 쿠키와 세션을 사용할 수 있다. 이 둘의 동작을 비교해보자.

구분 쿠키 세션
저장위치 클라이언트(웹 브라우저) 서버의 메모리
저장 가능한 데이터 문자열 객체
보안 네트워크로 전송되므로 탈취 위험 서버에 저장되므로 상대적으로 안전
소멸 유효기간 브라우저 종료, 유효기간, 명시적 세션 종료

쿠키의 경우는 웹 브라우저에 저장되기 때문에 특별히 누구의 것인지 구분해야할 필요가 없다. 하지만 세션은 서버의 메모리에 클라이언트의 상태를 저장한다. 서버에서는 어떻게 개별 사용자를 구분할 수 있을까? 이를 위해서는 JSESSIONID라는 쿠키를 이용한다.

JSESSIONID 쿠키를 이용한 세션의 동작

  1. 아무것도 없는 상태에서 사용자의 요청이 발생한다.
  2. 서버는 사용자를 위한 세션 공간을 서버에 할당하고 필요한 데이터를 저장하고 공간에 접근하기 위한 키로 JSESSIONID를 생성한다.
  3. JSESSIONID는 세션 쿠키(브라우저를 닫으면 삭제)로 응답을 통해서 브라우저에 저장된다.
  4. 브라우저를 닫지 않은 상태에서 요청이 발생하면 JSESSIONID는 서버로 전송된다.
  5. 서버는 JSESSIONID를 이용해 사용자를 구분하고 세션 공간을 사용할 수 있게 한다.

 

세션의 유효기간

쿠키의 경우 브러우저에 저장되므로 저장되는 데이터의 크기나 유효 기간에 큰 신경을 쓰지 않는다. 하지만 세션은 서버의 메모리 공간을 사용하기 때문에 효율적인 관리가 필요하다. 100만명의 사용자가 1kb의 메모리만 써도 1GB는 순간이다. 그럼 어떻게 메모리를 관리할까?

세션에서도 쿠키와 마찬가지로 유효기간의 개념이 존재한다. 재밋는 점은 쿠키가 생성 시점을 기준으로 유효기간을 잡는 반면 세션은 사용자의 마지막 요청(request) 시점 이후 주어진 세션 사용 시간까지 세션이 유효하다. 

session.getLastAccessedTime(); // 이번 세션의 클라이언트가 마지막으로 요청을 보낸 시각


세션의 유효시간 설정은 2가지 방식으로 처리할 수 있다. 먼저 메서드를 이용해서 세션별로 시간을 지정할 수 있다.

session.setMaxInactiveInterval(int sec);  // 초 단위로 세션의 최대 비활성 시간 설정

하지만 이 방법 보다는 설정을 통해서 유효기간을 설정하는 것이 일반적이다. 이를 위해 web.xml을 이용한다.

<session-config>
    <session-timeout>30</session-timeout>
</session-config>

만약 유효기간이 0이하일 경우 세션은 영구히 존재하게 된다. 이럴 경우 클라이언트가 접속을 끊으면 사용할 수 없는 공간이 서버에 계속 존재하게 되므로 주의가 필요하다.

참고로 세션 유효기간의 적용 우선순위는 다음과 같다.

  1. 프로그램에서 session.setMaxInactiveInterval)_에 의해 설정된 값
  2. project의 web.xml 에 설정된 값
  3. was의 conf/web.xml에 설정된 기본 값 

 

세션의 만료

세션은 다음의 상황에서 만료될 수 있다.

  1. 브라우저의 종료 - 사용자가 더 이상 웹을 사용하지 않는 상황
  2. 세션 유효기간 종료 - 일정 기간동안 사용자가 자리를 비운 상태에서 부정한 사용 방지
  3. 명시적 세션 종료 - session.invalidate()로 사용자가 현 사이트의 사용을 중지하는 경우(로그아웃)

session.removeAttribute(String name)의 경우 특정 name에 해당하는 속성만을 삭제한다. 반면 session.invalidate()의 경우는 해당 세션을 완전히 제거하는 것이다. 따라서 로그아웃 처리를 위해서는 invalidate 메서드를 사용한다.

 

세션과 쿠키를 활용한 로그인/로그아웃 처리

 

id와 pass를 입력 받아서 로그인 처리하기

id/pass를 입력 받아서 로그인 성공시와 실패시의 동작을 처리하자.
 - 사용자 정보를 담아서 처리할 DTO를 Member라고 하고 계정은 hong/1234로 한다.
 - 로그인 화면을 위한 url은 /loginform으로 하고 jsp를 연결한다. form의 parameter는 각각 id, pass로 설정한다.
 - 로그인 결과 처리를 위한 url은 /login으로 설정한다.


 - /loginform과 /login의 method는 각각 어떻게 처리할까?
 - 로그인 성공 시 어떻게 처리할까?
 - id/pass가 틀려서 로그인 실패 시 어떻게 처리할까?

 

record 클래스 및 서비스 구성

id / name / pass 로 구성된 User를 record로 만들어보자.

public record User(String id, String name, String password) {}

// SimpleService
public Optional<User> login(String id, String password) {
    if(id.equals("hong") && password.equals("1234")){
        return Optional.of(new User(id, password, "홍길동"));
    }else{
       return  Optional.empty();
    }
}

 

로그인 화면 구성

header.jsp를 수정해서 로그인 이전과 이후의 상태를 표현하도록 작성해보자.

<%@ page import="com.doding.simpleweb.model.service.dto.User" %>
<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<%
    String root = request.getContextPath();
%>
<h1 >Simple Web App</h1>
<%
    Object loginUser = session.getAttribute("loginUser");
    if(loginUser instanceof User) { // 로그인된 상태에서의 화면 - 정보 표시 및 로그아웃 기능 제공
        User user = (User) loginUser;
%>
    <%=user.name()%> 님 반갑습니다. <a href="<%=root%>/user/logout">로그아웃</a>
<%
    }else{                         // 로그인되지 않은 상태에서의 화면 - 로그인 기능 제공
%>
<form action="<%=root%>/user/login" method="post" >
    <input type="text" name="id" id="id">
    <!-- browser의 사용자 정보 저장 기능으로 autocomplete='off'는 동작하지 않음-->
    <input type="password" name="password" id="password"  autocomplete="new-password">
    <label> <input type="checkbox" name="rememberme" id="rememberme">아이디기억하기</label>
    <input type="submit" value="login">
</form>
<%
    }
%>

<script>
    <% // 에러 메시지가 있을 경우 alert을 통해 출력하기
        Object error = request.getAttribute("error");
        if(error != null) {
            out.print("alert('"+error+"')");
        }
    %>

    <% // 쿠키 정보에서 로그인 정보 확인 및 설정
        Cookie [] cookies = request.getCookies();
        String savedId = "";
        if(cookies!=null){
            for(Cookie c:cookies){
                if(c.getName().equals("loginId")){
                    savedId = c.getValue();
                    break;
                }
            }
        }

    %>
    (function(){
        if("<%=savedId%>"){
            document.querySelector("#id").value="<%=savedId%>";
            document.querySelector("#rememberme").checked=true;
        }
    })();
</script>

 

FrontController를 통한 login/logout 처리

private String login(HttpServletRequest request, HttpServletResponse response)
            throws IOException, ServletException {
    // rememberme 처리
    String remember = request.getParameter("rememberme");
    String id = request.getParameter("id");
    String pass = request.getParameter("password");
    if (remember != null) {
        response.addCookie(makeCookie("loginId", id, 60*5, request.getContextPath()));
    } else {
        response.addCookie(makeCookie("loginId", id, 0, request.getContextPath()));
    }
    // login 처리
    Optional<User> user = new SimpleService().login(id, pass);
    if (user.isPresent()) {
        HttpSession session = request.getSession();
        session.setAttribute("loginUser", user.get());
        return "redirect:/index.jsp";
    } else {
        request.setAttribute("error", "id/pass를 확인하세요.");
        return "/index.jsp";
    }
}
private String logout(HttpServletRequest request, HttpServletResponse response)
          throws IOException {
    HttpSession session = request.getSession();
    session.invalidate();
    return "redirect:/index.jsp";
}