Spring WebFlux 핵심 정리 시리즈 4편. @RestController + Mono/Flux 반환, ResponseEntity 패턴, switchIfEmpty 404 처리, Mono
이 글은 Spring WebFlux 핵심 정리 시리즈의 네 번째 편입니다. 3편까지 이론과 DB 연동을 다뤘다면, 이번 편에서는 드디어 실제 REST API를 WebFlux로 구축합니다. 기본 CRUD — 전체 조회, 단건 조회, 생성, 수정, 삭제 — 를 처음부터 끝까지 리액티브 파이프라인으로 이어 보는 편이에요.
이번 편 핵심 목표는 "성공 경로(Happy Path) 먼저" 입니다. 에러 처리와 입력 검증은 다음 5편에서 추가하고, 여기서는 잘 작동하는 경우의 구조를 잡는 데 집중합니다.
이 시리즈는 Spring 공식 문서, Project Reactor 공식 문서, 여러 리액티브 백엔드 학습 자료를 참고해 한국어 학습 노트로 풀어쓴 자료입니다.
Customer CRUD를 예제로 사용합니다. 실습하면서 따라오면 WebFlux 계층 구조가 한 번에 잡혀요.
왜 리액티브 CRUD가 처음엔 어렵게 느껴질까요
이유는 네 가지예요.
첫째, 반환 타입이 낯섭니다. Spring MVC에서는 User getUser() 였는데 WebFlux에서는 Mono 가 됩니다. 결과값이 아니라 "결과값을 가져올 파이프라인"을 반환하는 거라는 개념 전환이 필요해요.
둘째, Mono.empty() 처리를 빠뜨리면 204 빈 응답이 나옵니다. findById(999) 가 빈 Mono를 반환할 때 아무 처리 없이 그냥 반환하면 클라이언트는 200 OK + 빈 바디를 받아요. 404를 기대했다면 당황스럽죠.
셋째, UPDATE 시 ID 설정을 빠뜨립니다. DTO에서 Entity를 새로 만들면 id가 null이에요. save()는 id == null 이면 INSERT를 합니다. URL 경로에서 받은 id를 Entity에 명시적으로 심어줘야 UPDATE가 됩니다.
넷째, Repository를 컨트롤러에서 직접 호출하고 싶어집니다. 코드 줄이 줄어들어 편해 보이지만, 서비스 계층 없이는 비즈니스 로직 재사용과 테스트가 어려워집니다.
비유로 잡고 가겠습니다. 리액티브 CRUD = "주문서를 받으면 즉시 영수증을 건네고, 주방에 흘려보내는 식당" — 손님(클라이언트)이 주문하면 식당(컨트롤러)은 즉시 주문번호(Mono/Flux)를 돌려주고, 실제 요리는 주방(서비스·리포지토리)에서 비동기로 처리합니다. 손님은 음식이 나오면 그때 가져가고요.
프로젝트 구조 — 계층 분리 원칙
리액티브 CRUD의 핵심 설계 원칙은 관심사 분리입니다.
com.example/
├── controller/
│ └── CustomerController.java ← HTTP 요청/응답 처리만
├── service/
│ └── CustomerService.java ← 비즈니스 로직만
├── repository/
│ └── CustomerRepository.java ← 데이터 접근만
├── entity/
│ └── Customer.java ← DB 테이블 매핑
├── dto/
│ └── CustomerDto.java ← API 계약 (클라이언트와 주고받는 형식)
└── mapper/
└── EntityDtoMapper.java ← Entity ↔ DTO 변환 로직
DTO를 쓰는 이유 — 단순히 "구조를 예쁘게 하려고"가 아닙니다.
- 민감 필드(
password등)를 외부에 노출하지 않을 수 있어요. - DB 스키마 변경과 API 응답 형식 변경을 독립적으로 할 수 있어요.
- 검증 어노테이션(
@NotBlank,@Email)을 Entity가 아닌 DTO에만 붙일 수 있어요.
Java 14+ Record를 쓰면 DTO가 간결해집니다:
public record CustomerDto(
Integer id,
String name,
String email
) {}
// dto.id(), dto.name(), dto.email() 로 접근
한 줄 정리 — 컨트롤러는 HTTP, 서비스는 비즈니스, 리포지토리는 데이터. 각 계층이 자기 일만.
Mapper — 변환 로직 중앙화
Entity와 DTO 사이 변환 로직을 한 곳에 모읍니다.
public class EntityDtoMapper {
// Entity → DTO (DB 조회 결과를 API 응답으로)
public static CustomerDto toDto(Customer customer) {
return new CustomerDto(
customer.getId(),
customer.getName(),
customer.getEmail()
);
}
// DTO → Entity (API 요청을 DB 저장용으로)
// id는 설정하지 않음 — DB가 자동 생성
public static Customer toEntity(CustomerDto dto) {
Customer customer = new Customer();
customer.setName(dto.name());
customer.setEmail(dto.email());
return customer;
}
// DTO로 기존 Entity 업데이트
public static Customer updateEntity(Customer customer, CustomerDto dto) {
customer.setName(dto.name());
customer.setEmail(dto.email());
return customer;
// id는 그대로 유지 → UPDATE
}
}
static 메서드로 만들면 EntityDtoMapper::toDto 형식으로 메서드 참조가 가능해서 파이프라인 코드가 깔끔해집니다.
Service — 리액티브 파이프라인 설계
서비스가 이번 편의 핵심이에요. 각 CRUD 작업을 리액티브 파이프라인으로 구성합니다.
@Service
@RequiredArgsConstructor
public class CustomerService {
private final CustomerRepository customerRepository;
// ===== 전체 조회 =====
public Flux<CustomerDto> allCustomers() {
return customerRepository.findAll()
.map(EntityDtoMapper::toDto);
}
// ===== 단건 조회 =====
public Mono<CustomerDto> getCustomerById(Integer id) {
return customerRepository.findById(id)
.map(EntityDtoMapper::toDto);
// 없으면 빈 Mono가 그대로 전달
}
// ===== 생성 =====
public Mono<CustomerDto> saveCustomer(Mono<CustomerDto> dtoMono) {
return dtoMono
.map(EntityDtoMapper::toEntity) // DTO → Entity (id 없음 → INSERT)
.flatMap(customerRepository::save) // save는 Mono 반환 → flatMap
.map(EntityDtoMapper::toDto); // 저장된 Entity(id 포함) → DTO
}
// ===== 수정 =====
public Mono<CustomerDto> updateCustomer(Integer id, Mono<CustomerDto> dtoMono) {
return customerRepository.findById(id)
.flatMap(existingCustomer -> dtoMono) // 존재하면 새 DTO로 교체
.map(EntityDtoMapper::toEntity)
.doOnNext(entity -> entity.setId(id)) // ID 명시 설정 → UPDATE
.flatMap(customerRepository::save)
.map(EntityDtoMapper::toDto);
}
// ===== 삭제 =====
public Mono<Void> deleteCustomer(Integer id) {
return customerRepository.deleteById(id);
}
}
saveCustomer에서 flatMap을 쓰는 이유를 놓치지 마세요. map으로 customerRepository::save를 적용하면 save가 Mono를 반환하기 때문에 결과가 Mono가 됩니다. flatMap이 이 중첩을 한 겹 벗겨서 Mono로 만들어요.
여기서 시험 함정이 하나 있어요. UPDATE에서 .doOnNext(entity -> entity.setId(id))를 빠뜨리면 INSERT가 됩니다. EntityDtoMapper.toEntity()는 id를 설정하지 않아요. save()는 id가 null이면 새 레코드를 INSERT합니다. URL에서 받은 id를 Entity에 반드시 넣어줘야 UPDATE로 동작해요.
한 줄 정리 — 서비스는 리액티브 파이프라인의 집합. map = 동기 변환, flatMap = 비동기(Publisher 반환) 변환.
Controller — 얇은 계층 원칙
컨트롤러는 HTTP 관련 처리만 담당합니다. 비즈니스 로직은 없어요.
@RestController
@RequestMapping("/customers")
@RequiredArgsConstructor
public class CustomerController {
private final CustomerService customerService;
// GET /customers → 200 OK + JSON 배열
@GetMapping
public Flux<CustomerDto> allCustomers() {
return customerService.allCustomers();
}
// GET /customers/{id} → 200 OK + JSON 객체
@GetMapping("/{id}")
public Mono<CustomerDto> getCustomerById(@PathVariable Integer id) {
return customerService.getCustomerById(id);
// 없으면 빈 Mono → 204 No Content (에러 처리는 5편에서)
}
// POST /customers → 200 OK (기본), ResponseEntity 쓰면 201 지정 가능
@PostMapping
public Mono<CustomerDto> saveCustomer(@RequestBody Mono<CustomerDto> dtoMono) {
return customerService.saveCustomer(dtoMono);
}
// PUT /customers/{id} → 200 OK
@PutMapping("/{id}")
public Mono<CustomerDto> updateCustomer(
@PathVariable Integer id,
@RequestBody Mono<CustomerDto> dtoMono) {
return customerService.updateCustomer(id, dtoMono);
}
// DELETE /customers/{id} → 200 OK (기본), Mono<Void> 반환
@DeleteMapping("/{id}")
public Mono<Void> deleteCustomer(@PathVariable Integer id) {
return customerService.deleteCustomer(id);
}
}
여기서 시험 함정이 하나 있어요. @RequestBody Mono로 받는 것과 @RequestBody CustomerDto로 받는 것은 미묘하게 다릅니다. 일반 타입으로 받으면 요청 바디가 완전히 도착할 때까지 대기한 후 역직렬화돼요. Mono로 받으면 역직렬화 과정도 리액티브 파이프라인의 일부가 됩니다. 서비스에 그대로 넘겨서 논블로킹 흐름을 유지할 수 있어요.
ResponseEntity로 HTTP 상태 코드 명시
기본 방식은 항상 200 OK를 반환합니다. 생성은 201, 삭제는 204가 올바른 HTTP 의미이기 때문에 ResponseEntity로 명시해요.
// POST → 201 Created
@PostMapping
public Mono<ResponseEntity<CustomerDto>> saveCustomer(@RequestBody Mono<CustomerDto> dtoMono) {
return customerService.saveCustomer(dtoMono)
.map(dto -> ResponseEntity
.status(HttpStatus.CREATED)
.body(dto));
}
// GET → 200 OK or 404 Not Found
@GetMapping("/{id}")
public Mono<ResponseEntity<CustomerDto>> getCustomerById(@PathVariable Integer id) {
return customerService.getCustomerById(id)
.map(dto -> ResponseEntity.ok(dto))
.defaultIfEmpty(ResponseEntity.notFound().build()); // 없으면 404
}
// DELETE → 204 No Content
@DeleteMapping("/{id}")
public Mono<ResponseEntity<Void>> deleteCustomer(@PathVariable Integer id) {
return customerService.deleteCustomer(id)
.then(Mono.just(ResponseEntity.<Void>noContent().build()));
}
defaultIfEmpty는 switchIfEmpty와 비슷하지만 차이가 있어요. defaultIfEmpty는 빈 Mono 대신 기본값을 씁니다. switchIfEmpty는 빈 Mono 대신 다른 Publisher로 교체해요. 404 Not Found 응답처럼 값 자체를 반환하는 경우엔 defaultIfEmpty가 간결합니다.
각 API 동작 요약:
| HTTP | URL | 반환 타입 | 성공 코드 | 없을 때 |
|---|---|---|---|---|
| GET | /customers | Flux | 200 | 빈 배열 |
| GET | /customers/{id} | Mono | 200 | 404 (명시 설정 시) |
| POST | /customers | Mono | 201 (명시 시) | — |
| PUT | /customers/{id} | Mono | 200 | 빈 Mono |
| DELETE | /customers/{id} | Mono | 204 (명시 시) | — |
한 줄 정리 — 상태 코드를 명시하려면 반환 타입을 Mono로 감싸고 .map(dto -> ResponseEntity.status(...).body(dto)) 패턴 사용.
Repository를 컨트롤러에서 직접 부르면 안 되는 이유
간단한 조회라면 "서비스 계층을 굳이?" 하는 생각이 들 수 있어요. 하지만 서비스 계층 없이 컨트롤러에서 리포지토리를 직접 부르면 문제가 생깁니다.
- 비즈니스 로직 재사용 불가: 이메일 중복 체크, 권한 검사 같은 로직을 여러 컨트롤러에서 쓸 수 없어요.
- 테스트 어려움: 컨트롤러 테스트 시 DB까지 세팅해야 해요. 서비스 단위 테스트가 불가능합니다.
- 트랜잭션 관리 불명확:
@Transactional은 서비스 계층에서 관리하는 게 표준이에요.
여기서 시험 함정이 하나 있어요. Mono.empty() 를 컨트롤러에서 명시적으로 처리하지 않으면 204 No Content가 됩니다. 빈 Mono를 그냥 반환하면 바디 없이 완료 신호만 가기 때문에 클라이언트는 200 OK + 빈 바디 또는 204를 받아요. 404를 내려주려면 switchIfEmpty(Mono.error(...)) 또는 defaultIfEmpty(ResponseEntity.notFound().build())가 필요합니다.
WebTestClient — 리액티브 API 테스트
WebTestClient는 WebFlux 전용 테스트 클라이언트입니다.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class CustomerControllerTest {
@Autowired
private WebTestClient webTestClient;
@Test
void testGetAllCustomers() {
webTestClient.get()
.uri("/customers")
.exchange()
.expectStatus().isOk()
.expectBodyList(CustomerDto.class)
.hasSize(10);
}
@Test
void testCreateCustomer() {
CustomerDto newCustomer = new CustomerDto(null, "Alice", "alice@test.com");
webTestClient.post()
.uri("/customers")
.bodyValue(newCustomer)
.exchange()
.expectStatus().isCreated()
.expectBody(CustomerDto.class)
.value(dto -> {
Assertions.assertNotNull(dto.id()); // DB 자동 생성
Assertions.assertEquals("Alice", dto.name());
});
}
@Test
void testDeleteCustomer() {
webTestClient.delete()
.uri("/customers/1")
.exchange()
.expectStatus().isNoContent();
}
}
자주 만나는 함정 — 시험 직전 압축 노트
@RequestBody Mono— 요청 바디를 리액티브 타입으로 받아 논블로킹 파이프라인 유지mapvsflatMap— Entity→DTO 동기 변환은map,save()처럼 Publisher 반환은flatMapMono방지 —> map안에서save()호출하면 이중 래핑 발생.flatMap사용- UPDATE 시
doOnNext(entity -> entity.setId(id))— 빠뜨리면 INSERT 발생. URL id 반드시 주입 Mono.empty()처리 — 처리 없이 반환하면 204.defaultIfEmpty또는switchIfEmpty필수ResponseEntity패턴 — 201/204 같은 정확한 HTTP 상태 코드를 지정할 때Mono> defaultIfEmptyvsswitchIfEmpty— 전자는 기본값, 후자는 다른 Publisher 교체then(Mono.just(...))—Mono이후 다음 값 반환 (DELETE 204 패턴)- 얇은 컨트롤러 원칙 — 컨트롤러는 URL·상태 코드·헤더만. 비즈니스 로직은 서비스
- Repository 컨트롤러 직접 호출 금지 — 서비스 계층 필수 (재사용·테스트·트랜잭션)
- Entity vs DTO — Entity는 DB 구조, DTO는 API 계약. 민감 필드 노출 방지
- Mapper static 메서드 —
EntityDtoMapper::toDto형식 메서드 참조로 간결한 파이프라인 Flux전체 조회 — 빈 컬렉션은 빈Flux로 반환, 200 OK + 빈 배열Mono삭제 — 반환 데이터 없고 성공/실패 완료 신호만 필요WebTestClient— WebFlux 전용 테스트 클라이언트..exchange().expectStatus()...- Happy Path 먼저 — 성공 경로를 먼저 완성하고, 에러·검증은 다음 단계(5편)에서 추가
시리즈 다른 편
같은 시리즈의 다른 글들도 같은 친절 톤으로 묶어 정리되어 있어요.