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 핵심 정리 시리즈의 아홉 번째 편입니다. 8편까지 따라오셨다면 이제 동기 방식의 Spring MVC + RestTemplate/RestClient로 백엔드 한 사이클을 완전히 다룰 수 있을 거예요. 그런데 트래픽이 폭증하는 환경 — 예를 들어 동시 사용자 수가 만 단위로 올라가면 — 동기 방식의 한계가 보입니다. 한 요청당 한 스레드가 묶이고, I/O 대기 동안 그 스레드는 그냥 멈춰 있어요. 결국 스레드 풀이 고갈되어 시스템이 무너집니다.
9편의 주제는 이 한계를 풀어 주는 WebFlux 와 그 기반인 리액티브 프로그래밍 입니다. 이름이 어렵게 들려도 비동기 컨베이어 벨트라는 비유로 잡으면 한 번에 그림이 그려져요.
왜 WebFlux가 처음엔 어렵게 느껴질까요
이유는 네 가지예요.
첫째, 사고방식 자체가 바뀌어야 합니다. 그동안 우리가 쓴 코드는 "한 줄씩 차례로 실행되고 결과를 변수에 받는" 식이었어요. 리액티브는 "값이 언젠가 도착할 약속을 만들고 그 약속에 변환을 연결하는" 식이라 — 같은 동작을 표현하는 코드 모양이 달라집니다.
둘째, Mono·Flux가 마법처럼 보입니다. 리턴 타입이 Product가 아니라 Mono로 바뀌고, .map()·.flatMap()·.switchIfEmpty() 같은 연산자가 줄지어 나와요. 처음에는 이게 다 뭐지 싶은 마음이 들어요.
셋째, map과 flatMap의 차이가 헷갈립니다. 둘 다 변환 같은데 어디서 어느 걸 써야 할지 — 한 번 잘못 쓰면 컴파일은 되는데 결과가 이상하게 나와요.
넷째, 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 — 한 개 결과 약속
Mono는 0개 또는 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 — 흐르는 결과 약속
Flux는 0개 이상의 요소를 순차적으로 방출하는 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();
Mono와 Flux를 한 표로 비교해 두면 헷갈리지 않아요.
| 항목 | Mono | Flux |
|---|---|---|
| 요소 수 | 0 또는 1 | 0 또는 N |
| 용도 | 단건 조회, 저장, 삭제 | 목록 조회, 스트리밍 |
| 완료 시점 | 하나의 요소 방출 후 | 모든 요소 방출 후 |
| 예시 | findById() | findAll() |
map vs flatMap — 가장 헷갈리는 두 연산자
리액티브 프로그래밍에서 가장 자주 헷갈리는 게 이 둘이에요. 한 줄로 정리하면 — map은 동기 변환, flatMap은 비동기 변환입니다.
| 항목 | map() | flatMap() |
|---|---|---|
| 반환 타입 | 동기 값 T | Publisher (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.Id와 org.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-web과 spring-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.sql과 ConnectionFactoryInitializer를 직접 박습니다.
-- 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 MVC | Spring WebFlux |
|---|---|---|
| 실행 모델 | 동기/블로킹 | 비동기/논블로킹 |
| 서버 | Tomcat, Jetty (서블릿 기반) | Netty (논블로킹), Undertow |
| 반환 타입 | POJO, ResponseEntity | Mono |
| 어노테이션 | @Controller, @RequestMapping 등 | 동일 |
| 테스트 클라이언트 | MockMvc | WebTestClient |
| 스레드 사용 | 요청당 1 스레드 | 이벤트 루프 (소수의 스레드) |
| 데이터베이스 | JDBC, JPA, Hibernate | R2DBC |
| 학습 곡선 | 낮음 | 높음 (리액티브 패러다임 학습) |
| 적합한 상황 | 일반 CRUD, 낮은 동시성 | 고성능, 고동시성, 스트리밍 |
룰을 한 줄로 — 일반 CRUD는 MVC + JPA로 충분, 고동시성·스트리밍이 명확히 필요하면 WebFlux + R2DBC. 리액티브가 좋다고 무조건 옮기는 건 학습 비용 대비 이득이 적을 수 있어요.
R2DBC vs JPA — 기능 비교
| 기능 | JPA/Hibernate | R2DBC |
|---|---|---|
| @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()안 박는 게 정석 expectBodyvsexpectBodyList혼동 = 직렬화 에러- 리액티브 = 학습 곡선 ↑ — 일반 CRUD에는 MVC + JPA가 더 단순
- 리액티브 ↔ R2DBC 짝, 동기 ↔ JPA 짝 — 섞지 말 것
시리즈 다른 편
같은 시리즈의 다른 글들도 같은 친절 톤으로 묶어 정리되어 있어요.
- 1편 — Spring Boot 입문
- 2편 — Spring MVC REST · MockMVC
- 3편 — Spring Data JPA · 검증
- 4편 — MySQL · Flyway · TestContainers
- 5편 — CSV 업로드 · 페이징 · 동적 쿼리
- 6편 — JPA 관계 매핑 심화
- 7편 — Spring Security · OAuth 2.0 · JWT
- 8편 — RestTemplate · RestClient
- 9편 — Reactive Programming · WebFlux 입문 (현재 글)
- 10편 — WebFlux 심화 · MongoDB · WebClient
- 11편 — Cloud Gateway · Maven/Gradle · Buildpack
- 12편 — OpenAPI · Spring AI
- 13편 — Actuator · 관측성
- 14편 — Spring Cache · 이벤트
- 15편 — Docker · Compose · Kubernetes
- 16편 — 마이크로서비스 · Apache Kafka
- 17편 — Spring Professional · 베스트 프랙티스 (완)