※ 시리즈 글
Spring Security, 직접 만들면서 이해해보자! - 1편: 아키텍처 이해
Spring Security, 직접 만들면서 이해해보자! - 2편: 인증, 인가, 예외 처리
Spring Security, 직접 만들면서 이해해보자! - 3편: FilterChainProxy와 Lambda DSL [현재 글]
들어가며
안녕하세요! 저번 글에는 Spring Security의 인증, 인가, 예외 처리 등에 대해서 직접 구현해봤습니다. 이어서 이번 글에서는 FilterChainProxy와 Lambda DSL에 대해서 구현해보겠습니다. 마찬가지로 구현 과정은 목차를 확인하시고 순서대로 따라가시면 됩니다.
FilterChainProxy
목표
현재 구조에서 특정 경로에 대해 인증 필터, 인가 필터를 계속 추가하게 된다면 등록되는 필터는 늘어나고 등록 순서를 관리하기 힘들어집니다. 또한 진입점이 다양하기 때문에 디버깅에 대한 어려움도 있습니다. 이러한 문제들을 해결하기 위해서 Spring Security는 FilterChainProxy를 사용합니다.
이번 단계에서 구현될 내용은 step-7-filter-chain-proxy을 확인하시면 됩니다.
FilterChainProxy
FilterChainProxy는 요청 경로에 매칭되는 SecurityFilterChain을 찾아 해당 체인에 정의된 필터들을 실행합니다. 이때 실제 필터 실행은 VirtualFilterChain을 통해 이루어지며, 이는 FilterChain 인터페이스의 구현체입니다. 지금까지 우리가 작성한 예제는 경로가 "/api/**"인 하나의 SecurityFilterChain을 구성한 것이라 볼 수 있습니다.
왼쪽 그림에 보이는 FilterChain은, 현재 필터가 처리를 마친 후 다음 필터로 요청을 넘기는 연결고리 역할을 합니다. 이를 구현한 VirtualFilterChain은 SecurityFilterChain에 등록된 필터들을 내부에 담고, 이를 순차적으로 실행합니다. FilterChainProxy는 RequestMatcherDelegatingAuthorizationFilter와 유사하게, 요청 경로에 매칭되는 SecurityFilterChain을 찾아 해당 필터들을 실행한다고 이해하시면 됩니다.
먼저 SecurityFilterChain을 구현해보겠습니다.
// 경로 /security/filter/SecurityFilterChain
@RequiredArgsConstructor
public class SecurityFilterChain {
private final RequestMatcher matcher;
private final List<Filter> filters;
public boolean matches(HttpServletRequest request) {
return matcher.matches(request);
}
public List<Filter> getFilters() {
return new ArrayList<>(filters);
}
}
SecurityFilterChain은 RequestMatcherDelegatingAuthorizationFilter와 동일하게 RequestMatcher를 가지고, 실행될 Filter들의 목록을 필드로 가집니다.
// 경로 /security/filter/VirtualFilterChain
public class VirtualFilterChain implements FilterChain {
private final List<Filter> filters;
private final FilterChain originalChain;
private int currentPosition;
public VirtualFilterChain(List<Filter> filters, FilterChain originalChain) {
this.filters = filters;
this.originalChain = originalChain;
this.currentPosition = 0;
}
@Override
public void doFilter(ServletRequest request, ServletResponse response) throws IOException, ServletException {
if (this.currentPosition == filters.size()) {
originalChain.doFilter(request, response);
return;
}
this.currentPosition++;
Filter nextFilter = filters.get(currentPosition - 1);
nextFilter.doFilter(request, response, this);
}
}
VirtualFilterChain은 FilterChain 인터페이스의 구현체로, 실행할 필터 목록과 실제(진짜) FilterChain을 함께 받아 생성됩니다. 내부적으로는 등록된 필터들을 순서대로 실행하고, 모든 필터 실행이 끝나면 실제 FilterChain으로 흐름을 넘기는 구조입니다. 즉, 보안 필터 실행을 담당하는 가상의 필터 체인 역할을 하며, 최종적으로 요청은 원래의 서블릿 처리 흐름으로 이어집니다.
// 경로 /security/filter/FilterChainProxy
@RequiredArgsConstructor
public class FilterChainProxy extends GenericFilterBean {
private final List<SecurityFilterChain> filterChains;
@Override
public void doFilter(
ServletRequest servletRequest,
ServletResponse servletResponse,
FilterChain chain
) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
for (SecurityFilterChain filterChain : filterChains) {
if (filterChain.matches(request)) {
List<Filter> filters = filterChain.getFilters();
VirtualFilterChain virtualFilterChain = new VirtualFilterChain(filters, chain);
virtualFilterChain.doFilter(request, response);
return;
}
}
chain.doFilter(request, response);
}
}
FilterChainProxy는 등록된 SecurityFilterChain들을 반복문을 통해 순회하면서 매칭되는 경로를 찾습니다. 그리고 그에 해당하는 SecurityFilterChain의 필터들로 VirtualFilterChain을 생성하고 실행합니다. 매칭되는 경로가 없을 경우 다음으로 넘깁니다.
위에서 설명한 것처럼, VirtualFilterChain으로 가상의 필터 흐름을 만들어서 실행한 후, 실제 흐름으로 넘깁니다.
FilterChainProxy 등록
// 경로 /security/SecurityConfig
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
private static final String FILTER_CHAIN_PROXY_BEAN_NAME = "filterChainProxy";
private final TokenProvider tokenProvider;
private final TokenResolver tokenResolver;
private final AuthService authService;
private final AuthenticationEntryPoint authenticationEntryPoint;
private final AccessDeniedHandler accessDeniedHandler;
@Bean
public SecurityFilterChain securityFilterChain() {
AuthorizationManager authenticated =
(authentication, request) -> new AuthorizationDecision(authentication.get() != null);
RequestMatcherDelegatingAuthorizationManager authorizationManager = new RequestMatcherDelegatingAuthorizationManager()
.add(new RequestMatcher(null, "/api/private/admin/**"), hasAuthority("ADMIN"))
.add(new RequestMatcher(null, "/api/**"), authenticated);
List<Filter> filters = List.of(
new SecurityContextHolderFilter(),
new JwtAuthenticationFilter(tokenProvider, tokenResolver, authService, authenticationEntryPoint),
new ExceptionTranslationFilter(authenticationEntryPoint, accessDeniedHandler),
new AuthorizationFilter(authorizationManager)
);
return new SecurityFilterChain(new RequestMatcher(null, "/api/**"), filters);
}
@Bean(name = FILTER_CHAIN_PROXY_BEAN_NAME)
public FilterChainProxy filterChainProxy(List<SecurityFilterChain> securityFilterChains) {
return new FilterChainProxy(securityFilterChains);
}
@Bean
public DelegatingFilterProxyRegistrationBean securityFilterChainRegistration() {
DelegatingFilterProxyRegistrationBean registration =
new DelegatingFilterProxyRegistrationBean(FILTER_CHAIN_PROXY_BEAN_NAME);
registration.setOrder(1);
registration.addUrlPatterns("/*");
registration.setDispatcherTypes(EnumSet.allOf(DispatcherType.class));
return registration;
}
}
먼저, SecurityFilterChain Bean을 보겠습니다. 여기에는 지금까지 만들었던 필터들과 RequestMatcher로 SecurityFilterChain을 생성하고 있습니다. 이 SecurityFilterChain은 "/api/**"에 대해서 매칭됩니다.
다음으로, FilterChainProxy Bean은 SecurityFilterChain 목록을 주입받아 만들어집니다. 이 의미는, 매칭되는 경로에 대한 필터 조합을 실행시킬 수 있음을 의미합니다.
마지막으로 DelegatingFilterProxyRegistrationBean에 대해 설명드리겠습니다. 자세한 내용은 아래에서 다시 다루겠고, 우선 45번째 줄을 보겠습니다. 이 코드는 모든 DispatcherType에 대해 필터가 동작하도록 등록하는 부분입니다. 앞서 설명드린 OncePerRequestFilter 기억나시나요? FORWARD나 다른 DispatcherType의 요청에서도 필터가 작동해야 하기 때문에, EnumSet.allOf(DispatcherType.class)를 통해 모든 타입을 명시해주었습니다. 예를 들어, A 요청에서 인가 필터를 거친 후 B로 forward 되는 경우, FORWARD 타입이 등록되어 있지 않으면 인가 필터가 동작하지 않게 되고, 이로 인해 보안 문제가 발생할 수 있기 때문입니다.
DelegatingFilterProxy
DelegatingFilterProxyRegistrationBean은 FilterRegistrationBean과 유사하게, DelegatingFilterProxy를 쉽게 등록할 수 있도록 도와주는 역할을 합니다. DelegatingFilterProxy는 서블릿 컨테이너와 Spring의 ApplicationContext 사이를 연결하는 중요한 다리 역할을 하는 필터입니다. 서블릿 컨테이너는 필터를 등록할 수 있지만, Spring이 관리하는 Bean에는 접근할 수 없습니다. 이 문제를 해결하기 위해 DelegatingFilterProxy는 서블릿 컨테이너에는 자신을 필터처럼 등록하고, 실제 작업은 Spring ApplciationContext에 등록된 FilterBean에게 위임하는 방식으로 동작합니다.
DelegatingFilterProxy는 생성 시 FilterBean의 이름을 인자로 받아 해당 이름의 Bean을 찾아 실행합니다. DelegatingFilterProxy의 중요한 장점은 필터 빈 인스턴스를 지연 로딩할 수 있다는 점입니다. 일반적으로 서블릿 컨테이너는 시작할 때 필터 인스턴스를 필요로 하지만, Spring은 보통 ContextLoaderListener 등을 통해 ApplicationContext를 나중에 초기화합니다. 이 타이밍 차이 때문에 일반적인 필터 등록 방식은 문제가 될 수 있습니다. 하지만 DelegatingFilterProxy는 실제 FilterBean을 나중에 찾아 실행하므로, Spring ApplicationContext가 준비된 후에도 필터를 안전하게 사용할 수 있습니다.
참고로, FilterRegistrationBean은 필터 인스턴스를 직접 등록하므로, Spring Context가 초기화되기 전에도 인스턴스가 필요합니다. 즉, 지연 로딩이 불가능합니다. 현재 예제에서는 FilterRegistrationBean으로도 정상적으로 작동합니다.
필터 등록 시 주의할 점
현재 코드에서는 필터들을 직접 생성하여 SecurityFilterChain에 명시적으로 추가하고 있습니다. 그런데 만약 이 필터들을 @Component나 @Configuration 클래스 내의 @Bean으로 등록할 경우, Spring Boot가 이를 감지하여 서블릿 컨테이너에 자동으로 등록하게 됩니다. 이 경우, 해당 필터는 SecurityFilterChain을 통해 한 번, 서블릿 컨테이너를 통해 또 한 번 실행되어 필터가 중복 실행되는 문제가 발생할 수 있습니다.
결과
이번 Step에서는 FilterChainProxy를 구현함으로서써 인증 필터, 인가 필터의 다양한 조합을 SecurityFilterChain으로 등록할 수 있게 되었습니다. TestController와 테스트 코드는 변경되지 않았습니다. 기존 테스트가 정상적으로 돌아가는지 확인합니다.
Lambda DSL
목표
지금까지 인증, 인가, 예외 처리, FilterChainProxy 등 다양한 것을 만들면서 모든 요구사항을 만족시킬 수 있었습니다. 하지만 프레임워크로서 가독성이 떨어지고, 사용법이 어렵습니다. 이 문제를 해결하기 위해서 Lambda DSL을 간단하게 도입하려 합니다.
이번 단계에서 구현될 내용은 step-8-lambda-dsl을 확인하시면 됩니다.
Lambda DSL이란?
Lambda DSL은 람다 표현식을 사용해 직관적이고 선언적인 방식으로 도메인 특화 언어(DSL: Domain Specific Language)를 구성하는 방법입니다. 예를 들어, JAVA 8의 Stream, Collector나 QueryDSL이 있습니다.
먼저 최종 결과를 보여드리면, 아래와 같이 Spring Security의 DSL을 간단하게 적용하는 것이 목표입니다.
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) {
JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(
tokenProvider,
tokenResolver,
authService,
authenticationEntryPoint
);
return http
.securityMatcher("/api/**")
.addFilterBefore(jwtAuthenticationFilter)
.exceptionHandling(it -> it
.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler)
)
.authorizeHttpRequests(it -> it
.requestMatchers(HttpMethod.GET, "/api/posts/**").permitAll()
.requestMatchers("/api/private/member/**").hasAuthority("MEMBER")
.requestMatchers("/api/private/admin/**").hasAuthority("ADMIN")
.anyRequest().authenticated())
.build();
}
- securityMatcher: 생성된 SecurityFilterChain이 적용될 경로(기본 값: "/**")
- addFilterBefore: 필터를 앞에 추가, 인증 필터를 추가하기 위함.
- exceptionHandling: 커스텀 예외 처리를 등록하기 위함 (기본 값: AuthenticationEntryPointImpl, AccessDeniedImpl)
- authorizeHttpRequest: 접근 제어들을 등록
- requestMatchers: 특정 http method, pattern에 대한 접근 제어
- permitAll: 인증되지 않아도 접근 가능
- hasAuthority: 접근에 특정 권한이 필요
- authernticated: 접근에 인증이 필요
- anyRequest: requestMatchers 모든 http method, 모든 경로에 대한 requestMatchers
addFilterBefore은 Spring Security의 메서드를 간소화한 버전입니다. Spring Security의 경우 특정 Filter 앞뒤에 지정해서 필터를 추가할 수 있습니다. Spring Security의 경우 Filter마다 순서(Order)가 정해져 있기에 가능합니다.
HttpSecurity
HttpSecurity는 SecurityFilterChain을 만드는 핵심 클래스입니다. ExceptionHandlingConfigurer과 AuthorizeHttpRequestConfigurer을 통해서 예외와 인가를 커스텀할 수 있습니다.
// 경로 /security/dsl/HttpSecurity
@Component
public class HttpSecurity {
private final List<Filter> filters;
private RequestMatcher requestMatcher;
private final ExceptionHandlingConfigurer exceptionHandlingConfigurer;
private final AuthorizeHttpRequestsConfigurer authorizeHttpRequestsConfigurer;
public HttpSecurity(AuthenticationEntryPoint entryPoint, AccessDeniedHandler deniedHandler) {
this.filters = new ArrayList<>();
this.filters.add(new SecurityContextHolderFilter());
this.requestMatcher = RequestMatcher.ANY_REQUEST;
this.exceptionHandlingConfigurer = new ExceptionHandlingConfigurer(entryPoint, deniedHandler);
this.authorizeHttpRequestsConfigurer = new AuthorizeHttpRequestsConfigurer();
}
public HttpSecurity securityMatcher(String pattern) {
this.requestMatcher = new RequestMatcher(null, pattern);
return this;
}
public HttpSecurity securityMatcher(HttpMethod method, String pattern) {
this.requestMatcher = new RequestMatcher(method, pattern);
return this;
}
public HttpSecurity addFilterBefore(Filter filter) {
filters.addFirst(filter);
return this;
}
public HttpSecurity addFilter(Filter filter) {
filters.add(filter);
return this;
}
public HttpSecurity exceptionHandling(Consumer<ExceptionHandlingConfigurer> consumer) {
consumer.accept(exceptionHandlingConfigurer);
return this;
}
public HttpSecurity authorizeHttpRequests(
Consumer<AuthorizeHttpRequestsConfigurer.AuthorizationManagerRequestMatcherRegistry> consumer
) {
consumer.accept(authorizeHttpRequestsConfigurer.getRegistry());
return this;
}
public SecurityFilterChain build() {
exceptionHandlingConfigurer.configure(this);
authorizeHttpRequestsConfigurer.configure(this);
return new SecurityFilterChain(requestMatcher, filters);
}
}
앞서 설명한 것처럼 HttpSecurity는 SecurityFilterChain을 생성하는 역할을 합니다. 내부의 RequestMatcher 필드는 기본적으로 "/**"를 의미하는 ANY_REQUEST로 설정되어 있으며, securityMatcher 메서드를 통해 변경할 수 있습니다. List<Filter> 필드는 SecurityFilterChain에서 실행될 필터 목록을 나타내며, SecurityContextHolderFilter는 항상 가장 먼저 실행되어야 하므로 생성자에서 기본적으로 추가됩니다.
exceptionHandling와 authorizeHttpRequests는 각각 예외 처리와 인가를 구성하는 메서드입니다. 이들은 각각 ExceptionHandlingConfigurer, AuthorizeHttpRequests 클래스 내부에서 관련 구성을 수행하고, 마지막에 호출되는 build() 메서드 내 configure를 통해 List<Filter>에 해당 필터들을 추가합니다. 각 configurer는 configure시 HttpSecurity의 addFilter() 메서드를 호출하여 필터를 등록합니다.
// 경로 /security/dsl/ExceptionHandlingConfigurer
public class ExceptionHandlingConfigurer {
private AuthenticationEntryPoint authenticationEntryPoint;
private AccessDeniedHandler accessDeniedHandler;
public ExceptionHandlingConfigurer(
AuthenticationEntryPoint authenticationEntryPoint,
AccessDeniedHandler accessDeniedHandler
) {
this.authenticationEntryPoint = authenticationEntryPoint;
this.accessDeniedHandler = accessDeniedHandler;
}
public void configure(HttpSecurity http) {
ExceptionTranslationFilter exceptionTranslationFilter = new ExceptionTranslationFilter(
authenticationEntryPoint,
accessDeniedHandler
);
http.addFilter(exceptionTranslationFilter);
}
public ExceptionHandlingConfigurer authenticationEntryPoint(
AuthenticationEntryPoint authenticationEntryPoint
) {
this.authenticationEntryPoint = authenticationEntryPoint;
return this;
}
public ExceptionHandlingConfigurer accessDeniedHandler(
AccessDeniedHandler accessDeniedHandler
) {
this.accessDeniedHandler = accessDeniedHandler;
return this;
}
}
ExceptionHandlingConfigurer에는 authenticationEntryPoint와 accessDeniedHandler를 통해 커스텀 예외 처리를 사용할 수 있습니다. 위에서 말했듯이, configure 시 ExceptionTranslationFilter를 생성하고 HttpSecurity의 addFilter 메서드를 호출합니다.
// 경로 /security/dsl/AuthorizeHttpRequestsConfigurer
public class AuthorizeHttpRequestsConfigurer {
private static final AuthorizationManager permitAllAuthorizationManager =
(authentication, request) -> new AuthorizationDecision(true);
private static final AuthorizationManager authenticatedAuthorizationManager =
(authentication, request) -> new AuthorizationDecision(authentication.get() != null);
private final AuthorizationManagerRequestMatcherRegistry registry;
public AuthorizeHttpRequestsConfigurer() {
this.registry = new AuthorizationManagerRequestMatcherRegistry();
}
public void configure(HttpSecurity http) {
AuthorizationManager delegatingAuthorizationManager = this.registry.authorizationManager();
AuthorizationFilter authorizationFilter = new AuthorizationFilter(delegatingAuthorizationManager);
http.addFilter(authorizationFilter);
}
public AuthorizationManagerRequestMatcherRegistry getRegistry() {
return this.registry;
}
private AuthorizationManagerRequestMatcherRegistry addMapping(
AuthorizationManager manager,
List<RequestMatcher> matchers
) {
for (RequestMatcher matcher : matchers) {
this.registry.addMapping(matcher, manager);
}
return this.registry;
}
public class AuthorizationManagerRequestMatcherRegistry {
private final RequestMatcherDelegatingAuthorizationManager manager = new RequestMatcherDelegatingAuthorizationManager();
public AuthorizedUrl anyRequest() {
return new AuthorizedUrl(RequestMatcher.ANY_REQUEST);
}
public AuthorizedUrl requestMatchers(String... patterns) {
return requestMatchers(null, patterns);
}
public AuthorizedUrl requestMatchers(HttpMethod method, String... patterns) {
List<RequestMatcher> matchers = Arrays.stream(patterns)
.map(pattern -> new RequestMatcher(method, pattern))
.toList();
return new AuthorizedUrl(matchers);
}
public AuthorizationManager authorizationManager() {
return manager;
}
private void addMapping(RequestMatcher matcher, AuthorizationManager authorizationManager) {
manager.add(matcher, authorizationManager);
}
}
public class AuthorizedUrl {
private final List<RequestMatcher> matchers;
public AuthorizedUrl(RequestMatcher... matchers) {
this(List.of(matchers));
}
public AuthorizedUrl(List<RequestMatcher> matchers) {
this.matchers = matchers;
}
public AuthorizationManagerRequestMatcherRegistry permitAll() {
return access(permitAllAuthorizationManager);
}
public AuthorizationManagerRequestMatcherRegistry authenticated() {
return access(authenticatedAuthorizationManager);
}
public AuthorizationManagerRequestMatcherRegistry hasAuthority(String authority) {
return access(AuthorityAuthorizationManager.hasAuthority(authority));
}
public AuthorizationManagerRequestMatcherRegistry access(AuthorizationManager manager) {
return AuthorizeHttpRequestsConfigurer.this.addMapping(manager, matchers);
}
}
}
AuthorizeHttpRequestsConfigurer에는 두 개의 핵심 내부 클래스인 AuthorizationManagerRequestMatcherRegistry, AuthorizedUrl이 존재합니다.
AuthorizationManagerRequestMatcherRegistry는 RequestMatcherDelegatingAuthorizationManager를 통해 경로별 인가 정책을 관리합니다. 다시 한 번 리마인드 하자면, 이 AuthorizationManager는 여러 경로(RequestMatcher)와 그에 대응하는 AuthorizationManager를 저장해 두고, 실제 요청이 들어왔을 때 해당 경로와 매칭되는 AuthorizationManager를 찾아 실행하는 역할을 합니다. 경로 설정은 requestMatchers 메서드를 통해 이루어지며, requestMatchers로 받은 경로들은 내부적으로 AuthorizeUrl 객체를 생성해 반환합니다.
AuthorizeUrl은 지정된 경로(RequestMatcher)들을 보관하며, permitAll, authenticated, hasAuthority등의 메서드를 통해 인가 정책을 설정할 수 있습니다. 각 메서드는 적절한 AuthorizationManager를 생성하여 addMapping 메서드를 통해 경로와 함께 등록합니다. 이 등록된 정보는 최종적으로 AuthorizationFilter에서 사용되어 요청에 대한 접근 권한을 판단합니다.
AuthorizeHttpRequestsConfigurer도 ExceptionHandlingConfigurer과 마찬가지로 configure 시 AuthorizationFilter 객체를 생성하고, addFilter를 통해서 Filter 목록에 등록합니다.
HttpSecurity 적용
// 경로 /security/SecurityConfig
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
private static final String FILTER_CHAIN_PROXY_BEAN_NAME = "filterChainProxy";
private final TokenProvider tokenProvider;
private final TokenResolver tokenResolver;
private final AuthService authService;
private final AuthenticationEntryPoint authenticationEntryPoint;
private final AccessDeniedHandler accessDeniedHandler;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) {
JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(
tokenProvider,
tokenResolver,
authService,
authenticationEntryPoint
);
return http
.securityMatcher("/api/**")
.addFilterBefore(jwtAuthenticationFilter)
// exceptionHandling은 기본 값이므로 사용하지 않아도 된다.
.exceptionHandling(it -> it
.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler)
)
.authorizeHttpRequests(it -> it
.requestMatchers(HttpMethod.GET, "/api/posts/**").permitAll()
.requestMatchers("/api/private/member/**").hasAuthority("MEMBER")
.requestMatchers("/api/private/admin/**").hasAuthority("ADMIN")
.anyRequest().authenticated())
.build();
}
@Bean(name = FILTER_CHAIN_PROXY_BEAN_NAME)
public FilterChainProxy filterChainProxy(List<SecurityFilterChain> securityFilterChains) {
return new FilterChainProxy(securityFilterChains);
}
@Bean
public DelegatingFilterProxyRegistrationBean securityFilterChainRegistration() {
DelegatingFilterProxyRegistrationBean registration =
new DelegatingFilterProxyRegistrationBean(FILTER_CHAIN_PROXY_BEAN_NAME);
registration.setOrder(1);
registration.addUrlPatterns("/*");
registration.setDispatcherTypes(EnumSet.allOf(DispatcherType.class));
return registration;
}
}
기존에는 SecurityFilterChain을 직접 생성해서 필터를 등록했지만, 이제는 HttpSecurity를 사용하여 보다 선언적이고 명확하게 인가 정책을 구성할 수 있습니다. 다양한 인가 정책도 간편하게 등록할 수 있으며, 가독성도 훨씬 좋아졌습니다.
추가로, 다른 SecurityFilterChain을 등록하고 싶다면, 별도의 SecurityFilterChain Bean을 생성하고 HttpSecurity를 통해 구성하면 됩니다. 이때 securityMatcher()를 활용해 적용 범위를 다르게 설정하거나, @Order 애너테이션을 사용해 필터 체인의 우선순위를 조절할 수 있습니다.
결과
위의 SecurityConfig에서 다양한 인가 정책을 추가했고, TestController도 모든 요구사항을 테스트할 수 있게 변경했습니다. 테스트가 성공적으로 통과되는지 확인합니다.
Handler Method Argument Resolver
목표
이번 단계에서는 컨트롤러의 메서드 파라미터에서 인증 정보를 쉽게 꺼내 사용할 수 있도록 해보겠습니다. 현재까지는 인증 정보를 사용하려면 SecurityContextHolder에서 꺼내 써야 했습니다. 이를 개선하기 위해 Spring MVC의 Handler Method Arguemnt Resolver를 활용해보겠습니다.
아래는 목표로 하는 모습입니다.
@PostMapping("/api/posts")
public String createPost(@AuthenticationPrincipal JwtAuthentication authentication) {
return "인증된 사용자: 게시물 생성 #" + authentication.principal();
}
이번 단계에서 구현될 내용은 step-8-handler-method-argument-resolver 확인하시면 됩니다.
AuthenticationPrincipal
// 경로 /security/annotation
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface AuthenticationPrincipal {
}
먼저 AuthenticationPrincipal이라는 어노테이션 인터페이스를 만들어줍니다.
// 경로 /security/annotation
@Component
public class AuthenticationArgumentResolver implements HandlerMethodArgumentResolver {
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.hasParameterAnnotation(AuthenticationPrincipal.class);
}
@Override
public Authentication resolveArgument(
MethodParameter parameter,
ModelAndViewContainer mavContainer,
NativeWebRequest webRequest,
WebDataBinderFactory binderFactory
) {
return SecurityContextHolder.getContext().getAuthentication();
}
}
HandlerMethodArguemntResolver를 구현한 AuthenticatinoArgumentResolver를 만들어줍니다. 여기서는 단순히 SecurityContextHolder에서 값을 꺼내 반환해줍니다.
// 경로 /security/WebMvcConfig
@Configuration
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {
private final AuthenticationArgumentResolver authenticationArgumentResolver;
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(authenticationArgumentResolver);
}
}
만든 ArguemntResovler를 사용하기 위해서 addArguemntResovlers 메서드로 등록해줍니다.
결과
만든 AuthenticationPrincipal이 정상적으로 작동하는지 변경된 TestController와 테스트 코드를 통해서 확인합니다.
마치며
Spring Security를 직접 구현해보기 위한 모든 Step이 끝났습니다. Spring Security를 직접 구현해보면서 인증, 인가, 예외 처리뿐만 아니라 좋은 아키텍처에 대한 이해, Filter에 대한 깊은 이해를 배웠던 것 같습니다. 직접 만든 Spring Security이 요구 사항을 모두 만족했는지 다시 한 번 리마인드하고 글을 마치겠습니다.
- 인증 정보는 ThreadLocal을 통해 어디서든 접근 가능해야 한다.
- JWT 토큰을 이용해 인증 상태를 검증할 수 있어야 한다.
- 특정 경로에 대해 권한 기반 접근 제어가 가능해야 한다.
- 인증 실패 시 401, 권한 부족 시 403 상태 코드를 반환해야 한다.
- 인가는 하나의 필터에서 일괄적으로 처리되어야 한다.
- FilterChainProxy로 경로별 필터 체인을 구성할 수 있어야 한다.
- 보안 설정은 람다 DSL 방식으로 직관적으로 작성 가능해야 한다.
지긤까지 읽어주셔서 감사합니다!
참고
'서버' 카테고리의 다른 글
Spring Security, 직접 만들면서 이해해보자! - 2편: 인증, 인가, 예외 처리 (0) | 2025.05.02 |
---|---|
Spring Security, 직접 만들면서 이해해보자! - 1편: 아키텍처 이해 (1) | 2025.04.30 |
로컬 환경과 CI 환경의 시간 정밀도 차이로 인한 테스트 실패 해결 (0) | 2025.04.01 |
Presigned URL과 CDN으로 이미지 업로드 & 조회 개선 (0) | 2025.03.07 |
테스트 후 데이터 정리를 통해 테스트 간 데이터 독립성 보장 (0) | 2025.03.03 |