목차
같은 코드가 핸들러마다 반복된다
주문 목록을 주는 API 하나를 만든다고 하자. 컨트롤러는 이렇게 생겼다.
@GetMapping("/orders")
public List<Order> orders(HttpServletRequest req) {
if (req.getHeader("Authorization") == null) { // 로그인했나
throw new UnauthorizedException();
}
log.info("GET /orders"); // 접근 기록
return orderService.findAll(); // 진짜 하고 싶은 일
}그런데 API는 하나가 아니다. 사용자 목록도, 상품 목록도 있다.
@GetMapping("/users")
public List<User> users(HttpServletRequest req) {
if (req.getHeader("Authorization") == null) { // 또 로그인 확인
throw new UnauthorizedException();
}
log.info("GET /users"); // 또 접근 기록
return userService.findAll();
}같은 두 줄이 모든 핸들러 맨 앞에 복붙된다. 핸들러가 스무 개면 스무 번이다. 그리고 로그 형식을 바꾸거나 인증 방식을 고치려면 스무 군데를 다 찾아 고쳐야 한다. 하나 빠뜨리면 거기만 조용히 인증이 뚫린다.
공통 처리를 핸들러 밖으로 꺼낸다
문제의 핵심은 이거다. 저 두 줄은 “주문 목록을 준다”라는 그 핸들러의 진짜 일이 아니다. 어느 핸들러든 똑같이 해야 하는 공통 처리다.
그러면 핸들러마다 넣을 게 아니라, 핸들러에 닿기 전에 한 번 처리하면 된다.
미들웨어는 요청과 응답 사이에 끼워 넣는 처리 단계다. 요청이 핸들러에 도착하기 전에, 그리고 응답이 나가기 전에, 공통으로 할 일을 여기서 한다.
꺼내고 나면 컨트롤러는 자기 일만 남는다.
@GetMapping("/orders")
public List<Order> orders() {
return orderService.findAll(); // 인증·로그가 사라졌다
}인증과 로그는 어디로 갔나. 요청이 컨트롤러에 오기 전에 지나는 길목으로 옮겨갔다. 그 길목이 미들웨어다.
체인으로 흐른다
미들웨어는 하나가 아니라 여러 개를 줄로 세운다. 요청이 그 줄을 차례로 통과해서 핸들러까지 간다.
Spring에서 이 줄의 한 칸을 만드는 가장 기본적인 방법이 Filter다. 아까 복붙하던 로그를 필터 하나로 옮기면 이렇게 된다.
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(...)가 “다음으로 넘긴다”는 것이면, 안 부르면 어떻게 될까. 거기서 멈춘다. 요청이 컨트롤러까지 못 간다.
인증이 정확히 이걸 이용한다.
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에서 필터 순서는 FilterRegistrationBean의 setOrder(...)로 정한다. 숫자가 작을수록 앞이다. 순서를 안 정하면 등록된 순서를 따르는데, 그렇게 운에 맡기면 위 두 그림 중 어느 쪽인지 모르게 된다.
들어갈 때와 나올 때, 두 번 관여한다
미들웨어는 요청이 들어갈 때만 일하는 게 아니다. 응답이 나올 때도 같은 자리를 다시 지난다.
앞의 LoggingFilter를 조금 바꿔서 “응답까지 걸린 시간”을 재보자.
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)**는 컨트롤러가 응답을 만들고 돌아온 뒤에 실행된다. 한 필터 안에서 요청 처리와 응답 처리가 위아래로 나뉘는 것이다.
이걸 그림으로 보면 양파 껍질 같다. 들어갈 때 바깥에서 안으로, 나올 때 안에서 바깥으로, 같은 층을 두 번 지난다.
먼저 들어간 미들웨어가 나올 때는 마지막에 나온다. 그래서 응답 전체를 감싸는 처리(응답 시간 측정, 공통 헤더 붙이기, 에러를 잡아 형식 맞추기)를 가장 바깥 미들웨어가 맡는다.
Spring에는 자리가 둘이다: Filter와 Interceptor
여기서 초중급이 자주 헷갈리는 지점을 하나 짚는다. Spring에서 “요청과 응답 사이”에 낄 수 있는 자리는 하나가 아니라 둘이다.
- Filter - 서블릿 컨테이너 레벨. Spring MVC보다 바깥에 있다. 모든 요청이 지나고, Spring이 요청을 어느 컨트롤러에 보낼지 정하기 전이다.
- Interceptor (
HandlerInterceptor) - Spring MVC 안. 어느 컨트롤러가 처리할지 정해진 뒤에 낀다. 그래서 “어떤 핸들러가 잡혔는지” 같은 Spring 정보를 알 수 있다.
가르는 기준은 간단하다. Spring이 몰라도 되는 공통 처리(로그, 인코딩, CORS)는 Filter, Spring의 핸들러 정보가 필요한 처리는 Interceptor. 둘 다 “요청과 응답 사이의 처리 단계”라는 미들웨어의 개념은 똑같고, 어느 층에 끼느냐만 다르다.
Interceptor는 chain.doFilter 대신 세 지점으로 나뉘어 있다. preHandle(컨트롤러 전), postHandle(컨트롤러 후), afterCompletion(응답까지 끝난 뒤). 위에서 본 “들어갈 때 / 나올 때”가 메서드로 갈라져 있는 것뿐이다. preHandle이 false를 반환하면 컨트롤러로 안 넘어간다 - chain.doFilter를 안 부르는 것과 같은 일이다.
Spring Security도 결국 필터 체인이다
AuthFilter를 손으로 만들어봤지만, 실무에서 인증 필터를 직접 짜는 일은 드물다. 그 자리를 Spring Security가 맡는다. 그리고 그 Spring Security의 정체가, 방금 본 필터 체인 그대로다. 로그인·CSRF·권한 검사를 다 해주는 그 큰 프레임워크가, 뜯어보면 필터를 줄줄이 세운 것이다.
Spring Security는 요청을 가로챌 필터 하나를 컨테이너에 꽂는다. 이름이 DelegatingFilterProxy다. 그런데 이 필터는 직접 일하지 않고, Spring이 관리하는 진짜 필터 뭉치에 일을 넘긴다. 그 뭉치가 FilterChainProxy이고, 그 안에 보안 필터들이 순서대로 서 있다.
각 필터가 한 가지 일만 한다. 우리가 만든 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 안) |
핸들러가 자기 일만 하게 만드는 것, 그게 미들웨어가 하는 일이다. 공통 처리는 길목이 맡는다.