로그인 여부와 로그인 정보 유지를 위해서는 쿼리 파라미터를 사용하는 방법이 있는데, 이는 너무 번거롭고 힘든 작업이다. 따라서 쿠키를 사용해 사용자의 정보를 저장하기로 했다. 아래의 쿠키의 사용법을 살펴보자.
사용자가 로그인을 하기위해 아이디와 비밀번호를 입력해 서버에 요청하면, 서버는 로그인 정보를 회원저장소에서 찾아 memberId를 key로 하는 Cookie를 생성해 웹브라우저에게 응답한다.
쿠키의 구조 : key memberId
value 1
웹브라우저는 서버의 응답 메시지를 받아 Cookie를 쿠키 저장소에 저장한다.
Cookie를 쿠키 저장소에 저장했으니, 이제는 서버에 요청을 할 때마다 쿠키 저장소에서 쿠키를 찾아서 요청메세지에 담는다. 여기서 사용하는 쿠키는 세션 쿠키로 로그인 상태에서, 웹브라우저가 종료되지 않은 상태라면 계속 유지 된다.
위에서 말했듯 서버로 보내는 모든 요청 메세지에 쿠키 정보가 자동으로 포함된다. 따라서 서버는 요청을 한 클라이언트가 누구인지, 정보를 알 수 있다.
하지만 이러한 쿠키 사용엔 심각한 문제점이 있다.
바로 쿠키를 탈취당했을 경우인데, 위의 쿠키의 key는 memberId이지만 만약 쿠키의 key가 주민번호나 신용카드 번호라면 심각한 상황에 놓이게 된다. 따라서 Session을 사용하는데 세션의 동작은 아래에서 알아보자.
사용자가 loginId , password 정보를 요청 메시지에 담아 서버에 전달하면 서버에서 회원 저장소를 통해 해당 사용자가 맞는지 확인한다.
추정 불가능한 세션id를 생성해야하므로 UUID를 생성한다.
생성된 세션ID와 세션에 보관할 값인 memberA를 서버의 세션 저장소에 보관한다.
이 때 세션 저장소는 key와 value로 구성되어 있으며 key는 sessionId이고 value는 객체로 이루어져 있다.
서버는 클라이언트에 mySessionId라는 이름으로 세션ID만 쿠키에 담아 전달하고 클라이언트는 받은 mySessionId를 쿠키 저장소에 보관한다.
쿠키의 구조 : key(name) mySessionId
value(id) sessionId
따라서 클라이언트와 서버는 결국 쿠키로 연결이 되어야한다.
여기서 중요한 것은 서버는 세션id만 클라이언트에게 전달하므로 쿠키에는 중요한 회원정보등 회원과 관련된 아무런 정보도 담겨있지 않다는 것이다.
오직 추정 불가능한 세션 ID만 쿠키를 통해 클라이언트에 전달한다.
이때 주의해야할 점이 있는데 바로 쿠키와 세션저장소의 key와 value를 헷갈리지 말아야한다는 것이다. 쿠키에는 sessionId를 value값으로 보내는데 세션저장소에서는 sessionId가 key값이 된다. 이를 헷갈리지 말자.
쿠키의 구조 : key SessionName
value SessionId
세션 저장소의 구조 : key SessionId
value Object
클라이언트는 요청시에 항상 mySessionId 쿠키를 전달해 서버에선 해당 쿠키의 정보로 세션 저장소를 조회해 로그인시에 보관한 세션 정보를 사용한다.
그렇다면 위의 구조를 바탕으로 세션을 생성하고 로그인에 활용해보자.
private Map<String, Object> sessionStore = new ConcurrentHashMap<>();
public void createSession(Object value, HttpServletResponse response) {
//세션 id생성하고, 값을 세션에 저장
String sessionId = UUID.randomUUID().toString();
sessionStore.put(sessionId, value);
//쿠키 생성
Cookie mySessionCookie = new Cookie(SESSION_COOKIE_NAME, sessionId);
response.addCookie(mySessionCookie);
}
위의 코드는 session을 생성하는 메서드이다.
String sessionId = UUID.randomUUID().toString();
: sessionId는 추정 불가능한 값이어야하므로 UUID를 활용해 생성한다.
sessionStore.put(sessionId, value);
: 세션 저장소에 key를 sessionId로 value는 value로 값을 세션에 저장한다.
Cookie mySessionCookie = new Cookie(SESSION_COOKIE_NAME, sessionId);
: 쿠키를 생성한다. 이 때 쿠키는 SESSION_COOKIE_NAME = "mySessionId"이므로 mySessionId=sessionId의 구조를 가진다. 세션 저장소는 sessionId=value의 구조를 가질 것이다.
response.addCookie(mySessionCookie);
: 응답에 쿠키를 담는다.
여기서 주목해야할 점은 sessionStore, 즉 세션저장소는 Map으로 만들어 졌으며 (sessionId, value(Object))의 구조를 가진다는 점이다.
이제 서블릿이 지원하는 세션을 사용해서 sessionId를 생성하고 활용하는 것을 보자.
public String loginV3(@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";
}
//로그인 성공 처리
//세션이 있으면 있는 세션 반환, 없으면 신규 세션을 생성
HttpSession session = request.getSession();
//세션에 로그인 회원 정보 보관
session.setAttribute(SessionConst.LOGIN_MEMBER,loginMember);
return "redirect:/";
}
코드를 한줄씩 분석해보자.
if (bindingResult.hasErrors()) {
return "login/loginForm";
}
만약 검증에 실패할 경우 다시 로그인 페이지로 돌아간다.
Member loginMember = loginService.login(form.getLoginId(), form.getPassword());
: 입력한 로그인 정보를 바탕으로 회원 저장소를 통해 로그인한 회원을 찾아 반환해 loginMember에 담는다.
if (loginMember == null) {
bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습ㄴ디ㅏ.");
return "login/loginForm";
}
```
: loginMember가 null인 경우엔 입력한 로그인 정보와 맞는 정보가 회원저장소에 존재하지 않는 것이므로 다시 로그인 페이지로 반환한다.
HttpSession session = request.getSession();
: 로그인에 성공했으니, 요청에서 session을 만들어 가져와 session에 담는다. 세션이 있으면 있는 세션 반환하고, 없으면 신규 세션을 생성한다.
session.setAttribute(SessionConst.LOGIN_MEMBER,loginMember);
: 생성한 session에 session이름과 객체를 저장한다.
이 코드들을 보다보면 의문을 가질 것이다. request.getSession() ? 어떻게 session을 가져왔을까. 왜 session을 설정하는데, key값으로 sessionId가 아닌 sessionName이 들어갔을까. 하는 의문들이 들 수 있다. 이것에 대해 알아보자.
먼저 우리는 위에서 알아보았던 세션의 동작원리에 대해 기억해야한다. 웹브라우저는 서버에 요청을 할 때 쿠키에 mySessionId = sessionId를 담아 보낸다. 즉, 요청인 request에는 sessionId에 대한 정보가 있다. 따라서 위 코드의 변수 session
은 요청의 sessionId를 key값으로 하는 session이다.
그렇다면 value는 어떻게 되는걸까 ? session에 setAttribute를 통해 sessionName과 object를 담았는데, 원래는 object를 담아야하는 것 아닌가. 아래의 session 정보를 출력하는 코드를 살펴보자.
public class SessionInfoController {
@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("getMaxInactiveInterval={}", session.getMaxInactiveInterval());
log.info("creationTime={}", new Date(session.getMaxInactiveInterval()));
log.info("LastAccessedTime={}", new Date(session.getLastAccessedTime()));
log.info("IsNew={}", session.isNew());
return "세션 출력";
}
}
위의 코드 중 session.getAttributeNames().asIterator().forEachRemaining(name->log.info("session name={}, value={}" , name, session.getAttribute(name)));
를 살펴보자. 여기선 session.getAttribute를 하는데 파라미터를 name으로 주고 있다.
또한 session.getAttribute이므로 session의 정보하에 메서드가 행해지고 있다. 그렇다면 도대체 이 session의 구조는 어떻게 되는 걸까.
바로 key로 sessionId를 갖고, value로 Map(SessionName, Object)를 갖는 구조인 것이다.
따라서 클라이언트가 다를경우 sessionId로 구별하고, 그 session내에서의 정보들은 sessionName으로 구별하는 것이다. 서블릿의 세션은 하나의 세션에 여러 값들을 저장할 수 있으니 이러한 구조가 필요한 것이다.
따라서 우리가 만든 session과 서블릿의 차이점은 아래와 같다.
추가로 하나만 더 복습하자면, 우리는 filter에 대해 학습했었다.
로그인을 하지 않은 사용자는 상품에 관한 페이지를 열람할 수 없도록 filter를 사용한 것이다.
doFilter는 filter의 핵심 로직이 있는 메서드인데 코드를 하나씩 분석해보자.
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String requestURI = httpRequest.getRequestURI();
HttpServletResponse httpResponse = (HttpServletResponse) response;
try {
log.info("인증 체크 필터 시작 {} ", requestURI);
if (isLoginCheckPath(requestURI)) { //whitelist가 아니면
log.info("인증 체크 로직 실행 {} ", requestURI);
HttpSession session = httpRequest.getSession(false);
if (session == null || session.getAttribute(SessionConst.LOGIN_MEMBER) == null) {
log.info("미인증 사용자 요청 {} ", requestURI);
//로그인으로 redirect
//?redirectURL=" + requestURI : 로그인 하면 다시 이 페이지로 돌아오기 위해서
httpResponse.sendRedirect("/login?redirectURL=" + requestURI);
return;
}
}
chain.doFilter(request, response);
} catch (Exception e) {
throw e;//예외 로깅 가능하지만 톰캣까지 예외를 보내주어야 함
}finally {
log.info("인증 체크 필터 종료 {}", requestURI);
}
}
HttpServletRequest httpRequest = (HttpServletRequest) request;
: doFilter 메서드는 HttpServletRequest 말고도 다른 것들을 사용할 수 있게 하기 위해 파라미터를 ServletRequest로 받는다. 따라서 우리는 HttpServletRequest를 사용하기 때문에 다운캐스팅 과정이 필요하다.
String requestURI = httpRequest.getRequestURI();
: 요청 정보에서 URI정보를 가져와 requestURI에 담는다.
if (isLoginCheckPath(requestURI)) { //whitelist가 아니면
log.info("인증 체크 로직 실행 {} ", requestURI);
HttpSession session = httpRequest.getSession(false);
if (session == null || session.getAttribute(SessionConst.LOGIN_MEMBER) == null) {
log.info("미인증 사용자 요청 {} ", requestURI);
//로그인으로 redirect
//?redirectURL=" + requestURI : 로그인 하면 다시 이 페이지로 돌아오기 위해서
httpResponse.sendRedirect("/login?redirectURL=" + requestURI);
return;
}
}
HttpSession session = httpRequest.getSession(false);
: session을 가져온다. 이 때 session이 없는 경우 생성하지 않는다.
if (session == null || session.getAttribute(SessionConst.LOGIN_MEMBER) == null)
: session이 없거나, session에서 정보를 가져왔는데, null일 경우이다. session이 null이라면 로그인을 하지 않은 사용자임을 알 수 있다. 하지만 session.getAttribute는 session이 null이 아닐 경우이므로 sessionId가 있다는 소리다. 즉 로그인을 한 사용자라는 소리인데, 왜 값을 찾았을 때 null이 나온것일까. 바로 로그인을 하고 세션만료 시간만큼 재요청을 하지 않았기 때문이다. 클라이언트는 처음 로그인 요청시에 쿠키를 받아 쿠키 저장소에 보관한다. 이 쿠키엔 세션정보가 담겨있다.
반면 세션에서는 해커의 쿠키 탈취 및 사용의 방지를 위해 마지막 요청 시간을 기준으로 일정시간동안 재요청이 오지 않으면 서버에서 세션을 제거한다.
따라서 위의 경우 처음 로그인을 해서 세션 ID를 받았지만, 오랫동안 재요청 하지 않아 서버에서 세션을 제거한 뒤, 재요청을 해 서버에서 요청한 정보에 맞는 세션 정보를 찾지 못한 경우다.