본문 바로가기
Spring

[Spring Security 6] permitAll인데도 401이 발생했던 이유 (JSP 렌더링 이슈)

by soro.k 2026. 7. 18.

출처 : https://sitechecker.pro/what-is-401-status-code/

 

0. 들어가기 전에

외부 본인인증 API를 연동하면서 관련 경로는 인증 없이 접근이 가능하도록 열어두어야 했다. 외부에서 콜백이 들어오는 구조이다 보니 JWT 토큰 없이도 접근 가능한 엔드포인트가 필요했기 때문이다. 그래서 Spring Security 설정에서 해당 경로를 permitAll()로 처리했다.

 

하지만 예상과 다르게 401 에러가 발생했다. 실제 로그를 보면 아래와 같이 컨트롤러까지는 정상적으로 진입하고, 외부 API 호출도 문제없이 수행되는데 JSP가 렌더링 되는 순간 인증이 깨지면서 요청이 막혔다. 처음에는 JwtFilter나 Security 설정 자체를 의심했지만, 로그를 따라가다 보니 문제의 원인은 다른 곳에 있었다.

 

DEBUG o.s.security.web.FilterChainProxy : Securing GET /{외부서비스}/phone-cert/popup
...
INFO  ... {외부서비스}Controller 본인인증 팝업 자동 시작
...
DEBUG o.s.security.web.FilterChainProxy : Securing GET /WEB-INF/views/phone_popup2.jsp

 

 

 

1. 문제 상황

먼저 Security 설정을 살펴보자. 앞서 설명했듯이 외부 API 관련 경로는 인증이 필요하지 않도록 설정했고, 그 외 요청은 기존처럼 AuthorizationManager를 통해 권한을 검증하도록 했다.

.requestMatchers("/{외부서비스}/**").permitAll()
.anyRequest().access(adminAuthorizationManager)

 

 

그리고 실제 요청 흐름을 보면 다음과 같이 JSP 경로가 별도의 요청처럼 다시 Security 필터 체인을 타면서 에러가 발생했다.

  • "/{외부서비스}/phone-cert/popup" 요청은 정상적으로 처리된다.
  • 컨트롤러 로직도 문제없이 수행된다.
  • JSP 렌더링 시점에서 401 에러가 발생한다.

 

 

2. 원인

1) permitAll의 의미

Spring Security에서 permitAll()은 필터 체인을 건너뛰는 설정이 아니라, 인가 단계에서 요청을 허용하도록 하는 설정이다. 즉, 요청은 여전히 Security Filter Chain을 그대로 통과하고, JwtFilter나 AnonymousAuthenticationFilter 같은 필터들도 동일하게 실행되지만 마지막 인가 단계에서만 차단하지 않는다.

 

2) 필터 체인에서 일어나는 일

Spring Security는 Servlet Filter 기반으로 동작하며, 요청은 여러 필터를 순차적으로 거치게 된다. 일반적인 흐름은 다음과 같다.

SecurityContextPersistenceFilter
          ⇩
인증 필터 (예: JwtFilter)
          ⇩
AnonymousAuthenticationFilter
          ⇩
AuthorizationFilter

 

JWT가 없는 요청의 경우 인증 필터에서는 별도의 Authentication 객체를 만들지 않는다. 이 상태에서 AnonymousAuthenticationFilter가 동작하면서 SecurityContext에 AnonymousAuthenticationToken을 채운다.

이 때문에 요청은 인증되지 않은 상태가 아니라, anonymous로 인증된 상태가 된다. 이후 AuthorizationFilter에서 AuthorizationManager가 이 Authentication 객체를 기준으로 접근 여부를 판단하게 된다.

 

 

3) JSP 렌더링 과정에서의 요청

문제의 핵심은 JSP 렌더링 과정이었다. Spring MVC에서 컨트롤러가 뷰 이름을 반환하면 ViewResolver가 이를 실제 JSP 경로로 변환한다.

return "phone_popup2";

 

이 값은 "/WEB-INF/views/phone_popup2.jsp"로 매핑된다. 이 과정에서 클라이언트가 새로운 HTTP 요청을 보내는 것이 아니라, 서버 내부에서 RequestDispatcher.forward()가 호출된다. 그러니까 처음 "/{외부서비스}/**" 요청은 일반적인 REQUEST 타입이지만, JSP 렌더링은 FORWARD 타입의 요청으로 처리되는 것이다.

 

우리는 "/{외부서비스}/**" 경로만 permitAll()로 열어둔 상태였다. 하지만 JSP 렌더링 시 발생하는 요청 경로는 "/WEB-INF/views/...jsp"이기 때문에 "/{외부서비스}/**" 패턴에 포함되지 않는다. 결국 이 요청은 다음 설정으로 흘러간다.

.anyRequest().access(adminAuthorizationManager)

 

이 시점에서 SecurityContext에는 anonymous Authentication이 들어있고, AuthorizationManager는 이를 실제 인증 사용자로 보지 않기 때문에 접근을 거부하게 된다. 그 결과 401 또는 403 에러가 발생하게 되는 것이다.

 

 

3. DispatcherType을 통한 해결

이 문제는 경로가 아니라 요청의 성격을 기준으로 접근해야 한다. Spring Security 6에서는 DispatcherType 기준으로 인가를 제어할 수 있는데, JSP 렌더링처럼 서버 내부에서 발생하는 요청은 대부분 FORWARD 타입이다. 또한 에러 페이지 렌더링은 ERROR 타입으로 처리된다. 따라서 다음과 같이 설정하면 문제를 해결할 수 있다.

.dispatcherTypeMatchers(FORWARD, ERROR).permitAll()

 

전체 설정은 다음과 같은 형태가 된다.

.authorizeHttpRequests(registry ->
    registry
        .dispatcherTypeMatchers(FORWARD, ERROR).permitAll()
        .requestMatchers("/{외부서비스}/**").permitAll()
        .anyRequest().access(adminAuthorizationManager)
)

 

이렇게 하면 "/{외부서비스}/**" 요청뿐 아니라 JSP 렌더링 과정에서 발생하는 내부 forward 요청도 인가 단계에서 차단되지 않는다.

 

대안으로 "/WEB-INF/**" 경로를 직접 permitAll()로 열어주는 방법도 생각할 수 있다. 하지만 이 방식은 뷰 경로 구조에 강하게 의존하게 되고, JSP 외 다른 내부 리소스까지 노출될 가능성이 있다. 반면 DispatcherType을 기준으로 처리하면 경로가 아닌 요청의 성격으로 제어하게 되기 때문에 보다 일반적이고 안정적인 방식이 된다.

 

 

 ✨ Tip

에러가 발생할 경우 Spring MVC는 내부적으로 ERROR 타입의 디스패치를 통해 에러 페이지를 렌더링 한다. 만약 ERROR 디스패치가 보안에서 차단되면, 에러 페이지 렌더링 자체가 다시 보안에 걸리면서 반복적으로 에러가 발생할 수 있다.
이 때문에 실무에서는 FORWARD와 ERROR를 함께 permitAll로 처리하는 경우가 많다.

 

 

 

4. 마무리

이번 글에서는 permitAll()의 동작 방식과 JSP 렌더링 과정에서 발생하는 FORWARD 요청이 Security Filter Chain에 어떤 영향을 주는지 살펴보았다. 특히 permitAll()은 필터 체인을 우회하는 설정이 아니라 인가 단계에서만 요청을 허용한다는 점, 그리고 JSP 렌더링과 같은 내부 요청은 DispatcherType에 따라 별도의 인가 과정을 거칠 수 있다는 점이 이번 이슈의 핵심이었다.

Spring Security에서 예상과 다른 인증 문제가 발생한다면 URL 패턴만 확인하기보다 DispatcherType과 실제 요청 흐름을 함께 확인해 보는 것이 문제를 해결하는 데 도움이 되겠다.