Spring Security Architecture
이 페이지는 Servlet filter delegation architecture를 설명한다. Filter order와 security configuration DSL은 Spring Security version에 따라 달라질 수 있으므로, 개별 설정 문법보다 책임 경계를 우선한다.
Spring Security는 Servlet Filter Chain 기반으로 동작한다. 모든 HTTP 요청이 Spring MVC Controller의 DispatcherServlet에 도달하기 전에, Filter 단에서 인증·인가를 수행한다. Spring AOP와 비교하면 Filter(보안) vs Interceptor(MVC) vs AOP(Bean 메서드)는 요청을 가로채는 위치가 서로 다르다.
Filter가 가장 바깥에서 요청을 가로채는 이유
Spring Security의 Servlet 지원 기능은 Servlet Filter를 기반으로 구현된다.[1] 클라이언트가 요청을 보내면 Servlet Container는 요청 URI 경로를 기준으로 FilterChain을 생성하고, FilterChain에는 Filter 목록과 최종 처리할 Servlet(보통 DispatcherServlet)이 포함된다.[1:1]
flowchart TD Client["Client 요청"] FC["FilterChain
(Spring Security)"] AuthFilter["AuthenticationFilter
→ 인증"] AuthzFilter["AuthorizationFilter
→ 인가"] DS["DispatcherServlet
(Spring MVC)"] Interceptor["HandlerInterceptor
(선택적)"] Ctrl["Controller"] Client --> FC FC --> AuthFilter --> AuthzFilter AuthzFilter -->|인증·인가 통과| DS AuthzFilter -->|거부| Client DS --> Interceptor --> Ctrl
Filter는 이후 Filter 또는 Servlet 실행을 막고 직접 응답을 작성할 수 있다(예: 인증되지 않은 사용자 차단).[2] 보안은 가능한 한 가장 바깥 계층에서 처리해야 하며, 인증되지 않은 요청은 DispatcherServlet까지 도달하지 못하고 Filter 단계에서 차단된다.[3]
Filter vs Interceptor vs AOP
| 구분 | 위치 | 가로채는 대상 | Spring Bean 접근 |
|---|---|---|---|
| Filter | DispatcherServlet 앞 | HTTP 요청 자체 | DelegatingFilterProxy로 간접 |
| Interceptor | DispatcherServlet 뒤 | Controller 호출 | ApplicationContext 직접 접근 |
| AOP | Bean 메서드 호출 | Service/Repository 메서드 | Proxy 기반 |
Spring Security는 Servlet Filter 계층에서 동작하며, Interceptor는 DispatcherServlet 이후에 실행되므로 위치가 다르다.[3:1] Interceptor는 AOP의 일종이 아니다 — 둘 다 횡단 관심사 분리라는 공통 목적이 있지만, AOP는 프록시 기반이고 Interceptor는 Spring MVC 요청 처리 파이프라인 내부에서 동작한다.[4] Filter, Interceptor, AOP는 모두 공통 로직을 비즈니스 로직과 분리하는 도구이지만 가로채는 위치가 다르며, Filter → Interceptor → AOP 순으로 애플리케이션 내부 깊숙이 들어간다.[5]
Filter 순서(Order)는 자신 이후에 위치한 Filter와 Servlet에만 영향을 줄 수 있으므로 매우 중요하다. AuthenticationFilter가 AuthorizationFilter보다 먼저 실행되어야 권한 검사 필터가 현재 사용자를 확인할 수 있다.[6]
DelegatingFilterProxy — Servlet Container와 Spring Bean의 브릿지
Servlet Container는 Spring Bean을 직접 알지 못한다.[7] DelegatingFilterProxy는 이 간극을 메우는 중간 브릿지로, Spring ApplicationContext에서 Filter Bean을 찾아 실제 작업을 위임한다.[7:1]
flowchart LR SC["Servlet Container
(Spring Bean 모름)"] DFP["DelegatingFilterProxy
(브릿지)"] Bean["Spring Bean Filter
(ApplicationContext)"] SC --> DFP --> Bean
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
Filter delegate = getFilterBean(someBeanName); // Spring Bean 조회
delegate.doFilter(request, response); // 위임
}
FilterChainProxy와 SecurityFilterChain
- Spring Security의 Servlet 지원은
FilterChainProxy내부에 구현되어 있고,FilterChainProxy는 여러 Filter를 직접 호출하는 대신SecurityFilterChain에 위임하는 특수한 Filter다.[8] Spring Security의 delegate는 보통 이FilterChainProxy이며, 하나 이상의SecurityFilterChain을 보유한다. - 요청마다 matcher가 맞는 첫 번째
SecurityFilterChain이 선택된다. 예를 들어/api/**패턴의 chain과 그 외 패턴의 chain이 있으면,/api/messages/요청은 앞쪽 chain만 적용되고 나머지 chain의 패턴에도 맞더라도 무시된다.[9] 각SecurityFilterChain은 따로 구성할 수 있으므로 security policy가 다른 path를 별도 chain으로 둘 수 있다.[9:1] API, actuator, static resource 같은 구분은 이 page가 든 예시다. - 어떤 chain에도 매칭되지 않은 요청은 Spring Security filter 처리 없이 다음 filter로 진행할 수 있다. 이 경우는 "permitAll"과 다르다 — permitAll은 선택된 security chain 안에서 인증 없이 허용하는 policy라는 점에서, chain 자체가 적용되지 않는 이 상황과 구분된다.[10]
인증 실패 응답: ExceptionTranslationFilter와 authenticationEntryPoint
- 원본 노트의 JWT 설정에서 로그인이 필요한 경로에 토큰 없이, 또는 깨지거나 만료된 토큰으로 들어온 요청은 원래도 막혔지만 응답은 본문 없는 403이었다.[11]
- 그래서 프론트는 "로그인 만료"와 "권한 없음(남의 글)"을 구분할 수 없었다.[11:1]
- 막힌 요청은 아래 세 필터를 거쳐 응답이 된다.[12]
- 애플리케이션이 만든
JwtAuthenticationFilter는 토큰이 이상해도 막지 않고 "익명"으로 넘긴다. AuthorizationFilter는authenticated()경로인데 익명이면 거부한다.ExceptionTranslationFilter는 익명 사용자의 거부를 받아authenticationEntryPoint를 호출한다. 따로 정해 두지 않으면 기본값은 빈 403이다.
- 애플리케이션이 만든
flowchart LR Req["요청
(토큰 없음·만료)"] --> Jwt["JwtAuthenticationFilter
익명으로 통과"] Jwt --> Authz["AuthorizationFilter
authenticated() 경로면 거부"] Authz --> ETF["ExceptionTranslationFilter"] ETF --> EP["authenticationEntryPoint"] EP -->|미설정| R403["빈 403"] EP -->|설정| R401["401 + JSON"]
authenticationEntryPoint를 정하면 401과{"code":4011,"message":"Login required."}같은 JSON 본문을 줄 수 있다.[11:2]- 누가 어디에 접근할 수 있는지를 정하는
requestMatchers는 그대로 두고, 막힐 때의 응답 모양만 바꾸는 설정이다.[11:3]
필터에서 JSON을 직접 써야 하는 이유
- Security 필터도 스프링 빈이라 컨테이너 안에 있다. 막히는 지점은 컨테이너 밖이 아니라 DispatcherServlet 앞이다.[13] 위
DelegatingFilterProxy절이 설명하는 연결 덕분에 필터가 스프링 빈으로 동작한다고 볼 수 있다. - 평소 컨트롤러 응답에서는 Spring MVC의
HttpMessageConverter가 Jackson으로 객체를 JSON으로 바꾸고 상태 코드와 Content-Type을 맞추며,@RestControllerAdvice가 예외를 잡는다.[13:1] - 필터 단계에서는 둘 다 아직 동작하지 않으므로, Jackson의
JsonMapper를 직접 호출해 응답을 쓴다.[13:2]
.exceptionHandling(ex -> ex.authenticationEntryPoint(
(request, response, authException) -> writeError(response, ErrorCode.LOGIN_REQUIRED)))
private void writeError(HttpServletResponse response, ErrorCode errorCode) throws IOException {
response.setStatus(errorCode.getStatus());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding(StandardCharsets.UTF_8.name()); // 한글 메시지 깨짐 방지
jsonMapper.writeValue(response.getWriter(),
new ErrorResponseDto(errorCode.getCode(), errorCode.getMessage()));
}
JsonMapper는new로 만들지 않고 주입받는다. Boot가 만든 것을 써야 컨트롤러 응답과 같은 규칙으로 JSON이 나온다.[14]- 문자 인코딩을 UTF-8로 지정하지 않으면 한글 메시지가 깨질 수 있다.[14:1]
- Spring Boot 4는 Jackson 3을 쓰므로 패키지가
tools.jackson이다.[14:2] 버전에 따라 달라지는 사실이므로, Boot 버전을 바꿀 때 다시 확인해야 한다. - 컨트롤러 쪽 에러 응답과 같은
ErrorResponseDto를 쓰는 것은, 필터에서 막힌 요청과 컨트롤러에서 실패한 요청의 에러 형식을 프론트가 한 가지로 처리하게 하려는 선택으로 읽힌다.
실패 모드
- Interceptor에서 인증을 처리하는 경우 — DispatcherServlet까지 도달한 후 차단하므로 리소스가 낭비된다.
- AuthenticationFilter와 AuthorizationFilter 순서를 반대로 설정해 권한 검사가 인증 전에 실행되는 경우.
- DelegatingFilterProxy 없이 직접 Servlet Container에 Filter를 등록해 Spring Bean을 못 쓰는 경우.
- 특정 URL 패턴을 Security Filter에서 제외하지 않아 정적 리소스까지 인증을 요구하는 경우.
- Filter 체인이 너무 길어져 모든 요청에 보안 오버헤드가 발생하는 경우 — 성능 프로파일링이 필요하다.
authenticationEntryPoint를 정하지 않아 인증 실패가 본문 없는 403으로 나가는 경우 — 프론트가 로그인 만료와 권한 없음을 구분하지 못한다.[11:4]- 필터에서 막힌 요청의 에러 응답을
@RestControllerAdvice가 처리해 줄 것으로 기대하는 경우 — 필터 단계에서는 아직 동작하지 않는다.[13:3]
관련
- Spring MVC Controller — Security Filter가 DispatcherServlet 앞에서 보호.
- Spring Framework Core — DelegatingFilterProxy로 Spring Container와 연동.
- Spring AOP — Filter(보안) vs AOP(트랜잭션)는 가로채는 위치 차이.
- Custom Exception Design — 인증 실패 응답도
ErrorCodeenum의 상태·코드·메시지로 만든다는 점에서 같은 에러 코드 설계를 필터 단계까지 넓힌 사례로 볼 수 있다.
출처
테스트 질문
- Spring Security가 Filter에서 동작하는 이유는 무엇인가? Interceptor에서 동작하면 어떤 문제가 생기는가?
- Filter, Interceptor, AOP가 가로채는 위치는 각각 어디인가?
- DelegatingFilterProxy가 왜 필요한가? 없으면 어떤 문제가 발생하는가?
- AuthenticationFilter가 AuthorizationFilter보다 먼저 실행되어야 하는 이유는 무엇인가?
- 여러
SecurityFilterChain이 있을 때 어떤 chain이 선택되는가? - "permitAll"과 "어떤 chain에도 매칭되지 않은 요청"은 어떻게 다른가?
- 인증 없이 보호 경로에 들어온 요청이 기본 설정에서 빈 403을 받는 이유는 무엇이며, 401 JSON으로 바꾸려면 무엇을 설정하는가?
- 필터에서 막힌 요청의 에러 응답에
@RestControllerAdvice와HttpMessageConverter가 적용되지 않는 이유는 무엇인가?
Spring Security 아키텍처.md — "Spring Security의 Servlet 지원 기능은 Servlet Filter를 기반으로 구현되어 있습니다.", "클라이언트가 애플리케이션에 요청을 보내면 Servlet Container는 요청 URI 경로를 기준으로 해당 요청을 처리할 FilterChain을 생성합니다." ↩︎ ↩︎
Spring Security 아키텍처.md — "이 경우 Filter가 직접 응답을 작성합니다.", "예를 들어 인증되지 않은 사용자의 접근을 차단하는 경우가 이에 해당합니다." ↩︎
Spring Security 아키텍처.md — "Spring Security는 Servlet Filter 계층에서 동작한다.", "보안은 가능한 한 가장 바깥 계층에서 처리해야 한다.", "인증되지 않은 요청은 DispatcherServlet까지 도달하지 못하고 Filter 단계에서 차단된다." ↩︎ ↩︎
Spring Security 아키텍처.md — "둘 다 횡단 관심사(Cross-Cutting Concern)를 분리한다는 공통 목적은 있지만 구현 방식이 다르다.", "AOP는 프록시 기반이고, Interceptor는 Spring MVC 요청 처리 파이프라인 내부에서 동작한다." ↩︎
Spring Security 아키텍처.md — "셋 모두 공통 로직을 핵심 비즈니스 로직과 분리하기 위한 도구다.", "Filter ↓ Interceptor ↓ AOP 순으로 애플리케이션 내부 깊숙한 곳으로 들어간다." ↩︎
Spring Security 아키텍처.md — "Filter는 자신 이후에 위치한 Filter와 Servlet에만 영향을 줄 수 있다.", "인증 필터가 먼저 실행되어야 권한 검사 필터가 현재 사용자를 확인할 수 있다." ↩︎
Spring Security 아키텍처.md — "Servlet Container는 Spring Bean을 직접 알지 못하기 때문에 Spring Security는 중간 브릿지 역할을 하는 DelegatingFilterProxy를 사용한다.", "DelegatingFilterProxy는 Spring ApplicationContext에서 Filter Bean을 찾아 실제 작업을 위임한다." ↩︎ ↩︎
spring-security-servlet-architecture.md — "Spring Security's Servlet support is contained within FilterChainProxy.", "FilterChainProxy is a special Filter provided by Spring Security that allows delegating to many Filter instances through SecurityFilterChain." ↩︎
spring-security-servlet-architecture.md — "Only the first SecurityFilterChain that matches is invoked. If a URL of /api/messages/ is requested, it first matches on the SecurityFilterChain0 pattern of /api/**, so only SecurityFilterChain0 is invoked, even though it also matches on SecurityFilterChainn.", "each
SecurityFilterChaincan be unique and can be configured in isolation."(L118), "a SecurityFilterChain might have zero security Filter instances if the application wants Spring Security to ignore certain requests." ↩︎ ↩︎출처 매핑 미확인 — "어떤 chain에도 매칭되지 않은 요청은 다음 filter로 진행하며 이는 permitAll과 다르다"는 구분은 이 페이지가 인용하는 두 소스 어디에도 grep되지 않는다. Spring Security 공식 문서의
RequestMatcher/ignoring()관련 절을 추가로 확인해야 한다. ↩︎인증 실패 응답·좋아요 중복 방지 노트 — "응답이 본문 없는 403이었다.", "그래서 프론트가 "로그인 만료"와 "권한 없음(남의 글)"을 구분할 수 없었다.", "
authenticationEntryPoint를 정해서 401 +{"code":4011,"message":"Login required."}를 주게 했다.", "누가 어디에 접근할 수 있는지(requestMatchers)는 바꾸지 않았다. 바꾼 건 막힐 때의 응답 모양뿐이다." ↩︎ ↩︎ ↩︎ ↩︎ ↩︎인증 실패 응답·좋아요 중복 방지 노트 — "
JwtAuthenticationFilter: 토큰이 이상해도 막지 않고 "익명"으로 넘긴다.", "AuthorizationFilter:authenticated()경로인데 익명이면 거부한다.", "ExceptionTranslationFilter: 익명 사용자의 거부를 받아authenticationEntryPoint를 호출한다. 따로 정해 두지 않으면 기본값은 빈 403이다." ↩︎인증 실패 응답·좋아요 중복 방지 노트 — "Security 필터도 스프링 빈이라 컨테이너 안에 있다. 막히는 지점은 컨테이너 밖이 아니라 DispatcherServlet 앞이다.", "
HttpMessageConverter가 Jackson으로 객체를 JSON으로 바꾸고 상태 코드와 Content-Type을 맞춘다.", "@RestControllerAdvice가 예외를 잡는다.", "필터 단계에서는 둘 다 아직 동작하지 않는다. 그래서 Jackson(JsonMapper)을 직접 호출해 응답을 쓴다." ↩︎ ↩︎ ↩︎ ↩︎인증 실패 응답·좋아요 중복 방지 노트 —
writeError코드("한글 메시지 깨짐 방지"), "JsonMapper는new로 만들지 않고 주입받는다. Boot가 만든 것을 써야 컨트롤러 응답과 같은 규칙으로 JSON이 나온다.", "Boot 4는 Jackson 3이라 패키지가tools.jackson이다." ↩︎ ↩︎ ↩︎