본문 바로가기

Spring Boot/Security

Security : 플랫폼별 Spring Security

SecurityFilterChain과 SecurityWebFilterChain에 대해서

 

- 서블릿 기반(Spring MVC)리액티브 기반(WebFlux)에서 Spring Security가 어떻게 동작하는가

- 두 플랫폼 각각의 요청 처리 모델, 보안 필터 체인, 세션/토큰, CSRF 처리 차이

- 간단한 디렉토리 구조와 설정 코드를 통해 두 플랫폼의 차이 비교

- 프로젝트 성격에 맞춰 플랫폼 선택 기준을 세우고, 체크리스트로 적용 여부 결정하기


서블릿 기반(Spring MVC)에서의 Spring Security

1. 개념

서블릿 기반 애플리케이션은 동기, 블로킹 I/O 모델로 동작한다

요청이 들어오면 요청당 스레드(Per-Request Thread)가 할당되어 컨트롤러까지 진행한다

Spring Security는 이 경로 앞단에 FilterChainProxy를 두고, 다수의 서블릿 필터를 순서대로 통과시키며 인증, 인가, 예외처리, CSRF 등을 수행한다

서블릿 컨테이너를 거치는 서블릿 기반 Spring Security

 

* 필터 체인 - > 컨트롤러 순이며, 모든 요청이 필터를 반드시 거친다

 

 

 

2. 인증 흐름(폼 로그인 예)

서블릿 기반 인증 흐름

 

세션을 사용하는 경우, 인증 성공 후 서버 세션에 Authentication 저장

- > 이후 요청은 세션 아이디로 사용자를 식별함

 

 

 

3. 인가 처리(요청 경로 및 메서드 보안)

요청 경로 기반 : authorizeHttpRequest()에서 경로별 권한 지정

메서드 기반 : @PreAuthorize("hasRole('ADMIN')") 등으로 서비스 계층에서 2차 검증을 함

 

 

4. 예제(최소 설정)

build.gradle

dependencies {
    implementation 'org.springframework.boot:spring-boot-h2console'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-webmvc'
    compileOnly 'org.projectlombok:lombok'
    runtimeOnly 'com.h2database:h2'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test'
    testImplementation 'org.springframework.boot:spring-boot-starter-security-test'
    testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
}

 

 

SecurityConfig.java

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableMethodSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .csrf(csrf -> csrf.disable())
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/", "/public/**").permitAll()
                        .requestMatchers("/admin").hasRole("ADMIN")
                        .anyRequest().authenticated())
                .formLogin(login -> login
                        /**
                         * loginPage 메서드를 집어넣음으로 login 컨트롤러와 정적페이지를 만들어줘야함
                         */
                        .loginPage("/login").permitAll()
                        .defaultSuccessUrl("/", true))
                .logout(logout -> logout
                        .logoutUrl("/logout"));

        return http.build();
    }

    @Bean
    UserDetailsService users() {
        UserDetails user = User.withUsername("user")
                .password("{noop}pass")
                .roles("USER")
                .build();

        UserDetails admin = User.withUsername("admin")
                .password("{noop}pass")
                .roles("ADMIN")
                .build();
        return new InMemoryUserDetailsManager(user, admin);
    }
}

 

HomeController.java

@Controller
public class HomeController {
    @GetMapping("/")
    @ResponseBody
    public String home() {
        return "home";
    }

    @PreAuthorize("hasRole('ADMIN')")
    @GetMapping("/admin/panel")
    @ResponseBody
    public String admin() {
        return "admin";
    }
}

 

LoginController.java

@Controller
public class LoginController {
    @GetMapping("/login")
    public String login() {
        return "login";
    }
}

 

 

 

5. 세션, CSRF, 예외 처리

  • 세션 : 기본은 세션 기반임. 토큰 기반과 병행시 세션 생성 정책 조정 필요
  • CSRF : 상태 유지형 POST 등에서 보호가 필요함. REST API만 제공하면 비활성할 수 있으나, 폼 제출이 있다면 반드시 활성 후 토큰을 포함하기
  • 예외처리 : 인증 실패 - > AuthenticationEntryPoint / 인가 실패 -> AccessDeniedHandler

 

 

6. 성능, 확장성 특징

  • 요청당 스레드 고정으로 코드 단순성이 높다
  • 높은 동시성에서는 스레드 풀이 병목될 수 있어 캐싱, 커넥션, 풀, 쿼리 최적화가 중요하다
    1. 이커머스 대규모 할인 이벤트
      • 예 : 네이버 쇼핑, 쿠팡, 무신사에서 블랙프라이데이 이벤트 오픈 시간(00:00)에 수십만 명이 동시에 특정 상품 페이지를 새로고침 하거나 주문 API를 호출한다
      • 결과 : 상품 재고 조회 DB 쿼리문이 동시에 폭주함 -> DB 커넥션 풀이 부족해짐 -> 스레드가 대기 상태로 묶인다
    2. 공공기관 원서 접수 / 선착순 예약 서비스
      • 예 : 대학 수강신청, 코로나 백신 예약, 콘서트 예매 등(인터파크, Yes24)
      • 수만 건의 요청이 "한 시점"에 집중됨 -> 요청당 스레드 모델에서는 수천 개 스레드가 동시에 생성됨
        -> CPU Context Switching 폭증

 

 

7. 디버깅 체크리스트

  • 필터 순서 오해로 인한 우회 허용 여부 확인하기
  • 경로 매칭 우선순위 점검하기(/admin/** 앞에 과도한 permitAll() 배치 금지)
  • 세션 정책과 CSRF 정책 상충 여부 확인하기

 

항목 내용
처리 모델 동기 / 블로킹, 요청당 스레드
보안 핵심 FilterChainProxy + SecurityFilterChain
주 사용처 전통적 웹, 서버렌더링, 일반 REST API
강점 단순한 흐름, 풍부한 레거시 호환
주의 동시성 한계, 필터 순서 / 경로 매칭 실수

 


리액티브 기반(WebFlux)에서의 Spring Security

1. 개념

리액티브(WebFlux)는 비동기, 논블로킹 I/O와 리액티브 스트림 모델로 동작한다

요청은 이벤트 루프에 등록되어 스레드를 점유하지 않고 파이프라인을 타고 흐른다.

보안은 SecurityFilterChain(리액티브 웹 필터 체인)으로 처리된다

 

 

2. 인증 흐름(리액티브 라우터 예)

 

리액티브(WebFlux) 기반 인증 흐름. 비동기, 논블로킹 I/O

 

 

 

3. 예제(최소 설정)

build.gradle

    // 서블릿 기반(Spring MVC)
    //    implementation 'org.springframework.boot:spring-boot-starter-webmvc'

    // 리액티브 기반(WebFlux)
    implementation 'org.springframework.boot:spring-boot-starter-webflux'

 

 

SecurityConfig.java

@Configuration
@EnableWebFluxSecurity
@EnableReactiveMethodSecurity
//@EnableMethodSecurity
public class SecurityConfig {
    /**
     * 리액티브(WebFlux) 기반 SecurityFilterChain
     */
    @Bean
    SecurityWebFilterChain webfluxChain(ServerHttpSecurity http) {
        return http
                .csrf(csrf -> csrf.disable())
                .authorizeExchange(ex -> ex
                        .pathMatchers("/","/login","/public/**").permitAll()
                        .pathMatchers("/admin/**").hasRole("ADMIN")
                        .anyExchange().authenticated()
                )
                .formLogin(form -> form // 세션 기반 로그인 유지라서 브라우저에 인증 정보가 남아있음
                        .loginPage("/login")
                )
                .httpBasic(Customizer.withDefaults()) // 매 요청마다 Authorization 헤더로 인증을 보내는 형태임
                .build();
    }

    @Bean
    MapReactiveUserDetailsService userDetailsService() {
        UserDetails user = User.withUsername("user")
                .password("{noop}pass")
                .roles("USER")
                .build();
        UserDetails admin = User.withUsername("admin")
                .password("{noop}pass")
                .roles("ADMIN")
                .build();

        return new MapReactiveUserDetailsService(user, admin);
    }

 

 

HomeController.java

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import reactor.core.publisher.Mono;

@Controller
public class HomeController {
    @GetMapping("/")
    @ResponseBody
    public Mono<String> home() {
        return Mono.just("home");
    }

    /**
     * 리액티브(WebFlux) 기반 FilterChain을 사용할 때
     * FilterChain에 formLogin()과 httpBasic()을 둘 다 사용할때
     * 403이 뜰 수 있다
     */
    @PreAuthorize("hasRole('ADMIN')")
    @GetMapping("/admin/panel")
    @ResponseBody
    public Mono<String> admin() {
        return Mono.just("admin");
    }
}

 

 

LoginController.java

import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import reactor.core.publisher.Mono;

@Controller
public class LoginController {
    @GetMapping("/login")
    public Mono<String> login() {
        return Mono.just("login");
    }

    @GetMapping("/me")
    @ResponseBody
    public Mono<String> me(Authentication authentication) {
        if (authentication == null) {
            return Mono.just("anonymous");
        }
        return Mono.just(authentication.getName() + " / " + authentication.getAuthorities());
    }
}

 

 

 

 

4. 세션 / CSRF / 백프레셔

  • 세션 : 기본적으로 WebSession 기반임. 토큰 기반과 혼용시 ServerSecurityContextRepository 전략 검토하기
  • CSRF : 폼 제출 등 상태 유지형 요청에 필요함. API만 제공하면 비활성이 가능하나, 운영에서는 경로별 분리 권장

 

 

5. 성능, 확장성 특징

  • 이벤트 루프 기반으로, 적은 스레드로 높은 동시성 처리 가능
  • 블로킹 I/O 호출(전통 JDBC 등)이 섞이면 이점이 상쇄되므로 리액티브 스택 전반(DB, 외부 API)을 고려해야한다

 

 

6. 디버깅 체크리스트

  • 블로킹 호출 존재 여부 점검하기(리액터 스레드에서 블로킹 호출 금지)
  • pathMatchers 순서와 권한 맵핑 확인하기
  • 메서드 보안에서 Mono<Boolean> 조건을 사용할 때 비동기 평가 시점 주의하기

 

 

항목 내용
처리 모델 비동기 / 논블로킹, 이벤트 루프
보안 핵심 SecurityWebFilterChain + 리액티브 필터
주 사용처 스트리밍, IoT, 대규모 동시 연결 등
강점 높은 동시성, 효율적 리소스 사용
주의 전 스택 리액티브 요구(MVC와 섞어쓰면 X)
디버깅 난이도

플랫폼 선택과 심화 비교

1. 선택 가이드

  • 팀 역량과 기존 자산 : MVC 경험이 많고 JDBC 위주라면 서블릿이 유리함
  • 문제 성격 : 다수 장기 연결, 스트리밍, WebSocket 중심이라면 리액티브가 유리함
  • 외부 의존성 : 호출 대상 API/DB가 블로킹이라면 서블릿이 단순할 수 있다

2. 비교 표

항목 서블릿(Spring MVC) 리액티브(WebFlux)
실행 모델 요청당 스레드 이벤트 루프, 논블로킹
필터 체인 Servlet Filter 기반 WebFilter 기반
보안 체인 빈 SecurityFilterChain SecurityWebFilterChain
인증 저장 HttpSession에 SecurityContext WebSession 또는 토큰 컨텍스트
CSRF CsrfFilter CsrfWebFilter
메서드 보안 @PreAuthorize 즉시 평가 리액티브 컨텍스트에서 비동기 평가
적합한 업무 전통적 웹, 관리 백오피스 스트리밍, 실시간 알림, IoT
학습 난이도 낮음 높음
디버깅 익숙함 체계가 필요함(리액터 툴)

 

 

* CSRF에 대해서

SecurityFilterChain은 그냥 CsrfFilter임

그런데 CsrfWebFilter의 경우 implements로 WebFilter를 포함한다

public class CsrfWebFilter implements WebFilte

 

SecurityFilterChain, SecurityWebFilterChain 모두 똑같은 이름의 csrf 메서드를 사용하지만

.csrf(csrf -> csrf.disable())

 

CsrfSpec은 '아예' 다르다. 참고

 

 


적용 체크리스트

  • 경로별 보안 규칙 분리(/public/**, /api/**, /admin/**)
  • 인증 저장 전략 결정하기(세션 vs 토큰)
  • CSRF 전략 수립(폼 여부, API 분리)
  • 예외 처리 표준화하기(인증 실패 / 인가 실패에 대한 응답)
  • 로그, 감사 추적하기(SecurityContext, 사용자 ID)
  • 부하 테스트로 스레드 / 이벤트 루프 병목 점검하기
  • 외부 통신의 블로킹 / 논블로킹 여부 정리하기