WebFlux — Mono Flux와 R2DBC 입문

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

Spring Boot 3 핵심 정리 시리즈 9편. 리액티브 프로그래밍이 처음엔 왜 어렵게 느껴지는지부터 비동기 컨베이어 벨트 비유로 풀어가며 — 동기 블로킹의 한계와 논블로킹의 가치, Project Reactor의 Mono(0~1개 결과)와 Flux(0~N개 결과), map vs flatMap의 차이, R2DBC로 관계형 데이터베이스에 비동기 접근, Spring WebFlux 컨트롤러 작성법, WebTestClient로 리액티브 API 테스트, block() 금지 함정과 데드락 위험까지 처음 다루는 분도 따라올 수 있게 친절하게 풀어쓴 9편.

📚 Spring Boot 3 핵심 정리 · 9편 / 14편 — Mono Flux와 R2DBC 입문

이 글은 Spring Boot 3 핵심 정리 시리즈의 아홉 번째 편입니다. 8편까지 따라오셨다면 이제 동기 방식의 Spring MVC + RestTemplate/RestClient로 백엔드 한 사이클을 완전히 다룰 수 있을 거예요. 그런데 트래픽이 폭증하는 환경 — 예를 들어 동시 사용자 수가 만 단위로 올라가면 — 동기 방식의 한계가 보입니다. 한 요청당 한 스레드가 묶이고, I/O 대기 동안 그 스레드는 그냥 멈춰 있어요. 결국 스레드 풀이 고갈되어 시스템이 무너집니다.

9편의 주제는 이 한계를 풀어 주는 WebFlux 와 그 기반인 리액티브 프로그래밍 입니다. 이름이 어렵게 들려도 비동기 컨베이어 벨트라는 비유로 잡으면 한 번에 그림이 그려져요.

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

이유는 네 가지예요.

첫째, 사고방식 자체가 바뀌어야 합니다. 그동안 우리가 쓴 코드는 "한 줄씩 차례로 실행되고 결과를 변수에 받는" 식이었어요. 리액티브는 "값이 언젠가 도착할 약속을 만들고 그 약속에 변환을 연결하는" 식이라 — 같은 동작을 표현하는 코드 모양이 달라집니다.

둘째, Mono·Flux가 마법처럼 보입니다. 리턴 타입이 Product가 아니라 Mono로 바뀌고, .map()·.flatMap()·.switchIfEmpty() 같은 연산자가 줄지어 나와요. 처음에는 이게 다 뭐지 싶은 마음이 들어요.

셋째, mapflatMap의 차이가 헷갈립니다. 둘 다 변환 같은데 어디서 어느 걸 써야 할지 — 한 번 잘못 쓰면 컴파일은 되는데 결과가 이상하게 나와요.

넷째, JPA가 안 통합니다. Hibernate·JPA는 동기 블로킹 기반이라 리액티브 스택에서 못 써요. 그 자리를 R2DBC가 대신하는데, 이게 또 JPA만큼 풍부하지 않아 학습할 게 따로 있어요.

해결법은 한 가지예요. WebFlux를 "비동기 컨베이어 벨트" 로, Mono"한 개 결과 약속", Flux"흐르는 결과 약속" 으로 잡으면 갑자기 명확해집니다. map은 컨베이어 위에서 모양 바꾸기, flatMap은 또 다른 컨베이어를 갖다 붙이기 — 이 비유로 풀어 갑니다.

왜 리액티브인가 — 동기 방식의 한계

먼저 큰 그림부터. 기존 서블릿 기반 웹 애플리케이션은 요청 하나당 스레드 하나를 사용해요. 데이터베이스 쿼리나 외부 API 호출처럼 I/O를 기다리는 동안 — 스레드는 멈춰서 응답을 기다립니다. 동시 요청이 만 건 들어오면? 만 개의 스레드가 필요한데 — 그 정도 스레드는 시스템이 감당하지 못해요.

회사 비유로 — 손님 한 명에 직원 한 명이 전담으로 붙는 식이에요. 손님이 음식을 기다리는 동안 직원도 같이 멈춰 있어요. 손님이 만 명이면 직원도 만 명 필요 — 인건비가 폭발합니다.

리액티브 프로그래밍은 다른 모델이에요. 소수의 직원이 컨베이어 벨트 옆에 서서, 음식이 도착할 때마다 알림을 받고 손님 자리로 옮기는 방식이죠. 한 직원이 동시에 100명을 담당할 수 있어요. 이게 논블로킹 + 이벤트 기반 패러다임의 그림입니다.

여기서 정말 중요한 시험 함정 — 리액티브 = 빠름이 아닙니다. 개별 요청 하나의 처리 속도는 동기 방식과 비슷하거나 오히려 살짝 느릴 수 있어요. 리액티브의 진짜 가치는 높은 부하에서 적은 자원으로 더 많은 동시성을 처리하는 것 — 즉 확장성이에요.

리액티브 선언문 — 4가지 핵심 속성

속성설명
반응성(Responsive)시스템은 적시에 응답해야 한다
탄력성(Resilient)장애가 발생해도 응답성을 유지해야 한다 (복제·격리·위임)
확장성(Elastic)부하에 따라 자원을 동적으로 조절한다
메시지 기반(Message-Driven)비동기 메시지로 컴포넌트 간 통신하여 느슨한 결합 구현

이 네 속성을 "좋은 시스템이 가져야 할 4가지 가치" 로 묶은 게 리액티브 선언문이에요. 이름이 거창하지만 실제 내용은 시스템 설계 상식과 크게 다르지 않습니다.

리액티브 스트림즈 API — 표준 인터페이스 4개

리액티브 스트림즈(Reactive Streams)는 논블로킹 비동기 스트림 처리를 위한 표준 API예요. 네 가지 인터페이스로 구성됩니다.

public interface Publisher<T> {
    void subscribe(Subscriber<? super T> subscriber);
}

public interface Subscriber<T> {
    void onSubscribe(Subscription subscription);  // 구독 시작
    void onNext(T item);                          // 데이터 수신
    void onError(Throwable throwable);            // 에러 수신
    void onComplete();                            // 스트림 완료
}

public interface Subscription {
    void request(long n);  // n개의 항목 요청 (배압 제어)
    void cancel();         // 구독 취소
}

public interface Processor<T, R> extends Subscriber<T>, Publisher<R> {
    // Publisher이자 Subscriber 역할 (데이터 변환)
}

여기서 가장 중요한 키워드가 배압(Back Pressure) 이에요. 소비자가 처리할 수 있는 만큼만 데이터를 요청(request(n))해서 — 생산자가 너무 빨리 데이터를 쏟아 시스템이 넘치지 않게 하는 메커니즘입니다. 단순한 옵저버 패턴과 리액티브 스트림즈의 가장 큰 차이가 이 배압이에요.

회사 비유로 — 컨베이어 벨트에 손님이 "다음 5개만 보내 주세요"라고 신호를 보내는 식이에요. 무작정 쏟아붓지 않고, 받는 쪽 속도에 맞춰 흐름을 제어합니다.

Mono와 Flux — Project Reactor의 두 약속

Spring WebFlux는 리액티브 스트림즈의 구현체로 Project Reactor를 사용해요. Reactor는 두 가지 핵심 타입을 제공합니다.

Mono — 한 개 결과 약속

Mono0개 또는 1개의 요소를 방출하는 Publisher예요. 단건 조회·저장·삭제처럼 단일 결과가 예상되는 작업에 씁니다.

// Mono 생성 방법
Mono<String> mono1 = Mono.just("Hello");                      // 값 하나 포함
Mono<String> mono2 = Mono.empty();                            // 빈 Mono
Mono<String> mono3 = Mono.error(new RuntimeException());      // 에러 Mono

// Mono 변환 연산자
Mono<String> result = Mono.just("hello")
        .map(s -> s.toUpperCase())             // 동기 변환
        .flatMap(s -> Mono.just(s + "!"))      // 비동기 변환
        .filter(s -> s.startsWith("H"))        // 조건 필터
        .defaultIfEmpty("DEFAULT");            // 비어있을 때 기본값

// 구독 (실제 실행 시작!)
result.subscribe(
        value -> System.out.println("받은 값: " + value),
        error -> System.out.println("에러: " + error),
        () -> System.out.println("완료!")
);

여기서 시험 함정이 하나 있어요. subscribe()를 호출하기 전까지 아무 일도 일어나지 않아요. 위 코드에서 result는 그냥 "이런 변환을 하겠다는 약속"일 뿐 — 실제 실행은 마지막 줄 subscribe(...)에서 시작됩니다. 이 차이를 모르면 "왜 내 코드가 안 도는 걸까" 한참 고민해요. WebFlux 컨트롤러는 자동 구독해 주지만, 일반 코드에서는 명시 구독해야 합니다.

Flux — 흐르는 결과 약속

Flux0개 이상의 요소를 순차적으로 방출하는 Publisher예요. 목록 조회나 스트리밍처럼 다수의 결과가 예상되는 작업에 씁니다.

// Flux 생성 방법
Flux<Integer> flux1 = Flux.just(1, 2, 3, 4, 5);                    // 여러 값
Flux<Integer> flux2 = Flux.range(1, 10);                            // 1부터 10까지
Flux<String>  flux3 = Flux.fromIterable(list);                      // 컬렉션에서 생성
Flux<Long>    flux4 = Flux.interval(Duration.ofSeconds(1));         // 1초마다 값 방출

// Flux 변환 연산자
Flux<String> result = Flux.just(1, 2, 3, 4, 5)
        .filter(n -> n % 2 == 0)          // 짝수만 필터
        .map(n -> "Number: " + n)         // 변환
        .take(2)                           // 처음 2개만
        .doOnNext(s -> System.out.println("처리 중: " + s))  // 사이드 이펙트
        .doOnComplete(() -> System.out.println("완료!"));

// 컬렉션으로 수집 (Flux → Mono<List>)
Mono<List<String>> list = result.collectList();

MonoFlux를 한 표로 비교해 두면 헷갈리지 않아요.

항목MonoFlux
요소 수0 또는 10 또는 N
용도단건 조회, 저장, 삭제목록 조회, 스트리밍
완료 시점하나의 요소 방출 후모든 요소 방출 후
예시findById()findAll()

map vs flatMap — 가장 헷갈리는 두 연산자

리액티브 프로그래밍에서 가장 자주 헷갈리는 게 이 둘이에요. 한 줄로 정리하면 — map은 동기 변환, flatMap은 비동기 변환입니다.

항목map()flatMap()
반환 타입동기 값 TPublisher (Mono 또는 Flux)
사용 시동기 변환 (엔티티 → DTO 등)비동기 연산 (repository 호출 등)
결과동일 Publisher 내에서 값 변환새 Publisher를 생성하여 연결
// map — 동기 변환 (엔티티를 DTO로 즉시 변환)
Mono<ProductDTO> dto = productRepository.findById(id)
        .map(product -> productMapper.productToProductDto(product));

// flatMap — 비동기 연산 (다른 repository 호출처럼 또 Mono를 반환)
Mono<ProductDTO> saved = productRepository.findById(id)
        .flatMap(product -> productRepository.save(product))   // save도 Mono → flatMap
        .map(productMapper::productToProductDto);

회사 비유로 — map은 컨베이어 벨트 위 박스에 라벨을 붙이는 일(즉시 변환), flatMap은 박스를 다른 컨베이어 벨트에 옮겨 또 다른 가공을 거치게 하는 일(다른 Publisher와 연결)이에요.

여기서 시험 함정이 하나 있어요. flatMap을 써야 할 자리에 map을 쓰면 — 결과가 Mono>처럼 이상하게 중첩됩니다. 컴파일은 되니까 처음엔 안 보여요. "왜 결과가 안 나오지" 한참 헤매다가 깨닫는 함정입니다. 다음 단계가 또 Mono/Flux를 반환하면 무조건 flatMap — 이걸 외워 두세요.

리액티브 에러 처리 — 던지지 말고 스트림으로

리액티브에서는 예외를 던지지 않아요. 던지면 스트림이 끊겨 버리거든요. 대신 에러를 스트림 안의 이벤트로 처리합니다.

Mono<ProductDTO> result = productRepository.findById(productId)
        .map(productMapper::productToProductDto)
        // 요소가 없으면 빈 Mono → 404 처리
        .switchIfEmpty(Mono.error(new NotFoundException()))
        // 에러 발생 시 다른 값으로 대체
        .onErrorReturn(NotFoundException.class, ProductDTO.builder().build())
        // 에러를 변환
        .onErrorMap(ex -> new RuntimeException("데이터 처리 오류", ex))
        // 에러 발생 시 대체 Publisher 사용
        .onErrorResume(ex -> Mono.just(defaultProduct));

자주 쓰는 패턴 4가지 — switchIfEmpty(빈 결과 처리), onErrorReturn(에러 시 기본값), onErrorMap(에러 변환), onErrorResume(대체 Publisher). 동기 코드의 try-catch가 리액티브에서는 이 연산자들로 흩어집니다.

Spring Data R2DBC — JPA의 리액티브 사촌

리액티브 컨트롤러를 만드려면 데이터 접근도 리액티브여야 해요. JPA·Hibernate는 모두 동기 블로킹이라 — 리액티브 스택에 못 들어와요. 그 자리를 R2DBC(Reactive Relational Database Connectivity) 가 대신합니다.

항목JDBC (JPA/Hibernate)R2DBC
방식동기/블로킹비동기/논블로킹
스레드I/O 대기 중 스레드 점유I/O 대기 중 스레드 해제
성숙도20년 이상, 매우 성숙2020년 이후 본격화, 비교적 새로움
ORM 기능풍부한 JPA/Hibernate 기능제한적 (복잡한 관계 매핑 미지원)
리액티브 스택 통합불가 (WebFlux와 혼용 불가)완전 지원

여기서 시험 함정이 하나 있어요. R2DBC는 JPA의 어노테이션을 인식하지 못합니다. @Entity·@Column·@GeneratedValue 같은 jakarta.persistence 패키지 어노테이션을 박으면 — 무시당해 동작 안 해요. 반드시 org.springframework.data.annotation.Idorg.springframework.data.relational.core.mapping.Table 을 써야 합니다.

// 잘못된 예 — JPA 어노테이션
import jakarta.persistence.Entity;
import jakarta.persistence.Id;

@Entity  // R2DBC에서 무시됨!
public class Product {
    @Id  // jakarta.persistence.Id — R2DBC와 불호환!
    @GeneratedValue
    private Integer id;
}

// 올바른 예 — Spring Data R2DBC 어노테이션
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Table;

@Table("product")
public class Product {
    @Id  // org.springframework.data.annotation.Id
    private Integer id;
}

또 한 가지 — R2DBC는 @OneToMany·@ManyToOne·@ManyToMany 같은 관계 어노테이션을 지원 안 해요. 6편에서 풀었던 JPA 관계 매핑이 여기서는 직접 처리해야 합니다. 복잡한 관계가 필요하면 R2DBC가 아니라 JPA를 쓰는 게 좋아요.

프로젝트 설정

<!-- pom.xml: Spring MVC (Web) 대신 WebFlux 선택 -->
<!-- 두 의존성을 동시에 쓰면 충돌! -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webflux</artifactId>
</dependency>

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

<!-- H2 R2DBC 드라이버 -->
<dependency>
    <groupId>io.r2dbc</groupId>
    <artifactId>r2dbc-h2</artifactId>
    <scope>runtime</scope>
</dependency>

여기서 시험 함정이 하나 있어요. spring-boot-starter-webspring-boot-starter-webflux를 동시에 박으면 충돌이 납니다. Spring Boot 자동 구성이 MVC를 우선 선택하거나 예측 불가 동작을 보여요. 둘 중 하나만 — 동기 스택은 Web, 리액티브 스택은 WebFlux.

엔티티 정의

@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Table("product")
public class Product {

    @Id
    private Integer id;

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

    @NotNull
    private String category;

    @NotNull
    private String upc;

    private Integer quantityOnHand;

    @NotNull
    private BigDecimal price;

    private LocalDateTime createdDate;
    private LocalDateTime lastModifiedDate;
}

schema.sql + ConnectionFactoryInitializer

R2DBC는 JPA의 ddl-auto=create 방식을 못 써요. schema.sqlConnectionFactoryInitializer를 직접 박습니다.

-- src/main/resources/schema.sql
CREATE TABLE IF NOT EXISTS product (
    id              INTEGER NOT NULL AUTO_INCREMENT,
    product_name    varchar(50) NOT NULL,
    category        varchar(255) NOT NULL,
    upc             varchar(255) NOT NULL,
    quantity_on_hand integer,
    price           decimal(19, 2) NOT NULL,
    created_date    timestamp,
    last_modified_date timestamp,
    CONSTRAINT pk_product PRIMARY KEY (id)
);
@Configuration
public class DatabaseConfig {

    @Bean
    ConnectionFactoryInitializer initializer(ConnectionFactory connectionFactory) {
        ConnectionFactoryInitializer initializer = new ConnectionFactoryInitializer();
        initializer.setConnectionFactory(connectionFactory);

        initializer.setDatabasePopulator(
                new ResourceDatabasePopulator(new ClassPathResource("schema.sql")));
        return initializer;
    }
}

R2DBC Repository

@Repository
public interface ProductRepository extends ReactiveCrudRepository<Product, Integer> {
    // 기본 제공 메서드 (반환 타입이 리액티브!):
    // Mono<Product> findById(Integer id)
    // Flux<Product> findAll()
    // Mono<Product> save(Product product)
    // Mono<Void> deleteById(Integer id)

    // 커스텀 쿼리 메서드
    Mono<Product> findByProductName(String productName);
    Flux<Product> findAllByCategory(String category);
    Flux<Product> findAllByProductNameAndCategory(String productName, String category);

    // @Query 어노테이션으로 직접 SQL 작성
    @Query("SELECT * FROM product WHERE product_name LIKE :productName")
    Flux<Product> findByProductNameLike(@Param("productName") String productName);
}

리포지토리 인터페이스만 봐도 — Mono·Flux 반환 타입이 눈에 들어와요. 이게 R2DBC의 가장 큰 특징입니다.

Spring WebFlux REST 서비스

WebFlux 컨트롤러는 Spring MVC와 거의 같아 보여요. 어노테이션이 같습니다. @RestController·@GetMapping·@PathVariable·@RequestBody 다 그대로. 차이는 — 반환 타입이 Mono/Flux 라는 것 하나예요.

@RestController
public class ProductController {

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

    private final ProductService productService;

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

    // 목록 조회 — Flux<ProductDTO> 반환 (List<ProductDTO> 아님!)
    @GetMapping(PRODUCT_PATH)
    Flux<ProductDTO> listProducts() {
        return productService.listProducts();
    }

    // 단건 조회 — Mono<ProductDTO> 반환
    @GetMapping(PRODUCT_PATH_ID)
    Mono<ProductDTO> getProductById(@PathVariable Integer productId) {
        return productService.getProductById(productId);
    }

    // 생성 — Mono<ResponseEntity<Void>> (201 Created + Location)
    @PostMapping(PRODUCT_PATH)
    Mono<ResponseEntity<Void>> createNewProduct(@Validated @RequestBody ProductDTO productDTO) {
        return productService.saveNewProduct(productDTO)
                .map(savedDto -> ResponseEntity.created(
                        UriComponentsBuilder
                                .fromHttpUrl("http://localhost:8080/")
                                .path(PRODUCT_PATH_ID)
                                .build(savedDto.getId()))
                        .<Void>build());
    }

    // 수정 — Mono<ResponseEntity<Void>> (204 No Content)
    @PutMapping(PRODUCT_PATH_ID)
    Mono<ResponseEntity<Void>> updateExistingProduct(
            @PathVariable Integer productId,
            @Validated @RequestBody ProductDTO productDTO) {
        return productService.updateProduct(productId, productDTO)
                .map(updatedDto -> ResponseEntity.<Void>noContent().build());
    }

    // 삭제 — Mono<ResponseEntity<Void>>
    @DeleteMapping(PRODUCT_PATH_ID)
    Mono<ResponseEntity<Void>> deleteById(@PathVariable Integer productId) {
        return productService.deleteProduct(productId)
                .map(voidMono -> ResponseEntity.<Void>noContent().build());
    }
}

내부적으로는 — 서블릿 컨테이너(Tomcat) 대신 Netty(논블로킹 네트워크 서버) 를 씁니다. 이벤트 루프 기반이라 소수의 스레드로 많은 동시 요청을 처리해요.

Service Layer

public interface ProductService {
    Flux<ProductDTO> listProducts();
    Mono<ProductDTO> getProductById(Integer productId);
    Mono<ProductDTO> saveNewProduct(ProductDTO productDTO);
    Mono<ProductDTO> updateProduct(Integer productId, ProductDTO productDTO);
    Mono<ProductDTO> patchProduct(Integer productId, ProductDTO productDTO);
    Mono<Void> deleteProduct(Integer productId);
}

@Service
@RequiredArgsConstructor
public class ProductServiceImpl implements ProductService {

    private final ProductRepository productRepository;
    private final ProductMapper productMapper;

    @Override
    public Flux<ProductDTO> listProducts() {
        return productRepository.findAll()
                .map(productMapper::productToProductDto);
    }

    @Override
    public Mono<ProductDTO> getProductById(Integer productId) {
        return productRepository.findById(productId)
                .map(productMapper::productToProductDto)
                // 존재하지 않으면 404 에러 발생
                .switchIfEmpty(Mono.error(new NotFoundException()));
    }

    @Override
    public Mono<ProductDTO> saveNewProduct(ProductDTO productDTO) {
        return productRepository.save(productMapper.productDtoToProduct(productDTO))
                .map(productMapper::productToProductDto);
    }

    @Override
    public Mono<ProductDTO> updateProduct(Integer productId, ProductDTO productDTO) {
        return productRepository.findById(productId)
                .switchIfEmpty(Mono.error(new NotFoundException()))
                .map(foundProduct -> {
                    foundProduct.setProductName(productDTO.getProductName());
                    foundProduct.setCategory(productDTO.getCategory());
                    foundProduct.setPrice(productDTO.getPrice());
                    foundProduct.setUpc(productDTO.getUpc());
                    return foundProduct;
                })
                .flatMap(productRepository::save)   // save도 Mono 반환 → flatMap
                .map(productMapper::productToProductDto);
    }

    @Override
    public Mono<Void> deleteProduct(Integer productId) {
        return productRepository.deleteById(productId);
    }
}

updateProduct 메서드를 자세히 보세요. (1) findById → (2) 없으면 에러, 있으면 필드 업데이트(map) → (3) 저장(flatMap, save가 또 Mono를 반환하니까!) → (4) DTO 변환(map). 체인이 끊기지 않고 한 줄로 흐릅니다. 이게 리액티브 코드의 미학이에요.

WebTestClient — MockMvc의 리액티브 사촌

WebTestClient는 WebFlux 애플리케이션을 테스트하는 전용 클라이언트예요. MockMvc의 리액티브 버전이라 보면 됩니다.

@SpringBootTest
@AutoConfigureWebTestClient
public class ProductControllerTest {

    @Autowired
    WebTestClient webTestClient;

    @Test
    void testListProducts() {
        webTestClient.get()
                .uri(ProductController.PRODUCT_PATH)
                .exchange()                           // 요청 실행
                .expectStatus().isOk()                // 200 OK 검증
                .expectHeader()
                    .contentType(MediaType.APPLICATION_JSON)
                .expectBodyList(ProductDTO.class)     // 응답 본문 검증
                    .hasSize(3);                      // 3개 요소 예상
    }

    @Test
    void testGetProductById() {
        ProductDTO dto = getSavedTestProduct();

        webTestClient.get()
                .uri(ProductController.PRODUCT_PATH_ID, dto.getId())
                .exchange()
                .expectStatus().isOk()
                .expectBody(ProductDTO.class)
                    .value(productDTO -> {
                        assertThat(productDTO.getProductName()).isEqualTo(dto.getProductName());
                        assertThat(productDTO.getId()).isEqualTo(dto.getId());
                    });
    }

    @Test
    void testCreateNewProduct() {
        ProductDTO productDTO = ProductDTO.builder()
                .productName("New Product")
                .category("Books")
                .upc("12345")
                .price(new BigDecimal("12.99"))
                .build();

        webTestClient.post()
                .uri(ProductController.PRODUCT_PATH)
                .contentType(MediaType.APPLICATION_JSON)
                .body(BodyInserters.fromValue(productDTO))
                .exchange()
                .expectStatus().isCreated()           // 201 Created
                .expectHeader()
                    .locationMatches("http://localhost:8080/api/v2/product/\\d+");
    }

    @Test
    void testGetByIdNotFound() {
        webTestClient.get()
                .uri(ProductController.PRODUCT_PATH_ID, 999)
                .exchange()
                .expectStatus().isNotFound();         // 404 Not Found
    }

    @Test
    void testCreateNewProductValidationFailure() {
        ProductDTO invalidProduct = ProductDTO.builder()
                // productName 누락!
                .category("Books")
                .upc("12345")
                .price(new BigDecimal("12.99"))
                .build();

        webTestClient.post()
                .uri(ProductController.PRODUCT_PATH)
                .contentType(MediaType.APPLICATION_JSON)
                .body(BodyInserters.fromValue(invalidProduct))
                .exchange()
                .expectStatus().isBadRequest();       // 400 Bad Request
    }
}

흐름은 단순해요. exchange()expectStatus()expectBody()(단건) 또는 expectBodyList()(목록). 체이닝이 직관적이라 — MockMvc보다 살짝 쉬운 면도 있어요.

여기서 시험 함정이 하나 있어요. expectBody(ProductDTO.class)(단건)와 expectBodyList(ProductDTO.class)(목록)를 혼동하지 마세요. 응답이 객체면 expectBody, 배열이면 expectBodyList. 잘못 쓰면 직렬화 에러로 테스트가 깨집니다.

또 하나 — 테스트 간 데이터가 공유되는 문제예요. H2 인메모리 DB를 쓰는 통합 테스트는 — 한 테스트가 데이터를 추가하면 다음 테스트에 영향을 줍니다. @BeforeEach에서 deleteAll().block() + 초기 데이터 삽입을 박아 격리해 둬야 안전해요.

@BeforeEach
void setUp() {
    productRepository.deleteAll().block();  // 테스트 전 데이터 초기화
    productRepository.saveAll(List.of(p1, p2, p3)).collectList().block();
}

block() 절대 금지 — 데드락의 함정

이번 편의 가장 위험한 함정이에요. 리액티브 컨텍스트에서 block()을 호출하면 데드락이 발생할 수 있어요.

// 잘못된 예 — 리액티브 컨텍스트에서 block() 호출
public Mono<ProductDTO> getProduct(Integer id) {
    Product product = productRepository.findById(id).block();  // 절대 금지!
    return Mono.just(productMapper.productToProductDto(product));
}

// 올바른 예 — 연산자 체인 유지
public Mono<ProductDTO> getProduct(Integer id) {
    return productRepository.findById(id)
            .map(productMapper::productToProductDto);
}

// 단, 테스트에서는 block() 허용
@Test
void testSomething() {
    ProductDTO result = productService.getProduct(1).block();  // 테스트에서만 OK
    assertThat(result).isNotNull();
}

왜 데드락이 나느냐 — Netty의 이벤트 루프 스레드가 block()을 만나면 자기 작업 결과를 기다리며 멈추는데, 그 결과를 만들어 줄 스레드가 바로 자기예요. 그래서 영원히 멈춥니다. 테스트에서는 — 별도 스레드 풀이라 — 허용되지만, 운영 코드에서는 절대 금지예요.

또 하나 — 구독(subscribe) 없이 리액티브 파이프라인 정의도 함정입니다.

// 잘못된 예 — 구독 없으면 실행 안 됨
public void deleteProduct(Integer id) {
    productRepository.deleteById(id);  // 약속만 있고 실행 안 됨!
}

// 가장 좋은 방법 — 리액티브 타입 그대로 반환 (Spring이 구독)
public Mono<Void> deleteProduct(Integer id) {
    return productRepository.deleteById(id);
}

WebFlux 컨트롤러는 자동으로 구독해 주니, 메서드는 항상 Mono/Flux 그대로 반환하는 게 정석이에요.

Spring MVC vs Spring WebFlux — 비교 정리

항목Spring MVCSpring WebFlux
실행 모델동기/블로킹비동기/논블로킹
서버Tomcat, Jetty (서블릿 기반)Netty (논블로킹), Undertow
반환 타입POJO, ResponseEntityMono, Flux, ResponseEntity
어노테이션@Controller, @RequestMapping동일
테스트 클라이언트MockMvcWebTestClient
스레드 사용요청당 1 스레드이벤트 루프 (소수의 스레드)
데이터베이스JDBC, JPA, HibernateR2DBC
학습 곡선낮음높음 (리액티브 패러다임 학습)
적합한 상황일반 CRUD, 낮은 동시성고성능, 고동시성, 스트리밍

룰을 한 줄로 — 일반 CRUD는 MVC + JPA로 충분, 고동시성·스트리밍이 명확히 필요하면 WebFlux + R2DBC. 리액티브가 좋다고 무조건 옮기는 건 학습 비용 대비 이득이 적을 수 있어요.

R2DBC vs JPA — 기능 비교

기능JPA/HibernateR2DBC
@Entity, @Table지원미지원 (Spring Data 어노테이션)
@OneToMany, @ManyToMany지원미지원 (직접 처리)
Cascading지원미지원
Lazy Loading지원미지원
JPQL지원미지원 (Native SQL만)
Auditing (@CreatedDate 등)지원지원 (Spring Data Auditing)
커스텀 쿼리@Query + JPQL/SQL@Query + SQL
성숙도매우 성숙성장 중

복잡한 관계 매핑이 필요하면 — JPA가 압도적으로 풍부해요. R2DBC는 단순 CRUD 위주의 고성능 시나리오에서 빛을 발합니다.

더 자세히 — 공식 문서

WebFlux와 Project Reactor의 자세한 사양은 Spring WebFlux 공식 가이드Project Reactor 레퍼런스에서 확인할 수 있어요. 연산자 카탈로그, 마블 다이어그램, 백프레셔 패턴, R2DBC 통합까지 친절하게 정리돼 있습니다.

시험 직전 한 번 더 — 자주 헷갈리는 함정 모음

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

  • 리액티브 = 빠름이 아님 — 높은 부하에서 적은 자원으로 더 많은 동시성
  • 논블로킹 = I/O 대기 중 스레드 해제, 이벤트 도착 시 재개
  • 리액티브 선언문 4속성 — 반응성·탄력성·확장성·메시지 기반
  • 배압(Back Pressure) = 소비자가 생산자 속도 제어 (request(n))
  • Mono = 0 또는 1개 요소 (단건 조회·저장·삭제)
  • Flux = 0 또는 N개 요소 (목록 조회·스트리밍)
  • subscribe() 호출 전까지 실제로 안 돔 — 그냥 약속만 있는 상태
  • WebFlux 컨트롤러 = 자동 구독 / 일반 코드 = 명시 구독
  • map() = 동기 변환 (DTO 변환 등) / flatMap() = 비동기 변환 (또 Mono/Flux 반환할 때)
  • flatMap 자리에 map 쓰면 결과가 Mono> 같이 중첩!
  • 에러는 던지지 말고 — switchIfEmpty / onErrorReturn / onErrorMap / onErrorResume
  • R2DBC = 관계형 DB 비동기 드라이버 (JDBC와 별개)
  • JPA 어노테이션(jakarta.persistence) R2DBC와 불호환org.springframework.data.annotation.Id+@Table 사용
  • R2DBC는 @OneToMany·@ManyToOne·@ManyToMany 미지원 — 직접 조합 (zipWith 등)
  • R2DBC는 ddl-auto 미지원schema.sql + ConnectionFactoryInitializer
  • spring-boot-starter-web + spring-boot-starter-webflux 동시 사용 금지 — 충돌
  • WebFlux 서버 = Netty (Tomcat 대신, 이벤트 루프 기반)
  • 어노테이션은 MVC와 동일 — @RestController·@GetMapping·@PathVariable
  • 반환 타입만 Mono/Flux로 바뀜
  • WebTestClient = MockMvc의 리액티브 버전
  • exchange()expectStatus()expectBody()(단건) / expectBodyList()(목록)
  • 테스트 격리 = @BeforeEach에서 deleteAll().block() + 초기 데이터
  • block() 운영 코드 금지 — 이벤트 루프 데드락 위험
  • block()테스트에서만 허용
  • 메서드는 Mono/Flux를 그대로 반환 — subscribe()·block() 안 박는 게 정석
  • expectBody vs expectBodyList 혼동 = 직렬화 에러
  • 리액티브 = 학습 곡선 ↑ — 일반 CRUD에는 MVC + JPA가 더 단순
  • 리액티브 ↔ R2DBC 짝, 동기 ↔ JPA 짝 — 섞지 말 것

시리즈 다른 편

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

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

답글 남기기

error: Content is protected !!