WebClient · MongoDB — WebFlux 리액티브 스택 완성

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

Spring Boot 3 핵심 정리 시리즈 10편. WebClient·MongoDB Reactive·WebFlux.fn·WebTestClient·리액티브 OAuth2까지 — 리액티브 스택을 데이터베이스부터 API 클라이언트까지 일관되게 풀어쓴 글. WebTestClient로 논블로킹 컨트롤러를 어떻게 검증하는지, switchIfEmpty로 빈 결과를 예외로 변환하는 패턴, ReactiveCrudRepository로 MongoDB 문서를 다루는 법, RouterFunction 함수형 라우팅, WebClient.Builder를 통한 fluent HTTP 클라이언트, OAuth2 ExchangeFilter 등록까지 친절하게 풀어쓴 10편.

📚 Spring Boot 3 핵심 정리 · 10편 / 14편 — WebFlux 리액티브 스택 완성

이 글은 Spring Boot 3 핵심 정리 시리즈의 열 번째 편입니다. 9편에서 리액티브 프로그래밍의 입문 — Mono·Flux·R2DBC·기본 WebFlux 컨트롤러 — 을 풀어 봤다면, 이번 10편은 리액티브 스택을 데이터베이스부터 API 클라이언트까지 일관되게 완성하는 단계입니다. 핵심 도구 다섯 가지가 등장해요. 테스트용 WebTestClient, NoSQL 문서 저장소 MongoDB Reactive, 함수형 라우팅 WebFlux.fn, 비동기 HTTP 호출 도구 WebClient, 리액티브 OAuth2.

다섯 도구가 하나의 큰 그림으로 묶이는 게 핵심이에요. 들어오는 요청은 라우터(어노테이션 또는 함수형) → 서비스 → 리액티브 리포지토리(MongoDB) → 외부 API 호출(WebClient) — 이 모든 단계가 블로킹 한 줄도 없는 논블로킹 체인으로 흐릅니다. 한 군데라도 블로킹이 끼면 리액티브 스택의 장점이 다 무너지니, 끝에서 끝까지 Mono·Flux로 이어 가는 감각을 잡는 게 이번 글의 목표예요.

본문 흐름은 이렇습니다. 먼저 WebTestClient로 리액티브 컨트롤러를 어떻게 검증하는지부터 잡고, 리액티브 예외 처리의 함정을 짚어요. 그 다음 MongoDB Reactive로 NoSQL을 리액티브 스택에 끼우고, WebFlux.fn 함수형 라우팅을 풀어 보고, 마지막으로 WebClient로 외부 API를 비동기 호출하는 패턴 + OAuth2 통합까지 묶습니다.

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

이유는 네 가지예요.

첫째, 블로킹 vs 논블로킹의 경계가 한 줄로 깨집니다. findById()까지 멋지게 Mono로 받아 놓고 그 다음 단계에서 .block() 하나만 끼면 모든 리액티브 효과가 무효예요. 어디서 끊어지면 안 되는지 감각이 처음엔 안 잡힙니다.

둘째, 테스트 도구가 다릅니다. Spring MVC에서 익숙해진 MockMvc가 WebFlux에선 안 동작해요. 이건 단순히 이름이 바뀐 게 아니라 — MockMvc가 서블릿 API에 묶여 있어서 서블릿을 안 쓰는 WebFlux와 호환이 안 됩니다. 그래서 처음부터 다시 짠 WebTestClient가 필요해요.

셋째, 빈 결과를 예외로 어떻게 바꾸는지가 어려워요. 블로킹 코드에선 findById().orElseThrow(...)로 끝나지만, 리액티브에선 Mono가 비어 있을 수도 있고(데이터 없음) 에러로 끝날 수도 있어서 — switchIfEmpty(Mono.error(...)) 같은 별도 연산자를 알아야 해요.

넷째, 함수형 모델(WebFlux.fn)이 어노테이션 모델과 너무 달라요. @RestController에 익숙해진 눈으로 RouterFunction을 보면 "이게 컨트롤러야?" 싶죠. 어노테이션 없이 코드로 라우팅을 적는 패턴이라 처음엔 어색합니다.

해결법은 한 가지예요. WebClientMongoDB를 "택배 회사 + 무인 보관함" 비유로 잡으면 갑자기 명확해집니다. WebClient는 외부에 짐을 보내고 받아 오는 택배 기사고, MongoDB는 박스 모양·크기 상관없이 다 받아 주는 유연한 무인 보관함이에요. 이 비유를 따라 풀어 갈게요.

WebTestClient — 리액티브 컨트롤러 시험관

리액티브 컨트롤러를 테스트하는 가장 기본 도구가 WebTestClient입니다. 회사로 치면 — Spring MVC 시절에는 모의 면접관(MockMvc) 한 분이 모의 사무실(서블릿) 안에서 검증해 주셨다면, WebFlux에선 그 사무실이 아예 없어서 실제 회의실 같은 모의 환경에서 일하는 새 면접관(WebTestClient)이 필요해요.

설정은 두 어노테이션의 조합이에요.

어노테이션역할
@SpringBootTest전체 Spring Boot 컨텍스트 로드
@AutoConfigureWebTestClientWebTestClient를 컨텍스트에 자동 구성·주입
@SpringBootTest
@AutoConfigureWebTestClient
class ProductControllerTest {

    @Autowired
    WebTestClient webTestClient;

    @Test
    void testListProducts() {
        webTestClient.get()
                .uri("/api/v3/product")
                .exchange()
                .expectStatus().isOk()
                .expectHeader().valueEquals("Content-Type", "application/json")
                .expectBody()
                .jsonPath("$.size()").isEqualTo(3);
    }

    @Test
    void testGetProductById() {
        webTestClient.get()
                .uri("/api/v3/product/{id}", 1)
                .exchange()
                .expectStatus().isOk()
                .expectBody(ProductDTO.class);  // DTO 타입으로 역직렬화 검증
    }

    @Test
    void testCreateProduct() {
        ProductDTO newProduct = ProductDTO.builder()
                .productName("Sample Product")
                .category("Books")
                .price(new BigDecimal("9.99"))
                .build();

        webTestClient.post()
                .uri("/api/v3/product")
                .contentType(MediaType.APPLICATION_JSON)
                .bodyValue(newProduct)
                .exchange()
                .expectStatus().isCreated()
                .expectHeader().exists("Location");
    }

    @Test
    void testGetProductByIdNotFound() {
        webTestClient.get()
                .uri("/api/v3/product/{id}", 999)
                .exchange()
                .expectStatus().isNotFound();
    }
}

호출 흐름은 MockMvc와 거의 비슷해요 — 메서드 체이닝으로 GET/POST를 적고, .exchange()로 실제 요청을 보내고, .expectStatus()·.expectHeader()·.expectBody()로 검증합니다.

여기서 시험 함정이 하나 있어요. 리스트 응답과 단일 객체 응답의 검증 방식이 다릅니다. 리스트는 expectBody().jsonPath("$.size()").isEqualTo(3)처럼 jsonPath로 배열 크기를 확인하고, 단일 객체는 expectBody(ProductDTO.class)처럼 DTO 타입을 명시해 역직렬화 검증을 합니다. 이걸 헷갈리면 검증이 통과해 보이는데 사실 아무것도 검증 안 한 상태가 돼요.

또 하나 — @AutoConfigureWebTestClient를 깜빡하면 WebTestClient가 null로 주입돼서 NPE가 납니다. @SpringBootTest만으론 자동 구성이 안 돼요.

리액티브 예외 처리 — 빈 결과를 예외로 바꾸는 법

리액티브 스트림에서 처리되지 않은 예외는 이벤트 스트림 자체를 종료시킵니다. 회사로 치면 회의 중에 한 발표자가 갑자기 말없이 자리를 떠 버리는 거죠 — 회의 전체가 멈춥니다. 그래서 예외를 일반 평문처럼 다루지 말고, 스트림 안에서 명시적으로 처리해야 합니다.

블로킹 코드에선 자연스러운 흐름이 리액티브에선 함정이에요.

// 위험한 예 — 빈 Mono가 그대로 흘러감
public Mono<ProductDTO> getProductById(String id) {
    return productRepository.findById(id)
            .map(mapper::productToProductDTO);
    // 결과가 없으면 빈 Mono를 반환 → 컨트롤러까지 흘러감 → 200 OK + 빈 본문
}

// 올바른 예 — 빈 결과를 예외로 변환
public Mono<ProductDTO> getProductById(String id) {
    return productRepository.findById(id)
            .map(mapper::productToProductDTO)
            .switchIfEmpty(Mono.error(new NotFoundException()));
    // 빈 Mono → NotFoundException → @ControllerAdvice가 404로 변환
}

switchIfEmpty(Mono.error(...)) 패턴이 핵심이에요. 빈 결과를 명시적으로 에러로 바꿔야 컨트롤러 어드바이스에서 잡을 수 있습니다.

전역 예외 처리는 Spring MVC와 똑같이 @ControllerAdvice + @ExceptionHandler로 합니다.

@ControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(NotFoundException.class)
    public ResponseEntity<Void> handleNotFound(NotFoundException e) {
        return ResponseEntity.notFound().build();
    }

    @ExceptionHandler(WebExchangeBindException.class)
    public ResponseEntity<List<Map<String, String>>> handleValidation(
            WebExchangeBindException e) {
        // 유효성 검증 실패 → 400
        return ResponseEntity.badRequest().body(/* 에러 목록 */);
    }
}

여기서 시험 함정이 하나 있어요. flatMap 안에서 예외가 나면 그 자리에서 잡지 않는 한 스트림이 끝납니다. 리액티브 코드는 try/catch가 통하지 않아요. Mono.error(...)로 에러 이벤트를 명시적으로 전파하거나, onErrorResume(err -> ...)로 대체 흐름을 줘야 합니다.

> 한 줄 정리 — 빈 결과 = switchIfEmpty(Mono.error(...)) / 에러 변환 = onErrorResume(...) / 전역 처리 = @ControllerAdvice. 세 자리만 잡으면 리액티브 예외는 정복 끝.

Spring Data MongoDB Reactive — 유연한 무인 보관함

여기서 데이터베이스 쪽으로 한 칸 넘어갑니다. MongoDB는 NoSQL 문서 기반 데이터베이스인데, 리액티브 스택과 자연스럽게 어울려요. 회사 비유로는 — 관계형 DB가 칸이 정해진 우편함이라면, MongoDB는 박스 모양 상관없이 다 받아 주는 무인 보관함입니다. 같은 컬렉션에 들어가는 문서들이 필드 구성을 조금 달리해도 받아 줘요(스키마리스).

spring-boot-starter-data-mongodb-reactive 의존성 하나로 MongoDB 드라이버의 리액티브 버전이 자동 구성됩니다.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-mongodb-reactive</artifactId>
</dependency>

로컬에선 Docker로 한 노드 띄우는 게 편해요.

# docker-compose.yml
services:
  mongo:
    image: mongo
    environment:
      MONGO_INITDB_ROOT_USERNAME: root
      MONGO_INITDB_ROOT_PASSWORD: example
    ports:
      - "27017:27017"   # 반드시 노출 — 빠뜨리면 외부 접근 불가

도메인 모델은 JPA의 @Entity 대신 @Document 어노테이션을 써요.

@Document   // MongoDB 컬렉션과 매핑
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class Product {

    @Id
    private String id;   // MongoDB는 보통 String ID(ObjectId) 사용

    @NotBlank
    @Size(max = 50)
    private String productName;

    @NotNull
    private ProductCategory category;

    @NotBlank
    @Size(max = 255)
    private String upc;

    private Integer quantityOnHand;

    @NotNull
    private BigDecimal price;

    private LocalDateTime createdDate;
    private LocalDateTime lastModifiedDate;
}

리포지토리는 JPA의 JpaRepository 대신 ReactiveCrudRepository를 확장합니다. 모든 메서드가 Mono 또는 Flux를 반환해요.

public interface ProductRepository extends ReactiveCrudRepository<Product, String> {
    // 기본 CRUD: findById, findAll, save, deleteById ...
    // 모두 Mono/Flux 반환
    Flux<Product> findByCategory(ProductCategory category);
    Mono<Product> findByProductName(String name);
}

설정은 application.properties에 호스트·포트·인증 정보를 넣으면 끝.

spring.data.mongodb.host=localhost
spring.data.mongodb.port=27017
spring.data.mongodb.username=root
spring.data.mongodb.password=example
spring.data.mongodb.authentication-database=admin
spring.data.mongodb.database=sfg

MapStruct로 도메인-DTO 변환 자동화

Product(도메인) ↔ ProductDTO(DTO) 변환을 손으로 적으면 한 클래스에 수십 줄 보일러플레이트가 쌓여요. MapStruct가 이를 컴파일 타임에 자동 생성해 줍니다.

<dependency>
    <groupId>org.mapstruct</groupId>
    <artifactId>mapstruct</artifactId>
    <version>1.5.5.Final</version>
</dependency>

<!-- maven-compiler-plugin annotation processor -->
<configuration>
    <annotationProcessorPaths>
        <path>
            <groupId>org.mapstruct</groupId>
            <artifactId>mapstruct-processor</artifactId>
            <version>1.5.5.Final</version>
        </path>
        <path>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </path>
        <path>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok-mapstruct-binding</artifactId>
            <version>0.2.0</version>
        </path>
    </annotationProcessorPaths>
    <compilerArgs>
        <compilerArg>-Amapstruct.defaultComponentModel=spring</compilerArg>
    </compilerArgs>
</configuration>
@Mapper
public interface ProductMapper {
    ProductDTO productToProductDTO(Product product);
    Product productDTOToProduct(ProductDTO dto);
}

여기서 시험 함정 — Lombok과 MapStruct를 같이 쓸 때 lombok-mapstruct-binding이 빠지면 Lombok이 만든 getter를 MapStruct가 못 찾아 빈 매핑이 돼요. 셋(Lombok + MapStruct + binding)을 묶음으로 박는 게 정석.

Testcontainers로 실제 MongoDB 환경 테스트

통합 테스트에 내장형 MongoDB를 쓰면 호환성 문제가 생기기 쉬워요. 대신 Testcontainers로 컨테이너를 띄워 실제 환경에서 테스트하는 게 권장됩니다. 4편에서 다룬 패턴이 그대로 통해요.

> 한 줄 정리 — @Document + ReactiveCrudRepository + Testcontainers. 이 3종 세트가 MongoDB Reactive의 기본기.

WebFlux.fn — 어노테이션 없는 함수형 라우팅

여기서 새 패러다임이 등장해요. WebFlux.fn은 어노테이션 기반 @RestController 대신 함수 조합으로 라우팅을 적는 모델입니다.

회사 비유로 — @RestController가 "부서 명패가 붙은 부서장"이라면, WebFlux.fn은 "책상 위에 라우팅 표를 펼쳐 놓고 함수로 일을 분배하는 매니저"예요. 명패는 없지만 어디로 가야 할지가 코드로 명확히 보여요.

핵심 구성 요소 네 가지:

구성 요소역할
RouterFunctionHTTP 요청을 핸들러로 라우팅 (@RequestMapping 대체)
HandlerFunction실제 요청 처리 (@RequestMapping 메서드 대체)
ServerRequestHTTP 요청 정보 객체
ServerResponseHTTP 응답을 빌더 패턴으로 생성

핸들러 컴포넌트 먼저.

@Component
public class ProductHandler {

    private final ProductService productService;

    public ProductHandler(ProductService productService) {
        this.productService = productService;
    }

    public Mono<ServerResponse> listProducts(ServerRequest request) {
        return ServerResponse.ok()
                .body(productService.listProducts(), ProductDTO.class);
    }

    public Mono<ServerResponse> getProductById(ServerRequest request) {
        String productId = request.pathVariable("productId");
        return productService.getProductById(productId)
                .flatMap(dto -> ServerResponse.ok().bodyValue(dto))
                .switchIfEmpty(ServerResponse.notFound().build());
    }

    public Mono<ServerResponse> createProduct(ServerRequest request) {
        return request.bodyToMono(ProductDTO.class)
                .flatMap(dto -> productService.createProduct(dto))
                .flatMap(saved -> ServerResponse
                        .created(URI.create("/api/v3/product/" + saved.getId()))
                        .build());
    }

    public Mono<ServerResponse> updateProduct(ServerRequest request) {
        String productId = request.pathVariable("productId");
        return request.bodyToMono(ProductDTO.class)
                .flatMap(dto -> productService.updateProduct(productId, dto))
                .flatMap(updated -> ServerResponse.noContent().build())
                .switchIfEmpty(ServerResponse.notFound().build());
    }

    public Mono<ServerResponse> deleteProduct(ServerRequest request) {
        String productId = request.pathVariable("productId");
        return productService.deleteProductById(productId)
                .then(ServerResponse.noContent().build());
    }
}

라우터 설정.

@Configuration
public class ProductRouterConfig {

    public static final String PRODUCT_PATH = "/api/v3/product";
    public static final String PRODUCT_PATH_ID = PRODUCT_PATH + "/{productId}";

    @Bean
    public RouterFunction<ServerResponse> productRoutes(ProductHandler handler) {
        return RouterFunctions.route()
                .GET(PRODUCT_PATH, handler::listProducts)
                .GET(PRODUCT_PATH_ID, handler::getProductById)
                .POST(PRODUCT_PATH, handler::createProduct)
                .PUT(PRODUCT_PATH_ID, handler::updateProduct)
                .DELETE(PRODUCT_PATH_ID, handler::deleteProduct)
                .build();
    }
}

장점은 셋이에요. 함수형이라 더 간결하고, 어노테이션 없어 명시적이고, 마이크로서비스에 빠르게 적용됩니다. 학습 곡선은 함수형 프로그래밍에 익숙하지 않으면 조금 가팔라요.

여기까지 따라오셨다면 한 가지 의문이 들 거예요 — "어노테이션 방식과 함수형 방식, 둘 중 뭐가 더 나아요?" 답은 — 상황에 따라. 큰 비즈니스 로직 + 여러 어드바이저 + 인터셉터를 쓰는 큰 앱은 어노테이션 방식이 더 자연스럽고, 마이크로서비스나 단일 책임을 가진 작은 모듈은 함수형이 깔끔해요. 한 프로젝트 안에서도 두 방식을 섞을 수 있습니다.

WebClient — 비동기 택배 기사

이제 외부 API를 호출하는 도구로 넘어갑니다. WebClientRestTemplate의 리액티브 대안이에요. 회사 비유로는 — RestTemplate"한 번에 한 곳만 다녀오는 직원"이라면, WebClient"여러 곳을 동시에 비동기로 다녀오는 택배 기사"입니다.

RestTemplate이 블로킹 서블릿 API 기반이라 호출이 반환될 때까지 스레드가 묶여 있는 반면, WebClient는 WebFlux 스택에서 동작하면서 호출 결과를 Mono·Flux로 받아 리액티브 파이프라인에 그대로 통합돼요.

public interface ProductClient {
    Flux<Map> listProducts();
    Mono<ProductDTO> getProductById(String id);
    Mono<ProductDTO> createProduct(ProductDTO dto);
    Mono<ProductDTO> updateProduct(String id, ProductDTO dto);
    Mono<Void> deleteProduct(String id);
}

@Service
public class ProductClientImpl implements ProductClient {

    public static final String PRODUCT_PATH = "/api/v3/product";
    public static final String PRODUCT_PATH_ID = PRODUCT_PATH + "/{productId}";

    private final WebClient webClient;

    // Spring Boot가 자동 구성하는 WebClient.Builder 주입
    public ProductClientImpl(WebClient.Builder builder) {
        this.webClient = builder
                .baseUrl("http://localhost:8080")
                .build();
    }

    @Override
    public Flux<Map> listProducts() {
        return webClient.get()
                .uri(PRODUCT_PATH)
                .retrieve()
                .bodyToFlux(Map.class);   // 동적 JSON을 Map으로
    }

    @Override
    public Mono<ProductDTO> getProductById(String id) {
        return webClient.get()
                .uri(uri -> uri.path(PRODUCT_PATH_ID).build(id))
                .retrieve()
                .bodyToMono(ProductDTO.class);   // 특정 타입으로 역직렬화
    }

    @Override
    public Mono<ProductDTO> createProduct(ProductDTO dto) {
        return webClient.post()
                .uri(PRODUCT_PATH)
                .bodyValue(dto)
                .retrieve()
                .bodyToMono(ProductDTO.class);
    }
}

응답 처리 방식이 여러 가지인데, 상황에 맞게 골라요.

반환 타입사용처
String.class원시 JSON 문자열 (연결 테스트)
Map.class타입을 모르거나 동적 JSON
JsonNode.classJackson 강력 탐색 API 활용
ProductDTO.class특정 타입으로 역직렬화

WebClient.Builder를 주입받아 한 번 빌드한 WebClient를 빈으로 재사용하는 게 모범 사례예요. baseUrl·기본 헤더·필터 같은 공통 설정을 중앙에서 관리할 수 있고, Spring Boot가 자동 구성해 둔 빌더라 Reactor Netty 같은 기본값도 그대로 따라옵니다. 더 자세한 사양은 Spring WebFlux WebClient 공식 문서Spring Data MongoDB 공식 문서에서 확인할 수 있어요.

비동기 테스트 — Awaitility 패턴

WebClient의 호출은 비동기예요. 테스트에서 Thread.sleep(2000) 같은 식으로 결과를 기다리면 환경에 따라 실패하기 쉽습니다. 그 대신 Awaitility + AtomicBoolean으로 조건 기반 대기를 쓰는 게 정석.

@Test
void testListProducts() {
    AtomicBoolean done = new AtomicBoolean(false);

    productClient.listProducts()
            .doOnComplete(() -> done.set(true))
            .subscribe(System.out::println);

    await().untilTrue(done);   // 조건 충족 시 즉시 진행
}

여기서 시험 함정 — Thread.sleep()는 안티패턴이에요. CI 빌드 서버가 느릴 때, 테스트 결과가 환경 의존적으로 흔들립니다.

리액티브 OAuth2 — WebFlux 리소스 서버 + WebClient 클라이언트

WebFlux 앱도 OAuth2 리소스 서버로 보호할 수 있어요. 흥미로운 점은 — WebFlux와 Spring MVC의 리소스 서버 설정 방법이 거의 동일합니다. 두 스택이 같은 Spring Security 위에 얹혀 있어서 내부 메커니즘을 공유하기 때문이에요.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>

소비자 측에서 WebClient로 보호된 API를 호출할 때는 ReactiveOAuth2AuthorizedClientManager + 교환 필터를 거쳐야 해요.

# application.properties — OAuth2 클라이언트 설정
spring.security.oauth2.client.provider.spring-auth.token-uri=http://localhost:9000/oauth2/token
spring.security.oauth2.client.registration.spring-auth.provider=spring-auth
spring.security.oauth2.client.registration.spring-auth.client-id=messaging-client
spring.security.oauth2.client.registration.spring-auth.client-secret=secret
spring.security.oauth2.client.registration.spring-auth.scope=message.read,message.write
spring.security.oauth2.client.registration.spring-auth.authorization-grant-type=client_credentials
@Configuration
public class SpringSecurityConfig {

    @Bean
    public ReactiveOAuth2AuthorizedClientManager authorizedClientManager(
            ReactiveClientRegistrationRepository clientRegistrationRepository,
            ReactiveOAuth2AuthorizedClientService authorizedClientService) {

        ReactiveOAuth2AuthorizedClientProvider provider =
                ReactiveOAuth2AuthorizedClientProviderBuilder.builder()
                        .clientCredentials()
                        .build();

        DefaultReactiveOAuth2AuthorizedClientManager manager =
                new DefaultReactiveOAuth2AuthorizedClientManager(
                        clientRegistrationRepository, authorizedClientService);

        manager.setAuthorizedClientProvider(provider);
        return manager;
    }

    @Bean
    public WebClient webClient(ReactiveOAuth2AuthorizedClientManager manager) {
        ServerOAuth2AuthorizedClientExchangeFilterFunction oauth =
                new ServerOAuth2AuthorizedClientExchangeFilterFunction(manager);

        oauth.setDefaultClientRegistrationId("spring-auth");

        return WebClient.builder()
                .filter(oauth)   // 이 줄을 빠뜨리면 401 Unauthorized!
                .baseUrl("http://localhost:8080")
                .build();
    }
}

여기서 정말 중요한 시험 함정 — .filter(oauth)를 빠뜨리면 필터를 만들기만 하고 적용하지 않은 상태가 돼서 WebClient가 토큰 없이 호출하니 401이 떨어집니다. 또 의존성도 일반 spring-security-oauth2-client가 아닌 spring-boot-starter-oauth2-client(starter)를 써야 자동 구성이 활성화돼요.

> 한 줄 정리 — 리소스 서버 설정 = MVC와 동일 / 클라이언트 설정 = ReactiveOAuth2AuthorizedClientManager + .filter(oauth) 둘 다.

비교표 — 기억해 두면 헷갈리지 않는 핵심

MockMvc vs WebTestClient

특성MockMvcWebTestClient
대상Spring MVC (서블릿)Spring WebFlux (리액티브)
실제 서버불필요 (모의 서블릿)불필요 (WebFlux 컨텍스트)
서블릿 API의존미사용
리액티브 지원없음완전 지원
설정 어노테이션@AutoConfigureMockMvc@AutoConfigureWebTestClient
스트림 검증불가Flux 스트림 검증 가능

WebFlux 어노테이션 vs 함수형(WebFlux.fn)

특성어노테이션 (@Controller)WebFlux.fn (함수형)
구성 요소@Controller, @RequestMappingRouterFunction, HandlerFunction
스타일선언적함수형, 명령형
코드 구조클래스 + 메서드함수 조합
학습 곡선MVC 개발자에게 친숙함수형 사고 필요
마이크로서비스 적합성보통높음

RestTemplate vs WebClient vs RestClient

특성RestTemplateWebClientRestClient (Spring 6.1)
블로킹블로킹논블로킹블로킹
기반서블릿 API리액티브 스택RestTemplate 위에
API 스타일메서드 기반fluentfluent
반환 타입동기 TMono/Flux동기 T
권장 상황레거시 코드WebFlux 앱새 MVC 앱

Spring Data JPA vs Spring Data MongoDB

특성Spring Data JPASpring Data MongoDB
DB 유형관계형 (RDBMS)NoSQL 문서
기본 IDLong/UUIDString (ObjectId)
매핑 어노테이션@Entity·@Table@Document
리포지토리JpaRepositoryReactiveCrudRepository
스키마 관리Flyway/Liquibase스키마리스
리액티브 지원R2DBC 필요기본 지원

자주 만나는 함정 8가지

1. @AutoConfigureWebTestClient 누락

@SpringBootTest만으론 WebTestClient가 자동 주입 안 돼요. 두 어노테이션 같이 박기.

2. 경로 변수 바인딩 방법 오해

// 잘못 — id가 "{id}" 문자열로 전송됨
webTestClient.get().uri("/api/v3/product/{id}").exchange();

// 올바름 — 가변 인수로 바인딩
webTestClient.get().uri("/api/v3/product/{id}", 1L).exchange();

3. exchange() 호출 전 검증 메서드 호출

// 컴파일 에러 — exchange() 없이 expectStatus 호출 불가
webTestClient.get().uri("...").expectStatus().isOk();

// 올바름
webTestClient.get().uri("...").exchange().expectStatus().isOk();

4. 리액티브 빈 결과 처리 누락

switchIfEmpty(Mono.error(...)) 없이 그냥 두면 빈 응답이 200 OK로 흘러갑니다. 4-5번 항목과 자주 결합돼 함정이 됩니다.

5. WebClient 비동기 테스트에 Thread.sleep()

환경 따라 실패. Awaitility + AtomicBoolean 조합으로 조건 기반 대기.

6. OAuth2 WebClient 설정에서 .filter() 누락

ServerOAuth2AuthorizedClientExchangeFilterFunction을 만들기만 하고 .filter()로 적용 안 하면 401. 가장 흔한 실수.

7. starter가 아닌 일반 의존성 사용

spring-security-oauth2-client만 박으면 자동 구성 활성화 안 됨 → spring-boot-starter-oauth2-client가 정답.

8. Docker Compose에서 ports 누락

# 잘못 — 외부에서 27017 접근 불가
mongo:
  image: mongo
  # ports 없음

# 올바름
mongo:
  image: mongo
  ports:
    - "27017:27017"   # 반드시 노출

핵심 압축 노트 — 시험 직전 한 번 더

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

  • WebFlux 테스트 = WebTestClient (MockMvc는 서블릿 의존이라 WebFlux에서 못 씀)
  • 설정 = @SpringBootTest + @AutoConfigureWebTestClient 같이
  • 리스트 검증 = jsonPath("$.size()") / 단일 객체 = expectBody(DTO.class)
  • 빈 Mono → 예외switchIfEmpty(Mono.error(...))
  • 에러 변환 = onErrorResume(...) / 전역 처리 = @ControllerAdvice
  • MongoDB Reactive = @Document + ReactiveCrudRepository
  • MongoDB ID는 보통 String (ObjectId), JPA의 Long과 다름
  • Docker Compose에 ports: "27017:27017" 반드시 포함
  • MapStruct + Lombok 같이 쓸 때 lombok-mapstruct-binding 필수
  • 통합 테스트는 Testcontainers (내장형은 호환성 위험)
  • WebFlux.fn = RouterFunction + HandlerFunction (어노테이션 대체)
  • 어노테이션 vs 함수형 — 큰 앱 = 어노테이션 / 작은 마이크로서비스 = 함수형
  • WebClient.Builder 주입 → baseUrl 설정 → 빈으로 재사용
  • 응답 처리 — String(테스트) / Map(동적) / JsonNode(탐색) / DTO(타입)
  • 비동기 테스트 = Awaitility + AtomicBoolean (Thread.sleep 안티패턴)
  • OAuth2 WebClient = ReactiveOAuth2AuthorizedClientManager + .filter(oauth)
  • .filter() 빠뜨리면 → 401 Unauthorized (가장 흔한 실수)
  • 의존성은 spring-boot-starter-oauth2-client (starter)
  • WebFlux ↔ Spring MVC 리소스 서버 설정은 거의 동일 — Security 메커니즘 공유
  • 응답 검증은 exchange() 호출 후에만 가능 (체이닝 순서 주의)
  • 경로 변수 바인딩 = .uri("/path/{id}", value) 가변 인수 형태
  • 리액티브 코드는 try/catch 안 통함 — Mono.error·onErrorResume로 흐름 제어

시리즈 다른 편

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

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

답글 남기기

error: Content is protected !!