[CMC] Filter & Interceptor

janeljs·2021년 9월 25일
5
post-thumbnail

CMC 개발 컨퍼런스에서 Filter와 Interceptor를 주제로 발표를 했습니다. 첫 기술 관련 발표여서 많이 떨렸지만, 준비하는 과정 및 다른 분들의 발표를 듣는 과정에서 많이 배운 것 같습니다.

❓ Filter와 Interceptor는 왜 필요할까?

📑 요구사항

  • GitHub 레포에 권한이 있는 사용자만 이슈의 상태를 변경할 수 있다.
  • GitHub 레포에 권한이 있는 사용자만 이슈의 담당자를 업데이트 할 수 있다.


❗문제점

  • 메서드마다 사용자를 검증하는 로직을 추가한다면 많은 중복 코드 발생
    • 메서드로 추출하더라도 컨트롤러/서비스 계층에서의 수정이 필요
  • 사용자 검증 로직 변경 시 모든 메서드에서 해당 로직을 변경해주어야 함


✨ Filter & Interceptor

= 애플리케이션의 Cross-Cutting Concern을 해결해준다!

※ 현재 Cross-Cutting Concern = 사용자 검증 로직


🌐 흐름

HTTP Request → WAS → Filter → DispatherServlet → Interceptor → Controller

Servlet

웹 어플리케이션을 만들 때 필요한 인터페이스

Background

원래 웹 서버는 클라이언트 요청에 대해 정적인 페이지로만 응답할 수 있었다.
→ 사용자들은 동적인 페이지를 원한다!
→ 동적 데이터를 처리하기 위한 인터페이스 CGI(Common Gateway Interface)가 등장했다.
→ But, CGI는 쓰레드가 다르면 별도의 구현체를 생성한다.
→ CGI 구현체를 싱글톤 패턴으로 바꾼 것이 서블릿이다!
→ 웹 서버에서 요청이 들어오면 WAS(Web Application Server) 내부의 Web Container가 Thread를 생성하고 Servlet 인터페이스에 정의되어 있는 메서드를 호출한다.
→ 간단히 말하면 서블릿은 동적인 페이지를 만들기 위한 인터페이스이다!

DispatcherServlet

스프링이 사용하는 서블릿

Filter

  • Filter는 서블릿이 제공하는 기술로 스프링 프레임워크에 포함되지 않는다.
  • javax.servlet.Filter 인터페이스를 구현하여 사용할 수 있다.
  • 클라이언트로부터 오는 요청이 Servlet으로 가기 전에 요청을 조작/차단하거나 Servlet으로부터 오는 응답이 클라이언트에게 전달되기 전에 응답을 조작/차단할 수 있다.
  • 필터 체이닝이 가능하다.
    • HTTP Request → WAS → Filter 1→ Filter 2 → Servlet → Controller

Interceptor

  • Interceptor는 스프링 MVC가 제공하는 기술이다.
  • org.springframework.web.servlet.HandlerInterceptor 인터페이스를 구현하여 사용할 수 있다.
  • DispatcherServlet과 Controller 사이에서 요청과 응답을 가공한다.
  • 인터셉터 체이닝이 가능하다.
    • HTTP Request → WAS → Filter→ Servlet → Interceptor 1 → Interceptor 2 → Controller

🤔 Filter vs. Interceptor 어떤 걸 써야하지?

1. Filter

☑️ Filter에는 doFilter 메서드가 있으며, 매개변수로 ServletRequest를 받는다.

  • 클라이언트에서 HTTP 요청이 올 때마다 doFilter() 메서드가 호출된다.
  • ServletRequest는 HttpServletRequest의 상위 인터페이스이다.
  • chain.doFilter(request, response); → 필터가 존재한다면 다음 필터를, 필터가 없다면 서블릿을 호출한다.
    • 이 정의된 순서에 따라 필터 호출
    <filter-mapping>
        <filter-name>firstFilter</filter-name>
        <url-pattern>/*</url-pattern>
    </filer-mapping>
     
     
    <filter-mapping>
        <filter-name>secondFilter</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>

2. Interceptor

☑️ doFilter() → preHandle(), postHandle(), afterCompletion()

  • preHandle(): 컨트롤러 호출 전에 실행된다.
    • return 값이 true이면 다음 단계로 넘어간다.
    • return 값이 false이면 작업을 중지한다.
  • postHandle(): 컨트롤러 호출 후에 실행된다.
    • 컨트롤러에서 예외가 발생할 시 실행되지 않는다.
  • afterCompletion(): 요청 처리와 뷰 렌더링이 완료된 이후에 실행된다.
    • 컨트롤러에서의 예외 발생과 상관없이 호출된다.

🚀활용

1. URL 패턴 지정

addPathPatterns excludePathPatterns

@RequiredArgsConstructor
@Configuration
public class WebConfig implements WebMvcConfigurer {

    private final String[] SIGNIN_PATH_TO_EXCLUDE = {"/sign-in/**", "/users/**", "/oauth/**", "/h2-console/**"};

    private final SignInInterceptor signInInterceptor;

		(...)

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(signInInterceptor)
								.order(1) // 인터셉터 호출 순서 지정
                .addPathPatterns("/**") // 인터셉터 적용할 패턴 지정
                .excludePathPatterns(SIGNIN_PATH_TO_EXCLUDE); // 인터셉터에서 제외할 패턴 지정
    }
}

Path pattern 공식 문서: https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/util/pattern/PathPattern.html

2. Custom Annotation 이용

@Retention(RUNTIME)
@Target(METHOD)
public @interface LoginRequired {
}
    @LoginRequired
    @PatchMapping
    public void changeStatus(@RequestBody IssueNumbersRequestDTO issueNumbersRequestDTO, 
														 @RequestParam String status) {
        logger.debug("이슈 닫기 or 열기");
        issueCommandService.changeIssueStatus(issueNumbersRequestDTO, status);
    }

    @LoginRequired
    @PatchMapping("/{issueId}/assignees")
    public void updateAssignees(@PathVariable Long issueId, 
								@RequestBody AssigneesToUpdateRequestDTO updateAssigneesRequestDTO) {
        logger.debug("이슈의 담당자 편집");
        issueCommandService.updateAssignees(issueId, updateAssigneesRequestDTO);
    }
import org.springframework.web.servlet.HandlerInterceptor;

@Component
@RequiredArgsConstructor
public class JwtAuthInterceptor implements HandlerInterceptor {

    private static final String AUTHORIZATION = "Authorization";
    private static final String BEARER = "Bearer";
    private final JwtService jwtService;
    private final UserRepository userRepository;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, 
															Object handler) {
        if (loginRequired(handler)) {
            verifyJwt(request);
            verifyUser(request);
        }
        return true;
    }

    private void verifyUser(HttpServletRequest request) {
        Object userId = request.getAttribute(USER_ID);
        userRepository.findById((Long) userId).orElseThrow(IllegalUserAccessException::new);
    }

    private boolean loginRequired(Object handler) {
        return handler instanceof HandlerMethod
                && ((HandlerMethod) handler).hasMethodAnnotation(LoginRequired.class);
    }

    private void verifyJwt(HttpServletRequest request) {
        String header = request.getHeader(AUTHORIZATION);
        verifyHeader(header);

        String jwt = header.substring(BEARER.length()).trim();
        DecodedJWT decodedJWT = jwtService.verifyToken(jwt);

        request.setAttribute(USER_ID, jwtService.getUserId(decodedJWT));
    }

    private void verifyHeader(String header) {
        if (header == null || !header.startsWith(BEARER)) {
            throw new HttpHeaderFormatException();
        }
    }
}

HandlerMethod

  • 메서드와 빈으로 구성된 핸들러 메서드에 대한 정보를 캡슐화한다.
  • 메서드 파라미터, 메서드 반환 값, 메서드 어노테이션 등에 접근할 수 있다.
  • @Controller 또는 @RequestMapping을 활용하여 매핑할 경우 핸들러 정보로 HandlerMethod가 전달된다.

아래와 같은 코드는 이제 Bye~

    @GetMapping("/form")
    public String form(HttpSession session) {
        if (!isLoginUser(session)) {
            throw new IllegalUserAccessException("로그인이 필요합니다.");
        }
        return "/qna/form";
    }

    @PostMapping
    public String create(String title, String contents, HttpSession session) {
        if (!isLoginUser(session)) {
            throw new IllegalUserAccessException("로그인이 필요합니다.");
        }
        User sessionUser = getSessionUser(session);
        Question question = new Question(sessionUser, title, contents);
        questionService.update(question);

Source

2개의 댓글

comment-user-thumbnail
2021년 9월 26일

정성스럽게 잘 정리해주셔서 감사합니다! 역시 대단~ㅋㅋ

1개의 답글