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();   // 인증·로그가 사라졌다
}

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

체인으로 흐른다

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

요청 → 로그모든 요청에 인증모든 요청에 컨트롤러이 요청만의 일 → 응답
길목 - 누가 오든 똑같이 겪는 일

공통 처리를 길목이 맡으면, 핸들러는 자기 일만 하면 된다.

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를 안 부르니 컨트롤러는 이 요청을 아예 보지도 못한다. 통과한 요청만 다음 칸으로 넘어간다.

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

순서가 곧 내용이다

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

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

인증을 먼저 세우면

인증
통과 막힘
로그
컨트롤러

로그에 남는 것

정상 요청

(막힌 요청은 없음)

로그를 먼저 세우면

로그
통과 두드림
인증
막힘
컨트롤러

로그에 남는 것

정상 요청

인증 없이 두드린 요청

같은 두 미들웨어인데 장부에 남는 줄이 다르다. 순서는 취향이 아니라 무엇을 기록할지에 대한 결정이다.

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

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

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)**는 컨트롤러가 응답을 만들고 돌아온 뒤에 실행된다. 한 필터 안에서 요청 처리와 응답 처리가 위아래로 나뉘는 것이다.

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

요청
로그 미들웨어 (바깥)
1시작 시각을 적는다 2걸린 시간을 적는다
인증 미들웨어 (안)
1토큰을 확인한다 2그대로 통과시킨다
컨트롤러자기 일만 한다
응답

먼저 들어간 로그가 마지막에 나온다. 그래서 응답 전체를 감싸는 일은 가장 바깥이 맡는다.

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

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

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

서블릿 컨테이너 - Spring을 모른다
요청 → Filter서블릿 규격
DispatcherServlet - Spring MVC 안
InterceptorSpring 규격 컨트롤러
Filter가 아는 것요청·응답 그 자체. 어느 컨트롤러가 맡을지는 아직 정해지지 않았다.
Interceptor가 아는 것어느 컨트롤러가 맡을지까지. 이미 Spring이 정한 뒤다.

같은 "사이"인데 소속이 다르다. 안으로 들어와야 알 수 있는 것이 있어서 자리가 이다.

  • 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이고, 그 안에 보안 필터들이 순서대로 서 있다.

요청 → DelegatingFilterProxy컨테이너에 꽂힌 필터 하나 넘긴다
FilterChainProxy - Spring이 관리하는 보안 필터들
CSRF
필터
인증
필터
인가
필터

컨트롤러

컨테이너에 꽂힌 건 하나지만, 그 하나가 여럿에게 넘긴다. 새로울 게 없다 - 이것도 필터 체인이다.

각 필터가 한 가지 일만 한다. 우리가 만든 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 안)

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