Spring Security + JWT 기반 인증/인가 구현기
필자는 최근 프로젝트에서 Spring Security를 적용하며 JWT 기반 인증/인가 로직을 구현하였다. Spring Security는 기본적으로 모든 요청을 차단하고, 인증/인가 규칙에 따라 접근을 허용하는 보안 프레임워크이다. 본 글에서는 JWT 기반의 Stateless 인증 방식을 도입하며 마주한 이슈들과 해결 과정을 공유하고자 한다.
- 1. Spring Security 기본 개념
- 2. JwtAuthenticationFilter 동작
- 3. JwtProvider 구현
- 4. SecurityConfig 설정
- 5. @PreAuthorize를 통한 권한 제어
- 6. 예외 처리와 JSON 응답
- 7. 테스트 전략
- 8. SecurityContext 저장 방식
- 9. 마치며
- 10. Pull-Request
1. Spring Security 기본 개념
Spring Security의 핵심은 SecurityContext와 Authentication이다.
- Authentication은 현재 사용자의 인증 정보(주체, 권한 등)를 담는다.
- SecurityContext는 Authentication을 보관하며,
SecurityContextHolder를 통해 쓰레드 로컬(ThreadLocal)에 저장된다.
Stateful 환경에서는 세션(HttpSession)에 보관하여 요청 간 인증 정보를 유지한다. 반면 JWT 기반의 Stateless 환경에서는 매 요청마다 토큰을 검증하고 SecurityContext에 저장한 뒤, 요청이 끝나면 제거한다.
2. JwtAuthenticationFilter 동작
필자는 JwtAuthenticationFilter를 구현하여 JWT 기반 인증을 처리하였다.
이 필터는 요청 헤더에서 토큰을 추출하고, 유효하다면 Authentication 객체를 만들어 SecurityContextHolder에 저장한다.
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtProvider jwtProvider;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = jwtProvider.resolveToken(request);
if (token != null && jwtProvider.validateToken(token)) {
Authentication auth = jwtProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(auth);
}
filterChain.doFilter(request, response);
}
}
필터 동작 과정은 아래와 같다.
sequenceDiagram
participant Client
participant JwtFilter
participant JwtProvider
participant SecurityContext
participant Controller
Client->>JwtFilter: HTTP 요청 (Authorization: Bearer 토큰)
JwtFilter->>JwtProvider: resolveToken(), validateToken()
alt 유효한 토큰
JwtProvider-->>JwtFilter: Claims 반환
JwtFilter->>SecurityContext: Authentication 저장 (ThreadLocal)
JwtFilter->>Controller: 요청 위임
else 유효하지 않은 토큰
JwtProvider-->>JwtFilter: 검증 실패
JwtFilter->>SecurityConfig: EntryPoint/DeniedHandler 실행
end
3. JwtProvider 구현
토큰의 생성과 검증은 JwtProvider에서 담당한다.
@Component
public class JwtProvider {
private final SecretKey key;
public JwtProvider(@Value("${jwt.secret}") String secret) {
this.key = Keys.hmacShaKeyFor(secret.getBytes());
}
public String resolveToken(HttpServletRequest request) {
String bearer = request.getHeader("Authorization");
return (bearer != null && bearer.startsWith("Bearer ")) ? bearer.substring(7) : null;
}
public boolean validateToken(String token) {
try {
Jwts.parser().verifyWith(key).build().parseSignedClaims(token);
return true;
} catch (Exception e) {
return false;
}
}
public Authentication getAuthentication(String token) {
Claims claims = Jwts.parser().verifyWith(key).build().parseSignedClaims(token).getPayload();
String email = claims.getSubject();
String role = claims.get("role", String.class);
return new UsernamePasswordAuthenticationToken(
email,
"",
Collections.singleton(new SimpleGrantedAuthority("ROLE_" + role))
);
}
}
4. SecurityConfig 설정
필자는 SecurityConfig에서 JWT 필터를 추가하고, 특정 경로는 허용하며 나머지는 인증이 필요하도록 구성하였다.
또한 AuthenticationEntryPoint와 AccessDeniedHandler를 등록하여 인증/인가 실패 시 JSON 응답을 내려주도록 하였다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtProvider jwtProvider;
private final CustomAuthenticationEntryPoint authenticationEntryPoint;
private final CustomAccessDeniedHandler accessDeniedHandler;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
.requestMatchers("/api/auth/**").permitAll()
.anyRequest().authenticated()
)
.exceptionHandling(ex -> ex
.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler)
)
.addFilterBefore(new JwtAuthenticationFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class)
.build();
}
}
5. @PreAuthorize를 통한 권한 제어
Spring Security는 API 단위뿐만 아니라 메소드 단위에서도 권한 검증을 할 수 있다.
예시는 아래와 같다.
@RestController
@RequestMapping("/admin")
public class AdminController {
@GetMapping("/dashboard")
@PreAuthorize("hasRole('ADMIN')")
public String getDashboard() {
return "Admin Dashboard";
}
}
@PreAuthorize 동작 과정은 아래와 같다.
sequenceDiagram
participant Client
participant Controller
participant MethodInterceptor
participant SecurityContext
Client->>Controller: API 호출
Controller->>MethodInterceptor: @PreAuthorize("hasRole('ADMIN')")
MethodInterceptor->>SecurityContext: 현재 Authentication 조회
alt 권한 일치
SecurityContext-->>MethodInterceptor: 권한 확인됨
MethodInterceptor-->>Controller: 메서드 실행 허용
else 권한 불일치
SecurityContext-->>MethodInterceptor: 권한 없음
MethodInterceptor->>Controller: AccessDeniedException 발생
Controller->>SecurityConfig: AccessDeniedHandler 실행
end
6. 예외 처리와 JSON 응답
기본적으로 인증 실패나 권한 부족 시 HTML 에러 페이지가 반환된다.
필자는 CustomAuthenticationEntryPoint와 CustomAccessDeniedHandler를 구현하여 JSON ErrorResponse를 내려주도록 하였다.
또한 ErrorResponse DTO에 LocalDateTime을 포함하면서 Jackson 직렬화 문제가 발생하였다. 기본 설정에서는 LocalDateTime이 배열 형태로 반환되었으나, ObjectMapper에 JavaTimeModule을 등록하고 WRITE_DATES_AS_TIMESTAMPS 옵션을 비활성화하여 해결하였다.
@Bean
public ObjectMapper objectMapper() {
return new ObjectMapper()
.registerModule(new JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
}
7. 테스트 전략
보안 로직이 적용되면 테스트에서도 인증/인가를 고려해야 한다.
필자는 다음 두 가지 방식을 사용하였다.
- 단순화:
spring-security-test모듈의@WithMockUser를 이용해 가짜 사용자 인증을 주입한다. - 실제 시뮬레이션:
JwtProvider로 토큰을 발급받아 Authorization 헤더에 세팅 후 API 호출을 검증한다.
@Test
@WithMockUser(username = "admin@example.com", roles = {"ADMIN"})
void givenAdmin_whenCallAdminApi_thenOk() throws Exception {
mockMvc.perform(get("/admin/dashboard"))
.andExpect(status().isOk());
}
8. SecurityContext 저장 방식
SecurityContext는 기본적으로 ThreadLocal에 저장되어 요청 처리 중 어디서든 접근할 수 있다.- Stateful 모드에서는 세션(HttpSession)에도 저장된다.
- JWT 기반의 Stateless 모드에서는 매 요청마다 토큰을 검증하고 ThreadLocal에 저장한 뒤, 요청 종료 시 초기화한다.
sequenceDiagram
participant Filter
participant SecurityContextHolder
participant Controller
participant 종료
Filter->>SecurityContextHolder: Authentication 저장 (ThreadLocal)
Controller->>SecurityContextHolder: Authentication 조회
종료->>SecurityContextHolder: 요청 종료 시 clear()
9. 마치며
Spring Security의 흐름은 아래와 같다.
- 요청 진입 → JwtAuthenticationFilter → 토큰 검증 및 SecurityContextHolder에 Authentication 저장
- Controller 호출 → @PreAuthorize로 권한 검증
- 실패 시 EntryPoint/DeniedHandler를 통해 JSON ErrorResponse 반환
필자는 이번 경험을 통해 Spring Security의 동작 원리를 더 깊이 이해하게 되었다.
보안은 단순히 인증/인가를 넘어서, 일관된 응답 포맷, 테스트 전략, 직렬화 이슈 처리까지 고려해야 한다는 점이 인상적이었다.
10. Pull-Request
필자의 사이드 프로젝트의 PR을 함께 첨부하여 상세한 예제를 제공하고자 한다.