로그인 처리 - 쿠키, 세션
로그인 요구 사항
- 홈 화면 - 로그인 전
- 회원 가입
- 로그인
- 홈 화면 - 로그인 후
- 사용자 이름
- 상품 관리
- 로그아웃
- 보안 요구사항
- 로그인 사용자만 상품에 접근하고, 관리
- 로그인 하지 않은 사용자가 상품 관리에 접근하면 로그인 화면 이동
- 회원 가입, 상품 관리
패키지 구조 설계
- domain
- item
- member
- login
- web
- item
- member
- login
도메인이 가장 중요하다. 도메인은 화면, UI, 기술 인프라 등등의 영역을 제외한 시스템이 구현해야 하는 핵심 비즈니스 업무 영역을 의미한다. 향후 web을 다른 기술로 바꾸어도 도메인은 그대로 유지할 수 있어야 한다. 즉, web은 domain을 의존하지만, domain은 web을 의존하지 않도록 설계하는 것이 중요하다.
회원 가입
import lombok.Data;
import javax.validation.constraints.NotEmpty;
@Data
public class Member {
private Long id;
@NotEmpty
private String loginId; // 로그인 ID
@NotEmpty
private String name; // 사용자 이름
@NotEmpty
private String password;
}
- 개발자가 구별할
id
와 사용자가 로그인할로그인 id
를 구분해서 도메인 개발 - domain 영역
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 Map<Long, Member> store = new HashMap<>(); // static 사용
private static long sequence = 0L; // static 사용
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) {
/*List<Member> all = findAll();
for (Member m : all) {
if (m.getLoginId().equals(loginId)) {
return Optional.of(m);
}
}
return Optional.empty();*/
return findAll().stream()
.filter(m -> m.getLoginId().equals(loginId)).findFirst();
}
public List<Member> findAll() {
return new ArrayList<>(store.values());
}
public void clearStore() {
store.clear();
}
}
- 데이터를 관리할 Repository - domain 영역
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.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;
import javax.validation.Valid;
@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(@Valid @ModelAttribute Member member, BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
return "/members/addMemberForm";
}
memberRepository.save(member);
return "redirect:/";
}
}
- member Controller 생성 - web 영역
<!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>
<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>
<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>
<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>
- 타임리프 뷰 화면
로그인 기능
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) {
/*Optional<Member> findMemberOptional = memberRepository.findByLoginId(loginId);
Member member = findMemberOptional.get();
if (member.getPassword().equals(password)) {
return member;
} else return null;*/
return memberRepository.findByLoginId(loginId)
.filter(m -> m.getPassword().equals(password))
.orElse(null);
}
}
- 로그인의 핵심 비즈니스 로직은 회원을 조회한 다음에 파라미터로 넘어온 password를 조회하여 일치하면 반환하고, 다르면 null을 반환한다. 이 부분은 핵심 비즈니스 영역에 속하기 때문에 domain 영역에서 처리하자.
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라고 생각하자. 해당 영역은 당연히 web 영역에서 처리한다.
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.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import javax.validation.Valid;
@Slf4j
@Controller
@RequiredArgsConstructor
public class LoginController {
private final LoginService loginService;
@GetMapping("/login")
public String loginForm(@ModelAttribute("loginForm") LoginForm form) {
return "login/loginForm";
}
@PostMapping("/login")
public String login(@Valid @ModelAttribute LoginForm form, BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
return "login/loginForm";
}
Member loginMember = loginService.login(form.getLoginId(), form.getPassword());
if(loginMember == null) {
bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
return "login/loginForm";
}
// 로그인 성공 처리 TODO
return "redirect:/";
}
}
- 컨트롤러 단에서 별도의 DTO인
LoginForm
의 정보를 파라미터로 받아와서LoginService
를 통해 실제 정보와 비교하여 검증을 한다. 만약에 회원 정보가 없다면bindingResult
에 글로벌 오류를 생성한다. 로그인 성공에 대한 처리는 이후에 다시 하기 위해 TODO로 남겨두자.
<!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>
<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>
<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>
- 로그인에 대한 뷰 템플릿
로그인 처리 - 쿠키
쿠키와 세션에 대해서 자세하게 알고 싶다면 해당 포스팅을 읽고 오자. https://sasca37.tistory.com/146
기본적으로 HTTP는 Stateless 방식으로 설계되어 있다. 로그인 상태를 유지하기 위해선 연결을 유지해야하는데 쿼리 파라미터로 계속적으로 데이터를 담는 것은 매우 번거롭다. 이 문제점을 간단하게 해결해주는 쿠키와 세션을 사용한다.
- 서버 측에서 로그인 성공처리에 쿠키를 만들어서 보내준다면, 클라이언트 측에서 해당 쿠키를 브라우저 상에 저장하고 있는다.
- 로그인 이후에 클라이언트가 다시 서버에 접근할 때 쿠키 저장소에 쿠키가 있다면 요청 메시지에 쿠키를 담아서 보낸다. 그럼 서버는 클라이언트의 정보를 알 수 있다.
쿠키에는 영속 쿠키와 세션 쿠키가 있다.
- 영속 쿠키 : 만료 날짜를 입력하면 해당 날짜까지 유지
- 세션 쿠키 : 만료 날짜를 생략하면 브라우저 종료시 까지만 유지
브라우저 종료시 로그아웃이 되길 원한다면 세션 쿠키를 사용하면 된다 .
@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:/";
}
- 이전에 남겨두었던 TODO에 쿠키를 생성해서
response
에 담아서 보내주자.String.valueOf()
를 사용하는 이유는 쿠키의 타입이String
이어야 하기 때문이다. - 로그인에 성공했을 때 기본 도메인으로 보내고 로그인 여부를 처리하자.
@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";
}
HttpServletRequest
를 사용하여 파라미터를 받아올 수도 있지만,@CookieValue
를 사용하여 받아보자.required=false
는 로그인 상태가 아닌 경우도 접근할 수 있게 하기 위해 처리한다. 여기서 쿠키의 정보는String
타입으로 처리되어있는데memberId
는Long
타입으로 받아왔다. 이 부분은 컨버터가 자동으로 처리해준다고 보면 된다.
- 이후 로그인 하고
Response Headers
를 보면 쿠키가 생성된 것과 브라우저를 완전히 종료했을 때 쿠키가 사라진 것을 확인해볼 수 있다.
<!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>
- 로그인 한 사용자에게 보여질 뷰
로그아웃 기능
@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);
}
- 로그아웃 기능은 요청이 왔을 때 쿠키를 종료해버리면 된다.
- 서버에서 해당 쿠키의 종료는
MaxAge
(종료 날짜)를 0으로 지정하면 된다.
쿠키와 보안 문제
쿠키를 사용해서 로그인을 유지할 수 있었지만 보안상에 문제가 있다.
쿠키 값은 클라이언트의 브라우저에 저장되기 때문에 Application
에서 쿠키 값을 강제로 변경해서 사용할 수 있다. 또한, 해당 쿠키를 해킹당한다면 악의적인 요청을 계속적으로 시도할 수도 있다.
해당 문제를 해결하기 위해선 토큰을 예측 불가능한 임의의 토큰으로 만들어서 관리해야 한다. 하지만 해당 보안 이슈는 해결하기가 어려우며 결국 서버에서 저장해야하는 방식을 택하게 된다. 이 방식이 세션이다.
로그인 처리 - 세션
세션을 통해 클라이언트와 서버간 추정 불가능한 임의의 식별자 값으로 연결해보자.
- 서버는 세션 저장소를 만들어서 로그인에 성공한 사용자가 있으면 중복이 안되는 임의의 토큰 값(UUID)을 만들어서 세션 저장소에 UUID를 키값으로 회원 정보를 저장한다.
- 서버는 다시 클라이언트에게 쿠키를 넘겨줘야하는데, 이 때 쿠키에 담는 토큰 값은 UUID만 전달해준다. 즉, 회원과 관련된 정보는 일체 클라이언트에게 전달하지 않는다.
- 클라이언트는 해당 쿠키를 쿠키 저장소에 보관한다.
- 사용자는 로그인 이후 접근할 때 쿠키 저장소에 쿠키가 남아있다면 요청에 담아서 전달하고, 서버는 쿠키 값이 존재하다면 토큰 값으로 회원 정보를 찾아 사용자에게 정보를 전달해준다.
- 해당 토큰도 해킹당하지 않는다는 보장은 없다. 보안상 위험할 수 있으므로 세션 만료시간을 짧게 (예 : 30분) 유지하여 보안을 강화한다.
세션 직접 만들기
세션을 직접 개발해서 적용해보자. 세션 관리는 크게 3가지 기능을 제공하면 된다.
- 세션 생성
- sessionId 생성 (임의의 추정 불가능한 랜덤 값)
- 세션 저장소에 sessionId와 보관할 값 저장
- sessionId로 응답 쿠키를 생성해서 클라이언트에 전달
- 세션 조회
- 클라이언트가 요청한 sessionId 쿠키의 값으로, 세션 저장소에 보관한 값 조회
- 세션 만료
- 클라이언트가 요청한 sessionId 쿠키의 값으로, 세션 저장소에 보관한 sessionId와 값 제거
package hello.login.web.session;
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;
public class SessionManager {
private static final String SESSION_COOKIE_NAME = "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);
}
//세션 조회
public Object getSession(HttpServletRequest request) {
Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
if (sessionCookie == null) return null;
return sessionStore.get(sessionCookie.getValue());
}
// 세션 만료
public void expire(HttpServletRequest request) {
Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
if (sessionCookie != null) {
sessionStore.remove(sessionCookie.getValue());
}
}
// 쿠키 조회
public Cookie findCookie(HttpServletRequest request, String cookieName) {
Cookie[] cookies = request.getCookies();
if (cookies == null) {
return null;
}
return Arrays.stream(request.getCookies())
.filter(cookie -> cookie.getName().equals(cookieName))
.findAny().orElse(null);
}
}
- 자바에서 제공하는
UUID
를 통해 토큰을 만들어서 세션 저장소에 담고 쿠키를 생성하여 관리해 보자.
package hello.login.web.session;
import hello.login.domain.member.Member;
import org.assertj.core.api.Assertions;
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 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();
}
}
- 테스트코드를 통해 현재 세션의 관리가 정확하게 되는지 확인해보자. 테스트를 위해선
HttpServletRequest
와HttpServletResponse
를 사용해야 하는데 인터페이스다. 구현체를 사용하기에 마땅한 것도 없고 실제론 WAS에서 적용해주기 때문에 테스트용으로 제공해주는MockHttpServlet
을 사용하자.
// @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:/";
}
@PostMapping("/login")
public String loginV2(@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";
}
// 로그인 성공 처리
// 세션 관리자를 통해 세션을 생성하고, 회원 데이터 보관
sessionManager.createSession(loginMember, response);
return "redirect:/";
}
- 기존에 만들었던 곳에 loginV2를 만들어서 직접 만든 세션을 대입해서 사용한다. 로그아웃과 메인 URL 에서 사용자 정보를 검사하는 과정은 생략한다.
로그인 처리 - 서블릿 HTTP 세션 1
세션은 웹 애플리케이션에서 필수적으로 사용하므로 서블릿에서 HttpSession
을 제공한다. 지금까지 발생했던 문제들을 해결해주고 더 잘 구현되어 있다. HttpSession
에선 JSESSIONID
를 사용하여 추정 불가능한 랜덤값을 생성한다.
@PostMapping("/login")
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();
// 세션에 로그인 회원 정보 보관 (문자열 상수로 세션ID 재활용 "loginMember")
session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember);
return "redirect:/";
}
- 세션 생성을 하기 위해선
HttpServletRequest
와HttpSession
이 있으면 된다.getSession(true/false)
를 지정할 수 있는데 디폴트는true
이며,false
로 지정 시 세션이 있으면 기존 세션, 없으면null
을 반환한다.
@PostMapping("/logout")
public String logoutV3(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session != null) {
session.invalidate();
}
return "redirect:/";
}
- 로그아웃 시에는
invalidate()
를 해주면 세션을 삭제해준다.
@GetMapping("/")
public String homeLoginV3(HttpServletRequest request, Long memberId, Model model) {
HttpSession session = request.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";
}
- 처음 홈 화면에 진입했을 때 로그인하지 않은 사용자일 수 있으므로
false
로 세션을 생성하는 것에 주의하자. 이후 세션의getAttribute
를 통해 세션 정보를 받아와서 형변환 해주면 된다. - 스프링에서 제공하는
@SessionAttribute
를 사용하면 쉽게 만들 수 있다. 이어서 만들어보자.
로그인 처리 Http 세션 2
@GetMapping("/")
public String homeLoginV3Spring(@SessionAttribute(name = SessionConst.LOGIN_MEMBER, required = false) Member loginMember, Model model) {
// 세션에 회원 데이터가 없으면
if (loginMember == null) {
return "home";
}
// 세션이 유지되면 로그인으로 이동
model.addAttribute("member", loginMember);
return "loginHome";
}
@SessionAttribute
를 사용하여 이전 방식보다 가독성 좋게 관리할 수 있다.
TrackingModels
http://localhost:8080/;jsessionid=F59911518B921DF62D09F0DF8F83F872
최초 브라우저를 열고 로그인을 시도하면 jsessionid
를 포함하고 있는 것을 확인할 수 있다. 이것은 웹 브라우저가 쿠키를 지원하지 않을 때 쿠키 대신 URL을 통해 세션을 유지하는 방법이다.
타임리프 같은 템플릿 엔진을 통해서 링크를 걸면 URL에 자동으로 포함해주는 것이다. 즉, 서버 입장에서 최초 생성은 웹 브라우저가 쿠키를 지원하는지 여부를 판단하지 못하기 때문에 만드는 것이다.
server.servlet.session.tracking-modes=cookie
- URL 전달 방식을 끄고 싶다면
application.properties
에 다음과 같은 옵션을 넣어주자.
세션 정보와 타임아웃 설정
package hello.login.web.session;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import java.util.Date;
@Slf4j
@RestController
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("maxInactivateInterval={}", session.getMaxInactiveInterval());
log.info("creationTime={}", new Date(session.getCreationTime()));
log.info("lastAccessedTime={}", session.getLastAccessedTime());
log.info("isNew={}", session.isNew());
return "세션 출력";
}
}
- 세션에 대한 정보를 확인해서 로그를 출력해보자.
maxInactivateInterval
: 유효시간(초) ,isNew
: 새로 생성된 세션인지 여부 등을 확인해 볼 수 있다.
세션의 종료 시점
HttpSession
은 세션의 종료 시점은 생성시점이 아닌, 서버에 최근에 요청한 시간 (lastAccessedTime
)부터 30분으로 갱신하는 방식으로 사용자에게 편의성과 보안성을 둘 다 향상시키려는 방안으로 동작한다.
# 기본은 1800 (30분)
server.servlet.session.timeout=60
- 세션 타임아웃 설정을 할 때 글로벌 설정은 분 단위로 설정해야 한다.
timeout 시간이 지나면 WAS가 내부에서 해당 세션을 제거한다. 실무에서는 최소한의 데이터만을 보관해야 한다는 점을 고려해야하기 때문에 30분을 기준으로 고려한다.
본 포스팅은 인프런 - 김영한님의 '스프링 MVC 2편 - 백엔드 웹 개발 활용 기술'을 참고하였습니다.