Spring으로 마이크로서비스 구축하기 (8) - Flux를 이용해 반응형 웹 서비스 만들기
태그: Cloud, MicroService, Reactive, Spring, Webflux
카테고리: SpringCloud
업데이트:
Webflux를 알아보기
Webflux란?
Webflux는 Spring에서 Reactive 웹 애플리케이션을 개발할 수 있는 모듈입니다.
따라서 Non-blocking과 Reactive Stream을 지원합니다.
Webflux를 사용하는 이유
Non-blocking 방식으로 동작하기 때문에 효율적으로 동작해 CPU, Thread, Memory 자원을 낭비하지 않습니다.
따라서, 서비스 간 호출이 많은 마이크로 서비스 환경에서는 대기하는 시간이 많기 때문에 Webflux를 이용한 Non-blocking 방식이 적합합니다.
Webflux로 수정하기
이제 프로젝트를 WebFlux로 수정하기 위해서 모든 Spring 프로젝트에 다음과 같이 spring-boot-starter-web의존성 대신 spring-boot-starter-webflux의존성을 추가해 줍니다.
// implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-webflux'
Post Service 수정하기
먼저 Post 서비스의 요청을 수정해 주겠습니다. Post 정보를 요청하는 경우 단일 정보를 요청하기 때문에 Mono를 반환하도록 수정하겠습니다.
api 프로젝트에서 PostController Interface에서 다음과 같이 반환형을 수정합니다.
public interface PostController {
@GetMapping(value="/post/{postId}", produces="application/json")
Mono<Post> getPost(@PathVariable int postId) throws Exception;
}
이제 Mono로 반환하기 위해 Mono.just()를 이용해 Post 정보를 Mono로 만들어줍니다.
@Override
public Mono<Post> getPost(int postId) throws Exception {
if (postId >= 100) {
throw new IllegalArgumentException("Post ID는 100이하여야 합니다.");
}
LOG.debug("Post Service 요청 수신 후 응답 - post id : {}", postId);
return Mono.just(new Post(postId, "post title " + postId, "post author " + postId, "contents " + postId));
}
Comment Service 수정하기
api 프로젝트의 PostCompositeController Interface도 Comment를 Flux로 수정해 줍니다.
public interface PostCompositeController {
@GetMapping(value = "/comment/{postId}", produces = MediaType.APPLICATION_NDJSON_VALUE)
Flux<Comment> getComments(@PathVariable int postId);
}
이제 Comment List를 Flux.fromIterable()을 이용해 Flux로 만들어줍니다.
@Override
public Flux<Comment> getComments(int postId) {
LOG.debug("Comment Service 요청 수신 후 응답 - post id : {}", postId);
List<Comment> list = new ArrayList<>();
list.add(new Comment(postId, 1, "작성자1", "내용1"));
list.add(new Comment(postId, 2, "작성자2", "내용2"));
return Flux.fromIterable(list);
}
Composite Service 수정하기
이제 Composite 서비스에서 다른 서비스에 데이터를 요청해 데이터를 가공하도록 해야 합니다.
이때, Non-blocking 방식의 요청을 보내기 위해서는 RestTemplate 대신 WebClient를 사용해야 합니다.
따라서 Bean 객체를 WebClient로 수정해 주겠습니다.
@Configuration
public class Config {
@Bean
@LoadBalanced
public WebClient.Builder loadBalancedWebClientBuilder() {
return WebClient.builder();
}
}
그리고 CompositeController 또한 Mono를 반환하도록 수정해 줍니다.
public interface CompositeController {
@GetMapping(value = "/composite/{postId}", produces = "application/json")
Mono<PostComposite> getPost(@PathVariable int postId) throws Exception;
}
이제 Post 서비스와 Comment 서비스 요청을 해 각 정보를 요청하도록 수정하겠습니다.
Post 서비스의 경우 다음과 같이 bodyToMono를 이용해 Mono
private Mono<Post> getPostFromPostService(int postId) {
String url = String.format("%s/%d", POST_URL, postId);
LOG.debug("POST URL : {}", url);
return webclient
.get()
.uri(url).retrieve()
.bodyToMono(Post.class).log()
.onErrorMap(WebClientResponseException.class, this::handleException);
}
Comment 서비스의 경우 다음과 같이 bodyToFlux를 이용해 Flux
public Flux<Comment> getCommentFromCommentService(int postId) {
String url = String.format("%s/%d", COMMENT_URL, postId);
LOG.debug("COMMENT URL : {}", url);
return webclient
.get()
.uri(url).retrieve()
.bodyToFlux(Comment.class);
.onErrorMap(WebClientResponseException.class, this::handleException);
}
이때 Exception을 핸들링 하기 위해 다음과 같이 수정하겠습니다.
private Throwable handleException(Throwable ex) {
if (!(ex instanceof WebClientResponseException resEx)) {
LOG.warn("Unexpected Exception: {}", ex.toString());
return ex;
}
HttpStatusCode statusCode = resEx.getStatusCode();
if (HttpStatus.BAD_REQUEST.equals(statusCode)) {
return new IllegalArgumentException(getErrorMessage(resEx));
} else if (HttpStatus.NOT_FOUND.equals(statusCode)) {
return new IllegalAccessException(getErrorMessage(resEx));
}
LOG.warn("Unexpected Exception: {}", ex.toString());
return ex;
}
이제 수신 받은 Post와 Comment 정보를 하나의 Mono 객체로 합쳐주겠습니다.
이때, Mon.zip()을 이용해 다음과 같이 하나의 Mono 객체로 합쳐줍니다.
@Override
public Mono<PostComposite> getPost(int postId) {
Mono<Post> post = getPostFromPostService(postId);
Flux<Comment> comments = getCommentFromCommentService(postId);
return Mono.zip(post, comments.collectList())
.map(tuple -> new PostComposite(
postId,
tuple.getT1().getTitle(),
tuple.getT1().getAuthor(),
tuple.getT1().getContents() + " " + myProperty,
tuple.getT2()
));
}
테스트코드 수정하기
Webflux의 경우에는 WebMvcTest 대신 WebFluxTest를 이용해야 합니다.
그리고, Rest Docs를 이용할 때 mockmvc 대신 webtestclient 의존성을 추가하도록 해야 합니다.
따라서 rest docs의 의존성을 다음과 같이 수정해 줍니다.
testImplementation 'org.springframework.restdocs:spring-restdocs-webtestclient'
그리고 테스트 코드를 다음과 같이 수정합니다.
- @WebMvcTest를 @WebFluxTest로 수정하기
- WebClient.Builder를 MockBean으로 수정하기
- @BeforeEach를 이용해 WebTestClient를 빌드하기
- WebClient의 리턴값 정하기
- webTestClient를 이용해 요청 테스트 및 document 정의 작성하기
@WebFluxTest
@ExtendWith(RestDocumentationExtension.class)
@ActiveProfiles("test")
public class CompositeControllerImplTest {
@MockBean
WebClient.Builder webClientBuilder;
@Autowired
private WebTestClient webTestClient;
@BeforeEach
void setUp(RestDocumentationContextProvider restDocumentation) {
webTestClient = WebTestClient.bindToController(new CompositeControllerImpl(webClientBuilder))
.configureClient()
.filter(WebTestClientRestDocumentation.documentationConfiguration(restDocumentation))
.build();
}
@Test
public void getPostTest() {
int postId = 12;
Post post = new Post(postId, "post title " + postId, "post author " + postId, "contents " + postId);
List<Comment> comments = List.of(
new Comment(postId, 1, "작성자1", "내용1"),
new Comment(postId, 2, "작성자2", "내용2")
);
WebClient webClient = Mockito.mock(WebClient.class, Answers.RETURNS_DEEP_STUBS);
when(webClientBuilder.build()).thenReturn(webClient);
when(
webClient.get()
.uri(ArgumentMatchers.anyString())
.retrieve()
.bodyToMono(Post.class)
.log()
).thenReturn(Mono.just(post));
when(
webClient.get()
.uri(ArgumentMatchers.anyString())
.retrieve()
.bodyToFlux(Comment.class)
.log()
).thenReturn(Flux.fromIterable(comments));
webTestClient.get()
.uri("/composite/{postId}", postId)
.attribute("org.springframework.restdocs.urlTemplate", "/composite/{postId}")
.exchange()
.expectStatus().isOk()
.expectBody()
.consumeWith(document("composite",
pathParameters(
RequestDocumentation.parameterWithName("postId").description("post id")
),
responseFields(
fieldWithPath("postId").type(JsonFieldType.NUMBER).description("post id"),
fieldWithPath("title").type(JsonFieldType.STRING).description("post title"),
fieldWithPath("author").type(JsonFieldType.STRING).description("post author"),
fieldWithPath("contents").type(JsonFieldType.STRING).description("post contents"),
fieldWithPath("comments").type(JsonFieldType.ARRAY).description("post comments"),
fieldWithPath("comments[].postId").type(JsonFieldType.NUMBER).description("comment's post id"),
fieldWithPath("comments[].commentId").type(JsonFieldType.NUMBER).description("comment id"),
fieldWithPath("comments[].author").type(JsonFieldType.STRING).description("comment author"),
fieldWithPath("comments[].comment").type(JsonFieldType.STRING).description("comment")
)
));
}
}
댓글남기기