Spring WebFlux WebFilter — 요청·응답 공통 처리 완전 정리

2026-05-03AWS SAA-C03 스터디

Spring WebFlux WebFilter 핵심 정리 — ServerWebExchange·WebFilterChain·@Order 실행 순서·인증 필터·Auth 필터·응답 헤더 추가·Reactor Context trace ID 전파까지. Servlet Filter·HandlerInterceptor와 차이, chain.filter() 호출 누락 함정, ThreadLocal 금지 이유를 비유와 코드로 풀어 정리.

📚 Spring WebFlux 핵심 정리 · 6편 / 14편 — 요청·응답 공통 처리 완전 정리

이 글은 Spring WebFlux 핵심 정리 시리즈의 여섯 번째 편입니다. 1편에서 이벤트 루프 모델을 잡고, 2~5편에서 R2DBC·CRUD·검증·예외 처리를 쌓았다면 이번 6편은 모든 요청이 컨트롤러에 도달하기 전에 반드시 통과하는 관문 — WebFilter입니다.

인증·로깅·Rate Limiting·CORS 같은 공통 로직을 컨트롤러 100개에 복사하는 대신, WebFilter 하나에서 한 번에 처리하는 구조예요. 이번 편의 핵심 질문은 세 가지입니다. "WebFilter는 어디서 실행되는가, 어떻게 순서를 잡는가, Spring MVC의 Filter·HandlerInterceptor와 무엇이 다른가" — 이 세 가지만 머리에 들어오면 충분합니다.

본문 흐름은 회사 정문 보안실 비유를 따라 풀어 가요. WebFilter = "회사 정문 보안실 + 출입 기록 카메라" — 건물 안에 들어오려면 모든 사람이 통과해야 하는 곳, 누가 왔는지 기록하고, 허가된 사람만 안으로 들여보내는 역할이에요.

학습 노트

이 시리즈는 Spring 공식 문서, Project Reactor 공식 문서, Reactive Streams 명세, 여러 공개 학습 자료를 참고해 한국어 학습 노트로 풀어쓴 자료입니다.

WebFilter 실습은 spring-boot-starter-webflux만 있으면 되고, 30분이면 인증 필터 하나를 직접 띄울 수 있어요.

WebFlux WebFilter가 처음엔 왜 어렵게 느껴질까요

이유는 네 가지예요.

첫째, 이미 Spring MVC의 Servlet Filter·HandlerInterceptor에 익숙한데 왜 또 다른 무언가를? 라는 의문이 듭니다. 동작처럼 보이는데 내부 모델이 완전히 달라서 기존 지식이 오히려 방해가 돼요.

둘째, chain.filter(exchange)를 return 해야 한다는 규칙이 처음엔 낯섭니다. Servlet Filter의 doFilter() 호출 방식과 비슷해 보이지만, 리액티브 파이프라인 위이므로 return을 빠뜨리면 구독 자체가 안 됩니다. 요청이 아무 이유 없이 사라지는 디버깅 지옥이 열려요.

셋째, ThreadLocal 기반 MDC가 동작하지 않습니다. Spring MVC 필터에서는 MDC.put("traceId", id) 한 줄이면 같은 스레드에서 로그에 자동으로 trace ID가 붙었어요. WebFlux 이벤트 루프는 스레드를 수시로 바꾸기 때문에 ThreadLocal이 날아갑니다. Reactor Context라는 새 개념이 필요해요.

넷째, 필터 순서(@Order)를 잘못 잡으면 권한 부여 필터가 인증 필터보다 먼저 실행됩니다. 인증이 안 된 상태에서 권한 검사를 하니 NPE나 엉뚱한 403이 떨어져요.

해결법은 하나예요. WebFilter를 "정문 보안실"로 잡고 풀면 갑자기 명확해집니다. 정문(CORS, 기본 보안) → 경비원 1번(인증) → 경비원 2번(권한 부여) → 기록 카메라(로깅) → 건물 내부(컨트롤러). 이 그림 하나가 모든 후속 개념을 묶어 줘요.

WebFlux WebFilter란 무엇인가 — 구조와 위치

WebFilter는 서버와 컨트롤러 사이에 위치하는 중개 컴포넌트예요. 모든 HTTP 요청·응답을 가로채서 공통 로직을 수행합니다.

동작 흐름을 정리하면:

클라이언트 요청
    ↓
[WebFilter 1: CORS 필터]
    ↓
[WebFilter 2: 인증 필터] ← 실패 시 401 응답 즉시 반환
    ↓
[WebFilter 3: 권한 부여 필터] ← 실패 시 403 응답 즉시 반환
    ↓
[WebFilter 4: 로깅 필터]
    ↓
컨트롤러 → 서비스 → 레포지토리
    ↓ (응답이 역방향으로 흐름)
[WebFilter 4: 응답 로깅]
    ↓
클라이언트 응답

각 필터는 chain.filter(exchange)를 기준으로 이전 = 요청 처리 전 로직 / 이후 = 응답 처리 후 로직으로 나뉩니다.

WebFilter 인터페이스는 이렇게 생겼어요.

@FunctionalInterface
public interface WebFilter {
    Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain);
}

ServerWebExchange가 핵심이에요. 요청과 응답을 하나로 묶은 컨텍스트 객체입니다.

  • exchange.getRequest() — HTTP 요청 (헤더, URI, 메서드, 쿠키 등)
  • exchange.getResponse() — HTTP 응답 (상태 코드 설정, 헤더 추가)
  • exchange.getAttributes() — 필터 간 데이터 공유용 Map

비유로 한 줄 정리 — WebFilter는 "회사 정문 보안실 + 출입 기록 카메라". 건물에 들어오려는 모든 사람이 통과해야 하고, 출입 기록을 남기며, 허가받지 않은 사람은 돌려보내는 역할이에요.

기본 WebFilter 구현 — 요청 로깅과 처리 시간 측정

가장 기본적인 형태부터 시작해요. 모든 요청의 메서드와 경로를 로깅하는 필터입니다.

@Component
@Slf4j
public class RequestLoggingFilter implements WebFilter {

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();
        log.info("Incoming request: {} {}", request.getMethod(), request.getPath().value());

        // 반드시 return해야 함 — 누락 시 컨트롤러 미도달, 클라이언트는 타임아웃
        return chain.filter(exchange);
    }
}

여기서 시험 함정이 하나 있어요. chain.filter(exchange)를 호출만 하고 return하지 않으면 요청이 완전히 사라집니다. 리액티브 파이프라인은 구독이 없으면 아무것도 실행하지 않아요. 반드시 return chain.filter(exchange)여야 해요.

처리 시간을 측정하는 패턴도 자주 씁니다.

@Component
@Slf4j
public class RequestTimingFilter implements WebFilter {

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
        long startTime = System.currentTimeMillis();
        String path = exchange.getRequest().getPath().value();

        return chain.filter(exchange)
                .doFinally(signalType -> {
                    long elapsed = System.currentTimeMillis() - startTime;
                    log.info("Request {} completed in {}ms (signal: {})", path, elapsed, signalType);
                });
        // doFinally: 완료(성공)/에러/취소 — 어떤 상황에서도 항상 실행됨
    }
}

doFinally는 성공이든 에러든 취소든 항상 실행되는 연산자예요. 처리 시간 로깅처럼 "무조건 실행해야 하는 정리 코드"에 딱 맞습니다.

@Order로 WebFlux WebFilter 실행 순서 잡기

여러 WebFilter가 있을 때 실행 순서를 명시적으로 지정해야 합니다. @Order 어노테이션을 씁니다 — 숫자가 낮을수록 먼저 실행돼요.

@Component
@Order(Ordered.HIGHEST_PRECEDENCE)  // 가장 먼저 — CORS 처리
public class CorsFilter implements WebFilter { ... }

@Component
@Order(1)  // 첫 번째 — 인증 (누구인가?)
public class AuthenticationFilter implements WebFilter { ... }

@Component
@Order(2)  // 두 번째 — 권한 부여 (무엇을 할 수 있는가?)
public class AuthorizationFilter implements WebFilter { ... }

@Component
@Order(Integer.MAX_VALUE)  // 가장 마지막 — 응답 헤더 추가
public class ResponseHeaderFilter implements WebFilter { ... }
@Order실행 순서권장 사용
Ordered.HIGHEST_PRECEDENCE가장 먼저CORS, 기본 보안
@Order(1)첫 번째인증 필터
@Order(2)두 번째권한 부여 필터
@Order(3)세 번째Rate Limiting
@Order(Integer.MAX_VALUE)가장 마지막응답 헤더 추가
어노테이션 없음순서 보장 없음권장하지 않음

여기서 시험 함정이 하나 있어요. @Order가 없는 여러 WebFilter가 공존하면 실행 순서가 보장되지 않습니다. 인증 없이 권한 부여 필터가 먼저 실행될 수 있어요. 반드시 @Order로 명시적 순서를 지정하세요.

인증 필터 — 토큰 검증 후 chain.filter vs 차단

실전에서 가장 자주 쓰는 WebFilter 패턴이에요. 헤더에서 토큰을 꺼내 검증하고, 유효하면 chain.filter(exchange)로 다음 단계에 진행하고, 유효하지 않으면 401을 반환합니다.

@Component
@Order(1)
@Slf4j
public class AuthenticationFilter implements WebFilter {

    private static final List<String> WHITELIST = List.of("/public", "/health", "/v3/api-docs");

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
        String path = exchange.getRequest().getPath().value();

        // 화이트리스트 경로는 인증 없이 통과
        if (WHITELIST.stream().anyMatch(path::startsWith)) {
            return chain.filter(exchange);
        }

        String authToken = exchange.getRequest().getHeaders().getFirst("auth-token");

        // 토큰 없음 → 401 반환 (chain.filter 호출 안 함 → 요청 차단)
        if (authToken == null || authToken.isBlank()) {
            log.warn("Missing auth token: {}", path);
            exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
            return exchange.getResponse().setComplete();
        }

        // 토큰 검증 성공 → 사용자 정보 Attributes에 저장하고 진행
        exchange.getAttributes().put("user-id", resolveUserId(authToken));
        return chain.filter(exchange);
    }

    private String resolveUserId(String token) {
        // 실제로는 DB나 JWT 파싱으로 검증
        return "user-001";
    }
}

exchange.getAttributes()는 필터 간 데이터 공유 메커니즘이에요. 인증 필터에서 사용자 ID를 저장해 두면, Order(2) 권한 부여 필터에서 그 값을 꺼내 쓸 수 있습니다.

여기서 시험 함정이 하나 있어요. chain.filter(exchange)를 호출하지 않으면 요청이 컨트롤러에 도달하지 않습니다. 401이나 403을 반환하려면 setStatusCode() + setComplete()를 반환하고 끝내야 해요. Mono.empty()를 반환해도 클라이언트는 응답을 받지 못하고 타임아웃이 발생합니다.

Reactor Context — WebFlux에서 trace ID 전파하기

Spring MVC에서는 MDC.put("traceId", id) 한 줄로 같은 스레드 내 모든 로그에 trace ID가 붙었어요. 하지만 WebFlux 이벤트 루프는 요청 처리 중 스레드를 바꿀 수 있어서 ThreadLocal 기반 MDC가 동작하지 않습니다.

해결책은 Reactor Context예요. 파이프라인을 따라 흐르는 불변 키-값 저장소로, 스레드가 바뀌어도 값이 유지됩니다.

@Component
@Order(1)
public class RequestIdFilter implements WebFilter {

    public static final String REQUEST_ID_KEY = "requestId";

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
        // 헤더에서 받거나, 없으면 새 UUID 생성
        String requestId = exchange.getRequest().getHeaders()
                .getFirst("X-Request-ID");
        if (requestId == null || requestId.isBlank()) {
            requestId = UUID.randomUUID().toString();
        }

        exchange.getAttributes().put(REQUEST_ID_KEY, requestId);

        final String finalId = requestId;
        // contextWrite: 다운스트림 Reactor 파이프라인에 값 주입
        return chain.filter(exchange)
                .contextWrite(ctx -> ctx.put(REQUEST_ID_KEY, finalId));
    }
}

여기서 시험 함정이 하나 있어요. Spring MVC Filter · HandlerInterceptor는 ThreadLocal에 저장해도 동작하지만, WebFlux WebFilter에서는 반드시 Reactor Context를 사용해야 합니다. 이벤트 루프는 4~8개 스레드가 수천 건의 요청을 돌아가며 처리하므로, 스레드가 바뀌는 순간 ThreadLocal 값이 다른 요청의 것으로 오염됩니다.

WebFlux WebFilter vs Servlet Filter vs HandlerInterceptor 비교

이 세 가지를 혼동하면 프레임워크를 교차 사용할 때 낭패를 봅니다. 정확한 차이를 정리해요.

항목Servlet FilterSpring MVC HandlerInterceptorSpring WebFlux WebFilter
프레임워크Servlet 표준Spring MVCSpring WebFlux
동작 방식블로킹블로킹논블로킹
반환 타입voidbooleanMono
접근 가능 정보HttpServletRequest/ResponseHandler, ModelAndViewServerWebExchange
Bean 주입직접 주입 어려움쉬움쉬움
스레드 모델스레드당 요청 (Thread-per-Request)스레드당 요청이벤트 루프

여기서 시험 함정이 하나 있어요. Spring MVC의 HandlerInterceptor나 Servlet Filter를 WebFlux 프로젝트에 그대로 가져오면 동작하지 않습니다. WebFlux는 Servlet 컨테이너(Tomcat) 위에서 돌지 않기 때문에 Servlet 기반 클래스를 사용할 수 없어요. WebFlux 전용 WebFilter 인터페이스를 구현해야 합니다.

응답 헤더 추가 필터

모든 응답에 버전 정보나 보안 헤더를 자동으로 추가하는 패턴이에요.

@Component
@Order(Integer.MAX_VALUE)
public class ResponseHeaderFilter implements WebFilter {

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
        return chain.filter(exchange)
                .doOnSuccess(v -> {
                    HttpHeaders headers = exchange.getResponse().getHeaders();
                    headers.add("X-App-Version", "1.0.0");
                    headers.add("X-Content-Type-Options", "nosniff");
                    headers.add("X-Frame-Options", "DENY");
                });
    }
}

@Order(Integer.MAX_VALUE)를 붙여 가장 마지막에 실행하게 하면, 다른 모든 필터가 처리된 뒤 응답에 헤더를 추가할 수 있어요.

실전에서 자주 쓰는 Rate Limiting 패턴도 한 번 짚어 두면 좋습니다.

@Component
@Order(3)
@Slf4j
public class RateLimitingFilter implements WebFilter {

    private final Map<String, AtomicInteger> requestCounts = new ConcurrentHashMap<>();
    private static final int MAX_PER_MINUTE = 60;

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
        String clientIp = getClientIp(exchange);
        AtomicInteger count = requestCounts.computeIfAbsent(clientIp, k -> new AtomicInteger(0));

        if (count.incrementAndGet() > MAX_PER_MINUTE) {
            log.warn("Rate limit exceeded for IP: {}", clientIp);
            exchange.getResponse().setStatusCode(HttpStatus.TOO_MANY_REQUESTS);
            return exchange.getResponse().setComplete();
        }

        return chain.filter(exchange);
    }

    private String getClientIp(ServerWebExchange exchange) {
        String forwarded = exchange.getRequest().getHeaders().getFirst("X-Forwarded-For");
        if (forwarded != null) return forwarded.split(",")[0].trim();
        InetSocketAddress remote = exchange.getRequest().getRemoteAddress();
        return remote != null ? remote.getAddress().getHostAddress() : "unknown";
    }
}

자주 만나는 함정 — 시험 직전 압축 노트

여기까지가 WebFlux 6편의 핵심입니다. 시험 직전 또는 실무에서 헷갈릴 때 다시 펼쳐 볼 수 있게 압축 노트로 마무리할게요.

  • WebFilter = 서버와 컨트롤러 사이에 위치 — 모든 요청이 반드시 통과하는 관문
  • filter(ServerWebExchange, WebFilterChain) — 반환 타입 Mono, 논블로킹
  • chain.filter(exchange) 반드시 return — 호출만 하고 return 안 하면 구독 없이 버려짐 → 컨트롤러 미도달
  • exchange.getRequest() / exchange.getResponse() / exchange.getAttributes() — 요청·응답·필터 간 데이터 공유
  • @Order 낮을수록 먼저 실행Ordered.HIGHEST_PRECEDENCE → CORS / Order(1) → 인증 / Order(2) → 권한 부여
  • @Order 없으면 실행 순서 보장 안 됨 — 인증 없이 권한 부여 먼저 실행될 수 있음
  • chain.filter() 호출 안 하면 요청 차단 — 401·403 반환 시 setStatusCode() + setComplete()
  • exchange.getAttributes().put(key, value) — 필터 간 데이터 공유 (인증 필터 → 권한 부여 필터)
  • ThreadLocal 금지 — 이벤트 루프는 스레드를 바꾸므로 ThreadLocal 값 오염 위험
  • Reactor Context 사용chain.filter(exchange).contextWrite(ctx -> ctx.put(key, value)) 로 trace ID 전파
  • doFinally(signalType -> ...) — 성공·에러·취소 모두 실행 (처리 시간 로깅에 적합)
  • Spring MVC Filter·HandlerInterceptor ≠ WebFilter — WebFlux는 Servlet 컨테이너 없음, 전용 WebFilter 구현 필요
  • WebFilter는 교차 관심사(Cross-cutting Concerns)만 — 인증·로깅·CORS·Rate Limiting
  • 요청 본문 내용 검증은 WebFilter에서 X — Controller의 @Valid나 Service 로직에서
  • doFinally vs doOnSuccess — doFinally는 취소·에러도 실행, doOnSuccess는 성공만
  • Ordered.HIGHEST_PRECEDENCE = -2^31 — 절대적으로 가장 먼저
  • 인증(Authentication) vs 권한 부여(Authorization) 분리 — Order 1·2로 관심사 명확히
  • WebFilter는 @Component 등록만으로 자동으로 WebFilter 체인에 추가됨
  • Rate Limiting은 실제로 Redis 등 분산 저장소 사용 — 단일 인스턴스 Map은 서버 재시작 시 초기화

시리즈 다른 편

같은 시리즈의 다른 글들도 같은 친절 톤으로 묶어 정리되어 있어요.

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!