HTTP는 기본적으로 무상태 프로토콜이기 때문에 클라이언트와 서버가 요청을 한 번 주고받으면 연결이 끊어지고 정보를 유지하지 않는다.
로그인의 경우 하나의 상태가 계속 유지되어야 하므로 기존의 무상태 방식으로는 어려움이 있다. 쿠키와 세션을 통해 문제를 해결해보자.
@Data
public class LoginForm {
@NotEmpty
private String loginId;
@NotEmpty
private String password;
}
로그인 화면에서 사용자에게 받은 로그인 정보를 저장한다.
public Member login(String loginId, String password) {
return memberRepository.findById(loginId)
.filter(m -> m.getPassword().equals(password))
.orElse(null);
}
사용자가 알맞은 아이디와 패스워드를 입력한 경우 리포지토리에서 일치하는 회원 객체(Member
)를 찾아 반환한다. 비정상 로그인의 경우 null
을 반환한다.
로그인에 성공한 경우 로그인 정보를 쿠키로 만들어 브라우저의 쿠키 저장소에 저장하고, 이후에 저장소에서 쿠키를 조회하는 방식으로 상태를 유지할 수 있다.
쿠키는 영속 쿠키와 세션 쿠키가 있다. 로그인 처리에는 세션 쿠키가 적합하다.
로그인에 성공하면 로그인 정보(memberId
)를 갖는 쿠키를 생성하여 쿠키 저장소에 저장한다. 이후 사용자가 페이지에 접근하면 쿠키 저장소에서 로그인 정보를 조회하여 로그인 상태를 판단한다.
@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:/"; //홈으로 이동
}
loginForm
객체의 loginId
, password
에 각각 바인딩된다.loginForm
)는 @ModelAttribute
로 컨트롤러에 넘어온다. Member
가 반환되고 그렇지 않으면 null
이 반환된다.bindingResult
에 오류 정보를 추가하고 로그인 화면으로 이동한다.memberId
를 저장하고 HTTP 응답에 쿠키를 담아 보낸 뒤, 홈으로 이동한다.@GetMapping("/")
public String homeLogin(@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
를 통해 쿠키를 조회할 수 있다. 쿠키에 담긴 memberId
값으로 리포지토리에서 회원을 조회하여 로그인 상태인지 체크한다. 일치하는 회원(loginMember
)이 있는 경우 모델에 회원 객체를 담은 뒤 회원 전용 홈 화면인 loginHome
으로 이동한다. 쿠키 값이 없거나 리포지토리에서 일치하는 회원을 찾을 수 없다면 정상 로그인 상태가 아니므로 일반 홈 화면(home
)으로 이동한다.
@CookieValue
의 required
속성을 false
로 지정해야 한다. 해당 쿠키가 반드시 존재하지 않아도 된다는 의미이다. 로그인 상태가 아닌 경우, 즉 쿠키 값이 없는 경우에도 컨트롤러가 일반 홈 화면(home
)으로 이동시킬 수 있도록 로그인 컨트롤러가 동작해야 하기 때문이다. 기본값이 true
인 경우 쿠키 값이 존재해야만 컨트롤러가 동작한다.
@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);
}
쿠키의 memberId
값을 null
로 초기화하여 생성하고 setMaxAge(0)
로 쿠키가 곧바로 만료되게 설정한 뒤, 응답에 쿠키 정보를 담아 보낸다.
브라우저가 응답을 받으면 로그인 정보를 담고 있던 memberId
쿠키가 null
을 갖는 새 쿠키로 대체된 뒤 바로 만료된다.
memberId
를 한 번 훔치면 그 멤버로 계속해서 로그인 할 수 있다.쿠키의 보안 문제를 해결하기 위해서는 중요한 정보는 서버에만 저장하고, 클라이언트와 서버는 그 자체로는 의미가 없으며 추정이 불가능한 임의의 식별자 값으로 연결해야 한다. 이렇게 서버에만 중요 정보를 보관하고 연결을 유지하는 방법을 세션이라 한다.
세션은 크게 다음 3가지 기능을 지원한다 :
loginId
, password
)를 서버에 전송한다.mySessionId
라는 이름으로 세션 ID만 쿠키에 담아 전달한다.mySessionId
를 저장한다.중요한 점은 클라이언트는 회원과 관련된 중요한 정보를 갖고있지 않는다는 점이다. 클라이언트가 가지고 있는 세션 ID는 추정불가능하여 그 값을 통해 다른 정보를 가져올 수 없다.
세션 방식 또한 서버-클라이언트가 결국 쿠키로 연결된다는 점을 기억하자!!
HttpSession
은 세션 생성, 조회, 만료라는 세션의 기본적인 기능을 제공하는 서블릿 객체이다. 추정 불가능한 세션 ID 값으로 JSESSIONID
를 만들어준다. 추가로 오래 사용하지 않은 세션을 삭제하는 등 생명주기 관리 기능도 제공한다.
//로그인 성공
//세션이 있으면 있는 세션 반환, 없으면 신규 세션 생성
HttpSession session = request.getSession();
//세션에 로그인 회원 정보 보관
session.setAttribute("loginMember", loginMember);
return "redirect:/";
로그인에 성공하면 쿠키를 생성하는 대신 request
에서 HttpSession
을 획득한다. 그리고 session
에 로그인한 회원과 일치하는 loginMember
객체를 담아 리다이렉트한다.
나머지 로직은 쿠키를 사용할 때와 동일하다.
public HttpSession getSession(boolean create)
request.getSession(true)
→ 기본값request.getSession(false)
null
을 반환한다.@GetMapping("/")
public String homeLoginV3(HttpServletRequest request, Model model) {
//세션이 없으면 일반 홈 화면
HttpSession session = request.getSession(false);
if (session == null) {
return "home";
}
//세션 조회
Member loginMember = (Member) session.getAttribute("loginMember");
//세션에 회원 데이터가 없으면 일반 홈 화면
if (loginMember == null) {
return "home";
}
//세션이 유지되면 회원 전용 화면으로 이동
model.addAttribute("member", loginMember);
return "loginHome";
}
getSession(false)
→ 사용자가 로그인 없이 접속했을 때 세션이 불필요하게 생성되는 것을 방지session.getAttribute()
→ 세션에 정보가 있는지 조회(로그인이 되어있는지 체크)@PostMapping("/logout")
public String logoutV3(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session != null) {
session.invalidate();
}
return "redirect:/";
}
getSession(false)
→ 세션 정보를 제거하는 것이 목적이므로 세션이 없다고 새로 생성하면 안된다.session.invalidate()
→ 세션의 모든 정보를 무효화한다.@SessionAttribute
를 사용하면 로그인 화면 코드를 훨씬 간략하게 쓸 수 있다.
@GetMapping("/")
public String homeLoginV3Spring(@SessionAttribute(name = "loginMember", required = false) Member loginMember, Model model) {
if (loginMember == null) {
return "home";
}
//세션이 유지되면 회원전용 화면 loginHome
model.addAttribute("member", loginMember);
return "loginHome";
}
지금까지 만든 기능으로는 사용자가 로그아웃 버튼을 눌러야 session.invalidate()
를 통해 세션이 만료된다. 그러나 실제로 로그아웃 버튼을 누르는 사용자는 별로 없다. 세션을 방치하면 해킹의 위험과 메모리 부족의 염려가 있다.
세션의 유효시간을 지정하여 마지막 사용으로부터 일정 시간이 지난 세션은 제거하도록 설정해보자.
application.properties
에 다음 설정을 추가한다. 기본은 1800초(30분)이다.
server.servlet.session.timeout=60
로그인 후 60초가 지난 뒤 홈에 접속하면 로그아웃 상태로 홈 페이지가 열린다.
세션 덕분에 다음과 같은 쿠키의 보안 문제들을 해결할 수 있다.
세션을 통해 쿠키의 보안 문제를 해결했지만, 홈 화면 컨트롤러처럼 로그인 상태에서만 접근할 수 있는 모든 페이지와 기능에서 세션을 통해 로그인 상태를 체크해야 한다는 문제점이 남아있다.
로그인 여부처럼 여러 로직에서 관심이 있는 것을 공통 관심사(cross-cutting concern)라고 한다. 공통 관심사는 스프링 AOP로도 처리할 수 있지만 웹과 관련된 공통 관심사는 서블릿 필터나 스프링 인터셉터를 사용하는 것이 좋다.