Spring Boot

Spring Security 6 실전 가이드 - JWT + OAuth2 인증/인가 완벽 정리

백엔드 개발자 김승원 2026. 4. 2. 14:36

들어가며

Spring Security는 스프링 기반 애플리케이션의 인증(Authentication)과 인가(Authorization)를 담당하는 핵심 프레임워크입니다. Spring Security 6부터는 기존의 WebSecurityConfigurerAdapter가 완전히 제거되고, 컴포넌트 기반의 SecurityFilterChain으로 전환되었습니다. 이번 글에서는 Spring Security 6 환경에서 JWT 토큰 인증과 OAuth2 리소스 서버 설정을 실무 수준으로 다루겠습니다.

1. Spring Security 6 주요 변경점

Spring Security 6(Spring Boot 3.x 이상)에서 달라진 핵심 사항을 정리합니다.

Before: WebSecurityConfigurerAdapter 방식 (Spring Security 5)

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .csrf().disable()
            .authorizeRequests()
                .antMatchers("/api/public/**").permitAll()
                .antMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            .and()
            .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS);
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService)
            .passwordEncoder(passwordEncoder());
    }
}

After: SecurityFilterChain 방식 (Spring Security 6)

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/public/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            );
        return http.build();
    }

    @Bean
    public AuthenticationManager authenticationManager(
            AuthenticationConfiguration config) throws Exception {
        return config.getAuthenticationManager();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}
항목 Security 5 (이전) Security 6 (현재)
설정 방식 WebSecurityConfigurerAdapter 상속 SecurityFilterChain @Bean 등록
URL 매칭 antMatchers() requestMatchers()
인가 메서드 authorizeRequests() authorizeHttpRequests()
Lambda DSL 선택 기본 권장
Jakarta EE javax.* jakarta.*

2. JWT 토큰 생성 및 검증

실무에서 가장 많이 사용되는 JWT 기반 인증 구현입니다. io.jsonwebtoken:jjwt 라이브러리를 사용합니다.

의존성 추가 (build.gradle)

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
    runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
}

JwtTokenProvider 구현

@Component
public class JwtTokenProvider {

    @Value("${jwt.secret}")
    private String secretKey;

    @Value("${jwt.access-token-validity:3600000}")
    private long accessTokenValidity; // 1시간

    @Value("${jwt.refresh-token-validity:604800000}")
    private long refreshTokenValidity; // 7일

    private SecretKey key;

    @PostConstruct
    protected void init() {
        this.key = Keys.hmacShaKeyFor(
            Decoders.BASE64.decode(secretKey)
        );
    }

    public String createAccessToken(String email, List<String> roles) {
        return createToken(email, roles, accessTokenValidity);
    }

    public String createRefreshToken(String email) {
        return createToken(email, Collections.emptyList(), refreshTokenValidity);
    }

    private String createToken(String subject, List<String> roles, long validity) {
        Date now = new Date();
        Date expiration = new Date(now.getTime() + validity);

        return Jwts.builder()
            .subject(subject)
            .claim("roles", roles)
            .issuedAt(now)
            .expiration(expiration)
            .signWith(key)
            .compact();
    }

    public boolean validateToken(String token) {
        try {
            Jwts.parser().verifyWith(key).build()
                .parseSignedClaims(token);
            return true;
        } catch (JwtException | IllegalArgumentException e) {
            return false;
        }
    }

    public String getSubject(String token) {
        return Jwts.parser().verifyWith(key).build()
            .parseSignedClaims(token)
            .getPayload()
            .getSubject();
    }

    @SuppressWarnings("unchecked")
    public List<String> getRoles(String token) {
        return Jwts.parser().verifyWith(key).build()
            .parseSignedClaims(token)
            .getPayload()
            .get("roles", List.class);
    }
}

JwtAuthenticationFilter 구현

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider;
    private final UserDetailsService userDetailsService;

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain) throws ServletException, IOException {

        String token = resolveToken(request);

        if (token != null && jwtTokenProvider.validateToken(token)) {
            String email = jwtTokenProvider.getSubject(token);
            UserDetails userDetails = userDetailsService.loadUserByUsername(email);

            UsernamePasswordAuthenticationToken authentication =
                new UsernamePasswordAuthenticationToken(
                    userDetails, null, userDetails.getAuthorities());
            authentication.setDetails(
                new WebAuthenticationDetailsSource().buildDetails(request));

            SecurityContextHolder.getContext()
                .setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);
    }

    private String resolveToken(HttpServletRequest request) {
        String bearer = request.getHeader("Authorization");
        if (StringUtils.hasText(bearer) && bearer.startsWith("Bearer ")) {
            return bearer.substring(7);
        }
        return null;
    }
}

3. SecurityFilterChain에 JWT 필터 통합

@Configuration
@EnableWebSecurity
@EnableMethodSecurity  // @PreAuthorize 활성화
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtTokenProvider jwtTokenProvider;
    private final UserDetailsService userDetailsService;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .cors(cors -> cors.configurationSource(corsConfigurationSource()))
            .httpBasic(basic -> basic.disable())
            .formLogin(form -> form.disable())
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()
                .requestMatchers("/api/public/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            .addFilterBefore(
                new JwtAuthenticationFilter(jwtTokenProvider, userDetailsService),
                UsernamePasswordAuthenticationFilter.class
            )
            .exceptionHandling(ex -> ex
                .authenticationEntryPoint((req, res, authEx) -> {
                    res.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                    res.setContentType("application/json;charset=UTF-8");
                    res.getWriter().write("{\"error\":\"인증이 필요합니다.\"}");
                })
                .accessDeniedHandler((req, res, accessEx) -> {
                    res.setStatus(HttpServletResponse.SC_FORBIDDEN);
                    res.setContentType("application/json;charset=UTF-8");
                    res.getWriter().write("{\"error\":\"접근 권한이 없습니다.\"}");
                })
            );

        return http.build();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedOrigins(List.of("https://example.com"));
        config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH"));
        config.setAllowedHeaders(List.of("*"));
        config.setAllowCredentials(true);
        config.setMaxAge(3600L);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/api/**", config);
        return source;
    }
}

4. OAuth2 리소스 서버 설정

외부 인가 서버(Keycloak, Auth0 등)와 연동할 때 OAuth2 리소스 서버로 동작하도록 설정합니다.

application.yml

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://auth.example.com/realms/my-realm
          jwk-set-uri: https://auth.example.com/realms/my-realm/protocol/openid-connect/certs

OAuth2 리소스 서버 SecurityFilterChain

@Bean
public SecurityFilterChain oauth2ResourceServerFilterChain(
        HttpSecurity http) throws Exception {
    http
        .securityMatcher("/api/v2/**")
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/api/v2/public/**").permitAll()
            .anyRequest().authenticated()
        )
        .oauth2ResourceServer(oauth2 -> oauth2
            .jwt(jwt -> jwt
                .jwtAuthenticationConverter(jwtAuthenticationConverter())
            )
        );
    return http.build();
}

private JwtAuthenticationConverter jwtAuthenticationConverter() {
    JwtGrantedAuthoritiesConverter grantedAuthorities =
        new JwtGrantedAuthoritiesConverter();
    grantedAuthorities.setAuthoritiesClaimName("roles");
    grantedAuthorities.setAuthorityPrefix("ROLE_");

    JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
    converter.setJwtGrantedAuthoritiesConverter(grantedAuthorities);
    return converter;
}

5. @PreAuthorize 권한 관리

@EnableMethodSecurity를 활성화하면 메서드 수준에서 세밀한 권한 제어가 가능합니다.

@RestController
@RequestMapping("/api/posts")
@RequiredArgsConstructor
public class PostController {

    private final PostService postService;

    @GetMapping
    @PreAuthorize("hasAnyRole('USER', 'ADMIN')")
    public ResponseEntity<List<PostResponse>> getPosts() {
        return ResponseEntity.ok(postService.findAll());
    }

    @PostMapping
    @PreAuthorize("hasRole('ADMIN')")
    public ResponseEntity<PostResponse> createPost(
            @RequestBody @Valid PostRequest request) {
        return ResponseEntity.status(HttpStatus.CREATED)
            .body(postService.create(request));
    }

    @DeleteMapping("/{id}")
    @PreAuthorize("hasRole('ADMIN') or @postService.isAuthor(#id, authentication.name)")
    public ResponseEntity<Void> deletePost(@PathVariable Long id) {
        postService.delete(id);
        return ResponseEntity.noContent().build();
    }

    @GetMapping("/my")
    @PreAuthorize("isAuthenticated()")
    public ResponseEntity<List<PostResponse>> getMyPosts(
            @AuthenticationPrincipal UserDetails userDetails) {
        return ResponseEntity.ok(
            postService.findByAuthor(userDetails.getUsername()));
    }
}

6. 로그인/토큰 발급 API

@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {

    private final AuthenticationManager authenticationManager;
    private final JwtTokenProvider jwtTokenProvider;

    @PostMapping("/login")
    public ResponseEntity<TokenResponse> login(
            @RequestBody @Valid LoginRequest request) {

        Authentication authentication = authenticationManager.authenticate(
            new UsernamePasswordAuthenticationToken(
                request.getEmail(), request.getPassword())
        );

        List<String> roles = authentication.getAuthorities().stream()
            .map(GrantedAuthority::getAuthority)
            .toList();

        String accessToken = jwtTokenProvider.createAccessToken(
            request.getEmail(), roles);
        String refreshToken = jwtTokenProvider.createRefreshToken(
            request.getEmail());

        return ResponseEntity.ok(
            new TokenResponse(accessToken, refreshToken));
    }

    @PostMapping("/refresh")
    public ResponseEntity<TokenResponse> refresh(
            @RequestBody RefreshTokenRequest request) {

        String refreshToken = request.getRefreshToken();
        if (!jwtTokenProvider.validateToken(refreshToken)) {
            throw new InvalidTokenException("유효하지 않은 리프레시 토큰입니다.");
        }

        String email = jwtTokenProvider.getSubject(refreshToken);
        // DB에서 사용자 정보와 roles를 다시 조회
        List<String> roles = userService.getRoles(email);

        String newAccessToken = jwtTokenProvider.createAccessToken(email, roles);
        String newRefreshToken = jwtTokenProvider.createRefreshToken(email);

        return ResponseEntity.ok(
            new TokenResponse(newAccessToken, newRefreshToken));
    }
}

public record TokenResponse(String accessToken, String refreshToken) {}
public record LoginRequest(String email, String password) {}
public record RefreshTokenRequest(String refreshToken) {}

7. 테스트 코드

@WebMvcTest(PostController.class)
class PostControllerSecurityTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    @WithMockUser(roles = "ADMIN")
    void 관리자는_게시글을_생성할_수_있다() throws Exception {
        mockMvc.perform(post("/api/posts")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"title\":\"테스트\",\"content\":\"내용\"}"))
            .andExpect(status().isCreated());
    }

    @Test
    @WithMockUser(roles = "USER")
    void 일반_사용자는_게시글을_생성할_수_없다() throws Exception {
        mockMvc.perform(post("/api/posts")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"title\":\"테스트\",\"content\":\"내용\"}"))
            .andExpect(status().isForbidden());
    }

    @Test
    void 비인증_사용자는_접근할_수_없다() throws Exception {
        mockMvc.perform(get("/api/posts"))
            .andExpect(status().isUnauthorized());
    }
}

마치며

Spring Security 6은 기존 방식에서 크게 바뀌었지만, 오히려 설정이 더 직관적이고 유연해졌습니다. 핵심 포인트를 정리하면 다음과 같습니다.

  • SecurityFilterChain을 @Bean으로 등록하여 여러 보안 정책을 분리 관리할 수 있습니다.
  • JWT 인증은 커스텀 필터를 만들어 UsernamePasswordAuthenticationFilter 앞에 배치합니다.
  • OAuth2 리소스 서버는 외부 인가 서버와 연동할 때 사용하며, JWK Set URI만 설정하면 토큰 검증이 자동으로 됩니다.
  • @EnableMethodSecurity@PreAuthorize로 메서드 수준 권한 제어가 가능합니다.
  • CORS/CSRF 설정은 Lambda DSL로 깔끔하게 작성합니다. REST API 서버라면 CSRF는 비활성화합니다.

특히 마이그레이션 시에는 antMatchers -> requestMatchers, authorizeRequests -> authorizeHttpRequests 변경을 놓치지 마세요. Spring Security 공식 마이그레이션 가이드도 함께 참고하시길 권장합니다.