1. 서블릿 필터 개념
이때까지 만든 로그인 기능만으로는 사용자의 로그인 여부에 따라 웹페이지 내 특정 url로의 접속을 허용하거나 제한할 수 없다. 이런 기능을 지원하는 것이 서블릿 필터이다. 물론 로그인 여부만 검사하는 것이 아니라 컨트롤러에 접근 전에 미리 요청의 적절성을 검사해주는 기능 단위라고 이해하면 된다. 다음 글에서 배울 스프링 인터셉터도 마찬가지 기능을 제공한다. 만약 이런 기능들을 이용하지 않고 각 컨트롤러에서 로그인 여부를 직접 확인한다면, 로그인 여부를 확인하는 코드를 모든 컨트롤러들에 일일이 작성해주어야 할 것이다.
요청을 필터링 하는 기능은 각 컨트롤러에서의 공통 관심사이기 때문에, 공통 기능으로 통합하는 AOP 개념을 도입할 수 있지만, 웹과 관련된 기능을 공통화할때는 서블릿 필터나 스프링 인터셉터를 이용하는 것이 편리하다.
필터 적용 단계
아래와 같은 순서대로 요청이 처리되면서 필터가 적용된다. 스프링을 적용하는 경우 아래 흐름도에서 서블릿 부분에 DispatcherServlet 부분이라고 생각하면 된다. 만약 로그인되지 않았거나, 적절하지 않은 요청이라고 판단되면 필터 단계에서 서블릿 단계로 넘어가는 것을 막게 된다.
HTTP 요청 -> WAS -> 필터1 -> 필터2 -> ... -> 서블릿 -> 컨트롤러
2. 요청 로그 출력용 필터
Filter 작성
서블릿 필터의 개념을 이해하기 위해서 단순히 로그를 남기는 필터를 직접 만들면서 개념을 이해해보자.
필터를 사용하기 위해서 Filter 인터페이스를 implements 받는다. 여기서 javax.servlet의 Filter를 받아와야함에 유의하자. 그리고 지정된 init, doFilter, destroy 메서드를 Override 한다. 여기서 init과 destory 메서드는 default 메서드로 적용되어있기 때문에 Override가 필수는 아니다.
doFilter
웹 요청이 오면 doFilter 메서드가 호출된다. 그래서 이 메서드 내부에 request를 불러와서 uuid와 requestURI를 로그상에 남기도록 한다. doFilter는 파라미터로 ServletRequest 클래스를 받는데, 12번줄 주석문처럼 다운 캐스팅을 적용하여 HttpServletRequest 객체를 이용하도록 한다. try-catch 구문에서 try 부분에 반드시 chain.doFilter(request, response) 메서드를 작성해주어야만 한다. chain 구문이 있으면 필터 처리 후 다음 필터를 호출하거나, 다음 필터가 없으면 서블릿을 호출하기 때문이다. 만약 chain 구문이 없으면 필터 처리 후 더 이상 동작을 하지 않게 되어 서블릿 및 컨트롤러를 전혀 불러오지 않는 상황이 되므로 반드시 chain 메서드를 작성해주어야 한다.
java/hello/login/web/filter/LogFilter.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
@Slf4j
public class LogFilter implements Filter {
@Override
public void init(FilterConfig filterConfig) throws ServletException {
log.info("log filter init");
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
log.info("log filter doFilter");
//ServletRequest 객체는 웹 요청뿐 아니라 다른 요청도 받도록 설계되어서, 다운 캐스팅 적용
HttpServletRequest httpRequest = (HttpServletRequest) request;
String requestURI = httpRequest.getRequestURI();
String uuid = UUID.randomUUID().toString();
try {
log.info("REQUEST [{}][{}]", uuid, requestURI);
chain.doFilter(request, response);
} catch (Exception e) {
throw e;
} finally {
log.info("RESPONSE [{}][{}]", uuid, requestURI);
}
}
@Override
public void destroy() {
log.info("log filter destroy");
}
}
|
cs |
Filter Bean 등록
위에 작성한 LogFilter를 사용하기 위해서는 설정 정보에 Filter를 Bean으로 등록해야 한다. FilterRegistrationBean으로 필터를 등록하고, addUrlPatterns() 메서드 등으로 설정 정보를 넣어준다. 설정 정보에 대한 내용은 다음 장에서 다룬다. 다만 "/*" 라고 입력해준 것은 해당 프로젝트의 모든 URL 주소에 대해서 요청을 허용한다는 것을 이해하면 된다.
java/hello/login/web/login/WebConfig.java
1
2
3
4
5
6
7
8
9
10
11
12
13
|
@Configuration
public class WebConfig {
@Bean
public FilterRegistrationBean logFilter() {
FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
filterRegistrationBean.setFilter(new LogFilter());
filterRegistrationBean.setOrder(1);
filterRegistrationBean.addUrlPatterns("/*");
return filterRegistrationBean;
}
}
|
cs |
요청 결과
서버 실행 후 상품 등록 화면에 접속하여 일부러 에러가 나오도록 상품 등록을 해보면, REQUEST - 에러 - RESPONSE가 순차적으로 출력되는 것을 확인할 수 있다. 이것은 try문에서 REQUEST... 부분이 출력되고, chain.doFilter() 메서드를 통해 서블릿이 호출되어 컨트롤러로 요청이 넘어간 뒤, 원래 작성해둔 컨트롤러의 Validation 기능이 작동하여 errors(bindingResult) 내용이 출력된 것이다. 그리고 fianlly 구문을 통해 RESPONSE... 부분이 출력되었다.
3. 로그인 체크 필터
로그인 체크 필터 적용
이제 필터의 기본 기능에 대해 알아보았으니, 원래 목적대로 사용자의 로그인 여부에 따라 각 페이지로의 접근 권한을 관리하는 로그인 체크 필터를 작성해보자.
로그인 여부를 살펴봐야 하므로, HttpServletRequest 객체에서 .getSession(false) 메서드로 HttpSession 객체를 꺼내온다. 그리고 세션이 null이거나 세션에 로그인 회원 정보가 담겨있지 않은 경우, HttpServletResponse의 .sendRedirect 메서드를 이용해 로그인 페이지로 redirect 한다.
String[] 으로 화이트 리스트를 만들었는데, 이 목록을 조건문으로 넣어서 화이트 리스트에 해당하는 URL path인 경우 인증 체크가 되지 않도록 해준다. PatternMatchUtils.simpleMatch로 간편하게 whitelist와 requestURI간의 매칭을 살펴볼 수 있다는 것도 기억하자.
java/hello/login/web/filter/LoginCheckFilter.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
|
@Slf4j
public class LoginCheckFilter implements Filter {
private static final String[] whitelist = {"/", "/members/add", "/login", "/logout", "/css/*"};
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String requestURI = httpRequest.getRequestURI();
HttpServletResponse httpResponse = (HttpServletResponse) response;
try {
log.info("인증 체크 필터 시작 {}", requestURI);
if (isLoginCheckPath(requestURI)) {
log.info("인증 체크 로직 실행 {}", requestURI);
HttpSession session = httpRequest.getSession(false);
if (session == null || session.getAttribute(SessionConst.LOGIN_MEMBER) == null) {
log.info("미인증 사용자 요청 {}", requestURI);
httpResponse.sendRedirect("/login?redirectURL=" + requestURI);
return;
}
}
chain.doFilter(request, response);
} catch (Exception e) {
throw e; //예외 로깅 가능하지만, 톰캣까지 예외를 보내주어야 함
} finally {
log.info("인증 체크 필터 종료 {} ", requestURI);
}
}
/**
* 화이트 리스트의 경우 인증 체크X
*/
private boolean isLoginCheckPath(String requestURI) {
return !PatternMatchUtils.simpleMatch(whitelist, requestURI);
}
}
|
cs |
로그인 체크 필터도 설정 정보에 빈으로 등록해준다.
java/hello/login/web/login/WebConfig.java
1
2
3
4
5
6
7
8
9
|
@Bean
public FilterRegistrationBean loginCheckFilter() {
FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
filterRegistrationBean.setFilter(new LoginCheckFilter());
filterRegistrationBean.setOrder(2);
filterRegistrationBean.addUrlPatterns("/*");
return filterRegistrationBean;
}
|
cs |
이제 localhost:8080/items에 접속을 시도하면 리다이렉트가 되면서 로그인 페이지로 이동되버리는 것을 확인할 수 있다.
그리고 intelliJ의 콘솔창을 보면, 빈 등록시 로그필터의 setOrder(1), 로그인 체크 필터의 setOrder(2)가 순서대로 호출되며 진행된 것을 확인할 수 있다. 1 depth로 로그필터가 호출되고 2 depth로 로그인 체크 필터가 처리되는 방식이다. 로그인 체크 필터는 로그 필터의 chain.doFilter() 메서드에 의해서 호출된 것이다.
로그인 후 리다이렉트 주소로 복귀
로그인을 하지 않은 상태에서 /items로 접근 시, 아래와 같이 redirect URL을 통해 로그인 페이지로 이동하게 되었다.
http://localhost:8080/login?redirectURL=/items
그런데 이 상태에서 로그인을 하게 되면, /items 페이지로 가는 것이 아니라 홈페이지인 / 페이지로 이동하게 된다. 이것은 컨트롤러에서 return "redirect:/" 으로 지정해주었기 때문이다. 이런 방식으로 작동하면 사용자가 불편하게 되므로, 컨트롤러의 메서드를 다른 형태로 바꿔보자.
java/hello/login/web/login/LoginController.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
@PostMapping("/login")
public String loginV4(@Valid @ModelAttribute LoginForm form, BindingResult bindingResult,
@RequestParam(defaultValue = "/") 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";
}
//로그인 성공 처리
//세션이 있으면 있는 세션 반환, 없으면 신규 세션 생성
HttpSession session = request.getSession(true);
//세션에 로그인 회원 정보 보관
session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember);
return "redirect:" + redirectURL;
}
|
cs |
리다이렉트 요청이 왔을 때 URL 상에 /items라는 정보가 남아있기 때문에, @RequestParam을 통해서 redirectURL을 파라미터로 받아올 수 있다. 따라서 이 값을 이용해서 return으로 넘겨주면 문제를 해결하게 된다.
필터를 이용하여 로그인 여부에 따라 사용자의 권한(Authorization)을 처리할 수 있었다. 나중에 따로 공부하게 될 Spring Security도 기본 골자는 이런 필터 방식을 사용하므로 필터의 작동방식에 대해 잘 이해하고 있어야 한다. 그리고 Filter 패키지와 클래스를 따로 만들고 @Configuration인 설정 정보에 필터 클래스 빈들을 등록하여 사용함으로써 단일 책임 원칙(SRP)을 잘 지키며 기능이 확장되는 사례도 공부할 수 있었다.
참조
1. 인프런_스프링 MVC 2편 - 백엔드 웹개발 핵심 기술_김영한 님 강의
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2
'Programming-[Backend] > Spring' 카테고리의 다른 글
[스프링 웹MVC-2] 14. 예외 - 서블릿 오류 페이지 (0) | 2021.09.06 |
---|---|
[스프링 웹MVC-2] 13. 로그인 - 스프링 인터셉터, ArgumentResolver 활용 (0) | 2021.09.05 |
[스프링 웹MVC-2] 11. 로그인 - 세션 활용 (0) | 2021.08.29 |
[스프링 웹MVC-2] 10. 로그인 - 기본 기능, 쿠키 적용 (0) | 2021.08.29 |
[스프링 웹MVC-2] 9. 검증(Validation) - groups, 객체 분리 적용, API 전송에서의 성공, 실패 케이스 (0) | 2021.08.29 |