요구사항을 보면, 로그인한 사용자만 상품관리페이지(메인)에 들어갈 수 있어야 한다.
그러나 문제는, 로그인하지 않은 사용자도 다음의 URL을 호출하면, 상품관리화면에 들어갈 수 있다는 점이다.
http://localhost:8080/items
이렇듯, 어플리케이션의 여러 로직에서 공통으로 관심을 가지는 인증로직은 보통 서블릿 필터나, 스프링 인터셉터를 사용하는 것이 좋다.
해당 게시글은 로그인처리1의 그다음 게시글이다.
인증은 시스템이 사용자나 다른 엔디티의 신원을 확인하는 과정, 로그인을 예시로 들 수 있다.
또한 인가는, 시스템이 인증된 사용자에게 특정 리소스에 대한 접근권한을 부여하는 과정이다.
이때, 회원가입할 때, 사용자 입력폼으로부터 아이디와 비밀번호를 DB에 저장한다.
로그인할 때는 DB에 해당 id의 비번이 있는지 검사하고, '인증'한다.
또한, 로그인할 때, 쿠키나 세션을 사용해서 사용자 정보를 유지한 후, 나중에 '인가'(로그인한 사용자만 loginhome 접근권한 부여, 로그인안했다면 /login으로 리다이렉트)과정을 진행한다.
이 인가과정에서 쓰이는 개념이 필터와 인터셉터이다!
필터는 서블릿이 제공하는 기능이고,
인터셉터는 Spring이 제공하는 기능이다.
둘다 비슷한 기능을 하지만 차이점이 있다.
1. 필터
1-1. 필터 특징
1-2. 필터 인터페이스
1-3. 필터 - 요청로그 구현
1-4. 필터 - 인증 구현
2. 인터셉터
2-1. 인터셉터 특징
2-2. 인터셉터 인터페이스
2-3. 인터셉터 호출 흐름
2-4. 인터셉터 - 요청로그 구현
2-5. 인터셉터 - 인증 구현
1. 필터 흐름
HTTP 요청 -> WAS -> '필터' -> 서블릿 -> 컨트롤러
(필터는 서블릿 이전에 호출됨)
2. 필터 제한
HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 컨트롤러 //로그인사용자
HTTP 요청 -> WAS -> 필터 (적절하지 않은 요청이면, 서블릿 호출 X) // 비로그인사용자
3. 필터 체인
HTTP 요청 -> WAS -> 필터1 -> 필터2 -> 필터3 -> 서블릿 -> 컨트롤러
(필터는 중간에 자유롭게 추가가능.
예를 들어, 로그를 남기는 필터를 먼저 적용한후, 로그인여부를 체크하는 필터를 만들수도..)
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() : 필터초기화, 서블릿 컨테이너가 생성될 때 호출됨
-- dofilter() : 고객의 요청이 올때마다 해당 메서드 호출. 필터의 로직 구현 부분
-- destroy() : 필터종료메서드, 서블릿 컨테이너가 종료될 때 호출됨
가장 단순한 필터인, 모든 요청을 로그로 만드는 코드를 구현해보며 필터에 대한 감을 잡아본다.
@Slf4j
public class LogFilter implements Filter {
// 필터 초기화 -- 서블릿 컨테이너가 생성될 때 호출됨
@Override
public void init(FilterConfig filterConfig) throws ServletException {
log.info("####### log filter init ########");
}
// 고객의 요청이 올 때마다 해당 메서드 호출됨.
// >> 모든 사용자 요청에 대해 로그를 남겨보자! <<
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse,
FilterChain filterChain) throws IOException, ServletException {
log.info("####### log filter dofilter ########");
HttpServletRequest httpRequest = (HttpServletRequest) servletRequest;
String requestURI = httpRequest.getRequestURI();
// 요청온 것을 구분하기 위해 UUID를 남긴다. -- 요청당 임의의 uuid를 생성해둔다
String uuid = UUID.randomUUID().toString();
try {
log.info("REQUEST [{}][{}] : ", uuid, requestURI);
// 중요!! 다음 필터가 있으면 필터 호출, '없으면' 서블릿 호출 !!
// 만약 이 로직이 없다면, 다음 단계로 진행되지 않음 !
filterChain.doFilter(servletRequest, servletResponse);
} catch (Exception e){
throw e;
} finally {
log.info("RESPONSE [{}][{}] : ", uuid, requestURI);
}
}
// 필터 종료 메서드 -- 서블릿 컨테이너가 종료될 때 호출
@Override
public void destroy() {
log.info("####### log filter destory ########");
}
}
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Bean
public FilterRegistrationBean logFilter(){
FilterRegistrationBean<Filter> filterFilterRegistrationBean =
new FilterRegistrationBean<>();
// 등록할 필터 지정
filterFilterRegistrationBean.setFilter(new LogFilter());
// 필터는 체인으로 동작한다. 따라서 순서 필요. 낮을수록 먼저 동작
filterFilterRegistrationBean.setOrder(1);
// 필터를 적용할 URL 패턴 지정.
filterFilterRegistrationBean.addUrlPatterns("/*");
return filterFilterRegistrationBean;
}
}
필터를 등록하는 방법은 여러가지가 있지만, 스프링부트를 사용한다면 FilterRegistrationBean
을 사용해서 등록하면 된다.
참고로, URL 패턴에 대한 룰은 '서블릿 URL 패턴' 구글링하자.
필터 인터페이스에 대한 감을 잡았으니, 인증 체크 필터를 개발해보자.
로그인되지 않은 사용자는, 상품관리뿐만 아니라 미래에 개발될 페이지에도 접근하지 못하도록 한다.
이때, 로그인되지 않은 사용자가 접근가능한 url이 뭐가 있나 구분지어놓고 가면 쉽다.
로그인되지 않은 사용자 접근가능한 url
/, /members/add, /login, /logout, /css/*
(루트랑 회원가입, 로그인, 로그아웃은 해야 하니깐..)
로그인하지 않은 사용자가 접근불가능한 url
위에 있는 url 빼고 전부 다
@Slf4j
public class LoginCheckFilter implements Filter {
// 인증 필터를 적용해도 홈, 회원가입, 로그인 / 로그아웃, css 리소스에는 접근가능해야 함
// 화이트 리스트 경로는, 인증과 무관하게 항상 허용된다.
// 즉 반대로 말하자면, 화이트 리스트를 제외한 나머지 모든 경로에는, 인증 체크 로직을 적용 !
private static final String[] whitelist = {"/", "/members/add", "/login", "/logout","/css/*"};
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse,
FilterChain filterChain) throws IOException, ServletException
{
HttpServletRequest httpRequest = (HttpServletRequest) servletRequest;
String requestURI = httpRequest.getRequestURI(); // 해당 요청에 대한 URL
HttpServletResponse httpResponse = (HttpServletResponse) servletResponse;
try {
log.info("인증 체크 필터 시작 : {}", requestURI);
if (isLoginCheckPath(requestURI)){
log.info("인증 체크 로직 실행 : {}", requestURI);
HttpSession session = httpRequest.getSession(false);
/* 세션 자체가 없거나, 세션이 있더라도 세션ID가 없다면.. 로그인 안한사람! */
if (session == null || session.getAttribute(SessionConst.LOGIN_MEMBER) == null){
log.info("미인증 사용자 요청 : {}", requestURI);
// ** 로그인으로 redirect **
httpResponse.sendRedirect("/login?redirectURL=" + requestURI);
// 로그인 안한 사용자가, redirect되어 로그인한 이후에, 원래 보던 페이지로 돌아가게끔..
// 예) 상품관리화면 보려고 들어갔다가 로그인 화면으로 이동한 경우, 로그인 이후 다시 상품관리화면으로 돌아가고 싶다..
return;
// 여기가 중요! 필터 더는 진행 X. 그렇기에 이후에 필터는 물론, 서블릿, 컨트롤러도 더는 호출 X (redirect가 될 뿐)
}
}
// 로그인 성공한 사람 -- 이후, 그다음 필터나 / 서블릿 / 컨트롤러 호출할 수 있게끔..
filterChain.doFilter(servletRequest, servletResponse);
} catch (Exception e){
throw e; // 물론 예외 로깅도 가능하지만, 톰캣까지 예외를 보내주어야 함.
} finally {
log.info("인증 체크 필터 종료 : {}", requestURI);
}
}
/*
화이트 리스트의 경우, 인증 체크 X
*/
private boolean isLoginCheckPath(String requestURL){
return !PatternMatchUtils.simpleMatch(whitelist, requestURL);
// requestURL이 whitelist에 포함되지 않아야(인증 체크 O) true 리턴
}
}
@Configuration
public class WebConfig implements WebMvcConfigurer {
// 로그인 체크 필터설정 추가 !
@Bean
public FilterRegistrationBean loginCheckFilter(){
FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
filterRegistrationBean.setFilter(new LoginCheckFilter());
filterRegistrationBean.setOrder(2); // 이번엔 2번! -- 로그 필터 다음에, 로그인 필터 적용됨
filterRegistrationBean.addUrlPatterns("/*");
return filterRegistrationBean;
}
앞서, LoginCheckFilter에서 필터를 구현할 때, 로그인안한사용자가 redirect되어 로그인한 이후에 원래 보던 페이지로 돌아갈 수 있게끔, /login?redirectURL={requestURL}로 원래 보던 URL도 넘겨줬다.
그렇다면, 로그인 컨트롤러에서 해당 requestURL로 이동하게끔 리팩토링해주어야 한다.
//V4 - Filter 추가 -- 로그인 성공하면, 처음 요청한 URL로 이동하도록 !!
@PostMapping("/login")
public String loginV4(@Validated @ModelAttribute(name= "loginForm") LoginForm form,
BindingResult bindingResult,
@RequestParam(defaultValue = "/", name = "redirectURL") String redirectURL,
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";
}
// 로그인 성공 처리
// 세션이 있으면 있는 세션 반환, 없으면 신규 세션 생성 (디폴트값 true)
HttpSession session = request.getSession();
// 세션에 로그인 회원 정보 보관
session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember);
/// ---- 이 부분 업데이트! -- redirectURL 적용
return "redirect:" + redirectURL;
}
이렇게, 서블릿에서 제공하는 필터 인터페이스를 직접 구현해서, 로그인하지 않은 사용자는 특정 경로만 접근할 수 있게 하고, 그이외의 경로에 접근하면 모두 login url로 rediect해주고, 로그인에 성공한 후에는 원래 있었던 url로 돌아가게 로직을 짜주었다.
참고로, 스프링에서 제공해주는 스프링 인터셉터의 내용은 보다 더 간단하고 편리하다. 해당 내용을 살펴보자.
스프링 인터셉터 또한 서블릿 필터와 마찬가지로 웹과 관련된 공통관심사항을 처리하지만, 적용되는 순서와 범위, 그리고 사용법이 다르다.
1. 스프링 인터셉터 흐름
HTTP 요청 -> WAS -> 필터 -> 서블릿 -> '스프링 인터셉터' -> 컨트롤러
(스프링 인터셉터는 스프링 MVC가 제공해주는 기능이기에, 디스패처 서블릿 이후에 등장함)
2. 스프링 인터셉터 제한
HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링 언터셉터 -> 컨트롤러 // 로그인 사용자
HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링 인터셉터 (적절하지 않은 요청) // 비로그인 사용자
3. 스프링 인터셉터 체인
HTTP 요청 -> WAS -> 필터 -> 서블릿
-> 인터셉터1 -> 인터셉터2 -> 컨트롤러
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 {}
}
서블릿 필터의 경우, 단순하게 doFilter()만 제공되었지만, 인터셉터는 컨트롤러 호출전(preHandle) / 컨트롤러 호출 후 (postHandle) / 요청완료이후(afterCompletion)과 같이 세분화됨.
서블릿 필터는 단순히 request, response를 제공했지만, 인터셉터는 HttpServletRequest, HttpServletResponse를 바로 제공받음.
preHandle에서는 특히 handler로 메서드 호출 정보를,
postHandle에서는 modelAndView를,
afterCompletion에서는 Exception을, 사용할 수 있다.
사실 인터셉터의 각 세분화된 메서드의 흐름이 잘 이해가 안될수도 있다.
결론부터 말하자면, 예외가 발생했을 때와 아닐 때의 흐름이 차이가 있다.
컨트롤러에서 예외가 발생하면, postHandle은 호출되지 않지만, afterCompletion 함수는 그에 굴하지 않고 언제나 호출되는 함수..라는게 큰 차이점이다.
결국, 인터셉터는 스프링 MVC 구조에 특화된 필터 기능이다.
스프링 MVC를 사용하고, 특별히 필터를 꼭 사용해야 하는 상황이 아니라면 인터셉터를 사용하는 것이 더 편리하다 !!
필터를 배울 때와 마찬가지로, 요청 로그 먼저 인터셉터로 구현해보면서, 인터셉터에 대한 감을 잡아보자!
@Slf4j
public class LogInterceptor implements HandlerInterceptor {
public static final String LOG_ID = "logId";
// preHandle -- 컨트롤러 호출 이전 (핸들러 어댑터 호출 이전)
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
Object handler) throws Exception {
String requestURI = request.getRequestURI();
String uuid = UUID.randomUUID().toString();
request.setAttribute(LOG_ID, uuid);
/*
서블릿 필터인 경우 지역변수로 해결가능하지만, 스프링 인터셉터는 호출시점이 완전히 분리되어 있다.
따라서 preHandle에서 지정한 값을 postHandle, afterCompletion에서도 같이 쓰려면 "request"에 담아두어야 한다.
(LogInterceptor도 싱글톤처럼 사용되기에, 멤버변수 사용하면 위험함..)
*/
// @RequestMapping : HandlerMethod
// 정적 리소스 : ResourceHttpRequestHanlder
if (handler instanceof HandlerMethod){
HandlerMethod hm = (HandlerMethod) handler; // 호출할 컨트롤러 메서드의 모든 정보가 포함됨
}
log.info("preHandler -- REQUEST [{}][{}][{}] : ", uuid, requestURI, handler); // 여기선, 어떤 컨트롤러가 호출되는지도 앎
return true; // false면, 이 단계에서 끝남. 더이상 진행 X
}
// postHandle - 컨트롤러 호출 이후 (단, 예외 발생시 호출되지 않고 이 이후 모두 중단됨.)
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response,
Object handler, ModelAndView modelAndView) throws Exception {
// 얘는 특이하게 ModelAndView를 반환해줌 (preHandle은 handler를, afterCompletion은 ex를..)
log.info("postHandler [{}] : ", modelAndView);
}
// afterCompletion - 뷰 렌더링 이후에 호출 (예외가 발생해도 항상 호출됨. 이 경우, 예외 ex를 파라미터로 받아, 로그 출력도 가능..)
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
Object handler, Exception ex) throws Exception {
String requestURI = request.getRequestURI();
String logId = (String) request.getAttribute(LOG_ID);
log.info("afterCompletion -- RESPONSE [{}][{}] : ", logId, requestURI);
// afterCompletion에서 특이하게 제공해주는 exception 출력 !
if (ex != null){ // 에러 없으면 null이니깐..
log.error("afterCompletion error !! : ", ex );
}
}
}
@Configuration
public class WebConfig implements WebMvcConfigurer {
// 스프링 -- interceptor 설정 추가 !
// 1. implements WebMvcConfigurer
// 2. addInterceptors 메서드 오버라이드
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LogInterceptor())
.order(1) // -- 인터셉터 호출순서 지정
.addPathPatterns("/**")
.excludePathPatterns("/css/**", "/*.ico", "/error");
// addPathPatterns -- 인터셉터 적용할 URL 패턴
// excludePathPatterns -- 인터셉터에서 제외할 패턴 (인증을 거칠 필요가 없는,)
}
}
이제 서블릿 필터에서 사용했던 인증 체크 기능을 그대로, 스프링 인터셉터로 개발해보자.
@Slf4j
public class LoginCheckInterceptor implements HandlerInterceptor {
// preHandle -- 컨트롤러 호출전 ; handler 출력 가능
// 서블릿 필터 (LoginCheckFilter)와 비교해보면, 코드 간결해짐
// -- 로그인체크(인증)은, 컨트롤러 호출전에만 호출하면 되기에, preHandle만 오버라이드!
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
Object handler) throws Exception {
String requestURI = request.getRequestURI();
log.info("인증 체크 인터셉터 실행 : {}", requestURI);
HttpSession session = request.getSession(false); // 있으면 세션반환, 없으면 null 반환
if (session == null || session.getAttribute(SessionConst.LOGIN_MEMBER) == null){
log.info("!! 미인증 사용자 요청 !!");
// 로그인으로 redirect
response.sendRedirect("/login?redirectURL=" + requestURI);
return false; // 그 다음 단계 못감!! (redirect만 실행될 뿐..)
// -- 문제가 될 때만 false 리턴하면 됨!
}
return true; // (로그인 성공!) 그 다음 단계 (컨트롤러 호출 - 뷰 리턴 - postHandle - 뷰 렌더링 - afterCompletion) 쭉- 감
}
}
@Configuration
public class WebConfig implements WebMvcConfigurer {
// 스프링 -- interceptor 설정 추가 !
// 1. implements WebMvcConfigurer
// 2. addInterceptors 메서드 오버라이드
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LogInterceptor())
.order(1) // -- 인터셉터 호출순서 지정
.addPathPatterns("/**")
.excludePathPatterns("/css/**", "/*.ico", "/error");
// addPathPatterns -- 인터셉터 적용할 URL 패턴
/* excludePathPatterns -- 인터셉터에서 제외할 패턴 (인증을 거칠 필요가 없는,) */
// 스프링 -- interceptor 추가22 -- 로그인체크
registry.addInterceptor(new LoginCheckInterceptor())
.order(2)
.addPathPatterns("/**")
.excludePathPatterns(
"/", "/members/add", "/login", "/logout",
"/css/**", "/*.ico", "/error"
);
// excludePathPatterns로 LoginCheckFilter에서는 따로 메서드(isLoginCheckPath) 만들어야 했던 걸 훨씬 간단하게!
}
}