Spring Security 완전 가이드

아키텍처 이해

HTTP 요청
    ↓
DelegatingFilterProxy          (서블릿 컨테이너 → Spring 컨텍스트 연결)
    ↓
FilterChainProxy               (보안 필터 체인 관리)
    ↓
SecurityFilterChain            (필터 목록)
    ├── SecurityContextPersistenceFilter
    ├── UsernamePasswordAuthenticationFilter (Form 로그인)
    ├── JwtAuthenticationFilter (커스텀)
    ├── ExceptionTranslationFilter
    └── FilterSecurityInterceptor (인가)
    ↓
Controller

인증 흐름

UsernamePasswordAuthenticationFilter
    ↓
AuthenticationManager.authenticate(token)
    ↓
AuthenticationProvider (DaoAuthenticationProvider)
    ↓
UserDetailsService.loadUserByUsername(username)
    ↓
UserDetails 반환 → 비밀번호 검증
    ↓
Authentication 객체 생성 → SecurityContext 저장

SecurityFilterChain 설정 (Spring Security 6.x)

@Configuration
@EnableWebSecurity
@EnableMethodSecurity  // @PreAuthorize 등 메서드 보안 활성화
public class SecurityConfig {
 
    private final JwtAuthenticationFilter jwtAuthFilter;
 
    public SecurityConfig(JwtAuthenticationFilter jwtAuthFilter) {
        this.jwtAuthFilter = jwtAuthFilter;
    }
 
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
            .csrf(AbstractHttpConfigurer::disable)  // REST API는 CSRF 불필요
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()
                .requestMatchers("/actuator/health").permitAll()
                .requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
            .exceptionHandling(ex -> ex
                .authenticationEntryPoint((request, response, e) -> {
                    response.setStatus(HttpStatus.UNAUTHORIZED.value());
                    response.getWriter().write("{\"error\": \"인증이 필요합니다\"}");
                })
                .accessDeniedHandler((request, response, e) -> {
                    response.setStatus(HttpStatus.FORBIDDEN.value());
                    response.getWriter().write("{\"error\": \"권한이 없습니다\"}");
                })
            )
            .build();
    }
 
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
 
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration config)
        throws Exception {
        return config.getAuthenticationManager();
    }
}

JWT 완전 구현

// build.gradle.kts
implementation("io.jsonwebtoken:jjwt-api:0.12.5")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.5")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.5")

JwtTokenProvider

@Component
public class JwtTokenProvider {
 
    @Value("${jwt.secret}")
    private String secretKey;
 
    @Value("${jwt.access-token-expire:3600000}")    // 1시간
    private long accessTokenExpire;
 
    @Value("${jwt.refresh-token-expire:604800000}")  // 7일
    private long refreshTokenExpire;
 
    private SecretKey getSigningKey() {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        return Keys.hmacShaKeyFor(keyBytes);
    }
 
    public String generateAccessToken(Long userId, String email, List<String> roles) {
        return Jwts.builder()
            .subject(email)
            .claim("userId", userId)
            .claim("roles", roles)
            .issuedAt(new Date())
            .expiration(new Date(System.currentTimeMillis() + accessTokenExpire))
            .signWith(getSigningKey())
            .compact();
    }
 
    public String generateRefreshToken(Long userId) {
        return Jwts.builder()
            .subject(userId.toString())
            .issuedAt(new Date())
            .expiration(new Date(System.currentTimeMillis() + refreshTokenExpire))
            .signWith(getSigningKey())
            .compact();
    }
 
    public Claims parseToken(String token) {
        return Jwts.parser()
            .verifyWith(getSigningKey())
            .build()
            .parseSignedClaims(token)
            .getPayload();
    }
 
    public boolean isTokenValid(String token) {
        try {
            parseToken(token);
            return true;
        } catch (JwtException | IllegalArgumentException e) {
            return false;
        }
    }
 
    public String getEmail(String token) {
        return parseToken(token).getSubject();
    }
 
    @SuppressWarnings("unchecked")
    public List<String> getRoles(String token) {
        return (List<String>) parseToken(token).get("roles");
    }
}

JwtAuthenticationFilter

@Component
@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 = extractToken(request);
 
        if (token != null && jwtTokenProvider.isTokenValid(token)) {
            String email = jwtTokenProvider.getEmail(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 extractToken(HttpServletRequest request) {
        String header = request.getHeader("Authorization");
        if (StringUtils.hasText(header) && header.startsWith("Bearer ")) {
            return header.substring(7);
        }
        return null;
    }
}

인증 엔드포인트

@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
 
    private final AuthenticationManager authenticationManager;
    private final JwtTokenProvider jwtTokenProvider;
    private final UserService userService;
    private final RedisTemplate<String, String> redisTemplate;
 
    @PostMapping("/login")
    public TokenResponse login(@RequestBody @Valid LoginRequest request) {
        Authentication authentication = authenticationManager.authenticate(
            new UsernamePasswordAuthenticationToken(request.email(), request.password())
        );
 
        UserPrincipal principal = (UserPrincipal) authentication.getPrincipal();
        String accessToken = jwtTokenProvider.generateAccessToken(
            principal.getId(), principal.getEmail(), principal.getRoleNames()
        );
        String refreshToken = jwtTokenProvider.generateRefreshToken(principal.getId());
 
        // Refresh Token Redis 저장 (7일)
        redisTemplate.opsForValue().set(
            "refresh:" + principal.getId(), refreshToken, Duration.ofDays(7)
        );
 
        return new TokenResponse(accessToken, refreshToken);
    }
 
    @PostMapping("/refresh")
    public TokenResponse refresh(@RequestBody RefreshRequest request) {
        if (!jwtTokenProvider.isTokenValid(request.refreshToken())) {
            throw new BusinessException("유효하지 않은 리프레시 토큰입니다");
        }
        // Redis에서 토큰 검증 후 새 Access Token 발급
        // ...
    }
}

메서드 보안

@Service
public class OrderService {
 
    @PreAuthorize("hasRole('ADMIN') or #userId == authentication.principal.id")
    public List<Order> findOrdersByUser(Long userId) {
        return orderRepository.findByUserId(userId);
    }
 
    @PostAuthorize("returnObject.userId == authentication.principal.id")
    public Order findById(Long id) {
        return orderRepository.findById(id).orElseThrow();
    }
 
    @PreAuthorize("hasAuthority('ORDER_WRITE')")
    public Order createOrder(CreateOrderRequest request) { ... }
}

CORS 설정

@Configuration
public class CorsConfig {
 
    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedOrigins(List.of("https://myapp.com", "http://localhost:3000"));
        config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
        config.setAllowedHeaders(List.of("*"));
        config.setExposedHeaders(List.of("Authorization", "X-Request-Id"));
        config.setAllowCredentials(true);
        config.setMaxAge(3600L);
 
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/api/**", config);
        return source;
    }
}

SecurityFilterChain에 적용:

.cors(cors -> cors.configurationSource(corsConfigurationSource()))

OAuth2 Resource Server

외부 인증 서버(Keycloak, Auth0 등)에서 발급한 JWT를 검증한다.

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          jwk-set-uri: https://auth.example.com/.well-known/jwks.json
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    return http
        .oauth2ResourceServer(oauth2 -> oauth2
            .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()))
        )
        .build();
}
 
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
    JwtGrantedAuthoritiesConverter converter = new JwtGrantedAuthoritiesConverter();
    converter.setAuthoritiesClaimName("roles");  // JWT 클레임 이름
    converter.setAuthorityPrefix("ROLE_");
 
    JwtAuthenticationConverter jwtConverter = new JwtAuthenticationConverter();
    jwtConverter.setJwtGrantedAuthoritiesConverter(converter);
    return jwtConverter;
}

비밀번호 인코딩

@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder(12);  // strength: 4~31, 기본 10
}
 
// 회원가입
String encoded = passwordEncoder.encode(rawPassword);
 
// 로그인 검증
boolean matches = passwordEncoder.matches(rawPassword, encodedPassword);

DelegatingPasswordEncoder (알고리즘 마이그레이션)

// 여러 알고리즘을 혼용할 때 (기존 MD5 → BCrypt 전환 등)
@Bean
public PasswordEncoder passwordEncoder() {
    return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
 
// 저장 형식: {bcrypt}$2a$10$...