1. SecurityFilterChain 구조

Spring Security는 Servlet Filter 체인으로 구현된다. DelegatingFilterProxy가 Servlet 컨테이너와 Spring Security를 연결한다.

HTTP 요청
    |
    v
[Servlet Container]
    |
    v
[DelegatingFilterProxy]          ← Servlet Filter. Spring Bean이 아닌 척 동작
    |
    | Spring ApplicationContext에서
    | FilterChainProxy Bean을 찾아 위임
    v
[FilterChainProxy]               ← Spring Bean. SecurityFilterChain 목록 관리
    |
    | 요청 URL에 맞는 SecurityFilterChain 선택
    v
[SecurityFilterChain]            ← 보안 필터 목록
    |
    v
  [1] DisableEncodeUrlFilter
  [2] WebAsyncManagerIntegrationFilter
  [3] SecurityContextHolderFilter
  [4] HeaderWriterFilter
  [5] CorsFilter
  [6] CsrfFilter
  [7] LogoutFilter
  [8] UsernamePasswordAuthenticationFilter
  [9] DefaultLoginPageGeneratingFilter
  [10] BasicAuthenticationFilter
  [11] RequestCacheAwareFilter
  [12] SecurityContextHolderAwareRequestFilter
  [13] AnonymousAuthenticationFilter
  [14] SessionManagementFilter
  [15] ExceptionTranslationFilter
  [16] AuthorizationFilter
    |
    v
[DispatcherServlet]
    |
    v
[Controller]

SecurityFilterChain 설정

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            // CSRF 설정
            .csrf(csrf -> csrf.disable())  // REST API는 보통 비활성화

            // 세션 설정
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))  // JWT 사용 시

            // 인가 규칙
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/public/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .requestMatchers(HttpMethod.GET, "/api/orders").hasAnyRole("USER", "ADMIN")
                .anyRequest().authenticated()
            )

            // JWT 필터 추가
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

    // 여러 SecurityFilterChain 설정 가능 (URL 패턴별로)
    @Bean
    @Order(1)
    public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
        http
            .securityMatcher("/api/**")  // /api/** 에만 적용
            .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
            .httpBasic(Customizer.withDefaults());
        return http.build();
    }

    @Bean
    @Order(2)
    public SecurityFilterChain webFilterChain(HttpSecurity http) throws Exception {
        http
            .securityMatcher("/**")     // 나머지에 적용
            .authorizeHttpRequests(auth -> auth.anyRequest().permitAll());
        return http.build();
    }
}

2. 인증(Authentication) vs 인가(Authorization)

구분 Authentication (인증) Authorization (인가)
질문 “당신이 누구인가?” “당신이 이것을 할 수 있는가?”
처리 시점 먼저 인증 후
실패 시 401 Unauthorized 403 Forbidden
담당 컴포넌트 AuthenticationManager AuthorizationManager
Spring Security UsernamePasswordAuthenticationFilter 등 AuthorizationFilter

3. 주요 필터 상세 동작

UsernamePasswordAuthenticationFilter

폼 로그인 처리. POST /login 요청을 가로챈다.

POST /login (username, password)
        |
        v
[UsernamePasswordAuthenticationFilter]
        |
        | 1. UsernamePasswordAuthenticationToken 생성 (미인증)
        v
[AuthenticationManager]
        |
        | 2. 적절한 AuthenticationProvider 탐색
        v
[DaoAuthenticationProvider]
        |
        | 3. UserDetailsService.loadUserByUsername(username)
        v
[UserDetailsService] → UserDetails 반환
        |
        | 4. 비밀번호 검증 (PasswordEncoder)
        v
[DaoAuthenticationProvider]
        |
        | 5. 인증 성공 → 완전한 Authentication 객체 반환
        v
[SecurityContextHolder]
        |
        | 6. SecurityContext에 Authentication 저장
        v
[AuthenticationSuccessHandler]
        |
        | 7. 성공 응답 (리다이렉트 or JSON)
// 커스텀 설정
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http.formLogin(form -> form
        .loginPage("/login")                    // 커스텀 로그인 페이지
        .loginProcessingUrl("/login")           // 로그인 처리 URL (POST)
        .defaultSuccessUrl("/dashboard")        // 성공 시 이동
        .failureUrl("/login?error=true")        // 실패 시 이동
        .usernameParameter("email")             // 파라미터 이름 변경
        .passwordParameter("passwd")
        .successHandler(customSuccessHandler)   // 커스텀 핸들러
        .failureHandler(customFailureHandler)
    );
    return http.build();
}

BasicAuthenticationFilter

HTTP Basic 인증 처리. Authorization: Basic base64(username:password) 헤더.

http.httpBasic(basic -> basic
    .realmName("My API")
    .authenticationEntryPoint(customEntryPoint)
);

AnonymousAuthenticationFilter

인증되지 않은 요청에 익명 Authentication을 생성해 SecurityContext에 저장한다.

// 필터 체인 끝까지 인증이 안 되면 이 필터가 익명 Authentication 생성
// SecurityContext에 항상 Authentication이 있음을 보장
Authentication anonymous = new AnonymousAuthenticationToken(
    "anonymousUser",
    "anonymousUser",
    List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS"))
);

ExceptionTranslationFilter

보안 예외를 HTTP 응답으로 변환한다.

[AuthorizationFilter에서 예외 발생]
        |
        v
[ExceptionTranslationFilter]
        |
        +-- AccessDeniedException (인가 실패)
        |       |
        |       +-- 익명 사용자? → AuthenticationEntryPoint (401)
        |       +-- 인증된 사용자? → AccessDeniedHandler (403)
        |
        +-- AuthenticationException (인증 실패)
                |
                v
        [AuthenticationEntryPoint]
          - LoginUrlAuthenticationEntryPoint: 로그인 페이지로 리다이렉트
          - HttpStatusEntryPoint: 401 반환 (REST API)
          - BearerTokenAuthenticationEntryPoint: WWW-Authenticate 헤더 반환

4. AuthenticationManager와 AuthenticationProvider

구조

[AuthenticationManager]
        |
        | (구현체)
        v
[ProviderManager]
        |
        | 등록된 AuthenticationProvider 순회
        v
[AuthenticationProvider 목록]
  - DaoAuthenticationProvider    (username/password)
  - JwtAuthenticationProvider    (JWT 커스텀)
  - OAuth2LoginAuthenticationProvider (OAuth2)
  - RememberMeAuthenticationProvider
  - AnonymousAuthenticationProvider
// DaoAuthenticationProvider 동작
public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {

    protected UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken auth) {
        // UserDetailsService에서 사용자 조회
        UserDetails user = userDetailsService.loadUserByUsername(username);
        if (user == null) throw new UsernameNotFoundException(username);
        return user;
    }

    protected void additionalAuthenticationChecks(UserDetails userDetails,
                                                   UsernamePasswordAuthenticationToken auth) {
        // 비밀번호 검증
        if (!passwordEncoder.matches(
            auth.getCredentials().toString(),
            userDetails.getPassword())) {
            throw new BadCredentialsException("비밀번호 불일치");
        }
    }
}

ProviderManager 위임 구조

// 부모 ProviderManager로 위임 가능 (계층 구조)
@Bean
public AuthenticationManager authenticationManager() {
    DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
    provider.setUserDetailsService(userDetailsService);
    provider.setPasswordEncoder(passwordEncoder());

    return new ProviderManager(List.of(provider));
}

5. UserDetailsService

public interface UserDetailsService {
    UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

public interface UserDetails extends Serializable {
    Collection<? extends GrantedAuthority> getAuthorities(); // 권한 목록
    String getPassword();
    String getUsername();
    boolean isAccountNonExpired();
    boolean isAccountNonLocked();
    boolean isCredentialsNonExpired();
    boolean isEnabled();
}

구현 예제

@Service
public class CustomUserDetailsService implements UserDetailsService {

    @Autowired
    private UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username)
            throws UsernameNotFoundException {
        User user = userRepository.findByEmail(username)
                .orElseThrow(() -> new UsernameNotFoundException("사용자 없음: " + username));

        return org.springframework.security.core.userdetails.User.builder()
                .username(user.getEmail())
                .password(user.getPassword())  // 이미 BCrypt 인코딩된 비밀번호
                .roles(user.getRole().name())  // ROLE_ 접두사 자동 추가
                .accountExpired(!user.isActive())
                .accountLocked(user.isLocked())
                .build();
    }
}

커스텀 UserDetails

// 추가 정보를 담은 커스텀 UserDetails
public class CustomUserDetails implements UserDetails {
    private final User user;  // 도메인 User 객체

    public CustomUserDetails(User user) {
        this.user = user;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return user.getRoles().stream()
                .map(role -> new SimpleGrantedAuthority("ROLE_" + role.name()))
                .collect(Collectors.toList());
    }

    @Override
    public String getPassword() { return user.getPassword(); }

    @Override
    public String getUsername() { return user.getEmail(); }

    // 도메인 객체 접근
    public Long getId() { return user.getId(); }
    public String getName() { return user.getName(); }

    // ... isAccountNonExpired, isEnabled 등
}

6. SecurityContext와 ThreadLocal

SecurityContextHolder

[SecurityContextHolder]
        |
        | 기본 전략: ThreadLocalSecurityContextHolderStrategy
        v
[SecurityContext] (ThreadLocal로 Thread마다 독립)
        |
        v
[Authentication]
  ├── Principal (UserDetails 또는 사용자 식별자)
  ├── Credentials (비밀번호, 인증 후 보통 null로 초기화)
  ├── Authorities (권한 목록)
  └── isAuthenticated (인증 여부)
// SecurityContext에서 현재 사용자 꺼내기
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String username = auth.getName();
Collection<? extends GrantedAuthority> authorities = auth.getAuthorities();

// UserDetails 캐스팅
UserDetails userDetails = (UserDetails) auth.getPrincipal();

// 커스텀 UserDetails 캐스팅
CustomUserDetails customUser = (CustomUserDetails) auth.getPrincipal();
Long userId = customUser.getId();

// Spring MVC에서 자동 주입
@GetMapping("/mypage")
public String myPage(@AuthenticationPrincipal CustomUserDetails user) {
    Long userId = user.getId();
    return "mypage";
}

ThreadLocal 기반 동작

[요청 1] Thread-1
  FilterChainProxy → SecurityContext 생성 → ThreadLocal 저장
  Controller → SecurityContextHolder.getContext() → Thread-1의 SecurityContext

[요청 2] Thread-2
  FilterChainProxy → SecurityContext 생성 → ThreadLocal 저장
  Controller → SecurityContextHolder.getContext() → Thread-2의 SecurityContext

→ 각 Thread가 독립적인 SecurityContext를 가짐 → Thread 안전

주의: 비동기 처리 시 SecurityContext가 전파되지 않을 수 있다.

// @Async 메서드에서 SecurityContext 전파
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
    @Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.initialize();
        // SecurityContext를 자식 Thread에 전파
        return new DelegatingSecurityContextAsyncTaskExecutor(executor);
    }
}

SecurityContextHolderStrategy 변경

// 비동기/반응형 환경에서 전략 변경
// ThreadLocal 대신 InheritableThreadLocal 사용
SecurityContextHolder.setStrategyName(
    SecurityContextHolder.MODE_INHERITABLETHREADLOCAL
);

7. CSRF 동작 원리

CSRF(Cross-Site Request Forgery): 인증된 사용자의 브라우저를 이용해 악의적 요청을 보내는 공격.

[CSRF 공격 시나리오]
1. 사용자가 bank.com에 로그인 (세션 쿠키 발급)
2. 악의적 사이트(evil.com) 방문
3. evil.com의 자동 폼 제출:
   POST bank.com/transfer (amount=10000, to=hacker)
   → 브라우저가 자동으로 bank.com 세션 쿠키 포함
4. bank.com은 유효한 세션으로 인식 → 이체 실행!

Spring Security CSRF 방어

[CSRF 토큰 흐름]

GET /form-page
    |
    v
[CsrfFilter]
  - CsrfToken 생성 (랜덤 값)
  - 세션 또는 쿠키에 저장
    |
    v
[폼 응답에 CSRF 토큰 포함]
<input type="hidden" name="_csrf" value="토큰값">

POST /submit (데이터 + _csrf=토큰값)
    |
    v
[CsrfFilter]
  - 요청의 _csrf 값과 서버 저장값 비교
  - 불일치 → 403 Forbidden
  - 일치 → 다음 필터로
// Thymeleaf: 자동으로 CSRF 토큰 포함
<form th:action="@{/submit}" method="post">
    <!-- Thymeleaf가 자동으로 hidden input 추가 -->
</form>

// JavaScript (Axios 등): 헤더로 전송
axios.defaults.headers.common['X-CSRF-TOKEN'] = document.querySelector('meta[name="_csrf"]').content;

REST API에서 CSRF 비활성화

http.csrf(csrf -> csrf.disable());
// JWT + Stateless 세션 사용 시 CSRF 불필요
// 쿠키 기반 세션이 없으면 CSRF 공격 불가

8. CORS 동작 원리

CORS(Cross-Origin Resource Sharing): 브라우저의 동일 출처 정책(SOP)을 제어하는 메커니즘.

[SOP 위반 예시]
프론트엔드: http://localhost:3000
백엔드 API: http://localhost:8080

브라우저: "출처가 다르다! 요청 차단!"

Preflight 요청

브라우저가 실제 요청 전에 OPTIONS 메서드로 서버에 허용 여부를 물어본다.

OPTIONS /api/orders
Origin: http://localhost:3000
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization
    |
    v
[서버 응답]
Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 3600  ← 이 시간 동안 Preflight 캐시
    |
    v
[실제 요청 전송]
POST /api/orders

Spring Security CORS 설정

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.cors(cors -> cors.configurationSource(corsConfigurationSource()));
        // ...
        return http.build();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();

        config.setAllowedOrigins(List.of(
            "http://localhost:3000",
            "https://myapp.com"
        ));
        config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
        config.setAllowedHeaders(List.of("*"));
        config.setAllowCredentials(true);  // 쿠키/인증 헤더 허용
        config.setMaxAge(3600L);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/api/**", config);
        return source;
    }
}
// 컨트롤러 레벨 CORS
@RestController
@CrossOrigin(origins = "http://localhost:3000")
public class OrderController { ... }

// 메서드 레벨
@CrossOrigin(origins = "*", maxAge = 3600)
@GetMapping("/public/orders")
public List<Order> publicOrders() { ... }

9. JWT 인증 구현 예제

JWT는 세션 없이 상태를 토큰에 담는 방식이다.

// JWT 인증 필터
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    @Autowired
    private JwtTokenProvider jwtTokenProvider;

    @Autowired
    private UserDetailsService userDetailsService;

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

        // 1. 헤더에서 토큰 추출
        String token = resolveToken(request);

        // 2. 토큰 유효성 검증
        if (token != null && jwtTokenProvider.validateToken(token)) {
            // 3. 토큰에서 사용자 정보 추출
            String username = jwtTokenProvider.getUsername(token);

            // 4. UserDetails 로드
            UserDetails userDetails = userDetailsService.loadUserByUsername(username);

            // 5. Authentication 객체 생성
            UsernamePasswordAuthenticationToken auth =
                new UsernamePasswordAuthenticationToken(
                    userDetails,
                    null,
                    userDetails.getAuthorities()
                );
            auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

            // 6. SecurityContext에 저장
            SecurityContextHolder.getContext().setAuthentication(auth);
        }

        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;
    }
}

정리

구성요소 역할
DelegatingFilterProxy Servlet Container ↔ Spring 연결
FilterChainProxy SecurityFilterChain 목록 관리
SecurityFilterChain 보안 필터 체인
UsernamePasswordAuthenticationFilter 폼 로그인 처리
AuthenticationManager (ProviderManager) 인증 위임
AuthenticationProvider 실제 인증 처리
UserDetailsService 사용자 정보 로드
SecurityContextHolder ThreadLocal로 Authentication 저장
ExceptionTranslationFilter 보안 예외 → HTTP 응답 변환
AuthorizationFilter 인가 처리
CSRF 동일 출처 위조 방어 (세션 기반에서 필요)
CORS 교차 출처 요청 허용 정책

카테고리:

업데이트: