최근 티켓 예매 프로젝트에서 JWT 로그인을 붙이기 전에, Spring Security 기본 설정과 인증 실패 응답을 먼저 정리하게 되었다.
이 글에서 다룰 개념은 다음과 같다.
Spring Security
SecurityFilterChain
AuthenticationEntryPoint / AccessDeniedHandler
@EnableMethodSecurity
처음 접하면 의존성만 추가해도 보안이 "알아서" 적용되기 때문에, 굳이 설정 클래스를 만들어야 하는 이유가 잘 보이지 않는다.
하지만 핵심은 기본 설정이 폼 로그인과 세션 중심이라서, 토큰으로 인증하는 REST API 서버에는 그대로 맞지 않는다는 점이다.
간단히 정리하면
- Spring Security : 서블릿 필터 체인으로 인증과 인가를 처리하는 프레임워크
- SecurityFilterChain : 어떤 요청에 어떤 보안 규칙을 적용할지 정의하는 빈
- AuthenticationEntryPoint : 인증되지 않은 요청을 응답으로 바꾸는 진입점 (401)
- AccessDeniedHandler : 인증은 됐지만 권한이 없는 요청을 처리하는 핸들러 (403)
- @EnableMethodSecurity : @PreAuthorize 같은 메서드 단위 인가를 켜는 어노테이션
의존성만 추가 → 모든 요청 인증 필요, 폼 로그인/HTTP Basic 적용
REST API 서버 → SecurityFilterChain 빈을 직접 등록해 기본 설정을 대체
로그인 안 된 요청 → AuthenticationEntryPoint
권한이 부족한 요청 → AccessDeniedHandler
메서드 단위 권한 검사 → @EnableMethodSecurity + @PreAuthorize
Spring Security
Spring Security는 인증(Authentication)과 인가(Authorization)를 서블릿 필터 단계에서 처리하는 프레임워크다.
인증은 "누구인지 확인하는 것"이고, 인가는 "그 사용자가 이 요청을 해도 되는지 판단하는 것"이다.
요청은 다음처럼 컨트롤러에 닿기 전에 필터 체인을 먼저 통과한다.
HTTP 요청
→ DelegatingFilterProxy
→ FilterChainProxy (등록된 SecurityFilterChain 중 하나를 고른다)
→ SecurityFilterChain (인증, 인가, 예외 변환 필터들)
→ DispatcherServlet
→ Controller
서블릿 컨테이너는 자체 표준으로 필터를 등록할 뿐 Spring 빈을 알지 못한다. 그래서 DelegatingFilterProxy가 실제 처리를 Spring 빈인 FilterChainProxy에 넘긴다.
또한 SecurityFilterChain이 여러 개여도 요청과 처음 매칭되는 체인 하나만 실행된다.
즉, 필터 체인의 보안 판단은 모두 DispatcherServlet 앞에서 일어난다.
SecurityFilterChain
SecurityFilterChain은 기본 보안 설정을 대체하고, 요청별 보안 규칙을 직접 정의할 때 사용한다.
예를 들어 spring-boot-starter-security만 추가하면 Spring Boot 자동 설정이 다음 동작을 적용한다.
- 모든 요청에 인증을 요구한다.
user라는 사용자 하나를 만들고, 랜덤 비밀번호를 WARN 로그로 출력한다.Accept헤더에 따라 폼 로그인 또는 HTTP Basic이 적용된다.- POST 같은 상태 변경 요청에 CSRF 보호가 걸린다.
- Spring Boot의
/error엔드포인트도 보호 대상에 들어간다.
실제로 프로젝트에서 받은 응답에도 다음과 같은 보안 헤더가 기본으로 붙어 있었다.
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
하지만 프론트엔드가 따로 있고 토큰으로 인증하는 REST API 서버라면 로그인 화면, 세션, 랜덤 비밀번호가 필요 없다.
그래서 다음과 같이 SecurityFilterChain 빈을 직접 등록한다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final CustomAuthenticationEntryPoint authenticationEntryPoint;
private final CustomAccessDeniedHandler accessDeniedHandler;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.logout(AbstractHttpConfigurer::disable)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.dispatcherTypeMatchers(DispatcherType.FORWARD, DispatcherType.ERROR).permitAll()
.anyRequest().authenticated())
.exceptionHandling(exception -> exception
.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler))
.build();
}
}
SecurityFilterChain 빈을 등록하는 순간 Spring Boot의 기본 웹 보안 설정은 꺼지고, 이 빈의 규칙만 적용된다.
각 설정의 의미는 다음과 같다.
csrf 비활성화 → 쿠키가 아닌 헤더 토큰으로 인증할 예정이라 끈다
formLogin/httpBasic/logout → 로그인 화면과 Basic 인증, 세션 로그아웃을 쓰지 않는다
STATELESS → 인증 정보를 세션에 저장하지도, 세션에서 꺼내지도 않는다
FORWARD/ERROR 디스패치 허용 → 에러 디스패치가 다시 인가에 걸려 401로 덮이는 것을 막는다
anyRequest().authenticated() → 나머지는 모두 인증된 사용자만 허용한다
CSRF에 대해 공식 문서는 "브라우저 트래픽을 받지 않는 백엔드는 비활성화를 선택할 수 있다"고 안내한다. 다만 이 프로젝트는 브라우저에서 호출되므로 문장이 그대로 들어맞지는 않는다.
그래서 끈 근거는 브라우저가 자동으로 실어 보내는 쿠키 대신 Authorization 헤더 토큰으로 인증한다는 점이다. 쿠키 세션으로 로그인하는 구조로 바뀐다면 다시 켜야 한다.
STATELESS는 Javadoc 기준으로 Spring Security가 세션을 만들지 않고, SecurityContext를 세션에서 가져오지도 않는다는 뜻이다. 중요한 점은 매 요청마다 인증 정보를 새로 확인해야 한다는 것이다.
/error 처리도 짚어둘 만하다. AuthorizationFilter는 일반 요청뿐 아니라 FORWARD, ERROR 디스패치에도 동작하기 때문에, 예외가 나서 /error로 넘어갈 때 인가를 한 번 더 받는다.
이 디스패치를 막아두면 원래 500 에러가 401로 바뀌어 원인을 알 수 없게 된다. 공식 문서는 dispatcherTypeMatchers(DispatcherType.ERROR).permitAll()을 해법으로 제시한다.
프로젝트에서는 처음에 requestMatchers("/error").permitAll() 로 경로를 열었다
→ 공식 문서에 근거가 있는 방식은 dispatcherTypeMatchers 쪽이라 위 예시는 그 방식으로 적었다
또한 Spring Security 7부터는 and() 체이닝과 authorizeRequests가 제거되어, 위처럼 람다 DSL과 authorizeHttpRequests로 작성해야 한다. WebSecurityConfigurerAdapter를 상속하는 방식은 이미 5.7에서 deprecated, 6.0에서 제거되었다.
즉, SecurityFilterChain은 "이 서버는 어떤 방식으로 인증하는가"를 코드로 선언하는 곳이다.
AuthenticationEntryPoint / AccessDeniedHandler
AuthenticationEntryPoint와 AccessDeniedHandler는 인증, 인가 실패를 HTTP 응답으로 바꿀 때 사용한다.
이 둘을 호출하는 것은 필터 체인 안의 ExceptionTranslationFilter다. 판단 기준은 다음과 같다.
인증되지 않은 사용자 / AuthenticationException → AuthenticationEntryPoint (401)
인증된 사용자의 AccessDeniedException → AccessDeniedHandler (403)
예를 들어 실패 응답을 {code, errorCode, message} 형식으로 통일한 프로젝트라면, 401과 403도 같은 형식으로 내려야 한다.
프로젝트에서는 이 형식을 @RestControllerAdvice인 GlobalExceptionHandler에서 만들고 있었다. 하지만 ExceptionTranslationFilter는 DispatcherServlet보다 앞에서 동작하므로, 이 단계의 실패는 컨트롤러 계층의 예외 처리 흐름 밖에 있다.
그래서 두 핸들러가 직접 JSON을 쓰지 않고, 예외를 HandlerExceptionResolver에 넘겨 GlobalExceptionHandler 한 곳에서 응답을 만들도록 했다.
@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final HandlerExceptionResolver handlerExceptionResolver;
// 같은 타입의 빈(DefaultErrorAttributes)이 있어 이름으로 지정한다.
public CustomAuthenticationEntryPoint(
@Qualifier("handlerExceptionResolver") HandlerExceptionResolver handlerExceptionResolver) {
this.handlerExceptionResolver = handlerExceptionResolver;
}
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) {
handlerExceptionResolver.resolveException(request, response, null, authException);
}
}
resolveException을 호출하면 필터에서도 @ExceptionHandler 메서드를 찾아 실행한다. CustomAccessDeniedHandler도 같은 방식으로 AccessDeniedException을 넘긴다.
따라서 GlobalExceptionHandler에는 다음과 같이 두 예외 처리를 추가하면 된다.
@ExceptionHandler(AuthenticationException.class)
public ResponseEntity<ErrorDetail> handleAuthenticationException(AuthenticationException e) {
return toResponse(CommonErrorCode.UNAUTHORIZED);
}
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ErrorDetail> handleAccessDeniedException(AccessDeniedException e) {
return toResponse(CommonErrorCode.FORBIDDEN);
}
그 결과 로그인하지 않고 보호된 API를 호출하면 GlobalExceptionHandler를 거쳐 다음처럼 응답한다. 프로젝트의 @WebMvcTest와 실제 서버 호출에서 모두 같은 결과를 확인했다.
HTTP/1.1 401
Content-Type: application/json
{"code":401,"errorCode":"UNAUTHORIZED","message":"인증이 필요합니다."}
즉, 두 핸들러는 "실패를 어떤 모양으로 내려줄지"를 정하는 곳이고, 직접 설정하지 않으면 다른 API 실패 응답과 형식이 달라진다.
다만, 핸들러에서 JSON을 직접 쓰는 방법도 흔하다. 위임 방식을 고른 이유는 실패 응답을 만드는 곳을 한 군데로 유지하기 위해서였다.
@EnableMethodSecurity
추가적으로 메서드 단위 인가와의 관계를 알아보려고 한다!
코드 리뷰에서 "나중에 @PreAuthorize를 쓰면 권한 부족이 500으로 나갈 수 있다"는 지적을 받으면서 살펴보게 되었다.
@EnableMethodSecurity는 @PreAuthorize("hasRole('ADMIN')")처럼 메서드마다 권한을 검사할 때 사용한다. Spring Boot 스타터는 이 기능을 기본으로 켜지 않으므로 @Configuration 클래스에 직접 붙여야 한다.
권한 검사에 실패하면 기본적으로 AuthorizationDeniedException을 던지며, 이 클래스는 AccessDeniedException의 하위 클래스다.
중요한 점은 이 예외가 필터가 아니라 컨트롤러 메서드를 호출하는 과정에서 던져진다는 것이다. 그래서 구조상 @ExceptionHandler가 먼저 이 예외를 만나게 된다.
예를 들어 catch-all 핸들러만 있다면 다음처럼 처리된다.
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorDetail> handleException(Exception e) {
return toResponse(CommonErrorCode.INTERNAL_SERVER_ERROR);
// AccessDeniedException 처리가 따로 없으면 권한 부족도 여기서 500이 된다.
}
앞에서 GlobalExceptionHandler에 AccessDeniedException 처리를 미리 넣어둔 이유가 이것이다. 프로젝트 테스트에서는 컨트롤러 안에서 AccessDeniedException을 직접 던져 403으로 응답하는 것까지 확인했고, 실제 @PreAuthorize 경로는 메서드 시큐리티를 켤 때 다시 확인할 예정이다.
다만, permitAll 경로에 @PreAuthorize를 붙이면 로그인하지 않은 사용자도 필터를 통과해 이 경로로 들어온다. 이때는 필터의 "인증 안 된 사용자는 401" 판단을 거치지 않아 403을 받게 되는데, 이를 401로 나눠야 할지는 고민이 남는다.
즉, 메서드 시큐리티를 켜는 순간 인가 실패는 필터가 아닌 @ExceptionHandler의 책임이 된다.
정리
네 가지 모두 "요청이 컨트롤러에 닿기 전후로 보안을 어떻게 다룰지"를 정하는 도구다. 차이는 어느 단계에서, 무엇을 결정하느냐에 있다.
| 구분 | 동작 단계 | 역할 | 설정하지 않으면 |
|---|---|---|---|
SecurityFilterChain |
필터 체인 | 인증 방식, 경로별 접근 규칙 | 폼 로그인, HTTP Basic, 세션 기반 기본값 적용 |
AuthenticationEntryPoint |
필터 체인 (ExceptionTranslationFilter가 호출) |
미인증 요청의 응답 (401) | 공통 실패 응답 형식과 다른 응답 |
AccessDeniedHandler |
필터 체인 (ExceptionTranslationFilter가 호출) |
권한 부족 요청의 응답 (403) | 공통 실패 응답 형식과 다른 응답 |
@EnableMethodSecurity |
메서드 호출 시점 | 메서드 단위 권한 검사 | 기능이 꺼져 있음 |
Spring Security를 직접 설정해야 하는 이유는 결국 기본값이 우리 서버의 인증 방식과 응답 형식을 모르기 때문이라고 생각한다.
서버가 HTML 로그인 화면을 제공하는가 → 기본 설정에 가깝게 유지
토큰 인증 REST API 서버인가 → SecurityFilterChain으로 STATELESS + 폼 로그인 제거
실패 응답 형식을 통일했는가 → EntryPoint / AccessDeniedHandler 직접 등록
메서드 단위 권한이 필요한가 → @EnableMethodSecurity + AccessDeniedException 처리 확인'Back-End > Spring' 카테고리의 다른 글
| [OAuth 2.0] OAuth 2.0은 무엇이고 왜 사용할까 (0) | 2026.10.23 |
|---|---|
| [Spring Boot] @EnableJpaAuditing이란? (0) | 2026.10.23 |
| [JPA] N+1 문제란 무엇일까? (0) | 2025.12.02 |
| @RequestParam, @ModelAttribute, @RequestBody는 언제 사용할까? (0) | 2025.11.24 |
| [JPA] @ManyToOne, @OneToMany 연관관계 정리 (0) | 2025.11.22 |