HttpServletResponse
응답 객체에 쿠키를 저장한 후 이를 웹 브라우저에 반환Max-Age=0
으로 설정한 새로운 응답 쿠키를 만들어 이를 웹 브라우저에 반환하면 해당 쿠키는 즉시 소멸된다.@Slf4j
@Controller
@RequiredArgsConstructor
public class LoginController {
private final MemberRepository memberRepository;
private final LoginService loginService;
@GetMapping("/login")
public String loginForm(@ModelAttribute(name = "loginForm") LoginForm loginForm) {
return "login/loginForm";
}
@PostMapping("/login")
public String login(@Valid @ModelAttribute(name = "loginForm") LoginForm loginForm, BindingResult bindingResult, HttpServletResponse response) {
if (bindingResult.hasErrors()) {
return "login/loginForm";
}
Member login = loginService.login(loginForm.getLoginId(), loginForm.getPassword());
log.info("login={}", login);
if (login == null) {
bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
return "login/loginForm";
}
// 로그인에 성공 → 쿠키를 만들어 웹 브라우저 응답으로 보낸다.
// 날짜 정보 생략시 → 디폴트는 세션 쿠키
// public Cookie(String name, String value)
Cookie cookie = new Cookie("memberId", String.valueOf(login.getId()));
response.addCookie(cookie);
return "redirect:/";
}
// 쿠키 제거 방법
// 만료 시간을 0으로 설정한 쿠키를 새로 만들고 이걸 발급한다.
@PostMapping("/logout")
public String logout(HttpServletResponse response) {
Cookie cookie = new Cookie("memberId", null);
cookie.setMaxAge(0);
response.addCookie(cookie);
return "redirect:/";
}
}
@Slf4j
@Controller
@RequiredArgsConstructor
public class HomeController {
private final MemberRepository memberRepository;
//@GetMapping("/")
public String home() {
return "redirect:/items";
}
@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";
}
}
쿠키를 사용해서 로그인 ID를 전달하여 로그인을 유지할 수 있었다. 허나 이 방식은 심각한 보안 문제를 초래한다.
사용자가 loginId
와 password
정보를 전달하면 서버에서 해당 사용자가 맞는지 확인한다.
세션 ID를 생성하는데 이 때, 세션 ID는 추정 불가능해야 한다. UUID는 추정이 불가능하다. 생성된 세션 ID와 세션에 보관할 값을 서버의 세션 저장소에 보관한다.
만든 세션의 세션 ID를 응답 쿠키에 넣어 웹 브라우저로 보낸다.
클라이언트와 서버는 결국 쿠키로 연결이 되어야 한다. 서버는 클라이언트에 mySessionId
라는 이름으로 세션 ID만 쿠키에 담아서 전달한다. 클라이언트는 쿠키 저장소에 mySessionId
쿠키를 보관한다.
로그인 이후 클라이언트는 요청 시 항상 mySessionId
쿠키를 전달한다. 서버에서는 클라이언트가 전달한 쿠키 정보로 세션 저장소를 조회해서 로그인시 보관한 세션 정보를 사용한다.
@Component
public class SessionManager {
private static final String SESSION_COOKIE_NAME = "mySessionId";
// 세션 스토어 구성 : <세션 ID, value>
private Map<String, Object> sessionStore = new ConcurrentHashMap<>();
// 세션 생성
public void createSession(Object value, HttpServletResponse response) {
String sessionId = UUID.randomUUID().toString();
sessionStore.put(sessionId, value);
Cookie cookie = new Cookie(SESSION_COOKIE_NAME, sessionId);
response.addCookie(cookie);
}
// 세션 조회
public Object getSession(HttpServletRequest request) {
Cookie cookie = findCookie(request, SESSION_COOKIE_NAME);
if (cookie == null) {
return null;
}
return sessionStore.get(cookie.getValue());
}
private Cookie findCookie(HttpServletRequest request, String cookieName) {
Cookie[] cookies = request.getCookies();
if (cookies == null) {
return null;
}
return Arrays.stream(cookies).filter(cookie -> cookie.getName().equals(cookieName))
.findAny()
.orElse(null);
}
// 세션 만료
public void expire(HttpServletRequest request) {
Cookie cookie = findCookie(request, SESSION_COOKIE_NAME);
if (cookie != null) {
sessionStore.remove(cookie.getValue());
}
}
}
Mock
을 사용한다.HttpServletRequest
, HttpServletResponse
대신에 MockServletRequest
, MockServletResponse
를 사용한다.class SessionManagerTest {
private SessionManager sessionManager = new SessionManager();
@Test
@DisplayName("세션 테스트 코드")
void sessionTest() {
// Mock : 가짜 객체
// 세션 생성
MockHttpServletResponse response = new MockHttpServletResponse();
Member member = new Member();
// 세션 생성한다는 것 = 클라이언트의 로그인이 성공했다는 것
sessionManager.createSession(member, response);
// 요청에도 역시 Mock 가짜 객체를 활용한 요청이 있어야 함
// 세션 조회
MockHttpServletRequest request = new MockHttpServletRequest();
request.setCookies(response.getCookies());
Object session = sessionManager.getSession(request);
Assertions.assertThat(session).isEqualTo(member);
// 세션 만료
sessionManager.expire(request);
Object findSession = sessionManager.getSession(request);
Assertions.assertThat(findSession).isNull();
}
}
@Slf4j
@Controller
@RequiredArgsConstructor
public class LoginController {
private final SessionManager sessionManager;
private final MemberRepository memberRepository;
private final LoginService loginService;
// ...
@PostMapping("/login")
public String loginV2(@Valid @ModelAttribute(name = "loginForm") LoginForm loginForm, BindingResult bindingResult, HttpServletResponse response) {
if (bindingResult.hasErrors()) {
return "login/loginForm";
}
Member login = loginService.login(loginForm.getLoginId(), loginForm.getPassword());
log.info("login={}", login);
if (login == null) {
bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
return "login/loginForm";
}
// 로그인 성공 처리
// 세션 관리자를 통해 세션을 생성
sessionManager.createSession(login, response);
return "redirect:/";
}
@PostMapping("/logout")
public String logoutV2(HttpServletRequest request) {
sessionManager.expire(request);
return "redirect:/";
}
}
@Slf4j
@Controller
@RequiredArgsConstructor
public class HomeController {
private final MemberRepository memberRepository;
private final SessionManager sessionManager;
@GetMapping("/")
public String homeLoginV2(HttpServletRequest request, Model model) {
Object member = sessionManager.getSession(request);
if (member == null) {
return "home";
}
model.addAttribute("member", member);
return "loginhome";
}
}
서블릿에서 세션을 위해 HttpSession
이라는 기능을 제공한다. 서블릿을 통해 HttpSession
을 생성하면 다음과 같은 쿠키를 생성한다. 쿠키 이름이 JSESSIONID 이고, 값은 추정
불가능한 랜덤 값이다.
Cookie: JSESSIONID=5B78E23B513F50164D6FDD8C97B0AD05
@Slf4j
@Controller
@RequiredArgsConstructor
public class LoginController {
private final SessionManager sessionManager;
private final MemberRepository memberRepository;
private final LoginService loginService;
@PostMapping("/login")
public String loginV3(@Valid @ModelAttribute(name = "loginForm") LoginForm loginForm, BindingResult bindingResult, HttpServletRequest request) {
if (bindingResult.hasErrors()) {
return "login/loginForm";
}
Member loginMember = loginService.login(loginForm.getLoginId(), loginForm.getPassword());
log.info("login={}", loginMember);
if (loginMember == null) {
bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
return "login/loginForm";
}
// HttpSession 도입
HttpSession session = request.getSession();
session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember);
return "redirect:/";
}
@PostMapping("/logout")
public String logoutV3(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session != null) {
session.invalidate();
}
return "redirect:/";
}
}
request.getSession(true)
를 사용한다.create
옵션에는 boolean
타입의 true
와 false
가 들어갈 수 있다.true
의 경우 세션이 있으면 기존 세션을 반환하고 세션이 없으면 새로운 세션을 생성해서 반환한다. → loginfalse
의 경우 세션이 있으면 기존 세션을 반환하고 세션이 없으면 새로운 세션을 생성하지 않는다. → logout @Slf4j
@Controller
@RequiredArgsConstructor
public class HomeController {
private final MemberRepository memberRepository;
private final SessionManager sessionManager;
@GetMapping("/")
public String homeLoginV3(HttpServletRequest request, Model model) {
HttpSession session = request.getSession(false);
if (session == null) {
return "home";
}
Member loginMember = (Member) session.getAttribute(SessionConst.LOGIN_MEMBER);
model.addAttribute("member", loginMember);
return "loginhome";
}
}
TrackingModes
로그인을 처음 시도하면 URL이 다음과 같이 나오는 것을 볼 수 있다.
http://localhost:8080/;jsessionid=4A1CEB05E35DEFC202610867420EB480
이것은 웹 브라우저가 쿠키를 지원하지 않을 때 쿠키 대신 URL을 통해서 세션을 유지하는 방법이다. 이 방법을 사용하려면 URL에 이 값을 계속 포함해서 전달해야 한다. 타임리프 같은 템플릿은 엔진을 통해서 링크를 걸면 jsessionid
를 URL에 자동으로 포함해준다. 서버 입장에서 웹 브라우저가 쿠키를 지원하는지 안하는지 여부는 최초에 판단하지 못하므로 쿠키 값도 전달하고 jsessionid
도 함께 전달한다. URL 전달 방식을 끄고 항상 쿠키를 통해서만 세션을 유지하고 싶다면 application.properties
에 옵션을 지정해주면 된다.
server.servlet.session.tracking-modes=cookie
@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("getMaxInactiveInternal={}", session.getMaxInactiveInterval());
log.info("getCreationTime={}", new Date(session.getCreationTime()));
log.info("isNew={}", session.isNew());
return "세션 출력";
}
}
실행 결과
2024-07-16 17:17:56.570 INFO 15524 --- [nio-8080-exec-6] hello.login.web.login.LoginController : login=Member(id=1, loginId=test, password=password, name=test)
2024-07-16 17:18:06.553 INFO 15524 --- [nio-8080-exec-7] h.l.d.session.SessionInfoController : session name=login_member, value=Member(id=1, loginId=test, password=password, name=test)
2024-07-16 17:18:06.553 INFO 15524 --- [nio-8080-exec-7] h.l.d.session.SessionInfoController : sessionId=87414411A91B454D9C07A9412712B312
2024-07-16 17:18:06.553 INFO 15524 --- [nio-8080-exec-7] h.l.d.session.SessionInfoController : getMaxInactiveInternal=1800
2024-07-16 17:18:06.553 INFO 15524 --- [nio-8080-exec-7] h.l.d.session.SessionInfoController : getCreationTime=Tue Jul 16 17:17:56 KST 2024
2024-07-16 17:18:06.554 INFO 15524 --- [nio-8080-exec-7] h.l.d.session.SessionInfoController : isNew=false
session.invalidate()
가 호출되는 경우에 삭제된다.HttpSession
은 이 방식을 사용한다.application.properties
에 아래와 같이 설정한다.HttpSession
이 제공하는 타임아웃 기능 덕분에 세션을 안전하고 편리하게 사용할 수 있다. 실무에서 주의할 점은 세션에는 최소한의 데이터만 보관해야 한다는 점이다. 보관한 데이터 용량 x 사용자 수로 세션 메모리 사용량이 급증하면서 장애로 이어질 수 있다.server.servlet.session.timeout=60 // 60초(=1분)