Spring Webflux (7) Sequence 변환 Operator - map, flatMap, zip
API를 개발할 때 클라이언트에게 적절한 응답을 내려주기 위해서 여러 엔티티를 적절하게 조합하여 변환하는 과정을 거치곤합니다. 변환 Operator를 사용하면 이를 쉽게 구현할 수 있다. 사실 stream API를 사용해봤다면 매우 친근한 오퍼레이터도 존재할 것입니다.
목차
1. map
2. flatMap
3. zip
map
마블 다이어그램와 표현되어 있듯이 map() 오퍼레이터는 업스트림에서 emit된 데이터를 Function 인터페이스를 통해 다른 객체로 변환합니다. stream API의 map() 과 기능이 동일합니다.
아래 예제는 String 타입을 Store 타입으로 변경하고 로그를 찍는 예제입니다.
public static void main(String[] args) {
Flux.just("스타벅스 목성점", "스타벅스 지구점", "스타벅스 화성점")
.map(storeName -> new Store(storeName))
.subscribe(data -> log.info("# onNext: {}", data));
}
@Getter
@ToString
static class Store {
private String id;
private String name;
public Store(String name) {
this.id = UUID.randomUUID().toString().substring(0, 8);
this.name = name;
}
}
실행 결과
01:57:31.811 [main] INFO com.operators.transform.Map -- # onNext: Map.Store(id=148fc099, name=스타벅스 목성점)
01:57:31.826 [main] INFO com.operators.transform.Map -- # onNext: Map.Store(id=5617b91d, name=스타벅스 지구점)
01:57:31.826 [main] INFO com.operators.transform.Map -- # onNext: Map.Store(id=49c20188, name=스타벅스 화성점)
flatMap
stream API를 공부할 당시에는 flatMap의 유용성에 대해 회의적이었는데요. (우물 안 개구리기 때문에 그렇습니다.) webflux를 통해 API를 개발하다보니 flatMap을 종종 사용했어야 됐습니다.
flatMap은 쉽게 말해 차원을 -1 하는 용도입니다. 저는 여기서 말하는 차원을 Collections Interface, Optional과 같은 데이터를 감싸는 컨테이너 하나를 차원이라고 이해하였습니다. 감싼다는 표현이 조금 어색할 순 있는데요. 특정 컨테이너에 데이터를 여러개 넣어야 한다는 오해를 하지 않기 위해 감싼다고 하였습니다. 다르게 말하면 데이터를 0개 혹은 1개만 emit할 수 있는 Mono 또한 컨테이너이자 차원입니다. Flux는 당연하구요.
이해를 위해 두 개의 예제를 다루겠습니다.
public static void main(String[] args) {
Flux.just("Good", "Bad")
// map을 썻다면 emit 되는 데이터 타입은 String이 아닌 Flux<String>임
.flatMap(feeling -> Flux
.just("Morning", "Afternoon", "Evening")
.map(time -> feeling + " " + time))
.subscribe(log::info);
}
만약 flatMap 대신 map을 사용한다면 최종적으로 어떠한 형태의 데이터가 만들어지게 될까요?
Flux<Flux<String>> 타입이 만들어지게 됩니다. 하지만 실제 코드에서는 flatMap을 통해 (1) 과정에서 Flux<String> 이 아닌 String 타입의 객체들로 변환되게 됩니다.
실제 API를 개발할 때도 body를 만드는 과정에 Publisher를 인자로 넣어야해서 flatMap을 통해 차원을 감소시켜야 했던 적이 있습니다.
public Mono<ServerResponse> updatePlace(ServerRequest request) {
return request.bodyToMono(PlaceUpdateForm.class)
.map(form -> placeService.updatePlace(form))
.flatMap(body -> ServerResponse.ok() // (2) Mono 로 래핑되는걸 벗기기 위해 flatMap 사용
// (1) 인자로 Mono, Flux 와 같은 Publisher 를 넣아야 함
.body(Mono.just(new PlaceResponseBody(200, "장소 수정 완료", body)),
PlaceResponseBody.class));
}
zip
마블 다이어그램을 보면 알 수 있듯이 zip 오퍼레이터는 emit 된 여러 데이터를 조합해서 튜플에 각각 데이터를 담아 변환하여 다운스트림으로 emit 합니다. 예제 코드를 살펴봅시다.
public static void main(String[] args) throws InterruptedException {
Mono.zip(
// 외부 API를 통해 가져와야 한다고 상상해보자
Mono.just(new Member("jxx", 30)).delayElement(Duration.ofMillis(2500L)),
// 애플리케이션 서버와 연동된 DB에서 조회한 결과로 가져와야 한다고 상상해보자
Mono.just(new Order()).delayElement(Duration.ofMillis(1000L))
)
.map(tuple -> new OrderResponse(
tuple.getT1().getName(),
tuple.getT2().getId(),
tuple.getT2().getOrderTime())
)
.subscribe(res -> log.info("# onNext: {}", res));
Thread.sleep(3000L);
}
사용자에게 주문 데이터를 내려줘야 하는데요. 해당 애플리케이션은 주문서버기 때문에 사용자가 데이터를 외부 API를 통해 가져와야 한다고 해봅시다. 그리고 주문 정보는 DB를 통해 조회해야 합니다. 각각의 I/O를 처리함에 있어 걸리는 시간이 다르기 때문에 다른 딜레이타임을 주었습니다.
zip 오퍼레이터는 downstream으로 튜플을 emit 한다고 했습니다. 이 튜플에는 Member 객체와 Order 객체가 담겨져있습니다. 그래서 map 오퍼레이터에서 람다 함수에 인자로 tuple이라는 네이밍을 한 것입니다.
참고로 Tuple 타입은 Reactor에서 제공하는 util 패키지 안에 존재합니다. 해당 클래스를 살펴본다면 getT1, getT2와 같은 메서드가 무엇을 의미하는지 쉽게 이해할 수 있을 것입니다.