[Spring] 쿠키, 세션

본 글은 인프런 김영한님의 스프링 완전 정복 로드맵을 기반으로 정리했습니다.

0. Stateless HTTP


HTTP 는 무상태 프로토콜이기 때문에 이전 요청을 기억하지 않는다. 모든 요청을 새로운 요청으로 간주한다는 것이다. 그런데, 웹사이트들은 로그인, 로그아웃 등의 기능을 제공한다. 모든 요청을 새로운 요청으로 간주하는데 어떻게 로그인한 사용자와 아닌 사용자를 구분할 수 있는 것일까? 쿠키와 세션은 상태를 기억하지 않는 HTTP 프로토콜이 마치 상태가 있는 것처럼 동작할 수 있게 해주는 방법중 하나다. 로그인 처리 예재를 통해 쿠키와 세션이 무엇이며 어떻게 스프링MVC에서 편리하게 사용할 수 있는지 알아보자.

1. 쿠키


쿠키는 웹 브라우저의 쿠키 저장소에 저장되는 작은 데이터다. 서버가 Set-Cookie 응답 헤더를 통해 클라이언트의 쿠키 저장소에 쿠키를 저장하면, 클라이언트는 이 후 같은 서버에 요청시 쿠키 저장소에서 쿠키를 조회해서 Cookie 요청 헤더를 통해 전달한다. 이 쿠키에 사용자에대한 정보를 포함하면 HTTP를 마치 상태가 있는것처럼 동작하게 할 수 있다.

쿠키에는 영속 쿠키와 세션 쿠키가 있다. 예재에서는 세션 쿠키를 사용하도록 하겠다.

  • 영속 쿠키: 만료 날짜를 입력하면 해당 날짜까지 유지
  • 세션 쿠키: 만료 날짜를 생략하면 브라우저 종료시 까지만 유지
@PostMapping("/login")
public String login(@Valid @ModelAttribute LoginForm form,
                    BindingResult bindingResult,
                    HttpServletResponse response) {

    if (bindingResult.hasErrors()) {
        return "login/loginForm";
    }

    Member loginMember = loginService.login(form.getLoginId(), form.getPassword());

    if (loginMember == null) {
        bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
        return "login/loginForm";
    }

    // 쿠키에 시간 정보를 주지 않으면 세션 쿠키(브라우저 종료시 삭제)
    Cookie idCookie = new Cookie("memberId", String.valueOf(loginMember.getId()));
    response.addCookie(idCookie);
    return "redirect:/";
}

쿠키를 생성하고 HttpServletResponse 에 담는 부분에 집중하면 된다. 쿠키는 이름과 값을 가진다. 쿠키의 이름은 회원의 id를 표현한다는 의미에서 memberId이고 값으로 회원의 id를 문자열로 형변환해서 담았다. 이 id는 사용자가 로그인할 때 사용하는 id가 아니라 사용자의 값을 DB에서 식별하는 id다. 만료 날짜를 입력하지 않았으므로 영속 쿠키가 된다. 웹 브라우저는 종료 전까지 회원의 id를 서버에 계속 보내준다.

@GetMapping("/")
public String home(@CookieValue(name = "memberId", required = false) Long memberId, Model model) {

    if (memberId == null) {
        return "home";
    }

    Member loginMember = memberRepository.findById(memberId);
    if (loginMember == null) {
        return "home";
    }

    model.addAttribute("member", loginMember);
    return "loginHome";
}

브라우저가 보낸 쿠키를 사용하는 홈 화면 컨트롤러다.@CookieValue 애노테이션을 통해 편리하게 쿠키를 조회할 수 있다. 기본값은 true인데 홈 화면에 로그인하지 않은 사용자도 접근할 수 있어야 되므로 required 속성을 false로 했다. 로그인 하지 않은 사용자에게는 home화면을 렌더링하고 로그인한 사용자에게는 loginHome화면을 렌더링한다.

@PostMapping("/logout")
public String logout(HttpServletResponse response) {
    expireCookie(response, "memberId");
    return "redirect:/";
}

private void expireCookie(HttpServletResponse response, String cookieName) {
    Cookie cookie = new Cookie(cookieName, null);
    cookie.setMaxAge(0);
    response.addCookie(cookie);
}

로그아웃 요청을 하면 쿠키의 종료 날짜를 0으로 지정해서 응답한다. 그러면 Max-Age=0 인 쿠키가 응답되는데 브라우저는 해당 쿠키를 즉시 삭제한다.

쿠키만 사용해도 무상태 HTTP 프로토콜 위에서도 상태가 있는 것처럼 동작하게 할 수 있다. 그러나 쿠키만 사용하는 것은 아주 큰 보안 문제가 있다.

  1. 쿠키 값은 클라이언트가 임의로 변경할 수 있다. 클라이언트가 쿠키를 강제로 변경하면 다른 사용자인 것처럼 위장할 수 있다.

  2. 쿠키는 안전하지 않다. 쿠키는 클라이언트에 저장되고 네트워크 요청마다 계속 전달된다. 쿠키가 담긴 PC가 털릴수도 있고, 네트워크 요청 구간에서 털릴 수도 있다. 털릴 가능성이 있기 때문에 민감한 정보를 저장하면 안 된다.

  3. 쿠키를 탈취해간 사람이 그 쿠키로 악의적인 요청을 계속 시도할 수 있다.

위의 문제점들로 인해 쿠키에는 중요한 값을 노출하면 안 된다. 중요한 값은 이 예재에서는 클라이언트를 식별하는 사용자 id다. 그 대신, 사용자 별로 임의의 토큰(랜덤 값)을 노출하고, 서버에서 토큰과 사용자 id를 매핑해서 인식해야 한다. 그리고 서버에서 토큰을 관리해야 한다. 토큰값은 예상 불가능한 임의의 값이어야 한다. 그리고, 토큰이 털려도 피해가 적도록 토큰의 만료시간을 짧게(예: 30분)유지해야 한다. 토큰은 서버에서 관리하기 때문에 의심되는 경우 토큰을 제거할 수도 있다.

2. 세션 + 쿠키


쿠키는 보안에 취약하기 때문에 중요한 정보는 서버에 저장하고 관리되어야 한다. 그리고 클라이언트는 서버가 임의의 추정 불가능한 값(토큰)만 저장해야 한다. 이 토큰을 서버의 정보와 매핑해서 사용한다. 이렇게 서버에 중요한 정보를 보관하고 토큰을 통해 매핑해서 연결을 유지하는 방법을 세션이라고 한다.



세션을 사용해도 결국 클라이언트와 서버는 쿠키로 연결 된다. 단, 중요한 차이는 중요한 정보를 클라이언트가 아닌 서버에서 관리한다는 것이다. 클라이언트는 사용자와 관련된 정보를 전혀 저장하지 않는다. 다만, 서버가 관리하는 정보에 접근할 수 있는 의미 없어 보이는 추정 불가능한 값만 쿠키에 저장한다. 이 값(토큰)을 흔히 세션ID 라고 한다.

세션을 쿠키와 함께 사용하면 쿠키만 사용할 때의 문제점들이 해결된다.

  1. 쿠키 값을 변경 가능 -> 예상 불가능한 토큰을 사용하기 때문에 변조하기 힘들다.

  2. 탈취 가능성 -> 쿠키에 민감한 정보가 없다.

  3. 탈취 후 사용 -> 세션의 만료시간을 짧게(예: 30분) 유지해서 피해를 최소화 한다. 탈취가 의심되는 경우 해당 세션을 강제로 제거한다.

세션을 사용해도 세션ID가 저장된 쿠키를 탈취해가면 해당 사용자인 척 위장해서 요청을 보낼 수 있는 문제가 존재한다. 그래도, 쿠키 자체에는 민감한 정보가 없으며 만료시간을 짧게하고 또, 의심되는 경우 서버에서 해댱 세션을 강제로 제거하면 되기 때문에 쿠키만 사용하는 경우보다 크게 발전했다고 볼 수 있다.

세션을 사용해서 로그인하는 코드를 살펴보자.

public interface SessionConst {
    String LOGIN_MEMBER = "loginMember";
}

세션에 데이터를 넣고 뺄 때 사용하기 위해 SessionConst 인터페이스를 정의했다.

@PostMapping("/login")
public String login(@Valid @ModelAttribute LoginForm form,
                    BindingResult bindingResult,
                    HttpServletRequest request) {

    if (bindingResult.hasErrors()) {
        return "login/loginForm";
    }

    Member loginMember = loginService.login(form.getLoginId(), form.getPassword());

    if (loginMember == null) {
        bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
        return "login/loginForm";
    }

    // 세션이 있으면 반환, 없으면 신규 세션을 생성 (request.getSession(true) 와 동일)
    HttpSession session = request.getSession();

    session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember);
    return "redirect:/";
}

서블릿은 HttpSession 을 통해 세션기능을 제공한다. 위에서 세션을 생성하기 위해 public HttpSession getSession(boolean create)를 사용했다.

getSession 함수의 create는 기본값이 true기 때문에 옵션을 명시하지 않고 세션을 생성하기 위해 사용할 수 있다. 세션은 메모리를 사용하기 때문에 불필요한 경우 생성하면 메모리가 낭비된다는 것을 인지해야 한다.

request.getSession(true)

  • 세션이 있으면 기존 세션을 반환한다.
  • 세션이 없으면 새로운 세션을 생성해서 반환한다.

request.getSession(false)

  • 세션이 있으면 기존 세션을 반환한다.
  • 세션이 없으면 null을 반환한다.

세션에 데이터를 보관하는 것은 session.setAttribute(name, value); 를 통해 하면 된다. HttpServletRequest.setAttribute(...) 와 유사하다. 하나의 세션에 여러 값을 저장할 수 있다.

@PostMapping("/logout")
public String logout(HttpServletRequest request) {
    HttpSession session = request.getSession(false);
    if (session != null) {
        session.invalidate();
    }
    return "redirect:/";
}

로그아웃 요청시 세션을 무효화(삭제)하는 코드다. session.invalidate() 를 사용한다. 세션이 없을 때 생성하지 않기 위해 false 옵션을 준 것에 유의하자.

@GetMapping("/")
public String home(
        @SessionAttribute(name = SessionConst.LOGIN_MEMBER, required = false) Member loginMember,
        Model model) {

    if (loginMember == null) {
        return "home";
    }

    model.addAttribute("member", loginMember);
    return "loginHome";
}

세션을 사용하는 홈 화면 컨트롤러다. 세션을 조회해서 로그인 유무를 검사하고 로그인 유저와 비로그인 유저에게 각각 다른 화면을 생성해서 응답한다.

생성한 세션을 조회하기 위해 스프링은 @SessionAttribute를 제공한다. 이 기능은 세션을 생성하지 않기 때문에 HttpServletRequest.getSession(create) 보다 편리하게 사용할 수 있다. 세션에 저장한 이름(SessionConst.LOGIN_MEMBER)으로 조회하면 저장한 값 타입(Member)으로 알아서 형변환 해준다. 세션이 없는 비로그인 유저도 홈 화면에 접근할 수 있어야 되므로 required=false 옵션을 줬다.

참고로, 세션에 Member 객체 전체를 저장하는 방식은 실제로 추천하지 않는다. 앞서 말했듯이 세션은 메모리를 사용하기 때문에 불필요한 정보를 저장하면 메모리 낭비로 이어진다. Member의 식별자 혹은 꼭 필요한 정보만 추려서 세션에 저장하는 방식을 추천한다.

3. 세션 타임아웃


세션은 사용자가 로그아웃을 요청하면 session.invalidate() 가 호출되면서 무효화(삭제)된다. 그러나, 대부분의 사용자는 로그아웃하지 않고 그냥 웹 브라우저를 종료한다. 문제는 HTTP는 비연결성(Connectionless) 프로토콜이기 때문에 서버는 사용자가 브라우저를 종료했는지 알 방법이 없다. 따라서, 서버는 세션을 언제 삭제해야 하는지 알 수가 없다.

세션은 기본적으로 메모리에 저장되기 때문에 무한정 저장하면 메모리가 낭비될 뿐 아니라, 만약 세션ID를 보관하는 쿠키가 탈취됐을 경우 세션을 삭제하지 않으면 오랜 시간동안 해당 쿠키로 악의적인 요청이 수행될 위험이 있다. 즉, 세션의 종료 시점을 설정해줄 필요가 있다.

세션의 종료 시점은 짧게 해주는 것이 좋다. 단순하게 30분을 설정했다고 가정해보자. 그러면 사용자는 30분마다 로그인을 해야될 것인데 이는 번거롭기 때문에 사용자 경험 측면에서 나쁘다. 더 나은 대안은 사용자가 최근에 요청한 시간을 기준으로 30분을 유지하는 것이다. 사용자 요청이 있을 때마다 즉, 세션이 사용될 때마다 세션의 생존 시간을 30분으로 계속 유지하는 것이다. 그러다, 사용자가 30분동안 아무런 요청도 하지 않으면 세션을 삭제하는 것이다. HttpSession은 이 방식을 사용한다.

스프링 부트에서는 application.properties를 통해 세션 타임아웃 시간을 글로벌하게 설정할 수 있다. 아래의 경우 세션의 유효시간은 사용자의 마지막 요청부터 30분이 된다. 글로벌 설정은 분 단위로 설정해야 한다.

server.servlet.session.timeout=1800

필요하다면 세션에 글로벌 유효시간 대신 특정 유효시간을 적용할 수 있다.
session.setMaxInactiveInterval(1800) // 1800초

좋은 웹페이지 즐겨찾기