
Spring Security에서 CORS 설정을 마쳤는데도 불구하고 계속해서 CORS 에러가 발생했다. Spring Security 레퍼런스에 나와있는 설정 방법 그대로 구현을 하였음에도 계속해서 발생하는 CORS 에러... 다음은 내가 작성했던 CORS 설정 코드이다.
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(
Arrays.asList(FRONT_SERVER_URL, FRONT_LOCAL_URL, DEV_DOMAIN_URL));
configuration.addAllowedHeader("*");
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "DELETE", "PUT", "PATCH"));
configuration.addExposedHeader("*");
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
그렇게 해당 문제를 해결하기 위해 하루 종일 매달려 온갖 CORS 설정 방법이란 방법은 다 시도를 해보았지만(Spring의 장점이면서 동시에 단점이기도 한 것이 사용 방법이 너무 다양하다는 것이다.) 해결되지 않던 문제를 팀원이 불과 30분도 안되는 시간 만에 해결했다.
문제는 원인은 이전에 Spring Security를 설정하였던 팀원이 작성해놓은 필터에 있었다. 해결 방법은 매우 어이없을 정도로 간단했다. 다음 코드를 주석 처리하는 것이었다.
@Configuration
public class CorsConfig {
@Bean
public CorsFilter corsFilter() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration config = new CorsConfiguration();
config.setAllowCredentials(true);
config.addAllowedOrigin("*"); // e.g. http://domain1.com
config.addAllowedHeader("*");
config.addAllowedMethod("*");
source.registerCorsConfiguration("/**", config);
return new CorsFilter(source);
}
}
기존에 존재했던 CorsFilter 코드이다. 물론 코드 자체에는 문제가 없다. 그런데 문제는 이 코드가 존재함으로써 다른 CORS 설정들은 모두 무용지물이 된다는 것이다. 그렇다면 왜 이 코드 때문에 다른 CORS 설정이 무용지물이 될까? 아니 애초에 동시에 여러 CORS 설정이 존재하면 실제로 적용이 되는 설정은 어떻게 정해지는 거지?(당시에 이런 물음을 가지지 않았던걸 반성한다.) 해당 물음들을 해결하기 위해서는 우리가 Spring Security를 설정하기 위해 작성한 코드들이 내부적으로 어떻게 동작하여 SecurityFilterChain을 만들어내는지 알아야 한다!

출처: https://unluckyjung.github.io/spring/2022/03/12/Spring-Filter-vs-Interceptor/
너무 자세히 설명을 하게 되면 Spring의 전반적인 동작 과정과 Spring Security의 전반적인 구조를 모두 설명해야 하기에 간단하게 설명을 하자면, Client의 요청이 Spring Context의 제일 앞단에 위치한 Dispatcher Servlet(모든 요청들을 적절한 컨트롤러에게 전달해 주는 역할을 하는 프론트 컨트롤러)에 도달하기 전에 먼저 거치는 게 Filter들이다.

출처: https://docs.spring.io/spring-security/reference/servlet/architecture.html
Spring Security 공식 document에서 가져온 이미지인데 최하단의 Servelt이 Dispatcher Servlet의 인스턴스이다. 그 상단에 위치한 여러 필터 중 하나의 필터에 DelegatingFilterProxy가 존재하고 이 녀석은 내부에 FilterChainProxy를 가지고 있고 FilterChainProxy가 또 내부에 SecurityFilterChain을 가지고 있는 구조이다.
그런데 의문이 생길 수 있다. 위의 이미지를 보면 Filter는 Spring Context의 외부 영역에 존재하는데 어떻게 Spring Seucurity의 필터들을 이용할 수 있다는 걸까? 그건 바로 DelegatingFilterProxy가 Spring의 IOC 컨테이너에서 관리하는 Bean이 아니라 Servlet Filter의 구현체이며 내부에서 FilterChainProxy에게 모든 요청을 위임하고 있기 때문이다. 즉, DelegatingFilterProxy이 서블릿 컨테이너와 Spring IOC 컨테이너의 징검다리 역할을 한다고 보면 된다.
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// Lazily initialize the delegate if necessary.
Filter delegateToUse = this.delegate;
if (delegateToUse == null) {
synchronized (this.delegateMonitor) {
delegateToUse = this.delegate;
if (delegateToUse == null) {
WebApplicationContext wac = findWebApplicationContext();
if (wac == null) {
throw new IllegalStateException("No WebApplicationContext found: " +
"no ContextLoaderListener or DispatcherServlet registered?");
}
delegateToUse = initDelegate(wac);
}
this.delegate = delegateToUse;
}
}
// Let the delegate perform the actual doFilter operation.
invokeDelegate(delegateToUse, request, response, filterChain);
}
protected void invokeDelegate(
Filter delegate, ServletRequest request, ServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
delegate.doFilter(request, response, filterChain);
}
DelegatingFilterProxy의 doFilter()와 invokeDelegate() 함수이다. 위 코드를 보면 DelegatingFilterProxy의 doFilter()에서 delegate라는 필터의 doFitler()에 그대로 파라미터들을 넘기는 걸 확인할 수 있다.
아무튼 그러하여 결국에 Spring Security의 인증 & 인가 과정은 모두 이 SecurityFilterChain에서 이루어지는데 그럼 이 녀석은 어떻게 만드는가?
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf().disable()
.cors(withDefaults())
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/blog/**").permitAll()
.anyRequest().authenticated()
)
.formLogin(formLogin -> formLogin
.loginPage("/login")
.permitAll()
)
.rememberMe(Customizer.withDefaults());
return http.build();
}
}
그렇게나 열심히 Spring Security 설정을 하던 Bean 객체가 바로 SecurityFilterChain이다.
결론부터 말하자면 SecurityBuilder라는 녀석이 SecurityCofigurer들을 종합하여 만든다고 보면 된다. 위의 설정 코드를 보면 마지막에 HttpSecurity.build()를 호출하게 되는데,
public final class HttpSecurity extends AbstractConfiguredSecurityBuilder<DefaultSecurityFilterChain, HttpSecurity> implements SecurityBuilder<DefaultSecurityFilterChain>, HttpSecurityBuilder<HttpSecurity> {
}
HttpSecurity는 AbstractConfiguredSecurityBuilder라는 추상 클래스의 자식 클래스이고,
public abstract class AbstractConfiguredSecurityBuilder<O, B extends SecurityBuilder<O>> extends AbstractSecurityBuilder<O> {
이 추상 클래스는 AbstractSecurityBuilder라는 추상 클래스의 자식 클래스이다.
public final O build() throws Exception {
if (this.building.compareAndSet(false, true)) {
this.object = this.doBuild();
return this.object;
} else {
throw new AlreadyBuiltException("This object has already been built");
}
}
protected abstract O doBuild() throws Exception;
AbstractSecurityBuilder.build()는 내부에서 다시 추상 함수인 doBuild()를 호출하는데 이때,
protected final O doBuild() throws Exception {
synchronized(this.configurers) {
this.buildState = AbstractConfiguredSecurityBuilder.BuildState.INITIALIZING;
this.beforeInit();
this.init();
this.buildState = AbstractConfiguredSecurityBuilder.BuildState.CONFIGURING;
this.beforeConfigure();
this.configure();
this.buildState = AbstractConfiguredSecurityBuilder.BuildState.BUILDING;
O result = this.performBuild();
this.buildState = AbstractConfiguredSecurityBuilder.BuildState.BUILT;
return result;
}
}
AbstractConfiguredSecurityBuilder.doBuild()가 호출이 된다. 이 함수를 보면 this.init()과 this.configure() 두 함수를 호출하는데,
private void init() throws Exception {
Collection<SecurityConfigurer<O, B>> configurers = this.getConfigurers();
Iterator var2 = configurers.iterator();
SecurityConfigurer configurer;
while(var2.hasNext()) {
configurer = (SecurityConfigurer)var2.next();
configurer.init(this);
}
var2 = this.configurersAddedInInitializing.iterator();
while(var2.hasNext()) {
configurer = (SecurityConfigurer)var2.next();
configurer.init(this);
}
}
private void configure() throws Exception {
Collection<SecurityConfigurer<O, B>> configurers = this.getConfigurers();
Iterator var2 = configurers.iterator();
while(var2.hasNext()) {
SecurityConfigurer<O, B> configurer = (SecurityConfigurer)var2.next();
configurer.configure(this);
}
}
위 함수 모두 SecurityConfigurer Collection을 돌면서 각각 모두 init()과 configure()를 호출 시킨다는 걸 확인할 수 있다. 그럼 저기서 SecurityConfigurer가 무엇이냐?!
public interface SecurityConfigurer<O, B extends SecurityBuilder<O>> {
void init(B builder) throws Exception;
void configure(B builder) throws Exception;
}
이렇게 생겨먹은 인터페이스인데 intellij의 implementations의 기능을 통해 configure() 함수의 무려 36개의 구현체를 확인할 수 있는데 그 목록은 대충 다음과 같다.

굉장히 익숙한 클래스들이 보이지 않는가? 그렇다. 다시 Spring Security 설정 코드로 되돌아가서,
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf().disable()
.cors(withDefaults())
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/blog/**").permitAll()
.anyRequest().authenticated()
)
.formLogin(formLogin -> formLogin
.loginPage("/login")
.permitAll()
)
.rememberMe(Customizer.withDefaults());
return http.build();
}
}
아무 생각 없이 인터넷의 코드들을 참고해가며 builder pattern을 통해 설정하던 csrf(), cors() 등과 같은 함수들의 return type이 모두 저 SecurityConfigurer의 구현체들이다.
public CsrfConfigurer<HttpSecurity> csrf() throws Exception {
ApplicationContext context = this.getContext();
return (CsrfConfigurer)this.getOrApply(new CsrfConfigurer(context));
}
public CorsConfigurer<HttpSecurity> cors() throws Exception {
return (CorsConfigurer)this.getOrApply(new CorsConfigurer());
}
이 질문을 해결하기 위해서는 실제로 CORS 설정이 적용되는 CorsConfigurer 클래스를 살펴봐야 한다. 앞서 설명했다시피 이 클래스는 SecurityConfigurer의 구현체이고 SecurityBuilder는 이 구현체들의 Collection을 iterator로 순회하며 configure()라는 함수를 호출한다. CorsConfigurer의 configure()는 다음과 같다.
public void configure(H http) {
ApplicationContext context = (ApplicationContext)http.getSharedObject(ApplicationContext.class);
CorsFilter corsFilter = this.getCorsFilter(context);
Assert.state(corsFilter != null, () -> {
return "Please configure either a corsFilter bean or a corsConfigurationSourcebean.";
});
http.addFilter(corsFilter);
}
위 함수를 보면 this.getCorsFilter(context)를 통해 CorsFilter를 가져와 addFilter()로 추가한다. 그럼 getCorsFilter()를 보자.
private CorsFilter getCorsFilter(ApplicationContext context) {
if (this.configurationSource != null) {
return new CorsFilter(this.configurationSource);
} else {
boolean containsCorsFilter = context.containsBeanDefinition("corsFilter");
if (containsCorsFilter) {
return (CorsFilter)context.getBean("corsFilter", CorsFilter.class);
} else {
boolean containsCorsSource = context.containsBean("corsConfigurationSource");
if (containsCorsSource) {
CorsConfigurationSource configurationSource = (CorsConfigurationSource)context.getBean("corsConfigurationSource", CorsConfigurationSource.class);
return new CorsFilter(configurationSource);
} else {
boolean mvcPresent = ClassUtils.isPresent("org.springframework.web.servlet.handler.HandlerMappingIntrospector", context.getClassLoader());
return mvcPresent ? CorsConfigurer.MvcCorsFilter.getMvcCorsFilter(context) : null;
}
}
}
}
코드를 보면 this.configurationSource가 존재하면 얘를 통해 CorsFilter를 만든다. 하지만 configurationSource는 configurationSource() 함수를 통해 할당해 주지 않으면 null 상태이므로 else 문으로 넘어가는데 이때 우선적으로 컨테이너에서 "corsFilter"라는 이름의 Bean이 있는지를 확인하여 이를 CorsFilter로 형변환을 하여 return을 하게 된다.
그렇다. 이전에 팀원이 작성해놓은 corsFilter라는 이름의 Bean이 컨테이너에 존재하는 한, 아무리 CORS 설정을 바꿔가며 시도를 해봐도 씨알도 먹히지 않는 것은 당연했다...!
근데 이럴 거면 왜 document에선 CorsConfigurationSource로 설정하는 방법만을 안내하냐는 거... CorsFilter로 하는 설정을 안내해 줬으면 개고생할 일도 없었는데! 아니 사실 이건 핑계다... 내가 Spring 코드를 뜯어볼 생각만 했어도 해결이 됐을 문제였다!
이전까지의 나에게 스프링의 코드를 말 그대로 불가침의 영역이었다... 너무나도 복잡해 보였던 스프링의 코드들을 뜯어보고 그 내부 구조를 이해하는 것은 시도하는 것조차 꺼려지는 일이었다. 하지만 이번 트러블 슈팅을 계기로 코드를 찬찬히 뜯어보면서 깨달은 점은 생각보다 읽을만하다는 점과 정말 객체지향적으로 잘 짜여 있다는 점이었다! 단순히 Spring Security의 내부 구조를 파악하는 데에 그치지 않고 객체지향적인 설계에 대한 좋은 예시 코드를 제대로 읽어본 것 같아 좋은 경험이었다!
(추가로 아마추어의 관점에서 구조를 파악하다 보니 내용에 오류가 있을 수 있습니다. 그런 내용들을 발견하게 되시면 댓글이나 메일로 알려주시길 바랍니다!!)
좋은 글 감사합니다. 잘 읽고 가요