← Posts

Middleware · Spring · Backend

Middleware

핸들러마다 반복되는 공통 처리를 요청과 응답 사이 한 줄로 세운다. Spring의 Filter로 개념을 잡는다.

목차
  1. 같은 코드가 핸들러마다 반복된다
  2. 공통 처리를 핸들러 밖으로 꺼낸다
  3. 체인으로 흐른다
  4. 넘기지 않으면 거기서 멈춘다
  5. 순서가 곧 내용이다
  6. 들어갈 때와 나올 때, 두 번 관여한다
  7. Spring에는 자리가 둘이다: Filter와 Interceptor
  8. Spring Security도 결국 필터 체인이다
  9. 흔한 쓰임
  10. 정리

같은 코드가 핸들러마다 반복된다

주문 목록을 주는 API 하나를 만든다고 하자. 컨트롤러는 이렇게 생겼다.

java
@GetMapping("/orders")
public List<Order> orders(HttpServletRequest req) {
    if (req.getHeader("Authorization") == null) {   // 로그인했나
        throw new UnauthorizedException();
    }
    log.info("GET /orders");                        // 접근 기록

    return orderService.findAll();                  // 진짜 하고 싶은 일
}

그런데 API는 하나가 아니다. 사용자 목록도, 상품 목록도 있다.

java
@GetMapping("/users")
public List<User> users(HttpServletRequest req) {
    if (req.getHeader("Authorization") == null) {   // 또 로그인 확인
        throw new UnauthorizedException();
    }
    log.info("GET /users");                         // 또 접근 기록

    return userService.findAll();
}

같은 두 줄이 모든 핸들러 맨 앞에 복붙된다. 핸들러가 스무 개면 스무 번이다. 그리고 로그 형식을 바꾸거나 인증 방식을 고치려면 스무 군데를 다 찾아 고쳐야 한다. 하나 빠뜨리면 거기만 조용히 인증이 뚫린다.

공통 처리를 핸들러 밖으로 꺼낸다

문제의 핵심은 이거다. 저 두 줄은 “주문 목록을 준다”라는 그 핸들러의 진짜 일이 아니다. 어느 핸들러든 똑같이 해야 하는 공통 처리다.

그러면 핸들러마다 넣을 게 아니라, 핸들러에 닿기 전에 한 번 처리하면 된다.

미들웨어는 요청과 응답 사이에 끼워 넣는 처리 단계다. 요청이 핸들러에 도착하기 전에, 그리고 응답이 나가기 전에, 공통으로 할 일을 여기서 한다.

꺼내고 나면 컨트롤러는 자기 일만 남는다.

java
@GetMapping("/orders")
public List<Order> orders() {
    return orderService.findAll();   // 인증·로그가 사라졌다
}

인증과 로그는 어디로 갔나. 요청이 컨트롤러에 오기 전에 지나는 길목으로 옮겨갔다. 그 길목이 미들웨어다.

체인으로 흐른다

미들웨어는 하나가 아니라 여러 개를 줄로 세운다. 요청이 그 줄을 차례로 통과해서 핸들러까지 간다.

diagramdiagram

Spring에서 이 줄의 한 칸을 만드는 가장 기본적인 방법이 Filter다. 아까 복붙하던 로그를 필터 하나로 옮기면 이렇게 된다.

java
public class LoggingFilter implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        var req = (HttpServletRequest) request;

        log.info("{} {}", req.getMethod(), req.getRequestURI());

        chain.doFilter(request, response);   // 다음 칸으로 넘긴다
    }
}

봐야 할 건 마지막 줄 chain.doFilter(...)다. 이게 **“다음으로 넘긴다”**는 뜻이다. 다음 미들웨어가 있으면 그리로, 없으면 컨트롤러로 간다.

참고

이름은 프레임워크마다 다르지만 개념은 같다. Express(Node.js)는 next(), ASP.NET Core는 await next()라고 부른다. 전부 “이 칸 일은 끝났으니 다음으로 넘겨라”라는 같은 신호다. 그래서 미들웨어를 이해하면 어느 스택에서든 같은 그림이 보인다.

넘기지 않으면 거기서 멈춘다

chain.doFilter(...)가 “다음으로 넘긴다”는 것이면, 안 부르면 어떻게 될까. 거기서 멈춘다. 요청이 컨트롤러까지 못 간다.

인증이 정확히 이걸 이용한다.

java
public class AuthFilter implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        var req = (HttpServletRequest) request;
        var res = (HttpServletResponse) response;

        if (req.getHeader("Authorization") == null) {
            res.setStatus(401);
            return;                          // 넘기지 않는다 = 여기서 끊긴다
        }

        chain.doFilter(request, response);   // 통과한 요청만 다음으로
    }
}

토큰이 없으면 return으로 끝낸다. chain.doFilter를 안 부르니 컨트롤러는 이 요청을 아예 보지도 못한다. 통과한 요청만 다음 칸으로 넘어간다.

그래서 컨트롤러는 “여기 온 요청은 이미 인증을 통과한 것”이라고 믿어도 된다. 문지기가 앞에서 막아줬기 때문이다.

순서가 곧 내용이다

미들웨어를 줄로 세운다고 했다. 그러면 어느 순서로 세우느냐가 동작을 바꾼다.

앞의 예에서 로그와 인증의 순서를 생각해 보자.

diagramdiagram
  • 인증을 먼저 두면 막힌 요청은 로그를 안 남긴다. 통과한 정상 요청만 기록된다.
  • 로그를 먼저 두면 막힌 요청까지 다 기록된다. “누가 인증 없이 두드렸나”를 보고 싶으면 이 순서다.

둘 다 맞는 답일 수 있다. 요점은 순서가 취향이 아니라 결정이라는 것이다. 무엇을 먼저 세우느냐로 “무엇을 로그에 남길지”가 정해진다.

Spring Boot에서 필터 순서는 FilterRegistrationBeansetOrder(...)로 정한다. 숫자가 작을수록 앞이다. 순서를 안 정하면 등록된 순서를 따르는데, 그렇게 운에 맡기면 위 두 그림 중 어느 쪽인지 모르게 된다.

들어갈 때와 나올 때, 두 번 관여한다

미들웨어는 요청이 들어갈 때만 일하는 게 아니다. 응답이 나올 때도 같은 자리를 다시 지난다.

앞의 LoggingFilter를 조금 바꿔서 “응답까지 걸린 시간”을 재보자.

java
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
        throws IOException, ServletException {
    var req = (HttpServletRequest) request;
    long start = System.currentTimeMillis();   // (1) 넘기기 전

    chain.doFilter(request, response);         // 컨트롤러가 일하는 구간

    long took = System.currentTimeMillis() - start;
    log.info("{} {} - {}ms", req.getMethod(), req.getRequestURI(), took);   // (2) 돌아온 뒤
}

chain.doFilter(...) **위쪽 (1)**은 요청이 들어갈 때 실행되고, **아래쪽 (2)**는 컨트롤러가 응답을 만들고 돌아온 뒤에 실행된다. 한 필터 안에서 요청 처리와 응답 처리가 위아래로 나뉘는 것이다.

이걸 그림으로 보면 양파 껍질 같다. 들어갈 때 바깥에서 안으로, 나올 때 안에서 바깥으로, 같은 층을 두 번 지난다.

diagramdiagram

먼저 들어간 미들웨어가 나올 때는 마지막에 나온다. 그래서 응답 전체를 감싸는 처리(응답 시간 측정, 공통 헤더 붙이기, 에러를 잡아 형식 맞추기)를 가장 바깥 미들웨어가 맡는다.

Spring에는 자리가 둘이다: Filter와 Interceptor

여기서 초중급이 자주 헷갈리는 지점을 하나 짚는다. Spring에서 “요청과 응답 사이”에 낄 수 있는 자리는 하나가 아니라 둘이다.

diagramdiagram
  • Filter - 서블릿 컨테이너 레벨. Spring MVC보다 바깥에 있다. 모든 요청이 지나고, Spring이 요청을 어느 컨트롤러에 보낼지 정하기 이다.
  • Interceptor (HandlerInterceptor) - Spring MVC . 어느 컨트롤러가 처리할지 정해진 에 낀다. 그래서 “어떤 핸들러가 잡혔는지” 같은 Spring 정보를 알 수 있다.

가르는 기준은 간단하다. Spring이 몰라도 되는 공통 처리(로그, 인코딩, CORS)는 Filter, Spring의 핸들러 정보가 필요한 처리는 Interceptor. 둘 다 “요청과 응답 사이의 처리 단계”라는 미들웨어의 개념은 똑같고, 어느 층에 끼느냐만 다르다.

참고

Interceptor는 chain.doFilter 대신 세 지점으로 나뉘어 있다. preHandle(컨트롤러 전), postHandle(컨트롤러 후), afterCompletion(응답까지 끝난 뒤). 위에서 본 “들어갈 때 / 나올 때”가 메서드로 갈라져 있는 것뿐이다. preHandlefalse를 반환하면 컨트롤러로 안 넘어간다 - chain.doFilter를 안 부르는 것과 같은 일이다.

Spring Security도 결국 필터 체인이다

AuthFilter를 손으로 만들어봤지만, 실무에서 인증 필터를 직접 짜는 일은 드물다. 그 자리를 Spring Security가 맡는다. 그리고 그 Spring Security의 정체가, 방금 본 필터 체인 그대로다. 로그인·CSRF·권한 검사를 다 해주는 그 큰 프레임워크가, 뜯어보면 필터를 줄줄이 세운 것이다.

Spring Security는 요청을 가로챌 필터 하나를 컨테이너에 꽂는다. 이름이 DelegatingFilterProxy다. 그런데 이 필터는 직접 일하지 않고, Spring이 관리하는 진짜 필터 뭉치에 일을 넘긴다. 그 뭉치가 FilterChainProxy이고, 그 안에 보안 필터들이 순서대로 서 있다.

diagramdiagram

각 필터가 한 가지 일만 한다. 우리가 만든 AuthFilter 하나를, 역할별로 쪼개 여러 개로 세운 셈이다.

필터하는 일
CSRF 필터위조된 요청을 막는다
인증 필터로그인 자격을 확인한다 (UsernamePasswordAuthenticationFilter 등)
인가 필터이 사용자가 이 경로에 들어갈 권한이 있는지 본다 (AuthorizationFilter)

그래서 이 글에서 본 두 가지가 여기서 그대로 작동한다.

  • 순서가 곧 내용. 인증 필터가 인가 필터보다 먼저다. 누구인지 모르면 권한을 따질 수가 없다.
  • 안 넘기면 멈춘다. 인증에 실패하면 그 필터가 chain.doFilter를 부르지 않고 401로 끝낸다. 뒤의 필터도, 컨트롤러도 그 요청을 못 본다. 우리 AuthFilter가 하던 바로 그 일이다.
참고

DelegatingFilterProxy가 한 겹 끼는 이유. 서블릿 컨테이너가 만드는 필터는 Spring이 관리하는 빈이 아니라서, 다른 빈을 주입받지 못한다. 그래서 컨테이너에는 위임만 하는 필터 하나를 꽂고, 진짜 일은 Spring이 관리하는 FilterChainProxy에 넘긴다. 컨테이너의 필터 세계와 Spring의 빈 세계를 잇는 다리다.

우리가 필터 하나로 인증을 앞에서 막아본 것이, 규모만 커진 채 Spring Security 안에 그대로 들어 있다. 미들웨어를 이해하면 이 프레임워크의 뼈대가 같이 보인다.

흔한 쓰임

Spring Security는 인증·인가라는 한 갈래를 깊이 판 경우다. 미들웨어로 꺼내는 일은 그 밖에도 많고, 대체로 “모든 요청이 똑같이 겪어야 하는 일”이다.

미들웨어하는 일
인증 · 인가토큰을 확인하고, 없으면 여기서 막는다
로그 · 모니터링요청 경로 · 응답 시간 · 상태 코드를 기록한다
CORS다른 출처의 브라우저 요청을 허용할지 정한다
인코딩 · 압축문자셋을 맞추고, 응답을 gzip으로 줄인다
에러 처리안쪽에서 터진 예외를 잡아 공통 형식으로 바꾼다

공통점은 전부 핸들러의 본래 일이 아니라는 것이다. 주문 목록을 주는 일과 로그를 남기는 일은 별개다. 미들웨어는 그 둘을 갈라서, 핸들러는 자기 일만, 공통 처리는 길목에서 하게 만든다.

정리

미들웨어란요청과 응답 사이에 끼우는 공통 처리 단계
왜 쓰나핸들러마다 복붙되는 공통 코드를 한 곳으로 모은다
어떻게 흐르나여러 개를 줄로 세우고, 각자 “다음으로 넘긴다”(chain.doFilter)
안 넘기면거기서 멈춘다 - 인증이 요청을 막는 방식
순서취향이 아니라 결정. 무엇을 먼저 두느냐가 동작을 바꾼다
두 번 관여들어갈 때와 나올 때. 바깥 미들웨어가 응답 전체를 감싼다
Spring의 두 자리Filter(MVC 바깥) · Interceptor(MVC 안)

핸들러가 자기 일만 하게 만드는 것, 그게 미들웨어가 하는 일이다. 공통 처리는 길목이 맡는다.

관련 글