우리는 브라우저 종료 시 로그아웃을 기대하므로 세션 쿠키를 알아보자.
Cookie idCookie = new Cookie("memberId", String.valueOf(loginMember.getId()));
response.addCookie(idCookie);
이후 로그인 사용자 전용 홈 화면을 위한 HomeController 에서는 @CookieValue를 사용하여 편리하게 쿠키를 조회할 수 있다.
package hello.login.web.session;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
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";
public Map<String, Object> sessionStore = new ConcurrentHashMap<>();
/**
* 세션 생성
*/
public void createSession(Object value, HttpServletResponse response) {
// 세션 id를 생성하고, 값을 세션에 저장
String sessionId = UUID.randomUUID().toString();
sessionStore.put(sessionId, value); // sessionId, 사용자 객체를 맵핑해서 저장
// 쿠키 생성
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());
}
}
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);
}
}
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); // mySessionId=13524q243-1543e
// 요청에 응답 쿠키 저장(클라이언트->서버 : 클라이언트는 요청 시 매번 응답에서 받은 쿠키를 전달한다)
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();
}
}
private final SessionManager sessionManager;
// ...
// 로그인 성공 처리
sessionManager.createSession(loginMember, response);
@PostMapping("/logout")
public String logoutV2(HttpServletRequest request) {
sessionManager.expire(request);
return "redirect:/";
}
@GetMapping("/")
public String homeLoginV2(HttpServletRequest request, Model model) {
// 세션 관리자에 저장된 회원 정보 조회
Member member = (Member) sessionManager.getSession(request);
if (member == null) {
return "home";
}
// 로그인
model.addAttribute("member", member);
return "loginHome";
}
지금까지 공부한 내용들 모두 서블릿이 제공하는 HttpSession에 더 잘 구현되어 있다. 서블릿을 통해 HttpSession을 생성하면 JSESSIONID 라는 이름의 쿠키를 생성하고, 그 값은 추정 불가능한 랜덤 값으로 만들어진다.
// 로그인 성공 처리
// 세션이 있으면 있는 세션 반환, 없으면 신규 세션을 생성
HttpSession session = request.getSession();
// 세션에 로그인 회원 정보 보관
session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember);
return "redirect:/";
null
을 반환한다. @PostMapping("/logout") // request response 알아보기
public String logoutV3(HttpServletRequest request) {
HttpSession session = request.getSession(false); // 세션 없으면 새로운 세션 생성 X
if (session != null) {
session.invalidate();
}
return "redirect:/";
}
이미 로그인 된 사용자를 찾을 때 다음과 같이 사용한다.
@SessionAttribute(name = "loginMember", required = false) Member loginMember
만약 사용자가 로그아웃을 선택하지 않고 그냥 웹 브라우저를 종료하면, 서버는 세션 데이터를 언제 삭제해야 할까?
lastAccessedTime 을 이용하여, 사용자가 서버에 최근 요청한 시간을 기준으로 서버의 생존 시간을 30분으로 계속해서 늘린다! 이 경우 다음과 같은 효과가 있다.
적용 방법은 다음과 같다.
server.servlet.session.timeout=1800 // 30분
웹과 관련된 공통 관심사를 처리하는 데는 서블릿에서 제공하는 필터와, 스프링에서 제공하는 인터셉터가 있다. 이 중 보편적으로 많이 쓰이는 인터셉터 위주로 알아보자.
public interface Filter {
public default void init(FilterConfig filterConfig) throws ServletException {}
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException;
public default void destroy() {}
}
이 중 init() 과 destroy() 는 default 메서드로 따로 구현하지 않아도 사용 가능하다.
간단히 알아보자. 필터는 다음과 같이 사용한다.
인터셉터는 스프링 MVC 구조에 특화된 필터 기능을 제공한다. 서블릿 필터보다 편리하고, 더 정교하고 다양한 기능을 지원한다.
public interface HandlerInterceptor {
default boolean preHandle(HttpServletRequest request, HttpServletResponse response,
Object handler) throws Exception {}
default void postHandle(HttpServletRequest request, HttpServletResponse response,
Object handler, @Nullable ModelAndView modelAndView) throws Exception {}
default void afterCompletion(HttpServletRequest request, HttpServletResponse response,
Object handler, @Nullable Exception ex) throws Exception {}
}
package hello.login.web.interceptor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import java.util.UUID;
@Slf4j
public class LogInterceptor implements HandlerInterceptor {
public static final String LOG_ID = "logId";
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String requestURI = request.getRequestURI();
// 요청 로그를 구분하기 위한 uuid 생성
String uuid = UUID.randomUUID().toString();
// 여기(preHandle)에서 지정한 값을 다른 곳(postHandle, afterCompletion)에서도 사용할 것.
// LogInterceptor 도 싱글톤처럼 사용되기 때문에 멤버변수를 사용하면 다른 스레드에서 바꿔치기 당하는 등의 위험이 있다.
// 따라서 request 에 담는다.
request.setAttribute(LOG_ID, uuid);
if (handler instanceof HandlerMethod) {
HandlerMethod hm = (HandlerMethod) handler; // 호출할 컨트롤러 메서드의 모든 정보가 포함되어 있다.
}
log.info("REQUEST [{}][{}][{}]", uuid, requestURI, handler);
return true;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
log.info("postHandle [{}]", modelAndView);
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
String requestURI = request.getRequestURI();
// 위 preHandle 에서 담은 값을 찾아서 사용!
String logId = (String) request.getAttribute(LOG_ID);
// 예외가 발생한 경우 postHandle이 호출되지 않기 때문에 종료 로그는 afterCompletion에서 실행했다.
log.info("RESPONSE [{}][{}]", logId, requestURI);
if (ex != null) {
log.error("afterCompletion error!!", ex);
}
}
}
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LogInterceptor()) // 작성한 LogInterceptor 등록
.order(1) // 호출 순서 지정(낮을수록 먼저)
.addPathPatterns("/**") // 인터셉터 적용할 URI 패턴 지정
.excludePathPatterns("/css/**", "/*.ico", "/error"); // 제외할 패턴
}
}
package hello.login.web.interceptor;
import hello.login.web.SessionConst;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.servlet.HandlerInterceptor;
@Slf4j
public class LoginCheckInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String requestURI = request.getRequestURI();
log.info("인증 체크 인터셉터 실행 {}", requestURI);
HttpSession session = request.getSession(false);
if (session == null || session.getAttribute(SessionConst.LOGIN_MEMBER) == null) {
log.info("미인증 사용자 요청");
// 로그인으로 redirect
response.sendRedirect("/login?redirectURL=" + requestURI);
return false;
}
return true;
}
}
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LogInterceptor())
.order(1)
.addPathPatterns("/**")
.excludePathPatterns("/css/**", "/*.ico", "/error");
registry.addInterceptor(new LoginCheckInterceptor())
.order(2)
.addPathPatterns("/**")
.excludePathPatterns(
"/", "/members/add", "/login", "/logout",
"/css/**", "/*.ico", "/error"
);
}
}
Spring에서 공통으로 사용하는 것들을 Annotation 으로 등록하여 간편하게 관리할 수 있다. (개발자가 직접 구현)
아래 두 코드를 비교해보면 알 수 있다.
public String homeLoginV3Spring(@SessionAttribute(name = SessionConst.LOGIN_MEMBER, required = false) Member loginMember, Model model)
public String homeLoginV3ArgumentResolver(@Login Member loginMember, Model model)
package hello.login.web.argumentresolver;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface Login {
}
package hello.login.web.argumentresolver;
import hello.login.domain.member.Member;
import hello.login.web.SessionConst;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.MethodParameter;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;
@Slf4j
public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver {
@Override
public boolean supportsParameter(MethodParameter parameter) {
log.info("supportsParameter 실행");
boolean hasLoginAnnotation =
parameter.hasParameterAnnotation(Login.class);
boolean hasMemberType =
Member.class.isAssignableFrom(parameter.getParameterType());
return hasLoginAnnotation && hasMemberType;
}
@Override
public Object resolveArgument(MethodParameter parameter,
ModelAndViewContainer mavContainer, NativeWebRequest webRequest,
WebDataBinderFactory binderFactory) throws Exception {
log.info("resolveArgument 실행");
HttpServletRequest request = (HttpServletRequest)
webRequest.getNativeRequest();
HttpSession session = request.getSession(false);
if (session == null) {
return null;
}
return session.getAttribute(SessionConst.LOGIN_MEMBER);
}
}
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(new LoginMemberArgumentResolver());
}