T_era
스프링 시큐리티 인증 및 인가 흐름 본문

스프링 시큐리티는 복잡한 인증(Authentication) 및 인가(Authorization) 과정을 여러 컴포넌트를 통해 체계적으로 처리한다.
- 클라이언트 요청: 사용자가 리소스 접근을 위해 요청을 보낸다.
- 인증 객체 생성: 요청에서 **사용자 이름(username)**과 **비밀번호(password)**를 추출하여 Authentication 객체를 생성한다.
- AuthenticationManager를 통한 인증 수행:
- AuthenticationManager의 기본 구현체인 ProviderManager가 요청된 Authentication 객체를 처리할 적절한 AuthenticationProvider를 선택한다.
- AuthenticationProvider의 인증 로직 수행:
- 선택된 AuthenticationProvider (예: DaoAuthenticationProvider for DB 인증, AnonymousAuthenticationProvider for 익명 사용자)가 실제 인증 로직을 수행한다.
- 사용자 조회 및 인증 객체 생성/반환: AuthenticationProvider는 데이터베이스 등에서 사용자 정보를 조회하고, 인증에 성공하면 사용자의 정보를 담은 인증된 Authentication 객체를 생성하여 반환한다.
- SecurityContextHolder에 인증 객체 저장: 인증이 완료된 Authentication 객체는 SecurityContextHolder에 저장된다. SecurityContextHolder는 애플리케이션 어디서든 현재 인증된 사용자 정보에 접근할 수 있도록 하는 저장소 역할을 한다.


- AuthorizationFilter의 인가 조회: 요청이 AuthorizationFilter에 도달하면, SecurityContextHolder에서 현재 인증된 Authentication 객체를 조회한다.
- AuthorizationManager의 인가 규칙 실행: AuthorizationManager는 authorizeHttpRequests에 정의된 URL 패턴 및 HTTP 메서드 규칙과 현재 요청을 매칭하고, 해당 인가 규칙을 실행한다.
- 인가 결과:
- 인가 거부 시: AccessDeniedException이 발생하며, 설정된 AccessDeniedHandler에 의해 처리된다.
- 인가 성공 시: 요청은 정상적으로 처리되어 다음 필터나 컨트롤러로 넘어간다.
스프링 시큐리티와 JWT 설정 예시
JWT 기반 인증을 위한 스프링 시큐리티 설정 예시를 통해 위에서 설명한 흐름이 어떻게 코드에 적용되는지 살펴보겠다.
// SecurityConfig.class
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtUtil jwtUtil;
private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;
private final CustomAccessDeniedHandler customAccessDeniedHandler;
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity
.cors(Customizer.withDefaults()) // CORS 설정 (프론트엔드와 협업 시 필요)
.csrf(AbstractHttpConfigurer::disable) // CSRF 보호 비활성화 (Stateless REST API에선 불필요)
.httpBasic(AbstractHttpConfigurer::disable) // HTTP Basic 인증 비활성화
.formLogin(AbstractHttpConfigurer::disable) // HTML Form 로그인 비활성화
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 세션 사용 안 함 (JWT 기반이므로)
)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/login").permitAll() // "/login" 경로는 인증 없이 허용
.requestMatchers("/logout").authenticated() // "/logout" 경로는 인증된 사용자만 허용
.requestMatchers(HttpMethod.POST, "/post/admin").hasRole(UserRole.ADMIN.name()) // POST "/post/admin"은 ADMIN 역할만 허용
.requestMatchers(HttpMethod.POST, "/post/user").hasRole(UserRole.USER.name()) // POST "/post/user"는 USER 역할만 허용
.anyRequest().denyAll() // 위 조건을 제외한 모든 요청은 거부
)
// 필터 등록: JwtFilter를 UsernamePasswordAuthenticationFilter 이전에 추가
.addFilterBefore(new JwtFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class)
.exceptionHandling(configurer -> // 예외 처리 설정
configurer
.authenticationEntryPoint(customAuthenticationEntryPoint) // 인증 실패 시 처리할 핸들러
.accessDeniedHandler(customAccessDeniedHandler) // 인가 실패 시 처리할 핸들러
)
.build();
}
}
설정 항목 설명
- cors(Customizer.withDefaults()): CORS(Cross-Origin Resource Sharing) 설정을 활성화한다. 백엔드 자체에서 발생하는 문제는 아니지만, 웹 브라우저 기반의 프론트엔드와 연동 시 교차 출처 요청을 허용하기 위해 필요하다.
- csrf(AbstractHttpConfigurer::disable): CSRF(Cross-Site Request Forgery) 보호를 비활성화한다. JWT와 같은 Stateless REST API에서는 세션을 사용하지 않으므로 CSRF 공격으로부터 상대적으로 안전하여 비활성화하는 것이 일반적이다.
- httpBasic(AbstractHttpConfigurer::disable): HTTP Basic 인증 방식을 비활성화한다.
- formLogin(AbstractHttpConfigurer::disable): HTML 폼 기반 로그인을 비활성화한다.
- sessionManagement(...):
- sessionCreationPolicy(SessionCreationPolicy.STATELESS): 세션을 사용하지 않도록 설정한다. JWT는 클라이언트 측에서 토큰을 관리하므로 서버에 세션 상태를 유지할 필요가 없다.
- authorizeHttpRequests(...): 요청에 대한 인가(Authorization) 규칙을 정의한다.
- requestMatchers(URI).permitAll(): 해당 URI에 대한 인증 및 인가 검사를 생략하고 모든 접근을 허용한다.
- requestMatchers(URI).authenticated(): 해당 URI에 대한 접근은 인증된 사용자만 허용하며, 특정 역할(Role)이나 권한(Authority)은 요구하지 않는다.
- requestMatchers(HttpMethod.POST, URI).hasRole(UserRole.ADMIN.name()): 특정 HTTP 메서드(POST)와 URI에 대해 지정된 역할(ADMIN)을 가진 사용자만 접근을 허용한다.
- anyRequest().denyAll(): 위에 정의된 규칙을 제외한 모든 요청을 거부한다.
- addFilterBefore(new JwtFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class):
- **커스텀 필터(JwtFilter)**를 스프링 시큐리티 필터 체인에 추가한다.
- UsernamePasswordAuthenticationFilter.class 이전에 JwtFilter를 실행하도록 지정하여, JWT 기반 인증 로직이 먼저 수행되도록 한다.
- exceptionHandling(...): DispatcherServlet 이전 단계, 즉 스프링 시큐리티 필터 체인에서 발생하는 인증 및 인가 예외를 처리한다.
- authenticationEntryPoint(customAuthenticationEntryPoint): 인증 과정에서 예외(AuthenticationException)가 발생했을 때 처리할 핸들러를 지정한다. (예: 로그인하지 않은 사용자가 보호된 리소스에 접근 시)
- accessDeniedHandler(customAccessDeniedHandler): 인가 과정에서 예외(AccessDeniedException)가 발생했을 때 처리할 핸들러를 지정한다. (예: 인증은 되었지만 권한이 없는 사용자가 접근 시)
커스텀 예외 처리기 예시
스프링 시큐리티 필터 체인에서 발생하는 예외를 사용자에게 친화적인 JSON 형태로 응답하기 위한 커스텀 핸들러 예시이다.
// CustomAuthenticationEntryPoint.class (인증 실패 시)
@Slf4j
@Component
@RequiredArgsConstructor
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final ObjectMapper objectMapper; // JSON 변환을 위한 ObjectMapper
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
log.error("Not Authenticated Request", authException);
log.error("Request Uri : {}", request.getRequestURI());
ErrorResponse errorResponse = new ErrorResponse(HttpStatus.UNAUTHORIZED.value(), authException.getMessage());
String responseBody = objectMapper.writeValueAsString(errorResponse); // ErrorResponse 객체를 JSON 문자열로 변환
response.setContentType(MediaType.APPLICATION_JSON_VALUE); // 응답 Content-Type을 JSON으로 설정
response.setStatus(HttpStatus.UNAUTHORIZED.value()); // HTTP 상태 코드를 401 Unauthorized로 설정
response.setCharacterEncoding("UTF-8"); // 문자 인코딩 설정
response.getWriter().write(responseBody); // 응답 본문에 JSON 쓰기
}
}
// CustomAccessDeniedHandler.class (인가 실패 시)
@Slf4j
@Component
@RequiredArgsConstructor
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
private final ObjectMapper objectMapper;
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
log.error("No Authorities", accessDeniedException);
log.error("Request Uri : {}", request.getRequestURI());
ErrorResponse errorResponse = new ErrorResponse(HttpStatus.FORBIDDEN.value(), accessDeniedException.getMessage());
String responseBody = objectMapper.writeValueAsString(errorResponse);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setStatus(HttpStatus.FORBIDDEN.value()); // HTTP 상태 코드를 403 Forbidden으로 설정
response.setCharacterEncoding("UTF-8");
response.getWriter().write(responseBody);
}
}
JWT 필터 예시 코드
JWT 유효성 검증 및 인증 객체 생성을 담당하는 커스텀 필터 예시이다.
// JwtFilter.class
@Slf4j
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter { // 요청당 한 번만 실행되는 필터
private final JwtUtil jwtUtil; // JWT 관련 유틸리티
@Override
public void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
String bearerJwt = request.getHeader("Authorization"); // Authorization 헤더에서 JWT 추출
// JWT가 없으면 다음 필터로 넘김 (인증이 필요 없는 요청일 수 있음)
if (bearerJwt == null) {
chain.doFilter(request, response);
return;
}
String jwt = jwtUtil.substringToken(bearerJwt); // "Bearer " 접두사 제거
try {
// JWT 유효성 검사 및 Claims 추출
Claims claims = jwtUtil.extractClaims(jwt);
if (claims == null) {
response.sendError(HttpServletResponse.SC_BAD_REQUEST, "잘못된 JWT 토큰입니다.");
return;
}
Long userId = jwtUtil.getUserId(jwt);
UserRole userRole = jwtUtil.getUserRole(jwt);
String email = jwtUtil.getEmail(jwt);
AuthUser authUser = new AuthUser(userId, email, userRole); // 인증된 사용자 정보 객체 생성
// UsernamePasswordAuthenticationToken 생성 및 SecurityContextHolder에 저장
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
authUser, "", List.of(new SimpleGrantedAuthority("ROLE_" + userRole.name())) // 사용자 정보, 비밀번호(여기선 필요없음), 권한
);
SecurityContextHolder.getContext().setAuthentication(authenticationToken); // SecurityContextHolder에 인증 객체 설정
chain.doFilter(request, response); // 다음 필터로 요청 전달
} catch (SecurityException | MalformedJwtException e) {
log.error("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.", e);
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "유효하지 않는 JWT 서명입니다."); // 401 응답
} catch (ExpiredJwtException e) {
log.error("Expired JWT token, 만료된 JWT token 입니다.", e);
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "만료된 JWT 토큰입니다."); // 401 응답
} catch (UnsupportedJwtException e) {
log.error("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.", e);
response.sendError(HttpServletResponse.SC_BAD_REQUEST, "지원되지 않는 JWT 토큰입니다."); // 400 응답
} catch (Exception e) {
log.error("Internal server error", e);
response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); // 500 응답
}
}
}
JWT와 스프링 시큐리티 병용의 이유
JWT만 사용한다면 '인증'에 국한된 기능만을 직접 구현하는 것이 되지만, 스프링 시큐리티를 함께 사용하는 이유는 다음과 같다.
- 포괄적인 보안 프레임워크: 스프링 시큐리티는 인증뿐만 아니라 인가(Authorization) 기능, 세션 관리(필요시), CSRF 보호, 다양한 공격 방어 등 웹 애플리케이션 보안 전반에 걸친 광범위한 기능을 제공한다.
- 표준화된 구조: 보안 관련 로직을 직접 구현하는 대신, 스프링 시큐리티의 표준화된 필터 체인 및 컴포넌트 구조를 활용하여 안정적이고 유지보수하기 쉬운 보안 시스템을 구축할 수 있다.
- 유연한 확장성: 필요에 따라 AuthenticationProvider, UserDetailsService, AuthenticationEntryPoint 등 다양한 인터페이스를 구현하여 커스텀 인증/인가 로직을 쉽게 통합하고 확장할 수 있다.
즉, JWT는 인증을 위한 토큰 방식을 제공하고, 스프링 시큐리티는 이 토큰을 활용하여 인증 및 인가와 같은 포괄적인 보안 기능을 구현하고 관리하는 강력한 프레임워크 역할을 수행한다.
'Programing > Spring' 카테고리의 다른 글
| N+1 문제와 페치 조인 적용해 해결하기 (1) | 2025.06.02 |
|---|---|
| 좋아요 기능과 동시성 문제? (0) | 2025.06.02 |
| 쿼리를 직접적으로 작성하는 건 아쉬워서 DSL을 사용해봤다 근데 왜 업데이트가 반영이 안되지? (1) | 2025.05.23 |
| 매번 인증 코드를 넣어야하는데 이를 하나로 해결해주는 ArgumentResolver를 사용해보자 (0) | 2025.05.22 |
| @Entity 엔티티에서 사용하는 어노테이션과 이유 (0) | 2025.05.20 |