반응형

공통 관심 사항

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

https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/util/pattern/PathPattern.html

 

 

 

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를 이용해 애노테이션으로 요청 매핑 핸들러 어댑터를 구현해주면 하나의 특정 애노테이션으로 가독성도 좋고 편리하게 회원 정보를 조회할 수 있음

반응형

+ Recent posts