인터셉터

 

서블릿 필터에 이어 웹과 관련된 공통 관심사를 해결할 수 있는 방법으로 스프링 MVC가 제공하는 인터셉터가 있다.

인터셉터는 컨트롤러 호출 직전에 호출이 되며 필터처럼 체인으로 구성이 되는 등 비슷한 부분이 많지만 필터에 비해 좀 더 편리하고 정교한 기능을 지원한다.

 

 

 

먼저, 인터셉터를 사용하려면. HandlerIntercepter 인터페이스를 구현해야 한다.

  • preHandle : 컨트롤러 호출 전 동작
  • postHandle : 컨트롤러 호출 후 동작, 컨트롤러 예외 발생 시 호출되지 않는다.
  • afterCompletion : 마지막에 항상 호출, 예외를 받아서 처리할 수 있다.

DispatcherServlet -> preHandle -> handle() -> Controller -> postHandle -> render() -> View -> afterCompletion

 

public class LogInterceptor implements HandlerInterceptor {
    public static final String LOG_ID = "logId";

	@Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
    	Object handler) throws Exception {

        HttpSession session = request.getSession(false);

        //@Controller, @RequestMapping으로 핸들러 매핑을 할 경우, 핸들러 정보로 HandlerMethod가 넘어온다.
        if (handler instanceof HandlerMethod) {
            //호출할 컨트롤러 메서드의 모든 정보가 포함되어 있다.
            HandlerMethod hm = (HandlerMethod) handler;
        }
       
      	if (session == null || session.getAttribute("loginMember") == null) {
            redirect response.sendRedirect("/login?redirectURL=" + requestURI);
            return false;
        }
        return true;
    }

	@Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response,
    	Object handler, ModelAndView modelAndView) throws Exception {
        //ModelAndView를 받을 수 있다.
    }

	@Override
	public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
    	Object handler, Exception ex) throws Exception {
      	// Exception을 받아서 처리하거나 넘길 수 있다.
        
	}
}

 

인터셉터는 WebMvcConfigurer가 제공하는 addInterceptors() 를 사용해서 등록할 수 있다.

 

@Configuration
public class WebConfig implements WebMvcConfigurer {

	@Override
	public void addInterceptors(InterceptorRegistry registry) {
            registry.addInterceptor(new LogInterceptor())
                .order(1) // 필터와 마찬가지로 호출 우선 순위
                .addPathPatterns("/**") // URL 패턴
                .excludePathPatterns("/css/**", "/*.ico", "/error"); // 인터셉터 제외 패턴
            
            // 2번째 인터셉터 등록            
            registry.addInterceptor(new LoginCheckInterceptor())
                .order(2)
                .addPathPatterns("/**")
              	.excludePathPatterns("/", "/members/add", "/login", "/logout",
                	"/css/**", "/*.ico", "/error");
    }
}

 

필터보다 URL 패턴을 더욱 세밀하게 설정할 수 있다.

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

 


 

ArgumentResolver를 활용한 로그인 회원 조회

 

직접 만든 ArgumentResolver를 추가해서 로그인 회원을 편리하게 조회할 수 있다.

ArgumentResolver는 핸들러 어댑터에서 핸들러(컨트롤러)를 호출할 때 컨트롤러의 파라미터, 애노테이션 정보를 기반으로 전달 데이터를 생성하는 역할을 한다.

(ArgumentResolver는 요청 값을 처리하고 응답 값을 변환하고 처리하는 것은 ReturnValueHandler가 한다.)

 

@Login 애노테이션을 새로 만들어서 컨트롤러 파라미터에 입력하고 직접 만든 ArgumentResolver에서 이를 이용할 수 있다.

 

//@GetMapping을 제거하고 @Login 애노테이션을 선언
//@GetMapping("/")
public String homeLogin(@Login Member loginMember, Model model) {
	...
}

//@Login 애노테이션 생성
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface Login {

}

 

스프링은 30개가 넘는 ArgumentResovler를 기본으로 제공하는데 여기에 직접 만든 ArgumentResolver를 추가할 수 있다.

HandlerMethodArgumentResolver의 supportsParameter()를 호출해서 해당 파라미터를 지원하는지 체크하고, 지원하면 resolveArgument()를 호출해서 실제 객체를 생성하고 컨트롤러 호출 시 전달할 수 있다.

 

public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver {

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        boolean hasLoginAnnotation = parameter.hasParameterAnnotation(Login.class);
      	boolean hasMemberType = Member.class.isAssignableFrom(parameter.getParameterType());

        return hasLoginAnnotation && hasMemberType;
    }

	@Override
	public Object resolveArgument(MethodParameter parameter, 
    	ModelAndViewContainer mavContainer, NativeWebRequest webRequest,
        WebDataBinderFactory binderFactory) throws Exception {
		
        HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
        HttpSession session = request.getSession(false);
		
        if (session == null) {
            return null;
        }
        // 컨트롤러 파라미터에 선언된 @Login Member loginMember에 member 객체를 전달
        return session.getAttribute("loginMember");
    }
}

 

생성한 ArgumentResolver는 다음과 같이 등록할 수 있다.

 

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(new LoginMemberArgumentResolver());
    }
    ...
}

 

지금까지 사용자 입력 값을 검증하기 위한 여러 검증 방법과 로그인을 유지하기 위한 방법으로 쿠키, 세션에 대해 알아보았다.

 

쿠키, 세션을 사용하여 로그인을 한 사용자한테만 메인 화면을 보여주도록 설계를 했는데 만약에 로그인을 하지 않은 사용자가 직접 URL로 메인 화면을 호출을 하면 어떻게 될까?

각각의 컨트롤러에서 전부 로그인 여부를 체크하는 것이 아니라면 로그인을 하지 않아도 URL로 접근이 가능할 것이다.

 

이렇게 애플리케이션 여러 로직에서 공통으로 관심이 있는 것공통 관심사라고 한다. 이러한 공통 관심사는 스프링의 AOP로 해결할 수도 있지만 웹과 관련된 공통 관심사는 서블릿의 필터 또는 스프링의 인터셉터를 사용하는 것이 좋다.

 

서블릿 필터

 

서블릿 필터는 WAS에서 서블릿(DispatcherServlet)을 호출하기 전에 작동하며, 특정 URL 요청에 대해 적절하지 않은 요청의 경우 서블릿으로 넘어가지 않도록 필터 역할을 한다.

필터는 체인 기능을 통해 여러 개의 필터를 추가할 수 있으며 다음의 메서드를 오버라이드해서 필터 인터페이스를 구현한다.

 

  • init() : 필터 초기화 메서드서블릿 컨테이너가 생성될 때 호출
  • doFilter(): 요청이 올 때 마다 호출되는 메서드로, 필터의 로직을 담당
  • destroy(): 필터 종료 메서드서블릿 컨테이너가 종료될 때 호출

 

public class LogFilter implements Filter {
	@Override
	public void init(FilterConfig filterConfig) throws ServletException {
    	...
    	}
    
	@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 {
            	// 다음 필터를 호출하고, 다음 필터가 없을 경우 서블릿을 호출하는 필수 로직
                chain.doFilter(request, response);
            } catch (Exception e) {
                throw e;
            } finally {
                ...
            }
    	}
        
    	@Override
    	public void destroy() {
    	    ...
    	}
}

 

이렇게 만든 필터를 등록하는 방법은 여러가지가 있지만, 스프링 부트를 사용하는 경우 FilterRegistrationBean을 사용해서 등록하면 된다.

 

@Configuration
public class WebConfig {
	@Bean
	public FilterRegistrationBean logFilter() {
    	FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
		
        // 등록할 필터 지정
        filterRegistrationBean.setFilter(new LogFilter());
        // 필터 체인 우선 순위, 1이면 먼저 동작
        filterRegistrationBean.setOrder(1);
        // 필터를 적용할 URL 패턴
        filterRegistrationBean.addUrlPatterns("/*");
        
        return filterRegistrationBean;
    }
}

 

모든 URL에 대해 필터를 적용한다해도 홈, 회원가입, 로그인 화면, css 같은 리소스에는 접근할 수 있어야 하는데 예외적인 경로를 따로 배열로 만들어서 제외시킬 수 있다.

 

public class LoginCheckFilter implements Filter {

	private static final String[] whitelist = {"/", "/login", "/logout","/css/*"};
    
    @Override
    public void doFilter(...) {
    	try {
        	if (isLoginCheckPath(requestURI)) {
                    HttpSession session = httpRequest.getSession(false);
                
            		if (session == null || session.getAttribute("loginMember") == null) {
            			httpResponse.sendRedirect("/login?redirectURL=" + requestURI);
                    		return;
                	}
                }
           // 세션이 없을 경우 리턴, 아니면 다음 필터로
           chain.doFilter(request, response);
        } catch (Exception e) {
        	throw e;
        } finally {
			...       
        }
    }
    
    // whitelist의 경로일 경우 인증 체크 X
    private boolean isLoginCheckPath(String requestURI) {
    	return !PatternMatchUtils.simpleMatch(whitelist, requestURI);
	}

 

 

[참고] 인프런 김영한님 강의를 공부한 내용입니다.

로그인 상태를 유지하는 방법

 

1.  쿠키

HTTP 응답 시 쿠키에 데이터를 담아서 브라우저에 전달하면 브라우저의 쿠키 저장소에 쿠키가 저장이 되어 이후 요청을 보낼 때 쿠키를 같이 전송한다.

쿠키를 생성할 때는 일정 시간동안 유지되는 영속 쿠키, 브라우저 종료시까지 유지되는 세션 쿠키(만료 날짜 생략)가 있다. 또한 @CookieValue 애노테이션을 사용하면 편리하게 쿠키를 조회할 수 있다.

 

쿠키의 문제점

클라이언트가 쿠키 값을 임의로 변경할 수 있기 때문에 보안에 취약하다.

쿠키에는 임의의 예측 불가능한 값의 토큰을 생성해서 노출하고 서버에서는 해당 토큰을 매핑해서 중요한 데이터를 관리하는 방법이 있다.

이렇게 서버에 중요한 데이터를 보관하고 연결을 유지하는 방법을 세션이라 한다.

 

2.  세션

세션을 생성하고 sessionId로 쿠키를 생성해서 클라이언트에 전달하면, 클라이언트는 쿠키를 보관하면서 이후 요청 시 쿠키를 통해 세션 저장소에 저장된 값을 조회할 수 있고 세션이 만료되면 연결이 끊어지게 된다.

서블릿이 제공하는 HttpSession은 JSESSIONID 라는 이름의 쿠키를 생성하고 값은 추정 불가능한 랜덤 값을 가진다.

getSession(boolean create) 의 경우 기본 값이 true 옵션이라 로그인 확인을 하려면 false로 바꾸는 것이 좋다.

//세션이 있으면 있는 세션 반환, 없으면 신규 세션 생성
//getSession(boolean create)
// true : 세션이 없으면 새로 생성해서 반환, false : 세션이 없으면 null 반환
HttpSession session = request.getSession(default: true);

//세션에 로그인 회원 정보 보관
session.setAttribute("loginMember", loginMember);

 

스프링이 지원하는 @SessionAttribute를 사용하면 직접 생성할 필요 없이 다음과 같이 편하게 사용할 수 있다.

 

@GetMapping("/login")
public String homeLogin (
	@SessionAttribute("loginMember", required = false) Member loginMember, Model model) {
	...
}

 

세션은 session.invalidate() 를 호출하면 삭제가 되는데 HTTP는 비연결성이기 때문에 로그아웃을 누르지 않고 브라우저를 종료할 경우 세션 데이터가 삭제되지 않고 남아있게 된다.

세션기본적으로 메모리에 생성되기 때문에 필요한 경우에 생성했다 삭제하는 것이 좋은데 서블릿의 HttpSession은 클라이언트에서 서버에 마지막으로 요청한 시간을 기준으로 일정 시간동안 세션을 유지하는 방법을 사용한다. (기본 설정 30분, 만료 시 WAS가 내부에서 해당 세션을 제거)

특정 세션 단위로 시간을 설정하거나 스프링부트를 통해 application.properties로 글로벌 설정을 할 수 있다.

 

// 특정 세션에 적용
session.setMaxInactiveInterval(1800);

// application.properties
server.servlet.session.timeout=1800

 

 

 

[참고] 인프런 김영한님 강의를 공부한 내용입니다.

Validation

 

기존 클라이언트에서 전송한 데이터 오류 발생 시 문제점

 

뷰 템플릿에서 중복으로 처리할 것이 많고 숫자 타입에 문자가 입력되는 등의 타입 오류 시 컨트롤러에 진입하기 전에 예외 발생한다.

타입 오류 발생 시에 이전에 입력한 값을 보여주기 힘들다.

 

 

BindingResult

 

스프링이 제공하는 인터페이스(Error 인터페이스 상속)로 @ModelAttribute에 데이터 바인딩 오류가 발생해도 컨트롤러가 호출된다.

addError() 메서드의 파라미터로 오류 정보를 담은 FiledError(), ObjectError()를 생성한다.

FiledError, ObjectError의 생성자는 errorCode, arguments를 가지는데 이는 오류 메시지를 생성할 때 사용된다.

 

@PostMapping("/add")
public String createItem(@ModelAttribute Item item, BindingResult bindingResult) {
    if (!StringUtils.hasText(item.getItemName())) {
                bindingResult.addError(new FieldError("item", "itemName",
        item.getItemName(), false, new String[]{"required.item.itemName"}, null,
        null));
}

errors.properties 파일을 별도로 생성 (메시지 방식과 동일함)

<!-- errors.properties -->
required.item.itemName=상품명을 입력하세요.

 

rejectValue() 를 사용하면 FieldError를 직접 생성하지 않고 오류를 관리할 수 있다.

내부적으로 MessageCodeResolver가 동작하면서 errorCode를 보고 errors.properties에서 오류 메시지를 찾는데

객체, 필드명, errorCode를 조합해서 구체적인 것에서 덜 구체적인 순으로 조회를 한다.

 

//void rejectValue(@Nullable String field, String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage);
if (!StringUtils.hasText(item.getItemName())) {
	/**
    *	우선 순위
    *	1. required.item.itemName
    *	2. required.itemName
    *	3. required.java.lang.String
    *	4. required
    */
	bindingResult.rejectValue("itemName", "required");
}

 

타임리프

 

타임리프는 BindingResult를 활용해서 편리하게 검증 오류를 표현하는 기능 제공한다.

  • #fields : BindingResult 가 제공하는 검증 오류에 접근할 수 있다. (th:if="${#fields.hasGlobalErrors()}")
  • th:errors : 해당 필드에 오류가 있는 경우에만 태그를 출력한다. (th:if 편의 버전)
  • th:errorclass : th:field 에서 지정한 필드에 오류가 있는 경우에만 class 정보를 추가한다.
  • th:filed : 평상시 model 객체의 값을 사용, 오류 발생 시 FieldError에 보관한 값(rejectedValue)을 사용

 


 

Validator

BindingResult의 rejectValue()로 편해지긴 했지만 컨트롤러에서 작성하는 검증 로직이 매우 복잡하다.

Validator를 사용해서 검증 로직을 별도의 클래스로 분리 가능한데, Validator를 사용해서 컨트롤러에서 검증 로직을 분리시켜도 검증 코드를 하나하나 작성하는 건 변하지 않는다.

Bean Validation을 사용하면 검증 로직을 애노테이션으로 깔끔하게 해결 가능하다.

 

Bean Validation

Bean Validation 2.0(JSR-380) 표준 기술로, 실무에서는 보통 하이버네이트 validator(구현체)를 사용한다.

  • build.gradle 라이브러리 추가
implementation 'org.springframework.boot:spring-boot-starter-validation'

 

'spring-boot-starter-validation' 라이브러리를 추가하면 스프링 부트는 자동으로 Bean Validator를 스프링에 통합해준다.

필드 위에 검증 애노테이션을 입력만 하면 Bean Validation이 오류 발생 시 FeildError를 자동 생성한다.

스프링 부트는 Bean Validation을 글로벌 Validator로 사용하기 때문에 @ModelAttribute 앞에 @Validated만 추가하면 적용이 된다.

 

@Data
public class Item {
    private Long id;
    
    @NotBlank
    private String itemName;
    
    @NotNull
    @Range(min = 1000, max = 1000000)
    private Integer price;
}
@PostMapping("/add")
    public String addItem(@Validated @ModelAttribute Item item, BindingResult
bindingResult) {
	...
}

 

먼저 @ModelAttribute 각각의 필드에 타입 변환이 되면 Bean Validation이 적용되고 타입 변환에 실패할 경우 해당 필드는 typeMismatch(errorCode)로 FiledError가 생성된다. (나머지 필드는 Bean Validation 검증 시작)

 

Bean Validation은 검증 오류 발생 시 애노테이션명을 errorCode로 사용해서 FieldError 생성하기 때문에 errors.properties에 해당 애노테이션명(errorCode)로 오류 메시지를 추가해서 활용 가능하다.

 

NotBlank.item.itemName
NotBlank.itemName
NotBlank.java.lang.String
NotBlank

Range.item.price
Range.price
Range.java.lang.Integer
Range

처음 바인딩 되는 {0}은 필드명, {1}, {2}.. 는 애노테이션마다 다르다. (@Range의 경우 가격 범위 바인딩)

//Bean Validation 추가

NotBlank={0} 공백X
Range={0}, {2} ~ {1} 허용

 

ObjectError의 경우는 bean Validation 적용이 복잡해서 bindingResult.reject() 로 직접 생성해주는 것이 좋다. 

Bean Validation은 각각의 경우에 따라 검증 방식을 나눌 수 있는 groups 기능을 제공하지만 별도의 폼 객체(saveForm, updateForm.. )를 만들어서 사용하는 것이 좋다.

 

 

HttpMessageConverter(@RequestBody)에도 적용이 가능한데 FieldError 객체를 JSON으로 변환해서 전달한다. (필요한 데이터만 뽑아서 별도의 API 스펙으로 객체를 만들어서 반환)

@ModelAttribute는 @Valiated 적용 시 각각의 필드에 세밀하게 적용이 되어 특정 필드에 타입 오류가 발생해도 나머지 필드는 정상 처리가 된다.

반면, HttpMessageConverter전체 객체 단위로 적용이 되기 때문에 하나라도 문제가 생기면 예외가 발생하고 컨트롤러도 호출되지 않는다.

+ Recent posts