Spring WebFlux를 이용한 서버 구성
Spring Framework를 이용해 실제 서버를 구성하는 과정에서, 각각의 Controller, Service, Repository가 WebFlux 스타일로 어떻게 구현되는지 알아보겠습니다.
Controller
Spring WebFlux에서는 컨트롤러를 구현하는 두 가지 방식이 있습니다:
- Functional Endpoint
- Annotation Endpoint
프로젝트 설정 (application.yml
)
먼저, Spring Boot 애플리케이션의 설정을 application.yml
에 정의합니다. 다음은 애플리케이션의 이름과 로그 레벨을 설정하는 코드입니다:
spring:
application:
name: webflux1
logging:
level:
root: DEBUG
이 설정으로 애플리케이션 로그가 DEBUG
레벨로 출력되도록 구성했습니다.
1. Functional Endpoint
Spring WebFlux의 Functional Endpoint는 함수형 스타일을 이용하여 라우팅과 요청 처리를 할 수 있는 방식입니다. 기존의 @Controller와 @RequestMapping 같은 어노테이션 기반 방식과 달리, 함수형 스타일로 더욱 유연하게 코드를 작성할 수 있습니다.
라우터 구성 (RouteConfig.java
)
RouteConfig
클래스에서는 라우터 함수를 정의하여, /hello-function
경로로 들어오는 요청을 처리하도록 설정합니다. WebFlux의 Functional Endpoint 방식은 RouterFunctions
클래스를 사용하여 라우팅을 설정합니다.
package com.example.webflux1;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.RouterFunctions;
import org.springframework.web.reactive.function.server.ServerResponse;
@Configuration
@RequiredArgsConstructor
public class RouteConfig {
private final SimpleHandler simpleHandler;
@Bean
public RouterFunction<ServerResponse> route(){
return RouterFunctions.route()
.GET("/hello-function", simpleHandler::getString)
.build();
}
}
RouterFunction<ServerResponse>
: 특정 경로에 대한 HTTP 요청을 처리할 핸들러를 정의합니다.GET("/hello-function", simpleHandler::getString)
:/hello-function
경로에 대한GET
요청을 처리합니다.
요청 처리 핸들러 (SimpleHandler.java
)
요청을 처리하는 실제 로직은 SimpleHandler
클래스에서 정의됩니다. WebFlux는 비동기 처리를 위한 Mono 또는 Flux 타입을 반환하며, 이는 비동기 응답을 처리하는 기본 단위입니다.
package com.example.webflux1;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
@Component
public class SimpleHandler {
public Mono<ServerResponse> getString(ServerRequest request){
return ServerResponse.ok().bodyValue("hello functional endpoint");
}
}
Mono<ServerResponse>
: WebFlux의 비동기 처리 단위로서, 단일 값 또는 비동기 응답을 나타냅니다.ServerResponse.ok().bodyValue("hello functional endpoint")
: 요청에 대해200 OK
상태와 함께 응답 메시지를 반환합니다.
테스트 (curl
)
curl
명령어를 통해 해당 엔드포인트를 테스트할 수 있습니다. /hello-function
경로로 GET
요청을 보내면 서버는 "hello functional endpoint"라는 응답을 반환합니다.
❯ curl -v localhost:8080/hello-function
* Host localhost:8080 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
* Trying [::1]:8080...
* Connected to localhost (::1) port 8080
> GET /hello-function HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/8.7.1
> Accept: */*
>
* Request completely sent off
< HTTP/1.1 200 OK
< Content-Type: text/plain;charset=UTF-8
< Content-Length: 25
<
* Connection #0 to host localhost left intact
hello functional endpoint%
- 응답 코드
200 OK
: 서버가 요청을 성공적으로 처리했음을 나타냅니다. - 응답 메시지: 서버는 "hello functional endpoint"라는 응답을 반환합니다.
2. Annotation Endpoint
Annotation Endpoint 방식은 기존의 어노테이션을 이용하여 컨트롤러를 정의합니다. Spring WebFlux는 비동기 처리를 위해 WebFlux에서 비동기 Mono
또는 Flux
타입을 사용하지만, 어노테이션 기반으로 익숙한 @Controller
를 사용할 수 있습니다.
컨트롤러 구성 (SimpleController.java
)
package com.example.webflux1.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Mono;
@RestController
@RequestMapping("/simple")
public class SimpleController {
@GetMapping("/hello")
public Mono<String> getHello(){
return Mono.just("Hello World");
}
}
@RestController
: 해당 클래스가 REST API를 처리하는 컨트롤러임을 나타냅니다.@RequestMapping("/simple")
:/simple
경로에 대한 요청을 처리합니다.@GetMapping("/hello")
: HTTPGET
요청을 처리하고,Mono<String>
으로 비동기 응답을 반환합니다.
테스트 (curl
)
curl
명령어를 이용해 Annotation 기반 컨트롤러를 테스트할 수 있습니다. /simple/hello
경로로 GET
요청을 보내면 서버는 "Hello World"라는 응답을 반환합니다.
❯ curl -v localhost:8080/simple/hello
* Host localhost:8080 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
* Trying [::1]:8080...
* Connected to localhost (::1) port 8080
> GET /simple/hello HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/8.7.1
> Accept: */*
>
* Request completely sent off
< HTTP/1.1 200 OK
< Content-Type: text/plain;charset=UTF-8
< Content-Length: 11
<
* Connection #0 to host localhost left intact
Hello World%
요약
Spring WebFlux에서 컨트롤러는 두 가지 방식으로 구현할 수 있습니다:
- Functional Endpoint: 라우터와 핸들러를 사용하여 함수형 스타일로 요청을 처리합니다. 코드를 더 직관적이고 간결하게 작성할 수 있습니다.
- Annotation Endpoint: 기존의 어노테이션 기반 방식으로, 논블로킹 비동기 방식으로 동작하며, 전통적인 MVC 패턴과 유사한 구성을 가집니다.
두 방식 모두 WebFlux의 비동기 특성을 활용하여 고성능 서버를 구축할 수 있으며, 개발자의 선호에 따라 적절한 방식으로 선택할 수 있습니다.
유의사항: Spring WebFlux의 Subscription 메커니즘
Spring WebFlux를 사용하다 보면, Mono 또는 Flux를 반환하는 방식으로 비즈니스 로직을 작성하는데, 그 과정에서 리액티브 스트림의 중요한 요소인 구독(subscription) 메커니즘이 명시적으로 코드에 드러나지 않는다는 점을 발견할 수 있습니다.
리액티브 프로그래밍에서 기본적으로 Publisher(예: Mono, Flux)는 Subscriber가 데이터를 요청할 때까지 아무 작업도 수행하지 않습니다. 즉, Publisher는 구독자가 없으면 데이터 발행을 하지 않습니다. 하지만 WebFlux의 구조에서는 이러한 구독이 코드 상에서 명시적으로 보이지 않습니다. 그렇다면 이 구독은 어디서 어떻게 일어나는 걸까요?
Spring WebFlux의 자동 구독 처리
Spring WebFlux는 개발자가 Mono나 Flux와 같은 Publisher만 반환하면, DispatcherHandler와 같은 스프링 내부 컴포넌트가 자동으로 구독(subscribe)을 처리합니다. 이 과정은 서버의 요청이 들어오는 시점에서 자동으로 발생합니다.
다시 말해, 컨트롤러나 핸들러에서 비즈니스 로직을 처리한 결과로 Mono 또는 Flux를 반환하면, 실제 구독은 WebFlux의 내부에서 일어나며, 개발자가 별도로 subscribe() 메서드를 호출할 필요가 없습니다.
예시: SimpleHandler 코드에서의 구독
@Component
public class SimpleHandler {
public Mono<ServerResponse> getString(ServerRequest request){
return ServerResponse.ok().bodyValue("hello functional endpoint");
}
}
위 코드에서는 getString
메서드가 Mono를 반환하는데, 이 시점에서는 구독이 일어나
지 않았습니다. 구독은 RouterFunction에 의해 처리된 후, HTTP 요청이 들어올 때 WebFlux가 자동으로 subscribe를 호출하여 결과를 클라이언트로 반환합니다.
따라서, 개발자는 Mono나 Flux를 반환하는 방식으로 비즈니스 로직에만 집중할 수 있으며, 리액티브 스트림의 복잡한 구독 메커니즘을 일일이 신경 쓸 필요가 없습니다. 스프링이 이 부분을 자동으로 처리해주기 때문에 개발자는 보다 간결한 코드를 작성할 수 있습니다.
초심자에게 발생할 수 있는 의문점
WebFlux를 처음 접하는 개발자라면, 다음과 같은 의문이 들 수 있습니다:
- "왜 코드에서 직접 구독하는 부분이 없는데도 리액티브 스트림이 동작하죠?"
- "구독은 언제 이루어지는 걸까요?"
이러한 의문은 충분히 자연스럽습니다. 왜냐하면 리액티브 스트림의 본질은 Publisher와 Subscriber 간의 명시적인 구독 메커니즘에 있기 때문입니다. 그러나 Spring WebFlux는 이 복잡한 과정을 숨기고, 개발자가 직접적으로 구독을 처리하지 않도록 설계되어 있습니다. HTTP 요청이 들어오는 순간 WebFlux가 내부적으로 자동으로 구독을 수행하여 클라이언트에게 응답을 보내게 됩니다.
요약
결과적으로, Spring WebFlux에서는 개발자가 직접 구독(subscribe)를 처리할 필요가 없습니다. 이는 Spring이 비동기 HTTP 요청을 처리하는 단계에서 자동으로 구독을 관리하기 때문입니다. 따라서 개발자는 리액티브 프로그래밍의 핵심 개념을 유지하면서도, 구독 관리의 복잡성을 신경 쓰지 않고 간단하고 명료한 코드를 작성할 수 있습니다.
리액티브 스트림을 처음 사용하는 초심자라도 Spring WebFlux의 이러한 특성을 이해한다면, 비동기적이고 효율적인 서버 개발에 집중할 수 있게 될 것입니다.
CRUD API 실습
service, repository 로직 코드를 작성해봅시다.
위 코드 예시는 Spring WebFlux를 사용하여 비동기 방식으로 CRUD (Create, Read, Update, Delete) API를 구현한 예시입니다. 각 계층의 구성 요소는 다음과 같은 역할을 수행합니다.
엔티티(User)
User
엔티티는 데이터베이스의 사용자 정보를 표현하는 객체입니다.
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class User {
private Long id;
private String name;
private String email;
private LocalDateTime createTime;
private LocalDateTime updateTime;
}
- Lombok의
@Data
,@Builder
등을 사용해 코드를 간결하게 유지하고 있습니다. LocalDateTime
으로 생성 및 업데이트 시간을 관리합니다.
DTO
UserCreateRequest
, UserUpdateRequest
, UserResponse
는 클라이언트와 서버 간 데이터를 주고받기 위한 객체입니다. DTO를 통해 필요한 데이터만을 전송하고, 보안이나 성능 면에서 유리한 구조를 가집니다.
@Data
@AllArgsConstructor
@NoArgsConstructor
public class UserCreateRequest {
private String name;
private String email;
}
package com.example.webflux1.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class UserUpdateRequest {
private String name;
private String email;
}
package com.example.webflux1.dto;
import com.example.webflux1.entity.User;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class UserResponse {
private Long id;
private String name;
private String email;
private LocalDateTime createTime;
private LocalDateTime updateTime;
public static UserResponse of(User user){
return UserResponse.builder()
.id(user.getId())
.name(user.getName())
.email(user.getEmail())
.createTime(user.getCreateTime())
.updateTime(user.getUpdateTime())
.build();
}
}
UserCreateRequest
는 사용자 생성 시 필요한 데이터를 담고 있습니다.UserUpdateRequest
는 업데이트 시 사용되며,UserResponse
는 클라이언트에게 응답할 때 사용됩니다.
컨트롤러(UserController)
UserController
는 클라이언트의 요청을 받아 서비스 계층을 호출하고, 비동기 방식으로 처리 결과를 반환하는 역할을 합니다.
package com.example.webflux1.controller;
import com.example.webflux1.dto.UserCreateRequest;
import com.example.webflux1.dto.UserResponse;
import com.example.webflux1.dto.UserUpdateRequest;
import com.example.webflux1.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@RestController
@RequiredArgsConstructor
@RequestMapping("/users")
public class UserController {
private final UserService userSerivce;
@PostMapping
public Mono<UserResponse> create(@RequestBody UserCreateRequest request) {
return userSerivce.create(request.getName(), request.getEmail()).map(UserResponse::of);
}
@GetMapping
public Flux<UserResponse> findAllUsers() {
return userSerivce.findAll().map(UserResponse::of);
}
@GetMapping("/{id}")
public Mono<ResponseEntity<UserResponse>> findSingleUser(@PathVariable(name="id") Long id) {
return userSerivce.findById(id)
.map(user -> ResponseEntity.ok(UserResponse.of(user)))
.switchIfEmpty(Mono.just(ResponseEntity.notFound().build())); // 사용자 없을 경우 empty 반환;
}
@DeleteMapping("/{id}")
public Mono<ResponseEntity<Void>> deleteUser(@PathVariable(name = "id") Long id) {
return userSerivce.findById(id)
.flatMap(user -> userSerivce.deleteById(id)
.then(Mono.just(ResponseEntity.noContent().<Void>build()))) // 삭제 성공 시 204 No Content 반환
.switchIfEmpty(Mono.just(ResponseEntity.notFound().build())); // 사용자 없을 시 404 Not Found 반환
}
@PutMapping("/{id}")
public Mono<ResponseEntity<UserResponse>> updateUser(
@PathVariable(name="id") Long id,
@RequestBody UserUpdateRequest request
) {
return userSerivce.update(id, request.getName(), request.getEmail())
.map(user -> ResponseEntity.ok(UserResponse.of(user))) // 유저 업데이트 성공 시 200 OK 반환
.switchIfEmpty(Mono.just(ResponseEntity.notFound().build())); // 유저가 없을 경우 404 반환
}
}
Mono
,Flux
를 사용해 비동기 방식으로 응답을 처리합니다.@PostMapping
,@GetMapping
등을 사용해 각 요청 메서드를 처리합니다.switchIfEmpty
를 이용하여 데이터가 없을 경우 처리도 고려되었습니다.
.switchIfEmpty(Mono.just(ResponseEntity.notFound().build()))
이 부분의 코드는 Mono
타입의 빈 값을 처리하는 방식입니다. Spring WebFlux에서 Mono는 단일 값을 비동기적으로 반환하는 리액티브 타입입니다. 이 코드는 다음과 같은 시나리오에서 사용됩니다:
userSerivce.findById(id)
로 데이터를 조회했을 때, 해당id
의 데이터가 없을 경우 빈Mono
가 반환됩니다.- 이때,
.switchIfEmpty()
를 사용하여 빈 Mono일 경우 실행할 대체 동작을 정의할 수 있습니다.
.switchIfEmpty(Mono.just(ResponseEntity.notFound().build()));
Mono.just(ResponseEntity.notFound().build())
: 빈 값을 반환하는 대신404 Not Found
상태를 가진ResponseEntity
를 반환합니다.- 즉, 만약
findById()
에서 데이터가 없을 경우,404 Not Found
응답을 반환합니다.
동작 요약:
Mono<T>
가 비어 있으면switchIfEmpty()
가 실행되고, 대체 값을 반환합니다.switchIfEmpty()
는 일반적으로 데이터가 없을 때의 대체 로직을 처리하는 데 유용하게 사용됩니다.
map()
vs. flatMap()
map()
과 flatMap()
은 모두 리액티브 스트림에서 변환 작업을 수행하는 메서드이지만, 그 사용 목적과 동작 방식에 차이가 있습니다.
map()
:
- 목적: 입력된 데이터를 변환하여 다른 형태로 매핑합니다.
- 리턴 타입:
Mono<T>
또는Flux<T>
안의 데이터를 새로운 형태로 변환하여 다시Mono
나Flux
를 반환합니다. 그러나 반환되는 타입은 여전히 단일 값(Mono
) 또는 다중 값(Flux
)에 싸여 있습니다.
예시:
.map(user -> ResponseEntity.ok(UserResponse.of(user)))
- 여기서
user
를UserResponse
로 변환한 후, 그 값을ResponseEntity
로 래핑하여 반환합니다. map()
은 반환된 데이터를 다른 타입으로 변환할 때 사용됩니다.
flatMap()
:
- 목적: 데이터를 변환하되, 리액티브 타입(Mono/Flux) 자체를 반환하는 경우에 사용됩니다.
- 리턴 타입:
Mono<Mono<T>>
,Flux<Flux<T>>
와 같은 중첩 리액티브 타입을 단일 리액티브 타입으로 평탄화합니다.
예시:
.flatMap(user -> userSerivce.deleteById(id).then(Mono.just(ResponseEntity.noContent().<Void>build())))
flatMap()
은 내부에서 또 다른 비동기 작업을 처리하고 리액티브 타입을 반환할 때 사용됩니다.- 예를 들어,
userService.deleteById(id)
는Mono
를 반환하기 때문에, 이를 처리한 후 최종적으로Mono.just()
로 새로운 값을 반환하는 동작을flatMap()
으로 처리합니다. - 이 메서드는 비동기 연산을 연결할 때 매우 유용합니다.
map()
과 flatMap()
의 차이점
map() |
flatMap() |
|
---|---|---|
주요 역할 | 데이터 변환 | 비동기 작업과 데이터를 처리 |
리턴 타입 | 값을 매핑하여 Mono<T> 또는 Flux<T> 로 반환 |
Mono<Mono<T>> , Flux<Flux<T>> 같은 중첩 리액티브 타입을 평탄화 |
사용 시점 | 단순한 데이터 변환 | 비동기 작업(다른 Mono 나 Flux )을 연결할 때 |
deleteById(id)
에서 중첩 리액티브 타입이 반환되는 이유와 flatMap()
을 사용한 이유
deleteById(id)
의 반환 타입**
일반적으로, deleteById(id)
는 Mono<Void>
를 반환합니다. 이는 삭제 작업이 비동기적으로 처리되며, 성공적으로 완료되면 Void
(값 없음)를 의미하는 리액티브 타입입니다.
그러나 중요한 점은:
- 삭제 작업 자체가 비동기이기 때문에, 실제로 삭제가 완료될 때까지 기다려야 합니다.
- 따라서
Mono<Void>
를 사용하여 작업이 끝났음을 나타내고, 이Mono<Void>
는 리액티브 스트림에서 비동기 작업을 처리하는 데 사용됩니다.
중첩 리액티브 타입이 발생하는 이유
flatMap()
을 사용하는 이유는 deleteById가 리액티브 타입을 반환하기 때문입니다. 이 과정에서 Mono
가 중첩되는 것을 방지하기 위해 flatMap()
을 사용합니다.
예시
.flatMap(user -> userSerivce.deleteById(id)
.then(Mono.just(ResponseEntity.noContent().build())))
이 코드를 보면, userService.deleteById(id)
는 비동기 작업이고, 리턴 타입이 Mono<Void>
입니다. 그리고 나서 then()
을 사용해 삭제 작업이 끝난 후에 Mono.just(ResponseEntity.noContent().build())
를 반환하고 있습니다.
만약 여기에 map()
을 사용하면, 리턴 타입이 Mono<Mono<ResponseEntity>>
와 같이 중첩된 리액티브 타입이 됩니다. 중첩된 리액티브 타입은 원하지 않는 결과를 가져오고, 이는 리액티브 스트림에서 subscribe()를 통해 소비하기 까다롭습니다.
map()
을 사용했을 때의 중첩 리액티브 타입:
.map(user -> userSerivce.deleteById(id).then(Mono.just(ResponseEntity.noContent().build())))
이 코드는 Mono<Mono<ResponseEntity>>
를 반환하게 됩니다. 따라서 이를 단일 Mono<ResponseEntity>
로 평탄화해야 하는데, 이때 flatMap()
을 사용합니다.
flatMap()
을 사용하는 이유
flatMap()
은 리액티브 스트림 내부에서 또 다른 비동기 작업을 처리하고 그 결과를 연결하는 데 사용됩니다. deleteById(id)
는 비동기 작업으로, 삭제가 완료되었을 때 결과적으로 ResponseEntity
를 반환해야 합니다. 이 과정이 끝나기 전까지는 응답을 반환할 수 없기 때문에 비동기 처리를 연결하는 flatMap()
이 사용됩니다.
단일 리액티브로 하지 않은 이유
삭제 작업은 비동기 작업입니다. 만약 이를 동기적으로 처리하게 되면, 서버는 삭제가 완료되기 전에 응답을 보내거나, 블로킹 방식으로 동작해야 합니다. 그러나 WebFlux는 논블로킹 비동기 방식을 지원하므로, 이러한 작업을 단일 리액티브 스트림으로 평탄화하는 것이 필요합니다.
deleteById()
는 실제로Mono<Void>
를 반환하므로, 그 결과를 기다려야 하고, 이를 처리한 후에 응답(ResponseEntity
)을 보내기 위해flatMap()
을 사용한 것입니다.
요약
deleteById()
는 비동기 작업이기 때문에Mono
를 반환하고, 작업이 끝날 때까지 기다린 후 응답을 반환해야 합니다.- 이 과정에서 중첩된 리액티브 타입을 방지하고, 비동기 작업을 연결하기 위해
flatMap()
을 사용합니다. - 단일 리액티브 타입으로 변환하기 위해
flatMap()
을 사용하는 것은 비동기 작업의 결과를 처리하고 응답하는 자연스러운 흐름입니다.
코드에서 \<Void>
는 제네릭 타입 힌트로, ResponseEntity
객체를 만들 때 반환되는 타입을 명확하게 지정해주는 역할을 합니다.
1. ResponseEntity<Void>
의미
ResponseEntity<Void>
:ResponseEntity
는 HTTP 응답을 나타내는 객체입니다. 제네릭 타입인<Void>
는 이 응답이 본문(body)이 없는 상태임을 나타냅니다.- 즉,
ResponseEntity<Void>
는 HTTP 응답의 바디가 없는 상태로, 예를 들어204 No Content
응답과 같이 본문을 보내지 않는 경우 사용됩니다.
- 즉,
2. 왜 <Void>
를 명시하는가?
이 코드는 타입 안전성을 보장하기 위해 <Void>
를 명시한 것입니다. 여기서 ResponseEntity.noContent()
는 ResponseEntity<Void>
타입을 반환해야 하는데, 명시적으로 <Void>
를 지정하여 컴파일러에게 반환 타입이 정확히 Void
임을 알리고 있습니다.
3. 왜 <Void>
를 쓰는가?
204 No Content
는 응답 본문을 가지지 않는 HTTP 상태 코드입니다. 따라서 응답 본문이 필요 없고, 이 경우 Java의Void
타입을 사용하여 본문이 없음을 나타냅니다.<Void>
를 사용하지 않으면 Java 컴파일러는 반환되는ResponseEntity
의 타입을 추론할 수 없거나, 예상치 않은 타입으로 인식할 수 있습니다.
4. 코드 분석
.flatMap(user -> userSerivce.deleteById(id)
.then(Mono.just(ResponseEntity.noContent().<Void>build())));
이 코드는 다음과 같은 흐름입니다:
userService.deleteById(id)
는 삭제 작업을 수행하고, 비동기적으로 결과를 기다립니다.- 삭제 작업이 완료되면,
then()
을 통해 Mono.just(ResponseEntity.noContent().<Void>build())로 이어집니다. ResponseEntity.noContent()
는 204 No Content 응답을 나타내며, 응답 본문은 필요하지 않습니다.<Void>
는 본문이 없음을 명시적으로 표현하기 위해 사용된 제네릭 타입입니다.
따라서 <Void>
는 타입 안전성을 보장하고, 응답 본문이 없음을 나타내는 중요한 역할을 합니다.
서비스(UserService)
UserService
는 비즈니스 로직을 담당합니다. 저장소(UserRepository
)에 접근하여 데이터를 처리합니다.
package com.example.webflux1.service;
import com.example.webflux1.dto.UserUpdateRequest;
import com.example.webflux1.entity.User;
import com.example.webflux1.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
public Mono<User> create(String name, String email){
return userRepository.save(User.builder().name(name).email(email).build());
}
public Flux<User> findAll(){
return userRepository.findAll();
}
public Mono<User> findById(Long id){
return userRepository.findById(id);
}
public Mono<?> deleteById(Long id){
return userRepository.deleteById(id);
}
public Mono<User> update(Long id, String name, String email){
// 유저를 찾고 데이터를 변경하고 저장
return userRepository.findById(id)
.flatMap(user -> {
user.setName(name);
user.setEmail(email);
return userRepository.save(user); // 저장 후 업데이트된 유저 반환
})
.switchIfEmpty(Mono.empty()); // 유저를 찾지 못한 경우 Mono.empty() 반환
}
}
UserService
는 WebFlux 기반 애플리케이션에서 비즈니스 로직을 처리하는 역할을 합니다. 주로 저장소인 UserRepository
에 접근하여 사용자 데이터를 생성, 조회, 업데이트, 삭제하는 작업을 비동기적으로 처리합니다. WebFlux의 리액티브 타입인 Mono
와 Flux
를 사용하여 이러한 비동기 작업을 효율적으로 관리합니다.
코드 분석: UserService
UserService
는 UserRepository
와 상호작용하여 사용자 데이터를 처리합니다. 각 메서드는 비동기적으로 동작하며, WebFlux의 리액티브 타입을 반환합니다.
1. create()
- 사용자 생성
public Mono<User> create(String name, String email){
return userRepository.save(User.builder().name(name).email(email).build());
}
- 이 메서드는 사용자 데이터를 생성하고 저장하는 역할을 합니다.
- Mono를 반환하며, 저장이 완료되면 생성된 사용자 객체를 비동기적으로 반환합니다.
2. findAll()
- 모든 사용자 조회
public Flux<User> findAll(){
return userRepository.findAll();
}
- 이 메서드는 모든 사용자를 조회하는 작업을 비동기적으로 처리합니다.
- Flux를 반환하며, 여러 사용자 데이터를 비동기적으로 처리하여 반환합니다.
3. findById()
- 특정 사용자 조회
public Mono<User> findById(Long id){
return userRepository.findById(id);
}
findById()
는 특정 사용자의 정보를 조회하는 역할을 합니다.- Mono를 반환하며, 주어진
id
에 해당하는 사용자를 비동기적으로 찾고, 없을 경우 빈Mono
를 반환합니다.
4. deleteById()
- 사용자 삭제
public Mono<?> deleteById(Long id){
return userRepository.deleteById(id);
}
- 이 메서드는 사용자 삭제 작업을 비동기적으로 처리합니다.
- Mono<?>는 삭제 작업의 결과를 반환합니다. 작업이 완료되면 빈
Mono
가 반환됩니다.
5. update()
- 사용자 업데이트
public Mono<User> update(Long id, String name, String email){
return userRepository.findById(id)
.flatMap(user -> {
user.setName(name);
user.setEmail(email);
return userRepository.save(user); // 업데이트 후 저장
})
.switchIfEmpty(Mono.empty()); // 유저를 찾지 못하면 빈 Mono 반환
}
update()
는 주어진id
를 기준으로 사용자를 찾아 데이터를 업데이트합니다.- 먼저 사용자를 찾고,
flatMap()
을 통해 이름과 이메일을 수정한 후 저장합니다. Mono<User>
를 반환하며, 업데이트가 완료된 사용자를 비동기적으로 반환합니다.switchIfEmpty()
를 사용해 사용자가 존재하지 않을 경우 빈Mono
를 반환하도록 처리합니다.
WebFlux와 리액티브 타입을 사용한 비동기 처리
UserService
는 모든 메서드에서 Mono
나 Flux
를 반환하며, WebFlux의 논블로킹 및 비동기 특성을 활용합니다. 이러한 리액티브 타입을 사용하면, 서버는 작업이 완료될 때까지 블로킹되지 않고, 다른 요청들을 처리할 수 있습니다.
Mono
: 단일 값을 처리하는 비동기 작업에 적합하며,create()
,findById()
,deleteById()
등에서 사용됩니다.Flux
: 여러 값을 비동기적으로 처리할 때 사용되며,findAll()
에서 모든 사용자를 처리하는 데 사용됩니다.
Mono.empty()와 반환 타입의 관계
Mono.empty()
는 실제로 아무 값도 없는 Mono를 반환합니다. 이는 의미적으로 "값이 없는 상태"를 나타내지만, 타입적으로는 Mono<T>
로 간주됩니다.
즉, Mono.empty()
는 Mono<User>
, Mono<Void>
, Mono<?>
등 어떤 구체적인 제네릭 타입에서도 사용 가능합니다. 빈 Mono는 모든 타입의 Mono로 취급될 수 있기 때문입니다.
왜 컴파일 오류나 런타임 오류가 발생하지 않는가?
Mono.empty()
는 특별한 타입을 가지지 않기 때문에 제네릭 타입을 명확히 하지 않아도 컴파일러는 이를 유연하게 처리합니다.
.switchIfEmpty(Mono.empty());
위 코드에서는 값이 없을 경우 Mono.empty()
를 반환하게 되는데, 타입 시스템에서 Mono.empty()
는 Mono<User>
로 간주될 수 있습니다. 다시 말해, Mono.empty()
는 제네릭 타입 T에 관계없이 해당 타입을 가질 수 있는 빈 리액티브 타입을 반환하기 때문에 Mono<User>
로 처리하는 데 문제가 없습니다.
어떤 경우에 문제가 생길 수 있을까?
컴파일러가 허용하는 이유는, Mono.empty()
는 본질적으로 아무 값도 반환하지 않기 때문에 어떤 구체적인 타입이 필요하지 않습니다. 그러나 타입 불일치는 실제 값이 필요할 때 문제가 될 수 있습니다.
예를 들어, Mono<User>
에서 Mono<String>
을 반환하려고 하면, 실제 데이터 타입이 맞지 않기 때문에 문제가 발생하지만, Mono.empty()
는 값을 반환하지 않으므로 이러한 상황에서도 오류가 발생하지 않습니다.
// 예시
Mono<User> userMono = Mono.empty(); // 문제가 없음
Mono<String> stringMono = Mono.empty(); // 문제가 없음
위 코드에서는 타입에 관계없이 Mono.empty()
는 빈 값을 가진다는 의미에서 모든 제네릭 타입에 유효합니다.
정리
Mono.empty()
는 타입에 상관없이 빈 값을 나타내는 리액티브 타입입니다.switchIfEmpty(Mono.empty())
는 "해당 값이 없을 경우 빈 Mono를 반환한다"는 의미이며,Mono<User>
로 처리할 수 있습니다.- 컴파일러는 빈 값을 가지는
Mono.empty()
를 모든 타입에 유효하게 처리하므로, 컴파일 오류나 런타임 오류가 발생하지 않습니다.
따라서, update()
메서드에서 값이 없을 때 빈 Mono<User>
를 반환할 수 있는 이유는, Mono.empty()
가 그 자체로 모든 제네릭 타입의 Mono
로 처리될 수 있기 때문입니다.
저장소(UserRepository, UserRepositoryImpl)
UserRepository
는 사용자 데이터를 저장하거나 조회하는 저장소 인터페이스입니다.
package com.example.webflux1.repository;
import com.example.webflux1.entity.User;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
public interface UserRepository {
Mono<User> save(User user); // Create Update
Flux<User> findAll(); // ReadAll
Mono<Integer> deleteById(Long id); // ReadOne
Mono<User> findById(Long id); // DeleteOne
}
package com.example.webflux1.repository;
import com.example.webflux1.entity.User;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
@Repository
public class UserRepositoryImpl implements UserRepository{
private final ConcurrentHashMap<Long, User> userHashMap = new ConcurrentHashMap<>();
private AtomicLong sequence = new AtomicLong(1L);
@Override
public Mono<User> save(User user) {
var now = LocalDateTime.now();
if(user.getId() == null){
user.setId(sequence.getAndAdd(1));
user.setCreateTime(now);
}
user.setUpdateTime(now);
userHashMap.put(user.getId(), user);
return Mono.just(user);
}
@Override
public Flux<User> findAll() {
return Flux.fromIterable(userHashMap.values());
}
@Override
public Mono<User> findById(Long id) {
return Mono.justOrEmpty(userHashMap.getOrDefault(id, null));
}
@Override
public Mono<Integer> deleteById(Long id) {
User user = userHashMap.getOrDefault(id, null);
if(user == null) return Mono.just(0);
userHashMap.remove(id, user);
return Mono.just(1);
}
}
UserRepositoryImpl
클래스는 WebFlux와 Reactor를 활용하여 데이터를 저장하고 조회하는 기능을 제공합니다. 이 클래스는 데이터 저장소를 비동기적으로 처리하는 방식으로 구현되었으며, 주로 Mono
와 Flux
같은 리액티브 타입을 사용하여 비동기, 논블로킹 방식으로 동작합니다.
비동기 처리 핵심: Mono
와 Flux
의 사용
** save(User user)
**
- 설명: 새로운 유저를 저장하거나 기존 유저 정보를 업데이트하는 메서드입니다. 저장된
User
객체를 Mono로 감싸서 반환합니다. - Reactor 코드:
Mono.just(user)
: 저장된 유저 객체를 비동기적으로 반환하는 리액티브 타입입니다. 이 메서드는 단일 값(Mono)을 반환하므로 적합한 방식입니다.
return Mono.just(user);
- 비동기적인 이유: 저장 작업이 완료된 후 반환하는
User
객체가 비동기적으로 처리될 수 있도록Mono
를 사용합니다.
findAll()
- 설명: 저장소에 있는 모든 유저 데이터를 조회하는 메서드로, 여러 개의 데이터를 반환하므로 Flux를 사용합니다.
- Reactor 코드:
Flux.fromIterable(userHashMap.values())
:ConcurrentHashMap
에 저장된 모든 유저 객체들을Flux
로 변환하여 비동기적으로 스트림 처리합니다. Flux는 여러 값을 처리할 때 사용하는 리액티브 타입입니다.
return Flux.fromIterable(userHashMap.values());
- 비동기적인 이유: 다수의 데이터를 스트림처럼 순차적으로 제공할 때, 논블로킹 방식으로 처리할 수 있도록
Flux
를 사용합니다.
findById(Long id)
- 설명: 특정
id
를 가진 유저를 조회하는 메서드입니다. 존재하지 않는 경우 빈Mono
를 반환합니다. - Reactor 코드:
Mono.justOrEmpty(userHashMap.getOrDefault(id, null))
: 주어진id
로 유저를 조회하고, 만약 유저가 존재하지 않으면 빈Mono
를 반환합니다. justOrEmpty는 값이 없을 경우 자동으로 빈Mono
를 반환하는 역할을 합니다.
return Mono.justOrEmpty(userHashMap.getOrDefault(id, null));
- 비동기적인 이유: 단일 데이터를 비동기적으로 반환하기 위해
Mono
를 사용하며, 데이터가 없을 경우에도 빈Mono
를 반환하여 에러 없이 처리할 수 있습니다.
deleteById(Long id)
- 설명: 주어진
id
를 가진 유저를 삭제하는 메서드로, 삭제 결과로 1 또는 0을 반환하여 성공 여부를 알립니다. - Reactor 코드:
Mono.just(0)
또는Mono.just(1)
: 삭제 작업의 성공 여부를 단일 값으로 비동기적으로 반환합니다.
return Mono.just(1);
- 비동기적인 이유: 삭제 작업 역시 비동기적으로 처리되고, 그 결과가 리액티브 스트림에서 반환될 수 있도록
Mono
를 사용합니다.
비동기/논블로킹 작업의 이점
이 클래스에서 Mono
와 Flux
를 사용하여 데이터 저장, 조회, 삭제 작업을 비동기적으로 처리함으로써 논블로킹 방식으로 서버 리소스를 보다 효율적으로 사용할 수 있습니다. 즉, 특정 작업이 완료될 때까지 기다리지 않고, 즉시 다음 작업을 수행할 수 있습니다.
Mono
: 단일 값 또는 비어 있는 값을 비동기적으로 처리할 때 사용.Flux
: 다수의 값을 스트림처럼 순차적으로 처리할 때 사용.
Reactor의 논블로킹 처리의 이점
- 고성능 처리:
Mono
와Flux
를 사용함으로써 요청/응답을 처리할 때 블로킹 없이 빠르게 다른 작업을 처리할 수 있습니다. - 리액티브 프로그래밍: 이러한 방식은 리액티브 프로그래밍의 패러다임을 따르며, 서버의 확장성을 높이는 데 기여합니다.
요약
이 프로젝트는 WebFlux를 사용한 비동기적인 방식으로 사용자 CRUD API를 구현합니다. 각 계층을 명확히 구분하여 유지보수성과 확장성을 높였으며, 리액티브 스트림을 통해 서버의 성능을 최적화했습니다.
추가적으로 데이터베이스를 도입하면 UserRepository
를 R2DBC 등의 비동기적인 데이터베이스 솔루션과 연결하여 확장할 수 있습니다.
이 구조를 바탕으로 더 복잡한 비즈니스 로직을 추가하고 확장할 수 있습니다.
package com.example.webflux1.controller;
import com.example.webflux1.dto.UserCreateRequest;
import com.example.webflux1.dto.UserResponse;
import com.example.webflux1.dto.UserUpdateRequest;
import com.example.webflux1.entity.User;
import com.example.webflux1.service.UserService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.reactive.server.WebTestClient;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import static org.mockito.Mockito.when;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.*;
@WebFluxTest(UserController.class)
class UserControllerTest {
@Autowired
private WebTestClient webTestClient;
@MockBean
private UserService userService; // MockBean 오타 확인
@Test
void create() {
// Mocking the service method
when(userService.create("greg", "greg@gmail.com"))
.thenReturn(Mono.just(new User(1L, "greg", "greg@gmail.com", LocalDateTime.now(), LocalDateTime.now())));
// Testing the create user API
webTestClient.post().uri("/users")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(new UserCreateRequest("greg", "greg@gmail.com"))
.exchange()
.expectStatus().is2xxSuccessful()
.expectBody(UserResponse.class)
.value(res -> {
assertEquals("greg", res.getName());
assertEquals("greg@gmail.com", res.getEmail());
});
}
@Test
void findAllUsers() {
when(userService.findAll())
.thenReturn(Flux.just(
new User(1L, "greg", "greg@gmail.com", LocalDateTime.now(), LocalDateTime.now()),
new User(2L, "greg", "greg@gmail.com", LocalDateTime.now(), LocalDateTime.now()),
new User(3L, "greg", "greg@gmail.com", LocalDateTime.now(), LocalDateTime.now())
));
webTestClient.get().uri("/users")
.exchange()
.expectStatus().is2xxSuccessful()
.expectBodyList(UserResponse.class)
.hasSize(3);
}
@Test
void findSingleUser() {
when(userService.findById(1L))
.thenReturn(Mono.just(new User(1L, "greg", "greg@gmail.com", LocalDateTime.now(), LocalDateTime.now())
));
webTestClient.get().uri("/users/1")
.exchange()
.expectStatus().is2xxSuccessful()
.expectBody(UserResponse.class)
.value( res -> {
assertEquals("greg", res.getName());
assertEquals("greg@gmail.com", res.getEmail());
});
}
@Test
void findNotFoundUser() {
when(userService.findById(1L)).thenReturn(Mono.empty());
webTestClient.get().uri("/users/1")
.exchange()
.expectStatus().is4xxClientError();
}
@Test
void deleteUser() {
// Mocking the service to return a user when finding by ID, and simulate successful deletion.
when(userService.findById(1L))
.thenReturn(Mono.just(new User(1L, "greg", "greg@gmail.com", LocalDateTime.now(), LocalDateTime.now()))); // 유저가 존재
when(userService.deleteById(1L))
.thenReturn(Mono.empty()); // 성공적으로 삭제됨
// Performing the DELETE request and expecting a 204 No Content status.
webTestClient.delete().uri("/users/1")
.exchange()
.expectStatus().isNoContent(); // 204 No Content 반환 기대
}
@Test
void deleteUserNotFound() {
// Mocking the service to simulate that no user is found for the given ID.
when(userService.findById(1L))
.thenReturn(Mono.empty()); // 유저가 없음
// Performing the DELETE request and expecting a 404 Not Found status.
webTestClient.delete().uri("/users/1")
.exchange()
.expectStatus().isNotFound(); // 404 Not Found 반환 기대
}
@Test
void updateUser() {
// Mocking the service method
when(userService.update(1L, "greg1", "greg1@gmail.com"))
.thenReturn(Mono.just(new User(1L, "greg1", "greg1@gmail.com", LocalDateTime.now(), LocalDateTime.now())));
// Testing the update user API
webTestClient.put().uri("/users/1")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(new UserUpdateRequest("greg1", "greg1@gmail.com"))
.exchange()
.expectStatus().isOk()
.expectBody(UserResponse.class)
.value(res -> {
assertEquals("greg1", res.getName());
assertEquals("greg1@gmail.com", res.getEmail());
});
}
@Test
void updateUserNotFound() {
// Mocking the service method for user not found
when(userService.update(1L, "greg1", "greg1@gmail.com"))
.thenReturn(Mono.empty());
// Testing the update user API for not found case
webTestClient.put().uri("/users/1")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(new UserUpdateRequest("greg1", "greg1@gmail.com"))
.exchange()
.expectStatus().isNotFound();
}
}
package com.example.webflux1.repository;
import com.example.webflux1.entity.User;
import org.junit.jupiter.api.Test;
import reactor.test.StepVerifier;
import static org.junit.jupiter.api.Assertions.*;
class UserRepositoryTest {
private final UserRepository userRepository = new UserRepositoryImpl();
@Test
void save() {
var user = User.builder().name("greg").email("greg@gmail.com").build();
StepVerifier.create(userRepository.save(user))
.assertNext(u -> {
assertEquals(1L, u.getId());
assertEquals("greg", u.getName());
})
.verifyComplete();
}
@Test
void findAll() {
userRepository.save(User.builder().name("greg").email("greg@gmail.com").build());
userRepository.save(User.builder().name("greg2").email("greg2@gmail.com").build());
userRepository.save(User.builder().name("greg3").email("greg3@gmail.com").build());
StepVerifier.create(userRepository.findAll())
.expectNextCount(3)
.verifyComplete();
}
@Test
void findById() {
userRepository.save(User.builder().name("greg").email("greg@gmail.com").build());
userRepository.save(User.builder().name("greg2").email("greg2@gmail.com").build());
StepVerifier.create(userRepository.findById(2L))
.assertNext(u -> {
assertEquals(2L, u.getId());
assertEquals("greg2", u.getName());
})
.verifyComplete();
}
@Test
void deleteById() {
userRepository.save(User.builder().name("greg").email("greg@gmail.com").build());
userRepository.save(User.builder().name("greg2").email("greg2@gmail.com").build());
StepVerifier.create(userRepository.deleteById(2L))
.expectNext(1)
.verifyComplete();
}
}
두 테스트 클래스는 각각 Spring WebFlux 기반의 UserController
와 UserRepositoryImpl
의 동작을 검증하기 위해 작성되었습니다. WebFluxTest
와 StepVerifier
같은 도구를 활용해, 비동기 로직의 검증과 API 동작을 테스트합니다. WebFlux는 비동기 처리이므로, 리액티브 스트림에서 데이터 흐름을 검증하는 것이 핵심입니다.
1. UserControllerTest 클래스 설명
UserControllerTest
는 Spring WebFlux 애플리케이션에서 컨트롤러 레이어를 테스트합니다. WebTestClient
를 사용하여 HTTP 요청을 모의(mock)하고, 비동기적으로 API의 응답 상태 및 결과를 검증합니다.
주요 내용
@WebFluxTest(UserController.class)
:UserController
만 테스트하기 위해 나머지 빈들을 로드하지 않고, 컨트롤러 레이어만을 테스트하는 데 사용됩니다.@MockBean UserService userService
: 컨트롤러와 서비스 사이의 의존성을 주입하면서,UserService
의 실제 구현이 아닌 모의 객체(mock)를 주입하여 동작을 테스트합니다. 즉,UserService
메서드의 동작을when()
을 사용하여 모의한 후, 이를 바탕으로 컨트롤러의 동작을 검증합니다.
각 테스트 메서드 설명
create()
userService.create()
메서드를 모의하여 유저 생성 API의 동작을 테스트합니다.webTestClient.post()
를 사용해/users
로 유저 생성 요청을 보내고, 2xx 성공 상태와 응답 본문의 값을 검증합니다.
findAllUsers()
userService.findAll()
메서드를 모의하여 여러 유저를 조회하는 API를 테스트합니다.- Flux로 다수의 유저를 반환하도록 설정하고, 응답 리스트의 크기가 예상된 크기(3)와 일치하는지 검증합니다.
findSingleUser()
- 특정 유저를 조회하는 API를 테스트하며,
userService.findById()
로 특정 ID의 유저를 모의합니다. - 응답 상태가 2xx 성공이고, 반환된 유저의 이름과 이메일을 검증합니다.
- 특정 유저를 조회하는 API를 테스트하며,
findNotFoundUser()
- 존재하지 않는 유저를 조회할 경우,
Mono.empty()
를 반환하도록 모의합니다. - 이 경우 응답 상태가 4xx 클라이언트 오류(404 Not Found)인지 검증합니다.
- 존재하지 않는 유저를 조회할 경우,
deleteUser()
- 유저 삭제 API를 테스트하며, 먼저 유저를 찾고 삭제하는 과정을 모의합니다.
- 삭제가 성공하면 204 No Content 상태를 기대합니다.
deleteUserNotFound()
- 유저가 존재하지 않을 경우 삭제가 실패하고 404 Not Found를 반환하는지 검증합니다.
updateUser()
- 유저 정보를 업데이트하는 API를 테스트합니다.
userService.update()
메서드로 모의한 데이터를 반환하며, 응답 상태가 200 OK이고, 반환된 유저의 이름과 이메일이 올바른지 확인합니다.
- 유저 정보를 업데이트하는 API를 테스트합니다.
updateUserNotFound()
- 업데이트하려는 유저가 존재하지 않을 경우, 404 Not Found를 반환하는지 검증합니다.
2. UserRepositoryTest 클래스 설명
UserRepositoryTest
는 저장소 계층을 테스트합니다. WebFlux에서 비동기 처리를 검증하기 위해 StepVerifier
를 사용하여 Mono와 Flux의 동작을 확인합니다. UserRepositoryImpl
클래스에서의 CRUD 동작을 검증합니다.
주요 내용
StepVerifier
: WebFlux에서 비동기 동작을 테스트할 때 사용되는 유틸리티입니다. Mono나 Flux의 흐름을 검증하고, 비동기 스트림이 예상대로 동작하는지 확인할 수 있습니다.verifyComplete()
: 스트림이 성공적으로 완료되었는지 확인합니다.assertNext()
: 각 단계의 값을 검증합니다.expectNextCount()
: Flux에서 예상되는 항목 수를 검증합니다.
각 테스트 메서드 설명
save()
- 새로운 유저를 저장하는 테스트로, 저장된 유저의
id
와name
필드를 검증합니다. - StepVerifier로 저장된 유저가 제대로 생성되는지 확인하고,
verifyComplete()
를 통해 비동기 작업이 성공적으로 완료되었는지 확인합니다.
- 새로운 유저를 저장하는 테스트로, 저장된 유저의
findAll()
save()
메서드를 사용하여 세 명의 유저를 저장한 후, Flux로 모든 유저를 조회하는 테스트입니다.StepVerifier
로 3명의 유저가 반환되는지 확인하고, 데이터 흐름이 예상대로 진행되는지 검증합니다.
findById()
- ID로 특정 유저를 조회하는 테스트입니다.
StepVerifier
로id
가 2인 유저를 조회한 후, 유저 정보가 예상대로 반환되는지 확인합니다.
- ID로 특정 유저를 조회하는 테스트입니다.
deleteById()
- 특정 ID를 가진 유저를 삭제하는 테스트입니다. 삭제 후 성공적으로 1을 반환하는지 확인하고, verifyComplete()로 작업이 성공적으로 완료되었는지 검증합니다.
테스트 클래스의 공통점
- 비동기 검증:
WebTestClient
는 컨트롤러 API를 호출하고, 응답을 비동기적으로 검증합니다.StepVerifier
는 리액티브 스트림을 검증하는 도구로, 비동기 처리의 흐름을 하나하나 검증할 수 있게 도와줍니다.
- 모의 객체 사용 (
@MockBean
):UserControllerTest
는 서비스 계층을 모의(mock)하여 테스트합니다. 즉, 실제 데이터베이스나 서비스 계층에 의존하지 않고, 컨트롤러 레벨의 동작만 검증합니다.- 반면,
UserRepositoryTest
는 실제로UserRepositoryImpl
클래스를 직접 테스트하여 저장소 레이어의 CRUD 동작을 검증합니다.
요약
UserControllerTest
는 WebFlux의 컨트롤러 계층을 테스트하며, HTTP 요청과 응답 흐름을 검증합니다.WebTestClient
를 통해 컨트롤러가 올바르게 동작하는지 확인하고,Mono
,Flux
를 사용하는 WebFlux API의 비동기 응답을 테스트합니다.
UserRepositoryTest
는 저장소 계층을 테스트하며,StepVerifier
를 통해Mono
와Flux
의 비동기 흐름을 검증합니다.- CRUD 작업이 제대로 이루어지는지, 비동기적으로 데이터가 올바르게 반환되는지 검증합니다.
두 테스트 클래스 모두 Spring WebFlux의 리액티브 프로그래밍을 고려하여, 비동기적으로 동작하는 시스템을 정확히 테스트하기 위한 다양한 도구와 기법들을 사용하고 있습니다.
'프레임워크 > 자바 스프링' 카테고리의 다른 글
Spring Webflux 실습 - 2 (0) | 2024.09.26 |
---|---|
Spring Webflux 실습 - 2 (0) | 2024.09.26 |
Webflux - reactor (0) | 2024.09.18 |
Webflux - spring mvc vs webflux (1) | 2024.09.18 |
Webflux 소개 (0) | 2024.09.17 |