
이번 글에서는 그 중에서도 애너테이션 기반 WebFlux 컨트롤러에 집중하여, 단순히 Mono를 반환한다고 해서 자동으로 논블로킹이 보장되는 것은 아님을 설명하고, 실제로 논블로킹 구조를 설계하는 방법을 코드 중심으로 다룬다.
Spring MVC 스타일에 익숙하다면 WebFlux를 처음 사용할 때 기존 컨트롤러 코드의 리턴 타입만 Mono로 바꾸면 충분하다고 생각하기 쉽다. 실제로 다음과 같은 방식은 매우 흔하게 작성된다.
@RestController
@RequestMapping("/books")
public class BookController {
@PostMapping
public Mono<BookResponse> createBook(@RequestBody BookRequest request) {
Book book = mapper.toEntity(request); // 동기 변환
Book saved = service.save(book); // 동기 저장
return Mono.just(mapper.toResponse(saved)); // Mono로 감싸기만 함
}
}
표면적으로는 Mono<BookResponse>를 반환하고 있으므로 리액티브처럼 보이지만,
실제 처리 과정은 모두 동기적으로 수행되고 있다. DTO 변환, 저장 로직 모두 Blocking 메서드이며, 결과만 Mono로 감싼 구조다.
이런 방식은 리액티브의 이점을 거의 활용하지 못하고, 오히려 헷갈리는 코드가 되기 쉽다.
Mono만 사용하는 구조좀 더 리액티브하게 구성하려고 시도하면 다음과 같은 구조가 된다.
@PostMapping
public Mono<BookResponse> createBook(@RequestBody BookRequest request) {
Mono<Book> mono = Mono.just(mapper.toEntity(request));
return mono.map(service::save)
.map(mapper::toResponse);
}
이전보다 나아 보이지만, 여전히 핵심 로직은 Blocking 메서드(save())로 처리되고 있다.
이 구조의 문제는 Mono가 단순히 감싸는 용도에 그친다는 점이다.
이렇게 되면 내부 로직은 여전히 동기적으로 실행되며, 리액티브 스트림의 논블로킹 특성은 거의 적용되지 않는다.
리액티브 구조에서 중요한 것은 처리 흐름 전체가 Mono 또는 Flux 안에서 연결되어 있어야 한다는 점이다.
DTO 변환, 저장, 응답 변환까지 모든 작업이 리액티브 흐름 안에서 수행될 때 비로소 논블로킹 구조가 완성된다.
@PostMapping
public Mono<BookResponse> createBook(@RequestBody Mono<BookRequest> requestMono) {
return requestMono
.map(mapper::toEntity) // DTO → Entity
.flatMap(service::saveReactive) // 논블로킹 저장
.map(mapper::toResponse); // Entity → Response
}
여기서 중요한 점은 두 가지이다:
@RequestBody로 직접 Mono<DTO>를 받는다: HTTP 본문을 비동기적으로 읽기 위해 필요map, flatMap 안에서 연결되어야 한다: 외부 I/O 또는 연산이 Blocking이면 안 됨리액티브 흐름을 유지하려면 컨트롤러뿐 아니라 서비스 계층도 이에 맞게 구성되어야 한다.
Blocking 저장 로직 대신 Mono를 리턴하는 저장 로직으로 구성해야 한다.
@Service
public class BookService {
public Mono<Book> saveReactive(Book book) {
return reactiveRepository.save(book); // 예: R2DBC, Mongo Reactive 등
}
}
이렇게 구성하면, 데이터 변환 → 저장 → 응답 변환까지 모든 흐름이 비동기적으로 연결된다.
Spring WebFlux가 제공하는 스레드 모델의 효율성과 리소스 활용 효과를 제대로 누릴 수 있다.
리액티브 구조라고 해서 모든 처리를 컨트롤러에서 직접 수행하는 것은 바람직하지 않다.
변환이나 흐름 연결은 가능한 한 서비스 계층에서 처리하고, 컨트롤러는 흐름의 출입구만 담당하는 구조가 더 유지보수에 유리하다.
@PostMapping
public Mono<BookResponse> createBook(@RequestBody Mono<BookRequest> requestMono) {
return bookService.createBook(requestMono);
}
public Mono<BookResponse> createBook(Mono<BookRequest> requestMono) {
return requestMono
.map(mapper::toEntity)
.flatMap(repository::save) // 논블로킹 저장
.map(mapper::toResponse);
}
이 구조는 단위 테스트도 간편하고, 각 계층의 책임도 명확해진다.
| 항목 | 잘못된 예 (V1) | 올바른 예 (V2) |
|---|---|---|
@RequestBody | DTO로 직접 받음 | Mono<DTO>로 받음 |
| DTO 변환 위치 | Controller 내부 | Service 내부 |
| 저장 방식 | 동기 메서드 | 논블로킹 방식 (R2DBC 등) |
| 흐름 연결 | 없음 | map / flatMap으로 흐름 구성 |
| 논블로킹 여부 | 보장되지 않음 | 완전한 논블로킹 |