WebFlux Security — 리액티브 환경에서의 인증과 인가
Spring Security를 잘 알고 있다고 생각했는데, WebFlux 프로젝트에서
SecurityContextHolder.getContext()가null을 반환하는 걸 보고 당황한 적 없으신가요?
MVC에서 익숙하게 쓰던 Spring Security가 리액티브 환경에서는 완전히 다른 방식으로 동작합니다. Servlet 컨테이너도 없고, ThreadLocal도 쓸 수 없는 환경에서 인증과 인가를 어떻게 처리하는지 정리해 보겠습니다.
MVC Security와 WebFlux Security, 뭐가 다를까
Spring Security MVC는 Servlet Filter 체인 위에서 동작합니다. 요청이 들어오면 FilterChain을 타고 SecurityContextHolder(ThreadLocal 기반)에 인증 정보가 저장되고, 컨트롤러에서 꺼내 쓰는 구조입니다.
WebFlux에서는 이 전제가 통째로 사라집니다.
- Servlet 컨테이너가 없다 —
javax.servlet.Filter를 사용할 수 없습니다 - ThreadLocal이 무의미하다 — 하나의 요청이 여러 스레드를 넘나들 수 있습니다
- ** 모든 것이 비동기다** — 인증 결과도
Mono<Authentication>으로 반환됩니다
Spring Security는 리액티브 전용 모듈을 따로 제공합니다. 핵심 매핑을 정리하면 이렇습니다.
| MVC (Servlet) | WebFlux (Reactive) |
|---|---|
HttpSecurity | ServerHttpSecurity |
SecurityFilterChain | SecurityWebFilterChain |
SecurityContextHolder | ReactiveSecurityContextHolder |
AuthenticationManager | ReactiveAuthenticationManager |
UserDetailsService | ReactiveUserDetailsService |
클래스 이름만 바뀐 게 아니라, 내부 동작 원리가 완전히 다릅니다. MVC Security 코드를 복붙하면 컴파일은 되더라도 런타임에 제대로 동작하지 않는 경우가 많습니다.
ServerHttpSecurity와 SecurityWebFilterChain
WebFlux에서 보안 설정의 시작점은 ServerHttpSecurity입니다. MVC의 HttpSecurity와 비슷한 DSL을 제공하지만, 빌드 결과가 SecurityWebFilterChain이라는 점이 다릅니다.
@Configuration
@EnableWebFluxSecurity
public class SecurityConfig {
@Bean
public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
return http
// CSRF 비활성화 (REST API인 경우)
.csrf(ServerHttpSecurity.CsrfSpec::disable)
// 경로별 인가 규칙
.authorizeExchange(exchanges -> exchanges
.pathMatchers("/api/public/**").permitAll()
.pathMatchers("/api/admin/**").hasRole("ADMIN")
.anyExchange().authenticated()
)
// HTTP Basic 인증 활성화
.httpBasic(Customizer.withDefaults())
// 폼 로그인 비활성화
.formLogin(ServerHttpSecurity.FormLoginSpec::disable)
.build();
}
}
몇 가지 눈에 띄는 차이점이 있습니다.
@EnableWebFluxSecurity를 사용합니다 (@EnableWebSecurity가 아닙니다)authorizeRequests대신authorizeExchange를 씁니다antMatchers대신pathMatchers를 씁니다- 빌드 결과가
SecurityWebFilterChain이고, 이것이 WebFilter로 등록됩니다
SecurityWebFilterChain은 내부적으로 여러 WebFilter의 체인으로 구성됩니다. Servlet의 FilterChain과 개념은 비슷하지만, 각 필터가 Mono<Void>를 반환하는 리액티브 방식으로 동작합니다.
ReactiveSecurityContextHolder — ThreadLocal 없이 사용자 정보 전파하기
MVC에서는 SecurityContextHolder.getContext().getAuthentication()으로 언제든 현재 사용자 정보를 꺼낼 수 있었습니다. ThreadLocal이니까 같은 스레드 안에서는 어디서든 접근 가능했습니다.
WebFlux에서는 이게 불가능합니다. 대신 Reactor의 Context 를 활용합니다.
@RestController
public class UserController {
@GetMapping("/api/me")
public Mono<String> currentUser() {
// Reactor Context에서 보안 컨텍스트를 꺼낸다
return ReactiveSecurityContextHolder.getContext()
.map(SecurityContext::getAuthentication)
.map(Authentication::getName);
}
}
Reactor Context는 구독 시점에 아래에서 위로 전파되는 불변 맵입니다. Spring Security의 ReactorContextWebFilter가 요청마다 SecurityContext를 Reactor Context에 넣어주기 때문에, 리액티브 체인 안에서는 어디서든 접근할 수 있습니다.
컨트롤러 메서드에서 더 간결하게 사용하는 방법도 있습니다.
@GetMapping("/api/me")
public Mono<String> currentUser(
// 스프링이 Reactor Context에서 자동으로 주입
@AuthenticationPrincipal Mono<UserDetails> principal) {
return principal.map(UserDetails::getUsername);
}
@AuthenticationPrincipal의 타입이Mono<UserDetails>인 점에 주목하세요. MVC에서는UserDetails를 직접 받지만, WebFlux에서는Mono로 감싸서 받습니다. 이 차이를 놓치면 타입 캐스팅 에러가 발생합니다.
ReactiveAuthenticationManager — 비동기 인증 처리
MVC의 AuthenticationManager가 Authentication authenticate(Authentication)으로 동기 인증을 처리했다면, 리액티브에서는 ReactiveAuthenticationManager가 Mono<Authentication>을 반환합니다.
@Bean
public ReactiveAuthenticationManager authenticationManager(
ReactiveUserDetailsService userDetailsService,
PasswordEncoder passwordEncoder) {
// UserDetailsRepositoryReactiveAuthenticationManager가 기본 구현체
var manager = new UserDetailsRepositoryReactiveAuthenticationManager(userDetailsService);
manager.setPasswordEncoder(passwordEncoder);
return manager;
}
커스텀 인증 로직이 필요하면 직접 구현할 수도 있습니다. 반환 타입이 Mono<Authentication>이므로 내부에서 외부 API 호출 같은 비동기 작업도 자연스럽게 체인에 엮을 수 있습니다.
ReactiveUserDetailsService — 사용자 조회도 리액티브로
DB에서 사용자를 조회하는 UserDetailsService도 리액티브 버전이 있습니다.
@Service
public class CustomUserDetailsService implements ReactiveUserDetailsService {
private final UserRepository userRepository;
@Override
public Mono<UserDetails> findByUsername(String username) {
return userRepository.findByUsername(username)
.map(user -> User.builder()
.username(user.getUsername())
.password(user.getPassword())
.roles(user.getRoles().toArray(new String[0]))
.build())
.switchIfEmpty(Mono.error(
new UsernameNotFoundException("사용자를 찾을 수 없습니다: " + username)));
}
}
R2DBC나 MongoDB Reactive 같은 리액티브 데이터 액세스 기술과 함께 쓰면 요청 처리 전체가 논블로킹으로 동작합니다. JDBC를 쓰면 여기서 블로킹이 발생해 리액티브의 이점이 사라지므로 주의해야 합니다.
JWT 인증 구현
WebFlux 환경에서 JWT 인증을 구현하는 핵심은 AuthenticationWebFilter에 커스텀 컨버터와 매니저를 연결하는 것입니다.
@Configuration
@EnableWebFluxSecurity
public class JwtSecurityConfig {
@Bean
public SecurityWebFilterChain jwtFilterChain(ServerHttpSecurity http) {
return http
.csrf(ServerHttpSecurity.CsrfSpec::disable)
.httpBasic(ServerHttpSecurity.HttpBasicSpec::disable)
.authorizeExchange(exchanges -> exchanges
.pathMatchers("/api/auth/**").permitAll()
.anyExchange().authenticated()
)
// JWT 인증 필터 추가
.addFilterAt(jwtAuthenticationFilter(), SecurityWebFiltersOrder.AUTHENTICATION)
.build();
}
private AuthenticationWebFilter jwtAuthenticationFilter() {
var filter = new AuthenticationWebFilter(jwtAuthManager());
// Authorization 헤더에서 Bearer 토큰 추출
filter.setServerAuthenticationConverter(exchange -> {
String authHeader = exchange.getRequest()
.getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
return Mono.empty();
}
String token = authHeader.substring(7);
return Mono.just(new UsernamePasswordAuthenticationToken(token, token));
});
return filter;
}
private ReactiveAuthenticationManager jwtAuthManager() {
return authentication -> {
String token = authentication.getCredentials().toString();
try {
Claims claims = Jwts.parserBuilder()
.setSigningKey(secretKey).build()
.parseClaimsJws(token).getBody();
return Mono.just(new UsernamePasswordAuthenticationToken(
claims.getSubject(), null, extractAuthorities(claims)));
} catch (JwtException e) {
return Mono.error(new BadCredentialsException("유효하지 않은 JWT"));
}
};
}
}
MVC에서는
OncePerRequestFilter를 상속해서 JWT 필터를 만들었지만, WebFlux에서는AuthenticationWebFilter를 사용합니다. 인증 흐름(컨버터 -> 매니저 -> 성공/실패 핸들러)이 이미 갖춰져 있어 직접WebFilter를 구현하는 것보다 훨씬 편합니다.
Method Security — @PreAuthorize의 리액티브 버전
메서드 수준 보안도 WebFlux에서 사용할 수 있습니다. 설정 방법이 약간 다릅니다.
@Configuration
@EnableReactiveMethodSecurity
public class MethodSecurityConfig {
// MVC에서는 @EnableMethodSecurity를 사용
}
서비스 레이어에서 이렇게 씁니다.
@Service
public class ArticleService {
@PreAuthorize("hasRole('ADMIN')")
public Mono<Article> deleteArticle(String id) {
return articleRepository.deleteById(id);
}
@PreAuthorize("#username == authentication.name")
public Mono<UserProfile> getProfile(String username) {
return userRepository.findByUsername(username);
}
}
반환 타입이 Mono나 Flux이면 자동으로 리액티브 방식으로 동작합니다. 내부적으로 Reactor Context에서 Authentication을 꺼내 권한을 검사합니다.
CORS와 CSRF 설정
CORS
@Bean
public SecurityWebFilterChain corsFilterChain(ServerHttpSecurity http) {
return http
.cors(cors -> cors.configurationSource(corsConfig()))
.build();
}
private CorsConfigurationSource corsConfig() {
CorsConfiguration config = new CorsConfiguration();
config.addAllowedOrigin("https://example.com");
config.addAllowedMethod("*");
config.addAllowedHeader("*");
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}
CSRF
WebFlux의 CSRF는 기본 활성화입니다. REST API라면 보통 비활성화하지만, SPA와 함께 쓴다면 쿠키 기반 CSRF 토큰을 쓸 수 있습니다.
@Bean
public SecurityWebFilterChain csrfFilterChain(ServerHttpSecurity http) {
return http
.csrf(csrf -> csrf
// SPA에서 읽을 수 있도록 쿠키에 토큰 저장
.csrfTokenRepository(CookieServerCsrfTokenRepository.withHttpOnlyFalse()))
.build();
}
MVC의 CookieCsrfTokenRepository와 대응되는 CookieServerCsrfTokenRepository를 사용합니다.
테스트 — 리액티브 환경에서 @WithMockUser 사용하기
Spring Security Test는 WebFlux 환경에서도 @WithMockUser를 지원합니다. WebTestClient와 함께 사용하면 됩니다.
@WebFluxTest(UserController.class)
@Import(SecurityConfig.class)
class UserControllerTest {
@Autowired
private WebTestClient webTestClient;
@Test
@WithMockUser(username = "testuser", roles = {"USER"})
void 인증된_사용자는_프로필을_조회할_수_있다() {
webTestClient.get()
.uri("/api/me")
.exchange()
.expectStatus().isOk()
.expectBody(String.class)
.isEqualTo("testuser");
}
@Test
void 인증되지_않은_요청은_401을_반환한다() {
webTestClient.get().uri("/api/me")
.exchange()
.expectStatus().isUnauthorized();
}
@Test
void mutateWith로_인증을_수동_주입할_수도_있다() {
webTestClient
.mutateWith(SecurityMockServerConfigurers.mockUser("admin").roles("ADMIN"))
.get().uri("/api/admin/dashboard")
.exchange()
.expectStatus().isOk();
}
@Test
void JWT_모킹_테스트() {
webTestClient
.mutateWith(SecurityMockServerConfigurers.mockJwt()
.jwt(jwt -> jwt.subject("testuser")
.claim("roles", List.of("ROLE_USER"))))
.get().uri("/api/me")
.exchange()
.expectStatus().isOk();
}
}
SecurityMockServerConfigurers가 제공하는 mockUser(), mockJwt() 등의 메서드를 활용하면 다양한 인증 시나리오를 테스트할 수 있습니다.
@WithMockUser는 테스트 메서드 단위로 적용되고,mutateWith()는 요청 단위로 적용됩니다. 하나의 테스트에서 여러 역할을 검증해야 할 때는mutateWith()쪽이 더 유연합니다.
정리
WebFlux Security를 공부하다 보면 MVC Security와 개념은 비슷한데 클래스 이름과 동작 방식이 미묘하게 달라서 헷갈립니다. 핵심만 짚으면 이렇습니다.
- Servlet Filter -> WebFilter:
SecurityWebFilterChain이 WebFilter 체인으로 동작합니다 - ThreadLocal -> Reactor Context:
ReactiveSecurityContextHolder가 보안 컨텍스트를 전파합니다 - ** 동기 -> Mono/Flux**: 인증, 사용자 조회 등 모든 결과가 리액티브 타입으로 반환됩니다
- **DSL은 거의 동일 **:
authorizeExchange,pathMatchers등 이름만 약간 다릅니다
MVC Security 패턴을 그대로 가져오되, "ThreadLocal 대신 Reactor Context"라는 원칙만 기억하면 전환이 훨씬 수월해집니다.