본문 바로가기
💻 트러블슈팅 (에러 해결)

로그인 성공했는데 403 에러? 스프링 시큐리티 6 (Spring Boot 3) CSRF와 /error 3분 해결법

by 데브프리 2026. 8. 12.

안녕하세요, 여러분의 든든한 IT 길잡이 데브프리입니다.
 
스프링 부트(Spring Boot) 3.x 버전으로 프로젝트를 세팅하고 JWT(JSON Web Token) 기반의 로그인을 멋지게 구현했습니다. 포스트맨(Postman)으로 로그인 API를 찔러보니 200 OK와 함께 토큰이 예쁘게 발급되네요.
 
"좋아, 인증은 완벽해!"라고 생각하며 발급받은 토큰을 헤더에 넣고 게시글 작성(POST) API를 호출해 봅니다. 그런데 웬걸, 화면에 시뻘건 403 Forbidden 에러가 튀어나옵니다. 분명히 인증(Authentication)을 통과했는데, 서버는 왜 저의 접근을 단호하게 거부하는 걸까요?
 
스프링 시큐리티(Spring Security) 6.x 버전으로 넘어오면서 기본 보안 정책이 훨씬 엄격해졌습니다.
오늘은 실무에서 REST API를 개발할 때 초보 개발자들의 머리카락을 쥐어뜯게 만드는 403 에러의 양대 산맥인 'CSRF 방어'와 '/error 라우팅' 문제를 객관적으로 분석하고 단 3분 만에 해결하는 베스트 프랙티스를 정리해 드립니다.
 

1. 실전 트러블슈팅 ①: 범인은 CSRF (Cross-Site Request Forgery)

GET 요청(목록 조회 등)은 기가 막히게 잘 되는데, POST, PUT, DELETE 요청만 보냈다 하면 403 에러가 터지는 상황이라면 99.9% 확률로 범인은 CSRF(교차 사이트 요청 위조) 방어 기능입니다.

왜 터졌을까? (기본값의 함정)

스프링 시큐리티는 기본적으로 웹 서비스의 보안을 위해 CSRF 공격 방어 기능이 켜져(Enabled) 있습니다. CSRF 방어가 켜져 있으면, 서버 상태를 변화시키는 위험한 요청(POST, PUT, DELETE 등)이 들어올 때 반드시 헤더나 파라미터에 유효한 'CSRF 토큰'이 포함되어 있어야만 통과시켜 줍니다.
 
여러분이 포스트맨으로 POST 요청을 보낼 때 당연히 CSRF 토큰을 같이 보내지 않았을 테니, 시큐리티 필터가 "이거 악성 사이트에서 날아온 위조 요청 아냐?"라고 의심하며 403 Forbidden 에러를 뱉고 쫓아낸 것입니다.

해결 과정: REST API에서는 CSRF를 비활성화하세요

만약 여러분이 타임리프(Thymeleaf)나 JSP 같은 서버 사이드 렌더링(SSR) 화면을 만들고, 세션(Session) 기반으로 로그인을 유지한다면 CSRF 기능은 반드시 켜두어야 합니다.
 
하지만 우리가 만드는 것은 React, Vue, 모바일 앱 등과 통신하며 JWT를 사용하는 순수 'REST API' 서버입니다. REST API는 상태를 저장하지 않는 무상태성(Stateless)을 가지며, 브라우저의 쿠키/세션을 통한 자동 인증에 의존하지 않기 때문에 태생적으로 CSRF 공격으로부터 안전합니다.
 
따라서 최신 람다(Lambda) DSL 문법을 사용하여 시큐리티 설정(SecurityFilterChain)에서 과감하게 CSRF를 비활성화(Disable) 처리해 주면 됩니다.

Java

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            // 💡 REST API 환경이므로 CSRF 방어 기능을 안전하게 해제합니다.
            .csrf(AbstractHttpConfigurer::disable)
            // 💡 JWT를 사용하므로 세션을 무상태(STATELESS)로 설정합니다.
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()
                .anyRequest().authenticated()
            );
            
        return http.build();
    }
}

무상태(Stateless) REST API 환경에서의 CSRF 필터 우회 비유
쿠키와 세션에 의존하지 않는 순수 REST API 아키텍처에서는 CSRF 방어벽을 해제(Disable)하여 클라이언트의 POST 요청이 원활하게 통과하도록 설계하는 것이 표준 프랙티스입니다.

 

2. 실전 트러블슈팅 ②: 숨겨진 함정, /error 디스패치

CSRF를 껐는데도 특정 상황(예: 잘못된 파라미터 전송, 존재하지 않는 URL 접근 등)에서 400(Bad Request)이나 404(Not Found) 에러가 아니라 엉뚱하게 403 Forbidden이 튀어나오는 경우가 있습니다.

왜 터졌을까? (스프링 부트의 에러 처리 메커니즘)

이것은 스프링 부트 3.x 와 스프링 시큐리티 6.x 가 만나면서 발생하는 아주 교묘한 '내부 포워딩' 이슈입니다.

  1. 클라이언트가 데이터를 잘못 보내서 컨트롤러 내부나 필터에서 예외(Exception)가 터집니다.
  2. 스프링 부트는 기본적으로 에러가 발생하면, 에러 정보를 응답하기 위해 내부적으로 /error 경로로 자동 리다이렉트(포워딩)합니다.
  3. 이때 스프링 시큐리티 필터가 개입합니다. "어? 누군가 /error 경로에 접근하려고 하네? 여기 로그인한 사람만 들어갈 수 있는 곳이야!"라며 접근을 막아버립니다.

결국 원래 터졌어야 할 진짜 에러(404, 400 등)는 온데간데없이 사라지고, /error 경로에 대한 접근 권한이 없다는 이유로 403 Forbidden 에러로 덮어씌워져 버리는 대참사가 일어납니다.

해결 과정: Error Dispatcher 허용하기

이 문제를 해결하려면 시큐리티 설정에서 "스프링 부트 내부에서 발생하는 에러 처리(DispatcherType.ERROR) 목적의 라우팅은 시큐리티 검사 없이 통과시켜라"라고 명시해 주어야 합니다.

Java

import jakarta.servlet.DispatcherType;

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        .csrf(AbstractHttpConfigurer::disable)
        .authorizeHttpRequests(auth -> auth
            // 💡 에러 발생 시 내부로 포워딩되는 DispatcherType.ERROR 요청을 허용합니다.
            .dispatcherTypeMatchers(DispatcherType.ERROR).permitAll()
            
            // (또는 아래처럼 경로 자체를 열어두는 방법도 있습니다)
            // .requestMatchers("/error").permitAll()
            
            .requestMatchers("/api/auth/**").permitAll()
            .anyRequest().authenticated()
        );
        
    return http.build();
}

이렇게 DispatcherType.ERROR를 permitAll() 처리해 주면, 시큐리티가 불필요하게 403으로 에러를 덮어씌우는 현상이 사라지고 원래 발생했던 정확한 상태 코드(400, 404, 500 등)를 클라이언트(프론트엔드)에게 안전하게 전달할 수 있습니다.
 

3. 전문가의 확고한 결론 (Verdict)

CSRF 비활성화(Disable), 실무에 언제 적용하고 언제 피해야 할까요?

  • 👍 확실하게 꺼야 할 때 (비활성화 필수): 브라우저의 쿠키-세션 로그인 구조를 아예 사용하지 않고, 오로지 HTTP 헤더(Authorization)에 JWT 토큰을 담아서 통신하는 백엔드 REST API 서버라면 주저 없이 CSRF 방어 기능을 해제하세요. 애초에 공격자가 여러분의 세션(쿠키)을 탈취하거나 활용할 수 있는 아키텍처 자체가 아니기 때문에 방어벽을 세워둘 이유가 전혀 없습니다.
  • 🛑 끄면 절대 안 될 때 (유지 권장): 스프링 부트 내부에서 타임리프(Thymeleaf), 머스태치(Mustache), JSP 등을 이용해 서버 사이드 렌더링(SSR) 웹페이지를 직접 제공하고, 톰캣의 JSESSIONID 쿠키를 통해 로그인을 유지하는 관리자 백오피스(Admin)나 전통적인 웹 서비스라면 CSRF를 비활성화하는 것은 자살 행위와 같습니다. 이때는 폼(Form) 전송 시 반드시 CSRF 토큰을 히든 필드에 심어서 서버로 함께 보내야만 해커의 위조 요청 공격으로부터 시스템을 지킬 수 있습니다.

 
💡 [데브프리의 실무 꿀팁: 포스트맨은 되는데 리액트에선 403이 뜬다고요? 'CORS'를 의심하세요!]
"포스트맨(Postman)에서는 POST 요청이 기가 막히게 잘 되는데, 프론트엔드 개발자가 로컬(localhost:3000)에서 호출하면 403 에러가 뜬다고 난리예요!"
이 현상은 백엔드의 CSRF 문제가 아닙니다. 바로 브라우저의 보안 정책인 CORS(Cross-Origin Resource Sharing) 때문입니다.
포스트맨은 브라우저가 아니기 때문에 CORS 제약을 무시하고 통신하지만, 브라우저는 도메인이나 포트가 다르면 사전 요청(Preflight, OPTIONS 메서드)을 먼저 날려서 서버의 허락을 구합니다.
만약 시큐리티 설정에서 CORS 설정을 해주지 않거나, OPTIONS 메서드를 시큐리티가 막아버리면 브라우저는 이를 403(또는 CORS Network Error)으로 처리해 버립니다.
따라서 REST API 서버라면 시큐리티 설정에 http.cors(Customizer.withDefaults())를 추가하고, CorsConfigurationSource 빈(Bean)을 등록하여 허용할 프론트엔드 도메인 주소를 명확히 열어주어야만 프론트엔드와의 완벽한 평화를 유지할 수 있습니다!
 
스프링 시큐리티는 무작정 오류를 내뿜는 불친절한 프레임워크가 아닙니다. 프레임워크가 왜 이 요청을 차단했는지 '기본 방어 원리'만 이해한다면, 무작정 구글링으로 복사-붙여넣기 하던 403 에러도 논리적으로 풀어낼 수 있습니다.

 

👉 프론트엔드랑 연결할 때 에러가 난다면? 1초 만에 끝내는 스프링 부트 CORS 설정 코드 생성기 [링크]