이번 포스팅의 목적은 기본적인 로그인 기능을 구현할 때, 사용자 정보를 로그인 후 홈화면에 띄우는 것이다.
이때, stateless한 HTTP 프로토콜 때문에, 쿠키와 세션을 이용했다.
- 기본적인 코드 구현
- 쿠키를 이용해서, 로그인 처리해보기
- 쿠키와 보안문제
- 세션의 작동방식 이해
- 세션 만들기 - 직접 세션 구현
- 세션 만들기 - 실무 ver
- 추가 - 에러 및 세션 타임아웃 설정
살펴보자.
쿠키와 세션을 정리하기 전에, 기본이 되는 홈화면 / 회원가입 / 로그인 기능 코드이다.
홈 화면 // 홈 컨트롤러
@GetMapping("/")
public String home() {
return "home";
}
<!-- 홈화면
회원가입 누르면 -> GET /members/add
로그인 누르면 -> GET /login
-->
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
<link th:href="@{/css/bootstrap.min.css}"
href="css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div class="container" style="max-width: 600px">
<div class="py-5 text-center">
<h2>홈 화면</h2>
</div>
<div class="row">
<div class="col">
<button class="w-100 btn btn-secondary btn-lg" type="button"
th:onclick="|location.href='@{/members/add}'|">
회원 가입
</button>
</div>
<div class="col">
<button class="w-100 btn btn-dark btn-lg"
th:onclick="|location.href='@{/login}'|" type="button">
로그인
</button>
</div>
</div>
<hr class="my-4">
</div> <!-- /container -->
</body>
</html>
@Controller
@RequiredArgsConstructor
@RequestMapping("/members")
@Slf4j
public class MemberController {
private final MemberRepository memberRepository;
@GetMapping("/add") // 회원가입 폼 요청
public String addForm(@ModelAttribute("member") Member member){
return "members/addMemberForm";
}
@PostMapping("/add") // 회원가입 유효성 검사후, 성공하면 memberRepository에 멤버가입
public String saveMember(@Validated @ModelAttribute("member") Member member,
BindingResult bindingResult){
if (bindingResult.hasErrors()){
return "members/addMemberForm"; // 검증 실패!
}
// 검증 성공
memberRepository.save(member);
return "redirect:/";
}
}
/* LoginService
로그인 핵심 기능
회원 조회한 다음에, 파라미터로 넘어온 password와 비교해서 같으면 회원 반환,
다르면 null 반환
*/
@Service
@RequiredArgsConstructor
public class LoginService {
private final MemberRepository memberRepository;
// return null이면, 로그인 실패
public Member login(String loginId, String password){
return memberRepository.findByLoginId(loginId)
.filter(m -> m.getPassword().equals(password))
.orElse(null);
}
}
// LoginController
@Controller
@RequiredArgsConstructor
public class LoginController {
private final LoginService loginService;
@GetMapping("/login") // 로그인 폼 요청
public String loginForm(@ModelAttribute("loginForm") LoginForm loginForm){
return "login/loginForm";
}
@PostMapping("/login") // 실제 로그인 기능
public String login(@Validated @ModelAttribute("loginForm") LoginForm loginForm,
BindingResult bindingResult, HttpServletResponse response){
if (bindingResult.hasErrors()){
return "login/loginForm";
}
Member loginMember = loginService.login(loginForm.getLoginId(), loginForm.getPassword());
if (loginMember == null) {
// 아이디, 비밀번호 불일치는 fieldError 아니고 ObjectError이기 때문에, reject 함수 사용
bindingResult.reject("loginFail", "아이디 또는 비밀번호가 일치하지 않습니다.");
return "login/loginForm"; // 검증 에러 실패시, 다시 로그인 뷰 요청..
}
// 로그인 성공 처리
return "redirect:/";
}
기본적으로 HTTP는 stateless 프로토콜이기에, 클라이언트 - 서버는 서로 간의 정보를 유지할 수 없다.
예를 들어, "홍길동" 유저가 로그인이 끝난 후, "안녕하세요, 홍길동님"을 띄워줘야 한다고 생각해보자.
쿠키가 없다면, 클라-서버 간의 정보를 유지할 수가 없어, 매 요청마다 사용자의 정보를 넘겨야 하는 불편함이 있을 것이다.
쿠키란, 정보 저장을 위해 사용되며 최종적인 목적은 사용자의 인증을 위함이다.
아래 쿠키 작동방식을 살펴보자.
로그인 전, 서버에서 생성한 쿠키(set-cookie)를 쿠키저장소에 저장한다.
로그인 후, 클라이언트는 request를 보낼 때마다, 쿠키저장소에 있던 유저 정보(홍길동)을 Cookie에 담아 홈화면에 띄워준다. ("안녕하세요 홍길동님")
이때, 모든 요청에 쿠키를 담는 것이 아니고, 서버에서 set-cookie로 지정한 path, domain에 해당하는 request를 보낼 때 쿠키를 담아주는 것.
쿠키 개념을 살펴봤으니, 코드로 이해해보자.
로그인 @PostMapping("/login") // 실제 로그인 기능
public String login(@Validated @ModelAttribute("loginForm") LoginForm loginForm,
BindingResult bindingResult, HttpServletResponse response){
if (bindingResult.hasErrors()){
return "login/loginForm";
}
Member loginMember = loginService.login(loginForm.getLoginId(), loginForm.getPassword());
if (loginMember == null) {
// 아이디, 비밀번호 불일치는 fieldError 아니고 ObjectError이기 때문에, reject 함수 사용
bindingResult.reject("loginFail", "아이디 또는 비밀번호가 일치하지 않습니다.");
return "login/loginForm"; // 검증 에러 실패시, 다시 로그인 뷰 요청..
}
// 로그인 성공 처리
// 로그인id/로그인pw도 맞았으니, 서버에서, 쿠키를 만들어서 클라이언트에게 전달해줘야 함!!
// 쿠키에 시간정보를 주지 않으면, 세션 쿠키 (브라우저 종료 시 모두 종료)
Cookie idCookie = new Cookie("memberId",
String.valueOf(loginMember.getId()));
response.addCookie(idCookie);
return "redirect:/";
}
서버에 쿠키를 만들고 HTTP 응답 헤더에 쿠키를 추가해줬다. 이제 로그인에 성공하면, 사용자 홈페이지에 로그인된 사용자 정보를 띄워준다.
홈화면 - 로그인된 사용자 정보 띄우기// HomeController
@GetMapping("/")
public String homeLogin(@CookieValue(name = "memberId", required = false) Long memberId,
Model model){
if (memberId == null){
return "home"; // 로그인 쿠키가 없는 사용자는 기존 home으로 보낸다.
}
// 로그인한 유저가 있는지 체크
Member loginMember = memberRepository.findById(memberId);
if (loginMember == null){
return "home"; // 로그인한 유저가 없어도 홈으로 보냄.
}
// 로그인 쿠키 있으면, 모델 member를 담아, loginHome 뷰로 보낸다.
model.addAttribute("member", loginMember);
return "loginHome";
}
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
<link th:href="@{/css/bootstrap.min.css}"
href="../css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div class="container" style="max-width: 600px">
<div class="py-5 text-center">
<h2>홈 화면</h2>
</div>
<h4 class="mb-3" th:text="|로그인: ${member.name}|">로그인 사용자 이름</h4>
<hr class="my-4">
<div class="row">
<div class="col">
<button class="w-100 btn btn-secondary btn-lg" type="button"
th:onclick="|location.href='@{/items}'|">
상품 관리
</button>
</div>
<div class="col">
<form th:action="@{/logout}" method="post">
<button class="w-100 btn btn-dark btn-lg" type="submit">
로그아웃
</button>
</form>
</div>
</div>
<hr class="my-4">
</div> <!-- /container -->
</body>
</html>
th:text="|로그인: ${member.name}|">
로그인 사용자 이름 로, 쿠키로부터 얻은 member모델 값이 있다면 홈화면에 띄워주는 로직이었다.
// 로그아웃 기능1 -- 쿠키를 날린다 (쿠키의 시간을 지워버린다)
// 로그인 시, 계속 쿠키값을 유지했던 "memberId"의 쿠키의 setMaxAge를 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);
}
세션 쿠키의 setMaxAge = 0으로세팅해, 쿠키를 지워 로그아웃 기능을 구현했다. 해당 쿠키는 즉시 종료된다.
하지만 쿠키를 사용해 로그인처리를 하면 심각한 보안문제가 있다!!
쿠키값은 임의로 변경할 수 있다.
실제 웹브라우저 개발자모드 - Application - Cookie 변경 가능, 포스트맨에서도 변경가능..
쿠키에 보관된 정보는 훔쳐갈 수 있다.
쿠키저장소에 사용자정보가 그대로("홍길동") 저장되어있기에, 해당 브라우저의 쿠키저장소가 털리면.. 끝난다..
결국, 쿠키 (클라이언트 브라우저)에 중요한 정보를 저장하지 않고 예측불가능한 랜덤값을 저장하고, 서버에서 쿠키의 랜덤값과 사용자 정보를 매핑시켜 관리해야 한다.
또한, 행여나 서버에서 토큰이 털려도 시간이 지나면 사용할 수 없도록 토큰 만료시간을 (30분)으로 짧게 유지해야 한다.
사진에서도 알 수 있듯이, 결국 중요한 포인트는 쿠키와 달리, 중요한 회원정보는 서버에만 저장된다는 것이다.
서버에 추측불가능/랜덤값인 sessionId - 회원정보를 저장해두고, 해당 sessionId를 쿠키(set-cookie)를 통해 클라이언트에게 보내준다.
결국 세션도 쿠키를 이용해 클 - 서버와의 연결을 해주지만, 세션 ID는 추측불가능한 랜덤값이며, 중요한 회원정보는 서버의 세션 저장소에만 있다는 사실이 중요하다 !!
로그인 이후에 홈화면에 사용자 정보를 띄울 때는, 클라이언트에서 쿠키에 저장된 랜덤값 sessionId를 서버로 보내면, 서버에서 sessionId에 대응되는 회원정보를 대조해 화면에 띄워준다 !
세션 관리는 다음의 3가지 기능을 제공하면 된다.
세션 생성
세션 조회
세션 만료
@Component
public class SessionManager {
public static final String SESSION_COOKIE_NAME = "mySessionId";
private Map<String, Object> sessionStore = new ConcurrentHashMap<>(); // 세션Id - value
// 동시성 이슈; 동시에 여러 스레드가 sessionstore에 접근할 수 있으므로, HashMap대신 ConcurrentHashMap 사용..
/*
세션 생성 !!
*/
public void createSession(Object value, HttpServletResponse response){
// 세션 id를 생성하고, 값을 세션에 저장 -- UUID로 완전한 랜덤값 만들기 !!
String sessionId = UUID.randomUUID().toString();
sessionStore.put(sessionId, value);
// 쿠키 생성
Cookie mySessionCookie = new Cookie(SESSION_COOKIE_NAME, sessionId);
response.addCookie(mySessionCookie);
}
/*
세션 조회 !!
*/
public Object getSession(HttpServletRequest request){
// 클라이언트가 요청한 쿠키(sessionId)에 해당하는 값이 있는지 조회
// 세션 저장소에 보관한 값(회원 정보) 조회
Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
if (sessionCookie == null){
return null; // 클라이언트가 요청한 sessionId 쿠키 값이 없다
}
return sessionStore.get(sessionCookie.getValue());
}
private Cookie findCookie(HttpServletRequest request, String cookieName){
if (request.getCookies() == null){
return null;
}
return Arrays.stream(request.getCookies())
.filter(cookie -> cookie.getName().equals(cookieName))
.findAny()
.orElse(null);
}
/*
세션 만료 !!
*/
public void expire(HttpServletRequest request){
// 클라이언트가 요청한 sessionId 쿠키의 값으로, 세션 저장소에 보관한 sessionId와 값 제거
Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
if (sessionCookie != null){
sessionStore.remove(sessionCookie.getValue());
}
}
}
// 직접 만든 세션 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.expire(request);
Object expired = sessionManager.getSession(request);
assertThat(expired).isNull();
}
}
// POST /login
@PostMapping("/login")
public String loginV2(@Validated @ModelAttribute(name= "loginForm") 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";
}
// 로그인 성공 처리 - 이부분 업데이트 -- 쿠키 대신 세션 적용 !!!
sessionManager.createSession(loginMember, response);
return "redirect:/";
}
// POST /logout
@PostMapping("/logout")
public String logoutV2(HttpServletRequest request){
sessionManager.expire(request);
return "redirect:/";
}
로그인 성공하면, 홈화면에 사용자이름을 띄워야하니, homeController도 리팩토링해준다.
@GetMapping("/")
public String homeLoginV2(HttpServletRequest request, Model model){
// 세션 관리자에 저장된 회원정보조회
Member member = (Member) sessionManager.getSession(request);
if (member == null){
return "home"; // 클라이언트의 쿠키값에 해당하는 정보가 세션저장소에 없음..
}
// 로그인된 사용자 이름을 home 뷰에 띄우기 위해
model.addAttribute("member", member);
return "loginHome";
}
이 코드의 취지는, 세션의 작동원리를 더 잘 깨우치기 위함이었다. 이제, 실무에서 세션을 만들어 로그인 처리한 코드를 정리해보자.
실무에서도 직접 세션을 만들어 개발하면 불편할 것이다. 이제 서블릿이 공식 지원하는 HttpSession
을 사용해보자.
추가로 HttpSession 세션은 일정시간 사용하지 않으면, 해당 세션을 삭제하는 기능을 제공한다.
HttpSession도 앞서 직접 만든 세션과 거의 동일하게 작동한다. 서블릿을 통해 HttpSession을 생성하면, 다음과 같은 쿠키를 생성한다.
Cookie: JSESSIONID=5B78E23B513F50164D6FDD8C97B0AD05
쿠키값(SessionID)이 추정불가능한, 랜덤값인 것이 직접 만든 세션과 동일하다!
세션ID - 자주 쓰니까 상수화 public interface SessionConst { // // 어차피 상수값만 저장할 것이기에 굳이 인스턴스화할 수 있는 클래스 안 씀
public static final String LOGIN_MEMBER = "loginMember";
}
// 로그인/로그아웃 - 직접 만든 세션 말고, 서블릿이 제공해주는 HttpSession 활용
@PostMapping("/login")
public String loginV3(@Validated @ModelAttribute(name= "loginForm") 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 적용 !!
// 세션이 있으면 있는 세션 반환, 없으면 신규 세션 생성 (디폴트값 true)
HttpSession session = request.getSession();
// 세션Id (아까 상수화한 "loginMember")
// 세션value - loginMember 회원정보객체
session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember);
return "redirect:/";
}
@PostMapping("/logout")
public String logoutV3(HttpServletRequest request){
// 세션을 삭제한다.
HttpSession session = request.getSession(false);
// 세션이 있으면 기존 세션 반환, ** 없으면 null 반환 ** (false)
if (session != null){
session.invalidate(); // 세션 제거
}
return "redirect:/";
}
spring이 제공해주는 @SessionAttribute로 세션이 있다면 세션을 받아준다 !!
// 스프링이 제공해주는 @SessionAttribute
// -- 세션을 생성해주지는 않고, LoginController에서 생성해준 세션값을 쉽게 가져오는..
@GetMapping("/")
public String homeLoginV3Spring(
@SessionAttribute(name = SessionConst.LOGIN_MEMBER, required = false) Member loginMember,
Model model){
// 세션에 회원데이터가 없으면 home
if (loginMember == null){
return "home";
}
// 로그인된 사용자 이름을 home 뷰에 띄우기 위해
model.addAttribute("member", loginMember);
return "loginHome";
}
이때 개인적으로 궁금한 부분이 있어, 정리해보았다.
개발자가 작성한 코드상에서는 login 컨트롤러에서 HttpSession의 setAttribute로 세션ID("loginMember") - 세션값(loginMember)를 담아준 것을 볼 수 있었다.
그런데, 서버에서 클라이언트로 response 메세지를 보낼 때, set-cookie로 sessionID("loginMember"; 예측불가능한,랜덤값)을 보내야하는 걸로 알고 있었다. 그런데, 해당 코드를 설정해주는 부분이 없어서 어디서 설정해주는지 궁금했다.
결론부터 말하자면, '세션 만들기 - 직접 세션 구현'에서 SessionManager의 createSession에서 세션ID-세션값을 만드는것만이 아니라, 쿠키까지 만들어서 보내는 코드를 볼 수 있었다.
다시 말해, HttpSession에서 createSession을 하는 과정에서 쿠키까지 만들어보낸다는 것이었다!
로그인을 처음 시도하면, URL에 jsessionId(쿠키에 저장된 세션ID)가 함께 보내지는 것을 확인할 수 있다.
http://localhost:8080/;jsessionid=F59911518B921DF62D09F0DF8F83F872
이는 웹브라우저가 쿠키를 지원하지 않을 경우를 대비해,
쿠키 대신 해당 쿠키ID를 담은 URL을 통해서 세션을 유지하는 방법이다.
서버 입장에서는 웹브라우저가 쿠키를 지원하는지 안하는지 모르기에, 최초에는 쿠키값도 전달하고, URL에 jsessionid도 함께 전달한다.
하지만, 현재 스프링에서 위와 같이 URL이 전달되면 404에러가 발생할 수 있다. 이때, application.properties에 아래 옵션을 추가해주면 된다.
spring.mvc.pathmatch.matching-strategy=ant_path_matcher
세션은 사용자가 로그아웃을 직접 호출해서 session.invalidate()를 호출해 삭제된다. 하지만, 대부분의 사용자는 직접 로그아웃을 굳이 선택하지 않고, 그냥 웹브라우저를 종료한다.
문제는 HTTP가 비연결성이라, 서버 입장에서 사용자가 웹브라우저를 종료하는지 아닌지를 인식할 수 없다. 따라서, 서버에서 세션 데이터를 언제 삭제할지, 그 판단기준이 어렵다.
세션의 종료시점을 어떻게 정하면 좋을까?
가장 단순하게, 세션 생성 시점으로부터 30분이라고 해보자. 하지만 문제는 30분이 지나면 세션이 삭제되기에, 게임을 하던 사용자가 잠시 다른 사이트에 갔다가 돌아오면 30분마다 로그인을 해야 하는 매우 번거로운 상황이 생길 수 있다. 어떤 사용자가 그런 번거로움을 감수할까..
그래서 세션 생성시점으로부터가 아니라, 사용자가 서버에 최근에 요청한 시간을 기준으로 30분을 유지하는 것이 현명한 방법일 것이다. 그렇게 되면 사용자가 서비스를 요청할 때마다 세션의 유지기간이 계속 새로고침될 것이다.
HttpSession은 이 방식을 사용한다.