김영한 님의 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 강의를 보고 작성한 내용입니다.
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2/dashboard
로그인 한 사용자만 상품 관리 페이지에 들어갈 수 있어야 하는데 로그인을 하지 않은 사용자가 url을 직접 호출하면 접속할 수 있는 문제
이것을 방지하기 위해 로그인 여부를 체크하는 로직( 인증 로직 )이 필요한데 등록, 수정, 삭제 등 모든 로직에 하나씩 작성하는 것은 비효율적
모든 로직에서 관심 있는 것을 공통 관심 사항이라고 하고 이러한 공통 관심사는 AOP로 해결할 수 있지만 웹과 관련된 공통 관심 사항은 서블릿 필터나 스프링 인터셉터를 사용하는 것이 좋다
웹과 관련된 공통 관심 사항을 처리할 때 HTTP 헤더나 URL의 정보가 필요한데 서블릿 필터와 스프링 인터셉터는 HttpServletRequest
를 제공
필터 : 서블릿이 제공하는 기능
인터셉터 : 스프링이 제공하는 기능
HTTP 요청 ➜ WAS ➜ 필터 ➜ 서블릿( DispatcherServlet ) ➜ Controller
필터는 서블릿이 지원하는 수문장, 필터가 호출된 다음에 서블릿이 호출
필터가 적절하지 못한 요청이라고 판단하면 뒤의 서블릿을 호출하지 않는다
필터는 특정 URL 패턴을 적용해 URL 마다 다르게 수행하는 것이 가능
필터는 체인으로 구성되는데 중간에 여러 가지 필터를 적용할 수 있다
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()
WAS에서 호출하는 메서드
고객의 요청이 올 때마다 해당 메서드가 호출
이 메서드 내부에 필터의 로직을 구현한다
destroy()
: 필터 종료 메서드, 서블릿 컨테이너가 종료될 때 호출된다
public class LogFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
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);
}
}
}
init()
과 destroy()
는 생략
doFilter()
HTTP 요청이 오면 호출되는 메서드 ( 요청마다 호출 )
HTTP 요청이 아닌 경우까지 고려한 ServletRequest이기 때문에 위처럼 다운캐스팅을 시켜서 사용한다 ( ServletRequest는 HttpServletRequest의 부모 )
Response 사용 시에도 마찬가지로 다운캐스팅 후 사용 필요
chain.doFilter(request, response)
다음 필터가 있으면 필터가 호출되고 없으면 서블릿이 호출
위 코드를 실행하지 않으면 다음 단계로 진행되지 않는다
@Configuration
public class WebConfig {
@Bean
public FilterRegistrationBean logFilter() {
FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
filterRegistrationBean.setFilter(new LogFilter());
filterRegistrationBean.setOrder(1);
filterRegistrationBean.addUrlPatterns("/*");
return filterRegistrationBean;
}
}
구현한 필터를 사용하기 위해서는 등록을 해야한다
스프링부트를 사용하면 FilterRegistrationBean
을 사용해서 등록한다
위처럼 등록을 해놓으면 스프링부트가 WAS를 띄울 때 filter를 같이 넣어준다
LogFilter를 @Component
를 통해 스프링 빈으로 등록하고 WebConfig에서 @Autowired
를 통해 주입받아 Filter에 등록하는 방법도 있다
@ServletComponentSacn
이나 @WebFilter
로 필터 등록이 가능하지만 필터 순서 조절이 되지 않는다
참고> HTTP 요청시 같은 요청의 로그에 모두 같은 식별자를 자동으로 남기는 방법은 logback mdc을 검색
setFilter(new LogFilter())
: 등록할 필터를 지정
setOrder(1)
: 필터는 체인으로 동작하기 때문에 순서가 필요 ( 낮을수록 먼저 동작 )
addUrlPatterns("/*")
: 필터를 적용할 URL 패턴을 지정, 한 번에 여러 패턴 지정 가능
public class LoginCheckFilter implements Filter {
private static final String[] whitelist = {"/", "/members/add", "/login", "/logout", "/css/*"};
private boolean isLoginCheckPath(String requestURI) {
return !PatternMatchUtils.simpleMatch(whitelist, requestURI);
}
}
whitelist는 로그인 없이 접속 가능한 URL들을 지정한 것이다 ( 누구나 접근 가능한 URL )
isLoginCheckPath()
whitelist에 있는 URL은 인증 체크할 필요 없기 때문에 URL 체크를 위한 메서드
true를 반환하면 인증 체크 시도
whitelist에 있는 URL의 경우 앞의 !
때문에 false를 반환하게 된다
WebConfig에서 필터를 등록할 때 적용할 URL 패턴을 지정해도 되지만 그렇게 하면 URL이 추가되었을 때 수정해야하는 문제점
위처럼 whitelist를 만들면 체크하지 않을 URL을 제외한 모든 URL에 적용하기 때문에 체크가 필요한 URL이 추가되어도 수정하지 않아도 된다
@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);
}
}
isLoginCheckPath(requestURI)
: 현재 URL이 whitelist에 없는 URL이면 인증 체크 실행
httpResponse.sendRedirect("/login?redirectURL=" + requestURI);
미인증 사용자가 로그인 화면으로 redirect 되었을 때 로그인 후 현재 페이지로 돌아오기 위해 쿼리 파라미터에 현재 URL을 추가
전달된 URL로 리다이렉트를 위해 추가적으로 LoginController에서 로그인 후 리다이렉트 경로 수정이 필요
return;
필터를 더 이상 진행하지 않는다
뒤의 필터와 서블릿, 컨트롤러가 호출되지 않는다
앞서 redirect 를 사용했기 때문에 redirect 가 응답으로 적용되고 요청이 끝난다
// LoginController
@PostMapping("/login")
public String loginV4(@Validated @ModelAttribute LoginForm form, BindingResult bindingResult, HttpServletRequest request
@RequestParam(defaultValue = "/") String redirectURL) {
...
return "redirect:" + redirectURL;
}
LoginCheckFilter에서 로그인 화면으로 보낼 때 현재 URL을 쿼리 파라미터로 같이 넘김
로그인 메서드에서 쿼리 파라미터로 들어온 URL을 @RequestParam
으로 받고, 로그인 성공 시 이를 이용하여 redirect 시킨다
즉, 로그인을 성공하면 기존에 있던 페이지로 바로 이동한다
HTTP 요청 ➜ WAS ➜ 필터 ➜ 서블릿 ➜ 스프링 인터셉터 ➜ Controller
스프링 인터셉터는 DispatcherServlet과 Controller 사이에서 Controller 호출 직전에 호출
스프링 MVC의 시작점이 DispatcherServlet이고 스프링 인터셉터는 스프링 MVC가 제공하는 기술이기 때문에 서블릿 뒤에 호출
스프링 인터셉터가 적절하지 않은 요청이라고 판단하면 Controller를 호출하지 않는다
URL 패턴을 적용할 수 있는데 서블릿 URL 패턴과는 다르고, 매우 정밀하게 설정 가능
스프링 인터셉터는 체인으로 구성되는데 중간에 여러 가지 인터셉터를 추가할 수 있다
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 {
}
}
preHandle()
: Controller 호출 전
postHandle()
: Controller 호출 후
afterCompletion()
: 요청 완료 이후
어떤 Controller( Handler )가 호출되는지와 어떤 ModelAndView가 반환되는지 응답 정보도 알 수 있다
preHandle
컨트롤러 호출 전에 호출
정확히는 핸들러 어댑터 호출 전에 호출
preHandle()
의 응답값이 true 이면 다음으로 진행하고, false 이면 더 이상 진행하지 않는다 ( 뒤의 인터셉터와 핸들러 어댑터가 호출되지 않는다 )
postHandle
: 컨트롤러 호출 후에 호출 ( 정확히는 핸들러 어댑터 호출 후에 호출 )
afterCompletion
: 뷰가 렌더링 된 이후에 호출
preHandle
: 컨트롤러 호출 전에 호출
postHandle
: 컨트롤러에서 예외가 발생하면 호출되지 않는다
afterCompletion
예외 발생과 관계 없이 항상 호출된다 ( 예외와 무관하게 공통 처리를 할 때 사용 )
예외( ex )를 파라미터로 받아서 어떤 예외가 발생했는지 로그로 출력할 수 있다
정상적인 경우에 예외( ex )에 null을 포함해서 호출, 예외가 발생하면 예외 정보를 포함해서 호출
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();
String uuid = UUID.randomUUID().toString();
request.setAttribute(LOG_ID, uuid);
if (handler instanceof HandlerMethod) {
HandlerMethod hm = (HandlerMethod) handler; // 호출할 컨트롤러 메서드의 모든 정보가 포함되어 있음
}
log.info("REQUEST [{}][{}][{}]", uuid, requestURI, handler);
return true;
}
...
@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("RESPONSE [{}][{}][{}]", logId, requestURI, handler);
...
}
}
요청 로그와 응답 로그를 남길 때 어떤 요청에 대한 어떤 응답인지 구분할 수 있어야 한다
postHandle
은 Controller 에서 에러가 발생하면 호출되지 않기 때문에 응답 로그 출력은 afterCompletion
에서 하는 것이 옳다
어떤 요청에 대한 어떤 응답인지 알기 위해 요청에서 생성한 UUID 를 afterCompletion
에 넘겨야한다
인터셉터는 싱글톤처럼 사용되기 때문에 멤버변수를 사용하면 위험 ➜ HttpServletRequest 에 UUID 를 담아둔다
즉, preHandle()
에서 지정한 값을 postHandle()
이나 afterCompletion()
에서 사용하기 위해 request에 담아둔다
request.setAttribute(LOG_ID, uuid);
: UUID 를 HttpServletRequest
에 담는다
doFilter()
하나의 메서드이기 때문에 지역 변수로 사용했지만 인터셉터는 메서드들의 호출 시점이 완전히 분리되어 있기 때문return true
: 다음 인터셉트나 Controller를 호출
request.getAttribute(LOG_ID)
를 통해 UUID 값을 꺼낸 후 사용@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LogInterceptor())
.order(1)
.addPathPatterns("/**")
.excludePathPatterns("/css/**", "/*.ico", "/error");
}
}
등록할 때 @Bean
으로 등록하는 것이 아니라 메서드를 오버라이딩해서 등록한다
addPathPatterns("/**")
: 인터셉터를 적용할 URL 패턴을 지정
excludePathPatterns("/css/**", "/*.ico", "/error")
: 인터셉터를 적용하지 않을 경로들을 설정
필터 등록에서 설명한 것처럼 스프링 빈으로 등록해서 사용 가능하다
// @RequestMapping 을 사용하면 handler 는 HandlerMethod 가 사용
// 정적 리소스를 사용하면 ResourceHttpRequestHandler
if (handler instanceof HandlerMethod) {
HandlerMethod hm = (HandlerMethod) handler; // 호출할 컨트롤러 메서드의 모든 정보가 포함되어 있음
}
preHandle()
은 핸들러 매핑이 끝난 후 실행된다
핸들러 정보는 어떤 핸들러 매핑을 사용하는가에 따라 달라지기 때문에 타입에 따른 분기 처리가 필요
HandlerMethod
@Controller
, @RequestMapping
을 활용한 핸들러 매핑을 사용할 때 핸들러 정보로 HandlerMethod 가 넘어온다
즉, @Controller
, @RequestMapping
요청에 사용하는 핸들러가 HandlerMethod
ResourceHttpRequestHandler
HandlerMethod
: @RequestMapping
과 @GetMapping
과 같은 하위 어노테이션이 붙은 메서드의 정보를 추상화한 객체
DispatcherServlet이 어플리케이션이 실행될 때 모든 Controller 빈의 메서드를 찾아 매핑 후보가 되는 메서드를 추출하여 HandlerMethod
형태로 저장
실제 요청이 들어오면 조건에 맞는 HandlerMethod
를 참조해서 매핑되는 메서드를 실행
HTTP 요청을 HandlerMethod 객체로 변환하는 작업이나 해당 요청에 매핑되는 HandlerMethod 를 반환하는 작업은 RequestMappingHandlerMapping 이 담당
RequestMappingHandlerMapping은 @Controller
로 작성된 모든 컨트롤러 빈을 파싱하여 HashMap으로 (요청 정보, 처리할 대상) 을 관리
따라서 애플리케이션 컨텍스트가 초기화되면 RequestMappingHandlerMapping에 접근하여 저장된 매핑 정보와 핸들러 메소드 목록을 확인할 수 있다
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String requestURI = request.getRequestURI();
log.info("인증 체크 인터셉터 실행 = {}", requestURI);
HttpSession session = request.getSession();
if (session == null || session.getAttribute(SessionConst.LOGIN_MEMBER) == null) {
log.info("미인증 사용자 요청");
response.sendRedirect("/login?redirect:" + requestURI);
return false;
}
return true;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
...
registry.addInterceptor(new LoginCheckInterceptor())
.order(2)
.addPathPatterns("/**")
.excludePathPatterns("/", "members/add", "/login", "/logout", "/css/**", "/*ico", "/error");
}
필터처럼 새로 등록하는 것이 아닌 오버라이딩한 하나의 메서드 안에서 여러 개를 등록한다
필터는 등록할 때 적용할 URL만 작성할 수 있어서 whitelist를 따로 만들었지만 인터셉터는 등록하면서 제외할 URL을 지정할 수 있다
// HomeController
@GetMapping("/")
public String homeLoginV3ArgumentResolver(@Login Member loginMember, Model model) {
// 세션에 회원 데이터가 없으면 home
if (loginMember == null) {
return "home";
}
// 세션이 유지되면 로그인으로 이동
model.addAttribute("member", loginMember);
return "loginHome";
}
세션에서 멤버를 찾아 파라미터에 넣어주는 과정을 어노테이션 하나로 편리하게 할 수 있다
ArgumentResolver 를 활용하면 공통 작업이 필요할 때 컨트롤러를 더욱 편리하게 사용할 수 있다
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface Login { }
@Target(ElementType.PARAMETER)
: 파라미터에만 사용
@Retention(RetentionPolicy.RUNTIME)
: 리플렉션 등을 활용할 수 있도록 런타임까지 어노테이션 정보가 남아있음
public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver {
@Override
public boolean supportsParameter(MethodParameter parameter) {
log.info("supportsParameter 실행");
// parameter에 @Login이 있는지
boolean hasParameterAnnotation = parameter.hasParameterAnnotation(Login.class);
// parameter에 Member 클래스가 있는지
boolean hasMemberType = Member.class.isAssignableFrom(parameter.getParameterType());
// 위의 조건 두 개를 만족해야 실행
return hasMemberType && hasParameterAnnotation;
}
@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);
// 세션이 null이면 HomeController @Login Member member 부분에 null을 넣는다
if (session == null) {
return null;
}
return session.getAttribute(SessionConst.LOGIN_MEMBER);
}
}
ArgumentResolver를 구현하지 않으면 @Login
이 무엇인지 모르기 때문에 @ModelAttribute
처럼 동작한다
@Login Member
형태를 만족하면 ( @Login
어노테이션이 있으면서 Member 타입이면) 위의 ArgumentResolver가 사용되도록 구현한 것
supportsParameter(MethodParameter parameter)
: 파라미터 정보가 넘어옴
parameter.hasParameterAnnotation(Login.class);
: 파라미터에 @Login
어노테이션이 있는지 확인
Member.class.isAssignableFrom(parameter.getParameterType());
: Member 클래스인지 확인
resolveArgument()
컨트롤러 호출 직전에 호출 되어서 필요한 파라미터 정보를 생성
세션에 있는 로그인 회원 정보인 member 객체를 찾아서 반환하는 로직을 구현
이후 스프링MVC는 컨트롤러의 메서드를 호출하면서 여기에서 반환된 member 객체를 파라미터에 전달
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(new LoginMemberArgumentResolver());
}
}