스프링 시큐리티(Spring Security)는 자바 개발자에게 필수적인 보안 프레임워크입니다. 하지만 처음 접하면 설정의 복잡성 때문에 302 Found, 403 Forbidden 같은 이해하기 어려운 오류들과 자주 마주하게 되죠. 제가 직접 겪었던 디버깅 과정을 작성하고자 합니다.
302 Found, 그 정체는?저는 /api/posts라는 새로운 REST API를 만들고 Postman으로 POST 요청을 보냈습니다. 그런데 예상과 달리 200 OK 대신 302 Found 오류가 발생했고, 응답 헤더의 Location 필드는 저를 /login 페이지로 안내하고 있었습니다.
formLogin의 함정스프링 시큐리티는 기본적으로 웹 애플리케이션에 최적화되어 있습니다. 따라서 **formLogin**이라는 필터가 자동으로 활성화되는데, 이 필터의 주된 역할은 사용자가 인증되지 않았을 때 로그인 페이지로 리다이렉트시키는 것입니다.
포스트맨으로 보낸 API 요청은 당연히 로그인 정보를 담고 있지 않았습니다. 따라서 formLogin 필터는 요청을 차단하고 "로그인하세요!"라는 의미로 저를 /login 페이지로 보내버린 것이죠. API는 HTML 페이지를 받아 리다이렉트하는 것이 아닌, 상태 코드와 데이터만 주고받아야 하는데 말이죠.
SecurityFilterChain 빈(Bean)**을 허용하므로, API만을 위한 필터 체인과 웹을 위한 필터 체인을 따로 만들기로 했습니다. API용 필터 체인에는 formLogin을 비활성화하는 formLogin(form -> form.disable()) 설정을 추가했습니다.302가 사라지니 403 Forbidden이 나타났다formLogin 필터를 비활성화하자 마침내 302 오류가 사라졌습니다. 하지만 이번에는 403 Forbidden 오류가 저를 기다리고 있었습니다. SecurityConfig에 /api/** 경로를 permitAll()로 설정했음에도 말이죠.
@AuthenticationPrincipal과 permitAll()의 충돌403 Forbidden은 "인증은 되었지만, 해당 리소스에 접근할 권한이 없다"는 의미의 오류입니다. 그런데 저는 로그인한 적이 없으니 인증이 될 리가 없는데, 왜 이 오류가 발생했을까요? 원인은 바로 API 컨트롤러 메서드에 있었습니다.
// PostRestController.java
@PostMapping
public String createPost(@RequestBody PostCreateRequestDto dto,
@AuthenticationPrincipal UserDetails userDetails) {
// ... userDetails.getUsername() 사용 ...
}
permitAll()은 '로그인 여부와 관계없이' 접근을 허용합니다. 따라서 요청은 컨트롤러 메서드까지 도달했습니다.
하지만 메서드의 @AuthenticationPrincipal 파라미터는 **'로그인한 사용자의 정보'**를 요구합니다.
익명 사용자가 접근했기 때문에 userDetails가 null이 되었고, userDetails.getUsername() 같은 코드를 실행하는 순간 NullPointerException이 발생합니다.
스프링 시큐리티는 이 예외를 가로채서 403 상태 코드로 변환한 것입니다.
해결책: createPost 메서드에서 @AuthenticationPrincipal 파라미터를 제거하고, 게시글 작성자를 임시로 데이터베이스에 있는 특정 사용자(userRepository.findById(1L))로 지정했습니다. 이 API는 익명 사용자의 글쓰기를 허용하는 API였으므로, 로그인 정보를 요구하는 로직 자체가 잘못된 것이었죠.
302와 필터 체인 우선순위위의 두 가지 문제를 해결하고 다시 실행했을 때, 302 Found 오류가 또다시 저를 괴롭혔습니다. SecurityFilterChain을 분리하고 @Order를 부여했음에도 말이죠.
스프링 시큐리티는 여러 개의 SecurityFilterChain 빈이 있을 때, 요청 URL에 맞는 가장 구체적인 필터를 먼저 적용합니다. 하지만 제 코드에서는 이 순서가 예상과 다르게 작동하고 있었습니다.
// apiFilterChain
http.securityMatcher("/api/**") // API 전용 필터
// ...
// webFilterChain
http.securityMatcher("/**") // 모든 경로에 대한 필터
// ...
이 두 필터는 POST /api/posts 요청에 모두 매칭될 수 있습니다. Spring Boot는 apiFilterChain이 더 구체적이므로 먼저 적용해야 하지만, 내부적인 로딩 순서나 설정 미스로 인해 webFilterChain이 먼저 동작하는 경우가 발생할 수 있습니다. webFilterChain은 formLogin 필터를 포함하고 있으므로, API 요청을 가로채 다시 302 리다이렉트를 일으킨 것입니다.
apiFilterChain을 가장 단순하고 명확하게 만들었습니다. 다른 설정들과 충돌할 여지가 있는 anyRequest().authenticated()를 제거하고, anyRequest().permitAll()만 남겼습니다. 또한, apiFilterChain에 formLogin(form -> form.disable())을 명시적으로 추가하여, 설령 요청이 잘못 매칭되더라도 로그인 페이지로 리다이렉트되는 것을 원천적으로 차단했습니다.@Configuration
@EnableWebSecurity
public class SecurityConfig {
// ...
@Bean
@Order(1) // 우선순위 1
public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/**")
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll()) // ✅ API 요청은 무조건 허용
.formLogin(form -> form.disable()) // ✅ 로그인 폼 비활성화
.httpBasic(basic -> basic.disable());
return http.build();
}
// ...
}
200 OK를 만나다! 🎉수많은 오류와 디버깅 끝에 마침내 200 OK 응답을 받았습니다. 이 경험을 통해 스프링 시큐리티 디버깅의 핵심 원칙을 깨달았습니다.
302 Found 오류는 단순한 리다이렉트 문제가 아니라, 내부 로직의 충돌이나 예외가 겉으로 드러난 현상일 수 있습니다.SecurityFilterChain을 분리하고 @Order를 사용해 우선순위를 명확히 해야 합니다.formLogin, httpBasic 등 인증 필터를 명시적으로 비활성화하는 것이 가장 안전합니다.이 글이 여러분의 302 Found 디버깅에 큰 도움이 되기를 바랍니다.