공통 관심 사항
1. 공통 관심 사항이란
• 많은 로직에서 공통으로 관심이 있는 부분을 공통 관심사(cross-cutting concerns)라 함.
• 로그인을 하지 않은 사용자는 접근할 수 있는 페이지가 제한적이며 로그인이 필요한 페이지 접근이 허용되서는 안됨.
• 로그인이 필요한 모든 컨트롤러 로직에 로그인 여부를 확인하는 코드를 작성하기엔 비효율적
• 여러 로직에서 공통으로 로그인에 관심을 가지고 있는데, 이러한 공통 관심사는 스프링에서 AOP로 처리할 수 있음
• 웹에 관련된 공통 관심사는 스프링 AOP 보다는 서블릿 필터, 스프링 인터셉터에서 처리하는게 좋음.
• 웹과 관련된 공통 관심사를 처리할 때는 HTTP의 헤더나 URL 정보가 필요한데 서블릿 필터나, 스프링 인터셉터는 HttpServletRequest를 제공.
서블릿필터 VS 스프링 인터셉터
※ 서블릿 필터와 스프링 인터셉터는 모두 웹과 관련된 공통 관심사를 처리하는데 사용되는 기술
필터는 서블릿에서 제공하고 인터셉터는 스프링 MVC가 제공하는 기술인데, 적용되는 순서와 범위, 사용방법이 다름
1. 필터와 인터셉터의 흐름 비교
• 필터를 적용하면 필터가 호출된 이후 서블릿이 호출됨
(여기서 서블릿은 스프링의 경우 디스패처 서블릿을 의미한다고 생각하면 된다.)
• 인터셉터를 적용하면 디스패처 서블릿과 컨트롤러 사이에서 컨트롤러 호출 직전에 호출됨
• 필터는 서블릿 호출전에 인터셉터는 서블릿 호출 이후 호출되기에 인터셉터는 서블릿에서 예외가 발생한다면 호출되지 않음
2. 필터와 인터셉터의 제한 비교
• 필터와 인터셉터는 각각 요청이 적절하지 않을경우 자신의 상태에서 종료할 수 있음
• 필터는 서블릿까지 가지 못하지만, 스프링 인터셉터는 서블릿까진 통과 후 제한됨
3. 필터와 인터셉터 체인 비교
• 필터 및 인터셉터는 둘 다 추가로 적용할 수 있다.
ex) 로그를 남기는 필터(혹은 인터셉터)를 적용 후 그 다음 로그인 여부를 체크하는 필터(혹은 인터셉터)를 만들어 적용할 수 있음
※ 호출시점의 차이를 빼면 별 차이가 없어보이지만, 스프링 인터셉터는 좀 더 편하고 정교하며 다양한 기능을 제공함.
서블릿 필터
1. 필터 인터페이스
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(): 필터 종료 메서드, 서블릿 컨테이너가 종료될 때 호출됨
※ init, destroy 메서드는 default 메서드 이기에 따로 구현하지 않아도 됨.
2. 필터 구현 예제 1
2.1 로그 필터 기능 소스
package hello.login.web.filter;
import java.io.IOException;
import java.util.UUID;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class LogFilter implements Filter {
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");
HttpServletRequest httpRequest = (HttpServletRequest) request;
String requestURI = httpRequest.getRequestURI();
String uuid = UUID.randomUUID().toString();
try {
log.info("REQUEST [{}] [{}]", uuid, requestURI);
// 서블릿과 컨트롤러를 호출
chain.doFilter(httpRequest, response);
} catch(Exception e) {
throw e;
} finally {
log.info("RESPONSE[{}] [{}]", uuid, requestURI);
}
}
public void destroy() {
log.info("log filter destroy");
}
}
• public class LogFilter implements Filter
- Filter 인터페이스를 구현하며 init, doFilter, destroy 메서드를 재정의
• doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
- HTTP 요청이 오면 doFilter가 호출됨
- ServletRequest request는 HTTP 요청이 아닌 경우도 고려해서 만든 인터페이스.
- HTTP를 사용하면 HttpServletRequest로 다운캐스팅한 뒤 사용하면 됨
• UUID.randomUUID().toString()
- HTTP 요청을 구분하기 위해 요청당 임의의 uuid를 만든다. UUID로 만드는 값이 중복될 일은 거의 없다.
• chain.doFilter(request, response);
- 가장 중요.
- 다음 필터가 있으면 다음 필터를 호출하고 필터가 없으면 서블릿을 호출
- 만약 이 로직을 호출하지 않으면 다음단계로 진행되지 않음.
2.2 필터 등록
@Configuration
public class WebConfig implements WebMvcConfigurer{
@Bean
public FilterRegistrationBean logFilter() {
FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
filterRegistrationBean.setFilter(new LogFilter());
filterRegistrationBean.setOrder(1);
filterRegistrationBean.addUrlPatterns("/*");
return filterRegistrationBean;
}
}
• setFilter(new LogFilter)
- 등록 할 필터를 지정
• setOrder(1)
- 필터는 체인으로 동작하기에 순서가 필요하다. 순서가 낮을수록 먼저 동작
• addUrlPatterns("/*")
- 필터를 적용할 URL 패턴을 지정하며, 하나 이상의 패턴을 지정 할 수도 있음.
3. 필터 구현 예제 2
3.1 로그인 인증 체크 필터 기능 소스
package hello.login.web.filter;
import java.io.IOException;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import org.springframework.util.PatternMatchUtils;
import hello.login.web.SessionConst;
import lombok.extern.slf4j.Slf4j;
@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(httpRequest, httpResponse);
} catch (Exception e) {
throw e; // 예외 로깅 가능 하지만, 톰캣까지 예외를 보내주어야 함
} finally {
log.info("인증 체크 필터 종료 {}",requestURI);
}
}
//화이트 리스트의 경우 인증 체크 X
private boolean isLoginCheckPath(String requestURI) {
// 화이트리스트와 requesURI가 패턴이 일치 하는지
return !PatternMatchUtils.simpleMatch(whitelist, requestURI);
}
}
• private static final String[] whitelist = {"/", "/members/add", ...};
- 정적 리소스(css)와 로그인, 로그아웃의 경우 로그인을 하지 않아도 접근이 가능해야 함.
- 화이트리스트를 제외한 나머지 경로에는 인증 체크 로직을 적용 해 줌
• isLoginCheckPath(String requestURI)
- 매개변수로 전달받은 requestURI가 화이트리스트와 일치하는지 검사
- PatternMatchUtils라는 정적 헬퍼 클래스를 이용하여 쉽게 경로 검사가 가능
• httpResponse.sendRedirect("/login?redirectURL=" + requestURI);
- 로그인을 안했는데 로그인이 필요한 페이지에 접근시 로그인 페이지로 이동
- redirectURL의 queryString : 내가 등록한 상품 목록 페이지에 접근하려는 상황에서 로그인이 안되어 있어 로그인 페이지로 이동했다고 가정할 때 로그인을 하면 다시 상품목록 페이지로 이동시켜주면 사용자 입장에선 편리함.
- 컨트롤러에서 redirectURL 관련 처리를 해줘야함
• return;
- 필터를 더 진행하지 않음
- redirect를 사용했기에 redirect가 응답으로 적용되고 요청이 끝남
3.2 필터 등록
@Bean
public FilterRegistrationBean loginCheckFilter() {
FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
filterRegistrationBean.setFilter(new LoginCheckFilter());
filterRegistrationBean.setOrder(2);
filterRegistrationBean.addUrlPatterns("/*");
return filterRegistrationBean;
}
• addUrlPatterns("/*")
- 허용 URL은 /*으로 전부 허용을 해 줌
- 필터 내부에 화이트리스트가 있기 때문에 검사가 불필요한 경로는 검사를 하지 않음.
3.3 로그인 컨트롤러 (redirectURL처리)
@PostMapping("login")
public String loginV4(@Valid @ModelAttribute LoginForm form,
BindingResult bindingResult,
@RequestParam(defaultValue = "/") String redirectURL,
HttpServletResponse response,
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();
session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember);
if (redirectURL != null) {
return "redirect:" + redirectURL;
}
return "redirect:/";
}
• 로그인이 성공했을 경우 redirectURL이라는 @RequestParam이 있으면 로그인 하기 전 그 페이지로 되돌아가기위해 사용
스프링 인터셉터
1. 인터셉터 인터페이스
public interface HandlerInterceptor {
default boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
return true;
}
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 하나로 로직을 수행하는 서블릿 필터와는 다르게 인터셉터는 다음과 같이 3가지 단계로 세분화 되어있음.
- 컨트롤러 호출 전(preHandler)
- 컨트롤러 호출 후(postHandle)
- 요청 완료 이후(afterCompletion)
2. 인터셉터 흐름
2.1 인터셉터 메서드의 흐름
• (1) preHandler: 컨트롤러 호출 전에 호출되며 반환 타입은 Boolean, 반환 값이 false이면 그 뒤는 진행하지 않음
• (4) postHandler: 컨트롤러 호출 후 호출되며 정확히는 핸들러 어댑터 호출 후 호출.
• (6) afterCompletion: 뷰가 렌더링 된 후에 호출
2.2 인터셉터 예외 발생 흐름
• preHandle : 컨트롤러 호출 전에 호출
• postHandler : 컨트롤러에서 예외가 발생하면 postHandler은 호출되지 않음
• afterCompletion : 항상 호출(try-catch의 finally처럼) 이전에 발생한 예외가 있을 경우 이를 파라미터로 받아서 어떤 예외가 발생했는지 확인할 수 있음
※ 예외가 발생하면 postHandler()같은 경우 호출되지 않기 때문에 예외 처리가 필요하다면 afterCompletion()을 사용해야 함
afterCompletion은 Exception ex를 매개변수로 받고 있으며 Nullable하기에 notNull인 경우 해당 처리를 수행하면 됨
3. 인터셉터 구현 예제
3.1 로그 인터셉터 기능 소스
@Slf4j
public class LoginInterceptor 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);
//@RequestMapping: HandlerMethod가 넘어온다.
//정적 리소스: ResourcehttpRequesthandler가 넘어온다.
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("pohstHandler [{}]", modelAndView);
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
String requestURI = request.getRequestURI();
String uuid = (String) request.getAttribute(LOG_ID);
log.info("RESPONSE [{}][{}][{}]", uuid, requestURI, handler);
if (ex != null) {
log.error("afterCompletion error:", ex);
}
}
}
• public class LoginInterceptor implements HandlerInterceptor
- 인터셉터 구현은 HandlerInterceptor를 구현
• reqeust.setAttribute(LOG_ID, uuid)
- 스프링 인터셉터는 호출 시점이 분리되어 있기에 각각의 메서드가 호출되는 시점에 변수들의 값 유지가 되지 않음
- preHandler에서 지정한 값을 postHandler이나 afterCompletion에서 사용하려면 멤버변수를 사용하면 안되고 request 인스턴스에 담아두어서 사용해야함
※ 인터셉터는 싱글톤 처럼 사용되기 때문에 멤버변수에 값을 저장하면 안됨
- 위 코드에서 request에 담은 LOG_ID(uuid)는 afterCompletion에서 getAttribute로 찾아 사용
• HandlerMethod hm = (HandlerMethod) handler;
- 스프링에서는 일반적으로 @Controller, @RequestMapping을 활용해 핸들러 매핑을 사용하는데, 이 경우 스프링 인터셉터의 Object handler 매개변수에는 핸들러 정보로 HandlerMethod가 넘어옴
3.2 인터셉터 등록
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginInterceptor())
.order(1)
.addPathPatterns("/**")
.excludePathPatterns("/css/**", "/*.ico", "/error");
}
@Bean
public LoginInterceptor loginInterceptor() {
return new LoginInterceptor();
}
}
※ WebMvcConfigurer 인터페이스를 구현하여 addInterceptor 메서드를 재정의해서 인터셉터 등록이 가능
• addInterceptor: 인터셉터를 등록
• order(1): 인터셉터의 호출 순서를 지정하며 낮을 수록 먼저 호출
• addPathPatterns("/**"): 인터셉터를 적용할 URL 패턴을 지정
• excludePathPatterns("/css/**", "/*.ico", "/error") :인터셉터에서 제외할 패턴을 지정
※ PathPattern 공식 문서
? 한 문자 일치 * 경로(/) 안에서 0개 이상의 문자 일치 ** 경로 끝까지 0개 이상의 경로(/) 일치 {spring} 경로(/)와 일치하고 spring이라는 변수로 캡처 {spring:[a-z]+} matches the regexp [a-z]+ as a path variable named "spring" {spring:[a-z]+} regexp [a-z]+ 와 일치하고, "spring" 경로 변수로 캡처 {*spring} 경로가 끝날 때 까지 0개 이상의 경로(/)와 일치하고 spring이라는 변수로 캡처 /pages/t?st.html — matches /pages/test.html, /pages/tXst.html but not /pages/toast.html /resources/*.png — matches all .png files in the resources directory /resources/** — matches all files underneath the /resources/ path, including /resources/image.png and /resources/css/spring.css /resources/{*path} — matches all files underneath the /resources/ path and captures their relative path in a variable named "path"; /resources/image.png will match with "path" → "/image.png", and /resources/css/spring.css will match with "path" → "/css/spring.css" /resources/{filename:\\w+}.dat will match /resources/spring.dat and assign the value "spring" to the filename variable |
ArgumentResolver 활용
1. session 정보를 담는 에노테이션 구현 예시
1.1 @Login 에노테이션 생성
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 {
}
• @Target(ElementType.PARAMETER) : 파라미터에만 붙힐 수 있는 애노테이션
• @Retention(RetentionPolicy.RUNTIME) : 리플렉션 등을 활용할 수 있도록 런타임까지 애노테이션 정보가 남아있도록 해줌
1.2 ArgumentResolver 구현
package hello.login.web.argumentresolver;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
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;
import hello.login.domain.member.Member;
import hello.login.web.SessionConst;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver{
@Override
public boolean supportsParameter(MethodParameter parameter) {
log.info("supportsPararmeter 실행");
// Login에노테이션이 파라미터에 있는지
boolean hasLoginAnnotation = parameter.hasParameterAnnotation(Login.class);
// 파라미터의 타입이 Member 클래스의 타입인지
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("resolverArgument 실행");
HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
HttpSession session = request.getSession(false);
if(session == null) {
return null;
}
return session.getAttribute(SessionConst.LOGIN_MEMBER);
}
}
• supportsParameter()
- 컨트롤러 호출시 각 매개변수들은 ArgumentResolver에 의해 매핑이 됨
- 많은 ArgumentResolver가 각각 대응할 수 있는 객체는 제한되어있음
- 이를 책임사슬 패턴을 이용해 처리
- 각각의 ArgumentResolver는 이 메서드(supportsParameter())를 이용해 매핑가능여부를 Boolean 타입으로 반환함.
- 여기선 @Login 애노테이션이 붙어있고 Member객체인 경우 지원이 가능하다고 로직을 구현
• resolverArgument()
- 실제로 컨트롤러에 필요한 파라미터 정보를 생성해주는 메서드
- 여기서는 세션에서 로그인 회원 정보인 member 객체를 찾아 반환해줌
1.3 ArgumentResolver 등록
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(new LoginMemberArgumentResolver());
}
// ...
}
※ WebMvcConfigurer 인터페이스를 구현하여 addArgumentResolvers메서드를 재정의해서 ArgumentResolver 등록이 가능
1.4 controller에서 ArgumentResolver 사용
• 기존 소스
public String homeLoginV3Spring(
@SessionAttribute(name = SessionConst.LOGIN_MEMBER, required = false)Member loginMember,
HttpServletRequest request, Model model) {
if (loginMember == null) {
return "home";
}
model.addAttribute("member", loginMember);
return "loginHome";
}
※ 매번 속성들(name, required)를 작성해주는건 번거롭고, 해당 애노테이션을 통해 해당 객체에 대한 명시성이 부족
• ArgumentResolver 적용 소스
@GetMapping("/")
public String homeLoginV3ArgumentResolver(@Login Member loginMember, Model model) {
//세션에 회원 데이터가 없으면 home
if (loginMember == null) {
return "home";
}
//세션이 유지되면 로그인으로 이동
model.addAttribute("member", loginMember);
return "loginHome";
}
※ 애노테이션을 활용하면 더 명시적이고 간결하게 회원정보를 찾아서 매핑해줄 수 있음
※ 회원 객체(Member)의 구조가 바뀐다면 Resolver쪽만 수정해주면 됨
※ 직접 객체 매핑 로직을 구현하면 코드가 너무 길고 차후 후임자나 다른 개발자가 보기도 쉽지 않음
※ @SessionAttribute 애노테이션을 활용하면 좀 나아지지만 목적자체가 로그인 정보를 찾는다는 특정 목적의 애노테이션이 아니기 때문에 이 역시 다른 개발자가 볼 때 단번에 이해하기는 힘들고, name이 명확하지 않으면 세션의 이 정보를 어째서 조회하는지에 대해 한번에 이해하기 어려움
※ ArgumentResolver를 이용해 애노테이션으로 요청 매핑 핸들러 어댑터를 구현해주면 하나의 특정 애노테이션으로 가독성도 좋고 편리하게 회원 정보를 조회할 수 있음
'일상의 흔적 > Study' 카테고리의 다른 글
자바 ORM표준 JPA 프로그래밍 (기본편) : JPA소개 - 1 (0) | 2023.03.22 |
---|---|
생활코딩 OAuth2.0 (0) | 2023.03.21 |
인프런 스프링 MVC 2 (백엔드 웹개발 활용 기술) : 로그인처리1 쿠키, 세션 - 6 (0) | 2023.03.20 |
인프런 스프링 MVC 2 (백엔드 웹개발 활용 기술) : Bean validation - 5 (0) | 2023.03.19 |
인프런 스프링 MVC 2 (백엔드 웹개발 활용 기술) : validation - 4 (0) | 2023.03.19 |