Cookie & Session

쿠키를 이용해서 로그인 상태 유지

login_cookie

사용자가 로그인을 하게 되면 서버에서는 로그인 적합성 여부를 판단해서 로그인을 수행한 다음 쿠키를 생성해서 클라이언트에 보낸다. 클라이언트는 받은 쿠키를 이용해서 추후 로그인 과정을 생략할 수 있다.

Login Controller

@PostMapping("/login")
public String login(@Validated @ModelAttribute LoginForm loginForm, BindingResult bindingResult, HttpServletResponse response) {
    if(bindingResult.hasErrors()){
        return "login/loginForm";
    }
    Member member=loginService.login(loginForm.getLoginId(), loginForm.getPassword());
    log.info("login? {}", member);

    if(member == null){
        bindingResult.reject("loginError", "로그인이 실패하였습니다.");
        return "login/loginForm";
    }
    Cookie cookie = new Cookie("memberId",String.valueOf(member.getId()));
    response.addCookie(cookie);

    return "redirect:/";
}

쿠키를 생성해서 클라이언트에 전달하는 작업은 Controller에 수행하게 된다. 위와 같이 쿠키를 생성해서 전달하게 되면, 클라이언트는 추후 요청에 있어 해당 쿠키를 전달하게 된다.

Home Controller

@GetMapping("/")
public String homeLogin(@CookieValue(name = "memberId", required = false) Long memberId, Model model) {
    if (memberId == null) {
        return "home";
    }

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

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

서버 측에서는 추후에 전달받은 쿠키 값을 확인해서, 해당 쿠키값이 유효하다면 로그인이 완료된 창으로 보내게 된다. 그러면 클라이언트는 추가적인 로그인을 수행할 필요 없이 로그인된 상태를 유지할 수 있게 된다.

Logout

LoginController

쿠키를 없애기 위해서는 종료날짜가 0으로 설정된 쿠키를 전송하게 되면 해당 쿠키는 삭제된다. 그러면 쿠키가 삭제되었으므로 로그인 상태 또한 제거된다.

    @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);
    }

Limitations

하지만 이와 같이 쿠키를 이용한 로그인 방식은 매우 보안에 취약하다. 우선, 쿠키에 등록되어 있는 값은 우효한 멤버 Id 값이다. 따라서, 해당 멤버 Id값을 수정한 쿠키를 서버에 보내게 되면 전혀 다른 사용자로 로그인 되는 문제를 발생시킬 수 있다. 이 처럼 쿠키에 개인정보를 담는 것은 매우 부적절하다. 이러한 문제는 해킹으로도 이어질 수 있게 쿠키에 직접적으로 민감한 개인정보를 넣어서는 안된다.

이러한 쿠키의 이용에 대한 대안책으로 세션을 활용한다. 세션을 이용해서 중요한 정보를 저장하게 되고, 쿠키에는 해당 세션 Id 값을 보관하게 된다. 이렇게 되면 쿠키를 이용한 개인정보 탈취 문제를 막을 수도 있고, 주기적인 세션 관리를 통해 해킹 문제를 대비할 수 있다.

Sessions

login_session

위와 같이 세션을 서버에서 관리하게 되고, 세션 Id 값은 cookie로 전달하게 된다. 세션 Id 값은 추적 불가능한 랜덤 값으로 해당 정보를 이용해서 얻어낼 수 있는 민감한 정보는 없다. 또한 특정 세션 Id를 이용해서 다른 세션 Id를 알아내는 것 또한 어렵기 때문에 매우 안전한 방식이다.

Session 직접 구현

세션 관리를 위한 Spring Bean 생성

@Component
public class SessionManager {
    public static final String SESSION_COOKIE_NAME = "mySessionId";
    private Map<String, Object> sessionStore = new ConcurrentHashMap<>();

    ...
}

세션 생성 작접

Session Manager createSession

public void createSession(Object value, HttpServletResponse response) {
    String sessionId = UUID.randomUUID().toString();
    sessionStore.put(sessionId, value);

    Cookie cookie = new Cookie(SESSION_COOKIE_NAME, sessionId);
    response.addCookie(cookie);
}

Login Controller

@PostMapping("/login")
public String loginV2(@Validated @ModelAttribute LoginForm loginForm, BindingResult bindingResult, HttpServletResponse response) {
    if(bindingResult.hasErrors()){
        return "login/loginForm";
    }
    Member member=loginService.login(loginForm.getLoginId(), loginForm.getPassword());
    log.info("login? {}", member);

    if(member == null){
        bindingResult.reject("loginError", "로그인이 실패하였습니다.");
        return "login/loginForm";
    }
    sessionManager.createSession(member, response);

    return "redirect:/";
}

UUID를 이용해서 랜덤한 세션Id 값을 생성해서 특정 멤버 객체를 저장한다. 그런 다음, 세션 Id를 쿠키를 이용해서 클라이언트에 전달한다.

세션 조회 작업

Session Manager getSession

public Object getSession(HttpServletRequest request) {
    Cookie cookie = findCookie(request);

    if (cookie == null) {
        return null;
    }
    return sessionStore.get(cookie.getValue());
}

private Cookie findCookie(HttpServletRequest request){
    if(request.getCookies() == null){
        return null;
    }

    return stream(request.getCookies())
            .filter((c) -> c.getName().equals(SESSION_COOKIE_NAME))
            .findAny()
            .orElse(null);
}

Home Controller

@GetMapping("/")
public String homeLoginV2(HttpServletRequest request, Model model) {
    //세션 관리자에 저장된 회원 정보 조회
    Member member = (Member)sessionManager.getSession(request);
    if (member == null) {
        return "home";
    }
    //로그인
    model.addAttribute("member", member);
    return "loginHome";
}

특정 세션에 저장되어 있는 객체를 찾아내기 위한 메소드. 이때, HttpRequest에는 여러 Cookie 정보가 들어 있기 때문에 서버에서 생성해서 전달한 쿠키를 찾아서 해당 쿠키의 값을 반환하는 메소드가 필요하다.

세션 종료 작업

Session Manager expireSession

public void expireSession(HttpServletRequest request,HttpServletResponse response) {
    Cookie cookie = findCookie(request);
    if (cookie != null) {
        sessionStore.remove(cookie.getValue());
    }
    cookie = new Cookie(SESSION_COOKIE_NAME, null);
    cookie.setMaxAge(0);
    response.addCookie(cookie);
}

Login Controller

@PostMapping("logout")
public String logoutV2(HttpServletRequest request,HttpServletResponse response){
    sessionManager.expireSession(request,response);
    return "redirect:/";
}

세션을 종료할 때는 세션 내의 저장되어 있는 값을 제거하고, 종료시간을 0으로 설정한 쿠키를 전달하므로써 세션과 쿠키 모두 제거한다.

Session Test

class SessionManagerTest {
    SessionManager sessionManager = new SessionManager();

    @Test
    void sessionTest() {
        //세션 생성
        MockHttpServletResponse response = new MockHttpServletResponse();
        Member member = new Member();
        sessionManager.createSession(member, response);

        //요청에 응답 쿠키 저장
        MockHttpServletRequest request = new MockHttpServletRequest();
        request.setCookies(response.getCookies());

        //세션 조회
        Object result = sessionManager.getSession(request);
        assertThat(result).isEqualTo(member);

        //세션 만료
        sessionManager.expireSession(request);
        Object expired = sessionManager.getSession(request);
        assertThat(expired).isNull();
    }
}

Spring의 MockHttpServletRequest을 이용해서 Http Request 관련 테스트를 수행할 수 있다.

위와 같이 실제로 Session 객체를 생성해서 구현할 수도 있지만, 세션, 쿠키와 같은 기능은 매우 기본적인 공통 기능으로 이미 구현되어 있으므로 이를 직접 구현할 필요 없이, 공식적으로 제공하는 기능을 활용하면 된다.

HttpSession

http_session_structure

HttpSession의 경우 위와 같이 request 1개당 session을 하나를 생성해서 각각의 session을 session 저장소에 저장하게 되며 해당 세션 Id를 JSESSIONID라는 쿠키값을 이용해서 접근할 수 있다. 추가로, session 1개에는 여러개의 (key,value) 쌍을 저장할 수 있기 때문에, 멤버 객체를 저장해주기 위한 key를 상수로 나타내면 쉽게 활용할 수 있다.

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

위와 같이 Interface 형태로 key를 만들어 놓게 되면, 해당 인터페이스 내의 상수를 쓰기만 하면 된다.

세션 생성

Login Controller

@PostMapping("/login")
public String loginV3(@Validated @ModelAttribute LoginForm loginForm, BindingResult bindingResult, HttpServletRequest request) {
    if(bindingResult.hasErrors()){
        return "login/loginForm";
    }
    Member member=loginService.login(loginForm.getLoginId(), loginForm.getPassword());
    log.info("login? {}", member);

    if(member == null){
        bindingResult.reject("loginError", "로그인이 실패하였습니다.");
        return "login/loginForm";
    }
    HttpSession session = request.getSession();
    session.setAttribute(SessionConst.LOGIN_MEMBER, member);

    return "redirect:/";
}

request.getSession()을 이용해서 해당 request의 세션을 받아오거나, 생성할 수 있다. setAttribute() 메소드를 이용해서 key,value 형태로 세션에 저장할 수 있다.

  • request.getSession(true)
    • 세션이 있으면 기존 세션 반환
    • 세션이 없으면 새로운 세션 생성
  • request.getSession(false)
    • 세션이 있으면 기존 세션 반환
    • 세션이 없더라도 새로운 세션 생성 안함

session을 새로 만들어주기 위해서는 request.getSession(true) 혹은 request.getSession()을 이용한다.

세션 종료

Login Controller

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

session.invalidate()를 통해서 해당 세션을 제거한다. 세션을 종료 하기만 하더라도, 더 이상 로그인 상태를 유지하지 않는 것이므로 굳이, 쿠키를 제거할 필요가 없다.

세션 조회

Home Controller

@GetMapping("/")
public String homeLoginV2(HttpServletRequest request, Model model) {
    HttpSession session = request.getSession(false);
    if(session==null){
        return "home";
    }

    Member member = (Member) session.getAttribute(SessionConst.LOGIN_MEMBER.name());

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

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

request.getSession(false)를 이용해서 세션을 조회한다. 세션이 없으면 애초에 로그인 상태가 없는 것이다. 그래서 여기서 session을 새로 생성할 필요가 없기 때문에 false처리를 해서 기존의 세션을 조회한다.

Using @SessionAttribute

@GetMapping("/")
public String homeLoginV3(@SessionAttribute(name=SessionConst.LOGIN_MEMBER,required = false) Member member,Model model) {
    if (member == null) {
        return "home";
    }

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

@SessionAttribute을 이용하면 조금 더 간편하게 session 내의 값을 값을 가져올 수 있다.

세션이 가지는 정보 조회

@GetMapping("/session-info")
public String sessionInfo(HttpServletRequest request) {
    HttpSession session = request.getSession(false);
    if (session == null) {
        return "세션이 없습니다.";
    }
    //세션 데이터 출력
    session.getAttributeNames().asIterator()
            .forEachRemaining(name -> log.info("session name={}, value={}",
                    name, session.getAttribute(name)));

    log.info("sessionId={}", session.getId());
    log.info("maxInactiveInterval={}", session.getMaxInactiveInterval());
    log.info("creationTime={}", new Date(session.getCreationTime()));
    log.info("lastAccessedTime={}", new
            Date(session.getLastAccessedTime()));
    log.info("isNew={}", session.isNew());
    return "세션 출력";
}

세션의 getAttributeNames을 반복문을 통해, 모든 attribute에 대한 값을 살펴보면 아래와 같은 정보들이 세션이 저장되어 있다.

attributes description
sessionId JSESSIONID(세션Id)
maxInactiveInterval 세션의 유효 시간(기본 30분)
creationTime 세션을 생성한 시간
lastAccessTime 세션에 마지막 접속한 시간
isNew 새로 생성된 세션인지 여부

기본적으로 세션은 maxInactiveInterval이 30분으로 설정되어 있다. 이렇게 설정하게 되므로써 불필요한 메모리 낭비를 줄일 수 있고, 세션 탈취에 대한 문제도 방지 할 수 있다. 이때, 생성일시를 기준으로 30분을 설정하게 되면 사용자가 이용하면서도 30분이 되면 로그인을 다시 해야하는 문제가 발생할 수 있기 때문에, 세션에 접속한 마지막 시간(lastAccessTime)을 기준으로 30분을 설정한다.

세션의 타임아웃 시간을 설정하느 방법

application.properties

server.servlet.session.timeout=60

위와 같이 설정하면 글로벌하게 모든 세션의 타임아웃 시간을 수정한다.

특정 세션

session.setMaxInactiveInterval(1800);

특정 세션의 타임아웃 시간을 조절하기 위해서는 해당 세션에 대한 setMaxInactiveInterval 메소드를 호출한다.

세션은 request 1개당 1개의 세션이 생성된다. 따라서 많은 request가 발생하게 되면, 그 만큼 세션의 개수도 많아지게 되고 이는 메모리를 과도하게 차지하는 문제가 된다. 따라서, 세션을 이용하게 되면, 세션에 저장되는 정보는 최소화해서 메모리를 과도하게 쓰는 문제가 없도록 주의해야한다.

References

link: inflearn

link:springmvc

댓글남기기