목차
같은 코드가 핸들러마다 반복된다
주문 목록을 주는 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 안) |
핸들러가 자기 일만 하게 만드는 것, 그게 미들웨어가 하는 일이다. 공통 처리는 길목이 맡는다.