참고자료
사용자 로그인은 웹 애플리케이션이라면 무조건 있는 기능이다. 그래서 반드시 알고 배워야 한다.
우선, 로그인 처리를 하기 앞서 기본적인 폼이랑 컨트롤러가 필요하다. 실제 서비스에선 당연히 회원 유저에 대한 데이터베이스도 필요하지만 여기서는 데이터베이스 대신 메모리로 처리를 할 것이다. 그게 중요한 게 아니니까.
Member, MemberRepository
Member
package hello.login.domain.member;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.validation.constraints.NotEmpty;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Member {
private Long id;
@NotEmpty
private String loginId;
@NotEmpty
private String password;
@NotEmpty
private String name;
public Member(String loginId, String password, String name) {
this.loginId = loginId;
this.password = password;
this.name = name;
}
}
MemberRepository
package hello.login.domain.member;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Repository;
import java.util.*;
@Slf4j
@Repository
public class MemberRepository {
private static final Map<Long, Member> store = new HashMap<>();
private static long sequence = 0L;
public Member save(Member member) {
member.setId(++sequence);
log.info("save: member = {}", member);
store.put(member.getId(), member);
return member;
}
public Member findById(Long id) {
return store.get(id);
}
public Optional<Member> findByLoginId(String loginId) {
return findAll().stream().filter(member -> member.getLoginId().equals(loginId)).findFirst();
}
public List<Member> findAll() {
return new ArrayList<>(store.values());
}
public void clear() {
store.clear();
}
}
- 회원 정보에 대한 모델인 Member, 회원 정보에 대한 CRUD에 사용될 MemberRepository를 작성했다.
MemberController
MemberController
package hello.login.web.member;
import hello.login.domain.member.Member;
import hello.login.domain.member.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
@RequiredArgsConstructor
@RequestMapping("/members")
public class MemberController {
private final MemberRepository memberRepository;
@GetMapping("/add")
public String addForm(@ModelAttribute("member") Member member) {
return "members/addMemberForm";
}
@PostMapping("/add")
public String save(@Validated @ModelAttribute Member member, BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
return "members/addMemberForm";
}
memberRepository.save(member);
return "redirect:/";
}
}
- 로그인을 하려면 회원 가입이 필요하다. 회원 가입을 위한 컨트롤러를 만들었다. 저번 시간에 배운 검증하는 방법을 그대로 사용해서 @Validated, BindingResult를 사용했다. 그리고 Bean Validation으로 Member 클래스에 애노테이션을 달았다.
- 사실 회원가입도 데이터 검증 이후에 데이터베이스를 조회해서 해당 회원이 이미 있는지 확인하는 로직이 필요하지만 그 부분은생략했다. 그리고 검증에 성공하면 바로 회원을 저장하는 코드를 호출한다.
- 검증 시 에러가 발생하면 다시 로그인 폼을 사용자에게 보여주고 어떤 에러가 발생했는지 보여준다.
resources/templates/members/addMemberForm.html
<!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">
<style>
.container {
max-width: 560px;
}
.field-error {
border-color: #dc3545;
color: #dc3545;
}
</style>
</head>
<body>
<div class="container">
<div class="py-5 text-center">
<h2>회원 가입</h2>
</div>
<h4 class="mb-3">회원 정보 입력</h4>
<form action="" th:action th:object="${member}" method="post">
<div th:if="${#fields.hasGlobalErrors()}">
<p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">전체 오류 메시지</p>
</div>
<div>
<label for="loginId">로그인 ID</label>
<input type="text" id="loginId" th:field="*{loginId}" class="form-control"
th:errorclass="field-error">
<div class="field-error" th:errors="*{loginId}"></div>
</div>
<div>
<label for="password">비밀번호</label>
<input type="password" id="password" th:field="*{password}" class="form-control"
th:errorclass="field-error">
<div class="field-error" th:errors="*{password}"></div>
</div>
<div>
<label for="name">이름</label>
<input type="text" id="name" th:field="*{name}" class="form-control"
th:errorclass="field-error">
<div class="field-error" th:errors="*{name}"></div>
</div>
<hr class="my-4">
<div class="row">
<div class="col">
<button class="w-100 btn btn-primary btn-lg" type="submit">회원 가입</button>
</div>
<div class="col">
<button class="w-100 btn btn-secondary btn-lg" onclick="location.href='items.html'"
th:onclick="|location.href='@{/}'|"
type="button">취소</button>
</div>
</div>
</form>
</div> <!-- /container -->
</body>
</html>
LoginService, LoginController, LoginForm
LoginService
package hello.login.domain.login;
import hello.login.domain.member.Member;
import hello.login.domain.member.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class LoginService {
private final MemberRepository memberRepository;
public Member login(String loginId, String password) {
return memberRepository.findByLoginId(loginId)
.filter(member -> member.getPassword().equals(password))
.orElse(null);
}
}
- 로그인 시도를 하는 메서드인 login(String loginId, String password)에서는 MemberRepository를 통해 loginId로 유저를 찾고 찾은 유저의 패스워드가 전달받은 password와 일치하다면 그 유저를 반환한다. 찾지 못했다면 null을 반환한다.
- Stream API를 사용했다. Stream은 이제 자바 코드를 작성하면서 거의 필수불가결한 요소이다.
LoginController
package hello.login.web.login;
import hello.login.domain.login.LoginService;
import hello.login.domain.member.Member;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
@Slf4j
@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 loginForm(@Validated @ModelAttribute LoginForm loginForm, BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
return "login/loginForm";
}
Member loginMember = loginService.login(loginForm.getLoginId(), loginForm.getPassword());
if (loginMember == null) {
bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
return "login/loginForm";
}
// TODO: 로그인 성공 처리
return "redirect:/";
}
}
- 로그인 폼을 보여주는 GetMapping과 로그인 정보를 입력하고 요청하는 PostMapping 두 가지가 있다.
- 마찬가지로 저번 시간에 배운 Bean Validation을 통해 데이터 검증 처리를 한다. 검증에 실패하면 다시 로그인 화면으로 사용자를 보낸다.
- 검증에 성공하면, 로그인 서비스를 통해 유저를 찾는다. 만약 찾은 유저가 null이라면, BindingResult에 로그인 실패 에러를 ObjectError로 담아 다시 로그인 폼을 보여준다.
- 찾은 유저가 있다면 로그인 성공 처리를 해줘야 한다. 그 부분은 아직 남겨두었다.
- 그리고 홈 화면으로 리다이렉트 시킨다.
LoginForm
package hello.login.web.login;
import lombok.Data;
import javax.validation.constraints.NotEmpty;
@Data
public class LoginForm {
@NotEmpty
private String loginId;
@NotEmpty
private String password;
}
- 로그인 컨트롤러에서 사용자의 입력값에 대한 DTO 클래스이다.
resources/templates/login/loginForm.html
<!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">
<style>
.container {
max-width: 560px;
}
.field-error {
border-color: #dc3545;
color: #dc3545;
}
</style>
</head>
<body>
<div class="container">
<div class="py-5 text-center">
<h2>로그인</h2>
</div>
<form action="item.html" th:action th:object="${loginForm}" method="post">
<div th:if="${#fields.hasGlobalErrors()}">
<p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">전체 오류 메시지</p>
</div>
<div>
<label for="loginId">로그인 ID</label>
<input type="text" id="loginId" th:field="*{loginId}" class="form-control"
th:errorclass="field-error">
<div class="field-error" th:errors="*{loginId}"></div>
</div>
<div>
<label for="password">비밀번호</label>
<input type="password" id="password" th:field="*{password}" class="form-control"
th:errorclass="field-error">
<div class="field-error" th:errors="*{password}"></div>
</div>
<hr class="my-4">
<div class="row">
<div class="col">
<button class="w-100 btn btn-primary btn-lg" type="submit">로그인</button>
</div>
<div class="col">
<button class="w-100 btn btn-secondary btn-lg" onclick="location.href='items.html'"
th:onclick="|location.href='@{/}'|"
type="button">취소</button>
</div>
</div>
</form>
</div> <!-- /container -->
</body>
</html>
여기까지가 이제 가장 기본적으로 필요한 구성들이다. 이제 하나씩 로그인 처리를 알아가보자!
쿠키를 사용하여 로그인 처리하기
결론부터 말하면 이 방식은 사용하면 안된다. 하지만 이 과정을 먼저 진행해보면서 왜 사용하면 안되고 어떤 방식이 이를 해결하는지 이해해보자. 쿠키는 브라우저와 서버간 상태를 유지할 수 있는 방법 중 하나이다. HTTP는 기본이 Stateless이다. 즉, 상태를 저장하지 않는다는 의미이다. 그러면 사용자가 로그인을 했을때 이 사용자가 누구인지 서버가 인증/인가를 하려면 어딘가 사용자가 제공한 정보를 보관해야 한다. 그 방법이 쿠키라는 것이다.
쿠키의 방식
그래서 서버에서 로그인에 성공하면 응답을 돌려줄 때 HTTP 응답에 쿠키를 담아 전달한다. 그럼 사용자는 브라우저에 해당 URL 관련된 모든 사이트에 쿠키를 보관하여 어떤 요청을 할 때마다 서버에 이 쿠키를 같이 담아 요청한다.
로그인 시도
- 사용자가 로그인한다.
- 로그인에 성공하면 서버는 쿠키를 만들어 사용자에게 응답에 전달한다.
- 전달받은 쿠키를 브라우저에 저장한다.
이후 클라이언트가 서버에 쿠키를 지속적으로 전달
- 저장된 쿠키를 동일한 사이트에 요청을 할 때 지속적으로 서버에 쿠키를 전송한다.
- 서버에서는 받은 쿠키를 통해 사용자를 인증/인가처리 한다.
쿠키의 종류
- 영속 쿠키: 만료 날짜를 입력하면 그게 영속 쿠키이고, 해당 날짜까지 유지가 된다.
- 세션 쿠키: 만료 날짜를 생략하면 그게 세션 쿠키이고, 브라우저 종료시 까지만 유지된다.
참고로, 세션 쿠키는 HTTP 세션이 아니다. 세션 쿠키는 쿠키 종류이고 이름이다.
쿠키를 사용해 로그인 처리
그럼 이제 로그인 성공 시 쿠키를 생성해서 응답에 같이 내보내자.
@PostMapping("/login")
public String loginForm(@Validated @ModelAttribute LoginForm loginForm,
BindingResult bindingResult,
HttpServletResponse res) {
if (bindingResult.hasErrors()) {
return "login/loginForm";
}
Member loginMember = loginService.login(loginForm.getLoginId(), loginForm.getPassword());
if (loginMember == null) {
bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
return "login/loginForm";
}
Cookie idCookie = new Cookie("memberId", String.valueOf(loginMember.getId()));
res.addCookie(idCookie); // 쿠키에 시간을 주지 않으면 브라우저 종료 시 쿠키가 삭제 = 세션 쿠키 (세션 쿠키는 HTTP 세션이랑 다른 개념)
return "redirect:/";
}
- 로그인에 성공한 다음, 쿠키를 new Cookie()로 생성한다.
- 쿠키는 Key/Value 한 쌍이다. 그래서 Key는 `memberId`로, Value는 로그인 한 유저의 ID를 넣었다. (id는 loginId와 다른 것)
- HttpServletResponse 객체에 addCookie() 메서드로 쿠키를 추가한다.
이 상태로 서버를 다시 실행해서 로그인을 시도해보자. 그리고 브라우저의 Network창을 켜두고 확인해보자.
로그인 시도 후 돌아온 응답의 Headers를 살펴보자. Set-Cookie: memberId=1 이라는 값이 들어있다.
이제 이 상태에서 브라우저를 새로고침하거나 허용된 path에 요청을 보내면 항상 쿠키가 들어가 있다.
위 사진처럼, 같은 서버에 요청을 날리면 Request Headers에 Cookie에 memberId=1 이라는 쿠키가 넘어가고 있음을 확인 가능하다.
이제 서버는 이 요청 헤더에 있는 쿠키를 꺼내서 서버와 클라이언트 간 약속에 의해 이 memberId=x라는 값이 있는지 확인을 하는 것이다.
쿠키의 로그인 정보 유지와 로그아웃 기능
그럼 이제, 로그인에 성공한 경우에는 아래와 같은 화면을 보여주자. 로그인 한 유저의 이름을 보여주면서 로그인 유지가 되는 모습이다. (loginHome.html)
로그인이 되지 않은 경우 다음과 같은 아래 화면을 보여주자. (home.html)
resources/templates/home.html
<!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" onclick="location.href='items.html'"
th:onclick="|location.href='@{/login}'|" type="button">
로그인
</button>
</div>
</div>
<hr class="my-4">
</div> <!-- /container -->
</body>
</html>
resources/templates/loginHome.html
<!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" onclick="location.href='items.html'" type="submit">
로그아웃
</button>
</form>
</div>
</div>
<hr class="my-4">
</div> <!-- /container -->
</body>
</html>
HomeController
package hello.login.web;
import hello.login.domain.member.Member;
import hello.login.domain.member.MemberRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.GetMapping;
@Slf4j
@Controller
@RequiredArgsConstructor
public class HomeController {
private final MemberRepository memberRepository;
@GetMapping("/")
public String home(@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";
}
}
- 화면 중 하나인 "/" 경로의 홈 화면이다.
- 우선, 로그인 여부를 확인하기 위해 쿠키로부터 `memberId` 라는 키를 가진 쿠키를 가져온다. 그 방법은 여러가지가 있지만 스프링이 지원해주는 @CookieValue를 사용하자.
- 우선 첫번째로, 아예 쿠키값이 없는 경우엔 그냥 home 화면으로 보낸다.
- 쿠키값이 있는 경우, 쿠키값을 찾았다고 끝이 아니다. 쿠키값을 통해 회원을 찾아서 해당 회원이 실제로 존재하는 회원인지 확인해야 한다. MemberRepository를 사용해 멤버를 찾는다.
- 멤버를 찾지 못했을 경우 다시 그냥 home 화면으로 보낸다.
- 멤버를 찾은 경우 Model에 멤버 객체를 담아 loginHome 화면으로 보낸다.
위 방식처럼 사용자가 로그인 됐는지 아닌지를 확인하면 된다. 그럼 이제 로그아웃 기능을 구현해보자.
쿠키를 통해 로그인 처리를 했을 때 로그아웃 하는 방법은 그냥 쿠키 시간을 없애버리면 된다.
@PostMapping("/logout")
public String logout(HttpServletResponse res) {
expireCookie(res, "memberId");
return "redirect:/";
}
private static void expireCookie(HttpServletResponse res, String cookieName) {
Cookie cookie = new Cookie(cookieName, null);
cookie.setMaxAge(0);
res.addCookie(cookie);
}
- 삭제하고자 하는 쿠키를 생성해서 setMaxAge(0)을 호출한다.
- 해당 쿠키를 응답에 담는다.
로그아웃 버튼을 클릭하면 저 "/logout" 경로로 POST 요청을 날리게 하면 된다. 아래처럼.
<form th:action="@{/logout}" method="post">
<button class="w-100 btn btn-dark btn-lg" onclick="location.href='items.html'" type="submit">
로그아웃
</button>
</form>
굉장히 간단하게 로그인과 로그아웃 기능을 쿠키를 사용해 구현해봤다. 그러나, 이 쿠키를 사용한 방식은 심각한 보안 문제가 있다. 그래서 쿠키를 사용해서 절대로 로그인 기능을 구현하지 않는다. 그 이유가 뭘까?
쿠키의 보안 문제
- 쿠키값은 임의로 변경을 할 수 있다.
- 클라이언트가 쿠키를 강제로 변경이 가능하고 변경하면 다른 사용자가 된다. 예) `memberId=1` → `memberId=2`로 변경
- 쿠키에 보관된 정보는 훔쳐갈 수 있다.
- memberId도 굉장히 민감한 정보로 그대로 노출되어 있지만, 더 민감한 정보를 담았다면? (예: 주민등록번호, 신용카드 정보)그대로 훔쳐갈 수 있다.
- 헤커가 쿠키를 한번 훔쳐가면 그 쿠키로 지속적으로 악의적인 요청을 할 수 있다.
- 서버에서 원하는 시점에 해당 쿠키를 강제로 끊어버릴 수 없기 때문에 브라우저가 종료되기 전 또는 쿠키의 시간이 만료되기 전까지 모든짓을 다 할 수 있다.
대안은 이렇다.
- 쿠키엔 중요한 값을 노출시키면 안된다. 사용자 별 예측 불가능한 임의의 토큰값을 노출하고 서버에서 토큰과 사용자 ID를 매핑해서 인식한다. 그리고 서버에서 토큰을 관리한다.
- 토큰은 해커가 임의의 값을 넣어도 찾을 수 없도록 예상 불가능해야 한다.
- 해커가 토큰을 털어가도 시간이 지나면 사용할 수 없도록 서버에서 해당 토큰의 만료시간을 짧게 유지하게 하고, 해킹이 의심되는 경우 서버에서 토큰을 강제로 제거해서 토큰 무효화를 할 수 있어야 한다.
세션을 사용하여 로그인 처리하기
위 쿠키의 문제를 한방에 해결하는 방법이 바로 이 '세션'이다. 그리고 실제로 웹 애플리케이션 서비스들은 대부분 이 세션을 사용한다.
세션을 사용해서 어떻게 위 문제를 해결한다는 걸까?
세션을 사용한 흐름
- 사용자가 로그인을 한다.
- 로그인 정보를 서버에서 받는다.
- 로그인 정보를 통해 서버에서는 사용자를 찾는다.
- 사용자를 찾은 경우, 그 누구도 이해할 수 없는 랜덤문자열을 생성해서 세션 저장소에 해당 회원 정보와 같이 저장한다.
- 이 상태에서 사용자에게 응답에 쿠키로 방금 위에서 생성한 그 누구도 이해할 수 없는 랜덤문자열을 전송한다.
- 그럼 이제 클라이언트는 쿠키로 저장하고 있는 값이 이 랜덤문자열이 된다.
- 사용자는 이제 해당 사이트에 어떤 요청을 하더라도 쿠키에 저장된 랜덤문자열을 서버로 전송한다.
- 서버에서는 해당 문자열을 받아 세션 저장소에서 해당 문자열을 찾는다.
- 찾았다면 그 값과 매핑된 회원 정보를 통해 현재 사용자가 누구인지 인증/인가한다.
이렇게 로그인을 구현하면 위 쿠키를 사용했을 때 문제점들이 모두 해결된다.
- 쿠키 값을 변조 가능하다 → 예상 불가능한 임의의 랜덤문자열이기 때문에 변조가 불가능하다. 하더라도 그 값은 유효하지 않다.
- 쿠키에 보관하는 정보는 클라이언트 해킹 시 털릴 가능성이 있다 → 털린 값은 랜덤문자열이기 때문에 민감한 정보가 하나도 없다.
- 쿠키 탈취 후 원하는만큼 계속해서 재사용 할 가능성이 있다 → 해커가 이 토큰을 훔쳐가더라도 유효 시간을 짧게 설정하여 지속적으로 재사용 가능하지 못하도록 막고, 해킹 의심시에는 서버에서 언제든지 이 토큰을 삭제해버릴 수 있다.
이게 바로 세션이라는 방식을 사용한 로그인 처리 기법이다. 이제 세션을 사용해서 로그인 처리할 수 있도록 코드를 변경해보자.
세션 사용하여 로그인 처리 - 세션 직접 만들기
당연히 세션을 지원한다. 그러나, 한번 직접 만들어보는 것부터 해보자. 세션의 기능은 딱 3가지가 필요하다.
- 세션 생성
- 임의의 랜덤 값 생성
- 세션 저장소에 값 저장
- 해당 랜덤 값을 응답 쿠키로 생성해서 클라이언트에 전달
- 세션 조회
- 클라이언트가 요청 시 전달한 쿠키에 있는 랜덤값을 통해 세션 저장소에서 보관한 값 조회
- 세션 만료
- 클라이언트가 요청 시 전달하는 쿠키의 값을 세션 저장소에서 삭제
SessionManager
package hello.login.web.session;
import org.springframework.stereotype.Component;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Arrays;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
@Component
public class SessionManager {
public static final String SESSION_COOKIE_NAME = "mySessionId";
private final 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);
}
public Object getSession(HttpServletRequest request) {
Cookie findCookie = Arrays
.stream(request.getCookies())
.filter(cookie -> SESSION_COOKIE_NAME.equals(cookie.getName()))
.findFirst()
.orElse(null);
if (findCookie == null) {
return null;
}
// findCookie.getValue() => UUID
return sessionStore.get(findCookie.getValue());
}
public void expireSession(HttpServletRequest request) {
Arrays
.stream(request.getCookies())
.filter(cookie -> SESSION_COOKIE_NAME.equals(cookie.getName()))
.findFirst()
.ifPresent(cookie -> sessionStore.remove(cookie.getValue()));
}
}
- 세션 생성
- 자바에서 기본으로 제공하는 UUID를 사용해서 랜덤값을 생성한다.
- 해당 UUID값과 매핑될 값(멤버 객체)을 세션 보관소에 저장한다.
- 사용자에게 쿠키로 UUID값을 전달하기 위해 쿠키를 만든다.
- 응답에 해당 쿠키를 추가한다.
- 세션 조회
- 요청 헤더에서 쿠키를 가져와서 쿠키의 이름을 통해 UUID값을 찾는다.
- 찾았으면 해당값으로 세션 보관소에서 찾아 반환한다. 여기서 반환되는 값은 매핑된 값(멤버 객체)이다.
- 못찾았으면 null을 반환한다.
- 세션 만료
- 요청 헤더에서 쿠키를 가져와서 쿠키의 이름을 통해 서버에서 세션으로 사용되는 쿠키 객체를 찾는다.
- 찾았다면 해당 쿠키로부터 값(UUID)을 가져와서 세션 저장소에서 날린다.
매우 간단하게, 직접 세션을 만들어보았다. 만든 세션 저장소가 제대로 작동하는지 테스트 코드를 짜보자.
SessionManagerTest
package hello.login.web.session;
import hello.login.domain.member.Member;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import static org.assertj.core.api.Assertions.*;
class SessionManagerTest {
SessionManager sessionManager = new SessionManager();
@Test
void sessionTest() {
// 세션 생성
MockHttpServletResponse response = new MockHttpServletResponse();
Member member = new Member();
sessionManager.createSession(member, response);
// 요청 헤더에 쿠키 저장
MockHttpServletRequest req = new MockHttpServletRequest();
req.setCookies(response.getCookies());
// 사용자 요청으로부터 세션 조회 -> 해당 세션은 위에서 만든 멤버와 일치해야 한다.
Object value = sessionManager.getSession(req);
assertThat(value).isEqualTo(member);
// 세션 만료
sessionManager.expireSession(req);
Object expiredValue = sessionManager.getSession(req);
assertThat(expiredValue).isNull();
}
}
- 실제로 사용자가 요청을 하고 그에 대한 응답을 하고 있다고 가정해보자.
- 먼저 로그인을 사용자가 했다고 하면 해당 사용자와 세션 ID를 한 쌍으로 세션 저장소에 저장해야 한다.
- 동일한 사용자가 새로운 요청을 했을 때, 해당 요청에는 세션 정보가 들어있고 그 세션 정보를 통해 조회한 사용자는 동일한 사용자여야 한다.
여기서는 테스트 코드를 작성할 때 실제로 요청과 응답을 주고 받는게 아니기 때문에 HttpServletRequest, HttpServletResponse를 사용할 수 없다. 그 대신 테스트를 위해 만들어져 있는 MockHttpServletRequest, MockHttpServletResponse를 사용할 수 있다.
세션 사용하여 로그인 처리 - 직접 만든 세션 적용
이제 세션을 적용해보자. 기존의 쿠키로 로그인하는 방식 관련 컨트롤러는 싹 다 날려버려도 된다. 아니면 그냥 코드를 아래처럼 수정하자.
@PostMapping("/login")
public String loginForm(@Validated @ModelAttribute LoginForm loginForm,
BindingResult bindingResult,
HttpServletResponse res) {
if (bindingResult.hasErrors()) {
return "login/loginForm";
}
Member loginMember = loginService.login(loginForm.getLoginId(), loginForm.getPassword());
if (loginMember == null) {
bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
return "login/loginForm";
}
sessionManager.createSession(loginMember, res);
return "redirect:/";
}
- 다른 부분들은 전부 동일하고 로그인 성공 처리 부분에서 단지 SessionManager의 createSession()을 호출해주면 끝이다. 이 메서드 안에서 세션ID를 만들고 그 ID와 넘겨받는 멤버 객체가 한 쌍으로 세션 저장소에 보관도 되고, 응답에 쿠키로 세션ID도 추가한다.
@PostMapping("/logout")
public String logout(HttpServletRequest req) {
sessionManager.expireSession(req);
return "redirect:/";
}
- 로그아웃의 경우도 그냥 expireSession()을 호출하면 된다.
이제 HomeController도 세션을 적용하자.
SessionHomeController
package hello.login.web;
import hello.login.domain.member.Member;
import hello.login.domain.member.MemberRepository;
import hello.login.web.session.SessionManager;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.GetMapping;
import javax.servlet.http.HttpServletRequest;
@Slf4j
@Controller
@RequiredArgsConstructor
public class SessionHomeController {
private final SessionManager sessionManager;
@GetMapping("/")
public String home(HttpServletRequest req, Model model) {
Member member = (Member) sessionManager.getSession(req);
if (member == null) {
return "home";
}
model.addAttribute("member", member);
return "loginHome";
}
}
- 이제 더 이상 @CookieValue는 필요없다. 그저 SessionManager의 getSession()을 호출하면 된다. 이 메서드를 호출하면 요청 헤더에 담긴 세션ID를 통해 세션 저장소에서 이 세션ID와 한 쌍을 이루는 멤버 객체를 반환할 것이다.
- 찾지 못했다면 로그인되지 않은것으로 간주한다.
- 찾았다면 로그인 된 화면으로 이동시킨다.
이렇게 직접 만든 세션도 잘 적용해보았다. 그러나, 이번을 계기로 다시는 직접 만들지 않아도 된다. 훨씬 더 잘 만들어져 있는 제공된 기능을 사용하면 된다.
서블릿 HTTP 세션사용하기
이제 직접 만드는 세션이 아니라 제공되는 좋은 기능을 사용하면 된다. 서블릿은 세션을 위해 HttpSession이라는 기능을 제공한다.
이 녀석을 통해 세션을 생성하면 다음과 같은 쿠키를 생성한다. `JSESSIONID`. 이게 이제 쿠키의 이름이고 그 값으로는 똑같이 알 수 없는 랜덤값을 넣는다. 그럼 서블릿 세션도 내부적으로 그 랜덤값을 통해 우리가 실질적으로 매핑한 값(멤버 객체)를 찾아서 로그인 여부를 확인하게 된다.
우선, 이 서블릿 세션을 사용할 땐 세션의 키가 필요하다. 뭐 아무렇게나 지어도 상관없다. 그래서 나는 인터페이스를 만들어서 상수를 정의하려고 한다.
SessionConst
package hello.login.web.session;
public interface SessionConst {
String LOGIN_MEMBER = "loginMember";
}
그리고 이제, 로그인과 로그아웃을 이 서블릿 세션을 사용해서 처리할거니까 로그인 컨트롤러를 수정해주자.
HttpServletSessionLoginController
package hello.login.web.login;
import hello.login.domain.login.LoginService;
import hello.login.domain.member.Member;
import hello.login.web.session.SessionConst;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
@Slf4j
@Controller
@RequiredArgsConstructor
public class HttpServletSessionLoginController {
private final LoginService loginService;
@GetMapping("/login")
public String loginForm(@ModelAttribute("loginForm") LoginForm loginForm) {
return "login/loginForm";
}
@PostMapping("/login")
public String loginForm(@Validated @ModelAttribute LoginForm loginForm,
BindingResult bindingResult,
HttpServletRequest req) {
if (bindingResult.hasErrors()) {
return "login/loginForm";
}
Member loginMember = loginService.login(loginForm.getLoginId(), loginForm.getPassword());
if (loginMember == null) {
bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
return "login/loginForm";
}
// 세션이 있으면 세션 반환, 없으면 신규 세션을 생성
HttpSession session = req.getSession();
session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember);
return "redirect:/";
}
@PostMapping("/logout")
public String logout(HttpServletRequest req) {
// getSession(false): 새로 만드는 게 아니라 기존에 있는 세션을 가져와야 한다.
HttpSession session = req.getSession(false);
if (session != null) {
session.invalidate(); // 전부 무효화
}
return "redirect:/";
}
}
이게 전체 코드이고 코드를 나눠서 보자. 먼저 로그인 할 때이다.
@PostMapping("/login")
public String loginForm(@Validated @ModelAttribute LoginForm loginForm,
BindingResult bindingResult,
HttpServletRequest req) {
if (bindingResult.hasErrors()) {
return "login/loginForm";
}
Member loginMember = loginService.login(loginForm.getLoginId(), loginForm.getPassword());
if (loginMember == null) {
bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
return "login/loginForm";
}
// 세션이 있으면 세션 반환, 없으면 신규 세션을 생성
HttpSession session = req.getSession();
session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember);
return "redirect:/";
}
- 역시나 다 동일한데 로그인 성공 시 세션을 생성하는 부분만 변경해준다. HttpServletRequest에는 getSession()이라는 메서드가 있고 그 녀석을 통해 객체를 얻으면 HttpSession 타입의 객체가 있다.
- 그 객체에 setAttribute()를 호출해서 원하는 키에 저장할 값(멤버 객체)를 추가하면 된다.
@PostMapping("/logout")
public String logout(HttpServletRequest req) {
// getSession(false): 새로 만드는 게 아니라 기존에 있는 세션을 가져와야 한다.
HttpSession session = req.getSession(false);
if (session != null) {
session.invalidate(); // 전부 무효화
}
return "redirect:/";
}
- 이번엔 로그아웃이다. 마찬가지로 getSession()을 통해 HttpSession 객체를 가져온다.
- getSession(true) → 세션을 가져오는데 없으면 새로 만들어서 가져오는 것이고 이게 기본값이다.
- getSession(false) → 세션을 가져오는데 없으면 null을 반환한다.
- HttpSession이 null이 아니라면 invalidate()을 호출해서 세션의 데이터를 무효화한다.
이번엔 HomeController를 HttpSession을 사용하도록 수정해보자.
HttpServletSessionHomeController
package hello.login.web;
import hello.login.domain.member.Member;
import hello.login.web.session.SessionConst;
import hello.login.web.session.SessionManager;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
@Slf4j
@Controller
@RequiredArgsConstructor
public class HttpServletSessionHomeController {
@GetMapping("/")
public String home(HttpServletRequest req, Model model) {
HttpSession session = req.getSession(false);
if (session == null) {
return "home";
}
Member loginMember = (Member) session.getAttribute(SessionConst.LOGIN_MEMBER);
if (loginMember == null) {
return "home";
}
model.addAttribute("member", loginMember);
return "loginHome";
}
}
- 마찬가지로 HttpSession 객체를 가져온다. 여기서도 없으면 새로 만드는 게 아니라 기존에 있다면 가져오고 없으면 null 처리를 한다. 왜 새로 만들지 않냐면, 당연하게도 로그인을 했으면 당연히 세션이 저장된 상태일 것이다. 그럼 조회했을 때 null일 수 없다.
- 가져온 HttpSession 객체로부터 getAttribute()를 호출해서 아까 저장한 키/값을 키를 통해 가져온다.
- 가져온 값(멤버 객체)이 없다면 home 화면으로, 가져온 값(멤버 객체)이 있다면 loginHome 화면으로 이동한다.
이 상태로 실행을 다시 해보면 잘 동작할 것이다.
근데, 여기서 더 나아가서 스프링이 조금 더 도와준다. 이 HttpServletSessionHomeController를 보면 HttpServletRequest 받아서 세션 꺼내고 이런 작업이 너무 귀찮으니까 딱 이렇게 바꿔버릴 수 있다.
@GetMapping("/")
public String homeWithSpring(@SessionAttribute(name = SessionConst.LOGIN_MEMBER, required = false) Member member,
Model model) {
if (member == null) {
return "home";
}
model.addAttribute("member", member);
return "loginHome";
}
- @SessionAttribute 애노테이션을 스프링이 지원하는데 여기서 바로 키값으로 내가 원하는 해당 키와 매핑된 값(멤버 객체)을 꺼내올 수 있다. `required = false`는 당연히 있어야 한다. 로그인을 하지 않았을 수 있으니까.
그리고, 이렇게 서블릿 HTTP 세션을 사용할 때 최초 로그인 시에는 URL에 이런게 붙는다.
그러니까, 쿠키에 있는 JSESSIONID가 URL에 붙는 경우를 볼 수 있는데(최초 딱 한번만), 그 이유가 뭐냐면 서버 입장에서는 브라우저가 쿠키를 지원하는지 지원하지 않는지 최초에는 알 수가 없다. 이후에는 쿠키가 넘어오는지 아닌지를 통해 자연스럽게 파악이 된다고 해도 딱 최초에는 모른다. 그래서 그런 경우에 URL에 있는 이 JSESSIONID를 통해 세션을 유지할 수 있게 해주는 것인데, 쿠키를 지원하지 않는 브라우저는 거의 없다고 봐야하고 커스텀 설정을 통해 그런 작업을 했다면 그건 배제하자. URL에 이런 정보가 들어가봐야 좋을 거 하나 없기 때문에. 그래서 이 값을 없애는 방법은 다음과 같이 application.properties 파일에 이 한 줄을 추가한다.
server.servlet.session.tracking-modes=cookie
세션의 타임아웃
이제 이 세션의 타임아웃을 알아볼 차례다.
세션은 사용자가 로그아웃을 직접 호출해서 session.invalidate()이 호출되는 경우에 삭제된다. 그런데 대부분의 사용자는 로그아웃을 누르고 나가지 않고 그냥 웹 브라우저를 종료한다. 문제는 HTTP는 Stateless이기 때문에 요청 후 응답을 내보내면 더이상 뭐 서버는 알 수 있는게 없다. 그래서 사용자가 브라우저를 종료했는지, 아닌지 알 수 없다. 그러면 서버에서 세션 데이터를 언제 삭제해야 하는지 판단하기가 어렵다. 이런 경우에 남아있는 세션을 무한정 보관하면 다음과 같은 문제가 발생할 수 있다.
- 세션과 관련된 쿠키(JSESSIONID)를 탈취 당했을 때, 오랜 시간이 지나도 해당 쿠키로 악의적인 요청을 할 수 있다.
- 세션은 기본적으로 메모리에 생성된다.(물론, DB에 올릴수도 있다) 메모리의 크기가 무한하지 않기 때문에 꼭 필요한 경우에만 생성해서 사용해야 한다. 10만명의 사용자가 로그인하면 10만개의 세션이 생성되는 것이다.
세션의 종료 시점
세션의 종료 시점은 생성 시점으로부터 30분 정도 잡으면 될까? 근데 그러면 30분이 지나면 세션이 삭제되기 때문에 사용자가 사이트를 열심히 돌아다니다가 중간에 갑자기 뚝 세션이 삭제되면 로그인을 해야하는 번거로움이 발생할 것이다. 그래서 대부분의 경우 사용자의 마지막 요청 시간으로부터 30분을 종료 시점으로 설정한다. 그러니까 사용하다가 사용자가 요청을 하면 요청한 시점부터 다시 30분을 늘리는 것이다. HttpSession은 이 방식을 사용한다. 그러니까 우리는 따로 작업해줄게 없다.
근데, 당연히 이 설정값은 변경이 가능하다. 기본이 30분이고 다음과 같이 변경할 수 있다.
application.properties
server.servlet.session.timeout=60s
이러면 60초가 타임 아웃이다. 이건 글로벌 설정이다.
근데, 특정 세션별로 보안이 매우 중요해서 세션 타임 아웃을 달리 설정해야 하는 경우도 있다. 특정 세션의 타임 아웃을 바꾸려면
세션을 만들어서 다음 코드를 호출하면 된다.
session.setMaxInactiveInterval(1800); //1800초
정리하자면,
서블릿의 HttpSession이 제공하는 타임아웃 기능 덕분에 세션을 안전하고 편리하게 관리하고 사용할 수 있다. 실무에서 주의할 점은 세션에는 최소한의 데이터만 보관해야 한다는 점이다. 메모리 사용량을 고려해야 하기 때문이다.
참고로, 세션에는 이러한 메서드들이 있다. 참고하면 좋을 것 같다.
SessionInfoController
package hello.login.web.session;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import java.util.Date;
@Slf4j
@RestController
@RequestMapping("/session")
public class SessionInfoController {
@GetMapping("/info")
public String sessionInfo(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session == null) {
return "session is null";
}
session.getAttributeNames()
.asIterator()
.forEachRemaining(name -> log.info("session name = {}, value = {}", name, session.getAttribute(name)));
log.info("sessionId = {}", session.getId()); // 세션 ID
log.info("getMaxInactiveInterval = {}", session.getMaxInactiveInterval()); // 세션의 유효시간 예)1800초=30분
log.info("creationTime = {}", new Date(session.getCreationTime())); // 세션 생성 일시
log.info("lastAccessTime = {}", new Date(session.getLastAccessedTime())); // 세션과 연결된 사용자가 최근에 서버에 접근한 시간
log.info("isNew = {}", session.isNew()); // 새로 생성된 세션인지
return "done";
}
}
정리
이제 세션을 사용해 로그인 기능도 구현해보았다. 다음 포스팅에는 필터와 인터셉터를 사용해서 로그인 안 된 사용자를 걸러내는 방법을 알아보자.
'Spring MVC' 카테고리의 다른 글
ArgumentResolver 활용 (2) | 2024.09.04 |
---|---|
로그인 처리2 - Servlet Filter, Spring MVC Interceptor (0) | 2024.09.04 |
데이터 검증2 (Bean Validation으로 @ModelAttribute, @RequestBody 객체를 검증) (4) | 2024.09.01 |
데이터 검증 (BindingResult, Validator, @Validated) (0) | 2024.08.31 |
스프링의 메시지, 국제화 (2) | 2024.08.30 |